753 lines
36 KiB
C#
753 lines
36 KiB
C#
using System.Security.Claims;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using JobTrackerApi.Controllers;
|
|
using JobTrackerApi.Data;
|
|
using JobTrackerApi.Models;
|
|
using JobTrackerApi.Services;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
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.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();
|
|
|
|
Assert.IsType<OkObjectResult>(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<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_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": "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<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." }));
|
|
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>()));
|
|
|
|
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);
|
|
classifier.Verify(x => x.ClassifyBlockAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), 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<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);
|
|
}
|
|
|
|
private static ProfileCvController CreateController(UserManager<ApplicationUser> 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")
|
|
{
|
|
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
var currentUser = new Mock<ICurrentUserService>();
|
|
currentUser.SetupGet(x => x.UserId).Returns(userId);
|
|
return new JobTrackerContext(options, currentUser.Object);
|
|
}
|
|
|
|
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()
|
|
{
|
|
var store = new Mock<IUserStore<ApplicationUser>>();
|
|
return new Mock<UserManager<ApplicationUser>>(
|
|
store.Object,
|
|
Options.Create(new IdentityOptions()),
|
|
new PasswordHasher<ApplicationUser>(),
|
|
Array.Empty<IUserValidator<ApplicationUser>>(),
|
|
Array.Empty<IPasswordValidator<ApplicationUser>>(),
|
|
new UpperInvariantLookupNormalizer(),
|
|
new IdentityErrorDescriber(),
|
|
null!,
|
|
new NullLogger<UserManager<ApplicationUser>>()
|
|
);
|
|
}
|
|
}
|