using System.Security.Claims; using System.Text.Json; using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Moq; using Xunit; namespace JobTrackerApi.MariaDb.Tests; public sealed class Rd21CareerPersistenceTests { private static JobTrackerContext Context(MariaDbDatabase connection, IInterceptor? interceptor = null) => connection.Context(interceptor: interceptor); private static CareerProfileController Controller(JobTrackerContext db, bool identitySucceeds = true) => new(Users(db, identitySucceeds).Object, new CareerProfileService(db), db) { ControllerContext = new() { HttpContext = new DefaultHttpContext() }, }; private static Mock> Users(JobTrackerContext db, bool identitySucceeds) { var user = db.Users.Single(); var users = TestHostFactory.CreateUserManager(); users.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); users.Setup(x => x.UpdateAsync(user)).Returns(async () => { if (!identitySucceeds) return IdentityResult.Failed(new IdentityError { Description = "Synthetic Identity failure" }); await db.SaveChangesAsync(); return IdentityResult.Success; }); return users; } private static ProfileCvController ImportController(JobTrackerContext db, bool identitySucceeds = true) => new(Users(db, identitySucceeds).Object, new Mock().Object, db, null!) { ControllerContext = new() { HttpContext = new DefaultHttpContext() } }; private static CareerProfileSaveRequest Request(string title, int? expectedVersion) => JsonSerializer.Deserialize(JsonSerializer.Serialize(new { profile = new StructuredCvProfile { Jobs = { new() { Id = "stable-role", Title = title } } }, expectedVersion, }), new JsonSerializerOptions(JsonSerializerDefaults.Web))!; [Fact] public async Task Independent_stale_snapshot_cannot_replace_acknowledged_career_edit() { await using var connection = await MariaDbDatabase.CreateAsync(); await using (var seed = Context(connection)) { seed.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" }); await seed.SaveChangesAsync(); Assert.IsType((await Controller(seed).Put(Request("Original", 0), default)).Result); } await using var first = Context(connection); await using var stale = Context(connection); var staleController = Controller(stale); var source = Assert.IsType(Assert.IsType((await staleController.Get(default)).Result).Value); Assert.Equal(1, source.Revision); Assert.Equal("Original", source.Profile.Jobs.Single().Title); Assert.IsType((await Controller(first).Put(Request("Acknowledged winner", 1), default)).Result); var rejected = await staleController.Put(Request("Stale draft", 1), default); Assert.IsType(rejected.Result); await stale.SaveChangesAsync(); await using var verify = Context(connection); Assert.Equal("Acknowledged winner", (await verify.CareerExperiences.SingleAsync()).Title); Assert.Equal(2, (await verify.CareerProfiles.SingleAsync()).Version); Assert.Equal(2, await verify.CareerProfileVersions.CountAsync()); Assert.Contains("Acknowledged winner", (await verify.Users.SingleAsync()).ProfileCvStructureJson); } [Fact] public async Task Identity_failure_rolls_back_canonical_children_history_and_projection() { await using var connection = await MariaDbDatabase.CreateAsync(); await using var db = Context(connection); db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" }); await db.SaveChangesAsync(); var result = await Controller(db, false).Put(Request("Must not persist", 0), default); Assert.IsType(result.Result); await db.SaveChangesAsync(); await using var verify = Context(connection); Assert.Empty(await verify.CareerProfiles.ToListAsync()); Assert.Empty(await verify.CareerExperiences.ToListAsync()); Assert.Empty(await verify.CareerProfileVersions.ToListAsync()); Assert.Null((await verify.Users.SingleAsync()).ProfileCvStructureJson); } [Fact] public async Task Stale_tracked_entity_is_guarded_at_database_update_and_stale_restore_is_rejected() { await using var connection = await MariaDbDatabase.CreateAsync(); await using var first = Context(connection); var a = new CareerProfileService(first); await a.SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Id = "role", Title = "Original" } } }, "manual", 0, default); await using var stale = Context(connection); await stale.CareerProfiles.SingleAsync(); await a.SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Id = "role", Title = "Winner" } } }, "manual", 1, default); var b = new CareerProfileService(stale); await Assert.ThrowsAsync(() => b.SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Title = "Stale" } } }, "manual", 1, default)); await stale.SaveChangesAsync(); await Assert.ThrowsAsync(() => b.RestoreVersionAsync("rd21-owner", 1, 1, default)); await using var verify = Context(connection); Assert.Equal("Winner", (await verify.CareerExperiences.SingleAsync()).Title); Assert.Equal(2, await verify.CareerProfileVersions.CountAsync()); Assert.Equal(0, (await b.LoadSnapshotAsync("another-owner", default)).Revision); } [Fact] public async Task Competing_first_save_sources_have_one_acknowledged_winner() { await using var database = await MariaDbDatabase.CreateAsync(); await using (var setup = Context(database)) { setup.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" }); await setup.SaveChangesAsync(); } var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var readers = 0; async Task Save(string title) { await using var db = Context(database); var controller = Controller(db); Assert.Equal(0, Assert.IsType(Assert.IsType((await controller.Get(default)).Result).Value).Revision); if (Interlocked.Increment(ref readers) == 2) ready.TrySetResult(); await release.Task.WaitAsync(TimeSpan.FromSeconds(15)); return (await controller.Put(Request(title, 0), default)).Result; } var a = Save("Candidate A"); var b = Save("Candidate B"); try { await ready.Task.WaitAsync(TimeSpan.FromSeconds(10)); } finally { release.TrySetResult(); } var outcomes = await Task.WhenAll(a, b); Assert.Single(outcomes.OfType()); Assert.Single(outcomes.OfType()); await using var verify = Context(database); var profile = await verify.CareerProfiles.SingleAsync(); Assert.Equal(1, profile.Version); Assert.Single(await verify.CareerProfileVersions.ToListAsync()); Assert.Equal(profile.ProfileJson, (await verify.Users.SingleAsync()).ProfileCvStructureJson); Assert.Equal(StructuredCvProfileJson.DeserializePersisted(profile.ProfileJson).Jobs.Single().Title, (await verify.CareerExperiences.SingleAsync()).Title); } private sealed class FailAfterChildDeletion : SaveChangesInterceptor { private int _saves; public override ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { if (++_saves == 3) throw new InvalidOperationException("Synthetic child insert failure"); return base.SavingChangesAsync(eventData, result, cancellationToken); } } [Fact] public async Task Database_failure_after_deletion_restores_children_history_and_revision_and_clears_tracker() { await using var connection = await MariaDbDatabase.CreateAsync(); await using (var seed = Context(connection)) { await new CareerProfileService(seed).SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Id = "role", Title = "Original" } } }, "manual", 0, default); } await using var failed = Context(connection, new FailAfterChildDeletion()); await Assert.ThrowsAsync(() => new CareerProfileService(failed).SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Title = "Rejected" } } }, "manual", 1, default)); Assert.Empty(failed.ChangeTracker.Entries()); await failed.SaveChangesAsync(); await using var verify = Context(connection); Assert.Equal("Original", (await verify.CareerExperiences.SingleAsync()).Title); Assert.Equal(1, (await verify.CareerProfiles.SingleAsync()).Version); Assert.Single(await verify.CareerProfileVersions.ToListAsync()); await new CareerProfileService(failed).SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Id = "role", Title = "Recovered" } } }, "manual", 1, default); await using var recovered = Context(connection); Assert.Equal("Recovered", (await recovered.CareerExperiences.SingleAsync()).Title); Assert.Equal(2, (await recovered.CareerProfiles.SingleAsync()).Version); Assert.Equal(2, await recovered.CareerProfileVersions.CountAsync()); } [Theory] [InlineData(null)] [InlineData(-1)] public async Task Replacement_restore_parse_and_import_fail_closed_without_valid_revision(int? expectedVersion) { await using var connection = await MariaDbDatabase.CreateAsync(); await using var db = Context(connection); db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" }); await db.SaveChangesAsync(); var controller = Controller(db); Assert.IsType((await controller.Put(Request("Rejected", expectedVersion), default)).Result); Assert.IsType((await controller.Restore(1, new(expectedVersion), default)).Result); var imports = ImportController(db); Assert.IsType((await imports.Parse(new("# Synthetic", expectedVersion))).Result); Assert.IsType(await imports.AcceptRun(1, new(null, expectedVersion))); Assert.Empty(await db.CareerProfiles.ToListAsync()); Assert.Empty(await db.CvExtractionRuns.ToListAsync()); } [Theory] [InlineData(true)] [InlineData(false)] public async Task Reviewed_import_commits_or_rolls_back_canonical_projection_and_application_metadata(bool identitySucceeds) { await using var connection = await MariaDbDatabase.CreateAsync(); await using var db = Context(connection); db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" }); var run = new CvExtractionRun { OwnerUserId = "rd21-owner", Trigger = "upload", Status = "pending_review", StructuredProfileJson = StructuredCvProfileJson.Serialize(new() { Jobs = { new() { Id = "imported-role", Title = "Reviewed role" } } }), }; db.CvExtractionRuns.Add(run); await db.SaveChangesAsync(); var result = await ImportController(db, identitySucceeds).AcceptRun(run.Id, new(null, 0)); if (identitySucceeds) Assert.IsType(result); else Assert.IsType(result); await db.SaveChangesAsync(); await using var verify = Context(connection); var savedRun = await verify.CvExtractionRuns.SingleAsync(); var user = await verify.Users.SingleAsync(); Assert.Equal(identitySucceeds ? "applied" : "pending_review", savedRun.Status); Assert.Equal(identitySucceeds, savedRun.AppliedAtUtc.HasValue); Assert.Equal(identitySucceeds, user.CurrentCvExtractionRunId.HasValue); Assert.Equal(identitySucceeds ? 1 : 0, await verify.CareerProfileVersions.CountAsync()); Assert.Equal(identitySucceeds ? 1 : 0, await verify.CareerExperiences.CountAsync()); Assert.Equal(identitySucceeds, user.ProfileCvStructureJson is not null); } [Fact] public async Task Import_acceptance_requires_the_revision_shown_in_its_diff() { await using var connection = await MariaDbDatabase.CreateAsync(); await using var db = Context(connection); db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" }); var run = new CvExtractionRun { OwnerUserId = "rd21-owner", Trigger = "upload", Status = "pending_review", StructuredProfileJson = "{}" }; db.CvExtractionRuns.Add(run); await db.SaveChangesAsync(); var imports = ImportController(db); var diff = Assert.IsType(await imports.GetRunDiff(run.Id)); Assert.Equal(0, JsonSerializer.SerializeToElement(diff.Value).GetProperty("revision").GetInt32()); Assert.IsType((await Controller(db).Put(Request("Winner", 0), default)).Result); Assert.IsType(await imports.AcceptRun(run.Id, new(null, 0))); await using var verify = Context(connection); Assert.Equal("pending_review", (await verify.CvExtractionRuns.SingleAsync()).Status); Assert.Equal("Winner", (await verify.CareerExperiences.SingleAsync()).Title); } [Theory] [InlineData(true)] [InlineData(false)] public async Task Parsing_commits_or_rolls_back_its_entire_import_application(bool identitySucceeds) { await using var connection = await MariaDbDatabase.CreateAsync(); await using var db = Context(connection); db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" }); await db.SaveChangesAsync(); const string text = "# Contact\nSynthetic Person\n\n# Professional Summary\nBuilt APIs.\n\n# Skills\nC#\nSQL"; var result = await ImportController(db, identitySucceeds).Parse(new(text, 0)); if (identitySucceeds) Assert.IsType(result.Result); else Assert.IsType(result.Result); await db.SaveChangesAsync(); await using var verify = Context(connection); Assert.Equal(identitySucceeds ? 1 : 0, await verify.CvExtractionRuns.CountAsync()); Assert.Equal(identitySucceeds ? 1 : 0, await verify.CareerProfileVersions.CountAsync()); var user = await verify.Users.SingleAsync(); Assert.Equal(identitySucceeds, user.CurrentCvExtractionRunId.HasValue); Assert.Equal(identitySucceeds, user.ProfileCvStructureJson is not null); if (identitySucceeds) { var profile = await verify.CareerProfiles.SingleAsync(); Assert.Equal(profile.ProfileJson, user.ProfileCvStructureJson); Assert.Equal(profile.ProfileJson, (await verify.CvExtractionRuns.SingleAsync()).StructuredProfileJson); Assert.Equal(user.ProfileCvText, (await new CareerProfileService(verify).LoadSnapshotAsync("rd21-owner", default)).CvText); } } [Fact] public async Task Editor_snapshot_uses_relational_canonical_source_and_never_writes_during_read() { await using var connection = await MariaDbDatabase.CreateAsync(); await using var db = Context(connection); db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic", ProfileCvText = "Stored source text" }); var profile = new CareerProfile { OwnerUserId = "rd21-owner", Version = 7, ProfileJson = StructuredCvProfileJson.SerializePersisted(new() { Jobs = { new() { Title = "Outdated projection" } } }), LongTailJson = CareerProfileMapper.SerializeLongTail(new() { Summary = { "Canonical summary" } }), Experiences = { new() { OwnerUserId = "rd21-owner", ItemKey = "canonical-role", Title = "Canonical role" } }, }; db.CareerProfiles.Add(profile); await db.SaveChangesAsync(); db.ChangeTracker.Clear(); var snapshot = await new CareerProfileService(db).LoadSnapshotAsync("rd21-owner", default); Assert.Equal(7, snapshot.Revision); Assert.Equal("Canonical role", snapshot.Profile.Jobs.Single().Title); Assert.Equal("canonical-role", snapshot.Profile.Jobs.Single().Id); Assert.Equal("Canonical summary", snapshot.Profile.Summary.Single()); Assert.Equal("Stored source text", snapshot.CvText); Assert.Empty(db.ChangeTracker.Entries()); Assert.Equal(7, (await db.CareerProfiles.SingleAsync()).Version); Assert.Single(await db.CareerExperiences.ToListAsync()); Assert.Empty(await db.CareerProfileVersions.ToListAsync()); } [Fact] public async Task Empty_canonical_profile_preserves_legacy_raw_text_in_its_revision_zero_snapshot() { await using var connection = await MariaDbDatabase.CreateAsync(); await using var db = Context(connection); db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic", ProfileCvText = "Legacy raw CV text" }); await db.SaveChangesAsync(); var response = await Controller(db).Get(default); var dto = Assert.IsType(Assert.IsType(response.Result).Value); Assert.Equal(0, dto.Revision); Assert.Empty(dto.Profile.Jobs); Assert.Equal("Legacy raw CV text", dto.CvText); Assert.Empty(await db.CareerProfiles.ToListAsync()); Assert.Empty(await db.CareerProfileVersions.ToListAsync()); } [Fact] public async Task Extraction_history_returns_only_the_ten_newest_owned_runs_with_exact_metadata() { await using var database = await MariaDbDatabase.CreateAsync(); var started = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); var owner = new ApplicationUser { Id = "rd21-owner", UserName = "history-owner@example.test" }; CvExtractionRun[] expected; Guid latestOperationId; await using (var seed = Context(database)) { seed.Users.AddRange(owner, new ApplicationUser { Id = "other-owner", UserName = "history-other@example.test" }); var owned = Enumerable.Range(0, 13).Select(index => new CvExtractionRun { OwnerUserId = owner.Id, Trigger = "upload", Status = index == 12 ? "failed" : "applied", Artifact = new CvUploadArtifact { OwnerUserId = owner.Id, OriginalFileName = $"owned-{index}.pdf", StoredFileName = $"stored-{index}.pdf", MimeType = "application/pdf", Sha256 = new string('a', 64), StoragePath = $"fixture-{index}", ByteSize = 32, }, StartedAtUtc = started.AddMinutes(index), CompletedAtUtc = started.AddMinutes(index).AddSeconds(10), AppliedAtUtc = index == 12 ? null : started.AddMinutes(index).AddSeconds(20), ParserVersion = $"parser-{index}", NormalizerVersion = $"normalizer-{index}", LlmPromptVersion = $"prompt-{index}", ErrorMessage = index == 12 ? "Synthetic parser failure" : null, }).ToArray(); seed.CvExtractionRuns.AddRange(owned); seed.CvExtractionRuns.AddRange(Enumerable.Range(0, 3).Select(index => new CvExtractionRun { OwnerUserId = "other-owner", Trigger = "private-upload", Status = "pending_review", StartedAtUtc = started.AddDays(1).AddMinutes(index), })); await seed.SaveChangesAsync(); expected = owned.OrderByDescending(run => run.StartedAtUtc).Take(10).ToArray(); var subject = expected[0].Id.ToString(System.Globalization.CultureInfo.InvariantCulture); var latest = new UserOperation { OwnerUserId = owner.Id, TaskType = CvProcessingQueue.TaskType, SubjectType = CvProcessingQueue.SubjectType, SubjectId = subject, IdempotencyKey = "history-latest", Status = OperationStatuses.Succeeded, CreatedAtUtc = started.AddHours(2).UtcDateTime, AvailableAtUtc = started.UtcDateTime, }; seed.UserOperations.AddRange(latest, new UserOperation { OwnerUserId = owner.Id, TaskType = CvProcessingQueue.TaskType, SubjectId = subject, IdempotencyKey = "history-older", Status = OperationStatuses.Failed, CreatedAtUtc = started.AddHours(1).UtcDateTime, AvailableAtUtc = started.UtcDateTime, }, new UserOperation { OwnerUserId = "other-owner", TaskType = CvProcessingQueue.TaskType, SubjectId = subject, IdempotencyKey = "history-private", Status = OperationStatuses.Running, CreatedAtUtc = started.AddDays(2).UtcDateTime, AvailableAtUtc = started.UtcDateTime, }); await seed.SaveChangesAsync(); latestOperationId = latest.Id; } await using var read = Context(database); var users = TestHostFactory.CreateUserManager(owner); users.Setup(manager => manager.GetUserAsync(It.IsAny())).ReturnsAsync(owner); var controller = new ProfileCvController(users.Object, Mock.Of(), read, null!) { ControllerContext = new() { HttpContext = new DefaultHttpContext() }, }; var response = Assert.IsType((await controller.GetRuns()).Result); Assert.Equal(200, response.StatusCode); var actual = Assert.IsAssignableFrom>(response.Value).ToArray(); Assert.Equal(10, actual.Length); Assert.Equal(expected.Select(run => run.Id), actual.Select(run => run.Id)); Assert.Equal(expected.Select(run => run.StartedAtUtc), actual.Select(run => run.StartedAtUtc)); for (var index = 0; index < actual.Length; index++) { Assert.Equal(expected[index].Trigger, actual[index].Trigger); Assert.Equal(expected[index].Status, actual[index].Status); Assert.Equal(expected[index].Artifact!.OriginalFileName, actual[index].ArtifactFileName); Assert.Equal(expected[index].CompletedAtUtc, actual[index].CompletedAtUtc); Assert.Equal(expected[index].AppliedAtUtc, actual[index].AppliedAtUtc); Assert.Equal(expected[index].ParserVersion, actual[index].ParserVersion); Assert.Equal(expected[index].NormalizerVersion, actual[index].NormalizerVersion); Assert.Equal(expected[index].LlmPromptVersion, actual[index].LlmPromptVersion); Assert.Equal(expected[index].ErrorMessage, actual[index].ErrorMessage); } Assert.Equal(latestOperationId, actual[0].Operation!.Id); Assert.Equal(OperationStatuses.Succeeded, actual[0].Operation!.Status); Assert.All(actual.Skip(1), run => Assert.Null(run.Operation)); Assert.Empty(read.ChangeTracker.Entries()); } }