diff --git a/ChatAgent.sln b/ChatAgent.sln index 356303c..9d364d8 100644 --- a/ChatAgent.sln +++ b/ChatAgent.sln @@ -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 diff --git a/openspec/changes/archive/2026-04-04-add-test-coverage/.openspec.yaml b/openspec/changes/archive/2026-04-04-add-test-coverage/.openspec.yaml new file mode 100644 index 0000000..c54c137 --- /dev/null +++ b/openspec/changes/archive/2026-04-04-add-test-coverage/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-04 diff --git a/openspec/changes/archive/2026-04-04-add-test-coverage/design.md b/openspec/changes/archive/2026-04-04-add-test-coverage/design.md new file mode 100644 index 0000000..9d6f6b9 --- /dev/null +++ b/openspec/changes/archive/2026-04-04-add-test-coverage/design.md @@ -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` 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 diff --git a/openspec/changes/archive/2026-04-04-add-test-coverage/proposal.md b/openspec/changes/archive/2026-04-04-add-test-coverage/proposal.md new file mode 100644 index 0000000..b44b665 --- /dev/null +++ b/openspec/changes/archive/2026-04-04-add-test-coverage/proposal.md @@ -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 + + +## Impact + +- `tests/ChatAgent.Api.Tests/`: New xUnit test project +- `tests/ChatAgent.Client.Tests/`: New xUnit test project +- `ChatAgent.sln`: Add test projects diff --git a/openspec/changes/archive/2026-04-04-add-test-coverage/specs/test-infrastructure/spec.md b/openspec/changes/archive/2026-04-04-add-test-coverage/specs/test-infrastructure/spec.md new file mode 100644 index 0000000..df68162 --- /dev/null +++ b/openspec/changes/archive/2026-04-04-add-test-coverage/specs/test-infrastructure/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-04-04-add-test-coverage/tasks.md b/openspec/changes/archive/2026-04-04-add-test-coverage/tasks.md new file mode 100644 index 0000000..6cb7054 --- /dev/null +++ b/openspec/changes/archive/2026-04-04-add-test-coverage/tasks.md @@ -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 diff --git a/openspec/specs/test-infrastructure/spec.md b/openspec/specs/test-infrastructure/spec.md new file mode 100644 index 0000000..37efe03 --- /dev/null +++ b/openspec/specs/test-infrastructure/spec.md @@ -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 diff --git a/src/ChatAgent.Api/Program.cs b/src/ChatAgent.Api/Program.cs index 074ef8b..a04fb1e 100644 --- a/src/ChatAgent.Api/Program.cs +++ b/src/ChatAgent.Api/Program.cs @@ -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 in integration tests. +// Without this, the test project cannot reference the entry point type. +public partial class Program { } diff --git a/tests/ChatAgent.Api.Tests/ChatAgent.Api.Tests.csproj b/tests/ChatAgent.Api.Tests/ChatAgent.Api.Tests.csproj new file mode 100644 index 0000000..c9179fd --- /dev/null +++ b/tests/ChatAgent.Api.Tests/ChatAgent.Api.Tests.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/ChatAgent.Api.Tests/ChatControllerTests.cs b/tests/ChatAgent.Api.Tests/ChatControllerTests.cs new file mode 100644 index 0000000..2c1172c --- /dev/null +++ b/tests/ChatAgent.Api.Tests/ChatControllerTests.cs @@ -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> +{ + private readonly WebApplicationFactory _factory; + + public ChatControllerTests(WebApplicationFactory 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 + { + 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 + { + 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(); + } + + /// + /// Builds a fake SSE stream mimicking the Responses API format. + /// + 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(); + } + + /// + /// Extracts the data payloads from SSE-formatted text. + /// + private static List ParseSSEData(string sseText) + { + return sseText.Split('\n') + .Where(line => line.StartsWith("data: ")) + .Select(line => line.Substring(6).Trim()) + .ToList(); + } +} + +/// +/// Simple mock HttpMessageHandler that returns a canned response. +/// +public class MockHttpMessageHandler : HttpMessageHandler +{ + private readonly HttpResponseMessage _response; + + public MockHttpMessageHandler(HttpResponseMessage response) + { + _response = response; + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(_response); + } +} diff --git a/tests/ChatAgent.Api.Tests/HealthControllerTests.cs b/tests/ChatAgent.Api.Tests/HealthControllerTests.cs new file mode 100644 index 0000000..735efcc --- /dev/null +++ b/tests/ChatAgent.Api.Tests/HealthControllerTests.cs @@ -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> +{ + private readonly HttpClient _client; + + public HealthControllerTests(WebApplicationFactory 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(); + Assert.NotNull(health); + Assert.Equal("healthy", health.Status); + Assert.True(health.Timestamp > DateTime.MinValue); + } +} diff --git a/tests/ChatAgent.Client.Tests/ChatAgent.Client.Tests.csproj b/tests/ChatAgent.Client.Tests/ChatAgent.Client.Tests.csproj new file mode 100644 index 0000000..2ad8f05 --- /dev/null +++ b/tests/ChatAgent.Client.Tests/ChatAgent.Client.Tests.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/tests/ChatAgent.Client.Tests/ChatApiClientTests.cs b/tests/ChatAgent.Client.Tests/ChatApiClientTests.cs new file mode 100644 index 0000000..e753942 --- /dev/null +++ b/tests/ChatAgent.Client.Tests/ChatApiClientTests.cs @@ -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 + { + new() { Role = "user", Content = "Hi" } + } + }; + + // Act + var deltas = new List(); + 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 + { + new() { Role = "user", Content = "Hi" } + } + }; + + // Act & Assert + var ex = await Assert.ThrowsAsync(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); + } + + /// + /// Builds simplified SSE content from (type, value) pairs. + /// Each pair becomes: data: {"text":"value"}\n\n + /// Ends with data: [DONE]\n\n + /// + 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(); + } +} + +/// +/// Simple fake HttpMessageHandler for unit testing HttpClient-based services. +/// Returns a canned response for any request. +/// +public class FakeHttpMessageHandler : HttpMessageHandler +{ + private readonly HttpResponseMessage _response; + + public FakeHttpMessageHandler(HttpResponseMessage response) + { + _response = response; + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(_response); + } +}