508 lines
21 KiB
C#
508 lines
21 KiB
C#
using System.Security.Claims;
|
|
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.Logging.Abstractions;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace JobTrackerApi.Tests;
|
|
|
|
public sealed class JobApplicationsApplicationPackageTests
|
|
{
|
|
[Fact]
|
|
public async Task Save_application_drafts_replaces_notes_instead_of_appending()
|
|
{
|
|
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",
|
|
Notes = "Old notes"
|
|
};
|
|
db.JobApplications.Add(job);
|
|
await db.SaveChangesAsync();
|
|
|
|
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1");
|
|
var result = await controller.SaveApplicationDrafts(job.Id, new JobApplicationsController.SaveApplicationDraftsRequest(null, "Updated notes block", null), CancellationToken.None);
|
|
|
|
Assert.IsType<NoContentResult>(result);
|
|
var saved = await db.JobApplications.FirstAsync();
|
|
Assert.Equal("Updated notes block", saved.Notes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Generate_application_package_uses_imported_correspondence_and_recruiter_context()
|
|
{
|
|
await using var db = CreateDb();
|
|
var company = new Company
|
|
{
|
|
Name = "Acme",
|
|
RecruiterName = "Maria Recruiter",
|
|
RecruiterEmail = "maria@acme.test",
|
|
OwnerUserId = "user-1"
|
|
};
|
|
db.Companies.Add(company);
|
|
db.Users.Add(new ApplicationUser
|
|
{
|
|
Id = "user-1",
|
|
UserName = "user@example.test",
|
|
Email = "user@example.test",
|
|
ProfileCvText = "Built .NET APIs and led backend delivery.",
|
|
ProfileCvStructureJson = "[]"
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var job = new JobApplication
|
|
{
|
|
JobTitle = "Backend Developer",
|
|
CompanyId = company.Id,
|
|
OwnerUserId = "user-1",
|
|
Description = "Need .NET, APIs, and async collaboration with recruiters.",
|
|
Notes = "Priority role",
|
|
ShortSummary = "Acme backend hiring"
|
|
};
|
|
db.JobApplications.Add(job);
|
|
await db.SaveChangesAsync();
|
|
|
|
db.Correspondences.Add(new Correspondence
|
|
{
|
|
JobApplicationId = job.Id,
|
|
From = "Company",
|
|
Subject = "Backend Developer interview",
|
|
ExternalThreadId = "thread-1",
|
|
ExternalFrom = "Maria Recruiter <maria@acme.test>",
|
|
ExternalTo = "user@example.test",
|
|
Content = "We want someone who can own .NET APIs and communicate clearly with stakeholders.",
|
|
Date = DateTime.UtcNow.AddDays(-1)
|
|
});
|
|
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 context, int _, int __) =>
|
|
{
|
|
if (instruction.Contains("List up to 4 concrete application-package signals", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return "Use recruiter language about owning .NET APIs.\nMention the interview timeline from imported correspondence.";
|
|
}
|
|
|
|
if (instruction.Contains("cover letter", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return context.Contains("Imported correspondence context:", StringComparison.OrdinalIgnoreCase)
|
|
&& context.Contains("Maria Recruiter", StringComparison.OrdinalIgnoreCase)
|
|
? "Cover letter tailored with recruiter context and imported correspondence."
|
|
: "Generic cover letter.";
|
|
}
|
|
|
|
if (instruction.Contains("recruiter intro message", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return context.Contains("Recruiter email: maria@acme.test", StringComparison.OrdinalIgnoreCase)
|
|
? "Recruiter message that uses Maria's context."
|
|
: "Generic recruiter message.";
|
|
}
|
|
|
|
if (instruction.Contains("application answer", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return "Application answer grounded in the imported thread.";
|
|
}
|
|
|
|
if (instruction.Contains("Rewrite the candidate CV", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return "Tailored CV that highlights .NET API ownership for Acme.";
|
|
}
|
|
|
|
return "Variant draft";
|
|
});
|
|
|
|
var controller = CreateController(db, summarizer.Object, "user-1");
|
|
var result = await controller.GenerateApplicationPackage(job.Id, null, null, null, CancellationToken.None);
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
|
var payload = Assert.IsType<JobApplicationsController.GenerateApplicationPackageDto>(ok.Value);
|
|
|
|
Assert.Contains("Tailored CV", payload.TailoredCvText);
|
|
Assert.Equal("Cover letter tailored with recruiter context and imported correspondence.", payload.CoverLetterDraft);
|
|
Assert.Equal("Recruiter message that uses Maria's context.", payload.RecruiterMessageDraft);
|
|
Assert.Contains(payload.KeyPoints, item => item.Contains("interview timeline", StringComparison.OrdinalIgnoreCase));
|
|
Assert.Contains(payload.KeyPoints, item => item.Contains("recruiter language", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Generate_application_package_passes_typed_structured_cv_context_to_summarizer()
|
|
{
|
|
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",
|
|
ProfileCvText = "Built APIs and shipped backend work.",
|
|
ProfileCvStructureJson = """
|
|
{
|
|
"version": "1",
|
|
"contact": {
|
|
"fullName": "Demo User",
|
|
"headline": "Backend Developer",
|
|
"email": "user@example.test",
|
|
"location": "Oslo"
|
|
},
|
|
"summary": ["Backend-focused developer with strong API delivery experience."],
|
|
"jobs": [
|
|
{
|
|
"title": "System Developer",
|
|
"company": "Acme Consulting",
|
|
"location": "Oslo",
|
|
"start": "2020",
|
|
"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();
|
|
|
|
string? capturedContext = null;
|
|
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 context, int _, int __) =>
|
|
{
|
|
if (instruction.Contains("Rewrite the candidate CV", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
capturedContext = context;
|
|
return "Tailored CV";
|
|
}
|
|
|
|
if (instruction.Contains("List up to 4 concrete application-package signals", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return "Lead with .NET API ownership.";
|
|
}
|
|
|
|
return "Draft";
|
|
});
|
|
|
|
var controller = CreateController(db, summarizer.Object, "user-1");
|
|
var result = await controller.GenerateApplicationPackage(job.Id, null, null, null, CancellationToken.None);
|
|
|
|
Assert.IsType<OkObjectResult>(result.Result);
|
|
Assert.NotNull(capturedContext);
|
|
Assert.Contains("Structured CV:", capturedContext);
|
|
Assert.Contains("Name: Demo User", capturedContext);
|
|
Assert.Contains("Skills:\n.NET, SQL, APIs", capturedContext);
|
|
Assert.Contains("Work Experience:", capturedContext);
|
|
Assert.Contains("Owned .NET API delivery across multiple services.", capturedContext);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Get_tailored_cv_draft_returns_legacy_fallback_when_no_structured_draft_exists()
|
|
{
|
|
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
|
|
{
|
|
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(ClaimTypes.NameIdentifier, userId)
|
|
}, "test"))
|
|
}
|
|
};
|
|
return controller;
|
|
}
|
|
|
|
private static JobTrackerContext CreateDb()
|
|
{
|
|
return TestHostFactory.CreateInMemoryDb();
|
|
}
|
|
|
|
private static Mock<UserManager<ApplicationUser>> CreateUserManager(ApplicationUser? user = null)
|
|
{
|
|
return TestHostFactory.CreateUserManager(user);
|
|
}
|
|
|
|
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 }));
|
|
}
|
|
}
|
|
}
|