fix(rd21): guard career and job saves with durable revisions

This commit is contained in:
cesnimda
2026-10-23 08:07:23 +02:00
parent 8f2cad67f7
commit 12f342c09c
32 changed files with 1185 additions and 277 deletions
+14 -14
View File
@@ -201,7 +201,7 @@ public sealed class ApplicationAssetsTests
await using var _ = db; await using var _ = db;
var job = await SeedJobAsync(db, "user-1"); 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("Dear team", result!.Text);
Assert.Equal(1, result.CurrentVersion); Assert.Equal(1, result.CurrentVersion);
@@ -217,9 +217,9 @@ public sealed class ApplicationAssetsTests
await using var _ = db; await using var _ = db;
var job = await SeedJobAsync(db, "user-1"); 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, "First draft", CoverLetterSources.Manual, null, job.Version, default);
await assets.SaveCoverLetterAsync("user-1", job.Id, "Second draft", CoverLetterSources.Ai, "improve", 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, 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!.CurrentVersion);
Assert.Equal(3, result.Versions.Count); Assert.Equal(3, result.Versions.Count);
@@ -234,8 +234,8 @@ public sealed class ApplicationAssetsTests
await using var _ = db; await using var _ = db;
var job = await SeedJobAsync(db, "user-1"); var job = await SeedJobAsync(db, "user-1");
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, default); var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Same text", CoverLetterSources.Manual, null, job.Version, default);
Assert.Single(result!.Versions); Assert.Single(result!.Versions);
} }
@@ -246,10 +246,10 @@ public sealed class ApplicationAssetsTests
var (db, assets) = New("user-1"); var (db, assets) = New("user-1");
await using var _ = db; await using var _ = db;
var job = await SeedJobAsync(db, "user-1"); 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 good draft", CoverLetterSources.Manual, null, job.Version, default);
await assets.SaveCoverLetterAsync("user-1", job.Id, "The AI ruined it", CoverLetterSources.Ai, "shorten", 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); Assert.Equal("The good draft", result!.Text);
// Restore is additive: v1 and v2 both survive, and the restore is v3. // 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"); var (db, assets) = New("user-1");
await using var _ = db; await using var _ = db;
var job = await SeedJobAsync(db, "user-1"); 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] [Fact]
@@ -277,11 +277,11 @@ public sealed class ApplicationAssetsTests
await using var _ = db; await using var _ = db;
var mine = await SeedJobAsync(db, "user-1"); var mine = await SeedJobAsync(db, "user-1");
var theirs = await SeedJobAsync(db, "user-2"); 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.GetCoverLetterAsync("user-1", theirs.Id, default));
Assert.Null(await assets.SaveCoverLetterAsync("user-1", theirs.Id, "Sneak", CoverLetterSources.Manual, null, 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, default)); Assert.Null(await assets.RestoreCoverLetterAsync("user-1", theirs.Id, 1, theirs.Version, default));
} }
[Fact] [Fact]
@@ -564,28 +564,23 @@ public sealed class AuthAndSystemControllerTests
Assert.Equal("{\"jobs\":[]}", user.ProfileCvStructureJson); Assert.Equal("{\"jobs\":[]}", user.ProfileCvStructureJson);
} }
[Fact] [Theory]
public async Task Career_only_save_does_not_wipe_identity() [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 var user = new ApplicationUser
{ {
Email = "ada@example.com", UserName = "ada", FirstName = "Ada", ProfileCvText = "Approved text", ProfileCvStructureJson = "Approved structure",
UserName = "ada",
FirstName = "Ada",
LastName = "Lovelace",
DisplayName = "Ada L.",
}; };
var controller = BuildProfileController(user); var controller = BuildProfileController(user);
var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(
var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(null, null, null, null, null, "New CV text", "{\"jobs\":[1]}")); null, null, "Stale identity", null, null, text, structured));
Assert.IsType<BadRequestObjectResult>(result);
Assert.IsType<NoContentResult>(result);
Assert.Equal("Ada", user.FirstName); Assert.Equal("Ada", user.FirstName);
Assert.Equal("Lovelace", user.LastName); Assert.Equal("Approved text", user.ProfileCvText);
Assert.Equal("Ada L.", user.DisplayName); Assert.Equal("Approved structure", user.ProfileCvStructureJson);
Assert.Equal("New CV text", user.ProfileCvText);
Assert.Equal("{\"jobs\":[1]}", user.ProfileCvStructureJson);
} }
[Fact] [Fact]
@@ -47,7 +47,7 @@ public sealed class CareerProfileControllerTests
{ {
var (controller, _, user) = Build(); 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<OkObjectResult>(put.Result); Assert.IsType<OkObjectResult>(put.Result);
// The derived blob projection is kept in sync for legacy readers. // The derived blob projection is kept in sync for legacy readers.
@@ -78,7 +78,7 @@ public sealed class CareerProfileControllerTests
Notes = "Used with distributed teams", 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<CareerProfileDto>(Assert.IsType<OkObjectResult>(put.Result).Value).Profile; var saved = Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>(put.Result).Value).Profile;
var get = await controller.Get(CancellationToken.None); var get = await controller.Get(CancellationToken.None);
var reloaded = Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>(get.Result).Value).Profile; var reloaded = Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>(get.Result).Value).Profile;
@@ -104,7 +104,7 @@ public sealed class CareerProfileControllerTests
public async Task Get_reports_completeness_with_missing_sections() public async Task Get_reports_completeness_with_missing_sections()
{ {
var (controller, _, _) = Build(); 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 get = await controller.Get(CancellationToken.None);
var dto = Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>(get.Result).Value); var dto = Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>(get.Result).Value);
@@ -121,7 +121,7 @@ public sealed class CareerProfileControllerTests
var huge = new StructuredCvProfile(); var huge = new StructuredCvProfile();
for (var i = 0; i < 500; i++) huge.Skills.Add($"skill-{i}"); 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<BadRequestObjectResult>(put.Result); Assert.IsType<BadRequestObjectResult>(put.Result);
} }
@@ -133,7 +133,7 @@ public sealed class CareerProfileControllerTests
var profile = Sample(); var profile = Sample();
profile.Contact.Website = $"https://example.test/{new string('a', 600)}"; 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<BadRequestObjectResult>(put.Result); var badRequest = Assert.IsType<BadRequestObjectResult>(put.Result);
Assert.Equal("A contact field exceeds the allowed length.", badRequest.Value); Assert.Equal("A contact field exceeds the allowed length.", badRequest.Value);
@@ -144,7 +144,7 @@ public sealed class CareerProfileControllerTests
{ {
var (controller, _, _) = Build(); 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. // Completeness, not validation, is what flags an incomplete profile — an empty profile saves.
Assert.IsType<OkObjectResult>(put.Result); Assert.IsType<OkObjectResult>(put.Result);
@@ -29,7 +29,7 @@ public sealed class CareerProfileServiceTests
Jobs = { new StructuredCvJob { Title = "Engineer", Company = "Acme" } }, 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)); 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" } }, 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.Equal("shared", saved.Jobs[0].Id);
Assert.NotEqual(saved.Jobs[0].Id, saved.Education[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" } }, 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; 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); Assert.Equal(firstId, profile.Jobs[0].Id);
} }
@@ -108,8 +108,8 @@ public sealed class CareerProfileServiceTests
var service = new CareerProfileService(db); var service = new CareerProfileService(db);
var profile = new StructuredCvProfile { Summary = { "First version" } }; var profile = new StructuredCvProfile { Summary = { "First version" } };
await service.SaveVersionAsync("user-1", profile, "upload", default); await service.SaveVersionAsync("user-1", profile, "upload", 0, default);
await service.SaveVersionAsync("user-1", profile, "improve", default); await service.SaveVersionAsync("user-1", profile, "improve", 1, default);
var snapshots = await db.CareerProfiles.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync(); 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(); 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 } }, 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.Equal("2020-01", saved.Jobs[0].StartDate);
Assert.Null(saved.Jobs[0].EndDate); Assert.Null(saved.Jobs[0].EndDate);
@@ -165,7 +165,7 @@ public sealed class CareerProfileServiceTests
await using var db = NewContext("user-1"); await using var db = NewContext("user-1");
var service = new CareerProfileService(db); 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); var loaded = await service.LoadStructuredAsync("user-1", default);
Assert.Equal("Ada Lovelace", loaded.Contact.FullName); Assert.Equal("Ada Lovelace", loaded.Contact.FullName);
@@ -196,7 +196,7 @@ public sealed class CareerProfileServiceTests
var service = new CareerProfileService(db); var service = new CareerProfileService(db);
var profile = RichProfile(); 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 jobKey = profile.Jobs[0].Id;
var loaded = await service.LoadStructuredAsync("user-1", default); var loaded = await service.LoadStructuredAsync("user-1", default);
@@ -213,9 +213,9 @@ public sealed class CareerProfileServiceTests
await using var db = NewContext("user-1"); await using var db = NewContext("user-1");
var service = new CareerProfileService(db); 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" } } }; 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(); var experiences = await db.CareerExperiences.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync();
Assert.Single(experiences); Assert.Single(experiences);
@@ -225,7 +225,7 @@ public sealed class CareerProfileServiceTests
} }
[Fact] [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"); await using var db = NewContext("user-1");
// Simulate a profile that predates Phase 3: a CareerProfile with a ProfileJson blob but no // 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); var loaded = await service.LoadStructuredAsync("user-1", default);
Assert.Equal(2, loaded.Jobs.Count); Assert.Equal(2, loaded.Jobs.Count);
// The backfill materialized the relational rows. // Reads cannot materialize unversioned children alongside a concurrent replacement.
Assert.Equal(2, await db.CareerExperiences.IgnoreQueryFilters().CountAsync(x => x.OwnerUserId == "user-1")); Assert.Equal(0, await db.CareerExperiences.IgnoreQueryFilters().CountAsync(x => x.OwnerUserId == "user-1"));
} }
[Fact] [Fact]
@@ -260,8 +260,8 @@ public sealed class CareerProfileServiceTests
{ {
await using var db = NewContext("user-1"); await using var db = NewContext("user-1");
var service = new CareerProfileService(db); var service = new CareerProfileService(db);
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "v1" } }, "upload", default); await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "v1" } }, "upload", 0, default);
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "v2" } }, "improve", default); await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "v2" } }, "improve", 1, default);
var versions = await service.ListVersionsAsync("user-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"); await using var db = NewContext("user-1");
var service = new CareerProfileService(db); 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 = { "original" } }, "upload", 0, default); // v1
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "edited" } }, "manual", default); // v2 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.NotNull(restored);
Assert.Equal(new[] { "original" }, restored!.Summary); Assert.Equal(new[] { "original" }, restored!.Summary);
@@ -296,8 +296,8 @@ public sealed class CareerProfileServiceTests
{ {
await using var db = NewContext("user-1"); await using var db = NewContext("user-1");
var service = new CareerProfileService(db); 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));
} }
} }
+4 -4
View File
@@ -39,7 +39,7 @@ public sealed class CvAiReadinessTests
Db = new(new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(Connection).Options, current.Object); Db = new(new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(Connection).Options, current.Object);
await Db.Database.EnsureCreatedAsync(); await Db.Database.EnsureCreatedAsync();
Career = new(Db); Career = new(Db);
await Career.SaveVersionAsync("owner", Profile, "manual", default); await Career.SaveVersionAsync("owner", Profile, "manual", 0, default);
Variants = new(Db, Career, new ThemedCvRenderer()); Variants = new(Db, Career, new ThemedCvRenderer());
Cv = await Variants.CreateAsync("owner", "Synthetic CV", null, new CvVariantSettings(), default); Cv = await Variants.CreateAsync("owner", "Synthetic CV", null, new CvVariantSettings(), default);
Suggestions = new(Db, Career, Variants, Ai.Object, Protection); Suggestions = new(Db, Career, Variants, Ai.Object, Protection);
@@ -150,7 +150,7 @@ public sealed class CvAiReadinessTests
if (canonical) if (canonical)
{ {
fixture.Profile.Summary = ["Newer canonical user content"]; 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, else await fixture.Variants.SaveAsync("owner", fixture.Cv.Id, null,
new CvVariantSettings { Headline = "Newer CV user content" }, "manual", 1, default); new CvVariantSettings { Headline = "Newer CV user content" }, "manual", 1, default);
@@ -191,7 +191,7 @@ public sealed class CvAiReadinessTests
.Returns(async () => .Returns(async () =>
{ {
fixture.Profile.Summary = ["Newer canonical content"]; 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)); return new AiGenerationResult(Assessment(shortened, Source));
}); });
var error = await Assert.ThrowsAsync<CvAiSuggestionException>(() => fixture.Suggestions.ApplyAsync("owner", fixture.Cv.Id, proposal.ProposalToken, shortened, default)); var error = await Assert.ThrowsAsync<CvAiSuggestionException>(() => 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(); await using var fixture = new Fixture(); await fixture.Open();
fixture.Profile.Jobs = [new() { Id = "experience-key", Title = "Engineer", Company = "Nordlys", Bullets = { Source } }]; 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 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); { AccentColor = "#123456", PageMarginMm = 22, Sections = { new() { Key = "experience" }, new() { Key = "summary" } } }, "manual", 1, default);
fixture.Ai.Setup(value => value.GenerateSectionWithMetadataAsync(It.IsAny<string>(), It.IsAny<string>(), AiGenerationOutputMode.CvBlocksJson, It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>())) fixture.Ai.Setup(value => value.GenerateSectionWithMetadataAsync(It.IsAny<string>(), It.IsAny<string>(), AiGenerationOutputMode.CvBlocksJson, It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
+2 -2
View File
@@ -459,7 +459,7 @@ public sealed class CvBuilderTests
{ {
var (db, svc) = NewService("user-1"); var (db, svc) = NewService("user-1");
await using var _ = db; 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 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); 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 dbName = Guid.NewGuid().ToString();
var (ownerDb, ownerSvc) = NewService("user-1", dbName); 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); var v = await ownerSvc.CreateAsync("user-1", "CV", null, new CvVariantSettings { ThemeId = "minimal" }, default);
await ownerSvc.SetPublicAsync("user-1", v.Id, true, default); await ownerSvc.SetPublicAsync("user-1", v.Id, true, default);
await ownerDb.DisposeAsync(); await ownerDb.DisposeAsync();
@@ -78,7 +78,7 @@ public sealed class CvRevisionReadinessTests
Jobs = { new() { Id = "source-entry", Title = "Canonical role", Company = "Nordlys", Bullets = { "Canonical evidence" } } }, Jobs = { new() { Id = "source-entry", Title = "Canonical role", Company = "Nordlys", Bullets = { "Canonical evidence" } } },
Skills = { "C#", "Norsk" }, 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 service = Service(db);
var lens = new CvVariantSettings var lens = new CvVariantSettings
{ {
@@ -97,7 +97,7 @@ public sealed class CvRevisionReadinessTests
// History is the immutable lens; inherited source values intentionally remain live. // History is the immutable lens; inherited source values intentionally remain live.
profile.Contact.Headline = "Updated canonical source"; profile.Contact.Headline = "Updated canonical source";
profile.Jobs[0].Title = "Updated canonical role"; 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); Assert.Equal(originalHistory, (await db.CvVariantVersions.SingleAsync()).SettingsJson);
var reset = new CvVariantSettings { ThemeId = "minimal" }; var reset = new CvVariantSettings { ThemeId = "minimal" };
await service.SaveAsync("revision-owner", cv.Id, null, reset, "manual", 1, default); await service.SaveAsync("revision-owner", cv.Id, null, reset, "manual", 1, default);
@@ -39,7 +39,7 @@ public sealed class JobApplicationsApplicationPackageTests
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1"); var controller = CreateController(db, Mock.Of<ISummarizerService>(), "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<NoContentResult>(result); Assert.IsType<NoContentResult>(result);
var saved = await db.JobApplications.FirstAsync(); var saved = await db.JobApplications.FirstAsync();
@@ -68,7 +68,7 @@ public sealed class JobApplicationsApplicationPackageTests
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1"); var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1");
var updated = await controller.SaveApplicationDrafts( var updated = await controller.SaveApplicationDrafts(
job.Id, 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); CancellationToken.None);
Assert.IsType<NoContentResult>(updated); Assert.IsType<NoContentResult>(updated);
@@ -79,7 +79,7 @@ public sealed class JobApplicationsApplicationPackageTests
job.Notes += "\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nDuplicate stale answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>"; job.Notes += "\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nDuplicate stale answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>";
var cleared = await controller.SaveApplicationDrafts( var cleared = await controller.SaveApplicationDrafts(
job.Id, job.Id,
new SaveApplicationDraftsRequest(null, null, "", ""), new SaveApplicationDraftsRequest(null, null, "", "", ExpectedVersion: job.Version),
CancellationToken.None); CancellationToken.None);
Assert.IsType<NoContentResult>(cleared); Assert.IsType<NoContentResult>(cleared);
@@ -109,7 +109,7 @@ public sealed class JobApplicationsApplicationPackageTests
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1"); var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1");
var result = await controller.SaveApplicationDrafts( var result = await controller.SaveApplicationDrafts(
otherUsersJob.Id, otherUsersJob.Id,
new SaveApplicationDraftsRequest(null, null, "Changed", "Changed"), new SaveApplicationDraftsRequest(null, null, "Changed", "Changed", ExpectedVersion: otherUsersJob.Version),
CancellationToken.None); CancellationToken.None);
Assert.IsType<NotFoundResult>(result); Assert.IsType<NotFoundResult>(result);
@@ -424,7 +424,7 @@ public sealed class JobApplicationsApplicationPackageTests
generated.Education, generated.Education,
generated.CustomSections, generated.CustomSections,
generated.RenderOptions, generated.RenderOptions,
"edited"), CancellationToken.None); "edited", ExpectedVersion: generated.Revision), CancellationToken.None);
Assert.IsType<NoContentResult>(saveResult); Assert.IsType<NoContentResult>(saveResult);
@@ -28,7 +28,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var controller = CreateController(db, "user-1"); 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<NoContentResult>(result); Assert.IsType<NoContentResult>(result);
var saved = await db.JobApplications.FirstAsync(); var saved = await db.JobApplications.FirstAsync();
@@ -272,7 +272,8 @@ public sealed class JobApplicationsEndpointBehaviorTests
JobUrl: null, JobUrl: null,
DateApplied: null, DateApplied: null,
FeedbackRequestedAt: null, FeedbackRequestedAt: null,
StatusChangedAt: null); StatusChangedAt: null,
ExpectedVersion: job.Version);
var result = await controller.Update(job.Id, request, CancellationToken.None); var result = await controller.Update(job.Id, request, CancellationToken.None);
@@ -340,7 +341,8 @@ public sealed class JobApplicationsEndpointBehaviorTests
JobUrl: null, JobUrl: null,
DateApplied: null, DateApplied: null,
FeedbackRequestedAt: null, FeedbackRequestedAt: null,
StatusChangedAt: null); StatusChangedAt: null,
ExpectedVersion: job.Version);
Assert.IsType<NoContentResult>(await controller.Update(job.Id, request, CancellationToken.None)); Assert.IsType<NoContentResult>(await controller.Update(job.Id, request, CancellationToken.None));
@@ -348,7 +350,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
summarizer.Verify(service => service.SummarizeAsync(It.IsAny<string>(), 160, 60), Times.Once); summarizer.Verify(service => service.SummarizeAsync(It.IsAny<string>(), 160, 60), Times.Once);
Assert.Contains(await db.JobEvents.ToListAsync(), item => item.Type == "AiRefreshed" && item.Note!.Contains("automatically")); 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<NoContentResult>(await controller.Update(job.Id, request, CancellationToken.None)); Assert.IsType<NoContentResult>(await controller.Update(job.Id, request, CancellationToken.None));
summarizer.Verify(service => service.SummarizeAsync(It.IsAny<string>(), 160, 60), Times.Once); summarizer.Verify(service => service.SummarizeAsync(It.IsAny<string>(), 160, 60), Times.Once);
} }
@@ -28,7 +28,7 @@ public sealed class JobApplicationsMariaDraftTests
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var controller = CreateController(db, "user-1"); 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<NoContentResult>(result); Assert.IsType<NoContentResult>(result);
var saved = await db.JobApplications.FirstAsync(); var saved = await db.JobApplications.FirstAsync();
@@ -15,6 +15,33 @@ public sealed class MigrationChainTests
{ {
private const string SnapshotMigration = "20260711181039_SyncModelSnapshot"; 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<IMigrator>();
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<long>(connection, "SELECT Version FROM JobApplications WHERE Id=1;"));
Assert.Equal("Keep these notes", await ScalarAsync<string>(connection, "SELECT Notes FROM JobApplications WHERE Id=1;"));
await migrator.MigrateAsync(previous);
Assert.Equal("Durable role", await ScalarAsync<string>(connection, "SELECT JobTitle FROM JobApplications WHERE Id=1;"));
Assert.Equal("Keep these notes", await ScalarAsync<string>(connection, "SELECT Notes FROM JobApplications WHERE Id=1;"));
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM pragma_table_info('JobApplications') WHERE name='Version';"));
await migrator.MigrateAsync();
Assert.Equal(1L, await ScalarAsync<long>(connection, "SELECT Version FROM JobApplications WHERE Id=1;"));
}
[Fact] [Fact]
public async Task Blank_sqlite_chain_reaches_latest_and_is_idempotent() public async Task Blank_sqlite_chain_reaches_latest_and_is_idempotent()
{ {
+12 -12
View File
@@ -92,7 +92,7 @@ public sealed class ProfileCvControllerTests
Assert.Null(user.CurrentCvExtractionRunId); Assert.Null(user.CurrentCvExtractionRunId);
Assert.IsType<OkObjectResult>(await controller.GetRunDiff(run.Id)); Assert.IsType<OkObjectResult>(await controller.GetRunDiff(run.Id));
Assert.IsType<OkObjectResult>(await controller.AcceptRun(run.Id)); Assert.IsType<OkObjectResult>(await controller.AcceptRun(run.Id, new(null, 0)));
var parsed = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); var parsed = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Equal("applied", run.Status); Assert.Equal("applied", run.Status);
@@ -906,7 +906,7 @@ public sealed class ProfileCvControllerTests
var paths = CreatePaths(); var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths); 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<OkObjectResult>(result.Result); var ok = Assert.IsType<OkObjectResult>(result.Result);
var json = JsonSerializer.Serialize(ok.Value); var json = JsonSerializer.Serialize(ok.Value);
@@ -940,7 +940,7 @@ public sealed class ProfileCvControllerTests
var paths = CreatePaths(); var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths); 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<OkObjectResult>(result.Result); var ok = Assert.IsType<OkObjectResult>(result.Result);
var json = JsonSerializer.Serialize(ok.Value); var json = JsonSerializer.Serialize(ok.Value);
@@ -978,7 +978,7 @@ public sealed class ProfileCvControllerTests
var paths = CreatePaths(); var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object); 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<OkObjectResult>(result.Result); var ok = Assert.IsType<OkObjectResult>(result.Result);
var json = JsonSerializer.Serialize(ok.Value); var json = JsonSerializer.Serialize(ok.Value);
@@ -1018,7 +1018,7 @@ public sealed class ProfileCvControllerTests
var paths = CreatePaths(); var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object); 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<OkObjectResult>(result.Result); var ok = Assert.IsType<OkObjectResult>(result.Result);
var json = JsonSerializer.Serialize(ok.Value); var json = JsonSerializer.Serialize(ok.Value);
@@ -1054,7 +1054,7 @@ public sealed class ProfileCvControllerTests
var paths = CreatePaths(); var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object); 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<OkObjectResult>(result.Result); var ok = Assert.IsType<OkObjectResult>(result.Result);
var json = JsonSerializer.Serialize(ok.Value); var json = JsonSerializer.Serialize(ok.Value);
@@ -1237,7 +1237,7 @@ public sealed class ProfileCvControllerTests
await using var db = CreateDb(); await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths(), null, normalizer.Object); 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<OkObjectResult>(result.Result); Assert.IsType<OkObjectResult>(result.Result);
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
@@ -1276,7 +1276,7 @@ public sealed class ProfileCvControllerTests
var paths = CreatePaths(); var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths); 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<OkObjectResult>(result.Result); var ok = Assert.IsType<OkObjectResult>(result.Result);
Assert.NotNull(ok.Value); Assert.NotNull(ok.Value);
@@ -1305,7 +1305,7 @@ public sealed class ProfileCvControllerTests
await using var db = CreateDb(); await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths()); 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<OkObjectResult>(result.Result); Assert.IsType<OkObjectResult>(result.Result);
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
@@ -1336,7 +1336,7 @@ public sealed class ProfileCvControllerTests
await using var db = CreateDb(); await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths()); 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<OkObjectResult>(result.Result); Assert.IsType<OkObjectResult>(result.Result);
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
@@ -1365,7 +1365,7 @@ public sealed class ProfileCvControllerTests
await using var db = CreateDb(); await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths()); 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<OkObjectResult>(result.Result); Assert.IsType<OkObjectResult>(result.Result);
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
@@ -1393,7 +1393,7 @@ public sealed class ProfileCvControllerTests
await using var db = CreateDb(); await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths()); 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<OkObjectResult>(result.Result); Assert.IsType<OkObjectResult>(result.Result);
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
@@ -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<ICurrentUserService>();
current.SetupGet(x => x.UserId).Returns("rd21-owner");
var options = new DbContextOptionsBuilder<JobTrackerContext>().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<UserManager<ApplicationUser>> Users(JobTrackerContext db, bool identitySucceeds)
{
var user = db.Users.Single();
var users = TestHostFactory.CreateUserManager();
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).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<ISummarizerService>().Object, db, null!)
{ ControllerContext = new() { HttpContext = new DefaultHttpContext() } };
private static CareerProfileSaveRequest Request(string title, int? expectedVersion) =>
JsonSerializer.Deserialize<CareerProfileSaveRequest>(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<OkObjectResult>((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<CareerProfileDto>(Assert.IsType<OkObjectResult>((await staleController.Get(default)).Result).Value);
Assert.Equal(1, source.Revision);
Assert.Equal("Original", source.Profile.Jobs.Single().Title);
Assert.IsType<OkObjectResult>((await Controller(first).Put(Request("Acknowledged winner", 1), default)).Result);
var rejected = await staleController.Put(Request("Stale draft", 1), default);
Assert.IsType<ConflictObjectResult>(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<BadRequestObjectResult>(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<DbUpdateConcurrencyException>(() => b.SaveVersionAsync("rd21-owner", new() { Jobs = { new() { Title = "Stale" } } }, "manual", 1, default));
await stale.SaveChangesAsync();
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => 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<IActionResult?> 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<CareerProfileDto>(Assert.IsType<OkObjectResult>((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<OkObjectResult>());
Assert.Single(outcomes.OfType<ConflictObjectResult>());
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<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> 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<InvalidOperationException>(() => 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<BadRequestObjectResult>((await controller.Put(Request("Rejected", expectedVersion), default)).Result);
Assert.IsType<BadRequestObjectResult>((await controller.Restore(1, new(expectedVersion), default)).Result);
var imports = ImportController(db);
Assert.IsType<BadRequestObjectResult>((await imports.Parse(new("# Synthetic", expectedVersion))).Result);
Assert.IsType<BadRequestObjectResult>(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<OkObjectResult>(result);
else Assert.IsType<BadRequestObjectResult>(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<OkObjectResult>(await imports.GetRunDiff(run.Id));
Assert.Equal(0, JsonSerializer.SerializeToElement(diff.Value).GetProperty("revision").GetInt32());
Assert.IsType<OkObjectResult>((await Controller(db).Put(Request("Winner", 0), default)).Result);
Assert.IsType<ConflictObjectResult>(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<OkObjectResult>(result.Result);
else Assert.IsType<BadRequestObjectResult>(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<CareerProfileDto>(Assert.IsType<OkObjectResult>(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());
}
}
@@ -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<ICurrentUserService>();
current.SetupGet(x => x.UserId).Returns(owner);
return new JobTrackerContext(new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options, current.Object);
}
private static JobApplicationsController Controller(JobTrackerContext db) => new(db,
Mock.Of<ISummarizerService>(), 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<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
var summarizer = new Mock<ISummarizerService>();
summarizer.Setup(x => x.SummarizeAsync(It.IsAny<string>(), 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<string> { "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<NoContentResult>(await Controller(winner).Update(id,
Request(companyId, "Original job title", "Acknowledged concurrent notes", 1), default));
}
finally { release.TrySetResult("Delayed summary"); }
Assert.IsType<ConflictObjectResult>(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<NoContentResult>(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<UpdateJobApplicationRequest>(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<SaveTailoredCvDraftRequest>(
JsonSerializer.Serialize(new { summary = new[] { text }, expectedVersion = 1 }),
new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
await using var first = Context(connection);
Assert.IsType<NoContentResult>(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<ObjectResult>(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<DbUpdateException>(() => 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<NotFoundResult>(await Controller(db).Update(foreign.Id, Request(other.Id, "Stolen", null, 1), default));
Assert.IsType<BadRequestObjectResult>(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<IActionResult> 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<SaveApplicationDraftsRequest>(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<ClaimsPrincipal>())).ReturnsAsync(user);
var controller = new ApplicationAssetsController(users.Object,
new ApplicationAssetsService(db, Mock.Of<ICvVariantService>(), Mock.Of<IApplicationIntelligenceService>()),
Mock.Of<ISubmittedApplicationPackageService>())
{
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<ApplicationAssetsController.SaveCoverLetterRequest>(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<ObjectResult>(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<NoContentResult>(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<ObjectResult>(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());
}
}
@@ -42,7 +42,7 @@ public sealed class SqliteDateTimeOffsetCompatibilityTests
db.Attachments.Add(attachment); db.Attachments.Add(attachment);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var career = new CareerProfileService(db); var career = new CareerProfileService(db);
await career.SaveVersionAsync("user-1", JsonSerializer.Deserialize<StructuredCvProfile>("{\"version\":\"1\",\"contact\":{\"fullName\":\"Åse\",\"headline\":\"Original profile\"}}", new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!, "manual", default); await career.SaveVersionAsync("user-1", JsonSerializer.Deserialize<StructuredCvProfile>("{\"version\":\"1\",\"contact\":{\"fullName\":\"Åse\",\"headline\":\"Original profile\"}}", new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!, "manual", 0, default);
var variants = new CvVariantService(db, career, new ThemedCvRenderer()); var variants = new CvVariantService(db, career, new ThemedCvRenderer());
var assets = new ApplicationAssetsService(db, variants, new ApplicationIntelligenceService(db, new JobCvMatchService())); var assets = new ApplicationAssetsService(db, variants, new ApplicationIntelligenceService(db, new JobCvMatchService()));
Assert.NotNull(await assets.GetTailoringPlanAsync("user-1", job.Id, default)); Assert.NotNull(await assets.GetTailoringPlanAsync("user-1", job.Id, default));
@@ -19,7 +19,8 @@ namespace JobTrackerApi.Controllers;
public sealed class ApplicationAssetsController : ControllerBase public sealed class ApplicationAssetsController : ControllerBase
{ {
public sealed record AttachVariantRequest(int? VariantId); 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<ApplicationUser> _users; private readonly UserManager<ApplicationUser> _users;
private readonly IApplicationAssetsService _assets; private readonly IApplicationAssetsService _assets;
@@ -74,16 +75,16 @@ public sealed class ApplicationAssetsController : ControllerBase
var userId = await CurrentUserIdAsync(); var userId = await CurrentUserIdAsync();
if (userId is null) return Unauthorized(); if (userId is null) return Unauthorized();
var result = await _assets.SaveCoverLetterAsync( 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); return result is null ? NotFound() : Ok(result);
} }
[HttpPost("cover-letter/versions/{version:int}/restore")] [HttpPost("cover-letter/versions/{version:int}/restore")]
public async Task<ActionResult<CoverLetterDto>> RestoreCoverLetter(int jobId, int version, CancellationToken ct) public async Task<ActionResult<CoverLetterDto>> RestoreCoverLetter(int jobId, int version, [FromBody] RestoreCoverLetterRequest? request, CancellationToken ct)
{ {
var userId = await CurrentUserIdAsync(); var userId = await CurrentUserIdAsync();
if (userId is null) return Unauthorized(); 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); return result is null ? NotFound() : Ok(result);
} }
+4 -4
View File
@@ -516,12 +516,14 @@ public sealed class AuthController : ControllerBase
return StatusCode(501, "Profile updates are only supported for local username/password accounts."); 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: // Partial update. A field is only touched when the request actually carries it:
// - null / omitted -> leave unchanged (the caller isn't editing this field) // - null / omitted -> leave unchanged (the caller isn't editing this field)
// - "" -> explicitly clear // - "" -> explicitly clear
// - "value" -> set (trimmed) // - "value" -> set (trimmed)
// This lets /profile save identity fields and /career save the master-profile fields // Career data uses its own atomic, revision-guarded endpoint. Email and UserName are the
// through the same endpoint without one wiping the other. Email and UserName are the
// login identifiers and are never cleared to empty. Email ownership changes use the // login identifiers and are never cleared to empty. Email ownership changes use the
// separate, token-confirmed email-change flow below. // separate, token-confirmed email-change flow below.
if (request.UserName is not null) 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.FirstName is not null) user.FirstName = TrimOrNull(request.FirstName);
if (request.LastName is not null) user.LastName = TrimOrNull(request.LastName); if (request.LastName is not null) user.LastName = TrimOrNull(request.LastName);
if (request.DisplayName is not null) user.DisplayName = TrimOrNull(request.DisplayName); 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); var res = await _users.UpdateAsync(user);
if (!res.Succeeded) if (!res.Succeeded)
@@ -4,6 +4,7 @@ using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers; namespace JobTrackerApi.Controllers;
@@ -35,8 +36,8 @@ public sealed class CareerProfileController : ControllerBase
var user = await _users.GetUserAsync(User); var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized(); if (user is null) return Unauthorized();
var profile = await _career.LoadStructuredAsync(user.Id, cancellationToken); var snapshot = await _career.LoadSnapshotAsync(user.Id, cancellationToken);
return Ok(new CareerProfileDto(profile, CareerCompleteness.Evaluate(profile), user.ProfileCvText)); return Ok(new CareerProfileDto(snapshot.Profile, CareerCompleteness.Evaluate(snapshot.Profile), snapshot.CvText, snapshot.Revision));
} }
/// <summary> /// <summary>
@@ -50,21 +51,27 @@ public sealed class CareerProfileController : ControllerBase
{ {
var user = await _users.GetUserAsync(User); var user = await _users.GetUserAsync(User);
if (user is null) return StatusCode(501, "The career profile can only be edited on local accounts."); 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); var error = CareerProfileValidator.Validate(profile);
if (error is not null) return BadRequest(error); if (error is not null) return BadRequest(error);
var saved = await _career.SaveVersionAsync(user.Id, profile, "manual", cancellationToken); try
{
// Keep the derived projection in sync for legacy readers. CvText (the raw imported text) is var dto = await _career.ExecuteAtomicAsync(async () =>
// part of the career profile and is set here too; identity fields are never touched. {
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(saved); var saved = await _career.SaveVersionAsync(user.Id, profile, "manual", request.ExpectedVersion.Value, cancellationToken);
if (request?.CvText is not null) user.ProfileCvText = string.IsNullOrWhiteSpace(request.CvText) ? null : request.CvText; user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(saved);
var res = await _users.UpdateAsync(user); if (request.CvText is not null) user.ProfileCvText = string.IsNullOrWhiteSpace(request.CvText) ? null : request.CvText;
if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description))); var res = await _users.UpdateAsync(user);
if (!res.Succeeded) throw new CareerProjectionException(string.Join("; ", res.Errors.Select(e => e.Description)));
return Ok(new CareerProfileDto(saved, CareerCompleteness.Evaluate(saved), user.ProfileCvText)); 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); }
} }
/// <summary>Just the completeness scorecard, for the /career overview.</summary> /// <summary>Just the completeness scorecard, for the /career overview.</summary>
@@ -92,25 +99,34 @@ public sealed class CareerProfileController : ControllerBase
/// </summary> /// </summary>
[HttpPost("versions/{version:int}/restore")] [HttpPost("versions/{version:int}/restore")]
[Authorize(AuthenticationSchemes = "local")] [Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<CareerProfileDto>> Restore([FromRoute] int version, CancellationToken cancellationToken) public async Task<ActionResult<CareerProfileDto>> Restore([FromRoute] int version, [FromBody] CareerProfileRestoreRequest? request, CancellationToken cancellationToken)
{ {
var user = await _users.GetUserAsync(User); var user = await _users.GetUserAsync(User);
if (user is null) return StatusCode(501, "The career profile can only be edited on local accounts."); 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); try
if (restored is null) return NotFound($"Version {version} was not found."); {
var dto = await _career.ExecuteAtomicAsync(async () =>
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(restored); {
var res = await _users.UpdateAsync(user); var restored = await _career.RestoreVersionAsync(user.Id, version, request.ExpectedVersion.Value, cancellationToken);
if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description))); if (restored is null) return null;
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(restored);
return Ok(new CareerProfileDto(restored, CareerCompleteness.Evaluate(restored), user.ProfileCvText)); 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<string> Missing, List<CareerSectionStatusDto> Sections); public sealed record CareerCompletenessDto(int Percent, List<string> Missing, List<CareerSectionStatusDto> Sections);
@@ -79,7 +79,8 @@ namespace JobTrackerApi.Controllers
string? TailoredCvText, string? TailoredCvText,
WorkflowSignalDto WorkflowSignal, WorkflowSignalDto WorkflowSignal,
string? ShortSummary, string? ShortSummary,
string? FullSummary string? FullSummary,
int Version = 1
); );
public sealed record CreateJobApplicationRequest( public sealed record CreateJobApplicationRequest(
@@ -132,7 +133,8 @@ namespace JobTrackerApi.Controllers
string? JobUrl, string? JobUrl,
DateTime? DateApplied, DateTime? DateApplied,
DateTime? FeedbackRequestedAt, DateTime? FeedbackRequestedAt,
DateTime? StatusChangedAt DateTime? StatusChangedAt,
int? ExpectedVersion = null
); );
public sealed record UpdateStatusRequest(string Status); public sealed record UpdateStatusRequest(string Status);
@@ -189,7 +191,7 @@ namespace JobTrackerApi.Controllers
CandidateFitChannelGuidanceDto Guidance, CandidateFitChannelGuidanceDto Guidance,
string? CoverLetterDraft, string? CoverLetterDraft,
string? RecruiterMessageDraft); string? RecruiterMessageDraft);
public sealed record SaveTailoredCvRequest(string? TailoredCvText); public sealed record SaveTailoredCvRequest(string? TailoredCvText, int? ExpectedVersion = null);
public sealed record TailoredCvDraftDto( public sealed record TailoredCvDraftDto(
int? Id, int? Id,
int? CanonicalProfileVersion, int? CanonicalProfileVersion,
@@ -206,7 +208,8 @@ namespace JobTrackerApi.Controllers
DateTimeOffset? LastEditedAtUtc, DateTimeOffset? LastEditedAtUtc,
string Status, string Status,
string RenderedText, string RenderedText,
bool IsLegacyFallback); bool IsLegacyFallback,
int Revision = 1);
public sealed record SaveTailoredCvDraftRequest( public sealed record SaveTailoredCvDraftRequest(
string? TemplateId, string? TemplateId,
string? Headline, string? Headline,
@@ -216,13 +219,15 @@ namespace JobTrackerApi.Controllers
List<TailoredCvEducationItem>? Education, List<TailoredCvEducationItem>? Education,
List<TailoredCvCustomSection>? CustomSections, List<TailoredCvCustomSection>? CustomSections,
TailoredCvRenderOptions? RenderOptions, TailoredCvRenderOptions? RenderOptions,
string? Status); string? Status,
int? ExpectedVersion = null);
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints, List<string> AttachmentSignals, List<string> AttachmentFilesUsed, List<string> CoverLetterVariants, List<string> RecruiterMessageVariants); public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints, List<string> AttachmentSignals, List<string> AttachmentFilesUsed, List<string> CoverLetterVariants, List<string> RecruiterMessageVariants);
public sealed record SaveApplicationDraftsRequest( public sealed record SaveApplicationDraftsRequest(
string? CoverLetterText, string? CoverLetterText,
string? Notes, string? Notes,
string? RecruiterMessageDraft, 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 SavedPackageMaterial(string? TailoredCvText, string? CoverLetterText, string? RecruiterMessageDraft, string? Notes);
public sealed record InterviewPrepDto(string Summary, List<string> TalkingPoints, List<string> LikelyQuestions, List<string> WeakSpots); public sealed record InterviewPrepDto(string Summary, List<string> TalkingPoints, List<string> LikelyQuestions, List<string> WeakSpots);
public sealed record ReadinessDto(int Score, string Level, List<string> Completed, List<string> Missing, List<string> Reminders, WorkflowSignalDto WorkflowSignal); public sealed record ReadinessDto(int Score, string Level, List<string> Completed, List<string> Missing, List<string> Reminders, WorkflowSignalDto WorkflowSignal);
@@ -459,7 +459,8 @@ Canonical profile:
TailoredCvText: job.TailoredCvText, TailoredCvText: job.TailoredCvText,
WorkflowSignal: workflowSignal, WorkflowSignal: workflowSignal,
ShortSummary: job.ShortSummary, ShortSummary: job.ShortSummary,
FullSummary: fullSummary); FullSummary: fullSummary,
Version: job.Version);
} }
[HttpGet] [HttpGet]
@@ -840,6 +841,9 @@ Canonical profile:
var job = await _db.JobApplications.Include(j => j.Job).FirstOrDefaultAsync(j => j.Id == id, cancellationToken); var job = await _db.JobApplications.Include(j => j.Job).FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound(); 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 oldStatus = job.Status;
var oldResponseReceived = job.ResponseReceived; var oldResponseReceived = job.ResponseReceived;
var oldResponseDate = job.ResponseDate; var oldResponseDate = job.ResponseDate;
@@ -850,6 +854,8 @@ Canonical profile:
var title = (request.JobTitle ?? "").Trim(); var title = (request.JobTitle ?? "").Trim();
if (title.Length == 0) return BadRequest("Job title is required."); if (title.Length == 0) return BadRequest("Job title is required.");
if (request.CompanyId <= 0) return BadRequest("Valid companyId 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.JobTitle = title;
job.CompanyId = request.CompanyId; job.CompanyId = request.CompanyId;
@@ -926,7 +932,19 @@ Canonical profile:
JobLifecycleEvents.RecordStatusChange(_db, job, oldStatus, request.StatusChangedAt ?? DateTime.Now); JobLifecycleEvents.RecordStatusChange(_db, job, oldStatus, request.StatusChangedAt ?? DateTime.Now);
if (job.Job is not null) JobOpportunitySync.Apply(job, job.Job); 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(); return NoContent();
} }
@@ -1397,7 +1415,7 @@ Candidate CV/profile:
.AsNoTracking() .AsNoTracking()
.FirstOrDefaultAsync(x => x.JobApplicationId == id, cancellationToken); .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")] [HttpPost("{id:int}/tailored-cv-preview")]
@@ -1486,7 +1504,7 @@ Candidate CV/profile:
} }
var draft = await UpsertGeneratedTailoredCvDraftAsync(job, user, mode, cancellationToken); 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")] [HttpPut("{id:int}/tailored-cv-draft")]
@@ -1494,6 +1512,8 @@ Candidate CV/profile:
{ {
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken); var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound(); 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); var user = await GetCurrentUserAsync(cancellationToken);
if (user is null) return Unauthorized(); if (user is null) return Unauthorized();
@@ -1531,7 +1551,9 @@ Candidate CV/profile:
job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document); job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document);
job.TailoredCvUpdatedAt = DateTime.UtcNow; job.TailoredCvUpdatedAt = DateTime.UtcNow;
_db.Entry(job).Property(x => x.Version).IsModified = true;
await _db.SaveChangesAsync(cancellationToken); await _db.SaveChangesAsync(cancellationToken);
Response.Headers["X-Job-Version"] = job.Version.ToString(System.Globalization.CultureInfo.InvariantCulture);
return NoContent(); return NoContent();
} }
@@ -1540,10 +1562,14 @@ Candidate CV/profile:
{ {
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken); var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound(); 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.TailoredCvText = string.IsNullOrWhiteSpace(request.TailoredCvText) ? null : request.TailoredCvText.Trim();
job.TailoredCvUpdatedAt = job.TailoredCvText is null ? null : DateTime.UtcNow; job.TailoredCvUpdatedAt = job.TailoredCvText is null ? null : DateTime.UtcNow;
_db.Entry(job).Property(x => x.Version).IsModified = true;
await _db.SaveChangesAsync(cancellationToken); await _db.SaveChangesAsync(cancellationToken);
Response.Headers["X-Job-Version"] = job.Version.ToString(System.Globalization.CultureInfo.InvariantCulture);
return NoContent(); return NoContent();
} }
@@ -1552,6 +1578,8 @@ Candidate CV/profile:
{ {
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken); var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound(); 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) if (request.CoverLetterText is not null)
{ {
@@ -1575,7 +1603,9 @@ Candidate CV/profile:
: request.RecruiterMessageDraft.Trim(); : request.RecruiterMessageDraft.Trim();
} }
_db.Entry(job).Property(x => x.Version).IsModified = true;
await _db.SaveChangesAsync(cancellationToken); await _db.SaveChangesAsync(cancellationToken);
Response.Headers["X-Job-Version"] = job.Version.ToString(System.Globalization.CultureInfo.InvariantCulture);
return NoContent(); return NoContent();
} }
@@ -248,53 +248,60 @@ public sealed partial class ProfileCvController : ControllerBase
return new ExtractionPipelineResult(text, normalizedText, structuredCv); 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, var run = new CvExtractionRun
ArtifactId = artifactId, {
Trigger = trigger, OwnerUserId = user.Id,
ParserVersion = ParserVersion, ArtifactId = artifactId,
NormalizerVersion = NormalizerVersion, Trigger = trigger,
LlmPromptVersion = LlmPromptVersion, ParserVersion = ParserVersion,
Status = "applied", NormalizerVersion = NormalizerVersion,
RawExtractedText = rawText, LlmPromptVersion = LlmPromptVersion,
NormalizedText = normalizedText, Status = "applied",
StartedAtUtc = DateTimeOffset.UtcNow, RawExtractedText = rawText,
CompletedAtUtc = DateTimeOffset.UtcNow, NormalizedText = normalizedText,
AppliedAtUtc = DateTimeOffset.UtcNow, StartedAtUtc = DateTimeOffset.UtcNow,
}; CompletedAtUtc = DateTimeOffset.UtcNow,
_db.CvExtractionRuns.Add(run); AppliedAtUtc = DateTimeOffset.UtcNow,
await _db.SaveChangesAsync(cancellationToken); };
_db.CvExtractionRuns.Add(run);
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));
await _db.SaveChangesAsync(cancellationToken); await _db.SaveChangesAsync(cancellationToken);
throw new InvalidOperationException(run.ErrorMessage);
}
await _db.SaveChangesAsync(cancellationToken); structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
await PruneExtractionRunsAsync(user.Id, cancellationToken); 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<CvExtractionRun> CreateQueuedRunAsync(string ownerUserId, int? artifactId, string trigger, CancellationToken cancellationToken) private async Task<CvExtractionRun> CreateQueuedRunAsync(string ownerUserId, int? artifactId, string trigger, CancellationToken cancellationToken)
@@ -162,7 +162,7 @@ public sealed partial class ProfileCvController : ControllerBase
// in-file copies are dropped here to avoid duplicate definitions. The LayoutFamily/AtsRating // 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 // fields the branch added to CvTemplateDescriptor belong to the deferred ATS-badge work
// (Phase 4), not this foundation integration. // (Phase 4), not this foundation integration.
public sealed record AcceptCvRunRequest(List<string>? AcceptedLowConfidenceIds); public sealed record AcceptCvRunRequest(List<string>? AcceptedLowConfidenceIds, int? ExpectedVersion = null);
private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv); private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv);
private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification); 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 (run is null) return NotFound();
if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile."); 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); 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")] [HttpPost("runs/{id:int}/accept")]
@@ -268,34 +269,45 @@ public sealed partial class ProfileCvController : ControllerBase
{ {
var user = await _users.GetUserAsync(User); var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized(); 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); try
if (run is null) return NotFound(); {
if (run.Status != "pending_review") return Conflict("This extraction run is not awaiting review."); return await _careerProfileService.ExecuteAtomicAsync<IActionResult>(async () =>
if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile."); {
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 snapshot = await _careerProfileService.LoadSnapshotAsync(user.Id, HttpContext.RequestAborted);
var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson); if (snapshot.Revision != request.ExpectedVersion.Value)
var diff = _cvProfileDiffService.Diff(current, extracted); throw new DbUpdateConcurrencyException("The career profile changed after import review.");
var acceptedLowConfidenceIds = (request?.AcceptedLowConfidenceIds ?? new List<string>()).ToHashSet(StringComparer.Ordinal); var current = snapshot.Profile;
var merged = _cvProfileDiffService.Merge(current, extracted, acceptedLowConfidenceIds); var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson);
merged.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1; var diff = _cvProfileDiffService.Diff(current, extracted);
merged.Metadata.AppliedExtractionRunId = run.Id; var acceptedLowConfidenceIds = (request.AcceptedLowConfidenceIds ?? new List<string>()).ToHashSet(StringComparer.Ordinal);
merged.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow; var merged = _cvProfileDiffService.Merge(current, extracted, acceptedLowConfidenceIds);
await _careerProfileService.SaveVersionAsync(user.Id, merged, $"{run.Trigger}:accepted", HttpContext.RequestAborted); 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); user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(merged);
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) user.ProfileCvText = run.NormalizedText; if (string.IsNullOrWhiteSpace(user.ProfileCvText)) user.ProfileCvText = run.NormalizedText;
user.CurrentCvExtractionRunId = run.Id; user.CurrentCvExtractionRunId = run.Id;
user.CurrentCvProfileVersion = merged.Metadata.ProfileVersion; user.CurrentCvProfileVersion = merged.Metadata.ProfileVersion;
if (run.ArtifactId.HasValue) user.CurrentCvUploadArtifactId = run.ArtifactId.Value; if (run.ArtifactId.HasValue) user.CurrentCvUploadArtifactId = run.ArtifactId.Value;
run.Status = "applied"; run.Status = "applied";
run.AppliedAtUtc = DateTimeOffset.UtcNow; run.AppliedAtUtc = DateTimeOffset.UtcNow;
var update = await _users.UpdateAsync(user); var update = await _users.UpdateAsync(user);
if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(e => e.Description))); if (!update.Succeeded) throw new CareerProjectionException(string.Join("; ", update.Errors.Select(e => e.Description)));
await _db.SaveChangesAsync(HttpContext.RequestAborted); return Ok(new { runId = run.Id, run.Status, diff, structuredCv = merged, revision = request.ExpectedVersion.Value + 1 });
return Ok(new { runId = run.Id, run.Status, diff, structuredCv = merged }); }, 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")] [HttpPost("runs/{id:int}/discard")]
@@ -522,19 +534,20 @@ public sealed partial class ProfileCvController : ControllerBase
{ {
var user = await _users.GetUserAsync(User); var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized(); 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."); if (string.IsNullOrWhiteSpace(source)) return BadRequest("Add or import CV text before parsing sections.");
var normalizedSource = await MaybeReconstructStructuredCvAsync(source, HttpContext.RequestAborted); var normalizedSource = await MaybeReconstructStructuredCvAsync(source, HttpContext.RequestAborted);
var structuredCv = await BuildStructuredCvAsync(normalizedSource, 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); catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Reload before parsing." }); }
catch (CareerProjectionException ex) { return BadRequest(ex.Message); }
return Ok(new { structuredCv, sections = structuredCv.Sections, totalWords = CountWords(normalizedSource), extractionRunId = user.CurrentCvExtractionRunId, profileVersion = user.CurrentCvProfileVersion });
} }
[HttpPost("improve")] [HttpPost("improve")]
+1 -1
View File
@@ -2,7 +2,7 @@ using JobTrackerApi.Models;
namespace JobTrackerApi.Controllers; 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<string> PreviewBullets); public sealed record CvTemplateDescriptor(string Id, string Title, string Tone, string AccentColor, string PreviewTagline, string PreviewSummary, List<string> 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 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); public sealed record CvRewriteFailureDto(string Code, string Message, string? Detail = null, string? LastAiError = null);
+25
View File
@@ -72,10 +72,35 @@ namespace JobTrackerApi.Data
public DbSet<AccountDeletionRequest> AccountDeletionRequests => Set<AccountDeletionRequest>(); public DbSet<AccountDeletionRequest> AccountDeletionRequests => Set<AccountDeletionRequest>();
public DbSet<AccountDeletionFile> AccountDeletionFiles => Set<AccountDeletionFile>(); public DbSet<AccountDeletionFile> AccountDeletionFiles => Set<AccountDeletionFile>();
private void AdvanceJobRevisions()
{
ChangeTracker.DetectChanges();
foreach (var entry in ChangeTracker.Entries<JobApplication>().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<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
AdvanceJobRevisions();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
modelBuilder.Entity<JobApplication>().Property(x => x.Version).IsConcurrencyToken().HasDefaultValue(1);
modelBuilder.Entity<CareerProfile>().Property(x => x.Version).IsConcurrencyToken();
modelBuilder.Entity<ApplicationUser>() modelBuilder.Entity<ApplicationUser>()
.Property(x => x.PendingEmail) .Property(x => x.PendingEmail)
.HasMaxLength(320); .HasMaxLength(320);
@@ -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<int>(
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");
}
}
@@ -803,6 +803,7 @@ namespace JobTrackerApi.Migrations
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int>("Version") b.Property<int>("Version")
.IsConcurrencyToken()
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.HasKey("Id"); b.HasKey("Id");
@@ -1938,6 +1939,12 @@ namespace JobTrackerApi.Migrations
b.Property<string>("TranslatedDescription") b.Property<string>("TranslatedDescription")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int>("Version")
.IsConcurrencyToken()
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CompanyId"); b.HasIndex("CompanyId");
+1
View File
@@ -5,6 +5,7 @@ namespace JobTrackerApi.Models
public class JobApplication public class JobApplication
{ {
public int Id { get; set; } public int Id { get; set; }
public int Version { get; set; } = 1;
public string? OwnerUserId { get; set; } public string? OwnerUserId { get; set; }
public string JobTitle { get; set; } = ""; public string JobTitle { get; set; } = "";
public int CompanyId { get; set; } public int CompanyId { get; set; }
+1
View File
@@ -160,6 +160,7 @@ builder.Services.AddProblemDetails(options =>
context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier; context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
}); });
builder.Services.AddExceptionHandler<AiUsageLimitExceptionHandler>(); builder.Services.AddExceptionHandler<AiUsageLimitExceptionHandler>();
builder.Services.AddExceptionHandler<RevisionConflictExceptionHandler>();
builder.Services.AddExceptionHandler<PdfRenderExceptionHandler>(); builder.Services.AddExceptionHandler<PdfRenderExceptionHandler>();
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
var dataRoot = (builder.Configuration["Data:Root"] ?? "").Trim(); var dataRoot = (builder.Configuration["Data:Root"] ?? "").Trim();
@@ -41,7 +41,8 @@ public sealed record CoverLetterDto(
string? Text, string? Text,
int CurrentVersion, int CurrentVersion,
IReadOnlyList<CoverLetterVersionDto> Versions, IReadOnlyList<CoverLetterVersionDto> Versions,
int AiSuggestionCount); int AiSuggestionCount,
int Revision = 1);
public interface IApplicationAssetsService public interface IApplicationAssetsService
{ {
@@ -50,8 +51,8 @@ public interface IApplicationAssetsService
Task<TailoringPlanDto?> GetTailoringPlanAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); Task<TailoringPlanDto?> GetTailoringPlanAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
Task<CoverLetterDto?> GetCoverLetterAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); Task<CoverLetterDto?> GetCoverLetterAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
Task<CoverLetterDto?> SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, CancellationToken ct); Task<CoverLetterDto?> SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, int? expectedVersion, CancellationToken ct);
Task<CoverLetterDto?> RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, CancellationToken ct); Task<CoverLetterDto?> RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, int? expectedVersion, CancellationToken ct);
} }
public sealed class ApplicationAssetsService : IApplicationAssetsService 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 // 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 // text is what gets stored — an AI suggestion only becomes a version once the user saves it, which
// is what "requires approval" means here. // is what "requires approval" means here.
public async Task<CoverLetterDto?> SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, CancellationToken ct) public async Task<CoverLetterDto?> SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, int? expectedVersion, CancellationToken ct)
{ {
var job = await _db.JobApplications var job = await _db.JobApplications
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); .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 next = text?.Trim() ?? string.Empty;
var current = job.CoverLetterText?.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). // Nothing changed: do not spend a version on a no-op save (autosave calls this often).
if (string.Equals(next, current, StringComparison.Ordinal)) 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); 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 // Restore is non-destructive: the old text comes back as a NEW version, so the thing you restored
// from is still in the history. // from is still in the history.
public async Task<CoverLetterDto?> RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, CancellationToken ct) public async Task<CoverLetterDto?> RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, int? expectedVersion, CancellationToken ct)
{ {
var job = await _db.JobApplications var job = await _db.JobApplications
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); .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); .FirstOrDefaultAsync(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId && v.Version == version, ct);
if (snapshot is null) return null; 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<CoverLetterDto> BuildCoverLetterAsync(string ownerUserId, JobApplication job, CancellationToken ct) private async Task<CoverLetterDto> BuildCoverLetterAsync(string ownerUserId, JobApplication job, CancellationToken ct)
@@ -304,7 +311,8 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
current, current,
versions.Select(v => new CoverLetterVersionDto( versions.Select(v => new CoverLetterVersionDto(
v.Version, v.Source, v.AiAction, v.Text.Length, v.CreatedAtUtc, v.Version == current)).ToList(), v.Version, v.Source, v.AiAction, v.Text.Length, v.CreatedAtUtc, v.Version == current)).ToList(),
aiCount); aiCount,
job.Version);
} }
private async Task<int> NextVersionAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) private async Task<int> NextVersionAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
@@ -47,7 +47,8 @@ public sealed record WorkspaceOverviewDto(
DateTimeOffset? LastAiAtUtc, DateTimeOffset? LastAiAtUtc,
IReadOnlyList<WorkspaceActivityDto> RecentActivity, IReadOnlyList<WorkspaceActivityDto> RecentActivity,
WorkspaceNextStepDto? NextStep, WorkspaceNextStepDto? NextStep,
ChecklistProgressDto? ChecklistProgress); ChecklistProgressDto? ChecklistProgress,
int Revision = 1);
public interface IApplicationWorkspaceService public interface IApplicationWorkspaceService
{ {
@@ -147,7 +148,8 @@ public sealed class ApplicationWorkspaceService : IApplicationWorkspaceService
lastAi, lastAi,
activity, activity,
NextStep(checklist), 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 // "The user should never ask what to do next." Milestone 2 moved this onto the checklist: the first
+122 -61
View File
@@ -15,12 +15,17 @@ public interface ICareerProfileService
// Mutates the given profile in place (assigns missing item IDs + normalized dates), persists // 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 // it as the current CareerProfile snapshot plus an append-only CareerProfileVersion row, syncs
// the relational children (Experience/Education/Skill/Project/Certification/Language), and // 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. // returns the same profile. Controller writes include the legacy projection in ExecuteAtomicAsync.
Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken); Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, int expectedVersion, CancellationToken cancellationToken);
Task<CareerProfileSnapshot> LoadSnapshotAsync(string ownerUserId, CancellationToken cancellationToken);
// Includes Identity projection and import metadata in the canonical commit.
Task<T> ExecuteAtomicAsync<T>(Func<Task<T>> action, CancellationToken cancellationToken);
// Reads the master profile from the relational children (the source of truth). If the relational // 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 // rows are empty but a ProfileJson blob exists (a profile that predates Phase 3), reads fall
// backfilled from the blob first. Returns an empty profile if the user has none. // back to that blob without writing. Returns an empty profile if the user has none.
Task<StructuredCvProfile> LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken); Task<StructuredCvProfile> LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken);
// The append-only version history, newest first. // 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 // 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 // and the current state are both preserved; the restore is itself reversible). Returns the
// restored profile, or null if the version does not exist. // restored profile, or null if the version does not exist.
Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, CancellationToken cancellationToken); Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, int expectedVersion, CancellationToken cancellationToken);
// Read-only structured load for a specific owner, bypassing the tenant query filter. For public // 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 // 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 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 public sealed class CareerProfileService : ICareerProfileService
{ {
@@ -48,63 +55,119 @@ public sealed class CareerProfileService : ICareerProfileService
_db = db; _db = db;
} }
public async Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken) public async Task<T> ExecuteAtomicAsync<T>(Func<Task<T>> 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 using var transaction = _db.Database.IsRelational() && _db.Database.CurrentTransaction is null
? await _db.Database.BeginTransactionAsync(cancellationToken) ? await _db.Database.BeginTransactionAsync(cancellationToken)
: null; : null;
try
var json = StructuredCvProfileJson.SerializePersisted(profile);
var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (existing is null)
{ {
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<CareerProfileSnapshot> 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<StructuredCvProfile> 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, OwnerUserId = ownerUserId,
CareerProfileId = existing.Id,
Version = existing.Version,
ProfileJson = json, ProfileJson = json,
Version = 1, Source = string.IsNullOrWhiteSpace(source) ? "manual" : source.Trim(),
CreatedAtUtc = DateTimeOffset.UtcNow, CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow, });
}; return profile;
_db.CareerProfiles.Add(existing); }, cancellationToken);
}
else
{
existing.ProfileJson = json;
existing.Version += 1;
existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
existing.LongTailJson = CareerProfileMapper.SerializeLongTail(profile); private static bool IsDuplicateOwner(DbUpdateException exception) => exception.InnerException switch
await _db.SaveChangesAsync(cancellationToken); {
Microsoft.Data.Sqlite.SqliteException sqlite => sqlite.SqliteErrorCode == 19
await SyncRelationalChildrenAsync(existing.Id, ownerUserId, profile, cancellationToken); && sqlite.Message.Contains("UNIQUE constraint failed: CareerProfiles.OwnerUserId", StringComparison.Ordinal),
MySqlConnector.MySqlException mysql => mysql.Number == 1062
_db.CareerProfileVersions.Add(new CareerProfileVersion && mysql.Message.Contains("IX_CareerProfiles_OwnerUserId", StringComparison.Ordinal),
{ _ => false,
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;
}
public async Task<StructuredCvProfile> LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken) public async Task<StructuredCvProfile> 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(); if (profile is null) return new StructuredCvProfile();
var hasRelational = await _db.CareerExperiences.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken) 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.CareerCertifications.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
|| await _db.CareerLanguages.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)) if (!hasRelational && !string.IsNullOrWhiteSpace(profile.ProfileJson))
{ {
var fromBlob = StructuredCvProfileJson.DeserializePersisted(profile.ProfileJson); var fromBlob = StructuredCvProfileJson.DeserializePersisted(profile.ProfileJson);
AssignStableIds(fromBlob); return fromBlob;
NormalizeDates(fromBlob);
await SyncRelationalChildrenAsync(profile.Id, ownerUserId, fromBlob, cancellationToken);
} }
var experiences = UniqueChildren(await _db.CareerExperiences.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.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.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.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.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.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); 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(); return versions.Select(x => new CareerProfileVersionInfo(x.Version, x.Source, x.CreatedAtUtc, x.Version == profile.Version)).ToList();
} }
public async Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, CancellationToken cancellationToken) public async Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, int expectedVersion, CancellationToken cancellationToken)
{ {
var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (profile is null) return null; 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 // 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. // history, so a restore can itself be undone by restoring the version before it.
var restored = StructuredCvProfileJson.DeserializePersisted(target.ProfileJson); 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 // Replace-all: the profile is small and edited as a whole, so wiping and re-inserting the
@@ -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<bool> 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;
}
}