using System.Text.Json; using System.Security.Claims; using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using JobTrackerApi.Tests.TestSupport; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class Rd21JobPersistenceTests { private static JobTrackerContext Context(SqliteConnection connection, string owner = "rd21-owner") { var current = new Mock(); current.SetupGet(x => x.UserId).Returns(owner); return new JobTrackerContext(new DbContextOptionsBuilder().UseSqlite(connection).Options, current.Object); } private static JobApplicationsController Controller(JobTrackerContext db) => new(db, Mock.Of(), TestHostFactory.CreateUserManager(new ApplicationUser { Id = "rd21-owner" }).Object) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "rd21-owner") }, "test")), } }, }; [Fact] public async Task Write_that_overlaps_a_delayed_save_wins_without_partial_events_or_opportunity_changes() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); int id, companyId; await using (var seed = Context(connection)) { await seed.Database.EnsureCreatedAsync(); var company = new Company { OwnerUserId = "rd21-owner", Name = "RD21 overlap" }; seed.Add(company); await seed.SaveChangesAsync(); var job = new JobApplication { OwnerUserId = "rd21-owner", CompanyId = company.Id, JobTitle = "Original job title", Status = "Prospect" }; job.Job = JobOpportunitySync.Create(job); seed.Add(job); await seed.SaveChangesAsync(); id = job.Id; companyId = company.Id; } var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var summarizer = new Mock(); summarizer.Setup(x => x.SummarizeAsync(It.IsAny(), 160, 60)).Returns(() => { reached.TrySetResult(); return release.Task; }); var user = new ApplicationUser { Id = "rd21-owner", AiEnabled = true }; var users = TestHostFactory.CreateUserManager(user); users.Setup(x => x.GetRolesAsync(user)).ReturnsAsync(new List { "Premium" }); await using var delayed = Context(connection); var controller = new JobApplicationsController(delayed, summarizer.Object, users.Object) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, user.Id) }, "test")), } }, }; var pending = controller.Update(id, Request(companyId, "Delayed title", null, 1) with { Status = "Offer" }, default); await reached.Task.WaitAsync(TimeSpan.FromSeconds(10)); try { await using var winner = Context(connection); Assert.IsType(await Controller(winner).Update(id, Request(companyId, "Original job title", "Acknowledged concurrent notes", 1), default)); } finally { release.TrySetResult("Delayed summary"); } Assert.IsType(await pending); await using var verify = Context(connection); var saved = await verify.JobApplications.SingleAsync(); Assert.Equal(2, saved.Version); Assert.Equal("Original job title", saved.JobTitle); Assert.Equal("Acknowledged concurrent notes", saved.Notes); Assert.Equal("Prospect", saved.Status); Assert.Equal("Original job title", (await verify.Jobs.SingleAsync()).JobTitle); Assert.Empty(await verify.JobEvents.ToListAsync()); Assert.Empty(delayed.ChangeTracker.Entries()); // A deliberate reload/review uses the winner's content and new revision, never a blind retry. await using var reviewed = Context(connection); Assert.IsType(await Controller(reviewed).Update(id, Request(companyId, "Reviewed final title", saved.Notes, saved.Version), default)); await using var finalSession = Context(connection); var final = await finalSession.JobApplications.SingleAsync(); Assert.Equal(3, final.Version); Assert.Equal("Reviewed final title", final.JobTitle); Assert.Equal("Acknowledged concurrent notes", final.Notes); } private static UpdateJobApplicationRequest Request(int companyId, string title, string? notes, int? expectedVersion) => JsonSerializer.Deserialize(JsonSerializer.Serialize(new { jobTitle = title, companyId, status = "Prospect", notes, expectedVersion, location = "Oslo", salary = "unchanged salary", description = "Original description", }), new JsonSerializerOptions(JsonSerializerDefaults.Web))!; [Fact] public async Task Structured_tailored_cv_snapshot_cannot_replace_a_newer_approved_document() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); int id; await using (var seed = Context(connection)) { await seed.Database.EnsureCreatedAsync(); var job = new JobApplication { OwnerUserId = "rd21-owner", Company = new Company { Name = "RD21 CV", OwnerUserId = "rd21-owner" }, JobTitle = "Role" }; seed.Add(job); await seed.SaveChangesAsync(); id = job.Id; } SaveTailoredCvDraftRequest Draft(string text) => JsonSerializer.Deserialize( JsonSerializer.Serialize(new { summary = new[] { text }, expectedVersion = 1 }), new JsonSerializerOptions(JsonSerializerDefaults.Web))!; await using var first = Context(connection); Assert.IsType(await Controller(first).SaveTailoredCvDraft(id, Draft("Approved CV evidence"), default)); await using var stale = Context(connection); var rejected = await Controller(stale).SaveTailoredCvDraft(id, Draft("Stale CV evidence"), default); await using var verify = Context(connection); Assert.Contains("Approved CV evidence", (await verify.JobApplications.SingleAsync()).TailoredCvText); Assert.Contains("Approved CV evidence", (await verify.TailoredCvDrafts.SingleAsync()).SummaryJson); Assert.Equal(409, Assert.IsAssignableFrom(rejected).StatusCode); } [Fact] public async Task Failed_job_transaction_cannot_acknowledge_or_leave_partial_data_for_a_later_save() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); await using var db = Context(connection); await db.Database.EnsureCreatedAsync(); var company = new Company { OwnerUserId = "rd21-owner", Name = "RD21 rollback" }; db.Add(company); await db.SaveChangesAsync(); var job = new JobApplication { OwnerUserId = "rd21-owner", CompanyId = company.Id, JobTitle = "Approved title", Notes = "Approved notes", Status = "Prospect" }; job.Job = JobOpportunitySync.Create(job); db.Add(job); await db.SaveChangesAsync(); await db.Database.ExecuteSqlRawAsync(""" CREATE TRIGGER rd21_reject_event BEFORE INSERT ON JobEvents BEGIN SELECT RAISE(ABORT, 'synthetic RD21 event failure'); END; """); await Assert.ThrowsAsync(() => Controller(db).Update(job.Id, Request(company.Id, "Uncommitted title", "Uncommitted notes", 1) with { Status = "Offer" }, default)); Assert.Empty(db.ChangeTracker.Entries()); await db.SaveChangesAsync(); await using var verify = Context(connection); var actual = await verify.JobApplications.SingleAsync(); Assert.Equal("Approved title", actual.JobTitle); Assert.Equal("Approved notes", actual.Notes); Assert.Equal("Prospect", actual.Status); Assert.Equal(1, actual.Version); Assert.Equal("Approved title", (await verify.Jobs.SingleAsync()).JobTitle); Assert.Empty(await verify.JobEvents.ToListAsync()); } [Fact] public async Task Versioned_job_save_cannot_cross_tenant_job_or_company_ownership() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); await using var db = Context(connection); await db.Database.EnsureCreatedAsync(); var mine = new Company { OwnerUserId = "rd21-owner", Name = "Owned company" }; var other = new Company { OwnerUserId = "other-owner", Name = "Private company" }; var job = new JobApplication { OwnerUserId = "rd21-owner", Company = mine, JobTitle = "Owned job", Status = "Prospect" }; var foreign = new JobApplication { OwnerUserId = "other-owner", Company = other, JobTitle = "Private job", Status = "Prospect" }; db.AddRange(job, foreign); await db.SaveChangesAsync(); db.ChangeTracker.Clear(); Assert.IsType(await Controller(db).Update(foreign.Id, Request(other.Id, "Stolen", null, 1), default)); Assert.IsType(await Controller(db).Update(job.Id, Request(other.Id, "Changed", null, 1), default)); await using var verify = Context(connection); Assert.Equal("Owned job", (await verify.JobApplications.SingleAsync()).JobTitle); Assert.Equal(mine.Id, (await verify.JobApplications.SingleAsync()).CompanyId); await using var foreignSession = Context(connection, "other-owner"); Assert.Equal("Private job", (await foreignSession.JobApplications.SingleAsync()).JobTitle); } [Theory] [InlineData(false)] [InlineData(true)] public async Task Related_text_save_cannot_silently_replace_a_newer_acknowledged_draft(bool coverLetter) { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); int id; await using (var seed = Context(connection)) { await seed.Database.EnsureCreatedAsync(); var job = new JobApplication { OwnerUserId = "rd21-owner", Company = new Company { OwnerUserId = "rd21-owner", Name = "RD21" }, JobTitle = "Role", CoverLetterText = "Original letter", Notes = "Original notes", }; seed.Add(job); await seed.SaveChangesAsync(); id = job.Id; } async Task Save(JobTrackerContext db, string text) { var json = JsonSerializer.Serialize(new { text, notes = text, expectedVersion = 1 }); if (!coverLetter) return await Controller(db).SaveApplicationDrafts(id, JsonSerializer.Deserialize(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!, default); var user = new ApplicationUser { Id = "rd21-owner" }; var users = TestHostFactory.CreateUserManager(user); users.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); var controller = new ApplicationAssetsController(users.Object, new ApplicationAssetsService(db, Mock.Of(), Mock.Of()), Mock.Of()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, user.Id) }, "test")), } }, }; return (await controller.SaveCoverLetter(id, JsonSerializer.Deserialize(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!, default)).Result!; } await using var first = Context(connection); var acknowledged = await Save(first, "First approved text"); Assert.True(acknowledged is OkObjectResult or NoContentResult); await using (var immediate = Context(connection)) Assert.Equal("First approved text", coverLetter ? (await immediate.JobApplications.SingleAsync()).CoverLetterText : (await immediate.JobApplications.SingleAsync()).Notes); await using var stale = Context(connection); IActionResult rejected; try { rejected = await Save(stale, "Stale snapshot text"); } catch (DbUpdateConcurrencyException) { rejected = new ConflictObjectResult("revision_conflict"); } await using var verify = Context(connection); var saved = await verify.JobApplications.SingleAsync(); Assert.Equal("First approved text", coverLetter ? saved.CoverLetterText : saved.Notes); Assert.Equal(409, Assert.IsAssignableFrom(rejected).StatusCode); if (coverLetter) Assert.Single(await verify.CoverLetterVersions.ToListAsync()); } [Theory] [InlineData(false)] [InlineData(true)] public async Task Stale_or_unversioned_snapshot_cannot_erase_an_acknowledged_job_edit(bool omitRevision) { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); int id, companyId; await using (var seed = Context(connection)) { await seed.Database.EnsureCreatedAsync(); var company = new Company { OwnerUserId = "rd21-owner", Name = "Synthetic RD21" }; seed.Companies.Add(company); await seed.SaveChangesAsync(); var job = new JobApplication { OwnerUserId = "rd21-owner", Company = company, CompanyId = company.Id, JobTitle = "Original job title", Status = "Prospect", Location = "Oslo", Salary = "unchanged salary", Description = "Original description", }; job.Job = JobOpportunitySync.Create(job); seed.JobApplications.Add(job); await seed.SaveChangesAsync(); id = job.Id; companyId = company.Id; } // These are the two snapshots actually read by independent editors. await using var editorA = Context(connection); await using var editorBRead = Context(connection); var original = await editorBRead.JobApplications.AsNoTracking().SingleAsync(); Assert.Equal("Original job title", original.JobTitle); Assert.IsType(await Controller(editorA).Update(id, Request(companyId, "First editor committed title", null, 1), default)); await using (var immediatelyAfterAcknowledgement = Context(connection)) { Assert.Equal("First editor committed title", (await immediatelyAfterAcknowledgement.JobApplications.SingleAsync()).JobTitle); Assert.Equal("First editor committed title", (await immediatelyAfterAcknowledgement.Jobs.SingleAsync()).JobTitle); } await using var editorBWrite = Context(connection); var rejected = await Controller(editorBWrite).Update(id, Request(companyId, original.JobTitle, "Second editor stale notes", omitRevision ? null : 1), default); Assert.Equal(409, Assert.IsAssignableFrom(rejected).StatusCode); await using var independentSession = Context(connection); var persisted = await independentSession.JobApplications.SingleAsync(); Assert.Equal("First editor committed title", persisted.JobTitle); Assert.Null(persisted.Notes); Assert.Equal("Oslo", persisted.Location); Assert.Equal("unchanged salary", persisted.Salary); Assert.Equal("First editor committed title", (await independentSession.Jobs.SingleAsync()).JobTitle); Assert.Empty(await independentSession.JobEvents.ToListAsync()); } }