Files
jobtrackingapp/JobTrackerApi.Tests/ApplicationAssetsTests.cs
T
2026-08-24 20:21:23 +02:00

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);
}
}