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:
local
2026-04-04 02:21:10 +01:00
parent 00e7df2802
commit 17a5a58e73
13 changed files with 632 additions and 0 deletions

View File

@@ -11,6 +11,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatAgent.Api", "src\ChatAg
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatAgent.Shared", "src\ChatAgent.Shared\ChatAgent.Shared.csproj", "{06182E3F-BC78-449B-ADF6-D9EE49E48945}"
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
GlobalSection(SolutionConfigurationPlatforms) = preSolution
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|x86.ActiveCfg = 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
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -65,5 +95,7 @@ Global
{600EA0C4-7CDE-4807-BE3C-30A6D2242392} = {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}
{C70B5EEA-073A-4ED0-BA02-C4A92583ECE5} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{DBB73B66-042A-4858-B813-23AEA84FC4C6} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-04

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -66,3 +66,8 @@ app.UseAuthorization();
app.MapControllers();
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 { }

View 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>

View 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);
}
}

View 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);
}
}

View 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>

View 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);
}
}