From 12f342c09c715353dfe641481cd28a442d3f5dff Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 23 Oct 2026 08:07:23 +0200 Subject: [PATCH] fix(rd21): guard career and job saves with durable revisions --- JobTrackerApi.Tests/ApplicationAssetsTests.cs | 28 +- .../AuthAndSystemControllerTests.cs | 27 +- .../CareerProfileControllerTests.cs | 12 +- .../CareerProfileServiceTests.cs | 42 +-- JobTrackerApi.Tests/CvAiReadinessTests.cs | 8 +- JobTrackerApi.Tests/CvBuilderTests.cs | 4 +- .../CvRevisionReadinessTests.cs | 4 +- .../JobApplicationsApplicationPackageTests.cs | 10 +- .../JobApplicationsEndpointBehaviorTests.cs | 10 +- .../JobApplicationsMariaDraftTests.cs | 2 +- JobTrackerApi.Tests/MigrationChainTests.cs | 27 ++ .../ProfileCvControllerTests.cs | 24 +- .../Rd21CareerPersistenceTests.cs | 353 ++++++++++++++++++ .../Rd21JobPersistenceTests.cs | 307 +++++++++++++++ .../SqliteDateTimeOffsetCompatibilityTests.cs | 2 +- .../ApplicationAssetsController.cs | 9 +- JobTrackerApi/Controllers/AuthController.cs | 8 +- .../Controllers/CareerProfileController.cs | 64 ++-- .../Controllers/JobApplicationDtos.cs | 17 +- .../Controllers/JobApplicationsController.cs | 38 +- .../ProfileCvController.Pipeline.cs | 93 ++--- .../Controllers/ProfileCvController.cs | 79 ++-- JobTrackerApi/Controllers/ProfileCvDtos.cs | 2 +- JobTrackerApi/Data/JobTrackerContext.cs | 25 ++ .../20261023090000_AddJobRevision.cs | 25 ++ .../JobTrackerContextModelSnapshot.cs | 7 + JobTrackerApi/Models/JobApplication.cs | 1 + JobTrackerApi/Program.cs | 1 + .../Services/ApplicationAssetsService.cs | 22 +- .../Services/ApplicationWorkspaceService.cs | 6 +- .../Services/CareerProfileService.cs | 183 ++++++--- .../RevisionConflictExceptionHandler.cs | 22 ++ 32 files changed, 1185 insertions(+), 277 deletions(-) create mode 100644 JobTrackerApi.Tests/Rd21CareerPersistenceTests.cs create mode 100644 JobTrackerApi.Tests/Rd21JobPersistenceTests.cs create mode 100644 JobTrackerApi/Migrations/20261023090000_AddJobRevision.cs create mode 100644 JobTrackerApi/Services/RevisionConflictExceptionHandler.cs diff --git a/JobTrackerApi.Tests/ApplicationAssetsTests.cs b/JobTrackerApi.Tests/ApplicationAssetsTests.cs index 5675d07..cd25693 100644 --- a/JobTrackerApi.Tests/ApplicationAssetsTests.cs +++ b/JobTrackerApi.Tests/ApplicationAssetsTests.cs @@ -201,7 +201,7 @@ public sealed class ApplicationAssetsTests 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); + var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Dear team", CoverLetterSources.Manual, null, job.Version, default); Assert.Equal("Dear team", result!.Text); Assert.Equal(1, result.CurrentVersion); @@ -217,9 +217,9 @@ public sealed class ApplicationAssetsTests 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); + await assets.SaveCoverLetterAsync("user-1", job.Id, "First draft", CoverLetterSources.Manual, null, job.Version, default); + await assets.SaveCoverLetterAsync("user-1", job.Id, "Second draft", CoverLetterSources.Ai, "improve", job.Version, default); + var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Third draft", CoverLetterSources.Manual, null, job.Version, default); Assert.Equal(3, result!.CurrentVersion); Assert.Equal(3, result.Versions.Count); @@ -234,8 +234,8 @@ public sealed class ApplicationAssetsTests 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); + await assets.SaveCoverLetterAsync("user-1", job.Id, "Same text", CoverLetterSources.Manual, null, job.Version, default); + var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Same text", CoverLetterSources.Manual, null, job.Version, default); Assert.Single(result!.Versions); } @@ -246,10 +246,10 @@ public sealed class ApplicationAssetsTests 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); + await assets.SaveCoverLetterAsync("user-1", job.Id, "The good draft", CoverLetterSources.Manual, null, job.Version, default); + await assets.SaveCoverLetterAsync("user-1", job.Id, "The AI ruined it", CoverLetterSources.Ai, "shorten", job.Version, default); - var result = await assets.RestoreCoverLetterAsync("user-1", job.Id, 1, default); + var result = await assets.RestoreCoverLetterAsync("user-1", job.Id, 1, job.Version, default); Assert.Equal("The good draft", result!.Text); // Restore is additive: v1 and v2 both survive, and the restore is v3. @@ -265,9 +265,9 @@ public sealed class ApplicationAssetsTests 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); + await assets.SaveCoverLetterAsync("user-1", job.Id, "Only draft", CoverLetterSources.Manual, null, job.Version, default); - Assert.Null(await assets.RestoreCoverLetterAsync("user-1", job.Id, 99, default)); + Assert.Null(await assets.RestoreCoverLetterAsync("user-1", job.Id, 99, job.Version, default)); } [Fact] @@ -277,11 +277,11 @@ public sealed class ApplicationAssetsTests 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); + await assets.SaveCoverLetterAsync("user-1", mine.Id, "Mine", CoverLetterSources.Manual, null, mine.Version, 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)); + Assert.Null(await assets.SaveCoverLetterAsync("user-1", theirs.Id, "Sneak", CoverLetterSources.Manual, null, theirs.Version, default)); + Assert.Null(await assets.RestoreCoverLetterAsync("user-1", theirs.Id, 1, theirs.Version, default)); } [Fact] diff --git a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs index d65affd..3689146 100644 --- a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs +++ b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs @@ -564,28 +564,23 @@ public sealed class AuthAndSystemControllerTests Assert.Equal("{\"jobs\":[]}", user.ProfileCvStructureJson); } - [Fact] - public async Task Career_only_save_does_not_wipe_identity() + [Theory] + [InlineData("stale text", null)] + [InlineData(null, "{\"summary\":[\"stale\"]}")] + [InlineData("", "")] + public async Task Legacy_profile_endpoint_cannot_bypass_canonical_revision_guards(string? text, string? structured) { - // The mirror: /career saves the master profile and omits identity fields. var user = new ApplicationUser { - Email = "ada@example.com", - UserName = "ada", - FirstName = "Ada", - LastName = "Lovelace", - DisplayName = "Ada L.", + UserName = "ada", FirstName = "Ada", ProfileCvText = "Approved text", ProfileCvStructureJson = "Approved structure", }; var controller = BuildProfileController(user); - - var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(null, null, null, null, null, "New CV text", "{\"jobs\":[1]}")); - - Assert.IsType(result); + var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest( + null, null, "Stale identity", null, null, text, structured)); + Assert.IsType(result); Assert.Equal("Ada", user.FirstName); - Assert.Equal("Lovelace", user.LastName); - Assert.Equal("Ada L.", user.DisplayName); - Assert.Equal("New CV text", user.ProfileCvText); - Assert.Equal("{\"jobs\":[1]}", user.ProfileCvStructureJson); + Assert.Equal("Approved text", user.ProfileCvText); + Assert.Equal("Approved structure", user.ProfileCvStructureJson); } [Fact] diff --git a/JobTrackerApi.Tests/CareerProfileControllerTests.cs b/JobTrackerApi.Tests/CareerProfileControllerTests.cs index da6d4ac..5b24869 100644 --- a/JobTrackerApi.Tests/CareerProfileControllerTests.cs +++ b/JobTrackerApi.Tests/CareerProfileControllerTests.cs @@ -47,7 +47,7 @@ public sealed class CareerProfileControllerTests { var (controller, _, user) = Build(); - var put = await controller.Put(new CareerProfileSaveRequest(Sample(), null), CancellationToken.None); + var put = await controller.Put(new CareerProfileSaveRequest(Sample(), null, 0), CancellationToken.None); Assert.IsType(put.Result); // The derived blob projection is kept in sync for legacy readers. @@ -78,7 +78,7 @@ public sealed class CareerProfileControllerTests Notes = "Used with distributed teams", }); - var put = await controller.Put(new CareerProfileSaveRequest(profile, null), CancellationToken.None); + var put = await controller.Put(new CareerProfileSaveRequest(profile, null, 0), CancellationToken.None); var saved = Assert.IsType(Assert.IsType(put.Result).Value).Profile; var get = await controller.Get(CancellationToken.None); var reloaded = Assert.IsType(Assert.IsType(get.Result).Value).Profile; @@ -104,7 +104,7 @@ public sealed class CareerProfileControllerTests public async Task Get_reports_completeness_with_missing_sections() { var (controller, _, _) = Build(); - await controller.Put(new CareerProfileSaveRequest(Sample(), null), CancellationToken.None); // has personal + experience + skills + await controller.Put(new CareerProfileSaveRequest(Sample(), null, 0), CancellationToken.None); // has personal + experience + skills var get = await controller.Get(CancellationToken.None); var dto = Assert.IsType(Assert.IsType(get.Result).Value); @@ -121,7 +121,7 @@ public sealed class CareerProfileControllerTests var huge = new StructuredCvProfile(); for (var i = 0; i < 500; i++) huge.Skills.Add($"skill-{i}"); - var put = await controller.Put(new CareerProfileSaveRequest(huge, null), CancellationToken.None); + var put = await controller.Put(new CareerProfileSaveRequest(huge, null, 0), CancellationToken.None); Assert.IsType(put.Result); } @@ -133,7 +133,7 @@ public sealed class CareerProfileControllerTests var profile = Sample(); profile.Contact.Website = $"https://example.test/{new string('a', 600)}"; - var put = await controller.Put(new CareerProfileSaveRequest(profile, null), CancellationToken.None); + var put = await controller.Put(new CareerProfileSaveRequest(profile, null, 0), CancellationToken.None); var badRequest = Assert.IsType(put.Result); Assert.Equal("A contact field exceeds the allowed length.", badRequest.Value); @@ -144,7 +144,7 @@ public sealed class CareerProfileControllerTests { var (controller, _, _) = Build(); - var put = await controller.Put(new CareerProfileSaveRequest(new StructuredCvProfile(), null), CancellationToken.None); + var put = await controller.Put(new CareerProfileSaveRequest(new StructuredCvProfile(), null, 0), CancellationToken.None); // Completeness, not validation, is what flags an incomplete profile — an empty profile saves. Assert.IsType(put.Result); diff --git a/JobTrackerApi.Tests/CareerProfileServiceTests.cs b/JobTrackerApi.Tests/CareerProfileServiceTests.cs index cc13f0d..fa82cef 100644 --- a/JobTrackerApi.Tests/CareerProfileServiceTests.cs +++ b/JobTrackerApi.Tests/CareerProfileServiceTests.cs @@ -29,7 +29,7 @@ public sealed class CareerProfileServiceTests Jobs = { new StructuredCvJob { Title = "Engineer", Company = "Acme" } }, }; - var saved = await service.SaveVersionAsync("user-1", profile, "upload", default); + var saved = await service.SaveVersionAsync("user-1", profile, "upload", 0, default); Assert.False(string.IsNullOrWhiteSpace(saved.Jobs[0].Id)); } @@ -45,7 +45,7 @@ public sealed class CareerProfileServiceTests Education = { new StructuredCvEducation { Id = "shared", Qualification = "BSc", Institution = "University" } }, }; - var saved = await service.SaveVersionAsync("user-1", profile, "manual", default); + var saved = await service.SaveVersionAsync("user-1", profile, "manual", 0, default); Assert.Equal("shared", saved.Jobs[0].Id); Assert.NotEqual(saved.Jobs[0].Id, saved.Education[0].Id); @@ -80,10 +80,10 @@ public sealed class CareerProfileServiceTests { Jobs = { new StructuredCvJob { Title = "Engineer", Company = "Acme" } }, }; - await service.SaveVersionAsync("user-1", profile, "upload", default); + await service.SaveVersionAsync("user-1", profile, "upload", 0, default); var firstId = profile.Jobs[0].Id; - await service.SaveVersionAsync("user-1", profile, "rebuild", default); + await service.SaveVersionAsync("user-1", profile, "rebuild", 1, default); Assert.Equal(firstId, profile.Jobs[0].Id); } @@ -108,8 +108,8 @@ public sealed class CareerProfileServiceTests var service = new CareerProfileService(db); var profile = new StructuredCvProfile { Summary = { "First version" } }; - await service.SaveVersionAsync("user-1", profile, "upload", default); - await service.SaveVersionAsync("user-1", profile, "improve", default); + await service.SaveVersionAsync("user-1", profile, "upload", 0, default); + await service.SaveVersionAsync("user-1", profile, "improve", 1, default); var snapshots = await db.CareerProfiles.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync(); var history = await db.CareerProfileVersions.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync(); @@ -130,7 +130,7 @@ public sealed class CareerProfileServiceTests Jobs = { new StructuredCvJob { Title = "Engineer", Start = "Jan 2020", End = "Present", IsCurrent = true } }, }; - var saved = await service.SaveVersionAsync("user-1", profile, "upload", default); + var saved = await service.SaveVersionAsync("user-1", profile, "upload", 0, default); Assert.Equal("2020-01", saved.Jobs[0].StartDate); Assert.Null(saved.Jobs[0].EndDate); @@ -165,7 +165,7 @@ public sealed class CareerProfileServiceTests await using var db = NewContext("user-1"); var service = new CareerProfileService(db); - await service.SaveVersionAsync("user-1", RichProfile(), "manual", default); + await service.SaveVersionAsync("user-1", RichProfile(), "manual", 0, default); var loaded = await service.LoadStructuredAsync("user-1", default); Assert.Equal("Ada Lovelace", loaded.Contact.FullName); @@ -196,7 +196,7 @@ public sealed class CareerProfileServiceTests var service = new CareerProfileService(db); var profile = RichProfile(); - await service.SaveVersionAsync("user-1", profile, "manual", default); + await service.SaveVersionAsync("user-1", profile, "manual", 0, default); var jobKey = profile.Jobs[0].Id; var loaded = await service.LoadStructuredAsync("user-1", default); @@ -213,9 +213,9 @@ public sealed class CareerProfileServiceTests await using var db = NewContext("user-1"); var service = new CareerProfileService(db); - await service.SaveVersionAsync("user-1", RichProfile(), "manual", default); // 2 jobs + await service.SaveVersionAsync("user-1", RichProfile(), "manual", 0, default); // 2 jobs var trimmed = new StructuredCvProfile { Jobs = { new StructuredCvJob { Title = "Only role", Company = "Solo" } } }; - await service.SaveVersionAsync("user-1", trimmed, "manual", default); // 1 job + await service.SaveVersionAsync("user-1", trimmed, "manual", 1, default); // 1 job var experiences = await db.CareerExperiences.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync(); Assert.Single(experiences); @@ -225,7 +225,7 @@ public sealed class CareerProfileServiceTests } [Fact] - public async Task Load_backfills_relational_children_from_a_pre_phase3_blob() + public async Task Load_reads_a_pre_phase3_blob_without_mutating_canonical_children() { await using var db = NewContext("user-1"); // Simulate a profile that predates Phase 3: a CareerProfile with a ProfileJson blob but no @@ -239,8 +239,8 @@ public sealed class CareerProfileServiceTests var loaded = await service.LoadStructuredAsync("user-1", default); Assert.Equal(2, loaded.Jobs.Count); - // The backfill materialized the relational rows. - Assert.Equal(2, await db.CareerExperiences.IgnoreQueryFilters().CountAsync(x => x.OwnerUserId == "user-1")); + // Reads cannot materialize unversioned children alongside a concurrent replacement. + Assert.Equal(0, await db.CareerExperiences.IgnoreQueryFilters().CountAsync(x => x.OwnerUserId == "user-1")); } [Fact] @@ -260,8 +260,8 @@ public sealed class CareerProfileServiceTests { await using var db = NewContext("user-1"); var service = new CareerProfileService(db); - await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "v1" } }, "upload", default); - await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "v2" } }, "improve", default); + await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "v1" } }, "upload", 0, default); + await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "v2" } }, "improve", 1, default); var versions = await service.ListVersionsAsync("user-1", default); @@ -276,10 +276,10 @@ public sealed class CareerProfileServiceTests { await using var db = NewContext("user-1"); var service = new CareerProfileService(db); - await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "original" } }, "upload", default); // v1 - await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "edited" } }, "manual", default); // v2 + await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "original" } }, "upload", 0, default); // v1 + await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "edited" } }, "manual", 1, default); // v2 - var restored = await service.RestoreVersionAsync("user-1", 1, default); + var restored = await service.RestoreVersionAsync("user-1", 1, 2, default); Assert.NotNull(restored); Assert.Equal(new[] { "original" }, restored!.Summary); @@ -296,8 +296,8 @@ public sealed class CareerProfileServiceTests { await using var db = NewContext("user-1"); var service = new CareerProfileService(db); - await service.SaveVersionAsync("user-1", new StructuredCvProfile(), "upload", default); + await service.SaveVersionAsync("user-1", new StructuredCvProfile(), "upload", 0, default); - Assert.Null(await service.RestoreVersionAsync("user-1", 99, default)); + Assert.Null(await service.RestoreVersionAsync("user-1", 99, 1, default)); } } diff --git a/JobTrackerApi.Tests/CvAiReadinessTests.cs b/JobTrackerApi.Tests/CvAiReadinessTests.cs index f5e0b1a..357c12f 100644 --- a/JobTrackerApi.Tests/CvAiReadinessTests.cs +++ b/JobTrackerApi.Tests/CvAiReadinessTests.cs @@ -39,7 +39,7 @@ public sealed class CvAiReadinessTests Db = new(new DbContextOptionsBuilder().UseSqlite(Connection).Options, current.Object); await Db.Database.EnsureCreatedAsync(); Career = new(Db); - await Career.SaveVersionAsync("owner", Profile, "manual", default); + await Career.SaveVersionAsync("owner", Profile, "manual", 0, default); Variants = new(Db, Career, new ThemedCvRenderer()); Cv = await Variants.CreateAsync("owner", "Synthetic CV", null, new CvVariantSettings(), default); Suggestions = new(Db, Career, Variants, Ai.Object, Protection); @@ -150,7 +150,7 @@ public sealed class CvAiReadinessTests if (canonical) { fixture.Profile.Summary = ["Newer canonical user content"]; - await fixture.Career.SaveVersionAsync("owner", fixture.Profile, "manual", default); + await fixture.Career.SaveVersionAsync("owner", fixture.Profile, "manual", 1, default); } else await fixture.Variants.SaveAsync("owner", fixture.Cv.Id, null, new CvVariantSettings { Headline = "Newer CV user content" }, "manual", 1, default); @@ -191,7 +191,7 @@ public sealed class CvAiReadinessTests .Returns(async () => { fixture.Profile.Summary = ["Newer canonical content"]; - await fixture.Career.SaveVersionAsync("owner", fixture.Profile, "manual", default); + await fixture.Career.SaveVersionAsync("owner", fixture.Profile, "manual", 1, default); return new AiGenerationResult(Assessment(shortened, Source)); }); var error = await Assert.ThrowsAsync(() => fixture.Suggestions.ApplyAsync("owner", fixture.Cv.Id, proposal.ProposalToken, shortened, default)); @@ -359,7 +359,7 @@ public sealed class CvAiReadinessTests { await using var fixture = new Fixture(); await fixture.Open(); fixture.Profile.Jobs = [new() { Id = "experience-key", Title = "Engineer", Company = "Nordlys", Bullets = { Source } }]; - await fixture.Career.SaveVersionAsync("owner", fixture.Profile, "manual", default); + await fixture.Career.SaveVersionAsync("owner", fixture.Profile, "manual", 1, default); await fixture.Variants.SaveAsync("owner", fixture.Cv.Id, null, new CvVariantSettings { AccentColor = "#123456", PageMarginMm = 22, Sections = { new() { Key = "experience" }, new() { Key = "summary" } } }, "manual", 1, default); fixture.Ai.Setup(value => value.GenerateSectionWithMetadataAsync(It.IsAny(), It.IsAny(), AiGenerationOutputMode.CvBlocksJson, It.IsAny(), It.IsAny(), It.IsAny())) diff --git a/JobTrackerApi.Tests/CvBuilderTests.cs b/JobTrackerApi.Tests/CvBuilderTests.cs index 94fb8fb..72e2592 100644 --- a/JobTrackerApi.Tests/CvBuilderTests.cs +++ b/JobTrackerApi.Tests/CvBuilderTests.cs @@ -459,7 +459,7 @@ public sealed class CvBuilderTests { var (db, svc) = NewService("user-1"); await using var _ = db; - await new CareerProfileService(db).SaveVersionAsync("user-1", Rich(), "manual", default); + await new CareerProfileService(db).SaveVersionAsync("user-1", Rich(), "manual", 0, default); var v = await svc.CreateAsync("user-1", "CV", null, new CvVariantSettings { ThemeId = "modern" }, default); var render = await svc.RenderAsync("user-1", v.Id, new CvRenderPerson("Fallback", null), default); @@ -473,7 +473,7 @@ public sealed class CvBuilderTests { var dbName = Guid.NewGuid().ToString(); var (ownerDb, ownerSvc) = NewService("user-1", dbName); - await new CareerProfileService(ownerDb).SaveVersionAsync("user-1", Rich(), "manual", default); + await new CareerProfileService(ownerDb).SaveVersionAsync("user-1", Rich(), "manual", 0, default); var v = await ownerSvc.CreateAsync("user-1", "CV", null, new CvVariantSettings { ThemeId = "minimal" }, default); await ownerSvc.SetPublicAsync("user-1", v.Id, true, default); await ownerDb.DisposeAsync(); diff --git a/JobTrackerApi.Tests/CvRevisionReadinessTests.cs b/JobTrackerApi.Tests/CvRevisionReadinessTests.cs index df50c85..37e5ac8 100644 --- a/JobTrackerApi.Tests/CvRevisionReadinessTests.cs +++ b/JobTrackerApi.Tests/CvRevisionReadinessTests.cs @@ -78,7 +78,7 @@ public sealed class CvRevisionReadinessTests Jobs = { new() { Id = "source-entry", Title = "Canonical role", Company = "Nordlys", Bullets = { "Canonical evidence" } } }, Skills = { "C#", "Norsk" }, }; - await career.SaveVersionAsync("revision-owner", profile, "manual", default); + await career.SaveVersionAsync("revision-owner", profile, "manual", 0, default); var service = Service(db); var lens = new CvVariantSettings { @@ -97,7 +97,7 @@ public sealed class CvRevisionReadinessTests // History is the immutable lens; inherited source values intentionally remain live. profile.Contact.Headline = "Updated canonical source"; profile.Jobs[0].Title = "Updated canonical role"; - await career.SaveVersionAsync("revision-owner", profile, "manual", default); + await career.SaveVersionAsync("revision-owner", profile, "manual", 1, default); Assert.Equal(originalHistory, (await db.CvVariantVersions.SingleAsync()).SettingsJson); var reset = new CvVariantSettings { ThemeId = "minimal" }; await service.SaveAsync("revision-owner", cv.Id, null, reset, "manual", 1, default); diff --git a/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs b/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs index efbe8a5..09a9b7b 100644 --- a/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs @@ -39,7 +39,7 @@ public sealed class JobApplicationsApplicationPackageTests await db.SaveChangesAsync(); var controller = CreateController(db, Mock.Of(), "user-1"); - var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(null, "Updated notes block", null), CancellationToken.None); + var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(null, "Updated notes block", null, ExpectedVersion: job.Version), CancellationToken.None); Assert.IsType(result); var saved = await db.JobApplications.FirstAsync(); @@ -68,7 +68,7 @@ public sealed class JobApplicationsApplicationPackageTests var controller = CreateController(db, Mock.Of(), "user-1"); var updated = await controller.SaveApplicationDrafts( job.Id, - new SaveApplicationDraftsRequest(null, null, " New recruiter message ", " New answer "), + new SaveApplicationDraftsRequest(null, null, " New recruiter message ", " New answer ", ExpectedVersion: job.Version), CancellationToken.None); Assert.IsType(updated); @@ -79,7 +79,7 @@ public sealed class JobApplicationsApplicationPackageTests job.Notes += "\n\n<<>>\nDuplicate stale answer\n<<>>"; var cleared = await controller.SaveApplicationDrafts( job.Id, - new SaveApplicationDraftsRequest(null, null, "", ""), + new SaveApplicationDraftsRequest(null, null, "", "", ExpectedVersion: job.Version), CancellationToken.None); Assert.IsType(cleared); @@ -109,7 +109,7 @@ public sealed class JobApplicationsApplicationPackageTests var controller = CreateController(db, Mock.Of(), "user-1"); var result = await controller.SaveApplicationDrafts( otherUsersJob.Id, - new SaveApplicationDraftsRequest(null, null, "Changed", "Changed"), + new SaveApplicationDraftsRequest(null, null, "Changed", "Changed", ExpectedVersion: otherUsersJob.Version), CancellationToken.None); Assert.IsType(result); @@ -424,7 +424,7 @@ public sealed class JobApplicationsApplicationPackageTests generated.Education, generated.CustomSections, generated.RenderOptions, - "edited"), CancellationToken.None); + "edited", ExpectedVersion: generated.Revision), CancellationToken.None); Assert.IsType(saveResult); diff --git a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs index c94f432..7db2d1a 100644 --- a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs @@ -28,7 +28,7 @@ public sealed class JobApplicationsEndpointBehaviorTests await db.SaveChangesAsync(); var controller = CreateController(db, "user-1"); - var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(" Cover letter body ", " Notes body ", null), CancellationToken.None); + var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(" Cover letter body ", " Notes body ", null, ExpectedVersion: job.Version), CancellationToken.None); Assert.IsType(result); var saved = await db.JobApplications.FirstAsync(); @@ -272,7 +272,8 @@ public sealed class JobApplicationsEndpointBehaviorTests JobUrl: null, DateApplied: null, FeedbackRequestedAt: null, - StatusChangedAt: null); + StatusChangedAt: null, + ExpectedVersion: job.Version); var result = await controller.Update(job.Id, request, CancellationToken.None); @@ -340,7 +341,8 @@ public sealed class JobApplicationsEndpointBehaviorTests JobUrl: null, DateApplied: null, FeedbackRequestedAt: null, - StatusChangedAt: null); + StatusChangedAt: null, + ExpectedVersion: job.Version); Assert.IsType(await controller.Update(job.Id, request, CancellationToken.None)); @@ -348,7 +350,7 @@ public sealed class JobApplicationsEndpointBehaviorTests summarizer.Verify(service => service.SummarizeAsync(It.IsAny(), 160, 60), Times.Once); Assert.Contains(await db.JobEvents.ToListAsync(), item => item.Type == "AiRefreshed" && item.Note!.Contains("automatically")); - request = request with { Description = request.Description + " " }; + request = request with { Description = request.Description + " ", ExpectedVersion = job.Version }; Assert.IsType(await controller.Update(job.Id, request, CancellationToken.None)); summarizer.Verify(service => service.SummarizeAsync(It.IsAny(), 160, 60), Times.Once); } diff --git a/JobTrackerApi.Tests/JobApplicationsMariaDraftTests.cs b/JobTrackerApi.Tests/JobApplicationsMariaDraftTests.cs index e1651b6..422ce4c 100644 --- a/JobTrackerApi.Tests/JobApplicationsMariaDraftTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsMariaDraftTests.cs @@ -28,7 +28,7 @@ public sealed class JobApplicationsMariaDraftTests await db.SaveChangesAsync(); var controller = CreateController(db, "user-1"); - var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(null, null, " Recruiter hello "), CancellationToken.None); + var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(null, null, " Recruiter hello ", ExpectedVersion: job.Version), CancellationToken.None); Assert.IsType(result); var saved = await db.JobApplications.FirstAsync(); diff --git a/JobTrackerApi.Tests/MigrationChainTests.cs b/JobTrackerApi.Tests/MigrationChainTests.cs index 16b986d..fcebfbe 100644 --- a/JobTrackerApi.Tests/MigrationChainTests.cs +++ b/JobTrackerApi.Tests/MigrationChainTests.cs @@ -15,6 +15,33 @@ public sealed class MigrationChainTests { private const string SnapshotMigration = "20260711181039_SyncModelSnapshot"; + [Fact] + public async Task Rd21_revision_upgrade_and_rollback_preserve_existing_job_content() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + await using var db = Context(connection); + var migrator = db.GetService(); + const string previous = "20261021090000_AddDashboardLayoutPreference"; + await migrator.MigrateAsync(previous); + await ExecuteAsync(connection, """ + INSERT INTO Companies (Name) VALUES ('RD21 migration fixture'); + INSERT INTO JobApplications + (CompanyId, JobTitle, Status, ResponseReceived, OwnerUserId, SavedAt, Notes, + HasResume, HasCoverLetter, HasPortfolio, HasOtherAttachment, IsDeleted) + VALUES (1, 'Durable role', 'Saved', 0, 'rd21-owner', '2026-10-23', 'Keep these notes', 0, 0, 0, 0, 0); + """); + await migrator.MigrateAsync(); + Assert.Equal(1L, await ScalarAsync(connection, "SELECT Version FROM JobApplications WHERE Id=1;")); + Assert.Equal("Keep these notes", await ScalarAsync(connection, "SELECT Notes FROM JobApplications WHERE Id=1;")); + await migrator.MigrateAsync(previous); + Assert.Equal("Durable role", await ScalarAsync(connection, "SELECT JobTitle FROM JobApplications WHERE Id=1;")); + Assert.Equal("Keep these notes", await ScalarAsync(connection, "SELECT Notes FROM JobApplications WHERE Id=1;")); + Assert.Equal(0L, await ScalarAsync(connection, "SELECT COUNT(*) FROM pragma_table_info('JobApplications') WHERE name='Version';")); + await migrator.MigrateAsync(); + Assert.Equal(1L, await ScalarAsync(connection, "SELECT Version FROM JobApplications WHERE Id=1;")); + } + [Fact] public async Task Blank_sqlite_chain_reaches_latest_and_is_idempotent() { diff --git a/JobTrackerApi.Tests/ProfileCvControllerTests.cs b/JobTrackerApi.Tests/ProfileCvControllerTests.cs index f266d4f..ec149ea 100644 --- a/JobTrackerApi.Tests/ProfileCvControllerTests.cs +++ b/JobTrackerApi.Tests/ProfileCvControllerTests.cs @@ -92,7 +92,7 @@ public sealed class ProfileCvControllerTests Assert.Null(user.CurrentCvExtractionRunId); Assert.IsType(await controller.GetRunDiff(run.Id)); - Assert.IsType(await controller.AcceptRun(run.Id)); + Assert.IsType(await controller.AcceptRun(run.Id, new(null, 0))); var parsed = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); Assert.Equal("applied", run.Status); @@ -906,7 +906,7 @@ public sealed class ProfileCvControllerTests var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths); - var result = await controller.Parse(new ParseCvRequest(user.ProfileCvText)); + var result = await controller.Parse(new ParseCvRequest(user.ProfileCvText, 0)); var ok = Assert.IsType(result.Result); var json = JsonSerializer.Serialize(ok.Value); @@ -940,7 +940,7 @@ public sealed class ProfileCvControllerTests var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths); - var result = await controller.Parse(new ParseCvRequest(user.ProfileCvText)); + var result = await controller.Parse(new ParseCvRequest(user.ProfileCvText, 0)); var ok = Assert.IsType(result.Result); var json = JsonSerializer.Serialize(ok.Value); @@ -978,7 +978,7 @@ public sealed class ProfileCvControllerTests var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object); - var result = await controller.Parse(new ParseCvRequest(source)); + var result = await controller.Parse(new ParseCvRequest(source, 0)); var ok = Assert.IsType(result.Result); var json = JsonSerializer.Serialize(ok.Value); @@ -1018,7 +1018,7 @@ public sealed class ProfileCvControllerTests var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object); - var result = await controller.Parse(new ParseCvRequest(source)); + var result = await controller.Parse(new ParseCvRequest(source, 0)); var ok = Assert.IsType(result.Result); var json = JsonSerializer.Serialize(ok.Value); @@ -1054,7 +1054,7 @@ public sealed class ProfileCvControllerTests var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object); - var result = await controller.Parse(new ParseCvRequest(source)); + var result = await controller.Parse(new ParseCvRequest(source, 0)); var ok = Assert.IsType(result.Result); var json = JsonSerializer.Serialize(ok.Value); @@ -1237,7 +1237,7 @@ public sealed class ProfileCvControllerTests await using var db = CreateDb(); var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths(), null, normalizer.Object); - var result = await controller.Parse(new ParseCvRequest(source)); + var result = await controller.Parse(new ParseCvRequest(source, 0)); Assert.IsType(result.Result); var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); @@ -1276,7 +1276,7 @@ public sealed class ProfileCvControllerTests var paths = CreatePaths(); var controller = CreateController(userManager.Object, aiService.Object, db, paths); - var result = await controller.Parse(new ParseCvRequest(rawSource)); + var result = await controller.Parse(new ParseCvRequest(rawSource, 0)); var ok = Assert.IsType(result.Result); Assert.NotNull(ok.Value); @@ -1305,7 +1305,7 @@ public sealed class ProfileCvControllerTests await using var db = CreateDb(); var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths()); - var result = await controller.Parse(new ParseCvRequest(source)); + var result = await controller.Parse(new ParseCvRequest(source, 0)); Assert.IsType(result.Result); var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); @@ -1336,7 +1336,7 @@ public sealed class ProfileCvControllerTests await using var db = CreateDb(); var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths()); - var result = await controller.Parse(new ParseCvRequest(source)); + var result = await controller.Parse(new ParseCvRequest(source, 0)); Assert.IsType(result.Result); var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); @@ -1365,7 +1365,7 @@ public sealed class ProfileCvControllerTests await using var db = CreateDb(); var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths()); - var result = await controller.Parse(new ParseCvRequest(source)); + var result = await controller.Parse(new ParseCvRequest(source, 0)); Assert.IsType(result.Result); var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); @@ -1393,7 +1393,7 @@ public sealed class ProfileCvControllerTests await using var db = CreateDb(); var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths()); - var result = await controller.Parse(new ParseCvRequest(source)); + var result = await controller.Parse(new ParseCvRequest(source, 0)); Assert.IsType(result.Result); var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); diff --git a/JobTrackerApi.Tests/Rd21CareerPersistenceTests.cs b/JobTrackerApi.Tests/Rd21CareerPersistenceTests.cs new file mode 100644 index 0000000..0ac7142 --- /dev/null +++ b/JobTrackerApi.Tests/Rd21CareerPersistenceTests.cs @@ -0,0 +1,353 @@ +using System.Security.Claims; +using System.Text.Json; +using JobTrackerApi.Controllers; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using JobTrackerApi.Tests.TestSupport; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class Rd21CareerPersistenceTests +{ + private static JobTrackerContext Context(SqliteConnection connection, IInterceptor? interceptor = null) + { + var current = new Mock(); + current.SetupGet(x => x.UserId).Returns("rd21-owner"); + var options = new DbContextOptionsBuilder().UseSqlite(connection); + if (interceptor is not null) options.AddInterceptors(interceptor); + return new(options.Options, current.Object); + } + + 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 = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + await using (var seed = Context(connection)) + { + await seed.Database.EnsureCreatedAsync(); + 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 = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + await using var db = Context(connection); + await db.Database.EnsureCreatedAsync(); + 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 = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + await using var first = Context(connection); + await first.Database.EnsureCreatedAsync(); + 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() + { + var path = Path.Combine(Path.GetTempPath(), $"rd21-first-save-{Guid.NewGuid():N}.db"); + try + { + await using (var setupConnection = new SqliteConnection($"Data Source={path};Pooling=False")) + { + await setupConnection.OpenAsync(); + await using var setup = Context(setupConnection); + await setup.Database.EnsureCreatedAsync(); + setup.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic" }); + await setup.SaveChangesAsync(); + } + using var ready = new Barrier(2); + async Task Save(string title) => await Task.Run(async () => + { + await using var connection = new SqliteConnection($"Data Source={path};Pooling=False"); + await connection.OpenAsync(); + await using var db = Context(connection); + var controller = Controller(db); + Assert.Equal(0, Assert.IsType(Assert.IsType((await controller.Get(default)).Result).Value).Revision); + Assert.True(ready.SignalAndWait(TimeSpan.FromSeconds(10))); + return (await controller.Put(Request(title, 0), default)).Result; + }); + var outcomes = await Task.WhenAll(Save("Candidate A"), Save("Candidate B")); + Assert.Single(outcomes.OfType()); + Assert.Single(outcomes.OfType()); + await using var verifyConnection = new SqliteConnection($"Data Source={path};Pooling=False"); + await verifyConnection.OpenAsync(); + await using var verify = Context(verifyConnection); + 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); + } + finally { File.Delete(path); } + } + + 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 = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + await using (var seed = Context(connection)) + { + await seed.Database.EnsureCreatedAsync(); + 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()); + } + + [Theory] + [InlineData(null)] + [InlineData(-1)] + public async Task Replacement_restore_parse_and_import_fail_closed_without_valid_revision(int? expectedVersion) + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + await using var db = Context(connection); + await db.Database.EnsureCreatedAsync(); + 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 = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + await using var db = Context(connection); + await db.Database.EnsureCreatedAsync(); + 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 = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + await using var db = Context(connection); + await db.Database.EnsureCreatedAsync(); + 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 = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + await using var db = Context(connection); + await db.Database.EnsureCreatedAsync(); + 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 = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + await using var db = Context(connection); + await db.Database.EnsureCreatedAsync(); + 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 = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + await using var db = Context(connection); + await db.Database.EnsureCreatedAsync(); + 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()); + } +} diff --git a/JobTrackerApi.Tests/Rd21JobPersistenceTests.cs b/JobTrackerApi.Tests/Rd21JobPersistenceTests.cs new file mode 100644 index 0000000..f18305c --- /dev/null +++ b/JobTrackerApi.Tests/Rd21JobPersistenceTests.cs @@ -0,0 +1,307 @@ +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()); + } +} diff --git a/JobTrackerApi.Tests/SqliteDateTimeOffsetCompatibilityTests.cs b/JobTrackerApi.Tests/SqliteDateTimeOffsetCompatibilityTests.cs index 810b436..55952b8 100644 --- a/JobTrackerApi.Tests/SqliteDateTimeOffsetCompatibilityTests.cs +++ b/JobTrackerApi.Tests/SqliteDateTimeOffsetCompatibilityTests.cs @@ -42,7 +42,7 @@ public sealed class SqliteDateTimeOffsetCompatibilityTests db.Attachments.Add(attachment); await db.SaveChangesAsync(); var career = new CareerProfileService(db); - await career.SaveVersionAsync("user-1", JsonSerializer.Deserialize("{\"version\":\"1\",\"contact\":{\"fullName\":\"Åse\",\"headline\":\"Original profile\"}}", new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!, "manual", default); + await career.SaveVersionAsync("user-1", JsonSerializer.Deserialize("{\"version\":\"1\",\"contact\":{\"fullName\":\"Åse\",\"headline\":\"Original profile\"}}", new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!, "manual", 0, default); var variants = new CvVariantService(db, career, new ThemedCvRenderer()); var assets = new ApplicationAssetsService(db, variants, new ApplicationIntelligenceService(db, new JobCvMatchService())); Assert.NotNull(await assets.GetTailoringPlanAsync("user-1", job.Id, default)); diff --git a/JobTrackerApi/Controllers/ApplicationAssetsController.cs b/JobTrackerApi/Controllers/ApplicationAssetsController.cs index 57ced8c..b689db3 100644 --- a/JobTrackerApi/Controllers/ApplicationAssetsController.cs +++ b/JobTrackerApi/Controllers/ApplicationAssetsController.cs @@ -19,7 +19,8 @@ namespace JobTrackerApi.Controllers; public sealed class ApplicationAssetsController : ControllerBase { public sealed record AttachVariantRequest(int? VariantId); - public sealed record SaveCoverLetterRequest(string? Text, string? Source, string? AiAction); + public sealed record SaveCoverLetterRequest(string? Text, string? Source, string? AiAction, int? ExpectedVersion = null); + public sealed record RestoreCoverLetterRequest(int? ExpectedVersion); private readonly UserManager _users; private readonly IApplicationAssetsService _assets; @@ -74,16 +75,16 @@ public sealed class ApplicationAssetsController : ControllerBase var userId = await CurrentUserIdAsync(); if (userId is null) return Unauthorized(); var result = await _assets.SaveCoverLetterAsync( - userId, jobId, request?.Text, request?.Source ?? CoverLetterSources.Manual, request?.AiAction, ct); + userId, jobId, request?.Text, request?.Source ?? CoverLetterSources.Manual, request?.AiAction, request?.ExpectedVersion, ct); return result is null ? NotFound() : Ok(result); } [HttpPost("cover-letter/versions/{version:int}/restore")] - public async Task> RestoreCoverLetter(int jobId, int version, CancellationToken ct) + public async Task> RestoreCoverLetter(int jobId, int version, [FromBody] RestoreCoverLetterRequest? request, CancellationToken ct) { var userId = await CurrentUserIdAsync(); if (userId is null) return Unauthorized(); - var result = await _assets.RestoreCoverLetterAsync(userId, jobId, version, ct); + var result = await _assets.RestoreCoverLetterAsync(userId, jobId, version, request?.ExpectedVersion, ct); return result is null ? NotFound() : Ok(result); } diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index 2385d16..a22b804 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -516,12 +516,14 @@ public sealed class AuthController : ControllerBase return StatusCode(501, "Profile updates are only supported for local username/password accounts."); } + if (request.ProfileCvText is not null || request.ProfileCvStructureJson is not null) + return BadRequest("Career data must be saved through /api/career/profile with its source revision."); + // Partial update. A field is only touched when the request actually carries it: // - null / omitted -> leave unchanged (the caller isn't editing this field) // - "" -> explicitly clear // - "value" -> set (trimmed) - // This lets /profile save identity fields and /career save the master-profile fields - // through the same endpoint without one wiping the other. Email and UserName are the + // Career data uses its own atomic, revision-guarded endpoint. Email and UserName are the // login identifiers and are never cleared to empty. Email ownership changes use the // separate, token-confirmed email-change flow below. if (request.UserName is not null) @@ -532,8 +534,6 @@ public sealed class AuthController : ControllerBase if (request.FirstName is not null) user.FirstName = TrimOrNull(request.FirstName); if (request.LastName is not null) user.LastName = TrimOrNull(request.LastName); if (request.DisplayName is not null) user.DisplayName = TrimOrNull(request.DisplayName); - if (request.ProfileCvText is not null) user.ProfileCvText = TrimOrNull(request.ProfileCvText); - if (request.ProfileCvStructureJson is not null) user.ProfileCvStructureJson = TrimOrNull(request.ProfileCvStructureJson); var res = await _users.UpdateAsync(user); if (!res.Succeeded) diff --git a/JobTrackerApi/Controllers/CareerProfileController.cs b/JobTrackerApi/Controllers/CareerProfileController.cs index be7cc1c..80e4ef8 100644 --- a/JobTrackerApi/Controllers/CareerProfileController.cs +++ b/JobTrackerApi/Controllers/CareerProfileController.cs @@ -4,6 +4,7 @@ using JobTrackerApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Controllers; @@ -35,8 +36,8 @@ public sealed class CareerProfileController : ControllerBase var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); - var profile = await _career.LoadStructuredAsync(user.Id, cancellationToken); - return Ok(new CareerProfileDto(profile, CareerCompleteness.Evaluate(profile), user.ProfileCvText)); + var snapshot = await _career.LoadSnapshotAsync(user.Id, cancellationToken); + return Ok(new CareerProfileDto(snapshot.Profile, CareerCompleteness.Evaluate(snapshot.Profile), snapshot.CvText, snapshot.Revision)); } /// @@ -50,21 +51,27 @@ public sealed class CareerProfileController : ControllerBase { var user = await _users.GetUserAsync(User); if (user is null) return StatusCode(501, "The career profile can only be edited on local accounts."); + if (request is null || request.ExpectedVersion is null or < 0) return BadRequest("expectedVersion is required."); - var profile = StructuredCvProfileJson.NormalizeForPersistence(request?.Profile); + var profile = StructuredCvProfileJson.NormalizeForPersistence(request.Profile); var error = CareerProfileValidator.Validate(profile); if (error is not null) return BadRequest(error); - var saved = await _career.SaveVersionAsync(user.Id, profile, "manual", cancellationToken); - - // Keep the derived projection in sync for legacy readers. CvText (the raw imported text) is - // part of the career profile and is set here too; identity fields are never touched. - user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(saved); - if (request?.CvText is not null) user.ProfileCvText = string.IsNullOrWhiteSpace(request.CvText) ? null : request.CvText; - var res = await _users.UpdateAsync(user); - if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description))); - - return Ok(new CareerProfileDto(saved, CareerCompleteness.Evaluate(saved), user.ProfileCvText)); + try + { + var dto = await _career.ExecuteAtomicAsync(async () => + { + var saved = await _career.SaveVersionAsync(user.Id, profile, "manual", request.ExpectedVersion.Value, cancellationToken); + user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(saved); + if (request.CvText is not null) user.ProfileCvText = string.IsNullOrWhiteSpace(request.CvText) ? null : request.CvText; + var res = await _users.UpdateAsync(user); + if (!res.Succeeded) throw new CareerProjectionException(string.Join("; ", res.Errors.Select(e => e.Description))); + return new CareerProfileDto(saved, CareerCompleteness.Evaluate(saved), user.ProfileCvText, request.ExpectedVersion.Value + 1); + }, cancellationToken); + return Ok(dto); + } + catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Reload and review your draft before saving." }); } + catch (CareerProjectionException ex) { return BadRequest(ex.Message); } } /// Just the completeness scorecard, for the /career overview. @@ -92,25 +99,34 @@ public sealed class CareerProfileController : ControllerBase /// [HttpPost("versions/{version:int}/restore")] [Authorize(AuthenticationSchemes = "local")] - public async Task> Restore([FromRoute] int version, CancellationToken cancellationToken) + public async Task> Restore([FromRoute] int version, [FromBody] CareerProfileRestoreRequest? request, CancellationToken cancellationToken) { var user = await _users.GetUserAsync(User); if (user is null) return StatusCode(501, "The career profile can only be edited on local accounts."); + if (request is null || request.ExpectedVersion is null or < 0) return BadRequest("expectedVersion is required."); - var restored = await _career.RestoreVersionAsync(user.Id, version, cancellationToken); - if (restored is null) return NotFound($"Version {version} was not found."); - - user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(restored); - var res = await _users.UpdateAsync(user); - if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description))); - - return Ok(new CareerProfileDto(restored, CareerCompleteness.Evaluate(restored), user.ProfileCvText)); + try + { + var dto = await _career.ExecuteAtomicAsync(async () => + { + var restored = await _career.RestoreVersionAsync(user.Id, version, request.ExpectedVersion.Value, cancellationToken); + if (restored is null) return null; + user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(restored); + var res = await _users.UpdateAsync(user); + if (!res.Succeeded) throw new CareerProjectionException(string.Join("; ", res.Errors.Select(e => e.Description))); + return new CareerProfileDto(restored, CareerCompleteness.Evaluate(restored), user.ProfileCvText, request.ExpectedVersion.Value + 1); + }, cancellationToken); + return dto is null ? NotFound($"Version {version} was not found.") : Ok(dto); + } + catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Reload before restoring a version." }); } + catch (CareerProjectionException ex) { return BadRequest(ex.Message); } } } -public sealed record CareerProfileSaveRequest(StructuredCvProfile? Profile, string? CvText); +public sealed record CareerProfileSaveRequest(StructuredCvProfile? Profile, string? CvText, int? ExpectedVersion = null); +public sealed record CareerProfileRestoreRequest(int? ExpectedVersion); -public sealed record CareerProfileDto(StructuredCvProfile Profile, CareerCompletenessDto Completeness, string? CvText); +public sealed record CareerProfileDto(StructuredCvProfile Profile, CareerCompletenessDto Completeness, string? CvText, int Revision); public sealed record CareerCompletenessDto(int Percent, List Missing, List Sections); diff --git a/JobTrackerApi/Controllers/JobApplicationDtos.cs b/JobTrackerApi/Controllers/JobApplicationDtos.cs index 244d8df..29d1a45 100644 --- a/JobTrackerApi/Controllers/JobApplicationDtos.cs +++ b/JobTrackerApi/Controllers/JobApplicationDtos.cs @@ -79,7 +79,8 @@ namespace JobTrackerApi.Controllers string? TailoredCvText, WorkflowSignalDto WorkflowSignal, string? ShortSummary, - string? FullSummary + string? FullSummary, + int Version = 1 ); public sealed record CreateJobApplicationRequest( @@ -132,7 +133,8 @@ namespace JobTrackerApi.Controllers string? JobUrl, DateTime? DateApplied, DateTime? FeedbackRequestedAt, - DateTime? StatusChangedAt + DateTime? StatusChangedAt, + int? ExpectedVersion = null ); public sealed record UpdateStatusRequest(string Status); @@ -189,7 +191,7 @@ namespace JobTrackerApi.Controllers CandidateFitChannelGuidanceDto Guidance, string? CoverLetterDraft, string? RecruiterMessageDraft); - public sealed record SaveTailoredCvRequest(string? TailoredCvText); + public sealed record SaveTailoredCvRequest(string? TailoredCvText, int? ExpectedVersion = null); public sealed record TailoredCvDraftDto( int? Id, int? CanonicalProfileVersion, @@ -206,7 +208,8 @@ namespace JobTrackerApi.Controllers DateTimeOffset? LastEditedAtUtc, string Status, string RenderedText, - bool IsLegacyFallback); + bool IsLegacyFallback, + int Revision = 1); public sealed record SaveTailoredCvDraftRequest( string? TemplateId, string? Headline, @@ -216,13 +219,15 @@ namespace JobTrackerApi.Controllers List? Education, List? CustomSections, TailoredCvRenderOptions? RenderOptions, - string? Status); + string? Status, + int? ExpectedVersion = null); public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List KeyPoints, List AttachmentSignals, List AttachmentFilesUsed, List CoverLetterVariants, List RecruiterMessageVariants); public sealed record SaveApplicationDraftsRequest( string? CoverLetterText, string? Notes, string? RecruiterMessageDraft, - string? ApplicationAnswerDraft = null); + string? ApplicationAnswerDraft = null, + int? ExpectedVersion = null); public sealed record SavedPackageMaterial(string? TailoredCvText, string? CoverLetterText, string? RecruiterMessageDraft, string? Notes); public sealed record InterviewPrepDto(string Summary, List TalkingPoints, List LikelyQuestions, List WeakSpots); public sealed record ReadinessDto(int Score, string Level, List Completed, List Missing, List Reminders, WorkflowSignalDto WorkflowSignal); diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 132317c..c57cfb0 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -459,7 +459,8 @@ Canonical profile: TailoredCvText: job.TailoredCvText, WorkflowSignal: workflowSignal, ShortSummary: job.ShortSummary, - FullSummary: fullSummary); + FullSummary: fullSummary, + Version: job.Version); } [HttpGet] @@ -840,6 +841,9 @@ Canonical profile: var job = await _db.JobApplications.Include(j => j.Job).FirstOrDefaultAsync(j => j.Id == id, cancellationToken); if (job is null) return NotFound(); + if (request.ExpectedVersion is null or < 1 || request.ExpectedVersion != job.Version) + return Conflict(new { code = "revision_conflict", message = "This job changed or its revision is missing. Your draft was not saved. Reload and review before saving again." }); + var oldStatus = job.Status; var oldResponseReceived = job.ResponseReceived; var oldResponseDate = job.ResponseDate; @@ -850,6 +854,8 @@ Canonical profile: var title = (request.JobTitle ?? "").Trim(); if (title.Length == 0) return BadRequest("Job title is required."); if (request.CompanyId <= 0) return BadRequest("Valid companyId is required."); + if (!await _db.Companies.AnyAsync(x => x.Id == request.CompanyId, cancellationToken)) + return BadRequest("Company was not found."); job.JobTitle = title; job.CompanyId = request.CompanyId; @@ -926,7 +932,19 @@ Canonical profile: JobLifecycleEvents.RecordStatusChange(_db, job, oldStatus, request.StatusChangedAt ?? DateTime.Now); if (job.Job is not null) JobOpportunitySync.Apply(job, job.Job); - await _db.SaveChangesAsync(cancellationToken); + _db.Entry(job).Property(x => x.Version).IsModified = true; + try { await _db.SaveChangesAsync(cancellationToken); } + catch (DbUpdateConcurrencyException) + { + _db.ChangeTracker.Clear(); + return Conflict(new { code = "revision_conflict", message = "This job changed while saving. Your draft was not saved. Reload and review before saving again." }); + } + catch (DbUpdateException) + { + _db.ChangeTracker.Clear(); + throw; + } + Response.Headers["X-Job-Version"] = job.Version.ToString(System.Globalization.CultureInfo.InvariantCulture); return NoContent(); } @@ -1397,7 +1415,7 @@ Candidate CV/profile: .AsNoTracking() .FirstOrDefaultAsync(x => x.JobApplicationId == id, cancellationToken); - return Ok(draft is null ? ToLegacyTailoredCvDraftDto(job) : ToTailoredCvDraftDto(draft)); + return Ok((draft is null ? ToLegacyTailoredCvDraftDto(job) : ToTailoredCvDraftDto(draft)) with { Revision = job.Version }); } [HttpPost("{id:int}/tailored-cv-preview")] @@ -1486,7 +1504,7 @@ Candidate CV/profile: } var draft = await UpsertGeneratedTailoredCvDraftAsync(job, user, mode, cancellationToken); - return Ok(ToTailoredCvDraftDto(draft)); + return Ok(ToTailoredCvDraftDto(draft) with { Revision = job.Version }); } [HttpPut("{id:int}/tailored-cv-draft")] @@ -1494,6 +1512,8 @@ Candidate CV/profile: { var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken); if (job is null) return NotFound(); + if (request.ExpectedVersion is null or < 1 || request.ExpectedVersion != job.Version) + return Conflict(new { code = "revision_conflict", message = "The application changed. Your CV draft was not saved. Reload and review before saving again." }); var user = await GetCurrentUserAsync(cancellationToken); if (user is null) return Unauthorized(); @@ -1531,7 +1551,9 @@ Candidate CV/profile: job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document); job.TailoredCvUpdatedAt = DateTime.UtcNow; + _db.Entry(job).Property(x => x.Version).IsModified = true; await _db.SaveChangesAsync(cancellationToken); + Response.Headers["X-Job-Version"] = job.Version.ToString(System.Globalization.CultureInfo.InvariantCulture); return NoContent(); } @@ -1540,10 +1562,14 @@ Candidate CV/profile: { var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken); if (job is null) return NotFound(); + if (request.ExpectedVersion is null or < 1 || request.ExpectedVersion != job.Version) + return Conflict(new { code = "revision_conflict", message = "The application changed. Your draft was not saved. Reload and review before saving again." }); job.TailoredCvText = string.IsNullOrWhiteSpace(request.TailoredCvText) ? null : request.TailoredCvText.Trim(); job.TailoredCvUpdatedAt = job.TailoredCvText is null ? null : DateTime.UtcNow; + _db.Entry(job).Property(x => x.Version).IsModified = true; await _db.SaveChangesAsync(cancellationToken); + Response.Headers["X-Job-Version"] = job.Version.ToString(System.Globalization.CultureInfo.InvariantCulture); return NoContent(); } @@ -1552,6 +1578,8 @@ Candidate CV/profile: { var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken); if (job is null) return NotFound(); + if (request.ExpectedVersion is null or < 1 || request.ExpectedVersion != job.Version) + return Conflict(new { code = "revision_conflict", message = "The application changed. Your drafts were not saved. Reload and review before saving again." }); if (request.CoverLetterText is not null) { @@ -1575,7 +1603,9 @@ Candidate CV/profile: : request.RecruiterMessageDraft.Trim(); } + _db.Entry(job).Property(x => x.Version).IsModified = true; await _db.SaveChangesAsync(cancellationToken); + Response.Headers["X-Job-Version"] = job.Version.ToString(System.Globalization.CultureInfo.InvariantCulture); return NoContent(); } diff --git a/JobTrackerApi/Controllers/ProfileCvController.Pipeline.cs b/JobTrackerApi/Controllers/ProfileCvController.Pipeline.cs index 7f6c1c2..09e4201 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.Pipeline.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.Pipeline.cs @@ -248,53 +248,60 @@ public sealed partial class ProfileCvController : ControllerBase return new ExtractionPipelineResult(text, normalizedText, structuredCv); } - private async Task ApplyTextExtractionRunAsync(ApplicationUser user, string trigger, string rawText, string normalizedText, StructuredCvProfile structuredCv, int? artifactId, CancellationToken cancellationToken) + private async Task ApplyTextExtractionRunAsync(ApplicationUser user, string trigger, string rawText, string normalizedText, StructuredCvProfile structuredCv, int? artifactId, int expectedVersion, CancellationToken cancellationToken) { - var run = new CvExtractionRun + await _careerProfileService.ExecuteAtomicAsync(async () => { - OwnerUserId = user.Id, - ArtifactId = artifactId, - Trigger = trigger, - ParserVersion = ParserVersion, - NormalizerVersion = NormalizerVersion, - LlmPromptVersion = LlmPromptVersion, - Status = "applied", - RawExtractedText = rawText, - NormalizedText = normalizedText, - StartedAtUtc = DateTimeOffset.UtcNow, - CompletedAtUtc = DateTimeOffset.UtcNow, - AppliedAtUtc = DateTimeOffset.UtcNow, - }; - _db.CvExtractionRuns.Add(run); - await _db.SaveChangesAsync(cancellationToken); - - structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1; - structuredCv.Metadata.AppliedExtractionRunId = run.Id; - structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow; - await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, trigger, cancellationToken); - var structuredJson = StructuredCvProfileJson.Serialize(structuredCv); - run.StructuredProfileJson = structuredJson; - - user.ProfileCvText = normalizedText; - user.ProfileCvStructureJson = structuredJson; - user.CurrentCvExtractionRunId = run.Id; - user.CurrentCvProfileVersion = structuredCv.Metadata.ProfileVersion; - if (artifactId.HasValue) - { - user.CurrentCvUploadArtifactId = artifactId.Value; - } - - var update = await _users.UpdateAsync(user); - if (!update.Succeeded) - { - run.Status = "failed"; - run.ErrorMessage = string.Join("; ", update.Errors.Select(e => e.Description)); + var run = new CvExtractionRun + { + OwnerUserId = user.Id, + ArtifactId = artifactId, + Trigger = trigger, + ParserVersion = ParserVersion, + NormalizerVersion = NormalizerVersion, + LlmPromptVersion = LlmPromptVersion, + Status = "applied", + RawExtractedText = rawText, + NormalizedText = normalizedText, + StartedAtUtc = DateTimeOffset.UtcNow, + CompletedAtUtc = DateTimeOffset.UtcNow, + AppliedAtUtc = DateTimeOffset.UtcNow, + }; + _db.CvExtractionRuns.Add(run); await _db.SaveChangesAsync(cancellationToken); - throw new InvalidOperationException(run.ErrorMessage); - } - await _db.SaveChangesAsync(cancellationToken); - await PruneExtractionRunsAsync(user.Id, cancellationToken); + structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1; + structuredCv.Metadata.AppliedExtractionRunId = run.Id; + structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow; + await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, trigger, expectedVersion, cancellationToken); + var structuredJson = StructuredCvProfileJson.SerializePersisted(structuredCv); + run.StructuredProfileJson = structuredJson; + + user.ProfileCvText = normalizedText; + user.ProfileCvStructureJson = structuredJson; + user.CurrentCvExtractionRunId = run.Id; + user.CurrentCvProfileVersion = structuredCv.Metadata.ProfileVersion; + if (artifactId.HasValue) + { + user.CurrentCvUploadArtifactId = artifactId.Value; + } + + var update = await _users.UpdateAsync(user); + if (!update.Succeeded) + { + throw new CareerProjectionException(string.Join("; ", update.Errors.Select(e => e.Description))); + } + + return true; + }, cancellationToken); + + // Retention is maintenance; a failure cannot turn a committed profile into a rejected save. + try { await PruneExtractionRunsAsync(user.Id, cancellationToken); } + catch (Exception ex) + { + _db.ChangeTracker.Clear(); + _logger.LogWarning(ex, "Could not prune CV extraction history after the profile commit for user {UserId}", user.Id); + } } private async Task CreateQueuedRunAsync(string ownerUserId, int? artifactId, string trigger, CancellationToken cancellationToken) diff --git a/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs index 8d8abd4..078a0ad 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.cs @@ -162,7 +162,7 @@ public sealed partial class ProfileCvController : ControllerBase // in-file copies are dropped here to avoid duplicate definitions. The LayoutFamily/AtsRating // fields the branch added to CvTemplateDescriptor belong to the deferred ATS-badge work // (Phase 4), not this foundation integration. - public sealed record AcceptCvRunRequest(List? AcceptedLowConfidenceIds); + public sealed record AcceptCvRunRequest(List? AcceptedLowConfidenceIds, int? ExpectedVersion = null); private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv); private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification); @@ -258,9 +258,10 @@ public sealed partial class ProfileCvController : ControllerBase if (run is null) return NotFound(); if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile."); - var current = await _careerProfileService.LoadStructuredAsync(user.Id, HttpContext.RequestAborted); + var snapshot = await _careerProfileService.LoadSnapshotAsync(user.Id, HttpContext.RequestAborted); + var current = snapshot.Profile; var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson); - return Ok(new { runId = run.Id, run.Status, diff = _cvProfileDiffService.Diff(current, extracted) }); + return Ok(new { runId = run.Id, run.Status, revision = snapshot.Revision, diff = _cvProfileDiffService.Diff(current, extracted) }); } [HttpPost("runs/{id:int}/accept")] @@ -268,34 +269,45 @@ public sealed partial class ProfileCvController : ControllerBase { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); + if (request is null || request.ExpectedVersion is null or < 0) return BadRequest("expectedVersion is required."); - var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted); - if (run is null) return NotFound(); - if (run.Status != "pending_review") return Conflict("This extraction run is not awaiting review."); - if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile."); + try + { + return await _careerProfileService.ExecuteAtomicAsync(async () => + { + var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted); + if (run is null) return NotFound(); + if (run.Status != "pending_review") return Conflict("This extraction run is not awaiting review."); + if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile."); - var current = await _careerProfileService.LoadStructuredAsync(user.Id, HttpContext.RequestAborted); - var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson); - var diff = _cvProfileDiffService.Diff(current, extracted); - var acceptedLowConfidenceIds = (request?.AcceptedLowConfidenceIds ?? new List()).ToHashSet(StringComparer.Ordinal); - var merged = _cvProfileDiffService.Merge(current, extracted, acceptedLowConfidenceIds); - merged.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1; - merged.Metadata.AppliedExtractionRunId = run.Id; - merged.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow; - await _careerProfileService.SaveVersionAsync(user.Id, merged, $"{run.Trigger}:accepted", HttpContext.RequestAborted); + var snapshot = await _careerProfileService.LoadSnapshotAsync(user.Id, HttpContext.RequestAborted); + if (snapshot.Revision != request.ExpectedVersion.Value) + throw new DbUpdateConcurrencyException("The career profile changed after import review."); + var current = snapshot.Profile; + var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson); + var diff = _cvProfileDiffService.Diff(current, extracted); + var acceptedLowConfidenceIds = (request.AcceptedLowConfidenceIds ?? new List()).ToHashSet(StringComparer.Ordinal); + var merged = _cvProfileDiffService.Merge(current, extracted, acceptedLowConfidenceIds); + merged.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1; + merged.Metadata.AppliedExtractionRunId = run.Id; + merged.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow; + await _careerProfileService.SaveVersionAsync(user.Id, merged, $"{run.Trigger}:accepted", request.ExpectedVersion.Value, HttpContext.RequestAborted); - user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(merged); - if (string.IsNullOrWhiteSpace(user.ProfileCvText)) user.ProfileCvText = run.NormalizedText; - user.CurrentCvExtractionRunId = run.Id; - user.CurrentCvProfileVersion = merged.Metadata.ProfileVersion; - if (run.ArtifactId.HasValue) user.CurrentCvUploadArtifactId = run.ArtifactId.Value; + user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(merged); + if (string.IsNullOrWhiteSpace(user.ProfileCvText)) user.ProfileCvText = run.NormalizedText; + user.CurrentCvExtractionRunId = run.Id; + user.CurrentCvProfileVersion = merged.Metadata.ProfileVersion; + if (run.ArtifactId.HasValue) user.CurrentCvUploadArtifactId = run.ArtifactId.Value; - run.Status = "applied"; - run.AppliedAtUtc = DateTimeOffset.UtcNow; - var update = await _users.UpdateAsync(user); - if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(e => e.Description))); - await _db.SaveChangesAsync(HttpContext.RequestAborted); - return Ok(new { runId = run.Id, run.Status, diff, structuredCv = merged }); + run.Status = "applied"; + run.AppliedAtUtc = DateTimeOffset.UtcNow; + var update = await _users.UpdateAsync(user); + if (!update.Succeeded) throw new CareerProjectionException(string.Join("; ", update.Errors.Select(e => e.Description))); + return Ok(new { runId = run.Id, run.Status, diff, structuredCv = merged, revision = request.ExpectedVersion.Value + 1 }); + }, HttpContext.RequestAborted); + } + catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Review the import again before accepting." }); } + catch (CareerProjectionException ex) { return BadRequest(ex.Message); } } [HttpPost("runs/{id:int}/discard")] @@ -522,19 +534,20 @@ public sealed partial class ProfileCvController : ControllerBase { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); + if (request is null || request.ExpectedVersion is null or < 0) return BadRequest("expectedVersion is required."); - var source = string.IsNullOrWhiteSpace(request?.Text) ? user.ProfileCvText : request!.Text; + var source = string.IsNullOrWhiteSpace(request.Text) ? user.ProfileCvText : request.Text; if (string.IsNullOrWhiteSpace(source)) return BadRequest("Add or import CV text before parsing sections."); var normalizedSource = await MaybeReconstructStructuredCvAsync(source, HttpContext.RequestAborted); var structuredCv = await BuildStructuredCvAsync(normalizedSource, HttpContext.RequestAborted); - if (string.IsNullOrWhiteSpace(request?.Text)) + try { - user.ProfileCvText = normalizedSource; + await ApplyTextExtractionRunAsync(user, "parse", source, normalizedSource, structuredCv, user.CurrentCvUploadArtifactId, request.ExpectedVersion.Value, HttpContext.RequestAborted); + return Ok(new { structuredCv, sections = structuredCv.Sections, totalWords = CountWords(normalizedSource), extractionRunId = user.CurrentCvExtractionRunId, profileVersion = user.CurrentCvProfileVersion, revision = request.ExpectedVersion.Value + 1 }); } - await ApplyTextExtractionRunAsync(user, "parse", source, normalizedSource, structuredCv, user.CurrentCvUploadArtifactId, HttpContext.RequestAborted); - - return Ok(new { structuredCv, sections = structuredCv.Sections, totalWords = CountWords(normalizedSource), extractionRunId = user.CurrentCvExtractionRunId, profileVersion = user.CurrentCvProfileVersion }); + catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Reload before parsing." }); } + catch (CareerProjectionException ex) { return BadRequest(ex.Message); } } [HttpPost("improve")] diff --git a/JobTrackerApi/Controllers/ProfileCvDtos.cs b/JobTrackerApi/Controllers/ProfileCvDtos.cs index 285de71..5c520d0 100644 --- a/JobTrackerApi/Controllers/ProfileCvDtos.cs +++ b/JobTrackerApi/Controllers/ProfileCvDtos.cs @@ -2,7 +2,7 @@ using JobTrackerApi.Models; namespace JobTrackerApi.Controllers; -public sealed record ParseCvRequest(string? Text); +public sealed record ParseCvRequest(string? Text, int? ExpectedVersion = null); public sealed record CvTemplateDescriptor(string Id, string Title, string Tone, string AccentColor, string PreviewTagline, string PreviewSummary, List PreviewBullets); public sealed record ProfileCvPreviewDto(string TemplateId, string Html, string SuggestedFileName, string FullText, string RewrittenText, string? SectionName, StructuredCvProfile StructuredCv, TailoredCvDocument Document, string? TargetRole, int? JobApplicationId); public sealed record CvRewriteFailureDto(string Code, string Message, string? Detail = null, string? LastAiError = null); diff --git a/JobTrackerApi/Data/JobTrackerContext.cs b/JobTrackerApi/Data/JobTrackerContext.cs index 9da0627..899926e 100644 --- a/JobTrackerApi/Data/JobTrackerContext.cs +++ b/JobTrackerApi/Data/JobTrackerContext.cs @@ -72,10 +72,35 @@ namespace JobTrackerApi.Data public DbSet AccountDeletionRequests => Set(); public DbSet AccountDeletionFiles => Set(); + private void AdvanceJobRevisions() + { + ChangeTracker.DetectChanges(); + foreach (var entry in ChangeTracker.Entries().Where(x => x.State == EntityState.Modified)) + { + // All writers share this guard, including partial updates and background workers. + entry.Entity.Version = checked(entry.Property(x => x.Version).OriginalValue + 1); + } + } + + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + AdvanceJobRevisions(); + return base.SaveChanges(acceptAllChangesOnSuccess); + } + + public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default) + { + AdvanceJobRevisions(); + return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); + } + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); + modelBuilder.Entity().Property(x => x.Version).IsConcurrencyToken().HasDefaultValue(1); + modelBuilder.Entity().Property(x => x.Version).IsConcurrencyToken(); + modelBuilder.Entity() .Property(x => x.PendingEmail) .HasMaxLength(320); diff --git a/JobTrackerApi/Migrations/20261023090000_AddJobRevision.cs b/JobTrackerApi/Migrations/20261023090000_AddJobRevision.cs new file mode 100644 index 0000000..de185f5 --- /dev/null +++ b/JobTrackerApi/Migrations/20261023090000_AddJobRevision.cs @@ -0,0 +1,25 @@ +using JobTrackerApi.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobTrackerApi.Migrations; + +[DbContext(typeof(JobTrackerContext))] +[Migration("20261023090000_AddJobRevision")] +public partial class AddJobRevision : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) => migrationBuilder.AddColumn( + name: "Version", table: "JobApplications", + type: ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase) ? "int" : "INTEGER", + nullable: false, defaultValue: 1); + + protected override void Down(MigrationBuilder migrationBuilder) + { + if (ActiveProvider.Contains("Sqlite", StringComparison.OrdinalIgnoreCase)) + migrationBuilder.Sql("ALTER TABLE \"JobApplications\" DROP COLUMN \"Version\";"); + else + migrationBuilder.DropColumn(name: "Version", table: "JobApplications"); + } +} diff --git a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs index db0bd2c..7c78d37 100644 --- a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs +++ b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs @@ -803,6 +803,7 @@ namespace JobTrackerApi.Migrations .HasColumnType("TEXT"); b.Property("Version") + .IsConcurrencyToken() .HasColumnType("INTEGER"); b.HasKey("Id"); @@ -1938,6 +1939,12 @@ namespace JobTrackerApi.Migrations b.Property("TranslatedDescription") .HasColumnType("TEXT"); + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + b.HasKey("Id"); b.HasIndex("CompanyId"); diff --git a/JobTrackerApi/Models/JobApplication.cs b/JobTrackerApi/Models/JobApplication.cs index 1f1e635..3f3c809 100644 --- a/JobTrackerApi/Models/JobApplication.cs +++ b/JobTrackerApi/Models/JobApplication.cs @@ -5,6 +5,7 @@ namespace JobTrackerApi.Models public class JobApplication { public int Id { get; set; } + public int Version { get; set; } = 1; public string? OwnerUserId { get; set; } public string JobTitle { get; set; } = ""; public int CompanyId { get; set; } diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 4e8c09d..144fe8b 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -160,6 +160,7 @@ builder.Services.AddProblemDetails(options => context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier; }); builder.Services.AddExceptionHandler(); +builder.Services.AddExceptionHandler(); builder.Services.AddExceptionHandler(); builder.Services.AddOpenApi(); var dataRoot = (builder.Configuration["Data:Root"] ?? "").Trim(); diff --git a/JobTrackerApi/Services/ApplicationAssetsService.cs b/JobTrackerApi/Services/ApplicationAssetsService.cs index c93445b..68df06a 100644 --- a/JobTrackerApi/Services/ApplicationAssetsService.cs +++ b/JobTrackerApi/Services/ApplicationAssetsService.cs @@ -41,7 +41,8 @@ public sealed record CoverLetterDto( string? Text, int CurrentVersion, IReadOnlyList Versions, - int AiSuggestionCount); + int AiSuggestionCount, + int Revision = 1); public interface IApplicationAssetsService { @@ -50,8 +51,8 @@ public interface IApplicationAssetsService Task GetTailoringPlanAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); Task GetCoverLetterAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); - Task SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, CancellationToken ct); - Task RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, CancellationToken ct); + Task SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, int? expectedVersion, CancellationToken ct); + Task RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, int? expectedVersion, CancellationToken ct); } public sealed class ApplicationAssetsService : IApplicationAssetsService @@ -237,7 +238,7 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService // Every save snapshots the PREVIOUS text first, so an AI rewrite can always be undone. The user's // text is what gets stored — an AI suggestion only becomes a version once the user saves it, which // is what "requires approval" means here. - public async Task SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, CancellationToken ct) + public async Task SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, int? expectedVersion, CancellationToken ct) { var job = await _db.JobApplications .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); @@ -246,9 +247,15 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService var next = text?.Trim() ?? string.Empty; var current = job.CoverLetterText?.Trim() ?? string.Empty; + if (expectedVersion is null or < 1 || expectedVersion != job.Version) + throw new DbUpdateConcurrencyException("The application changed after this draft was loaded."); + // Nothing changed: do not spend a version on a no-op save (autosave calls this often). if (string.Equals(next, current, StringComparison.Ordinal)) { + // A no-op still consumes the source revision so an overlapping write cannot be acknowledged as this draft. + _db.Entry(job).Property(x => x.Version).IsModified = true; + await _db.SaveChangesAsync(ct); return await BuildCoverLetterAsync(ownerUserId, job, ct); } @@ -274,7 +281,7 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService // Restore is non-destructive: the old text comes back as a NEW version, so the thing you restored // from is still in the history. - public async Task RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, CancellationToken ct) + public async Task RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, int? expectedVersion, CancellationToken ct) { var job = await _db.JobApplications .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); @@ -284,7 +291,7 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService .FirstOrDefaultAsync(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId && v.Version == version, ct); if (snapshot is null) return null; - return await SaveCoverLetterAsync(ownerUserId, jobApplicationId, snapshot.Text, CoverLetterSources.Restore, null, ct); + return await SaveCoverLetterAsync(ownerUserId, jobApplicationId, snapshot.Text, CoverLetterSources.Restore, null, expectedVersion, ct); } private async Task BuildCoverLetterAsync(string ownerUserId, JobApplication job, CancellationToken ct) @@ -304,7 +311,8 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService current, versions.Select(v => new CoverLetterVersionDto( v.Version, v.Source, v.AiAction, v.Text.Length, v.CreatedAtUtc, v.Version == current)).ToList(), - aiCount); + aiCount, + job.Version); } private async Task NextVersionAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) diff --git a/JobTrackerApi/Services/ApplicationWorkspaceService.cs b/JobTrackerApi/Services/ApplicationWorkspaceService.cs index d408dfd..6a0c049 100644 --- a/JobTrackerApi/Services/ApplicationWorkspaceService.cs +++ b/JobTrackerApi/Services/ApplicationWorkspaceService.cs @@ -47,7 +47,8 @@ public sealed record WorkspaceOverviewDto( DateTimeOffset? LastAiAtUtc, IReadOnlyList RecentActivity, WorkspaceNextStepDto? NextStep, - ChecklistProgressDto? ChecklistProgress); + ChecklistProgressDto? ChecklistProgress, + int Revision = 1); public interface IApplicationWorkspaceService { @@ -147,7 +148,8 @@ public sealed class ApplicationWorkspaceService : IApplicationWorkspaceService lastAi, activity, NextStep(checklist), - checklist?.Progress); + checklist?.Progress, + job.Version); } // "The user should never ask what to do next." Milestone 2 moved this onto the checklist: the first diff --git a/JobTrackerApi/Services/CareerProfileService.cs b/JobTrackerApi/Services/CareerProfileService.cs index fecefc3..cde2a9b 100644 --- a/JobTrackerApi/Services/CareerProfileService.cs +++ b/JobTrackerApi/Services/CareerProfileService.cs @@ -15,12 +15,17 @@ public interface ICareerProfileService // Mutates the given profile in place (assigns missing item IDs + normalized dates), persists // it as the current CareerProfile snapshot plus an append-only CareerProfileVersion row, syncs // the relational children (Experience/Education/Skill/Project/Certification/Language), and - // returns the same profile so the caller can go on to serialize it into the legacy column. - Task SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken); + // returns the same profile. Controller writes include the legacy projection in ExecuteAtomicAsync. + Task SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, int expectedVersion, CancellationToken cancellationToken); + + Task LoadSnapshotAsync(string ownerUserId, CancellationToken cancellationToken); + + // Includes Identity projection and import metadata in the canonical commit. + Task ExecuteAtomicAsync(Func> action, CancellationToken cancellationToken); // Reads the master profile from the relational children (the source of truth). If the relational - // rows are empty but a ProfileJson blob exists (a profile that predates Phase 3), it is - // backfilled from the blob first. Returns an empty profile if the user has none. + // rows are empty but a ProfileJson blob exists (a profile that predates Phase 3), reads fall + // back to that blob without writing. Returns an empty profile if the user has none. Task LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken); // The append-only version history, newest first. @@ -29,7 +34,7 @@ public interface ICareerProfileService // Restores a past version by re-saving its snapshot as a NEW version (non-destructive: history // and the current state are both preserved; the restore is itself reversible). Returns the // restored profile, or null if the version does not exist. - Task RestoreVersionAsync(string ownerUserId, int version, CancellationToken cancellationToken); + Task RestoreVersionAsync(string ownerUserId, int version, int expectedVersion, CancellationToken cancellationToken); // Read-only structured load for a specific owner, bypassing the tenant query filter. For public // CV rendering (/cv/{slug}) where there is no authenticated current user. Never writes, never @@ -38,6 +43,8 @@ public interface ICareerProfileService } public sealed record CareerProfileVersionInfo(int Version, string Source, DateTimeOffset CreatedAtUtc, bool IsCurrent); +public sealed record CareerProfileSnapshot(StructuredCvProfile Profile, int Revision, string? CvText = null); +public sealed class CareerProjectionException(string message) : Exception(message); public sealed class CareerProfileService : ICareerProfileService { @@ -48,63 +55,119 @@ public sealed class CareerProfileService : ICareerProfileService _db = db; } - public async Task SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken) + public async Task ExecuteAtomicAsync(Func> action, CancellationToken cancellationToken) { - AssignStableIds(profile); - NormalizeDates(profile); - - // Publish the profile row, relational children, and history revision atomically. Without - // this boundary a concurrent outline/read can observe the new profile before its children, - // enter the legacy backfill path, and insert a second copy of every child row. await using var transaction = _db.Database.IsRelational() && _db.Database.CurrentTransaction is null ? await _db.Database.BeginTransactionAsync(cancellationToken) : null; - - var json = StructuredCvProfileJson.SerializePersisted(profile); - - var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); - if (existing is null) + try { - existing = new CareerProfile + var result = await action(); + await _db.SaveChangesAsync(cancellationToken); + if (transaction is not null) await transaction.CommitAsync(cancellationToken); + return result; + } + catch + { + try + { + if (transaction is not null) await transaction.RollbackAsync(CancellationToken.None); + } + finally + { + // A later SaveChanges must never publish an operation whose response failed. + _db.ChangeTracker.Clear(); + } + throw; + } + } + + public async Task LoadSnapshotAsync(string ownerUserId, CancellationToken cancellationToken) + { + // Hold a read snapshot across the canonical row and relational children. + await using var transaction = _db.Database.IsRelational() && _db.Database.CurrentTransaction is null + ? await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, cancellationToken) + : null; + var row = await _db.CareerProfiles.AsNoTracking().Where(x => x.OwnerUserId == ownerUserId) + .Select(x => new + { + x.Version, + CvText = _db.Users.Where(user => user.Id == ownerUserId).Select(user => user.ProfileCvText).FirstOrDefault(), + }).FirstOrDefaultAsync(cancellationToken); + var snapshot = row is null ? new CareerProfileSnapshot(new StructuredCvProfile(), 0, + await _db.Users.Where(user => user.Id == ownerUserId).Select(user => user.ProfileCvText).FirstOrDefaultAsync(cancellationToken)) + : new(await LoadStructuredAsync(ownerUserId, cancellationToken), row.Version, row.CvText); + if (transaction is not null) await transaction.CommitAsync(cancellationToken); + return snapshot; + } + + public Task SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, int expectedVersion, CancellationToken cancellationToken) => + ExecuteAtomicAsync(async () => + { + var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); + if (expectedVersion < 0 || (existing?.Version ?? 0) != expectedVersion) + throw new DbUpdateConcurrencyException("The career profile changed after it was loaded."); + + AssignStableIds(profile); + NormalizeDates(profile); + + var json = StructuredCvProfileJson.SerializePersisted(profile); + + if (existing is null) + { + existing = new CareerProfile + { + OwnerUserId = ownerUserId, + ProfileJson = json, + Version = 1, + CreatedAtUtc = DateTimeOffset.UtcNow, + UpdatedAtUtc = DateTimeOffset.UtcNow, + }; + _db.CareerProfiles.Add(existing); + } + else + { + existing.ProfileJson = json; + existing.Version += 1; + existing.UpdatedAtUtc = DateTimeOffset.UtcNow; + } + + existing.LongTailJson = CareerProfileMapper.SerializeLongTail(profile); + try + { + await _db.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException ex) when (expectedVersion == 0 && IsDuplicateOwner(ex)) + { + throw new DbUpdateConcurrencyException("The career profile was created by another request.", ex); + } + + await SyncRelationalChildrenAsync(existing.Id, ownerUserId, profile, cancellationToken); + + _db.CareerProfileVersions.Add(new CareerProfileVersion { OwnerUserId = ownerUserId, + CareerProfileId = existing.Id, + Version = existing.Version, ProfileJson = json, - Version = 1, + Source = string.IsNullOrWhiteSpace(source) ? "manual" : source.Trim(), CreatedAtUtc = DateTimeOffset.UtcNow, - UpdatedAtUtc = DateTimeOffset.UtcNow, - }; - _db.CareerProfiles.Add(existing); - } - else - { - existing.ProfileJson = json; - existing.Version += 1; - existing.UpdatedAtUtc = DateTimeOffset.UtcNow; - } + }); + return profile; + }, cancellationToken); - existing.LongTailJson = CareerProfileMapper.SerializeLongTail(profile); - await _db.SaveChangesAsync(cancellationToken); - - await SyncRelationalChildrenAsync(existing.Id, ownerUserId, profile, cancellationToken); - - _db.CareerProfileVersions.Add(new CareerProfileVersion - { - OwnerUserId = ownerUserId, - CareerProfileId = existing.Id, - Version = existing.Version, - ProfileJson = json, - Source = string.IsNullOrWhiteSpace(source) ? "manual" : source.Trim(), - CreatedAtUtc = DateTimeOffset.UtcNow, - }); - await _db.SaveChangesAsync(cancellationToken); - if (transaction is not null) await transaction.CommitAsync(cancellationToken); - - return profile; - } + private static bool IsDuplicateOwner(DbUpdateException exception) => exception.InnerException switch + { + Microsoft.Data.Sqlite.SqliteException sqlite => sqlite.SqliteErrorCode == 19 + && sqlite.Message.Contains("UNIQUE constraint failed: CareerProfiles.OwnerUserId", StringComparison.Ordinal), + MySqlConnector.MySqlException mysql => mysql.Number == 1062 + && mysql.Message.Contains("IX_CareerProfiles_OwnerUserId", StringComparison.Ordinal), + _ => false, + }; public async Task LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken) { - var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); + var profile = await _db.CareerProfiles.AsNoTracking().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); if (profile is null) return new StructuredCvProfile(); var hasRelational = await _db.CareerExperiences.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken) @@ -114,21 +177,19 @@ public sealed class CareerProfileService : ICareerProfileService || await _db.CareerCertifications.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken) || await _db.CareerLanguages.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken); - // Backfill a pre-Phase-3 profile from its blob, once, before reading relationally. + // A read cannot materialize unversioned children during a concurrent replacement. if (!hasRelational && !string.IsNullOrWhiteSpace(profile.ProfileJson)) { var fromBlob = StructuredCvProfileJson.DeserializePersisted(profile.ProfileJson); - AssignStableIds(fromBlob); - NormalizeDates(fromBlob); - await SyncRelationalChildrenAsync(profile.Id, ownerUserId, fromBlob, cancellationToken); + return fromBlob; } - var experiences = UniqueChildren(await _db.CareerExperiences.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); - var education = UniqueChildren(await _db.CareerEducations.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); - var skills = UniqueChildren(await _db.CareerSkills.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); - var projects = UniqueChildren(await _db.CareerProjects.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); - var certifications = UniqueChildren(await _db.CareerCertifications.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); - var languages = UniqueChildren(await _db.CareerLanguages.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); + var experiences = UniqueChildren(await _db.CareerExperiences.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); + var education = UniqueChildren(await _db.CareerEducations.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); + var skills = UniqueChildren(await _db.CareerSkills.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); + var projects = UniqueChildren(await _db.CareerProjects.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); + var certifications = UniqueChildren(await _db.CareerCertifications.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); + var languages = UniqueChildren(await _db.CareerLanguages.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken)); return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages); } @@ -170,7 +231,7 @@ public sealed class CareerProfileService : ICareerProfileService return versions.Select(x => new CareerProfileVersionInfo(x.Version, x.Source, x.CreatedAtUtc, x.Version == profile.Version)).ToList(); } - public async Task RestoreVersionAsync(string ownerUserId, int version, CancellationToken cancellationToken) + public async Task RestoreVersionAsync(string ownerUserId, int version, int expectedVersion, CancellationToken cancellationToken) { var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); if (profile is null) return null; @@ -181,7 +242,7 @@ public sealed class CareerProfileService : ICareerProfileService // Re-save the old snapshot as a new version. Non-destructive: the current state stays in // history, so a restore can itself be undone by restoring the version before it. var restored = StructuredCvProfileJson.DeserializePersisted(target.ProfileJson); - return await SaveVersionAsync(ownerUserId, restored, $"restore:v{version}", cancellationToken); + return await SaveVersionAsync(ownerUserId, restored, $"restore:v{version}", expectedVersion, cancellationToken); } // Replace-all: the profile is small and edited as a whole, so wiping and re-inserting the diff --git a/JobTrackerApi/Services/RevisionConflictExceptionHandler.cs b/JobTrackerApi/Services/RevisionConflictExceptionHandler.cs new file mode 100644 index 0000000..3c7fce7 --- /dev/null +++ b/JobTrackerApi/Services/RevisionConflictExceptionHandler.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace JobTrackerApi.Services; + +public sealed class RevisionConflictExceptionHandler : IExceptionHandler +{ + public async ValueTask TryHandleAsync(HttpContext context, Exception exception, CancellationToken cancellationToken) + { + if (exception is not DbUpdateConcurrencyException) return false; + context.Response.StatusCode = StatusCodes.Status409Conflict; + await context.Response.WriteAsJsonAsync(new ProblemDetails + { + Status = StatusCodes.Status409Conflict, + Title = "The record changed", + Detail = "Your draft was not saved. Reload and review the current record before saving again.", + Extensions = { ["code"] = "revision_conflict" }, + }, cancellationToken); + return true; + } +}