Files

1247 lines
70 KiB
C#

using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
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<ClaimsPrincipal>())).ReturnsAsync(user);
var aiService = new Mock<ISummarizerService>();
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<BadRequestObjectResult>(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<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.ExtractTextAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.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<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 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<OkObjectResult>(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<ClaimsPrincipal>())).ReturnsAsync(user);
var aiService = new Mock<ISummarizerService>();
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<OkObjectResult>(result.Result);
var runs = Assert.IsAssignableFrom<IEnumerable<ProfileCvController.CvExtractionRunListItem>>(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<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.ExtractTextAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.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<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 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();
var accepted = Assert.IsType<AcceptedResult>(result);
var queuedRun = await db.CvExtractionRuns.SingleAsync();
Assert.Equal("queued", queuedRun.Status);
await controller.ProcessQueuedRunAsync(queuedRun.Id, CancellationToken.None);
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<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.ExtractTextAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiTextExtractionResult(rawExtraction, false, "application/pdf", 1, rawExtraction.Length, "Resume.en.pdf"));
aiService
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(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<string>(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<OkObjectResult>(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_uses_ai_normalizer_fallback_when_flattened_text_stays_low_structure()
{
var rawExtraction = "connor.babbington@cesnimda.co.uk cesnimda.co.uk +47 41 33 44 70 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";
var user = new ApplicationUser { Id = "user-1" };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.ExtractTextAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiTextExtractionResult(rawExtraction, false, "application/pdf", 1, rawExtraction.Length, "Resume.en.pdf"));
aiService
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(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<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900))
.ReturnsAsync("not-json");
var normalizer = new Mock<ICvAiNormalizer>();
normalizer
.Setup(x => x.NormalizeAsync(It.Is<string>(text => text.Contains("Warwickshire County Council", StringComparison.Ordinal)), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CvNormalizationResult(
0.91,
"Recovered structured sections from flattened OCR text",
"# Contact\nConnor Babbington\nconnor.babbington@cesnimda.co.uk\n+47 41 33 44 70\ncesnimda.co.uk\n\n# Professional Summary\nMid-level system developer with eight years of experience in UK local government.\n\n# Work Experience\nSystem Developer\nWarwickshire County Council, UK\n2015 - 2023\n- Developed and maintained multiple full-stack applications using C#, Python, Ruby on Rails, SQL, and JavaScript.\n\n# Skills\nC#\nPython\nRuby on Rails\nSQL\nJavaScript"));
await using var db = CreateDb();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths, null, normalizer.Object);
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<OkObjectResult>(result);
normalizer.Verify(x => x.NormalizeAsync(It.Is<string>(text => text.Contains("Warwickshire County Council", StringComparison.Ordinal)), It.IsAny<CancellationToken>()), Times.Once);
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Equal("Connor Babbington", structured.Contact.FullName);
Assert.Contains("# Skills", user.ProfileCvText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Warwickshire County Council", user.ProfileCvText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}
[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<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.ExtractTextAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiTextExtractionResult(rawExtraction, false, "application/pdf", 1, rawExtraction.Length, "Resume.en.pdf"));
aiService
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(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<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 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<OkObjectResult>(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": "Python,Ruby",
"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": []
},
{
"title": "Developer",
"company": "Demo Co",
"location": "Warwickshire College, UK S A L E S R E P R E S E N T A T I V E",
"start": "2021",
"end": "2022",
"isCurrent": false,
"bullets": ["Managed account handovers"],
"skills": []
}
],
"education": [
{
"qualification": "Warwickshire College",
"institution": "ICT Level 3",
"location": "Warwickshire College, UK S A L E S R E P R E S E N T A T I V E",
"start": "2012",
"end": "2015",
"details": []
}
],
"skills": [],
"languages": [],
"interests": [],
"otherSections": []
}
""");
Assert.Null(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);
Assert.Equal("Warwickshire College, UK", structured.Jobs[2].Location);
Assert.Equal("ICT Level 3", structured.Education[0].Qualification);
Assert.Equal("Warwickshire College", structured.Education[0].Institution);
Assert.Equal("Warwickshire College, UK", structured.Education[0].Location);
}
[Fact]
public async Task Rewrite_section_returns_ai_service_unavailable_detail_when_ai_health_is_unhealthy()
{
var user = new ApplicationUser { Id = "user-1", ProfileCvText = "Professional Summary\nBuilt backend systems." };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), 1800, 400))
.ReturnsAsync(string.Empty);
aiService
.Setup(x => x.GetMetricsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiServiceMetrics(
Healthy: false,
Model: "distilbart",
Device: "cpu",
GpuAvailable: false,
GpuName: null,
OcrAvailable: true,
OcrLanguages: "eng",
OllamaConfigured: true,
OllamaReachable: true,
OllamaModel: "qwen2.5:7b",
OllamaModelAvailable: true,
OllamaVersion: "0.6.0",
OllamaInstalledModels: new List<string> { "qwen2.5:7b" },
OllamaLoadedModels: new List<string>(),
OllamaLoadedCount: 0,
HealthLatencyMs: 21,
ProbeLatencyMs: null,
LastProbeAt: null,
LastProbeSuccessAt: null,
LastProbeFailureAt: null,
ProbeFailures: 1,
Requests: 1,
CacheHits: 0,
CacheMisses: 1,
Failures: 1,
AverageLatencyMs: 21,
OcrRequests: 0,
OcrFailures: 0,
AverageOcrLatencyMs: null,
LastOcrSuccessAt: null,
LastOcrFailureAt: null,
LastSuccessAt: null,
LastFailureAt: DateTimeOffset.UtcNow,
LastError: "Model loading is disabled by AI_SERVICE_SKIP_MODEL_LOAD."));
await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
var result = await controller.RewriteSection(new ProfileCvController.RewriteSectionRequest());
var objectResult = Assert.IsType<ObjectResult>(result);
Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode);
var payload = Assert.IsType<ProfileCvController.CvRewriteFailureDto>(objectResult.Value);
Assert.Equal("ai-service-unavailable", payload.Code);
Assert.Contains("could not rewrite", payload.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("unavailable", payload.Detail ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("AI_SERVICE_SKIP_MODEL_LOAD", payload.LastAiError ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Rewrite_section_returns_rewrite_empty_detail_when_ai_health_is_healthy()
{
var user = new ApplicationUser { Id = "user-1", ProfileCvText = "Professional Summary\nBuilt backend systems." };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), 1800, 400))
.ReturnsAsync(string.Empty);
aiService
.Setup(x => x.GetMetricsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiServiceMetrics(
Healthy: true,
Model: "distilbart",
Device: "cpu",
GpuAvailable: false,
GpuName: null,
OcrAvailable: true,
OcrLanguages: "eng",
OllamaConfigured: true,
OllamaReachable: true,
OllamaModel: "qwen2.5:7b",
OllamaModelAvailable: true,
OllamaVersion: "0.6.0",
OllamaInstalledModels: new List<string> { "qwen2.5:7b" },
OllamaLoadedModels: new List<string>(),
OllamaLoadedCount: 0,
HealthLatencyMs: 21,
ProbeLatencyMs: null,
LastProbeAt: null,
LastProbeSuccessAt: null,
LastProbeFailureAt: null,
ProbeFailures: 0,
Requests: 1,
CacheHits: 0,
CacheMisses: 1,
Failures: 0,
AverageLatencyMs: 21,
OcrRequests: 0,
OcrFailures: 0,
AverageOcrLatencyMs: null,
LastOcrSuccessAt: null,
LastOcrFailureAt: null,
LastSuccessAt: DateTimeOffset.UtcNow,
LastFailureAt: null,
LastError: null));
await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
var result = await controller.RewriteSection(new ProfileCvController.RewriteSectionRequest());
var objectResult = Assert.IsType<ObjectResult>(result);
Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode);
var payload = Assert.IsType<ProfileCvController.CvRewriteFailureDto>(objectResult.Value);
Assert.Equal("rewrite-empty", payload.Code);
Assert.Contains("empty", payload.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("no usable text", payload.Detail ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Rewrite_section_can_target_saved_job_context_and_whole_cv()
{
var user = new ApplicationUser { Id = "user-1", ProfileCvText = "Professional Summary\nBuilt backend systems." };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Harvard template", StringComparison.Ordinal) && instruction.Contains("Senior Backend Engineer", StringComparison.Ordinal)), It.Is<string>(text => text.Contains("Professional Summary", StringComparison.Ordinal)), 1800, 400))
.ReturnsAsync("Professional Summary\nSharper backend platform positioning.");
await using var db = CreateDb();
db.Companies.Add(new Company { Id = 7, Name = "Acme Systems", OwnerUserId = "user-1" });
db.JobApplications.Add(new JobApplication { Id = 42, JobTitle = "Senior Backend Engineer", Description = "Build API integrations and platform workflows.", OwnerUserId = "user-1", CompanyId = 7, Status = "Waiting", DateApplied = DateTime.UtcNow });
await db.SaveChangesAsync();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
var result = await controller.RewriteSection(new ProfileCvController.RewriteSectionRequest
{
Style = "harvard",
JobApplicationId = JsonDocument.Parse("42").RootElement.Clone(),
TemplateId = "harvard",
});
var ok = Assert.IsType<OkObjectResult>(result);
var json = JsonSerializer.Serialize(ok.Value);
Assert.Contains("Sharper backend platform positioning", json);
Assert.Contains("harvard", json, StringComparison.OrdinalIgnoreCase);
Assert.Contains("42", json, StringComparison.OrdinalIgnoreCase);
}
[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<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(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<OkObjectResult>(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<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(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<OkObjectResult>(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<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), source, 3200, 900))
.ReturnsAsync("not-json");
var classifier = new Mock<ICvAiClassifier>();
classifier
.Setup(x => x.ClassifyBlockAsync(It.Is<string>(block => block.Contains("Atlas Systems", StringComparison.Ordinal)), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CvBlockClassificationResult("Work Experience", 0.93, "job block", "Senior Platform Engineer", "Atlas Systems", "Oslo", "2019", "Present", new List<string> { "Built event-driven APIs and migration tooling." }, null, new List<string> { "Python", "SQL" }));
classifier
.Setup(x => x.ClassifyBlockAsync(It.Is<string>(block => block.Contains("Python", StringComparison.Ordinal)), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CvBlockClassificationResult("Skills", 0.88, "skills block", null, null, null, null, null, new List<string>(), null, new List<string> { "Python", "SQL", "Azure" }));
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<OkObjectResult>(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);
Assert.Equal("classifier", structured.Metadata.Fields["jobs[0].title"].Method);
Assert.Equal("block-1", structured.Metadata.Fields["jobs[0].title"].SourceBlockId);
classifier.Verify(x => x.ClassifyBlockAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.AtLeastOnce());
}
[Fact]
public async Task Parse_uses_classifier_fallback_for_education_blocks_without_real_sections()
{
var source = "BSc Computer Science\nUniversity of Oslo\nOslo\n2016 - 2019\nGraduated with focus on distributed systems.";
var user = new ApplicationUser { Id = "user-1", ProfileCvText = source };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), source, 3200, 900))
.ReturnsAsync("not-json");
var classifier = new Mock<ICvAiClassifier>();
classifier
.Setup(x => x.ClassifyBlockAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CvBlockClassificationResult("Education", 0.87, "education block", "BSc Computer Science", "University of Oslo", "Oslo", "2016", "2019", new List<string> { "Graduated with focus on distributed systems." }, null, 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<OkObjectResult>(result.Result);
var json = JsonSerializer.Serialize(ok.Value);
Assert.Contains("BSc Computer Science", json);
Assert.Contains("University of Oslo", json);
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Single(structured.Education);
Assert.Equal("BSc Computer Science", structured.Education[0].Qualification);
Assert.Equal("University of Oslo", structured.Education[0].Institution);
Assert.Equal("classifier", structured.Metadata.Fields["education[0].qualification"].Method);
}
[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<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), source, 3200, 900))
.ReturnsAsync("not-json");
var classifier = new Mock<ICvAiClassifier>();
classifier
.Setup(x => x.ClassifyBlockAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.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<OkObjectResult>(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<string>(), It.IsAny<CancellationToken>()), 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<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.ExtractTextAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.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<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 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<OkObjectResult>(result);
Assert.Contains("Built APIs", user.ProfileCvText);
Assert.Equal("Connor Babbington", StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson).Contact.FullName);
}
[Fact]
public void Normalized_markdown_parse_preserves_real_estate_job_and_language_levels()
{
var normalized = "# Contact\nAvery Cooper\n(415) 223-4344\nhttps://www.linkedin.com/in/avery-cooper/\nhttps://www.realtor.com/realestateagents/avery-copper/\nSan Francisco\n\n# Professional Summary\nDynamic real estate professional with 12 years of experience in residential and commercial property.\n\n# Work Experience\nReal Estate Agent\nEleanor Lane Agency, White Plains\nJuly 2017 - Present\n- Managed all aspects of the sales process from preparation to close, achieving a 25% increase in closed deals compared to previous periods.\n- Successfully negotiated favorable terms for clients in over 50 real estate transactions, consistently securing above-market value.\n\nReal Estate Assistant\nHathaway Properties, New Rochelle\nOctober 2012 - June 2017\n- Managed administrative tasks in a fast-paced real estate office, ensuring smooth daily operations.\n- Supported Realtors and Brokers by coordinating marketing materials, client communications, and office transactions.\n\n# Skills\n- Contract Management\n- Retail Market Analysis\n- Property Valuation\n- Client Relationship Management\n- Digital Marketing\n- Attention to Detail\n\n# Languages\n- English (Native)\n- Spanish - C2";
var actual = ParseNormalizedMarkdown(normalized);
Assert.Equal("Avery Cooper", actual.Contact.FullName);
Assert.Equal("San Francisco", actual.Contact.Location);
Assert.NotEmpty(actual.Jobs);
Assert.Equal("Real Estate Agent", actual.Jobs[0].Title);
Assert.Equal("Eleanor Lane Agency, White Plains", actual.Jobs[0].Company);
Assert.True(actual.Jobs[0].Bullets.Count >= 2);
Assert.Contains("Contract Management", actual.Skills);
Assert.Contains(actual.Languages, item => string.Equals(item.Name, "Spanish", StringComparison.OrdinalIgnoreCase) && string.Equals(item.Level, "C2", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Normalized_markdown_parse_preserves_web_developer_bullets_and_skills()
{
var normalized = "# Contact\nChristoper Morgan\nchristoper.m@gmail.com\n+44 (0)20 7666 8555\n\n# Professional Summary\nSenior Web Developer specializing in front end development. Experienced with all stages of the development cycle for dynamic web projects.\n\n# Work Experience\nWeb Developer\nLuna Web Design, New York\n09/2015 - 05/2019\n- Cooperate with designers to create clean interfaces and simple, intuitive interactions and experiences.\n- Develop project concepts and maintain optimal workflow.\n- Work with senior developer to manage large, complex design projects for corporate clients.\n- Complete detailed programming and development tasks for front end public and internal websites as well as challenging back-end server code.\n- Carry out quality assurance tests to discover errors and optimize usability.\n\n# Skills\n- JavaScript\n- HTML5\n- PHP OOP\n- CSS\n- SQL\n- MySQL\n\n# Languages\n- Spanish - C2\n- Chinese - A1\n- German - A2";
var actual = ParseNormalizedMarkdown(normalized);
Assert.Equal("Christoper Morgan", actual.Contact.FullName);
Assert.NotEmpty(actual.Jobs);
Assert.Equal("Web Developer", actual.Jobs[0].Title);
Assert.Equal("Luna Web Design, New York", actual.Jobs[0].Company);
Assert.True(actual.Jobs[0].Bullets.Count >= 5);
Assert.Contains("JavaScript", actual.Skills);
Assert.Contains("MySQL", actual.Skills);
Assert.Contains(actual.Languages, item => string.Equals(item.Name, "Chinese", StringComparison.OrdinalIgnoreCase) && string.Equals(item.Level, "A1", StringComparison.OrdinalIgnoreCase));
Assert.Contains(actual.Languages, item => string.Equals(item.Name, "German", StringComparison.OrdinalIgnoreCase) && string.Equals(item.Level, "A2", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task Parse_uses_forced_ai_normalizer_output_when_enabled()
{
var previous = Environment.GetEnvironmentVariable("CV_FORCE_AI_NORMALIZER");
Environment.SetEnvironmentVariable("CV_FORCE_AI_NORMALIZER", "true");
try
{
var source = "Avery CooperReal Estate Agent\nSan Francisco(415) 223-4344\nDynamic real estate professional with 12 years of experience in residential and commercial property.";
var user = new ApplicationUser { Id = "user-1", ProfileCvText = source };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900))
.ReturnsAsync("not-json");
var normalizer = new Mock<ICvAiNormalizer>();
normalizer
.Setup(x => x.NormalizeAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CvNormalizationResult(
0.88,
"forced test",
"# Contact\nAvery Cooper\n(415) 223-4344\nhttps://www.linkedin.com/in/avery-cooper/\nhttps://www.realtor.com/realestateagents/avery-copper/\nSan Francisco\n\n# Professional Summary\nDynamic real estate professional with 12 years of experience in residential and commercial property.\n\n# Work Experience\nReal Estate Agent\nEleanor Lane Agency, White Plains\nJuly 2017 - Present\n- Managed all aspects of the sales process from preparation to close.\n\n# Skills\n- Contract Management\n- Property Valuation\n\n# Languages\n- English (Native)\n- Spanish - C2"));
await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths(), null, normalizer.Object);
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
Assert.IsType<OkObjectResult>(result.Result);
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Equal("Avery Cooper", actual.Contact.FullName);
Assert.Equal("San Francisco", actual.Contact.Location);
Assert.NotEmpty(actual.Jobs);
Assert.Contains("Real Estate Agent", actual.Jobs[0].Title ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Contract Management", actual.Skills);
Assert.Contains(actual.Languages, item => string.Equals(item.Name, "Spanish", StringComparison.OrdinalIgnoreCase) && string.Equals(item.Level, "C2", StringComparison.OrdinalIgnoreCase));
}
finally
{
Environment.SetEnvironmentVariable("CV_FORCE_AI_NORMALIZER", previous);
}
}
[Fact]
public async Task Approved_fixture_regression_for_cv_txt_keeps_core_fields_stable()
{
var approvedPath = "/home/pi/cvs/approved-jsons/cv-txt.json";
var rawPath = "/home/pi/cvs/cv.txt";
if (!System.IO.File.Exists(approvedPath) || !System.IO.File.Exists(rawPath)) return;
var approved = StructuredCvProfileJson.Deserialize(await System.IO.File.ReadAllTextAsync(approvedPath));
var rawSource = await System.IO.File.ReadAllTextAsync(rawPath);
var user = new ApplicationUser { Id = "user-1", ProfileCvText = rawSource };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 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(rawSource));
var ok = Assert.IsType<OkObjectResult>(result.Result);
Assert.NotNull(ok.Value);
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Equal(approved.Contact.FullName, actual.Contact.FullName);
Assert.Equal(approved.Contact.Location, actual.Contact.Location);
Assert.True(actual.Skills.Count >= 2);
}
[Fact]
public async Task Approved_fixture_regression_for_new_resume_docx_keeps_contact_and_role_core_fields_stable()
{
var approvedPath = "/home/pi/cvs/approved-jsons/new-resume-001-docx.json";
if (!System.IO.File.Exists(approvedPath)) return;
var approved = StructuredCvProfileJson.Deserialize(await System.IO.File.ReadAllTextAsync(approvedPath));
var source = "Christoper Morgan\nPhone: +49 800 600 600\nE-Mail: christoper.morgan@gmail.com\nLinkedin: linkedin.com/christopher.morgan\n\nSkill Highlights\nProject management\nStrong decision maker\nComplex problem solver\nCreative design\nInnovative\nService-focused\n\n09/2015 to 05/2019\nWeb Developer\nLuna Web Design, New York\nCooperate with designers to create clean interfaces and simple, intuitive interactions and experiences.\nDevelop project concepts and maintain optimal workflow.\nWork with senior developer to manage large, complex design projects for corporate clients.\nComplete detailed programming and development tasks for front end public and internal websites as well as challenging back-end server code.\nCarry out quality assurance tests to discover errors and optimize usability.\n\n2014 to 2019\nBachelor Of Science: Computer Information Systems\nColumbia University, NY\n\nLanguages\nSpanish C2\nChinese C2\n\nSkills\nJavaScript\nSQL";
var user = new ApplicationUser { Id = "user-1", ProfileCvText = source };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900))
.ReturnsAsync("not-json");
await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
Assert.IsType<OkObjectResult>(result.Result);
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Equal(approved.Contact.FullName, actual.Contact.FullName);
Assert.Equal(approved.Contact.Email, actual.Contact.Email);
Assert.NotEmpty(actual.Jobs);
Assert.Contains("Web Developer", actual.Jobs[0].Title ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("JavaScript", actual.Skills);
Assert.Contains("SQL", actual.Skills);
}
[Fact]
public async Task Approved_fixture_regression_for_coolfreecv_resume_keeps_summary_and_bullets_stable()
{
var approvedPath = "/home/pi/cvs/approved-jsons/coolfreecv-resume-en-03-n-docx.json";
if (!System.IO.File.Exists(approvedPath)) return;
var approved = StructuredCvProfileJson.Deserialize(await System.IO.File.ReadAllTextAsync(approvedPath));
var source = "Christoper Morgan\nchristoper.m@gmail.com\n+44 (0)20 7666 8555\n\nSenior Web Developer specializing in front end development. Experienced with all stages of the development cycle for dynamic web projects. Well-versed in numerous programming languages including HTML5, PHP OOP, JavaScript, CSS, MySQL. Strong background in project management and customer relations.\n\nWeb Developer - 09/2015 to 05/2019\nLuna Web Design, New York\nCooperate with designers to create clean interfaces and simple, intuitive interactions and experiences.\nDevelop project concepts and maintain optimal workflow.\nWork with senior developer to manage large, complex design projects for corporate clients.\nComplete detailed programming and development tasks for front end public and internal websites as well as challenging back-end server code.\nCarry out quality assurance tests to discover errors and optimize usability.\n\nBachelor Of Science: Computer Information Systems - 2014\nColumbia University, NY\n\nSkills\nJavaScript, HTML5, PHP OOP, CSS, SQL, MySQL\nProject management\nStrong decision maker\nComplex problem solver\nCreative design\nInnovative\nService-focused\n\nLanguages\nSpanish C2\nChinese A1\nGerman A2";
var user = new ApplicationUser { Id = "user-1", ProfileCvText = source };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900))
.ReturnsAsync("not-json");
await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
Assert.IsType<OkObjectResult>(result.Result);
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Equal(approved.Contact.FullName, actual.Contact.FullName);
Assert.Equal(approved.Contact.Email, actual.Contact.Email);
Assert.NotEmpty(actual.Summary);
Assert.Contains("Senior Web Developer", actual.Summary[0], StringComparison.OrdinalIgnoreCase);
Assert.NotEmpty(actual.Jobs);
Assert.Contains("Web Developer", actual.Jobs[0].Title ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("JavaScript", actual.Skills);
Assert.Contains("MySQL", actual.Skills);
}
[Fact]
public async Task Deterministic_parse_handles_flat_resume_contact_and_first_job()
{
var source = "Christoper Morgan\nchristoper.m@gmail.com\n+44 (0)20 7666 8555\nSenior Web Developer specializing in front end development. Experienced with all stages of the development cycle for dynamic web projects.\n\nWeb Developer - 09/2015 to 05/2019\nLuna Web Design, New York\nCooperate with designers to create clean interfaces and simple, intuitive interactions and experiences.\nDevelop project concepts and maintain optimal workflow.\nWork with senior developer to manage large, complex design projects for corporate clients.\nComplete detailed programming and development tasks for front end public and internal websites as well as challenging back-end server code.\nCarry out quality assurance tests to discover errors and optimize usability.\n\nSkills\nJavaScript, HTML5, PHP OOP, CSS, SQL, MySQL";
var user = new ApplicationUser { Id = "user-1", ProfileCvText = source };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900))
.ReturnsAsync("not-json");
await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
Assert.IsType<OkObjectResult>(result.Result);
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Equal("Christoper Morgan", actual.Contact.FullName);
Assert.Equal("christoper.m@gmail.com", actual.Contact.Email);
Assert.Equal("+44 (0)20 7666 8555", actual.Contact.Phone);
Assert.NotEmpty(actual.Jobs);
Assert.Contains("Web Developer", actual.Jobs[0].Title ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("JavaScript", actual.Skills);
Assert.Contains("SQL", actual.Skills);
}
[Fact]
public async Task Deterministic_parse_handles_real_estate_contact_summary_and_jobs()
{
var source = "Avery Cooper Real Estate Agent\n(415) 223-4344\nSan Francisco\nhttps://www.linkedin.com/in/avery-cooper\nhttps://www.realtor.com/realestateagents/avery-copper/\n\nDynamic real estate professional with 12 years of experience in residential and commercial property. Proven track record in developing strong client relationships, closing over 50 successful deals, and providing exceptional real estate experiences.\n\nReal Estate Agent at Eleanor Lane Agency\nWhite Plains\n2017 - Present\nManaged all aspects of the sales process from preparation to close, achieving a 25% increase in closed deals compared to previous periods.\nSuccessfully negotiated favorable terms for clients in over 50 real estate transactions, consistently securing above-market value.\n\nReal Estate Assistant at Hathaway Properties\nNew Rochelle\n2012 - 2017\nManaged administrative tasks in a fast-paced real estate office, ensuring smooth daily operations.\nSupported Realtors and Brokers by coordinating marketing materials, client communications, and office transactions.\n\nSkills\nContract Management, Retail Market Analysis, Property Valuation, Client Relationship Management, Digital Marketing, Attention to Detail";
var user = new ApplicationUser { Id = "user-1", ProfileCvText = source };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900))
.ReturnsAsync("not-json");
await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
Assert.IsType<OkObjectResult>(result.Result);
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Equal("Avery Cooper", actual.Contact.FullName);
Assert.Equal("San Francisco", actual.Contact.Location);
Assert.Contains("realtor.com", actual.Contact.Website ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.NotEmpty(actual.Summary);
Assert.True(actual.Jobs.Count >= 2);
Assert.Contains("Contract Management", actual.Skills);
Assert.Contains("Attention to Detail", actual.Skills);
}
private static StructuredCvProfile ParseNormalizedMarkdown(string normalized)
{
var method = typeof(ProfileCvController).GetMethod("BuildStructuredCvFromNormalizedMarkdown", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert.NotNull(method);
var result = method!.Invoke(null, new object[] { normalized });
Assert.NotNull(result);
return StructuredCvProfileJson.Normalize((StructuredCvProfile)result!);
}
private static ProfileCvController CreateController(UserManager<ApplicationUser> userManager, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null)
{
return new ProfileCvController(userManager, aiService, db, paths, null, cvAiClassifier ?? NoOpCvAiClassifier.Instance, cvAiNormalizer ?? NoOpCvAiNormalizer.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<string, string?>
{
["Data:Root"] = tempRoot,
["Data:CvArtifactsRoot"] = Path.Combine(tempRoot, "CvArtifacts")
})
.Build();
var env = new Mock<IHostEnvironment>();
env.SetupGet(x => x.ContentRootPath).Returns(tempRoot);
return new AppPaths(config, env.Object);
}
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
{
return TestHostFactory.CreateUserManager();
}
}