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", "en", v.PublicSlug, v.IsPublic, v.Version, v.JobApplicationId, v.UpdatedAtUtc, null, null))
|
|
.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);
|
|
}
|
|
}
|