test: add xUnit test coverage for API controllers and client services
Create ChatAgent.Api.Tests and ChatAgent.Client.Tests projects with xUnit and Moq. Test HealthController (200 + valid response), ChatController (SSE streaming with mocked upstream, error handling), and ChatApiClient (delta parsing, error events, health endpoint). 6 tests, all passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatAgent.Api", "src\ChatAg
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatAgent.Shared", "src\ChatAgent.Shared\ChatAgent.Shared.csproj", "{06182E3F-BC78-449B-ADF6-D9EE49E48945}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatAgent.Shared", "src\ChatAgent.Shared\ChatAgent.Shared.csproj", "{06182E3F-BC78-449B-ADF6-D9EE49E48945}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatAgent.Api.Tests", "tests\ChatAgent.Api.Tests\ChatAgent.Api.Tests.csproj", "{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatAgent.Client.Tests", "tests\ChatAgent.Client.Tests\ChatAgent.Client.Tests.csproj", "{DBB73B66-042A-4858-B813-23AEA84FC4C6}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -57,6 +63,30 @@ Global
|
|||||||
{06182E3F-BC78-449B-ADF6-D9EE49E48945}.Release|x64.Build.0 = Release|Any CPU
|
{06182E3F-BC78-449B-ADF6-D9EE49E48945}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{06182E3F-BC78-449B-ADF6-D9EE49E48945}.Release|x86.ActiveCfg = Release|Any CPU
|
{06182E3F-BC78-449B-ADF6-D9EE49E48945}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{06182E3F-BC78-449B-ADF6-D9EE49E48945}.Release|x86.Build.0 = Release|Any CPU
|
{06182E3F-BC78-449B-ADF6-D9EE49E48945}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{DBB73B66-042A-4858-B813-23AEA84FC4C6}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -65,5 +95,7 @@ Global
|
|||||||
{600EA0C4-7CDE-4807-BE3C-30A6D2242392} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{600EA0C4-7CDE-4807-BE3C-30A6D2242392} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{467D4550-6F9A-456E-B99C-0ABE94070ECF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{467D4550-6F9A-456E-B99C-0ABE94070ECF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{06182E3F-BC78-449B-ADF6-D9EE49E48945} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{06182E3F-BC78-449B-ADF6-D9EE49E48945} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
|
{DBB73B66-042A-4858-B813-23AEA84FC4C6} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-04-04
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
No test projects exist. The solution has 3 projects (Client, Api, Shared) under `src/`. Tests need to cover the API controllers and the client's ChatApiClient service, both of which involve HTTP and SSE streaming.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Establish test infrastructure (xUnit + Moq)
|
||||||
|
- Test API controllers using WebApplicationFactory (integration-style)
|
||||||
|
- Test ChatApiClient using a mock HttpMessageHandler (unit-style)
|
||||||
|
- All tests runnable via `dotnet test` from solution root
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Blazor component tests (bUnit) — Chat.razor is UI-heavy, defer to a future phase
|
||||||
|
- End-to-end browser tests (Playwright/Selenium)
|
||||||
|
- Testing the upstream Responses API itself
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Decision 1: Test framework — xUnit + Moq
|
||||||
|
|
||||||
|
xUnit is the .NET standard. Moq for mocking HttpMessageHandler so we can control HTTP responses in tests without hitting real servers.
|
||||||
|
|
||||||
|
### Decision 2: Test project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── ChatAgent.Api.Tests/ → references Api project
|
||||||
|
└── ChatAgent.Client.Tests/ → references Client project + Shared
|
||||||
|
```
|
||||||
|
|
||||||
|
Both added to the `ChatAgent.sln` solution file under a `tests` solution folder.
|
||||||
|
|
||||||
|
### Decision 3: API tests use WebApplicationFactory
|
||||||
|
|
||||||
|
`Microsoft.AspNetCore.Mvc.Testing` provides `WebApplicationFactory<Program>` for integration-style tests. For ChatController, we inject a mock `IHttpClientFactory` that returns a handler with canned SSE responses — no real Responses API needed.
|
||||||
|
|
||||||
|
### Decision 4: Client tests mock HttpMessageHandler
|
||||||
|
|
||||||
|
ChatApiClient takes an HttpClient. Tests create an HttpClient with a custom `DelegatingHandler` that returns canned SSE response streams. This tests the SSE parsing logic in isolation.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [WebApplicationFactory requires Api's Program to be accessible] → Add `InternalsVisibleTo` or use the `public partial class Program {}` trick in Api's Program.cs
|
||||||
|
- [SSE stream mocking is verbose] → Create a small helper method that builds SSE response content from a list of events
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The project has zero test coverage. There are two controllers, a typed HttpClient service, and shared models — all untested. Adding tests now establishes the pattern before the codebase grows, and catches regressions as new phases are added.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Create an xUnit test project for the API (`ChatAgent.Api.Tests`)
|
||||||
|
- Create an xUnit test project for the Client services (`ChatAgent.Client.Tests`)
|
||||||
|
- Add tests for HealthController (GET /api/health)
|
||||||
|
- Add tests for ChatController (POST /api/chat SSE streaming proxy)
|
||||||
|
- Add tests for ChatApiClient (GetHealthAsync, SendChatStreamingAsync)
|
||||||
|
- Add both test projects to the solution
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `test-infrastructure`: Test project setup, test framework choices, and shared test utilities
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
<!-- None — adding tests doesn't change existing specs -->
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `tests/ChatAgent.Api.Tests/`: New xUnit test project
|
||||||
|
- `tests/ChatAgent.Client.Tests/`: New xUnit test project
|
||||||
|
- `ChatAgent.sln`: Add test projects
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: API test project exists
|
||||||
|
|
||||||
|
An xUnit test project SHALL exist at `tests/ChatAgent.Api.Tests/` targeting the API project, with xUnit, Moq, and Microsoft.AspNetCore.Mvc.Testing as dependencies.
|
||||||
|
|
||||||
|
#### Scenario: API tests run
|
||||||
|
|
||||||
|
- **WHEN** `dotnet test` is run from the solution root
|
||||||
|
- **THEN** API tests are discovered and executed
|
||||||
|
|
||||||
|
### Requirement: Client test project exists
|
||||||
|
|
||||||
|
An xUnit test project SHALL exist at `tests/ChatAgent.Client.Tests/` targeting the Client services, with xUnit and Moq as dependencies.
|
||||||
|
|
||||||
|
#### Scenario: Client tests run
|
||||||
|
|
||||||
|
- **WHEN** `dotnet test` is run from the solution root
|
||||||
|
- **THEN** Client service tests are discovered and executed
|
||||||
|
|
||||||
|
### Requirement: HealthController test coverage
|
||||||
|
|
||||||
|
Tests SHALL verify that GET /api/health returns HTTP 200 with a valid HealthResponse containing a non-empty Status and a recent Timestamp.
|
||||||
|
|
||||||
|
#### Scenario: Health endpoint returns 200
|
||||||
|
|
||||||
|
- **WHEN** a GET request is sent to /api/health
|
||||||
|
- **THEN** the response status is 200 and the body contains `status: "healthy"` and a UTC timestamp
|
||||||
|
|
||||||
|
### Requirement: ChatController test coverage
|
||||||
|
|
||||||
|
Tests SHALL verify that POST /api/chat returns a streaming SSE response containing text deltas and a [DONE] terminator. Tests SHALL mock the upstream Responses API.
|
||||||
|
|
||||||
|
#### Scenario: Chat streams text deltas
|
||||||
|
|
||||||
|
- **WHEN** a POST is sent to /api/chat with a valid ChatRequest
|
||||||
|
- **THEN** the response is `text/event-stream` containing `data: {"text":"..."}` events followed by `data: [DONE]`
|
||||||
|
|
||||||
|
#### Scenario: Chat handles upstream error
|
||||||
|
|
||||||
|
- **WHEN** the Responses API is unreachable or returns an error
|
||||||
|
- **THEN** the response contains a `data: {"error":"..."}` event followed by `data: [DONE]`
|
||||||
|
|
||||||
|
### Requirement: ChatApiClient test coverage
|
||||||
|
|
||||||
|
Tests SHALL verify that SendChatStreamingAsync correctly parses SSE events from the backend into text deltas, handles [DONE], and throws on error events.
|
||||||
|
|
||||||
|
#### Scenario: Client parses text deltas
|
||||||
|
|
||||||
|
- **WHEN** the backend returns SSE events with text deltas
|
||||||
|
- **THEN** SendChatStreamingAsync yields each text fragment in order
|
||||||
|
|
||||||
|
#### Scenario: Client handles error event
|
||||||
|
|
||||||
|
- **WHEN** the backend returns an error SSE event
|
||||||
|
- **THEN** SendChatStreamingAsync throws HttpRequestException with the error message
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
## 1. Test Project Setup
|
||||||
|
|
||||||
|
- [x] 1.1 Create xUnit test project at tests/ChatAgent.Api.Tests with xUnit, Moq, Microsoft.AspNetCore.Mvc.Testing
|
||||||
|
- [x] 1.2 Create xUnit test project at tests/ChatAgent.Client.Tests with xUnit, Moq
|
||||||
|
- [x] 1.3 Add both test projects to ChatAgent.sln under a tests solution folder
|
||||||
|
- [x] 1.4 Make Api's Program class accessible to tests (public partial class Program)
|
||||||
|
|
||||||
|
## 2. API Tests
|
||||||
|
|
||||||
|
- [x] 2.1 Test HealthController: GET /api/health returns 200 with valid HealthResponse
|
||||||
|
- [x] 2.2 Test ChatController: POST /api/chat streams SSE text deltas from mocked upstream
|
||||||
|
- [x] 2.3 Test ChatController: POST /api/chat handles upstream error gracefully
|
||||||
|
|
||||||
|
## 3. Client Service Tests
|
||||||
|
|
||||||
|
- [x] 3.1 Create SSE response helper for building mock SSE streams
|
||||||
|
- [x] 3.2 Test ChatApiClient.SendChatStreamingAsync: parses text deltas in order
|
||||||
|
- [x] 3.3 Test ChatApiClient.SendChatStreamingAsync: throws on error event
|
||||||
|
- [x] 3.4 Test ChatApiClient.GetHealthAsync: returns HealthResponse on success
|
||||||
|
|
||||||
|
## 4. Verify
|
||||||
|
|
||||||
|
- [x] 4.1 Run dotnet test from solution root — all tests pass
|
||||||
60
openspec/specs/test-infrastructure/spec.md
Normal file
60
openspec/specs/test-infrastructure/spec.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
Define test project setup, framework choices, and required test coverage for the ChatAgent solution.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: API test project exists
|
||||||
|
|
||||||
|
An xUnit test project SHALL exist at `tests/ChatAgent.Api.Tests/` targeting the API project, with xUnit, Moq, and Microsoft.AspNetCore.Mvc.Testing as dependencies.
|
||||||
|
|
||||||
|
#### Scenario: API tests run
|
||||||
|
|
||||||
|
- **WHEN** `dotnet test` is run from the solution root
|
||||||
|
- **THEN** API tests are discovered and executed
|
||||||
|
|
||||||
|
### Requirement: Client test project exists
|
||||||
|
|
||||||
|
An xUnit test project SHALL exist at `tests/ChatAgent.Client.Tests/` targeting the Client services, with xUnit and Moq as dependencies.
|
||||||
|
|
||||||
|
#### Scenario: Client tests run
|
||||||
|
|
||||||
|
- **WHEN** `dotnet test` is run from the solution root
|
||||||
|
- **THEN** Client service tests are discovered and executed
|
||||||
|
|
||||||
|
### Requirement: HealthController test coverage
|
||||||
|
|
||||||
|
Tests SHALL verify that GET /api/health returns HTTP 200 with a valid HealthResponse containing a non-empty Status and a recent Timestamp.
|
||||||
|
|
||||||
|
#### Scenario: Health endpoint returns 200
|
||||||
|
|
||||||
|
- **WHEN** a GET request is sent to /api/health
|
||||||
|
- **THEN** the response status is 200 and the body contains `status: "healthy"` and a UTC timestamp
|
||||||
|
|
||||||
|
### Requirement: ChatController test coverage
|
||||||
|
|
||||||
|
Tests SHALL verify that POST /api/chat returns a streaming SSE response containing text deltas and a [DONE] terminator. Tests SHALL mock the upstream Responses API.
|
||||||
|
|
||||||
|
#### Scenario: Chat streams text deltas
|
||||||
|
|
||||||
|
- **WHEN** a POST is sent to /api/chat with a valid ChatRequest
|
||||||
|
- **THEN** the response is `text/event-stream` containing `data: {"text":"..."}` events followed by `data: [DONE]`
|
||||||
|
|
||||||
|
#### Scenario: Chat handles upstream error
|
||||||
|
|
||||||
|
- **WHEN** the Responses API is unreachable or returns an error
|
||||||
|
- **THEN** the response contains a `data: {"error":"..."}` event followed by `data: [DONE]`
|
||||||
|
|
||||||
|
### Requirement: ChatApiClient test coverage
|
||||||
|
|
||||||
|
Tests SHALL verify that SendChatStreamingAsync correctly parses SSE events from the backend into text deltas, handles [DONE], and throws on error events.
|
||||||
|
|
||||||
|
#### Scenario: Client parses text deltas
|
||||||
|
|
||||||
|
- **WHEN** the backend returns SSE events with text deltas
|
||||||
|
- **THEN** SendChatStreamingAsync yields each text fragment in order
|
||||||
|
|
||||||
|
#### Scenario: Client handles error event
|
||||||
|
|
||||||
|
- **WHEN** the backend returns an error SSE event
|
||||||
|
- **THEN** SendChatStreamingAsync throws HttpRequestException with the error message
|
||||||
@@ -66,3 +66,8 @@ app.UseAuthorization();
|
|||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
// This partial class declaration makes the auto-generated Program class public,
|
||||||
|
// which is required by WebApplicationFactory<Program> in integration tests.
|
||||||
|
// Without this, the test project cannot reference the entry point type.
|
||||||
|
public partial class Program { }
|
||||||
|
|||||||
28
tests/ChatAgent.Api.Tests/ChatAgent.Api.Tests.csproj
Normal file
28
tests/ChatAgent.Api.Tests/ChatAgent.Api.Tests.csproj
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.14" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ChatAgent.Api\ChatAgent.Api.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\ChatAgent.Shared\ChatAgent.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
158
tests/ChatAgent.Api.Tests/ChatControllerTests.cs
Normal file
158
tests/ChatAgent.Api.Tests/ChatControllerTests.cs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ChatAgent.Shared.Models;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace ChatAgent.Api.Tests;
|
||||||
|
|
||||||
|
public class ChatControllerTests : IClassFixture<WebApplicationFactory<Program>>
|
||||||
|
{
|
||||||
|
private readonly WebApplicationFactory<Program> _factory;
|
||||||
|
|
||||||
|
public ChatControllerTests(WebApplicationFactory<Program> factory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostChat_StreamsTextDeltas_AndDone()
|
||||||
|
{
|
||||||
|
// Arrange: mock the upstream Responses API with canned SSE events
|
||||||
|
var sseContent = BuildSSE(
|
||||||
|
("response.created", null),
|
||||||
|
("response.output_text.delta", "Hello"),
|
||||||
|
("response.output_text.delta", " world"),
|
||||||
|
("response.completed", null)
|
||||||
|
);
|
||||||
|
|
||||||
|
var client = CreateClientWithMockedUpstream(sseContent);
|
||||||
|
|
||||||
|
var request = new ChatRequest
|
||||||
|
{
|
||||||
|
Messages = new List<ChatMessage>
|
||||||
|
{
|
||||||
|
new() { Role = "user", Content = "Hi" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsJsonAsync("/api/chat", request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal("text/event-stream", response.Content.Headers.ContentType?.MediaType);
|
||||||
|
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
var events = ParseSSEData(body);
|
||||||
|
|
||||||
|
// Assert: should contain text deltas and [DONE]
|
||||||
|
Assert.Contains(events, e => e.Contains("\"text\":\"Hello\""));
|
||||||
|
Assert.Contains(events, e => e.Contains("\"text\":\" world\""));
|
||||||
|
Assert.Contains(events, e => e == "[DONE]");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostChat_HandlesUpstreamError_ReturnsErrorEvent()
|
||||||
|
{
|
||||||
|
// Arrange: upstream returns 500
|
||||||
|
var mockHandler = new MockHttpMessageHandler(
|
||||||
|
new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||||
|
{
|
||||||
|
Content = new StringContent("Internal Server Error")
|
||||||
|
});
|
||||||
|
|
||||||
|
var client = CreateClientWithHandler(mockHandler);
|
||||||
|
|
||||||
|
var request = new ChatRequest
|
||||||
|
{
|
||||||
|
Messages = new List<ChatMessage>
|
||||||
|
{
|
||||||
|
new() { Role = "user", Content = "Hi" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsJsonAsync("/api/chat", request);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
var events = ParseSSEData(body);
|
||||||
|
|
||||||
|
// Assert: should contain error and [DONE]
|
||||||
|
Assert.Contains(events, e => e.Contains("\"error\""));
|
||||||
|
Assert.Contains(events, e => e == "[DONE]");
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient CreateClientWithMockedUpstream(string sseContent)
|
||||||
|
{
|
||||||
|
var mockHandler = new MockHttpMessageHandler(
|
||||||
|
new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(sseContent, Encoding.UTF8, "text/event-stream")
|
||||||
|
});
|
||||||
|
|
||||||
|
return CreateClientWithHandler(mockHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient CreateClientWithHandler(MockHttpMessageHandler handler)
|
||||||
|
{
|
||||||
|
return _factory.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
// Remove existing IHttpClientFactory registrations for "ResponsesApi"
|
||||||
|
// and replace with our mock
|
||||||
|
services.AddHttpClient("ResponsesApi")
|
||||||
|
.ConfigurePrimaryHttpMessageHandler(() => handler);
|
||||||
|
});
|
||||||
|
}).CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a fake SSE stream mimicking the Responses API format.
|
||||||
|
/// </summary>
|
||||||
|
private static string BuildSSE(params (string type, string? delta)[] events)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var (type, delta) in events)
|
||||||
|
{
|
||||||
|
var data = delta != null
|
||||||
|
? $"{{\"type\":\"{type}\",\"delta\":\"{delta}\"}}"
|
||||||
|
: $"{{\"type\":\"{type}\"}}";
|
||||||
|
sb.AppendLine($"event: {type}");
|
||||||
|
sb.AppendLine($"data: {data}");
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the data payloads from SSE-formatted text.
|
||||||
|
/// </summary>
|
||||||
|
private static List<string> ParseSSEData(string sseText)
|
||||||
|
{
|
||||||
|
return sseText.Split('\n')
|
||||||
|
.Where(line => line.StartsWith("data: "))
|
||||||
|
.Select(line => line.Substring(6).Trim())
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simple mock HttpMessageHandler that returns a canned response.
|
||||||
|
/// </summary>
|
||||||
|
public class MockHttpMessageHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly HttpResponseMessage _response;
|
||||||
|
|
||||||
|
public MockHttpMessageHandler(HttpResponseMessage response)
|
||||||
|
{
|
||||||
|
_response = response;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(_response);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
tests/ChatAgent.Api.Tests/HealthControllerTests.cs
Normal file
29
tests/ChatAgent.Api.Tests/HealthControllerTests.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using ChatAgent.Shared.Models;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
|
||||||
|
namespace ChatAgent.Api.Tests;
|
||||||
|
|
||||||
|
public class HealthControllerTests : IClassFixture<WebApplicationFactory<Program>>
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
public HealthControllerTests(WebApplicationFactory<Program> factory)
|
||||||
|
{
|
||||||
|
_client = factory.CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetHealth_Returns200_WithValidHealthResponse()
|
||||||
|
{
|
||||||
|
var response = await _client.GetAsync("/api/health");
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
|
||||||
|
var health = await response.Content.ReadFromJsonAsync<HealthResponse>();
|
||||||
|
Assert.NotNull(health);
|
||||||
|
Assert.Equal("healthy", health.Status);
|
||||||
|
Assert.True(health.Timestamp > DateTime.MinValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
tests/ChatAgent.Client.Tests/ChatAgent.Client.Tests.csproj
Normal file
27
tests/ChatAgent.Client.Tests/ChatAgent.Client.Tests.csproj
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ChatAgent.Client\ChatAgent.Client.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\ChatAgent.Shared\ChatAgent.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
141
tests/ChatAgent.Client.Tests/ChatApiClientTests.cs
Normal file
141
tests/ChatAgent.Client.Tests/ChatApiClientTests.cs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text;
|
||||||
|
using ChatAgent.Client.Services;
|
||||||
|
using ChatAgent.Shared.Models;
|
||||||
|
|
||||||
|
namespace ChatAgent.Client.Tests;
|
||||||
|
|
||||||
|
public class ChatApiClientTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task SendChatStreamingAsync_ParsesTextDeltas_InOrder()
|
||||||
|
{
|
||||||
|
// Arrange: build a canned SSE response with two text deltas
|
||||||
|
var sse = BuildSSE(
|
||||||
|
("text", "Hello"),
|
||||||
|
("text", " world")
|
||||||
|
);
|
||||||
|
|
||||||
|
var handler = new FakeHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(sse, Encoding.UTF8, "text/event-stream")
|
||||||
|
});
|
||||||
|
|
||||||
|
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
|
||||||
|
var client = new ChatApiClient(httpClient);
|
||||||
|
|
||||||
|
var request = new ChatRequest
|
||||||
|
{
|
||||||
|
Messages = new List<ChatMessage>
|
||||||
|
{
|
||||||
|
new() { Role = "user", Content = "Hi" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var deltas = new List<string>();
|
||||||
|
await foreach (var delta in client.SendChatStreamingAsync(request))
|
||||||
|
{
|
||||||
|
deltas.Add(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(2, deltas.Count);
|
||||||
|
Assert.Equal("Hello", deltas[0]);
|
||||||
|
Assert.Equal(" world", deltas[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendChatStreamingAsync_ThrowsOnErrorEvent()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sse = "data: {\"error\":\"Something went wrong\"}\n\n" +
|
||||||
|
"data: [DONE]\n\n";
|
||||||
|
|
||||||
|
var handler = new FakeHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(sse, Encoding.UTF8, "text/event-stream")
|
||||||
|
});
|
||||||
|
|
||||||
|
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
|
||||||
|
var client = new ChatApiClient(httpClient);
|
||||||
|
|
||||||
|
var request = new ChatRequest
|
||||||
|
{
|
||||||
|
Messages = new List<ChatMessage>
|
||||||
|
{
|
||||||
|
new() { Role = "user", Content = "Hi" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var ex = await Assert.ThrowsAsync<HttpRequestException>(async () =>
|
||||||
|
{
|
||||||
|
await foreach (var _ in client.SendChatStreamingAsync(request))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Contains("Something went wrong", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetHealthAsync_ReturnsHealthResponse()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var healthJson = "{\"status\":\"healthy\",\"timestamp\":\"2026-04-04T12:00:00Z\"}";
|
||||||
|
var handler = new FakeHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(healthJson, Encoding.UTF8, "application/json")
|
||||||
|
});
|
||||||
|
|
||||||
|
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
|
||||||
|
var client = new ChatApiClient(httpClient);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await client.GetHealthAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("healthy", result.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds simplified SSE content from (type, value) pairs.
|
||||||
|
/// Each pair becomes: data: {"text":"value"}\n\n
|
||||||
|
/// Ends with data: [DONE]\n\n
|
||||||
|
/// </summary>
|
||||||
|
private static string BuildSSE(params (string key, string value)[] events)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var (key, value) in events)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"data: {{\"{key}\":\"{value}\"}}");
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
sb.AppendLine("data: [DONE]");
|
||||||
|
sb.AppendLine();
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simple fake HttpMessageHandler for unit testing HttpClient-based services.
|
||||||
|
/// Returns a canned response for any request.
|
||||||
|
/// </summary>
|
||||||
|
public class FakeHttpMessageHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly HttpResponseMessage _response;
|
||||||
|
|
||||||
|
public FakeHttpMessageHandler(HttpResponseMessage response)
|
||||||
|
{
|
||||||
|
_response = response;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(_response);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user