using System.Security.Claims; using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class JobApplicationsApplicationPackageTests { [Fact] public async Task Save_application_drafts_replaces_notes_instead_of_appending() { await using var db = CreateDb(); var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; db.Companies.Add(company); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1", Notes = "Old notes" }; db.JobApplications.Add(job); await db.SaveChangesAsync(); var controller = CreateController(db, Mock.Of(), "user-1"); var result = await controller.SaveApplicationDrafts(job.Id, new JobApplicationsController.SaveApplicationDraftsRequest(null, "Updated notes block", null), CancellationToken.None); Assert.IsType(result); var saved = await db.JobApplications.FirstAsync(); Assert.Equal("Updated notes block", saved.Notes); } [Fact] public async Task Generate_application_package_uses_imported_correspondence_and_recruiter_context() { await using var db = CreateDb(); var company = new Company { Name = "Acme", RecruiterName = "Maria Recruiter", RecruiterEmail = "maria@acme.test", 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.", ProfileCvStructureJson = "[]" }); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1", Description = "Need .NET, APIs, and async collaboration with recruiters.", Notes = "Priority role", ShortSummary = "Acme backend hiring" }; db.JobApplications.Add(job); await db.SaveChangesAsync(); db.Correspondences.Add(new Correspondence { JobApplicationId = job.Id, From = "Company", Subject = "Backend Developer interview", ExternalThreadId = "thread-1", ExternalFrom = "Maria Recruiter ", ExternalTo = "user@example.test", Content = "We want someone who can own .NET APIs and communicate clearly with stakeholders.", Date = DateTime.UtcNow.AddDays(-1) }); await db.SaveChangesAsync(); var summarizer = new Mock(); summarizer .Setup(service => service.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((string instruction, string context, int _, int __) => { if (instruction.Contains("List up to 4 concrete application-package signals", StringComparison.OrdinalIgnoreCase)) { return "Use recruiter language about owning .NET APIs.\nMention the interview timeline from imported correspondence."; } if (instruction.Contains("cover letter", StringComparison.OrdinalIgnoreCase)) { return context.Contains("Imported correspondence context:", StringComparison.OrdinalIgnoreCase) && context.Contains("Maria Recruiter", StringComparison.OrdinalIgnoreCase) ? "Cover letter tailored with recruiter context and imported correspondence." : "Generic cover letter."; } if (instruction.Contains("recruiter intro message", StringComparison.OrdinalIgnoreCase)) { return context.Contains("Recruiter email: maria@acme.test", StringComparison.OrdinalIgnoreCase) ? "Recruiter message that uses Maria's context." : "Generic recruiter message."; } if (instruction.Contains("application answer", StringComparison.OrdinalIgnoreCase)) { return "Application answer grounded in the imported thread."; } if (instruction.Contains("Rewrite the candidate CV", StringComparison.OrdinalIgnoreCase)) { return "Tailored CV that highlights .NET API ownership for Acme."; } return "Variant draft"; }); var controller = CreateController(db, summarizer.Object, "user-1"); var result = await controller.GenerateApplicationPackage(job.Id, null, null, null, CancellationToken.None); var ok = Assert.IsType(result.Result); var payload = Assert.IsType(ok.Value); Assert.Contains("Tailored CV", payload.TailoredCvText); Assert.Equal("Cover letter tailored with recruiter context and imported correspondence.", payload.CoverLetterDraft); Assert.Equal("Recruiter message that uses Maria's context.", payload.RecruiterMessageDraft); Assert.Contains(payload.KeyPoints, item => item.Contains("interview timeline", StringComparison.OrdinalIgnoreCase)); Assert.Contains(payload.KeyPoints, item => item.Contains("recruiter language", StringComparison.OrdinalIgnoreCase)); } [Fact] public async Task Generate_application_package_passes_typed_structured_cv_context_to_summarizer() { await using var db = CreateDb(); 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 APIs and shipped backend work.", ProfileCvStructureJson = """ { "version": "1", "contact": { "fullName": "Demo User", "headline": "Backend Developer", "email": "user@example.test", "location": "Oslo" }, "summary": ["Backend-focused developer with strong API delivery experience."], "jobs": [ { "title": "System Developer", "company": "Acme Consulting", "location": "Oslo", "start": "2020", "end": "2024", "isCurrent": false, "bullets": ["Owned .NET API delivery across multiple services."], "skills": [".NET", "SQL", "APIs"] } ], "education": [], "skills": [".NET", "SQL", "APIs"], "languages": [{ "name": "English", "level": "Native" }], "interests": [], "otherSections": [] } """ }); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1", Description = "Need .NET API ownership and strong SQL skills." }; db.JobApplications.Add(job); await db.SaveChangesAsync(); string? capturedContext = null; var summarizer = new Mock(); summarizer .Setup(service => service.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((string instruction, string context, int _, int __) => { if (instruction.Contains("Rewrite the candidate CV", StringComparison.OrdinalIgnoreCase)) { capturedContext = context; return "Tailored CV"; } if (instruction.Contains("List up to 4 concrete application-package signals", StringComparison.OrdinalIgnoreCase)) { return "Lead with .NET API ownership."; } return "Draft"; }); var controller = CreateController(db, summarizer.Object, "user-1"); var result = await controller.GenerateApplicationPackage(job.Id, null, null, null, CancellationToken.None); Assert.IsType(result.Result); Assert.NotNull(capturedContext); Assert.Contains("Structured CV:", capturedContext); Assert.Contains("Name: Demo User", capturedContext); Assert.Contains("Skills:\n.NET, SQL, APIs", capturedContext); Assert.Contains("Work Experience:", capturedContext); Assert.Contains("Owned .NET API delivery across multiple services.", capturedContext); } [Fact] public async Task Get_tailored_cv_draft_returns_legacy_fallback_when_no_structured_draft_exists() { await using var db = CreateDb(); var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; db.Companies.Add(company); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1", TailoredCvText = "Existing tailored CV text", TailoredCvUpdatedAt = DateTime.UtcNow }; db.JobApplications.Add(job); await db.SaveChangesAsync(); var controller = CreateController(db, Mock.Of(), "user-1"); var result = await controller.GetTailoredCvDraft(job.Id, CancellationToken.None); var ok = Assert.IsType(result.Result); var payload = Assert.IsType(ok.Value); Assert.True(payload.IsLegacyFallback); Assert.Equal("legacy-text", payload.TemplateId); Assert.Contains("Existing tailored CV text", payload.RenderedText); } [Fact] public async Task Generate_and_save_tailored_cv_draft_persists_job_scoped_document_without_mutating_master_cv() { await using var db = CreateDb(); 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", CurrentCvProfileVersion = 7, ProfileCvText = "Built APIs and owned backend delivery.", ProfileCvStructureJson = """ { "version": "1", "contact": { "fullName": "Demo User", "headline": "Backend Developer" }, "summary": ["Backend-focused developer with API delivery experience."], "jobs": [ { "title": "System Developer", "company": "Acme Consulting", "location": "Oslo", "start": "2021", "end": "2024", "isCurrent": false, "bullets": ["Owned .NET API delivery across multiple services."], "skills": [".NET", "SQL", "APIs"] } ], "education": [], "skills": [".NET", "SQL", "APIs"], "languages": [{ "name": "English", "level": "Native" }], "interests": [], "otherSections": [] } """ }); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1", Description = "Need .NET API ownership and strong SQL skills." }; db.JobApplications.Add(job); await db.SaveChangesAsync(); var summarizer = new Mock(); summarizer .Setup(service => service.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((string instruction, string _, int __, int ___) => { if (instruction.Contains("headline", StringComparison.OrdinalIgnoreCase)) return "Senior Backend Engineer"; if (instruction.Contains("summary bullets", StringComparison.OrdinalIgnoreCase)) return "Led backend API delivery.\nImproved SQL-backed workflows."; return "Draft"; }); var controller = CreateController(db, summarizer.Object, "user-1"); var generateResult = await controller.GenerateTailoredCvDraft(job.Id, "ats", CancellationToken.None); var generateOk = Assert.IsType(generateResult.Result); var generated = Assert.IsType(generateOk.Value); Assert.False(generated.IsLegacyFallback); Assert.Equal(7, generated.CanonicalProfileVersion); Assert.Equal("Senior Backend Engineer", generated.Headline); Assert.Contains("Led backend API delivery.", generated.RenderedText); var saveResult = await controller.SaveTailoredCvDraft(job.Id, new JobApplicationsController.SaveTailoredCvDraftRequest( generated.TemplateId, "Principal Backend Engineer", new List { "Own backend delivery for critical APIs." }, new List { ".NET", "SQL" }, generated.Experience, generated.Education, generated.CustomSections, generated.RenderOptions, "edited"), CancellationToken.None); Assert.IsType(saveResult); var savedDraft = await db.TailoredCvDrafts.SingleAsync(); var savedJob = await db.JobApplications.SingleAsync(); var savedUser = await db.Users.SingleAsync(); Assert.Equal("edited", savedDraft.Status); Assert.Equal(7, savedDraft.CanonicalProfileVersion); Assert.Contains("Principal Backend Engineer", savedJob.TailoredCvText); Assert.Equal("Built APIs and owned backend delivery.", savedUser.ProfileCvText); } [Fact] public async Task Preview_and_export_tailored_cv_use_same_renderer_contract_and_profile_avatar_default() { await using var db = CreateDb(); 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", AvatarImageDataUrl = "data:image/png;base64,abc123" }); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1", TailoredCvText = "Saved tailored CV" }; db.JobApplications.Add(job); await db.SaveChangesAsync(); var renderer = new TestCvTemplateRenderer(); var exporter = new TestCvPdfExporter(); var controller = CreateController(db, Mock.Of(), "user-1", renderer, exporter); var request = new JobApplicationsController.TailoredCvRenderRequest( "ats-minimal", "Backend Engineer", new List { "Built APIs" }, new List { ".NET" }, new List(), new List(), new List(), new TailoredCvRenderOptions { ShowPhoto = true, AccentColor = "#123456" }, null, true); var previewResult = await controller.PreviewTailoredCv(job.Id, request, CancellationToken.None); var ok = Assert.IsType(previewResult.Result); var preview = Assert.IsType(ok.Value); Assert.Equal("ats-minimal", preview.TemplateId); Assert.Equal("preview.pdf", preview.SuggestedFileName); Assert.Equal("data:image/png;base64,abc123", renderer.LastPhotoDataUrl); var exportResult = await controller.ExportTailoredCvPdf(job.Id, request, CancellationToken.None); var file = Assert.IsType(exportResult); Assert.Equal("application/pdf", file.ContentType); Assert.Equal("preview.pdf", file.FileDownloadName); Assert.NotNull(exporter.LastRenderResult); Assert.Equal(preview.Html, exporter.LastRenderResult!.Html); } [Fact] public void Template_renderer_supports_named_variants() { var renderer = new CvTemplateRenderer(); var document = TailoredCvDraftJson.Normalize(new TailoredCvDocument { TemplateId = "harvard", Headline = "Product Manager", Summary = new List { "Built and shipped product roadmaps." }, SelectedSkills = new List { "Strategy", "Stakeholder management" }, Experience = new List { new() { Title = "Product Manager", Company = "Acme", Start = "2022", End = "2025", Bullets = new List { "Launched new product line." } } } }); var harvard = renderer.Render(document, "harvard", "Andrew O'Sullivan", "Product Manager", "Acme", null); var auckland = renderer.Render(document, "auckland", "Andrew O'Sullivan", "Product Manager", "Acme", "data:image/png;base64,abc"); var edinburgh = renderer.Render(document, "edinburgh", "Andrew O'Sullivan", "Product Manager", "Acme", "data:image/png;base64,abc"); Assert.Equal("harvard", harvard.TemplateId); Assert.Contains("Template: ATS Minimal", renderer.Render(document, "ats-minimal", "Andrew O'Sullivan", "Product Manager", "Acme", null).Html); Assert.Contains("Andrew O'Sullivan", harvard.Html); Assert.Contains("sidebar", auckland.Html, StringComparison.OrdinalIgnoreCase); Assert.Contains("curved", edinburgh.Html, StringComparison.OrdinalIgnoreCase); } private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId, ICvTemplateRenderer? renderer = null, ICvPdfExporter? exporter = null) { var user = db.Users.AsNoTracking().FirstOrDefault(x => x.Id == userId); var controller = new JobApplicationsController( db, summarizer, Mock.Of(), CreateUserManager(user).Object, NullLogger.Instance, renderer ?? new TestCvTemplateRenderer(), exporter ?? new TestCvPdfExporter()); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "test")) } }; return controller; } private static JobTrackerContext CreateDb() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; var currentUser = new Mock(); currentUser.SetupGet(service => service.UserId).Returns("user-1"); return new JobTrackerContext(options, currentUser.Object); } private static Mock> CreateUserManager(ApplicationUser? user = null) { var store = new Mock>(); var manager = new Mock>( store.Object, Options.Create(new IdentityOptions()), new PasswordHasher(), Array.Empty>(), Array.Empty>(), new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), null!, new NullLogger>()); manager.Setup(x => x.FindByIdAsync(It.IsAny())).ReturnsAsync((string id) => user is not null && user.Id == id ? user : null); return manager; } private sealed class TestCvTemplateRenderer : ICvTemplateRenderer { public string? LastPhotoDataUrl { get; private set; } public TailoredCvRenderResult Render(TailoredCvDocument? document, string? templateId, string candidateName, string jobTitle, string? companyName, string? photoDataUrl = null) { LastPhotoDataUrl = photoDataUrl; return new TailoredCvRenderResult(templateId ?? "ats-minimal", "preview.pdf", $"{candidateName}|{jobTitle}|{companyName}|{document?.Headline}|{photoDataUrl}"); } } private sealed class TestCvPdfExporter : ICvPdfExporter { public TailoredCvRenderResult? LastRenderResult { get; private set; } public Task ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken) { LastRenderResult = renderResult; return Task.FromResult(new CvPdfArtifact("preview.pdf", "/tmp/preview.pdf", new byte[] { 1, 2, 3 })); } } }