using System.Net; using System.Text; using System.Text.Json; using JobTrackerApi.Services; using Microsoft.Extensions.Caching.Memory; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class CvAiEvaluationTests { [Fact] public async Task Evaluation_uses_the_private_typed_endpoint_without_adding_profile_context() { var handler = new CaptureHandler(); var factory = new Mock(); factory.Setup(value => value.CreateClient("ai-service")).Returns(new HttpClient(handler) { BaseAddress = new Uri("http://localhost") }); using var cache = new MemoryCache(new MemoryCacheOptions()); var service = new SummarizerService(factory.Object, cache); var result = await service.EvaluateCvRewriteAsync("Built APIs.", "Developed APIs.", "Employer requires Kubernetes."); Assert.Equal("/cv/evaluate-rewrite", handler.Path); using var body = JsonDocument.Parse(handler.Body!); Assert.Equal("Built APIs.", body.RootElement.GetProperty("candidate_source").GetString()); Assert.Equal("Developed APIs.", body.RootElement.GetProperty("proposal").GetString()); Assert.Equal("Employer requires Kubernetes.", body.RootElement.GetProperty("context_only").GetString()); Assert.Equal(3, body.RootElement.EnumerateObject().Count()); Assert.NotNull(result); Assert.Equal("ollama", result.Provider); Assert.False(CvAiGrounding.IsSupported("Built APIs.", "Developed APIs.", result.Text)); // Incomplete evaluator coverage is never approved. } [Fact] public async Task Oversized_evaluation_never_truncates_or_calls_the_provider() { var factory = new Mock(MockBehavior.Strict); using var cache = new MemoryCache(new MemoryCacheOptions()); var service = new SummarizerService(factory.Object, cache); Assert.Null(await service.EvaluateCvRewriteAsync(new string('x', 20_000), "Proposal.", null)); factory.VerifyNoOtherCalls(); } [Fact] public async Task Older_summarizers_fail_closed_without_an_evaluator() => Assert.Null(await ((ISummarizerService)new OlderSummarizer()).EvaluateCvRewriteAsync("Built APIs.", "Developed APIs.", null)); [Fact] public async Task Typed_document_output_reaches_the_private_route_and_has_a_separate_cache_key() { var handler = new CaptureHandler(); var factory = new Mock(); factory.Setup(value => value.CreateClient("ai-service")).Returns(new HttpClient(handler) { BaseAddress = new Uri("http://localhost") }); using var cache = new MemoryCache(new MemoryCacheOptions()); var service = new SummarizerService(factory.Object, cache); await service.GenerateSectionWithMetadataAsync("Improve this source.", "Built APIs."); using (var text = JsonDocument.Parse(handler.Body!)) Assert.Equal("text", text.RootElement.GetProperty("output_mode").GetString()); var document = await service.GenerateSectionWithMetadataAsync("Improve this source.", "Built APIs.", AiGenerationOutputMode.CvBlocksJson); Assert.NotNull(document); Assert.Equal("/cv/rewrite", handler.Path); Assert.Equal(2, handler.RequestCount); // Identical text cannot reuse a plain-text cached result for JSON blocks. using var body = JsonDocument.Parse(handler.Body!); Assert.Equal("cv_blocks_json", body.RootElement.GetProperty("output_mode").GetString()); } [Fact] public async Task Older_implementations_keep_text_behavior_but_cannot_claim_typed_document_support() { ISummarizerService service = new OlderSummarizer(); Assert.Equal("Legacy rewrite.", (await service.GenerateSectionWithMetadataAsync("Improve", "Source", AiGenerationOutputMode.Text))?.Text); Assert.Null(await service.GenerateSectionWithMetadataAsync("Improve", "Source", AiGenerationOutputMode.CvBlocksJson)); } private sealed class CaptureHandler : HttpMessageHandler { public string? Path { get; private set; } public string? Body { get; private set; } public int RequestCount { get; private set; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) { Path = request.RequestUri!.AbsolutePath; Body = await request.Content!.ReadAsStringAsync(ct); RequestCount++; var response = Path == "/cv/rewrite" ? "{\"rewritten_text\":\"Generated value.\"}" : "{\"assessment\":{\"verdict\":\"supported\",\"coverageComplete\":true,\"claims\":[]}}"; var result = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(response, Encoding.UTF8, "application/json") }; result.Headers.Add("X-Ai-Provider", "ollama"); return result; } } private sealed class OlderSummarizer : ISummarizerService { public Task SummarizeAsync(string text, int maxLength = 150, int minLength = 30) => Task.FromResult(null); public Task SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40) => Task.FromResult("Legacy rewrite."); public Task ExtractTextAsync(Stream stream, string fileName, string? contentType = null, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task RunProbeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public Task GetMetricsAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); } }