using System.Net; using System.Net.Http; using System.Text; using Microsoft.Extensions.Caching.Memory; using Moq; using Xunit; using JobTrackerApi.Services; namespace JobTrackerApi.Tests; public sealed class SummarizerServiceTests { [Fact] public async Task Summarize_section_uses_cv_rewrite_endpoint() { var handler = new CapturingHandler(); var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost:8001") }; var httpFactory = new Mock(); httpFactory.Setup(x => x.CreateClient("ai-service")).Returns(httpClient); using var memoryCache = new MemoryCache(new MemoryCacheOptions()); var service = new SummarizerService(httpFactory.Object, memoryCache); var result = await service.SummarizeSectionAsync("Rewrite this CV", "Professional Summary\nBuilt backend systems.", 1800, 400); Assert.Equal("rewritten cv", result); Assert.Equal("/cv/rewrite", handler.LastPath); Assert.NotNull(handler.LastBody); Assert.Contains("\"instruction\":\"Rewrite this CV\"", handler.LastBody); Assert.Contains("\"max_length\":256", handler.LastBody); Assert.Contains("\"min_length\":180", handler.LastBody); } [Fact] public async Task Summarize_section_clamps_lengths_to_ai_service_limits() { var handler = new CapturingHandler(); var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost:8001") }; var httpFactory = new Mock(); httpFactory.Setup(x => x.CreateClient("ai-service")).Returns(httpClient); using var memoryCache = new MemoryCache(new MemoryCacheOptions()); var service = new SummarizerService(httpFactory.Object, memoryCache); await service.SummarizeSectionAsync("Rewrite this CV", "Professional Summary\nBuilt backend systems.", 1800, 400); Assert.NotNull(handler.LastBody); Assert.Contains("\"max_length\":256", handler.LastBody); Assert.Contains("\"min_length\":180", handler.LastBody); } [Fact] public async Task Generate_section_returns_actual_provider_model_and_fallback_metadata() { var handler = new CapturingHandler(); var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost:8001") }; var httpFactory = new Mock(); httpFactory.Setup(x => x.CreateClient("ai-service")).Returns(httpClient); using var memoryCache = new MemoryCache(new MemoryCacheOptions()); var service = new SummarizerService(httpFactory.Object, memoryCache); var result = await service.GenerateSectionWithMetadataAsync("Rewrite", "Synthetic CV", cancellationToken: default); Assert.NotNull(result); Assert.Equal("rewritten cv", result!.Text); Assert.Equal("gemini", result.Provider); Assert.Equal("gemini-test", result.Model); Assert.Equal("local_provider_unavailable", result.FallbackReason); Assert.Equal("external_fallback", result.RouteReason); } [Fact] public async Task Metadata_path_returns_a_typed_sanitized_provider_failure_while_legacy_path_returns_null() { var httpClient = new HttpClient(new FailureHandler()) { BaseAddress = new Uri("http://localhost:8001") }; var httpFactory = new Mock(); httpFactory.Setup(x => x.CreateClient("ai-service")).Returns(httpClient); using var memoryCache = new MemoryCache(new MemoryCacheOptions()); var service = new SummarizerService(httpFactory.Object, memoryCache); var failure = await Assert.ThrowsAsync(() => service.GenerateSectionWithMetadataAsync("Rewrite", "Synthetic CV", cancellationToken: default)); Assert.Equal("provider_unavailable", failure.Category); Assert.True(failure.Retryable); Assert.Equal("gemini", failure.Provider); Assert.Equal("external_fallback", failure.RouteReason); Assert.DoesNotContain("synthetic upstream detail", failure.Message, StringComparison.OrdinalIgnoreCase); Assert.Null(await service.SummarizeSectionAsync("Rewrite again", "Synthetic CV")); } private sealed class CapturingHandler : HttpMessageHandler { public string? LastBody { get; private set; } public string? LastPath { get; private set; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { LastPath = request.RequestUri?.AbsolutePath; LastBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); var responseBody = LastPath == "/cv/rewrite" ? "{\"rewritten_text\":\"rewritten cv\"}" : "{\"summary\":\"ok\"}"; var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(responseBody, Encoding.UTF8, "application/json") }; if (LastPath == "/cv/rewrite") { response.Headers.Add("X-Ai-Provider", "gemini"); response.Headers.Add("X-Ai-Model", "gemini-test"); response.Headers.Add("X-Ai-Fallback-Reason", "local_provider_unavailable"); response.Headers.Add("X-Ai-Route-Reason", "external_fallback"); } return response; } } private sealed class FailureHandler : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var response = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) { Content = new StringContent("{\"detail\":\"synthetic upstream detail\"}", Encoding.UTF8, "application/json"), }; response.Headers.Add("X-Ai-Provider", "gemini"); response.Headers.Add("X-Ai-Model", "gemini-test"); response.Headers.Add("X-Ai-Route-Reason", "external_fallback"); return Task.FromResult(response); } } }