using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.EntityFrameworkCore; using Moq; using Xunit; namespace JobTrackerApi.MariaDb.Tests; public sealed class CoverLetterAiSourceTests { private const string Owner = "rd21-owner"; private static async Task SeedAsync(JobTrackerContext db) { var job = new JobApplication { OwnerUserId = Owner, Company = new Company { OwnerUserId = Owner, Name = "Source-bound employer" }, JobTitle = "Engineer", Description = "Build C# services", Status = "Prospect", CoverLetterText = "Approved manual letter", }; db.Users.Add(new() { Id = Owner, DisplayName = "Ada" }); db.JobApplications.Add(job); await db.SaveChangesAsync(); db.CareerProfiles.Add(new() { OwnerUserId = Owner, Version = 1, ProfileJson = StructuredCvProfileJson.Serialize(new() { Skills = { "C#" } }), }); db.CvVariants.Add(new() { OwnerUserId = Owner, JobApplicationId = job.Id, Name = "Owned linked CV", Version = 1, PublicSlug = Guid.NewGuid().ToString("N"), SettingsJson = CvVariantSettingsJson.Serialize(new()), CreatedAtUtc = DateTimeOffset.UtcNow, UpdatedAtUtc = DateTimeOffset.UtcNow, }); await db.SaveChangesAsync(); return job.Id; } private static AiWorkspaceService Service(JobTrackerContext db, Func? beforeResult = null) { var ai = new Mock(); ai.Setup(value => value.GenerateSectionWithMetadataAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(async () => { // Provider waits must not hold the later serializable validation/history transaction. Assert.Null(db.Database.CurrentTransaction); if (beforeResult is not null) await beforeResult(); return new AiGenerationResult("Verified source-bound suggestion.", "local-test", "stub", null, "local_primary"); }); return new(db, ai.Object); } private static Task Generate(AiWorkspaceService service, int jobId) => service.GenerateAsync(Owner, jobId, "unused fallback", "Ada", new("cover-letter", "professional", null), "local-test", default); private static async Task AssertLetterUntouched(JobTrackerContext db, int jobId) { var job = await db.JobApplications.AsNoTracking().SingleAsync(value => value.Id == jobId); Assert.Equal("Approved manual letter", job.CoverLetterText); Assert.Equal(1, job.Version); Assert.Empty(await db.CoverLetterVersions.AsNoTracking().ToListAsync()); } [Fact] public async Task Bound_generation_commits_on_mariadb_and_read_only_validation_accepts_current_source() { await using var database = await MariaDbDatabase.CreateAsync(); await using var db = database.Context(); var jobId = await SeedAsync(db); var service = Service(db); var result = await Generate(service, jobId); Assert.NotNull(result); Assert.Null(db.Database.CurrentTransaction); Assert.True(await service.ValidateCoverLetterSourceAsync(Owner, jobId, result.Id, default)); await using var fresh = database.Context(); Assert.True(await Service(fresh).ValidateCoverLetterSourceAsync(Owner, jobId, result.Id, default)); Assert.Single(await fresh.AiInteractions.AsNoTracking().ToListAsync()); Assert.Equal(1, (await fresh.CareerProfiles.AsNoTracking().SingleAsync()).Version); Assert.Equal(1, (await fresh.CvVariants.AsNoTracking().SingleAsync()).Version); await AssertLetterUntouched(fresh, jobId); Assert.False(db.ChangeTracker.HasChanges()); } [Fact] public async Task Separate_context_profile_edit_during_inference_is_rejected_without_stale_history_on_mariadb() { await using var database = await MariaDbDatabase.CreateAsync(); await using var db = database.Context(); var jobId = await SeedAsync(db); var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var service = Service(db, () => { reached.TrySetResult(); return release.Task; }); var pending = Generate(service, jobId); await reached.Task.WaitAsync(TimeSpan.FromSeconds(15)); try { await using var writer = database.Context(); var profile = await writer.CareerProfiles.SingleAsync(); profile.Version++; profile.ProfileJson = StructuredCvProfileJson.Serialize(new() { Skills = { "Changed qualification" } }); await writer.SaveChangesAsync(); } finally { release.TrySetResult(); } var stale = await Assert.ThrowsAsync(() => pending); Assert.Equal("cover_letter_source_changed", stale.Message); Assert.True(stale.InputCharacterCount > 0); Assert.True(stale.OutputCharacterCount > 0); Assert.Null(db.Database.CurrentTransaction); await using var fresh = database.Context(); Assert.Empty(await fresh.AiInteractions.AsNoTracking().ToListAsync()); Assert.Equal(2, (await fresh.CareerProfiles.AsNoTracking().SingleAsync()).Version); await AssertLetterUntouched(fresh, jobId); } [Fact] public async Task Separate_context_cv_and_attachment_changes_invalidate_pre_apply_source_on_mariadb() { await using var database = await MariaDbDatabase.CreateAsync(); await using var db = database.Context(); var jobId = await SeedAsync(db); var service = Service(db); var beforeCv = await Generate(service, jobId); Assert.True(await service.ValidateCoverLetterSourceAsync(Owner, jobId, beforeCv!.Id, default)); await using (var writer = database.Context()) { var cv = await writer.CvVariants.SingleAsync(); cv.Version++; cv.SettingsJson = CvVariantSettingsJson.Serialize(new() { Headline = "Reviewed CV lens changed" }); await writer.SaveChangesAsync(); } Assert.False(await service.ValidateCoverLetterSourceAsync(Owner, jobId, beforeCv.Id, default)); var beforeAttachment = await Generate(service, jobId); Assert.True(await service.ValidateCoverLetterSourceAsync(Owner, jobId, beforeAttachment!.Id, default)); await using (var writer = database.Context()) { writer.Attachments.Add(new() { JobApplicationId = jobId, FileName = "new-source.txt", FilePath = "synthetic-managed-path", FileSize = 17 }); await writer.SaveChangesAsync(); } Assert.False(await service.ValidateCoverLetterSourceAsync(Owner, jobId, beforeAttachment.Id, default)); await using var fresh = database.Context(); Assert.Equal(2, await fresh.AiInteractions.CountAsync()); Assert.Equal(1, (await fresh.CareerProfiles.AsNoTracking().SingleAsync()).Version); await AssertLetterUntouched(fresh, jobId); } }