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