feat: introduce CV variant schema, dual-written from tailored CV saves
Phase F2 of the Career Workspace roadmap: CvVariant, CvVersion, and TailoredApplication -- the reference seam. Per the product boundary, a job application REFERENCES a tailored output; it does not own it. CvVariant is not job-owned: it survives job deletion (SetNull on its optional CareerProfile link, not cascaded), can be reused across applications, and carries its own append-only CvVersion history. TailoredApplication is the join that links a variant to a job (cascades with either side, since the link is meaningless without both). Rather than shipping empty tables with no consumer, this dual-writes from both existing TailoredCvDraft save paths (SaveTailoredCvDraft, UpsertGeneratedTailoredCvDraftAsync via GenerateTailoredCvDraft) -- same pattern as CareerProfile in Phase F1. TailoredCvDraft remains authoritative for every existing read path; the sync is additive and never blocks or fails a draft save. 2 new tests: variant/version/link created on first save, same variant reused (not duplicated) with version incrementing on subsequent saves. Verified against the real dev DB -- FK dependency ordering (CareerProfiles -> CvVariants -> CvVersions/TailoredApplications) holds in both SQLite and MySQL reconciler dialects.
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
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.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
// Career Workspace foundation, Phase F2 (career-workspace-implementation-roadmap.md). Every
|
||||
// TailoredCvDraft save should dual-write a CvVariant + CvVersion + TailoredApplication without
|
||||
// changing TailoredCvDraft's own behavior -- these tests lock in that seam.
|
||||
public sealed class CvVariantSyncTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SaveTailoredCvDraft_creates_a_variant_version_and_application_link()
|
||||
{
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var job = await SeedJobAsync(db);
|
||||
var controller = CreateController(db, "user-1");
|
||||
|
||||
var result = await controller.SaveTailoredCvDraft(job.Id, new JobApplicationsController.SaveTailoredCvDraftRequest(
|
||||
"ats-minimal", "Backend Engineer", new List<string> { "Built things." }, new List<string> { "C#" },
|
||||
new List<TailoredCvExperienceItem>(), new List<TailoredCvEducationItem>(), new List<TailoredCvCustomSection>(), null, "edited"), CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
|
||||
var variant = Assert.Single(db.CvVariants.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1"));
|
||||
Assert.Equal(1, variant.Version);
|
||||
Assert.Contains("Backend Developer", variant.Name);
|
||||
|
||||
var link = Assert.Single(db.TailoredApplications.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id));
|
||||
Assert.Equal(variant.Id, link.CvVariantId);
|
||||
|
||||
var version = Assert.Single(db.CvVersions.IgnoreQueryFilters().Where(x => x.CvVariantId == variant.Id));
|
||||
Assert.Equal(1, version.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveTailoredCvDraft_reuses_the_same_variant_on_subsequent_saves()
|
||||
{
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var job = await SeedJobAsync(db);
|
||||
var controller = CreateController(db, "user-1");
|
||||
|
||||
var request = new JobApplicationsController.SaveTailoredCvDraftRequest(
|
||||
"ats-minimal", "Backend Engineer", new List<string> { "Built things." }, new List<string> { "C#" },
|
||||
new List<TailoredCvExperienceItem>(), new List<TailoredCvEducationItem>(), new List<TailoredCvCustomSection>(), null, "edited");
|
||||
|
||||
await controller.SaveTailoredCvDraft(job.Id, request, CancellationToken.None);
|
||||
await controller.SaveTailoredCvDraft(job.Id, request with { Headline = "Updated headline" }, CancellationToken.None);
|
||||
|
||||
var variant = Assert.Single(db.CvVariants.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1"));
|
||||
Assert.Equal(2, variant.Version);
|
||||
|
||||
var links = db.TailoredApplications.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id).ToList();
|
||||
Assert.Single(links);
|
||||
|
||||
var versions = db.CvVersions.IgnoreQueryFilters().Where(x => x.CvVariantId == variant.Id).ToList();
|
||||
Assert.Equal(2, versions.Count);
|
||||
}
|
||||
|
||||
private static async Task<JobApplication> SeedJobAsync(JobTrackerContext db)
|
||||
{
|
||||
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" });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1" };
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
return job;
|
||||
}
|
||||
|
||||
private static JobApplicationsController CreateController(JobTrackerContext db, string userId)
|
||||
{
|
||||
var user = db.Users.AsNoTracking().FirstOrDefault(x => x.Id == userId);
|
||||
var controller = new JobApplicationsController(
|
||||
db,
|
||||
Mock.Of<ISummarizerService>(),
|
||||
Mock.Of<IAppEmailSender>(),
|
||||
TestHostFactory.CreateUserManager(user).Object,
|
||||
NullLogger<JobApplicationsController>.Instance,
|
||||
Mock.Of<ICvTemplateRenderer>(),
|
||||
Mock.Of<ICvPdfExporter>());
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId)
|
||||
}, "test"))
|
||||
}
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user