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() .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; var currentUser = new Mock(); 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(); variants .Setup(s => s.ListAsync(It.IsAny(), It.IsAny())) .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 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 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); } }