02b38f7acb
Phase 5.4. Connects the career outputs a user already has to one job
application, without building a second copy of any of them.
The flow is strictly one-directional — CareerProfile -> CvVariant ->
application output — and nothing writes back up. No code path in this phase
touches CareerProfile or its children.
CV integration re-points rather than duplicates. GET/PUT /{id}/cv attaches one
variant to an application via CvVariant.JobApplicationId; replacing detaches the
previous variant instead of deleting it. Creating, duplicating, editing, theming,
previewing, exporting PDF and version history all stay in the existing CV
builder, which the section links into. There is no second CV system.
Tailoring composes the Phase 5.3 analysis and match into skills to highlight,
experience to prioritise, projects to emphasise, keywords to include and gaps to
address. Deterministic and advisory: it says what the user could emphasise and
the user edits the variant themselves. Nothing auto-applies.
Cover letters gain the history they were missing. JobApplication.CoverLetterText
stays the current text with its API contract unchanged; CoverLetterVersions
records what it used to be, so an AI rewrite is never destructive. Restore is
additive — the old text comes back as a new version, so what you restored from
still exists. Source and AiAction record whether the user wrote a version or
approved it from a suggestion, and an AI generation only becomes a version once
the user saves it.
Documents are untouched: the existing Attachment system already covers CV, cover
letter, certificates and portfolio files with a Purpose field, so the workspace
mounts that component rather than adding a second upload path.
CoverLetterVersions is the only new table — reconciler-owned, no-op migration,
guarded on JobApplications, and verified on a fresh MariaDB 11: int
AUTO_INCREMENT primary key, varchar(255) owner, datetime(6), composite index
inside the key limit.
360 backend tests, 115 frontend tests, type check, Release build and the
production build all pass locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
309 lines
13 KiB
C#
309 lines
13 KiB
C#
using JobTrackerApi.Data;
|
|
using JobTrackerApi.Models;
|
|
using JobTrackerApi.Services;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace JobTrackerApi.Tests;
|
|
|
|
// Phase 5.4 — Application Assets. The properties under test are the architecture rules: the flow runs
|
|
// CareerProfile -> CvVariant -> application output and never back, versions are never destroyed, and
|
|
// nothing is reachable across tenants.
|
|
public sealed class ApplicationAssetsTests
|
|
{
|
|
private static (JobTrackerContext db, ApplicationAssetsService assets) New(string userId)
|
|
{
|
|
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
|
|
var currentUser = new Mock<ICurrentUserService>();
|
|
currentUser.SetupGet(s => s.UserId).Returns(userId);
|
|
var db = new JobTrackerContext(options, currentUser.Object);
|
|
|
|
// Only ListAsync is used here — attaching, creating, previewing and exporting all stay in
|
|
// CvVariantService, which has its own tests. Mocking it keeps these tests about association.
|
|
var variants = new Mock<ICvVariantService>();
|
|
variants
|
|
.Setup(s => s.ListAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((string owner, CancellationToken _) => db.CvVariants
|
|
.Where(v => v.OwnerUserId == owner)
|
|
.Select(v => new CvVariantSummary(v.Id, v.Name, "nordic", v.PublicSlug, v.IsPublic, v.Version, v.JobApplicationId, v.UpdatedAtUtc))
|
|
.ToList());
|
|
|
|
var intelligence = new ApplicationIntelligenceService(db, new JobCvMatchService());
|
|
return (db, new ApplicationAssetsService(db, variants.Object, intelligence));
|
|
}
|
|
|
|
private static async Task<JobApplication> SeedJobAsync(JobTrackerContext db, string owner)
|
|
{
|
|
var company = new Company { OwnerUserId = owner, Name = "Acme" };
|
|
db.Companies.Add(company);
|
|
await db.SaveChangesAsync();
|
|
var job = new JobApplication
|
|
{
|
|
OwnerUserId = owner,
|
|
CompanyId = company.Id,
|
|
JobTitle = "Senior Backend Developer",
|
|
Status = "Applied",
|
|
Description = "We need strong C# and .NET experience, plus SQL and Docker. You will build REST APIs.",
|
|
};
|
|
db.JobApplications.Add(job);
|
|
await db.SaveChangesAsync();
|
|
return job;
|
|
}
|
|
|
|
private static async Task<CvVariant> SeedVariantAsync(JobTrackerContext db, string owner, string name, int? jobId = null)
|
|
{
|
|
var variant = new CvVariant
|
|
{
|
|
OwnerUserId = owner,
|
|
Name = name,
|
|
JobApplicationId = jobId,
|
|
PublicSlug = Guid.NewGuid().ToString("N"),
|
|
SettingsJson = CvVariantSettingsJson.Serialize(new CvVariantSettings { ThemeId = "nordic" }),
|
|
CreatedAtUtc = DateTimeOffset.UtcNow,
|
|
UpdatedAtUtc = DateTimeOffset.UtcNow,
|
|
};
|
|
db.CvVariants.Add(variant);
|
|
await db.SaveChangesAsync();
|
|
return variant;
|
|
}
|
|
|
|
// ---------- Part 1: CV variant association ----------
|
|
|
|
[Fact]
|
|
public async Task Attaching_a_variant_points_the_application_at_it()
|
|
{
|
|
var (db, assets) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedJobAsync(db, "user-1");
|
|
var variant = await SeedVariantAsync(db, "user-1", "Backend CV");
|
|
|
|
var cv = await assets.AttachVariantAsync("user-1", job.Id, variant.Id, default);
|
|
|
|
Assert.Equal(variant.Id, cv!.AttachedVariantId);
|
|
Assert.Equal("Backend CV", cv.AttachedVariantName);
|
|
Assert.Equal("nordic", cv.AttachedThemeId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Attaching_a_second_variant_replaces_the_first_rather_than_stacking()
|
|
{
|
|
var (db, assets) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedJobAsync(db, "user-1");
|
|
var first = await SeedVariantAsync(db, "user-1", "First");
|
|
var second = await SeedVariantAsync(db, "user-1", "Second");
|
|
|
|
await assets.AttachVariantAsync("user-1", job.Id, first.Id, default);
|
|
var cv = await assets.AttachVariantAsync("user-1", job.Id, second.Id, default);
|
|
|
|
Assert.Equal(second.Id, cv!.AttachedVariantId);
|
|
// The replaced variant is detached, not deleted — it is still the user's to reuse.
|
|
var stillThere = await db.CvVariants.FirstAsync(v => v.Id == first.Id);
|
|
Assert.Null(stillThere.JobApplicationId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Detaching_leaves_the_variant_intact()
|
|
{
|
|
var (db, assets) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedJobAsync(db, "user-1");
|
|
var variant = await SeedVariantAsync(db, "user-1", "Backend CV", null);
|
|
await assets.AttachVariantAsync("user-1", job.Id, variant.Id, default);
|
|
|
|
var cv = await assets.AttachVariantAsync("user-1", job.Id, null, default);
|
|
|
|
Assert.Null(cv!.AttachedVariantId);
|
|
Assert.Equal(1, await db.CvVariants.CountAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Another_users_variant_cannot_be_attached()
|
|
{
|
|
var (db, assets) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedJobAsync(db, "user-1");
|
|
var theirs = await SeedVariantAsync(db, "user-2", "Not yours");
|
|
|
|
Assert.Null(await assets.AttachVariantAsync("user-1", job.Id, theirs.Id, default));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cv_section_is_not_readable_for_another_users_application()
|
|
{
|
|
var (db, assets) = New("user-1");
|
|
await using var _ = db;
|
|
var other = await SeedJobAsync(db, "user-2");
|
|
|
|
Assert.Null(await assets.GetCvAsync("user-1", other.Id, default));
|
|
}
|
|
|
|
// ---------- Part 2: tailoring ----------
|
|
|
|
[Fact]
|
|
public async Task Tailoring_suggests_keywords_even_without_a_career_profile()
|
|
{
|
|
var (db, assets) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedJobAsync(db, "user-1");
|
|
|
|
var plan = await assets.GetTailoringPlanAsync("user-1", job.Id, default);
|
|
|
|
Assert.NotNull(plan);
|
|
Assert.False(plan!.HasCareerProfile);
|
|
Assert.Contains(plan.Suggestions, s => s.Kind == "include-keywords");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Tailoring_never_writes_to_the_career_profile_or_the_variant()
|
|
{
|
|
var (db, assets) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedJobAsync(db, "user-1");
|
|
var variant = await SeedVariantAsync(db, "user-1", "Backend CV", job.Id);
|
|
db.CareerProfiles.Add(new CareerProfile
|
|
{
|
|
OwnerUserId = "user-1",
|
|
Experiences = { new CareerExperience { OwnerUserId = "user-1", Title = "Dev", Company = "Initech", BulletsJson = """["C# and .NET"]""" } },
|
|
});
|
|
await db.SaveChangesAsync();
|
|
var settingsBefore = variant.SettingsJson;
|
|
var versionBefore = variant.Version;
|
|
var profileVersionBefore = (await db.CareerProfiles.FirstAsync()).Version;
|
|
|
|
var plan = await assets.GetTailoringPlanAsync("user-1", job.Id, default);
|
|
|
|
Assert.NotEmpty(plan!.Suggestions);
|
|
var variantAfter = await db.CvVariants.AsNoTracking().FirstAsync(v => v.Id == variant.Id);
|
|
Assert.Equal(settingsBefore, variantAfter.SettingsJson);
|
|
Assert.Equal(versionBefore, variantAfter.Version);
|
|
Assert.Equal(profileVersionBefore, (await db.CareerProfiles.AsNoTracking().FirstAsync()).Version);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Tailoring_is_not_readable_for_another_users_application()
|
|
{
|
|
var (db, assets) = New("user-1");
|
|
await using var _ = db;
|
|
var other = await SeedJobAsync(db, "user-2");
|
|
|
|
Assert.Null(await assets.GetTailoringPlanAsync("user-1", other.Id, default));
|
|
}
|
|
|
|
// ---------- Part 3: cover letter ----------
|
|
|
|
[Fact]
|
|
public async Task Saving_a_cover_letter_stores_a_version_and_sets_the_current_text()
|
|
{
|
|
var (db, assets) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedJobAsync(db, "user-1");
|
|
|
|
var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Dear team", CoverLetterSources.Manual, null, default);
|
|
|
|
Assert.Equal("Dear team", result!.Text);
|
|
Assert.Equal(1, result.CurrentVersion);
|
|
Assert.Single(result.Versions);
|
|
Assert.True(result.Versions[0].IsCurrent);
|
|
Assert.Equal("Dear team", (await db.JobApplications.AsNoTracking().FirstAsync(j => j.Id == job.Id)).CoverLetterText);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Each_edit_adds_a_version_and_none_are_lost()
|
|
{
|
|
var (db, assets) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedJobAsync(db, "user-1");
|
|
|
|
await assets.SaveCoverLetterAsync("user-1", job.Id, "First draft", CoverLetterSources.Manual, null, default);
|
|
await assets.SaveCoverLetterAsync("user-1", job.Id, "Second draft", CoverLetterSources.Ai, "improve", default);
|
|
var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Third draft", CoverLetterSources.Manual, null, default);
|
|
|
|
Assert.Equal(3, result!.CurrentVersion);
|
|
Assert.Equal(3, result.Versions.Count);
|
|
Assert.Equal("improve", result.Versions.Single(v => v.Version == 2).AiAction);
|
|
Assert.Equal(CoverLetterSources.Ai, result.Versions.Single(v => v.Version == 2).Source);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_unchanged_save_does_not_burn_a_version()
|
|
{
|
|
var (db, assets) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedJobAsync(db, "user-1");
|
|
|
|
await assets.SaveCoverLetterAsync("user-1", job.Id, "Same text", CoverLetterSources.Manual, null, default);
|
|
var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Same text", CoverLetterSources.Manual, null, default);
|
|
|
|
Assert.Single(result!.Versions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Restoring_brings_back_old_text_without_destroying_the_newer_version()
|
|
{
|
|
var (db, assets) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedJobAsync(db, "user-1");
|
|
await assets.SaveCoverLetterAsync("user-1", job.Id, "The good draft", CoverLetterSources.Manual, null, default);
|
|
await assets.SaveCoverLetterAsync("user-1", job.Id, "The AI ruined it", CoverLetterSources.Ai, "shorten", default);
|
|
|
|
var result = await assets.RestoreCoverLetterAsync("user-1", job.Id, 1, default);
|
|
|
|
Assert.Equal("The good draft", result!.Text);
|
|
// Restore is additive: v1 and v2 both survive, and the restore is v3.
|
|
Assert.Equal(3, result.CurrentVersion);
|
|
Assert.Equal(3, result.Versions.Count);
|
|
Assert.Equal(CoverLetterSources.Restore, result.Versions.Single(v => v.Version == 3).Source);
|
|
Assert.Equal("The AI ruined it", (await db.CoverLetterVersions.AsNoTracking().FirstAsync(v => v.Version == 2)).Text);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Restoring_a_version_that_does_not_exist_is_a_miss()
|
|
{
|
|
var (db, assets) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedJobAsync(db, "user-1");
|
|
await assets.SaveCoverLetterAsync("user-1", job.Id, "Only draft", CoverLetterSources.Manual, null, default);
|
|
|
|
Assert.Null(await assets.RestoreCoverLetterAsync("user-1", job.Id, 99, default));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cover_letter_history_is_scoped_to_its_owner()
|
|
{
|
|
var (db, assets) = New("user-1");
|
|
await using var _ = db;
|
|
var mine = await SeedJobAsync(db, "user-1");
|
|
var theirs = await SeedJobAsync(db, "user-2");
|
|
await assets.SaveCoverLetterAsync("user-1", mine.Id, "Mine", CoverLetterSources.Manual, null, default);
|
|
|
|
Assert.Null(await assets.GetCoverLetterAsync("user-1", theirs.Id, default));
|
|
Assert.Null(await assets.SaveCoverLetterAsync("user-1", theirs.Id, "Sneak", CoverLetterSources.Manual, null, default));
|
|
Assert.Null(await assets.RestoreCoverLetterAsync("user-1", theirs.Id, 1, default));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_ai_draft_only_becomes_a_version_when_the_user_saves_it()
|
|
{
|
|
var (db, assets) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedJobAsync(db, "user-1");
|
|
// An AI generation on its own is an AiInteraction — history, not the user's cover letter.
|
|
db.AiInteractions.Add(new AiInteraction
|
|
{
|
|
OwnerUserId = "user-1", JobApplicationId = job.Id, Module = "cover-letter",
|
|
Title = "Cover letter", Provider = "p", ResultJson = """{"text":"AI suggestion"}""",
|
|
CreatedAtUtc = DateTimeOffset.UtcNow,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var before = await assets.GetCoverLetterAsync("user-1", job.Id, default);
|
|
|
|
Assert.Null(before!.Text);
|
|
Assert.Empty(before.Versions);
|
|
Assert.Equal(1, before.AiSuggestionCount);
|
|
}
|
|
}
|