Add CV template preview and PDF export pipeline

This commit is contained in:
2026-03-29 00:43:54 +01:00
parent 2392b135c2
commit 839a2ed80d
15 changed files with 2288 additions and 97 deletions
@@ -234,9 +234,232 @@ public sealed class JobApplicationsApplicationPackageTests
Assert.Contains("Owned .NET API delivery across multiple services.", capturedContext);
}
private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId)
[Fact]
public async Task Get_tailored_cv_draft_returns_legacy_fallback_when_no_structured_draft_exists()
{
var controller = new JobApplicationsController(db, summarizer, Mock.Of<IAppEmailSender>(), CreateUserManager().Object, NullLogger<JobApplicationsController>.Instance);
await using var db = CreateDb();
var company = new Company
{
Name = "Acme",
OwnerUserId = "user-1"
};
db.Companies.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication
{
JobTitle = "Backend Developer",
CompanyId = company.Id,
OwnerUserId = "user-1",
TailoredCvText = "Existing tailored CV text",
TailoredCvUpdatedAt = DateTime.UtcNow
};
db.JobApplications.Add(job);
await db.SaveChangesAsync();
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1");
var result = await controller.GetTailoredCvDraft(job.Id, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<JobApplicationsController.TailoredCvDraftDto>(ok.Value);
Assert.True(payload.IsLegacyFallback);
Assert.Equal("legacy-text", payload.TemplateId);
Assert.Contains("Existing tailored CV text", payload.RenderedText);
}
[Fact]
public async Task Generate_and_save_tailored_cv_draft_persists_job_scoped_document_without_mutating_master_cv()
{
await using var db = CreateDb();
var company = new Company
{
Name = "Acme",
OwnerUserId = "user-1"
};
db.Companies.Add(company);
db.Users.Add(new ApplicationUser
{
Id = "user-1",
UserName = "user@example.test",
Email = "user@example.test",
CurrentCvProfileVersion = 7,
ProfileCvText = "Built APIs and owned backend delivery.",
ProfileCvStructureJson = """
{
"version": "1",
"contact": {
"fullName": "Demo User",
"headline": "Backend Developer"
},
"summary": ["Backend-focused developer with API delivery experience."],
"jobs": [
{
"title": "System Developer",
"company": "Acme Consulting",
"location": "Oslo",
"start": "2021",
"end": "2024",
"isCurrent": false,
"bullets": ["Owned .NET API delivery across multiple services."],
"skills": [".NET", "SQL", "APIs"]
}
],
"education": [],
"skills": [".NET", "SQL", "APIs"],
"languages": [{ "name": "English", "level": "Native" }],
"interests": [],
"otherSections": []
}
"""
});
await db.SaveChangesAsync();
var job = new JobApplication
{
JobTitle = "Backend Developer",
CompanyId = company.Id,
OwnerUserId = "user-1",
Description = "Need .NET API ownership and strong SQL skills."
};
db.JobApplications.Add(job);
await db.SaveChangesAsync();
var summarizer = new Mock<ISummarizerService>();
summarizer
.Setup(service => service.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
.ReturnsAsync((string instruction, string _, int __, int ___) =>
{
if (instruction.Contains("headline", StringComparison.OrdinalIgnoreCase)) return "Senior Backend Engineer";
if (instruction.Contains("summary bullets", StringComparison.OrdinalIgnoreCase)) return "Led backend API delivery.\nImproved SQL-backed workflows.";
return "Draft";
});
var controller = CreateController(db, summarizer.Object, "user-1");
var generateResult = await controller.GenerateTailoredCvDraft(job.Id, "ats", CancellationToken.None);
var generateOk = Assert.IsType<OkObjectResult>(generateResult.Result);
var generated = Assert.IsType<JobApplicationsController.TailoredCvDraftDto>(generateOk.Value);
Assert.False(generated.IsLegacyFallback);
Assert.Equal(7, generated.CanonicalProfileVersion);
Assert.Equal("Senior Backend Engineer", generated.Headline);
Assert.Contains("Led backend API delivery.", generated.RenderedText);
var saveResult = await controller.SaveTailoredCvDraft(job.Id, new JobApplicationsController.SaveTailoredCvDraftRequest(
generated.TemplateId,
"Principal Backend Engineer",
new List<string> { "Own backend delivery for critical APIs." },
new List<string> { ".NET", "SQL" },
generated.Experience,
generated.Education,
generated.CustomSections,
generated.RenderOptions,
"edited"), CancellationToken.None);
Assert.IsType<NoContentResult>(saveResult);
var savedDraft = await db.TailoredCvDrafts.SingleAsync();
var savedJob = await db.JobApplications.SingleAsync();
var savedUser = await db.Users.SingleAsync();
Assert.Equal("edited", savedDraft.Status);
Assert.Equal(7, savedDraft.CanonicalProfileVersion);
Assert.Contains("Principal Backend Engineer", savedJob.TailoredCvText);
Assert.Equal("Built APIs and owned backend delivery.", savedUser.ProfileCvText);
}
[Fact]
public async Task Preview_and_export_tailored_cv_use_same_renderer_contract_and_profile_avatar_default()
{
await using var db = CreateDb();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
db.Users.Add(new ApplicationUser
{
Id = "user-1",
UserName = "user@example.test",
Email = "user@example.test",
AvatarImageDataUrl = "data:image/png;base64,abc123"
});
await db.SaveChangesAsync();
var job = new JobApplication
{
JobTitle = "Backend Developer",
CompanyId = company.Id,
OwnerUserId = "user-1",
TailoredCvText = "Saved tailored CV"
};
db.JobApplications.Add(job);
await db.SaveChangesAsync();
var renderer = new TestCvTemplateRenderer();
var exporter = new TestCvPdfExporter();
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1", renderer, exporter);
var request = new JobApplicationsController.TailoredCvRenderRequest(
"ats-minimal",
"Backend Engineer",
new List<string> { "Built APIs" },
new List<string> { ".NET" },
new List<TailoredCvExperienceItem>(),
new List<TailoredCvEducationItem>(),
new List<TailoredCvCustomSection>(),
new TailoredCvRenderOptions { ShowPhoto = true, AccentColor = "#123456" },
null,
true);
var previewResult = await controller.PreviewTailoredCv(job.Id, request, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(previewResult.Result);
var preview = Assert.IsType<JobApplicationsController.TailoredCvPreviewDto>(ok.Value);
Assert.Equal("ats-minimal", preview.TemplateId);
Assert.Equal("preview.pdf", preview.SuggestedFileName);
Assert.Equal("data:image/png;base64,abc123", renderer.LastPhotoDataUrl);
var exportResult = await controller.ExportTailoredCvPdf(job.Id, request, CancellationToken.None);
var file = Assert.IsType<FileContentResult>(exportResult);
Assert.Equal("application/pdf", file.ContentType);
Assert.Equal("preview.pdf", file.FileDownloadName);
Assert.NotNull(exporter.LastRenderResult);
Assert.Equal(preview.Html, exporter.LastRenderResult!.Html);
}
[Fact]
public void Template_renderer_supports_named_variants()
{
var renderer = new CvTemplateRenderer();
var document = TailoredCvDraftJson.Normalize(new TailoredCvDocument
{
TemplateId = "harvard",
Headline = "Product Manager",
Summary = new List<string> { "Built and shipped product roadmaps." },
SelectedSkills = new List<string> { "Strategy", "Stakeholder management" },
Experience = new List<TailoredCvExperienceItem>
{
new() { Title = "Product Manager", Company = "Acme", Start = "2022", End = "2025", Bullets = new List<string> { "Launched new product line." } }
}
});
var harvard = renderer.Render(document, "harvard", "Andrew O'Sullivan", "Product Manager", "Acme", null);
var auckland = renderer.Render(document, "auckland", "Andrew O'Sullivan", "Product Manager", "Acme", "data:image/png;base64,abc");
var edinburgh = renderer.Render(document, "edinburgh", "Andrew O'Sullivan", "Product Manager", "Acme", "data:image/png;base64,abc");
Assert.Equal("harvard", harvard.TemplateId);
Assert.Contains("Template: ATS Minimal", renderer.Render(document, "ats-minimal", "Andrew O'Sullivan", "Product Manager", "Acme", null).Html);
Assert.Contains("Andrew O&#39;Sullivan", harvard.Html);
Assert.Contains("sidebar", auckland.Html, StringComparison.OrdinalIgnoreCase);
Assert.Contains("curved", edinburgh.Html, StringComparison.OrdinalIgnoreCase);
}
private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId, ICvTemplateRenderer? renderer = null, ICvPdfExporter? exporter = null)
{
var user = db.Users.AsNoTracking().FirstOrDefault(x => x.Id == userId);
var controller = new JobApplicationsController(
db,
summarizer,
Mock.Of<IAppEmailSender>(),
CreateUserManager(user).Object,
NullLogger<JobApplicationsController>.Instance,
renderer ?? new TestCvTemplateRenderer(),
exporter ?? new TestCvPdfExporter());
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
@@ -260,10 +483,10 @@ public sealed class JobApplicationsApplicationPackageTests
return new JobTrackerContext(options, currentUser.Object);
}
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
private static Mock<UserManager<ApplicationUser>> CreateUserManager(ApplicationUser? user = null)
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
var manager = new Mock<UserManager<ApplicationUser>>(
store.Object,
Options.Create(new IdentityOptions()),
new PasswordHasher<ApplicationUser>(),
@@ -273,5 +496,29 @@ public sealed class JobApplicationsApplicationPackageTests
new IdentityErrorDescriber(),
null!,
new NullLogger<UserManager<ApplicationUser>>());
manager.Setup(x => x.FindByIdAsync(It.IsAny<string>())).ReturnsAsync((string id) => user is not null && user.Id == id ? user : null);
return manager;
}
private sealed class TestCvTemplateRenderer : ICvTemplateRenderer
{
public string? LastPhotoDataUrl { get; private set; }
public TailoredCvRenderResult Render(TailoredCvDocument? document, string? templateId, string candidateName, string jobTitle, string? companyName, string? photoDataUrl = null)
{
LastPhotoDataUrl = photoDataUrl;
return new TailoredCvRenderResult(templateId ?? "ats-minimal", "preview.pdf", $"<html><body>{candidateName}|{jobTitle}|{companyName}|{document?.Headline}|{photoDataUrl}</body></html>");
}
}
private sealed class TestCvPdfExporter : ICvPdfExporter
{
public TailoredCvRenderResult? LastRenderResult { get; private set; }
public Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
{
LastRenderResult = renderResult;
return Task.FromResult(new CvPdfArtifact("preview.pdf", "/tmp/preview.pdf", new byte[] { 1, 2, 3 }));
}
}
}