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.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Moq; using Xunit; namespace JobTrackerApi.MariaDb.Tests; public class CvImportReviewedPersistenceTests { private readonly Xunit.Abstractions.ITestOutputHelper _output; public CvImportReviewedPersistenceTests(Xunit.Abstractions.ITestOutputHelper output) => _output = output; [Fact] public async Task Existing_profile_fields_survive_and_import_metadata_uses_the_canonical_revision() { await using var database = await MariaDbDatabase.CreateAsync(); await using var db = database.Context(); var run = await Seed(db); await new CareerProfileService(db).SaveVersionAsync(Owner, new() { Summary = { "Preserve my summary" }, Contact = new() { GitHub = "https://github.com/existing" }, OtherSections = { new() { Title = "Private", Items = { "Preserve my custom fact" } } } }, "manual", 0, default); (await db.Users.SingleAsync()).CurrentCvProfileVersion = 99; await db.SaveChangesAsync(); var controller = Controller(db); var clock = System.Diagnostics.Stopwatch.StartNew(); Assert.IsType(await controller.ApplyReviewed(run.Id, new(1, new[] { new CvImportDecision("skills.0", "use") }))); clock.Stop(); _output.WriteLine("ReviewedImportApplyMs={0:F3}; provider=MariaDB12.2.2; includes=validation,merge,transaction,Identity_projection", clock.Elapsed.TotalMilliseconds); await using var verify = database.Context(); var snapshot = await new CareerProfileService(verify).LoadSnapshotAsync(Owner, default); Assert.Equal(2, snapshot.Revision); Assert.Equal(2, snapshot.Profile.Metadata.ProfileVersion); Assert.Equal("Preserve my summary", Assert.Single(snapshot.Profile.Summary)); Assert.Equal("https://github.com/existing", snapshot.Profile.Contact.GitHub); Assert.Equal("Preserve my custom fact", Assert.Single(Assert.Single(snapshot.Profile.OtherSections).Items)); } private const string Owner = "rd21-owner"; private static ProfileCvController Controller(JobTrackerContext db, bool identitySucceeds = true) { var user = db.Users.Single(u => u.Id == Owner); var users = TestHostFactory.CreateUserManager(); users.Setup(u => u.GetUserAsync(It.IsAny())).ReturnsAsync(user); users.Setup(u => u.UpdateAsync(user)).Returns(async () => { if (!identitySucceeds) return IdentityResult.Failed(new IdentityError { Description = "Synthetic rollback" }); await db.SaveChangesAsync(); return IdentityResult.Success; }); return new(users.Object, Mock.Of(), db, null!) { ControllerContext = new() { HttpContext = new DefaultHttpContext() } }; } private static async Task Seed(JobTrackerContext db) { db.Users.Add(new() { Id = Owner, UserName = "import-synthetic" }); var run = new CvExtractionRun { OwnerUserId = Owner, Trigger = "document_import", ParserVersion = ProfileCvController.DocumentParserVersion, Status = "pending_review", RawExtractedText = "Skills\nC#\nSQL", DocumentJson = "{\"synthetic\":true}", StructuredProfileJson = StructuredCvProfileJson.Serialize(new() { Skills = { "C#", "SQL" } }), }; db.CvExtractionRuns.Add(run); await db.SaveChangesAsync(); return run; } [Theory] [InlineData(true)] [InlineData(false)] public async Task Reviewed_selection_commits_once_or_rolls_back_entire_projection(bool succeeds) { await using var database = await MariaDbDatabase.CreateAsync(); await using var db = database.Context(); var run = await Seed(db); var request = new ProfileCvController.ApplyReviewedRequest(0, new[] { new CvImportDecision("skills.0", "use"), new CvImportDecision("skills.1", "keep") }); var result = await Controller(db, succeeds).ApplyReviewed(run.Id, request); if (succeeds) { Assert.IsType(result); Assert.IsType(await Controller(db).ApplyReviewed(run.Id, request)); Assert.IsType(await Controller(db).ApplyReviewed(run.Id, new(0, new[] { new CvImportDecision("skills.1", "use") }))); } else Assert.IsType(result); await db.SaveChangesAsync(); await using var verify = database.Context(); var saved = await verify.CvExtractionRuns.SingleAsync(); Assert.Equal(succeeds ? "applied" : "pending_review", saved.Status); Assert.Equal(succeeds, saved.ReviewJson is not null); Assert.Equal(succeeds ? 1 : 0, await verify.CareerProfileVersions.CountAsync()); Assert.Equal(succeeds ? new[] { "C#" } : Array.Empty(), await verify.CareerSkills.Select(s => s.Name).ToArrayAsync()); Assert.Equal(succeeds, (await verify.Users.SingleAsync()).CurrentCvExtractionRunId.HasValue); } [Fact] public async Task Stale_review_does_not_overwrite_acknowledged_edit_or_mark_run_applied() { await using var database = await MariaDbDatabase.CreateAsync(); await using var db = database.Context(); var run = await Seed(db); await new CareerProfileService(db).SaveVersionAsync(Owner, new() { Skills = { "Saved elsewhere" } }, "manual", 0, default); Assert.IsType(await Controller(db).ApplyReviewed(run.Id, new(0, new[] { new CvImportDecision("skills.0", "use") }))); await db.SaveChangesAsync(); await using var verify = database.Context(); Assert.Equal("Saved elsewhere", (await new CareerProfileService(verify).LoadStructuredAsync(Owner, default)).Skills.Single()); Assert.Equal("pending_review", (await verify.CvExtractionRuns.SingleAsync()).Status); Assert.Single(await verify.CareerProfileVersions.ToListAsync()); } [Fact] public async Task Review_owner_boundary_and_unknown_fields_fail_closed() { await using var database = await MariaDbDatabase.CreateAsync(); await using var db = database.Context(); var run = await Seed(db); Assert.IsType(await Controller(db).ApplyReviewed(run.Id, new(0, new[] { new CvImportDecision("metadata.owner", "correct", "other") }))); (await db.CvExtractionRuns.SingleAsync()).OwnerUserId = "another-owner"; await db.SaveChangesAsync(); Assert.IsType(await Controller(db).GetImportReview(run.Id)); Assert.IsType(await Controller(db).ApplyReviewed(run.Id, new(0, Array.Empty()))); Assert.Empty(await db.CareerProfiles.ToListAsync()); } [Fact] public async Task Additive_import_schema_down_up_preserves_canonical_and_historical_facts() { await using var database = await MariaDbDatabase.CreateAsync(); await using var db = database.Context(); var run = await Seed(db); await new CareerProfileService(db).SaveVersionAsync(Owner, new() { Skills = { "Existing" }, Contact = new() { GitHub = "https://github.com/example" } }, "manual", 0, default); var before = (await db.CareerProfiles.SingleAsync()).ProfileJson; await db.GetService().MigrateAsync("20261025090000_AddCoverLetterDocumentDesign"); db.ChangeTracker.Clear(); await db.GetService().MigrateAsync(); Assert.Equal(before, (await db.CareerProfiles.AsNoTracking().SingleAsync()).ProfileJson); Assert.Equal(before, (await db.CareerProfileVersions.AsNoTracking().SingleAsync()).ProfileJson); Assert.Null((await db.CvExtractionRuns.AsNoTracking().SingleAsync()).ReviewJson); Assert.Equal("pending_review", (await db.CvExtractionRuns.AsNoTracking().SingleAsync()).Status); Assert.Empty(await db.Database.GetPendingMigrationsAsync()); } }