using System.Security.Claims; using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using JobTrackerApi.Tests.TestSupport; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; namespace JobTrackerApi.Tests; // Candidate fit and Strategy Snapshot share AiWorkspaceNote persistence. Strategy generation is // durable now, so this file verifies its service-level result cache rather than invoking a model // from the GET endpoint. public sealed class AiWorkspaceNotePersistenceTests { [Fact] public async Task GetCandidateFit_persists_and_reuses_the_generated_note() { await using var db = TestHostFactory.CreateInMemoryDb(); var job = await SeedJobWithCvAsync(db); var summarizer = new Mock(); var callCount = 0; summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(() => { callCount++; return $"Summary call {callCount}"; }); var controller = CreateController(db, summarizer.Object, "user-1"); var first = await controller.GetCandidateFit(job.Id, null, false, CancellationToken.None); var callsAfterFirst = callCount; var second = await controller.GetCandidateFit(job.Id, null, false, CancellationToken.None); Assert.Equal(GetDto(first).MatchSummary, GetDto(second).MatchSummary); Assert.Equal(callsAfterFirst, callCount); var stored = Assert.Single(db.AiWorkspaceNotes.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id && x.NoteType == "candidate-fit")); Assert.NotEmpty(stored.ResultJson); } [Fact] public async Task GetCandidateFit_regenerates_when_refresh_is_requested() { await using var db = TestHostFactory.CreateInMemoryDb(); var job = await SeedJobWithCvAsync(db); var summarizer = new Mock(); var callCount = 0; summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(() => { callCount++; return $"Summary call {callCount}"; }); var controller = CreateController(db, summarizer.Object, "user-1"); await controller.GetCandidateFit(job.Id, null, false, CancellationToken.None); var callsAfterFirst = callCount; var refreshed = await controller.GetCandidateFit(job.Id, null, true, CancellationToken.None); Assert.True(callCount > callsAfterFirst); Assert.NotNull(GetDto(refreshed).MatchSummary); } [Fact] public async Task StrategySnapshot_generation_persists_a_stable_cached_result() { await using var db = TestHostFactory.CreateInMemoryDb(); var job = await SeedJobWithCvAsync(db); var summarizer = new Mock(); summarizer.Setup(x => x.GenerateSectionWithMetadataAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AiGenerationResult("""{"strategicSummary":"Lead with backend delivery.","cvBulletIdeas":["Built APIs"],"proofPointsToLeadWith":["Led delivery"],"coverLetterAngles":["Relevant platform work"]}""", "ollama", "test-model")); var service = new StrategySnapshotService(db, summarizer.Object); var generated = await service.GenerateAsync(job.Id, [], CancellationToken.None); var cached = await service.GetCachedAsync(job.Id, string.Empty, CancellationToken.None); Assert.NotNull(cached); Assert.Equal(generated.Result.StrategicSummary, cached.StrategicSummary); Assert.Equal(generated.Result.CvBulletIdeas, cached.CvBulletIdeas); Assert.Equal(generated.Result.ProofPointsToLeadWith, cached.ProofPointsToLeadWith); Assert.Equal(generated.Result.CoverLetterAngles, cached.CoverLetterAngles); Assert.Equal("ollama", generated.Provider); var stored = Assert.Single(db.AiWorkspaceNotes.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id && x.NoteType == "focus-plan")); Assert.NotEmpty(stored.ResultJson); } private static CandidateFitDto GetDto(ActionResult result) => (CandidateFitDto)Assert.IsType(result.Result).Value!; private static async Task SeedJobWithCvAsync(JobTrackerContext db) { var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; db.Companies.Add(company); db.Users.Add(new ApplicationUser { Id = "user-1", UserName = "user@example.test", Email = "user@example.test", ProfileCvText = "Built .NET APIs and led backend delivery with SQL and Docker.", ProfileCvStructureJson = "[]", }); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1", Description = "Needs .NET, SQL, and Docker experience.", }; db.JobApplications.Add(job); await db.SaveChangesAsync(); return job; } private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId) { var user = db.Users.AsNoTracking().FirstOrDefault(x => x.Id == userId); var controller = new JobApplicationsController( db, summarizer, TestHostFactory.CreateUserManager(user).Object, Mock.Of(), Mock.Of()); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "test")) } }; return controller; } }