using System.Security.Claims; using System.Text; using System.Text.Json; 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.Configuration; using Microsoft.Extensions.Hosting; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class ProfileCvControllerTests { [Fact] public async Task Upload_rejects_unsupported_extension() { var user = new ApplicationUser { Id = "user-1" }; var userManager = CreateUserManager(); userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); var aiService = new Mock(); await using var db = CreateDb(); var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths); var file = new FormFile(new MemoryStream(Encoding.UTF8.GetBytes("hello")), 0, 5, "file", "resume.exe"); var result = await controller.Upload(file); var badRequest = Assert.IsType(result); Assert.True((badRequest.Value?.ToString() ?? string.Empty).Contains("supported", StringComparison.OrdinalIgnoreCase)); } [Fact] public async Task Upload_stores_cv_artifact_and_extraction_run_metadata() { var user = new ApplicationUser { Id = "user-1" }; var userManager = CreateUserManager(); userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); var aiService = new Mock(); aiService .Setup(x => x.ExtractTextAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AiTextExtractionResult("# Connor Babbington\n\n## Professional Summary\nBuilt APIs and UIs", false, "text/markdown", null, 62, "resume.md")); aiService .Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny(), 3200, 900)) .ReturnsAsync(""" { "version":"1", "contact":{"fullName":"Connor Babbington"}, "summary":["Built APIs and UIs"], "jobs":[], "education":[], "skills":[], "languages":[], "interests":[], "otherSections":[] } """); await using var db = CreateDb(); var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths); var file = new FormFile(new MemoryStream(Encoding.UTF8.GetBytes("# Connor Babbington\n\n## Professional Summary\nBuilt APIs and UIs")), 0, 62, "file", "resume.md") { Headers = new HeaderDictionary(), ContentType = "text/markdown" }; var result = await controller.Upload(file); Assert.IsType(result); var artifact = await db.CvUploadArtifacts.SingleAsync(); var run = await db.CvExtractionRuns.SingleAsync(); var parsed = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); Assert.Equal("user-1", artifact.OwnerUserId); Assert.Equal("resume.md", artifact.OriginalFileName); Assert.True(System.IO.File.Exists(artifact.StoragePath)); Assert.Equal("applied", run.Status); Assert.Equal("upload", run.Trigger); Assert.Equal(artifact.Id, run.ArtifactId); Assert.Equal(run.Id, user.CurrentCvExtractionRunId); Assert.Equal(artifact.Id, user.CurrentCvUploadArtifactId); Assert.Equal(1, user.CurrentCvProfileVersion); Assert.Equal(run.Id, parsed.Metadata.AppliedExtractionRunId); Assert.True(parsed.Metadata.ProfileVersion >= 1); Assert.Contains(parsed.Metadata.Fields.Keys, key => key == "contact.fullName" || key == "summary"); } [Fact] public async Task GetRuns_returns_latest_extraction_runs() { var user = new ApplicationUser { Id = "user-1" }; var userManager = CreateUserManager(); userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); var aiService = new Mock(); await using var db = CreateDb(); db.CvExtractionRuns.Add(new CvExtractionRun { OwnerUserId = "user-1", Trigger = "upload", ParserVersion = "m005-s01", NormalizerVersion = "m005-s01", LlmPromptVersion = "m005-s01", Status = "applied", StartedAtUtc = DateTimeOffset.UtcNow, AppliedAtUtc = DateTimeOffset.UtcNow, }); await db.SaveChangesAsync(); var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths); var result = await controller.GetRuns(); var ok = Assert.IsType(result.Result); var runs = Assert.IsAssignableFrom>(ok.Value); var single = Assert.Single(runs); Assert.Equal("upload", single.Trigger); Assert.Equal("applied", single.Status); } [Fact] public async Task Reprocess_uses_latest_stored_artifact_and_creates_a_new_run() { var user = new ApplicationUser { Id = "user-1", CurrentCvProfileVersion = 1 }; var userManager = CreateUserManager(); userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); var aiService = new Mock(); aiService .Setup(x => x.ExtractTextAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AiTextExtractionResult("# Connor Babbington\n\n## Professional Summary\nRefined profile", false, "text/markdown", null, 49, "resume.md")); aiService .Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny(), 3200, 900)) .ReturnsAsync(""" { "version":"1", "contact":{"fullName":"Connor Babbington"}, "summary":["Refined profile"], "jobs":[], "education":[], "skills":[], "languages":[], "interests":[], "otherSections":[] } """); await using var db = CreateDb(); var paths = CreatePaths(); var artifactPath = Path.Combine(paths.CvArtifactsRoot, "user-1", "resume.md"); Directory.CreateDirectory(Path.GetDirectoryName(artifactPath)!); await System.IO.File.WriteAllTextAsync(artifactPath, "# Connor Babbington\n\n## Professional Summary\nLegacy profile"); db.CvUploadArtifacts.Add(new CvUploadArtifact { OwnerUserId = "user-1", OriginalFileName = "resume.md", StoredFileName = "resume.md", MimeType = "text/markdown", ByteSize = 58, Sha256 = "ABC", StoragePath = artifactPath, UploadedAtUtc = DateTimeOffset.UtcNow, }); await db.SaveChangesAsync(); var controller = CreateController(userManager.Object, aiService.Object, db, paths); var result = await controller.Reprocess(); Assert.IsType(result); var run = await db.CvExtractionRuns.SingleAsync(); Assert.Equal("reprocess", run.Trigger); Assert.Equal("applied", run.Status); Assert.Equal(2, user.CurrentCvProfileVersion); Assert.Equal(run.Id, user.CurrentCvExtractionRunId); Assert.Equal("# Connor Babbington\n\n## Professional Summary\nRefined profile", user.ProfileCvText); } [Fact] public async Task Upload_reconstructs_flattened_pdf_cv_before_save() { var rawExtraction = "connor.babbington@cesnimda.co.uk cesnimda.co.uk +47 41 33 44 70 E D U C A T I O N E X T E N D E D D I P L O M A N V Q L E V E L 3 I N I C T 2012 - 2015 F O L L O W A B O U T M E Mid-level system developer with eight years of experience in UK local government. I N T E R E S T S E X P E R I E N C E S Y S T E M D E V E L O P E R 2015 - 2023 Developed and maintained multiple full-stack applications using C#, Python, Ruby on Rails, SQL, and JavaScript. + Warwickshire County Council, UK C O N T A C T Native English speaker, Norwegian level A2/B1."; var reconstructed = "# Connor Babbington\n\n## Contact\nconnor.babbington@cesnimda.co.uk\ncesnimda.co.uk\n+47 41 33 44 70\nTønsberg, Norway\n\n## Professional Summary\nMid-level system developer with eight years of experience in UK local government.\n\n## Work Experience\n### System Developer\nWarwickshire County Council\nUK\n2015 - 2023\n- Developed and maintained multiple full-stack applications using C#, Python, Ruby on Rails, SQL, and JavaScript.\n\n## Education\n### Extended Diploma NVQ Level 3 in ICT\nWarwickshire College\n2012 - 2015\n\n## Languages\nEnglish: Native\nNorwegian: A2/B1"; var structuredJson = """ { "version": "1", "contact": { "fullName": "Connor Babbington", "email": "connor.babbington@cesnimda.co.uk", "phone": "+47 41 33 44 70", "location": "Tønsberg, Norway", "website": "cesnimda.co.uk" }, "summary": ["Mid-level system developer with eight years of experience in UK local government."], "jobs": [ { "title": "System Developer", "company": "Warwickshire County Council", "location": "UK", "start": "2015", "end": "2023", "isCurrent": false, "bullets": ["Developed and maintained multiple full-stack applications using C#, Python, Ruby on Rails, SQL, and JavaScript."], "skills": ["C#", "Python", "Ruby on Rails", "SQL", "JavaScript"] } ], "education": [ { "qualification": "Extended Diploma NVQ Level 3 in ICT", "institution": "Warwickshire College", "start": "2012", "end": "2015", "details": [] } ], "skills": ["C#", "Python", "Ruby on Rails", "SQL", "JavaScript"], "languages": [ { "name": "English", "level": "Native" }, { "name": "Norwegian", "level": "A2/B1" } ], "interests": [], "otherSections": [] } """; var user = new ApplicationUser { Id = "user-1" }; var userManager = CreateUserManager(); userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); var aiService = new Mock(); aiService .Setup(x => x.ExtractTextAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AiTextExtractionResult(rawExtraction, false, "application/pdf", 1, rawExtraction.Length, "Resume.en.pdf")); aiService .Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Reconstruct this CV text extracted from a PDF", StringComparison.Ordinal)), rawExtraction, 2800, 900)) .ReturnsAsync(reconstructed); aiService .Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), reconstructed, 3200, 900)) .ReturnsAsync(structuredJson); await using var db = CreateDb(); var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths); var bytes = Encoding.UTF8.GetBytes("fake pdf bytes"); var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "Resume.en.pdf") { Headers = new HeaderDictionary(), ContentType = "application/pdf" }; var result = await controller.Upload(file); Assert.IsType(result); Assert.Equal(reconstructed, user.ProfileCvText); var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); Assert.Equal("Connor Babbington", structured.Contact.FullName); Assert.Single(structured.Summary); Assert.Single(structured.Jobs); Assert.Equal("System Developer", structured.Jobs[0].Title); Assert.Single(structured.Education); Assert.Equal("Extended Diploma NVQ Level 3 in ICT", structured.Education[0].Qualification); Assert.Contains(structured.Sections, section => section.Name == "Contact"); Assert.Contains(structured.Sections, section => section.Name == "Professional Summary"); Assert.Contains(structured.Sections, section => section.Name == "Work Experience"); Assert.Contains(structured.Sections, section => section.Name == "Education"); } [Fact] public async Task Upload_populates_structured_fields_from_flattened_cv_when_ai_json_is_invalid() { var rawExtraction = "connor.babbington@cesnimda.co.uk cesnimda.co.uk +47 41 33 44 70 E D U C A T I O N E X T E N D E D D I P L O M A N V Q L E V E L 3 I N I C T 2012 - 2015 F O L L O W A B O U T M E Mid-level system developer with eight years of experience in UK local government, with expertise in full-stack development, backend, frontend and server administration. I N T E R E S T S I am interested in PC and board games, as well as cooking and learning new skills. E X P E R I E N C E S Y S T E M D E V E L O P E R 2015 - 2023 Developed and maintained multiple full-stack applications using C#, Python, Ruby on Rails, SQL, and JavaScript. + Warwickshire County Council, UK C O N T A C T Native English speaker, Norwegian level A2/B1, C#, SQL, and public speaking."; var user = new ApplicationUser { Id = "user-1" }; var userManager = CreateUserManager(); userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); var aiService = new Mock(); aiService .Setup(x => x.ExtractTextAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AiTextExtractionResult(rawExtraction, false, "application/pdf", 1, rawExtraction.Length, "Resume.en.pdf")); aiService .Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Reconstruct this CV text extracted from a PDF", StringComparison.Ordinal)), rawExtraction, 2800, 900)) .ReturnsAsync(string.Empty); aiService .Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny(), 3200, 900)) .ReturnsAsync("not-json"); await using var db = CreateDb(); var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths); var bytes = Encoding.UTF8.GetBytes("fake pdf bytes"); var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "Resume.en.pdf") { Headers = new HeaderDictionary(), ContentType = "application/pdf" }; var result = await controller.Upload(file); Assert.IsType(result); var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); Assert.Equal("Connor Babbington", structured.Contact.FullName); Assert.Equal("connor.babbington@cesnimda.co.uk", structured.Contact.Email); Assert.Equal("+47 41 33 44 70", structured.Contact.Phone); Assert.Contains(structured.Summary, item => item.Contains("eight years of experience", StringComparison.OrdinalIgnoreCase)); Assert.Contains(structured.Skills, item => item.Equals("C#", StringComparison.OrdinalIgnoreCase)); Assert.Contains(structured.Interests, item => item.Contains("board games", StringComparison.OrdinalIgnoreCase) || item.Contains("cooking", StringComparison.OrdinalIgnoreCase)); Assert.Contains(structured.Languages, item => item.Name != null && item.Name.Equals("English", StringComparison.OrdinalIgnoreCase)); Assert.Contains(structured.Languages, item => item.Name != null && item.Name.StartsWith("Norwegian", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(structured.Languages, item => item.Name != null && item.Name.Equals("C#", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(structured.Languages, item => item.Name != null && item.Name.Equals("SQL", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(structured.Languages, item => item.Name != null && item.Name.Contains("public speaking", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(structured.Sections, section => section.Name == "General"); } [Fact] public void Structured_cv_normalization_keeps_human_languages_and_drops_skill_noise() { var structured = StructuredCvProfileJson.Deserialize(""" { "version": "1", "contact": {}, "summary": [], "jobs": [], "education": [], "skills": [], "languages": [ { "name": "English", "level": "Native" }, { "name": "Native Norwegian speaker", "level": null }, { "name": "French", "level": null }, { "name": "C#", "level": "Advanced" }, { "name": "Leadership", "level": null } ], "interests": [], "otherSections": [] } """); Assert.Collection( structured.Languages.OrderBy(item => item.Name, StringComparer.OrdinalIgnoreCase), first => { Assert.Equal("English", first.Name); Assert.Equal("Native", first.Level); }, second => { Assert.Equal("Norwegian", second.Name); Assert.Equal("Native", second.Level); }); } [Fact] public void Structured_cv_normalization_separates_job_title_company_and_tasks() { var structured = StructuredCvProfileJson.Deserialize(""" { "version": "1", "contact": {}, "summary": [], "jobs": [ { "title": "Acme Ltd", "company": "Senior Backend Developer", "location": "Oslo", "start": "2022", "end": "2024", "isCurrent": false, "bullets": [ "Senior Backend Developer", "Acme Ltd", "2022 - 2024", "Built API integrations for recruiter workflows and reduced manual follow-up churn." ], "skills": [".NET", "SQL"] }, { "title": "Lead Engineer at Northwind Council", "company": null, "location": "Remote", "start": "2020", "end": "Present", "isCurrent": true, "bullets": [ "Led platform delivery across case-management and reporting surfaces.", "Skills: C#, SQL" ], "skills": ["C#", "SQL"] } ], "education": [], "skills": [], "languages": [], "interests": [], "otherSections": [] } """); Assert.Collection( structured.Jobs, first => { Assert.Equal("Senior Backend Developer", first.Title); Assert.Equal("Acme Ltd", first.Company); Assert.Equal(new[] { "Built API integrations for recruiter workflows and reduced manual follow-up churn." }, first.Bullets); }, second => { Assert.Equal("Lead Engineer", second.Title); Assert.Equal("Northwind Council", second.Company); Assert.Equal(new[] { "Led platform delivery across case-management and reporting surfaces." }, second.Bullets); }); } [Fact] public void Structured_cv_normalization_hardens_contact_links_locations_and_dates() { var structured = StructuredCvProfileJson.Deserialize(""" { "version": "1", "contact": { "location": "Tønsberg, Norway", "website": "https://cesnimda.co.uk/about", "linkedin": "linkedin.com/in/demo-user?trk=foo" }, "summary": [], "jobs": [ { "title": "System Developer", "company": "Warwickshire County Council", "location": "Warwickshire, England, UK", "start": "Sept 2023", "end": "1/1/2024", "isCurrent": false, "bullets": ["Built APIs"], "skills": [] }, { "title": "Developer", "company": "Demo Co", "location": "Remote 123", "start": "Spring 2024", "end": "Later", "isCurrent": false, "bullets": ["Kept services running"], "skills": [] } ], "education": [], "skills": [], "languages": [], "interests": [], "otherSections": [] } """); Assert.Equal("Tønsberg, Norway", structured.Contact.Location); Assert.Equal("cesnimda.co.uk", structured.Contact.Website); Assert.Equal("https://www.linkedin.com/in/demo-user", structured.Contact.LinkedIn); Assert.Equal("Warwickshire, England, UK", structured.Jobs[0].Location); Assert.Equal("Sept 2023", structured.Jobs[0].Start); Assert.Equal("1/1/2024", structured.Jobs[0].End); Assert.Null(structured.Jobs[1].Location); Assert.Null(structured.Jobs[1].Start); Assert.Null(structured.Jobs[1].End); } [Fact] public async Task Parse_returns_structured_cv_and_persists_it() { var user = new ApplicationUser { Id = "user-1", ProfileCvText = "# Connor Babbington\n\n## Contact\nconnor@example.com\n+47 41 33 44 70\n\n## Professional Summary\nBuilt backend systems.\n\n## Work Experience\n### System Developer\nWarwickshire County Council\n2015 - 2023\n- Built APIs\n\n## Education\n### Warwickshire College\n2012 - 2015" }; var structuredJson = """ { "version": "1", "contact": { "fullName": "Connor Babbington", "email": "connor@example.com", "phone": "+47 41 33 44 70" }, "summary": ["Built backend systems."], "jobs": [ { "title": "System Developer", "company": "Warwickshire County Council", "start": "2015", "end": "2023", "isCurrent": false, "bullets": ["Built APIs"], "skills": [".NET"] } ], "education": [ { "qualification": "Warwickshire College", "start": "2012", "end": "2015", "details": [] } ], "skills": [".NET"], "languages": [], "interests": [], "otherSections": [] } """; var userManager = CreateUserManager(); userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); var aiService = new Mock(); aiService .Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), user.ProfileCvText, 3200, 900)) .ReturnsAsync(structuredJson); await using var db = CreateDb(); var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths); var result = await controller.Parse(new ProfileCvController.ParseCvRequest(user.ProfileCvText)); var ok = Assert.IsType(result.Result); var json = JsonSerializer.Serialize(ok.Value); Assert.Contains("structuredCv", json, StringComparison.OrdinalIgnoreCase); Assert.Contains("Connor Babbington", json); Assert.Contains("System Developer", json); var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); Assert.Equal("Connor Babbington", structured.Contact.FullName); Assert.Single(structured.Jobs); Assert.Equal("System Developer", structured.Jobs[0].Title); } [Fact] public async Task Parse_falls_back_to_section_parsing_when_ai_json_is_invalid() { var user = new ApplicationUser { Id = "user-1", ProfileCvText = "# Connor Babbington\n\n## Professional Summary\nBuilt backend systems.\n\n## Skills\n.NET\nSQL\nAzure" }; var userManager = CreateUserManager(); userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); var aiService = new Mock(); aiService .Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), user.ProfileCvText, 3200, 900)) .ReturnsAsync("not-json"); await using var db = CreateDb(); var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths); var result = await controller.Parse(new ProfileCvController.ParseCvRequest(user.ProfileCvText)); var ok = Assert.IsType(result.Result); var json = JsonSerializer.Serialize(ok.Value); Assert.Contains("Professional Summary", json); var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); Assert.Contains("Built backend systems.", structured.Summary); Assert.Contains(".NET", structured.Skills); Assert.Contains("SQL", structured.Skills); Assert.Equal("Connor Babbington", structured.Contact.FullName); } [Fact] public async Task Parse_uses_classifier_fallback_when_plain_text_has_no_real_sections() { var source = "Senior Platform Engineer at Atlas Systems\nOslo\n2019 - Present\nBuilt event-driven APIs and migration tooling.\n\nPython\nSQL\nAzure"; var user = new ApplicationUser { Id = "user-1", ProfileCvText = source }; var userManager = CreateUserManager(); userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); var aiService = new Mock(); aiService .Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), source, 3200, 900)) .ReturnsAsync("not-json"); var classifier = new Mock(); classifier .Setup(x => x.ClassifyBlockAsync(It.Is(block => block.Contains("Atlas Systems", StringComparison.Ordinal)), It.IsAny())) .ReturnsAsync(new CvBlockClassificationResult("Work Experience", 0.93, "job block", "Senior Platform Engineer", "Atlas Systems", "Oslo", "2019", "Present", new List { "Built event-driven APIs and migration tooling." })); classifier .Setup(x => x.ClassifyBlockAsync(It.Is(block => block.Contains("Python", StringComparison.Ordinal)), It.IsAny())) .ReturnsAsync(new CvBlockClassificationResult("Skills", 0.88, "skills block", null, null, null, null, null, new List())); await using var db = CreateDb(); var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object); var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source)); var ok = Assert.IsType(result.Result); var json = JsonSerializer.Serialize(ok.Value); Assert.Contains("Senior Platform Engineer", json); Assert.Contains("Atlas Systems", json); var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); var matchedJob = structured.Jobs.FirstOrDefault(job => job.Title == "Senior Platform Engineer"); Assert.NotNull(matchedJob); Assert.Contains("Atlas Systems", matchedJob!.Company ?? string.Empty, StringComparison.Ordinal); Assert.Contains("Python", structured.Skills); Assert.Contains("SQL", structured.Skills); classifier.Verify(x => x.ClassifyBlockAsync(It.IsAny(), It.IsAny()), Times.AtLeastOnce()); } [Fact] public async Task Parse_keeps_general_fallback_when_classifier_returns_nothing() { var source = "Independent consultant building internal platforms for public-sector clients across data, delivery, and migration work."; var user = new ApplicationUser { Id = "user-1", ProfileCvText = source }; var userManager = CreateUserManager(); userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); var aiService = new Mock(); aiService .Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), source, 3200, 900)) .ReturnsAsync("not-json"); var classifier = new Mock(); classifier .Setup(x => x.ClassifyBlockAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((CvBlockClassificationResult?)null); await using var db = CreateDb(); var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object); var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source)); var ok = Assert.IsType(result.Result); var json = JsonSerializer.Serialize(ok.Value); Assert.Contains("Professional Summary", json); var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); Assert.Contains(structured.Summary, item => item.Contains("Independent consultant", StringComparison.OrdinalIgnoreCase)); Assert.NotNull(user.ProfileCvStructureJson); classifier.Verify(x => x.ClassifyBlockAsync(It.IsAny(), It.IsAny()), Times.AtLeastOnce()); } [Fact] public async Task Upload_accepts_markdown_cv_and_saves_text() { var user = new ApplicationUser { Id = "user-1" }; var userManager = CreateUserManager(); userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); var aiService = new Mock(); aiService .Setup(x => x.ExtractTextAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AiTextExtractionResult("# Connor Babbington\n\n## Professional Summary\nBuilt APIs and UIs", false, "text/markdown", null, 62, "resume.md")); aiService .Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny(), 3200, 900)) .ReturnsAsync(""" { "version":"1", "contact":{"fullName":"Connor Babbington"}, "summary":["Built APIs and UIs"], "jobs":[], "education":[], "skills":[], "languages":[], "interests":[], "otherSections":[] } """); await using var db = CreateDb(); var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths); var file = new FormFile(new MemoryStream(Encoding.UTF8.GetBytes("# Connor Babbington\n\n## Professional Summary\nBuilt APIs and UIs")), 0, 62, "file", "resume.md") { Headers = new HeaderDictionary(), ContentType = "text/markdown" }; var result = await controller.Upload(file); Assert.IsType(result); Assert.Contains("Built APIs", user.ProfileCvText); Assert.Equal("Connor Babbington", StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson).Contact.FullName); } private static ProfileCvController CreateController(UserManager userManager, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ICvAiClassifier? cvAiClassifier = null) { return new ProfileCvController(userManager, aiService, db, paths, cvAiClassifier ?? NoOpCvAiClassifier.Instance) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; } private static JobTrackerContext CreateDb(string userId = "user-1") { return TestHostFactory.CreateInMemoryDb(userId); } private static AppPaths CreatePaths() { var tempRoot = Path.Combine(Path.GetTempPath(), $"jobtracker-cv-tests-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempRoot); var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Data:Root"] = tempRoot, ["Data:CvArtifactsRoot"] = Path.Combine(tempRoot, "CvArtifacts") }) .Build(); var env = new Mock(); env.SetupGet(x => x.ContentRootPath).Returns(tempRoot); return new AppPaths(config, env.Object); } private static Mock> CreateUserManager() { return TestHostFactory.CreateUserManager(); } }