Add CV template preview and PDF export pipeline
This commit is contained in:
@@ -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'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 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user