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.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; 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 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 Save_application_drafts_updates_and_clears_answer_without_changing_human_notes() { 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 = "Human note\n\n<<>>\nOld answer\n<<>>", RecruiterMessageDraft = "Old recruiter message" }; db.JobApplications.Add(job); await db.SaveChangesAsync(); var controller = CreateController(db, Mock.Of(), "user-1"); var updated = await controller.SaveApplicationDrafts( job.Id, new SaveApplicationDraftsRequest(null, null, " New recruiter message ", " New answer "), CancellationToken.None); Assert.IsType(updated); Assert.Equal("Human note", JobApplicationHelpers.RemoveSavedApplicationAnswerDraft(job.Notes)); Assert.Equal("New answer", JobApplicationHelpers.ExtractSavedApplicationAnswerDraft(job.Notes)); Assert.Equal("New recruiter message", job.RecruiterMessageDraft); job.Notes += "\n\n<<>>\nDuplicate stale answer\n<<>>"; var cleared = await controller.SaveApplicationDrafts( job.Id, new SaveApplicationDraftsRequest(null, null, "", ""), CancellationToken.None); Assert.IsType(cleared); Assert.Equal("Human note", job.Notes); Assert.Null(job.RecruiterMessageDraft); } [Fact] public async Task Save_application_drafts_cannot_mutate_another_users_application() { await using var db = CreateDb(); var company = new Company { Name = "Other Acme", OwnerUserId = "user-2" }; db.Companies.Add(company); await db.SaveChangesAsync(); var otherUsersJob = new JobApplication { JobTitle = "Private role", CompanyId = company.Id, OwnerUserId = "user-2", Notes = "Private note", RecruiterMessageDraft = "Private draft" }; db.JobApplications.Add(otherUsersJob); await db.SaveChangesAsync(); var controller = CreateController(db, Mock.Of(), "user-1"); var result = await controller.SaveApplicationDrafts( otherUsersJob.Id, new SaveApplicationDraftsRequest(null, null, "Changed", "Changed"), CancellationToken.None); Assert.IsType(result); Assert.Equal("Private note", otherUsersJob.Notes); Assert.Equal("Private draft", otherUsersJob.RecruiterMessageDraft); } [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 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 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); } [Fact] public void Template_renderer_wraps_long_content_and_escapes_sidebar_values() { var document = TailoredCvDraftJson.Normalize(new TailoredCvDocument { Headline = new string('H', 180), SelectedSkills = new List { $"{new string('s', 400)}" }, Experience = new List { new() { Title = new string('T', 220), Company = new string('C', 220), Start = "2020", End = "Present", Bullets = Enumerable.Range(1, 7).Select(index => index == 1 ? new string('x', 500) : $"Achievement {index}").ToList(), }, }, }); var html = new CvTemplateRenderer().Render(document, "auckland", new string('N', 180), "Engineer", "Acme", null).Html; Assert.Contains("class=\"entry entry-flow\"", html); Assert.Contains("class=\"item-flow\"", html); Assert.Contains("grid-template-columns:34% minmax(0,66%)", html); Assert.Contains("overflow-wrap:anywhere", html); Assert.Contains("<script>alert(1)</script>", html); Assert.DoesNotContain("