fix(rd21): guard career and job saves with durable revisions
This commit is contained in:
@@ -201,7 +201,7 @@ public sealed class ApplicationAssetsTests
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
|
||||
var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Dear team", CoverLetterSources.Manual, null, default);
|
||||
var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Dear team", CoverLetterSources.Manual, null, job.Version, default);
|
||||
|
||||
Assert.Equal("Dear team", result!.Text);
|
||||
Assert.Equal(1, result.CurrentVersion);
|
||||
@@ -217,9 +217,9 @@ public sealed class ApplicationAssetsTests
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "First draft", CoverLetterSources.Manual, null, default);
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "Second draft", CoverLetterSources.Ai, "improve", default);
|
||||
var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Third draft", CoverLetterSources.Manual, null, default);
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "First draft", CoverLetterSources.Manual, null, job.Version, default);
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "Second draft", CoverLetterSources.Ai, "improve", job.Version, default);
|
||||
var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Third draft", CoverLetterSources.Manual, null, job.Version, default);
|
||||
|
||||
Assert.Equal(3, result!.CurrentVersion);
|
||||
Assert.Equal(3, result.Versions.Count);
|
||||
@@ -234,8 +234,8 @@ public sealed class ApplicationAssetsTests
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "Same text", CoverLetterSources.Manual, null, default);
|
||||
var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Same text", CoverLetterSources.Manual, null, default);
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "Same text", CoverLetterSources.Manual, null, job.Version, default);
|
||||
var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Same text", CoverLetterSources.Manual, null, job.Version, default);
|
||||
|
||||
Assert.Single(result!.Versions);
|
||||
}
|
||||
@@ -246,10 +246,10 @@ public sealed class ApplicationAssetsTests
|
||||
var (db, assets) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "The good draft", CoverLetterSources.Manual, null, default);
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "The AI ruined it", CoverLetterSources.Ai, "shorten", default);
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "The good draft", CoverLetterSources.Manual, null, job.Version, default);
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "The AI ruined it", CoverLetterSources.Ai, "shorten", job.Version, default);
|
||||
|
||||
var result = await assets.RestoreCoverLetterAsync("user-1", job.Id, 1, default);
|
||||
var result = await assets.RestoreCoverLetterAsync("user-1", job.Id, 1, job.Version, default);
|
||||
|
||||
Assert.Equal("The good draft", result!.Text);
|
||||
// Restore is additive: v1 and v2 both survive, and the restore is v3.
|
||||
@@ -265,9 +265,9 @@ public sealed class ApplicationAssetsTests
|
||||
var (db, assets) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "Only draft", CoverLetterSources.Manual, null, default);
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "Only draft", CoverLetterSources.Manual, null, job.Version, default);
|
||||
|
||||
Assert.Null(await assets.RestoreCoverLetterAsync("user-1", job.Id, 99, default));
|
||||
Assert.Null(await assets.RestoreCoverLetterAsync("user-1", job.Id, 99, job.Version, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -277,11 +277,11 @@ public sealed class ApplicationAssetsTests
|
||||
await using var _ = db;
|
||||
var mine = await SeedJobAsync(db, "user-1");
|
||||
var theirs = await SeedJobAsync(db, "user-2");
|
||||
await assets.SaveCoverLetterAsync("user-1", mine.Id, "Mine", CoverLetterSources.Manual, null, default);
|
||||
await assets.SaveCoverLetterAsync("user-1", mine.Id, "Mine", CoverLetterSources.Manual, null, mine.Version, default);
|
||||
|
||||
Assert.Null(await assets.GetCoverLetterAsync("user-1", theirs.Id, default));
|
||||
Assert.Null(await assets.SaveCoverLetterAsync("user-1", theirs.Id, "Sneak", CoverLetterSources.Manual, null, default));
|
||||
Assert.Null(await assets.RestoreCoverLetterAsync("user-1", theirs.Id, 1, default));
|
||||
Assert.Null(await assets.SaveCoverLetterAsync("user-1", theirs.Id, "Sneak", CoverLetterSources.Manual, null, theirs.Version, default));
|
||||
Assert.Null(await assets.RestoreCoverLetterAsync("user-1", theirs.Id, 1, theirs.Version, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -564,28 +564,23 @@ public sealed class AuthAndSystemControllerTests
|
||||
Assert.Equal("{\"jobs\":[]}", user.ProfileCvStructureJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Career_only_save_does_not_wipe_identity()
|
||||
[Theory]
|
||||
[InlineData("stale text", null)]
|
||||
[InlineData(null, "{\"summary\":[\"stale\"]}")]
|
||||
[InlineData("", "")]
|
||||
public async Task Legacy_profile_endpoint_cannot_bypass_canonical_revision_guards(string? text, string? structured)
|
||||
{
|
||||
// The mirror: /career saves the master profile and omits identity fields.
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
Email = "ada@example.com",
|
||||
UserName = "ada",
|
||||
FirstName = "Ada",
|
||||
LastName = "Lovelace",
|
||||
DisplayName = "Ada L.",
|
||||
UserName = "ada", FirstName = "Ada", ProfileCvText = "Approved text", ProfileCvStructureJson = "Approved structure",
|
||||
};
|
||||
var controller = BuildProfileController(user);
|
||||
|
||||
var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(null, null, null, null, null, "New CV text", "{\"jobs\":[1]}"));
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(
|
||||
null, null, "Stale identity", null, null, text, structured));
|
||||
Assert.IsType<BadRequestObjectResult>(result);
|
||||
Assert.Equal("Ada", user.FirstName);
|
||||
Assert.Equal("Lovelace", user.LastName);
|
||||
Assert.Equal("Ada L.", user.DisplayName);
|
||||
Assert.Equal("New CV text", user.ProfileCvText);
|
||||
Assert.Equal("{\"jobs\":[1]}", user.ProfileCvStructureJson);
|
||||
Assert.Equal("Approved text", user.ProfileCvText);
|
||||
Assert.Equal("Approved structure", user.ProfileCvStructureJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -47,7 +47,7 @@ public sealed class CareerProfileControllerTests
|
||||
{
|
||||
var (controller, _, user) = Build();
|
||||
|
||||
var put = await controller.Put(new CareerProfileSaveRequest(Sample(), null), CancellationToken.None);
|
||||
var put = await controller.Put(new CareerProfileSaveRequest(Sample(), null, 0), CancellationToken.None);
|
||||
Assert.IsType<OkObjectResult>(put.Result);
|
||||
|
||||
// The derived blob projection is kept in sync for legacy readers.
|
||||
@@ -78,7 +78,7 @@ public sealed class CareerProfileControllerTests
|
||||
Notes = "Used with distributed teams",
|
||||
});
|
||||
|
||||
var put = await controller.Put(new CareerProfileSaveRequest(profile, null), CancellationToken.None);
|
||||
var put = await controller.Put(new CareerProfileSaveRequest(profile, null, 0), CancellationToken.None);
|
||||
var saved = Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>(put.Result).Value).Profile;
|
||||
var get = await controller.Get(CancellationToken.None);
|
||||
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()
|
||||
{
|
||||
var (controller, _, _) = Build();
|
||||
await controller.Put(new CareerProfileSaveRequest(Sample(), null), CancellationToken.None); // has personal + experience + skills
|
||||
await controller.Put(new CareerProfileSaveRequest(Sample(), null, 0), CancellationToken.None); // has personal + experience + skills
|
||||
|
||||
var get = await controller.Get(CancellationToken.None);
|
||||
var dto = Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>(get.Result).Value);
|
||||
@@ -121,7 +121,7 @@ public sealed class CareerProfileControllerTests
|
||||
var huge = new StructuredCvProfile();
|
||||
for (var i = 0; i < 500; i++) huge.Skills.Add($"skill-{i}");
|
||||
|
||||
var put = await controller.Put(new CareerProfileSaveRequest(huge, null), CancellationToken.None);
|
||||
var put = await controller.Put(new CareerProfileSaveRequest(huge, null, 0), CancellationToken.None);
|
||||
|
||||
Assert.IsType<BadRequestObjectResult>(put.Result);
|
||||
}
|
||||
@@ -133,7 +133,7 @@ public sealed class CareerProfileControllerTests
|
||||
var profile = Sample();
|
||||
profile.Contact.Website = $"https://example.test/{new string('a', 600)}";
|
||||
|
||||
var put = await controller.Put(new CareerProfileSaveRequest(profile, null), CancellationToken.None);
|
||||
var put = await controller.Put(new CareerProfileSaveRequest(profile, null, 0), CancellationToken.None);
|
||||
|
||||
var badRequest = Assert.IsType<BadRequestObjectResult>(put.Result);
|
||||
Assert.Equal("A contact field exceeds the allowed length.", badRequest.Value);
|
||||
@@ -144,7 +144,7 @@ public sealed class CareerProfileControllerTests
|
||||
{
|
||||
var (controller, _, _) = Build();
|
||||
|
||||
var put = await controller.Put(new CareerProfileSaveRequest(new StructuredCvProfile(), null), CancellationToken.None);
|
||||
var put = await controller.Put(new CareerProfileSaveRequest(new StructuredCvProfile(), null, 0), CancellationToken.None);
|
||||
|
||||
// Completeness, not validation, is what flags an incomplete profile — an empty profile saves.
|
||||
Assert.IsType<OkObjectResult>(put.Result);
|
||||
|
||||
@@ -29,7 +29,7 @@ public sealed class CareerProfileServiceTests
|
||||
Jobs = { new StructuredCvJob { Title = "Engineer", Company = "Acme" } },
|
||||
};
|
||||
|
||||
var saved = await service.SaveVersionAsync("user-1", profile, "upload", default);
|
||||
var saved = await service.SaveVersionAsync("user-1", profile, "upload", 0, default);
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(saved.Jobs[0].Id));
|
||||
}
|
||||
@@ -45,7 +45,7 @@ public sealed class CareerProfileServiceTests
|
||||
Education = { new StructuredCvEducation { Id = "shared", Qualification = "BSc", Institution = "University" } },
|
||||
};
|
||||
|
||||
var saved = await service.SaveVersionAsync("user-1", profile, "manual", default);
|
||||
var saved = await service.SaveVersionAsync("user-1", profile, "manual", 0, default);
|
||||
|
||||
Assert.Equal("shared", saved.Jobs[0].Id);
|
||||
Assert.NotEqual(saved.Jobs[0].Id, saved.Education[0].Id);
|
||||
@@ -80,10 +80,10 @@ public sealed class CareerProfileServiceTests
|
||||
{
|
||||
Jobs = { new StructuredCvJob { Title = "Engineer", Company = "Acme" } },
|
||||
};
|
||||
await service.SaveVersionAsync("user-1", profile, "upload", default);
|
||||
await service.SaveVersionAsync("user-1", profile, "upload", 0, default);
|
||||
var firstId = profile.Jobs[0].Id;
|
||||
|
||||
await service.SaveVersionAsync("user-1", profile, "rebuild", default);
|
||||
await service.SaveVersionAsync("user-1", profile, "rebuild", 1, default);
|
||||
|
||||
Assert.Equal(firstId, profile.Jobs[0].Id);
|
||||
}
|
||||
@@ -108,8 +108,8 @@ public sealed class CareerProfileServiceTests
|
||||
var service = new CareerProfileService(db);
|
||||
var profile = new StructuredCvProfile { Summary = { "First version" } };
|
||||
|
||||
await service.SaveVersionAsync("user-1", profile, "upload", default);
|
||||
await service.SaveVersionAsync("user-1", profile, "improve", default);
|
||||
await service.SaveVersionAsync("user-1", profile, "upload", 0, default);
|
||||
await service.SaveVersionAsync("user-1", profile, "improve", 1, default);
|
||||
|
||||
var snapshots = await db.CareerProfiles.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync();
|
||||
var history = await db.CareerProfileVersions.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync();
|
||||
@@ -130,7 +130,7 @@ public sealed class CareerProfileServiceTests
|
||||
Jobs = { new StructuredCvJob { Title = "Engineer", Start = "Jan 2020", End = "Present", IsCurrent = true } },
|
||||
};
|
||||
|
||||
var saved = await service.SaveVersionAsync("user-1", profile, "upload", default);
|
||||
var saved = await service.SaveVersionAsync("user-1", profile, "upload", 0, default);
|
||||
|
||||
Assert.Equal("2020-01", saved.Jobs[0].StartDate);
|
||||
Assert.Null(saved.Jobs[0].EndDate);
|
||||
@@ -165,7 +165,7 @@ public sealed class CareerProfileServiceTests
|
||||
await using var db = NewContext("user-1");
|
||||
var service = new CareerProfileService(db);
|
||||
|
||||
await service.SaveVersionAsync("user-1", RichProfile(), "manual", default);
|
||||
await service.SaveVersionAsync("user-1", RichProfile(), "manual", 0, default);
|
||||
var loaded = await service.LoadStructuredAsync("user-1", default);
|
||||
|
||||
Assert.Equal("Ada Lovelace", loaded.Contact.FullName);
|
||||
@@ -196,7 +196,7 @@ public sealed class CareerProfileServiceTests
|
||||
var service = new CareerProfileService(db);
|
||||
var profile = RichProfile();
|
||||
|
||||
await service.SaveVersionAsync("user-1", profile, "manual", default);
|
||||
await service.SaveVersionAsync("user-1", profile, "manual", 0, default);
|
||||
var jobKey = profile.Jobs[0].Id;
|
||||
var loaded = await service.LoadStructuredAsync("user-1", default);
|
||||
|
||||
@@ -213,9 +213,9 @@ public sealed class CareerProfileServiceTests
|
||||
await using var db = NewContext("user-1");
|
||||
var service = new CareerProfileService(db);
|
||||
|
||||
await service.SaveVersionAsync("user-1", RichProfile(), "manual", default); // 2 jobs
|
||||
await service.SaveVersionAsync("user-1", RichProfile(), "manual", 0, default); // 2 jobs
|
||||
var trimmed = new StructuredCvProfile { Jobs = { new StructuredCvJob { Title = "Only role", Company = "Solo" } } };
|
||||
await service.SaveVersionAsync("user-1", trimmed, "manual", default); // 1 job
|
||||
await service.SaveVersionAsync("user-1", trimmed, "manual", 1, default); // 1 job
|
||||
|
||||
var experiences = await db.CareerExperiences.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync();
|
||||
Assert.Single(experiences);
|
||||
@@ -225,7 +225,7 @@ public sealed class CareerProfileServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Load_backfills_relational_children_from_a_pre_phase3_blob()
|
||||
public async Task Load_reads_a_pre_phase3_blob_without_mutating_canonical_children()
|
||||
{
|
||||
await using var db = NewContext("user-1");
|
||||
// Simulate a profile that predates Phase 3: a CareerProfile with a ProfileJson blob but no
|
||||
@@ -239,8 +239,8 @@ public sealed class CareerProfileServiceTests
|
||||
var loaded = await service.LoadStructuredAsync("user-1", default);
|
||||
|
||||
Assert.Equal(2, loaded.Jobs.Count);
|
||||
// The backfill materialized the relational rows.
|
||||
Assert.Equal(2, await db.CareerExperiences.IgnoreQueryFilters().CountAsync(x => x.OwnerUserId == "user-1"));
|
||||
// Reads cannot materialize unversioned children alongside a concurrent replacement.
|
||||
Assert.Equal(0, await db.CareerExperiences.IgnoreQueryFilters().CountAsync(x => x.OwnerUserId == "user-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -260,8 +260,8 @@ public sealed class CareerProfileServiceTests
|
||||
{
|
||||
await using var db = NewContext("user-1");
|
||||
var service = new CareerProfileService(db);
|
||||
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "v1" } }, "upload", default);
|
||||
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "v2" } }, "improve", default);
|
||||
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "v1" } }, "upload", 0, default);
|
||||
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "v2" } }, "improve", 1, default);
|
||||
|
||||
var versions = await service.ListVersionsAsync("user-1", default);
|
||||
|
||||
@@ -276,10 +276,10 @@ public sealed class CareerProfileServiceTests
|
||||
{
|
||||
await using var db = NewContext("user-1");
|
||||
var service = new CareerProfileService(db);
|
||||
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "original" } }, "upload", default); // v1
|
||||
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "edited" } }, "manual", default); // v2
|
||||
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "original" } }, "upload", 0, default); // v1
|
||||
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "edited" } }, "manual", 1, default); // v2
|
||||
|
||||
var restored = await service.RestoreVersionAsync("user-1", 1, default);
|
||||
var restored = await service.RestoreVersionAsync("user-1", 1, 2, default);
|
||||
|
||||
Assert.NotNull(restored);
|
||||
Assert.Equal(new[] { "original" }, restored!.Summary);
|
||||
@@ -296,8 +296,8 @@ public sealed class CareerProfileServiceTests
|
||||
{
|
||||
await using var db = NewContext("user-1");
|
||||
var service = new CareerProfileService(db);
|
||||
await service.SaveVersionAsync("user-1", new StructuredCvProfile(), "upload", default);
|
||||
await service.SaveVersionAsync("user-1", new StructuredCvProfile(), "upload", 0, default);
|
||||
|
||||
Assert.Null(await service.RestoreVersionAsync("user-1", 99, default));
|
||||
Assert.Null(await service.RestoreVersionAsync("user-1", 99, 1, default));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public sealed class CvAiReadinessTests
|
||||
Db = new(new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(Connection).Options, current.Object);
|
||||
await Db.Database.EnsureCreatedAsync();
|
||||
Career = new(Db);
|
||||
await Career.SaveVersionAsync("owner", Profile, "manual", default);
|
||||
await Career.SaveVersionAsync("owner", Profile, "manual", 0, default);
|
||||
Variants = new(Db, Career, new ThemedCvRenderer());
|
||||
Cv = await Variants.CreateAsync("owner", "Synthetic CV", null, new CvVariantSettings(), default);
|
||||
Suggestions = new(Db, Career, Variants, Ai.Object, Protection);
|
||||
@@ -150,7 +150,7 @@ public sealed class CvAiReadinessTests
|
||||
if (canonical)
|
||||
{
|
||||
fixture.Profile.Summary = ["Newer canonical user content"];
|
||||
await fixture.Career.SaveVersionAsync("owner", fixture.Profile, "manual", default);
|
||||
await fixture.Career.SaveVersionAsync("owner", fixture.Profile, "manual", 1, default);
|
||||
}
|
||||
else await fixture.Variants.SaveAsync("owner", fixture.Cv.Id, null,
|
||||
new CvVariantSettings { Headline = "Newer CV user content" }, "manual", 1, default);
|
||||
@@ -191,7 +191,7 @@ public sealed class CvAiReadinessTests
|
||||
.Returns(async () =>
|
||||
{
|
||||
fixture.Profile.Summary = ["Newer canonical content"];
|
||||
await fixture.Career.SaveVersionAsync("owner", fixture.Profile, "manual", default);
|
||||
await fixture.Career.SaveVersionAsync("owner", fixture.Profile, "manual", 1, default);
|
||||
return new AiGenerationResult(Assessment(shortened, Source));
|
||||
});
|
||||
var error = await Assert.ThrowsAsync<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();
|
||||
fixture.Profile.Jobs = [new() { Id = "experience-key", Title = "Engineer", Company = "Nordlys", Bullets = { Source } }];
|
||||
await fixture.Career.SaveVersionAsync("owner", fixture.Profile, "manual", default);
|
||||
await fixture.Career.SaveVersionAsync("owner", fixture.Profile, "manual", 1, default);
|
||||
await fixture.Variants.SaveAsync("owner", fixture.Cv.Id, null, new CvVariantSettings
|
||||
{ AccentColor = "#123456", PageMarginMm = 22, Sections = { new() { Key = "experience" }, new() { Key = "summary" } } }, "manual", 1, default);
|
||||
fixture.Ai.Setup(value => value.GenerateSectionWithMetadataAsync(It.IsAny<string>(), It.IsAny<string>(), AiGenerationOutputMode.CvBlocksJson, It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
|
||||
@@ -459,7 +459,7 @@ public sealed class CvBuilderTests
|
||||
{
|
||||
var (db, svc) = NewService("user-1");
|
||||
await using var _ = db;
|
||||
await new CareerProfileService(db).SaveVersionAsync("user-1", Rich(), "manual", default);
|
||||
await new CareerProfileService(db).SaveVersionAsync("user-1", Rich(), "manual", 0, default);
|
||||
var v = await svc.CreateAsync("user-1", "CV", null, new CvVariantSettings { ThemeId = "modern" }, default);
|
||||
|
||||
var render = await svc.RenderAsync("user-1", v.Id, new CvRenderPerson("Fallback", null), default);
|
||||
@@ -473,7 +473,7 @@ public sealed class CvBuilderTests
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString();
|
||||
var (ownerDb, ownerSvc) = NewService("user-1", dbName);
|
||||
await new CareerProfileService(ownerDb).SaveVersionAsync("user-1", Rich(), "manual", default);
|
||||
await new CareerProfileService(ownerDb).SaveVersionAsync("user-1", Rich(), "manual", 0, default);
|
||||
var v = await ownerSvc.CreateAsync("user-1", "CV", null, new CvVariantSettings { ThemeId = "minimal" }, default);
|
||||
await ownerSvc.SetPublicAsync("user-1", v.Id, true, default);
|
||||
await ownerDb.DisposeAsync();
|
||||
|
||||
@@ -78,7 +78,7 @@ public sealed class CvRevisionReadinessTests
|
||||
Jobs = { new() { Id = "source-entry", Title = "Canonical role", Company = "Nordlys", Bullets = { "Canonical evidence" } } },
|
||||
Skills = { "C#", "Norsk" },
|
||||
};
|
||||
await career.SaveVersionAsync("revision-owner", profile, "manual", default);
|
||||
await career.SaveVersionAsync("revision-owner", profile, "manual", 0, default);
|
||||
var service = Service(db);
|
||||
var lens = new CvVariantSettings
|
||||
{
|
||||
@@ -97,7 +97,7 @@ public sealed class CvRevisionReadinessTests
|
||||
// History is the immutable lens; inherited source values intentionally remain live.
|
||||
profile.Contact.Headline = "Updated canonical source";
|
||||
profile.Jobs[0].Title = "Updated canonical role";
|
||||
await career.SaveVersionAsync("revision-owner", profile, "manual", default);
|
||||
await career.SaveVersionAsync("revision-owner", profile, "manual", 1, default);
|
||||
Assert.Equal(originalHistory, (await db.CvVariantVersions.SingleAsync()).SettingsJson);
|
||||
var reset = new CvVariantSettings { ThemeId = "minimal" };
|
||||
await service.SaveAsync("revision-owner", cv.Id, null, reset, "manual", 1, default);
|
||||
|
||||
@@ -39,7 +39,7 @@ public sealed class JobApplicationsApplicationPackageTests
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
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);
|
||||
var saved = await db.JobApplications.FirstAsync();
|
||||
@@ -68,7 +68,7 @@ public sealed class JobApplicationsApplicationPackageTests
|
||||
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1");
|
||||
var updated = await controller.SaveApplicationDrafts(
|
||||
job.Id,
|
||||
new SaveApplicationDraftsRequest(null, null, " New recruiter message ", " New answer "),
|
||||
new SaveApplicationDraftsRequest(null, null, " New recruiter message ", " New answer ", ExpectedVersion: job.Version),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<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>>>";
|
||||
var cleared = await controller.SaveApplicationDrafts(
|
||||
job.Id,
|
||||
new SaveApplicationDraftsRequest(null, null, "", ""),
|
||||
new SaveApplicationDraftsRequest(null, null, "", "", ExpectedVersion: job.Version),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(cleared);
|
||||
@@ -109,7 +109,7 @@ public sealed class JobApplicationsApplicationPackageTests
|
||||
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1");
|
||||
var result = await controller.SaveApplicationDrafts(
|
||||
otherUsersJob.Id,
|
||||
new SaveApplicationDraftsRequest(null, null, "Changed", "Changed"),
|
||||
new SaveApplicationDraftsRequest(null, null, "Changed", "Changed", ExpectedVersion: otherUsersJob.Version),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result);
|
||||
@@ -424,7 +424,7 @@ public sealed class JobApplicationsApplicationPackageTests
|
||||
generated.Education,
|
||||
generated.CustomSections,
|
||||
generated.RenderOptions,
|
||||
"edited"), CancellationToken.None);
|
||||
"edited", ExpectedVersion: generated.Revision), CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(saveResult);
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, "user-1");
|
||||
var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(" Cover letter body ", " Notes body ", null), CancellationToken.None);
|
||||
var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(" Cover letter body ", " Notes body ", null, ExpectedVersion: job.Version), CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
var saved = await db.JobApplications.FirstAsync();
|
||||
@@ -272,7 +272,8 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
||||
JobUrl: null,
|
||||
DateApplied: null,
|
||||
FeedbackRequestedAt: null,
|
||||
StatusChangedAt: null);
|
||||
StatusChangedAt: null,
|
||||
ExpectedVersion: job.Version);
|
||||
|
||||
var result = await controller.Update(job.Id, request, CancellationToken.None);
|
||||
|
||||
@@ -340,7 +341,8 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
||||
JobUrl: null,
|
||||
DateApplied: null,
|
||||
FeedbackRequestedAt: null,
|
||||
StatusChangedAt: null);
|
||||
StatusChangedAt: null,
|
||||
ExpectedVersion: job.Version);
|
||||
|
||||
Assert.IsType<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);
|
||||
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));
|
||||
summarizer.Verify(service => service.SummarizeAsync(It.IsAny<string>(), 160, 60), Times.Once);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class JobApplicationsMariaDraftTests
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, "user-1");
|
||||
var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(null, null, " Recruiter hello "), CancellationToken.None);
|
||||
var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(null, null, " Recruiter hello ", ExpectedVersion: job.Version), CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
var saved = await db.JobApplications.FirstAsync();
|
||||
|
||||
@@ -15,6 +15,33 @@ public sealed class MigrationChainTests
|
||||
{
|
||||
private const string SnapshotMigration = "20260711181039_SyncModelSnapshot";
|
||||
|
||||
[Fact]
|
||||
public async Task Rd21_revision_upgrade_and_rollback_preserve_existing_job_content()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await using var db = Context(connection);
|
||||
var migrator = db.GetService<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]
|
||||
public async Task Blank_sqlite_chain_reaches_latest_and_is_idempotent()
|
||||
{
|
||||
|
||||
@@ -92,7 +92,7 @@ public sealed class ProfileCvControllerTests
|
||||
Assert.Null(user.CurrentCvExtractionRunId);
|
||||
|
||||
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);
|
||||
Assert.Equal("applied", run.Status);
|
||||
@@ -906,7 +906,7 @@ public sealed class ProfileCvControllerTests
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
|
||||
|
||||
var result = await controller.Parse(new ParseCvRequest(user.ProfileCvText));
|
||||
var result = await controller.Parse(new ParseCvRequest(user.ProfileCvText, 0));
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var json = JsonSerializer.Serialize(ok.Value);
|
||||
@@ -940,7 +940,7 @@ public sealed class ProfileCvControllerTests
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
|
||||
|
||||
var result = await controller.Parse(new ParseCvRequest(user.ProfileCvText));
|
||||
var result = await controller.Parse(new ParseCvRequest(user.ProfileCvText, 0));
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var json = JsonSerializer.Serialize(ok.Value);
|
||||
@@ -978,7 +978,7 @@ public sealed class ProfileCvControllerTests
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object);
|
||||
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source, 0));
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var json = JsonSerializer.Serialize(ok.Value);
|
||||
@@ -1018,7 +1018,7 @@ public sealed class ProfileCvControllerTests
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object);
|
||||
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source, 0));
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var json = JsonSerializer.Serialize(ok.Value);
|
||||
@@ -1054,7 +1054,7 @@ public sealed class ProfileCvControllerTests
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object);
|
||||
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source, 0));
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var json = JsonSerializer.Serialize(ok.Value);
|
||||
@@ -1237,7 +1237,7 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
await using var db = CreateDb();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths(), null, normalizer.Object);
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source, 0));
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
|
||||
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
@@ -1276,7 +1276,7 @@ public sealed class ProfileCvControllerTests
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
|
||||
|
||||
var result = await controller.Parse(new ParseCvRequest(rawSource));
|
||||
var result = await controller.Parse(new ParseCvRequest(rawSource, 0));
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
Assert.NotNull(ok.Value);
|
||||
|
||||
@@ -1305,7 +1305,7 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
await using var db = CreateDb();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source, 0));
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
|
||||
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
@@ -1336,7 +1336,7 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
await using var db = CreateDb();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source, 0));
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
|
||||
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
@@ -1365,7 +1365,7 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
await using var db = CreateDb();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source, 0));
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
|
||||
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
@@ -1393,7 +1393,7 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
await using var db = CreateDb();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source, 0));
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
|
||||
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);
|
||||
await db.SaveChangesAsync();
|
||||
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 assets = new ApplicationAssetsService(db, variants, new ApplicationIntelligenceService(db, new JobCvMatchService()));
|
||||
Assert.NotNull(await assets.GetTailoringPlanAsync("user-1", job.Id, default));
|
||||
|
||||
@@ -19,7 +19,8 @@ namespace JobTrackerApi.Controllers;
|
||||
public sealed class ApplicationAssetsController : ControllerBase
|
||||
{
|
||||
public sealed record AttachVariantRequest(int? VariantId);
|
||||
public sealed record SaveCoverLetterRequest(string? Text, string? Source, string? AiAction);
|
||||
public sealed record SaveCoverLetterRequest(string? Text, string? Source, string? AiAction, int? ExpectedVersion = null);
|
||||
public sealed record RestoreCoverLetterRequest(int? ExpectedVersion);
|
||||
|
||||
private readonly UserManager<ApplicationUser> _users;
|
||||
private readonly IApplicationAssetsService _assets;
|
||||
@@ -74,16 +75,16 @@ public sealed class ApplicationAssetsController : ControllerBase
|
||||
var userId = await CurrentUserIdAsync();
|
||||
if (userId is null) return Unauthorized();
|
||||
var result = await _assets.SaveCoverLetterAsync(
|
||||
userId, jobId, request?.Text, request?.Source ?? CoverLetterSources.Manual, request?.AiAction, ct);
|
||||
userId, jobId, request?.Text, request?.Source ?? CoverLetterSources.Manual, request?.AiAction, request?.ExpectedVersion, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("cover-letter/versions/{version:int}/restore")]
|
||||
public async Task<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();
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -516,12 +516,14 @@ public sealed class AuthController : ControllerBase
|
||||
return StatusCode(501, "Profile updates are only supported for local username/password accounts.");
|
||||
}
|
||||
|
||||
if (request.ProfileCvText is not null || request.ProfileCvStructureJson is not null)
|
||||
return BadRequest("Career data must be saved through /api/career/profile with its source revision.");
|
||||
|
||||
// Partial update. A field is only touched when the request actually carries it:
|
||||
// - null / omitted -> leave unchanged (the caller isn't editing this field)
|
||||
// - "" -> explicitly clear
|
||||
// - "value" -> set (trimmed)
|
||||
// This lets /profile save identity fields and /career save the master-profile fields
|
||||
// through the same endpoint without one wiping the other. Email and UserName are the
|
||||
// Career data uses its own atomic, revision-guarded endpoint. Email and UserName are the
|
||||
// login identifiers and are never cleared to empty. Email ownership changes use the
|
||||
// separate, token-confirmed email-change flow below.
|
||||
if (request.UserName is not null)
|
||||
@@ -532,8 +534,6 @@ public sealed class AuthController : ControllerBase
|
||||
if (request.FirstName is not null) user.FirstName = TrimOrNull(request.FirstName);
|
||||
if (request.LastName is not null) user.LastName = TrimOrNull(request.LastName);
|
||||
if (request.DisplayName is not null) user.DisplayName = TrimOrNull(request.DisplayName);
|
||||
if (request.ProfileCvText is not null) user.ProfileCvText = TrimOrNull(request.ProfileCvText);
|
||||
if (request.ProfileCvStructureJson is not null) user.ProfileCvStructureJson = TrimOrNull(request.ProfileCvStructureJson);
|
||||
|
||||
var res = await _users.UpdateAsync(user);
|
||||
if (!res.Succeeded)
|
||||
|
||||
@@ -4,6 +4,7 @@ using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
@@ -35,8 +36,8 @@ public sealed class CareerProfileController : ControllerBase
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var profile = await _career.LoadStructuredAsync(user.Id, cancellationToken);
|
||||
return Ok(new CareerProfileDto(profile, CareerCompleteness.Evaluate(profile), user.ProfileCvText));
|
||||
var snapshot = await _career.LoadSnapshotAsync(user.Id, cancellationToken);
|
||||
return Ok(new CareerProfileDto(snapshot.Profile, CareerCompleteness.Evaluate(snapshot.Profile), snapshot.CvText, snapshot.Revision));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -50,21 +51,27 @@ public sealed class CareerProfileController : ControllerBase
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return StatusCode(501, "The career profile can only be edited on local accounts.");
|
||||
if (request is null || request.ExpectedVersion is null or < 0) return BadRequest("expectedVersion is required.");
|
||||
|
||||
var profile = StructuredCvProfileJson.NormalizeForPersistence(request?.Profile);
|
||||
var profile = StructuredCvProfileJson.NormalizeForPersistence(request.Profile);
|
||||
var error = CareerProfileValidator.Validate(profile);
|
||||
if (error is not null) return BadRequest(error);
|
||||
|
||||
var saved = await _career.SaveVersionAsync(user.Id, profile, "manual", cancellationToken);
|
||||
|
||||
// Keep the derived projection in sync for legacy readers. CvText (the raw imported text) is
|
||||
// part of the career profile and is set here too; identity fields are never touched.
|
||||
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(saved);
|
||||
if (request?.CvText is not null) user.ProfileCvText = string.IsNullOrWhiteSpace(request.CvText) ? null : request.CvText;
|
||||
var res = await _users.UpdateAsync(user);
|
||||
if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||
|
||||
return Ok(new CareerProfileDto(saved, CareerCompleteness.Evaluate(saved), user.ProfileCvText));
|
||||
try
|
||||
{
|
||||
var dto = await _career.ExecuteAtomicAsync(async () =>
|
||||
{
|
||||
var saved = await _career.SaveVersionAsync(user.Id, profile, "manual", request.ExpectedVersion.Value, cancellationToken);
|
||||
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(saved);
|
||||
if (request.CvText is not null) user.ProfileCvText = string.IsNullOrWhiteSpace(request.CvText) ? null : request.CvText;
|
||||
var res = await _users.UpdateAsync(user);
|
||||
if (!res.Succeeded) throw new CareerProjectionException(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||
return new CareerProfileDto(saved, CareerCompleteness.Evaluate(saved), user.ProfileCvText, request.ExpectedVersion.Value + 1);
|
||||
}, cancellationToken);
|
||||
return Ok(dto);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Reload and review your draft before saving." }); }
|
||||
catch (CareerProjectionException ex) { return BadRequest(ex.Message); }
|
||||
}
|
||||
|
||||
/// <summary>Just the completeness scorecard, for the /career overview.</summary>
|
||||
@@ -92,25 +99,34 @@ public sealed class CareerProfileController : ControllerBase
|
||||
/// </summary>
|
||||
[HttpPost("versions/{version:int}/restore")]
|
||||
[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);
|
||||
if (user is null) return StatusCode(501, "The career profile can only be edited on local accounts.");
|
||||
if (request is null || request.ExpectedVersion is null or < 0) return BadRequest("expectedVersion is required.");
|
||||
|
||||
var restored = await _career.RestoreVersionAsync(user.Id, version, cancellationToken);
|
||||
if (restored is null) return NotFound($"Version {version} was not found.");
|
||||
|
||||
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(restored);
|
||||
var res = await _users.UpdateAsync(user);
|
||||
if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||
|
||||
return Ok(new CareerProfileDto(restored, CareerCompleteness.Evaluate(restored), user.ProfileCvText));
|
||||
try
|
||||
{
|
||||
var dto = await _career.ExecuteAtomicAsync(async () =>
|
||||
{
|
||||
var restored = await _career.RestoreVersionAsync(user.Id, version, request.ExpectedVersion.Value, cancellationToken);
|
||||
if (restored is null) return null;
|
||||
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(restored);
|
||||
var res = await _users.UpdateAsync(user);
|
||||
if (!res.Succeeded) throw new CareerProjectionException(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||
return new CareerProfileDto(restored, CareerCompleteness.Evaluate(restored), user.ProfileCvText, request.ExpectedVersion.Value + 1);
|
||||
}, cancellationToken);
|
||||
return dto is null ? NotFound($"Version {version} was not found.") : Ok(dto);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Reload before restoring a version." }); }
|
||||
catch (CareerProjectionException ex) { return BadRequest(ex.Message); }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record CareerProfileSaveRequest(StructuredCvProfile? Profile, string? CvText);
|
||||
public sealed record CareerProfileSaveRequest(StructuredCvProfile? Profile, string? CvText, int? ExpectedVersion = null);
|
||||
public sealed record CareerProfileRestoreRequest(int? ExpectedVersion);
|
||||
|
||||
public sealed record CareerProfileDto(StructuredCvProfile Profile, CareerCompletenessDto Completeness, string? CvText);
|
||||
public sealed record CareerProfileDto(StructuredCvProfile Profile, CareerCompletenessDto Completeness, string? CvText, int Revision);
|
||||
|
||||
public sealed record CareerCompletenessDto(int Percent, List<string> Missing, List<CareerSectionStatusDto> Sections);
|
||||
|
||||
|
||||
@@ -79,7 +79,8 @@ namespace JobTrackerApi.Controllers
|
||||
string? TailoredCvText,
|
||||
WorkflowSignalDto WorkflowSignal,
|
||||
string? ShortSummary,
|
||||
string? FullSummary
|
||||
string? FullSummary,
|
||||
int Version = 1
|
||||
);
|
||||
|
||||
public sealed record CreateJobApplicationRequest(
|
||||
@@ -132,7 +133,8 @@ namespace JobTrackerApi.Controllers
|
||||
string? JobUrl,
|
||||
DateTime? DateApplied,
|
||||
DateTime? FeedbackRequestedAt,
|
||||
DateTime? StatusChangedAt
|
||||
DateTime? StatusChangedAt,
|
||||
int? ExpectedVersion = null
|
||||
);
|
||||
|
||||
public sealed record UpdateStatusRequest(string Status);
|
||||
@@ -189,7 +191,7 @@ namespace JobTrackerApi.Controllers
|
||||
CandidateFitChannelGuidanceDto Guidance,
|
||||
string? CoverLetterDraft,
|
||||
string? RecruiterMessageDraft);
|
||||
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
|
||||
public sealed record SaveTailoredCvRequest(string? TailoredCvText, int? ExpectedVersion = null);
|
||||
public sealed record TailoredCvDraftDto(
|
||||
int? Id,
|
||||
int? CanonicalProfileVersion,
|
||||
@@ -206,7 +208,8 @@ namespace JobTrackerApi.Controllers
|
||||
DateTimeOffset? LastEditedAtUtc,
|
||||
string Status,
|
||||
string RenderedText,
|
||||
bool IsLegacyFallback);
|
||||
bool IsLegacyFallback,
|
||||
int Revision = 1);
|
||||
public sealed record SaveTailoredCvDraftRequest(
|
||||
string? TemplateId,
|
||||
string? Headline,
|
||||
@@ -216,13 +219,15 @@ namespace JobTrackerApi.Controllers
|
||||
List<TailoredCvEducationItem>? Education,
|
||||
List<TailoredCvCustomSection>? CustomSections,
|
||||
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 SaveApplicationDraftsRequest(
|
||||
string? CoverLetterText,
|
||||
string? Notes,
|
||||
string? RecruiterMessageDraft,
|
||||
string? ApplicationAnswerDraft = null);
|
||||
string? ApplicationAnswerDraft = null,
|
||||
int? ExpectedVersion = null);
|
||||
public sealed record SavedPackageMaterial(string? TailoredCvText, string? CoverLetterText, string? RecruiterMessageDraft, string? Notes);
|
||||
public sealed record InterviewPrepDto(string Summary, List<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);
|
||||
|
||||
@@ -459,7 +459,8 @@ Canonical profile:
|
||||
TailoredCvText: job.TailoredCvText,
|
||||
WorkflowSignal: workflowSignal,
|
||||
ShortSummary: job.ShortSummary,
|
||||
FullSummary: fullSummary);
|
||||
FullSummary: fullSummary,
|
||||
Version: job.Version);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -840,6 +841,9 @@ Canonical profile:
|
||||
var job = await _db.JobApplications.Include(j => j.Job).FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
if (request.ExpectedVersion is null or < 1 || request.ExpectedVersion != job.Version)
|
||||
return Conflict(new { code = "revision_conflict", message = "This job changed or its revision is missing. Your draft was not saved. Reload and review before saving again." });
|
||||
|
||||
var oldStatus = job.Status;
|
||||
var oldResponseReceived = job.ResponseReceived;
|
||||
var oldResponseDate = job.ResponseDate;
|
||||
@@ -850,6 +854,8 @@ Canonical profile:
|
||||
var title = (request.JobTitle ?? "").Trim();
|
||||
if (title.Length == 0) return BadRequest("Job title is required.");
|
||||
if (request.CompanyId <= 0) return BadRequest("Valid companyId is required.");
|
||||
if (!await _db.Companies.AnyAsync(x => x.Id == request.CompanyId, cancellationToken))
|
||||
return BadRequest("Company was not found.");
|
||||
|
||||
job.JobTitle = title;
|
||||
job.CompanyId = request.CompanyId;
|
||||
@@ -926,7 +932,19 @@ Canonical profile:
|
||||
JobLifecycleEvents.RecordStatusChange(_db, job, oldStatus, request.StatusChangedAt ?? DateTime.Now);
|
||||
|
||||
if (job.Job is not null) JobOpportunitySync.Apply(job, job.Job);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
_db.Entry(job).Property(x => x.Version).IsModified = true;
|
||||
try { await _db.SaveChangesAsync(cancellationToken); }
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_db.ChangeTracker.Clear();
|
||||
return Conflict(new { code = "revision_conflict", message = "This job changed while saving. Your draft was not saved. Reload and review before saving again." });
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
_db.ChangeTracker.Clear();
|
||||
throw;
|
||||
}
|
||||
Response.Headers["X-Job-Version"] = job.Version.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -1397,7 +1415,7 @@ Candidate CV/profile:
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.JobApplicationId == id, cancellationToken);
|
||||
|
||||
return Ok(draft is null ? ToLegacyTailoredCvDraftDto(job) : ToTailoredCvDraftDto(draft));
|
||||
return Ok((draft is null ? ToLegacyTailoredCvDraftDto(job) : ToTailoredCvDraftDto(draft)) with { Revision = job.Version });
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/tailored-cv-preview")]
|
||||
@@ -1486,7 +1504,7 @@ Candidate CV/profile:
|
||||
}
|
||||
|
||||
var draft = await UpsertGeneratedTailoredCvDraftAsync(job, user, mode, cancellationToken);
|
||||
return Ok(ToTailoredCvDraftDto(draft));
|
||||
return Ok(ToTailoredCvDraftDto(draft) with { Revision = job.Version });
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}/tailored-cv-draft")]
|
||||
@@ -1494,6 +1512,8 @@ Candidate CV/profile:
|
||||
{
|
||||
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
if (request.ExpectedVersion is null or < 1 || request.ExpectedVersion != job.Version)
|
||||
return Conflict(new { code = "revision_conflict", message = "The application changed. Your CV draft was not saved. Reload and review before saving again." });
|
||||
|
||||
var user = await GetCurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
@@ -1531,7 +1551,9 @@ Candidate CV/profile:
|
||||
|
||||
job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document);
|
||||
job.TailoredCvUpdatedAt = DateTime.UtcNow;
|
||||
_db.Entry(job).Property(x => x.Version).IsModified = true;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
Response.Headers["X-Job-Version"] = job.Version.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -1540,10 +1562,14 @@ Candidate CV/profile:
|
||||
{
|
||||
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
if (request.ExpectedVersion is null or < 1 || request.ExpectedVersion != job.Version)
|
||||
return Conflict(new { code = "revision_conflict", message = "The application changed. Your draft was not saved. Reload and review before saving again." });
|
||||
|
||||
job.TailoredCvText = string.IsNullOrWhiteSpace(request.TailoredCvText) ? null : request.TailoredCvText.Trim();
|
||||
job.TailoredCvUpdatedAt = job.TailoredCvText is null ? null : DateTime.UtcNow;
|
||||
_db.Entry(job).Property(x => x.Version).IsModified = true;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
Response.Headers["X-Job-Version"] = job.Version.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -1552,6 +1578,8 @@ Candidate CV/profile:
|
||||
{
|
||||
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
if (request.ExpectedVersion is null or < 1 || request.ExpectedVersion != job.Version)
|
||||
return Conflict(new { code = "revision_conflict", message = "The application changed. Your drafts were not saved. Reload and review before saving again." });
|
||||
|
||||
if (request.CoverLetterText is not null)
|
||||
{
|
||||
@@ -1575,7 +1603,9 @@ Candidate CV/profile:
|
||||
: request.RecruiterMessageDraft.Trim();
|
||||
}
|
||||
|
||||
_db.Entry(job).Property(x => x.Version).IsModified = true;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
Response.Headers["X-Job-Version"] = job.Version.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
||||
@@ -248,53 +248,60 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
return new ExtractionPipelineResult(text, normalizedText, structuredCv);
|
||||
}
|
||||
|
||||
private async Task ApplyTextExtractionRunAsync(ApplicationUser user, string trigger, string rawText, string normalizedText, StructuredCvProfile structuredCv, int? artifactId, CancellationToken cancellationToken)
|
||||
private async Task ApplyTextExtractionRunAsync(ApplicationUser user, string trigger, string rawText, string normalizedText, StructuredCvProfile structuredCv, int? artifactId, int expectedVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
var run = new CvExtractionRun
|
||||
await _careerProfileService.ExecuteAtomicAsync(async () =>
|
||||
{
|
||||
OwnerUserId = user.Id,
|
||||
ArtifactId = artifactId,
|
||||
Trigger = trigger,
|
||||
ParserVersion = ParserVersion,
|
||||
NormalizerVersion = NormalizerVersion,
|
||||
LlmPromptVersion = LlmPromptVersion,
|
||||
Status = "applied",
|
||||
RawExtractedText = rawText,
|
||||
NormalizedText = normalizedText,
|
||||
StartedAtUtc = DateTimeOffset.UtcNow,
|
||||
CompletedAtUtc = DateTimeOffset.UtcNow,
|
||||
AppliedAtUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
_db.CvExtractionRuns.Add(run);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
|
||||
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
|
||||
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, trigger, cancellationToken);
|
||||
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
|
||||
run.StructuredProfileJson = structuredJson;
|
||||
|
||||
user.ProfileCvText = normalizedText;
|
||||
user.ProfileCvStructureJson = structuredJson;
|
||||
user.CurrentCvExtractionRunId = run.Id;
|
||||
user.CurrentCvProfileVersion = structuredCv.Metadata.ProfileVersion;
|
||||
if (artifactId.HasValue)
|
||||
{
|
||||
user.CurrentCvUploadArtifactId = artifactId.Value;
|
||||
}
|
||||
|
||||
var update = await _users.UpdateAsync(user);
|
||||
if (!update.Succeeded)
|
||||
{
|
||||
run.Status = "failed";
|
||||
run.ErrorMessage = string.Join("; ", update.Errors.Select(e => e.Description));
|
||||
var run = new CvExtractionRun
|
||||
{
|
||||
OwnerUserId = user.Id,
|
||||
ArtifactId = artifactId,
|
||||
Trigger = trigger,
|
||||
ParserVersion = ParserVersion,
|
||||
NormalizerVersion = NormalizerVersion,
|
||||
LlmPromptVersion = LlmPromptVersion,
|
||||
Status = "applied",
|
||||
RawExtractedText = rawText,
|
||||
NormalizedText = normalizedText,
|
||||
StartedAtUtc = DateTimeOffset.UtcNow,
|
||||
CompletedAtUtc = DateTimeOffset.UtcNow,
|
||||
AppliedAtUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
_db.CvExtractionRuns.Add(run);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
throw new InvalidOperationException(run.ErrorMessage);
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await PruneExtractionRunsAsync(user.Id, cancellationToken);
|
||||
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
|
||||
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
|
||||
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, trigger, expectedVersion, cancellationToken);
|
||||
var structuredJson = StructuredCvProfileJson.SerializePersisted(structuredCv);
|
||||
run.StructuredProfileJson = structuredJson;
|
||||
|
||||
user.ProfileCvText = normalizedText;
|
||||
user.ProfileCvStructureJson = structuredJson;
|
||||
user.CurrentCvExtractionRunId = run.Id;
|
||||
user.CurrentCvProfileVersion = structuredCv.Metadata.ProfileVersion;
|
||||
if (artifactId.HasValue)
|
||||
{
|
||||
user.CurrentCvUploadArtifactId = artifactId.Value;
|
||||
}
|
||||
|
||||
var update = await _users.UpdateAsync(user);
|
||||
if (!update.Succeeded)
|
||||
{
|
||||
throw new CareerProjectionException(string.Join("; ", update.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
return true;
|
||||
}, cancellationToken);
|
||||
|
||||
// Retention is maintenance; a failure cannot turn a committed profile into a rejected save.
|
||||
try { await PruneExtractionRunsAsync(user.Id, cancellationToken); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_db.ChangeTracker.Clear();
|
||||
_logger.LogWarning(ex, "Could not prune CV extraction history after the profile commit for user {UserId}", user.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<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
|
||||
// fields the branch added to CvTemplateDescriptor belong to the deferred ATS-badge work
|
||||
// (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 ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification);
|
||||
|
||||
@@ -258,9 +258,10 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
if (run is null) return NotFound();
|
||||
if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile.");
|
||||
|
||||
var current = await _careerProfileService.LoadStructuredAsync(user.Id, HttpContext.RequestAborted);
|
||||
var snapshot = await _careerProfileService.LoadSnapshotAsync(user.Id, HttpContext.RequestAborted);
|
||||
var current = snapshot.Profile;
|
||||
var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson);
|
||||
return Ok(new { runId = run.Id, run.Status, diff = _cvProfileDiffService.Diff(current, extracted) });
|
||||
return Ok(new { runId = run.Id, run.Status, revision = snapshot.Revision, diff = _cvProfileDiffService.Diff(current, extracted) });
|
||||
}
|
||||
|
||||
[HttpPost("runs/{id:int}/accept")]
|
||||
@@ -268,34 +269,45 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
if (request is null || request.ExpectedVersion is null or < 0) return BadRequest("expectedVersion is required.");
|
||||
|
||||
var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted);
|
||||
if (run is null) return NotFound();
|
||||
if (run.Status != "pending_review") return Conflict("This extraction run is not awaiting review.");
|
||||
if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile.");
|
||||
try
|
||||
{
|
||||
return await _careerProfileService.ExecuteAtomicAsync<IActionResult>(async () =>
|
||||
{
|
||||
var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted);
|
||||
if (run is null) return NotFound();
|
||||
if (run.Status != "pending_review") return Conflict("This extraction run is not awaiting review.");
|
||||
if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile.");
|
||||
|
||||
var current = await _careerProfileService.LoadStructuredAsync(user.Id, HttpContext.RequestAborted);
|
||||
var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson);
|
||||
var diff = _cvProfileDiffService.Diff(current, extracted);
|
||||
var acceptedLowConfidenceIds = (request?.AcceptedLowConfidenceIds ?? new List<string>()).ToHashSet(StringComparer.Ordinal);
|
||||
var merged = _cvProfileDiffService.Merge(current, extracted, acceptedLowConfidenceIds);
|
||||
merged.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
|
||||
merged.Metadata.AppliedExtractionRunId = run.Id;
|
||||
merged.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _careerProfileService.SaveVersionAsync(user.Id, merged, $"{run.Trigger}:accepted", HttpContext.RequestAborted);
|
||||
var snapshot = await _careerProfileService.LoadSnapshotAsync(user.Id, HttpContext.RequestAborted);
|
||||
if (snapshot.Revision != request.ExpectedVersion.Value)
|
||||
throw new DbUpdateConcurrencyException("The career profile changed after import review.");
|
||||
var current = snapshot.Profile;
|
||||
var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson);
|
||||
var diff = _cvProfileDiffService.Diff(current, extracted);
|
||||
var acceptedLowConfidenceIds = (request.AcceptedLowConfidenceIds ?? new List<string>()).ToHashSet(StringComparer.Ordinal);
|
||||
var merged = _cvProfileDiffService.Merge(current, extracted, acceptedLowConfidenceIds);
|
||||
merged.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
|
||||
merged.Metadata.AppliedExtractionRunId = run.Id;
|
||||
merged.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _careerProfileService.SaveVersionAsync(user.Id, merged, $"{run.Trigger}:accepted", request.ExpectedVersion.Value, HttpContext.RequestAborted);
|
||||
|
||||
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(merged);
|
||||
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) user.ProfileCvText = run.NormalizedText;
|
||||
user.CurrentCvExtractionRunId = run.Id;
|
||||
user.CurrentCvProfileVersion = merged.Metadata.ProfileVersion;
|
||||
if (run.ArtifactId.HasValue) user.CurrentCvUploadArtifactId = run.ArtifactId.Value;
|
||||
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(merged);
|
||||
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) user.ProfileCvText = run.NormalizedText;
|
||||
user.CurrentCvExtractionRunId = run.Id;
|
||||
user.CurrentCvProfileVersion = merged.Metadata.ProfileVersion;
|
||||
if (run.ArtifactId.HasValue) user.CurrentCvUploadArtifactId = run.ArtifactId.Value;
|
||||
|
||||
run.Status = "applied";
|
||||
run.AppliedAtUtc = DateTimeOffset.UtcNow;
|
||||
var update = await _users.UpdateAsync(user);
|
||||
if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(e => e.Description)));
|
||||
await _db.SaveChangesAsync(HttpContext.RequestAborted);
|
||||
return Ok(new { runId = run.Id, run.Status, diff, structuredCv = merged });
|
||||
run.Status = "applied";
|
||||
run.AppliedAtUtc = DateTimeOffset.UtcNow;
|
||||
var update = await _users.UpdateAsync(user);
|
||||
if (!update.Succeeded) throw new CareerProjectionException(string.Join("; ", update.Errors.Select(e => e.Description)));
|
||||
return Ok(new { runId = run.Id, run.Status, diff, structuredCv = merged, revision = request.ExpectedVersion.Value + 1 });
|
||||
}, HttpContext.RequestAborted);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Review the import again before accepting." }); }
|
||||
catch (CareerProjectionException ex) { return BadRequest(ex.Message); }
|
||||
}
|
||||
|
||||
[HttpPost("runs/{id:int}/discard")]
|
||||
@@ -522,19 +534,20 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
if (request is null || request.ExpectedVersion is null or < 0) return BadRequest("expectedVersion is required.");
|
||||
|
||||
var source = string.IsNullOrWhiteSpace(request?.Text) ? user.ProfileCvText : request!.Text;
|
||||
var source = string.IsNullOrWhiteSpace(request.Text) ? user.ProfileCvText : request.Text;
|
||||
if (string.IsNullOrWhiteSpace(source)) return BadRequest("Add or import CV text before parsing sections.");
|
||||
|
||||
var normalizedSource = await MaybeReconstructStructuredCvAsync(source, HttpContext.RequestAborted);
|
||||
var structuredCv = await BuildStructuredCvAsync(normalizedSource, HttpContext.RequestAborted);
|
||||
if (string.IsNullOrWhiteSpace(request?.Text))
|
||||
try
|
||||
{
|
||||
user.ProfileCvText = normalizedSource;
|
||||
await ApplyTextExtractionRunAsync(user, "parse", source, normalizedSource, structuredCv, user.CurrentCvUploadArtifactId, request.ExpectedVersion.Value, HttpContext.RequestAborted);
|
||||
return Ok(new { structuredCv, sections = structuredCv.Sections, totalWords = CountWords(normalizedSource), extractionRunId = user.CurrentCvExtractionRunId, profileVersion = user.CurrentCvProfileVersion, revision = request.ExpectedVersion.Value + 1 });
|
||||
}
|
||||
await ApplyTextExtractionRunAsync(user, "parse", source, normalizedSource, structuredCv, user.CurrentCvUploadArtifactId, HttpContext.RequestAborted);
|
||||
|
||||
return Ok(new { structuredCv, sections = structuredCv.Sections, totalWords = CountWords(normalizedSource), extractionRunId = user.CurrentCvExtractionRunId, profileVersion = user.CurrentCvProfileVersion });
|
||||
catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Reload before parsing." }); }
|
||||
catch (CareerProjectionException ex) { return BadRequest(ex.Message); }
|
||||
}
|
||||
|
||||
[HttpPost("improve")]
|
||||
|
||||
@@ -2,7 +2,7 @@ using JobTrackerApi.Models;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
public sealed record ParseCvRequest(string? Text);
|
||||
public sealed record ParseCvRequest(string? Text, int? ExpectedVersion = null);
|
||||
public sealed record CvTemplateDescriptor(string Id, string Title, string Tone, string AccentColor, string PreviewTagline, string PreviewSummary, List<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 CvRewriteFailureDto(string Code, string Message, string? Detail = null, string? LastAiError = null);
|
||||
|
||||
@@ -72,10 +72,35 @@ namespace JobTrackerApi.Data
|
||||
public DbSet<AccountDeletionRequest> AccountDeletionRequests => Set<AccountDeletionRequest>();
|
||||
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)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<JobApplication>().Property(x => x.Version).IsConcurrencyToken().HasDefaultValue(1);
|
||||
modelBuilder.Entity<CareerProfile>().Property(x => x.Version).IsConcurrencyToken();
|
||||
|
||||
modelBuilder.Entity<ApplicationUser>()
|
||||
.Property(x => x.PendingEmail)
|
||||
.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");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
@@ -1938,6 +1939,12 @@ namespace JobTrackerApi.Migrations
|
||||
b.Property<string>("TranslatedDescription")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CompanyId");
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace JobTrackerApi.Models
|
||||
public class JobApplication
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int Version { get; set; } = 1;
|
||||
public string? OwnerUserId { get; set; }
|
||||
public string JobTitle { get; set; } = "";
|
||||
public int CompanyId { get; set; }
|
||||
|
||||
@@ -160,6 +160,7 @@ builder.Services.AddProblemDetails(options =>
|
||||
context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
|
||||
});
|
||||
builder.Services.AddExceptionHandler<AiUsageLimitExceptionHandler>();
|
||||
builder.Services.AddExceptionHandler<RevisionConflictExceptionHandler>();
|
||||
builder.Services.AddExceptionHandler<PdfRenderExceptionHandler>();
|
||||
builder.Services.AddOpenApi();
|
||||
var dataRoot = (builder.Configuration["Data:Root"] ?? "").Trim();
|
||||
|
||||
@@ -41,7 +41,8 @@ public sealed record CoverLetterDto(
|
||||
string? Text,
|
||||
int CurrentVersion,
|
||||
IReadOnlyList<CoverLetterVersionDto> Versions,
|
||||
int AiSuggestionCount);
|
||||
int AiSuggestionCount,
|
||||
int Revision = 1);
|
||||
|
||||
public interface IApplicationAssetsService
|
||||
{
|
||||
@@ -50,8 +51,8 @@ public interface IApplicationAssetsService
|
||||
Task<TailoringPlanDto?> GetTailoringPlanAsync(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?> RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, 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, int? expectedVersion, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class ApplicationAssetsService : IApplicationAssetsService
|
||||
@@ -237,7 +238,7 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
|
||||
// Every save snapshots the PREVIOUS text first, so an AI rewrite can always be undone. The user's
|
||||
// text is what gets stored — an AI suggestion only becomes a version once the user saves it, which
|
||||
// is what "requires approval" means here.
|
||||
public async Task<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
|
||||
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
|
||||
@@ -246,9 +247,15 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
|
||||
var next = text?.Trim() ?? string.Empty;
|
||||
var current = job.CoverLetterText?.Trim() ?? string.Empty;
|
||||
|
||||
if (expectedVersion is null or < 1 || expectedVersion != job.Version)
|
||||
throw new DbUpdateConcurrencyException("The application changed after this draft was loaded.");
|
||||
|
||||
// Nothing changed: do not spend a version on a no-op save (autosave calls this often).
|
||||
if (string.Equals(next, current, StringComparison.Ordinal))
|
||||
{
|
||||
// A no-op still consumes the source revision so an overlapping write cannot be acknowledged as this draft.
|
||||
_db.Entry(job).Property(x => x.Version).IsModified = true;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return await BuildCoverLetterAsync(ownerUserId, job, ct);
|
||||
}
|
||||
|
||||
@@ -274,7 +281,7 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
|
||||
|
||||
// Restore is non-destructive: the old text comes back as a NEW version, so the thing you restored
|
||||
// from is still in the history.
|
||||
public async Task<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
|
||||
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
|
||||
@@ -284,7 +291,7 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
|
||||
.FirstOrDefaultAsync(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId && v.Version == version, ct);
|
||||
if (snapshot is null) return null;
|
||||
|
||||
return await SaveCoverLetterAsync(ownerUserId, jobApplicationId, snapshot.Text, CoverLetterSources.Restore, null, ct);
|
||||
return await SaveCoverLetterAsync(ownerUserId, jobApplicationId, snapshot.Text, CoverLetterSources.Restore, null, expectedVersion, ct);
|
||||
}
|
||||
|
||||
private async Task<CoverLetterDto> BuildCoverLetterAsync(string ownerUserId, JobApplication job, CancellationToken ct)
|
||||
@@ -304,7 +311,8 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
|
||||
current,
|
||||
versions.Select(v => new CoverLetterVersionDto(
|
||||
v.Version, v.Source, v.AiAction, v.Text.Length, v.CreatedAtUtc, v.Version == current)).ToList(),
|
||||
aiCount);
|
||||
aiCount,
|
||||
job.Version);
|
||||
}
|
||||
|
||||
private async Task<int> NextVersionAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
|
||||
|
||||
@@ -47,7 +47,8 @@ public sealed record WorkspaceOverviewDto(
|
||||
DateTimeOffset? LastAiAtUtc,
|
||||
IReadOnlyList<WorkspaceActivityDto> RecentActivity,
|
||||
WorkspaceNextStepDto? NextStep,
|
||||
ChecklistProgressDto? ChecklistProgress);
|
||||
ChecklistProgressDto? ChecklistProgress,
|
||||
int Revision = 1);
|
||||
|
||||
public interface IApplicationWorkspaceService
|
||||
{
|
||||
@@ -147,7 +148,8 @@ public sealed class ApplicationWorkspaceService : IApplicationWorkspaceService
|
||||
lastAi,
|
||||
activity,
|
||||
NextStep(checklist),
|
||||
checklist?.Progress);
|
||||
checklist?.Progress,
|
||||
job.Version);
|
||||
}
|
||||
|
||||
// "The user should never ask what to do next." Milestone 2 moved this onto the checklist: the first
|
||||
|
||||
@@ -15,12 +15,17 @@ public interface ICareerProfileService
|
||||
// Mutates the given profile in place (assigns missing item IDs + normalized dates), persists
|
||||
// it as the current CareerProfile snapshot plus an append-only CareerProfileVersion row, syncs
|
||||
// the relational children (Experience/Education/Skill/Project/Certification/Language), and
|
||||
// returns the same profile so the caller can go on to serialize it into the legacy column.
|
||||
Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken);
|
||||
// returns the same profile. Controller writes include the legacy projection in ExecuteAtomicAsync.
|
||||
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
|
||||
// rows are empty but a ProfileJson blob exists (a profile that predates Phase 3), it is
|
||||
// backfilled from the blob first. Returns an empty profile if the user has none.
|
||||
// rows are empty but a ProfileJson blob exists (a profile that predates Phase 3), reads fall
|
||||
// back to that blob without writing. Returns an empty profile if the user has none.
|
||||
Task<StructuredCvProfile> LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken);
|
||||
|
||||
// The append-only version history, newest first.
|
||||
@@ -29,7 +34,7 @@ public interface ICareerProfileService
|
||||
// Restores a past version by re-saving its snapshot as a NEW version (non-destructive: history
|
||||
// and the current state are both preserved; the restore is itself reversible). Returns the
|
||||
// restored profile, or null if the version does not exist.
|
||||
Task<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
|
||||
// CV rendering (/cv/{slug}) where there is no authenticated current user. Never writes, never
|
||||
@@ -38,6 +43,8 @@ public interface ICareerProfileService
|
||||
}
|
||||
|
||||
public sealed record CareerProfileVersionInfo(int Version, string Source, DateTimeOffset CreatedAtUtc, bool IsCurrent);
|
||||
public sealed record CareerProfileSnapshot(StructuredCvProfile Profile, int Revision, string? CvText = null);
|
||||
public sealed class CareerProjectionException(string message) : Exception(message);
|
||||
|
||||
public sealed class CareerProfileService : ICareerProfileService
|
||||
{
|
||||
@@ -48,63 +55,119 @@ public sealed class CareerProfileService : ICareerProfileService
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<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 _db.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
var json = StructuredCvProfileJson.SerializePersisted(profile);
|
||||
|
||||
var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (existing is null)
|
||||
try
|
||||
{
|
||||
existing = new CareerProfile
|
||||
var result = await action();
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
try
|
||||
{
|
||||
if (transaction is not null) await transaction.RollbackAsync(CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// A later SaveChanges must never publish an operation whose response failed.
|
||||
_db.ChangeTracker.Clear();
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<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,
|
||||
CareerProfileId = existing.Id,
|
||||
Version = existing.Version,
|
||||
ProfileJson = json,
|
||||
Version = 1,
|
||||
Source = string.IsNullOrWhiteSpace(source) ? "manual" : source.Trim(),
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
UpdatedAtUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
_db.CareerProfiles.Add(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.ProfileJson = json;
|
||||
existing.Version += 1;
|
||||
existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
});
|
||||
return profile;
|
||||
}, cancellationToken);
|
||||
|
||||
existing.LongTailJson = CareerProfileMapper.SerializeLongTail(profile);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await SyncRelationalChildrenAsync(existing.Id, ownerUserId, profile, cancellationToken);
|
||||
|
||||
_db.CareerProfileVersions.Add(new CareerProfileVersion
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
CareerProfileId = existing.Id,
|
||||
Version = existing.Version,
|
||||
ProfileJson = json,
|
||||
Source = string.IsNullOrWhiteSpace(source) ? "manual" : source.Trim(),
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
return profile;
|
||||
}
|
||||
private static bool IsDuplicateOwner(DbUpdateException exception) => exception.InnerException switch
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteException sqlite => sqlite.SqliteErrorCode == 19
|
||||
&& sqlite.Message.Contains("UNIQUE constraint failed: CareerProfiles.OwnerUserId", StringComparison.Ordinal),
|
||||
MySqlConnector.MySqlException mysql => mysql.Number == 1062
|
||||
&& mysql.Message.Contains("IX_CareerProfiles_OwnerUserId", StringComparison.Ordinal),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
public async Task<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();
|
||||
|
||||
var hasRelational = await _db.CareerExperiences.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
|
||||
@@ -114,21 +177,19 @@ public sealed class CareerProfileService : ICareerProfileService
|
||||
|| await _db.CareerCertifications.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
|
||||
|| await _db.CareerLanguages.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken);
|
||||
|
||||
// Backfill a pre-Phase-3 profile from its blob, once, before reading relationally.
|
||||
// A read cannot materialize unversioned children during a concurrent replacement.
|
||||
if (!hasRelational && !string.IsNullOrWhiteSpace(profile.ProfileJson))
|
||||
{
|
||||
var fromBlob = StructuredCvProfileJson.DeserializePersisted(profile.ProfileJson);
|
||||
AssignStableIds(fromBlob);
|
||||
NormalizeDates(fromBlob);
|
||||
await SyncRelationalChildrenAsync(profile.Id, ownerUserId, fromBlob, cancellationToken);
|
||||
return fromBlob;
|
||||
}
|
||||
|
||||
var experiences = UniqueChildren(await _db.CareerExperiences.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var education = UniqueChildren(await _db.CareerEducations.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var skills = UniqueChildren(await _db.CareerSkills.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var projects = UniqueChildren(await _db.CareerProjects.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var certifications = UniqueChildren(await _db.CareerCertifications.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var languages = UniqueChildren(await _db.CareerLanguages.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var experiences = UniqueChildren(await _db.CareerExperiences.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var education = UniqueChildren(await _db.CareerEducations.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var skills = UniqueChildren(await _db.CareerSkills.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var projects = UniqueChildren(await _db.CareerProjects.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var certifications = UniqueChildren(await _db.CareerCertifications.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var languages = UniqueChildren(await _db.CareerLanguages.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
|
||||
return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages);
|
||||
}
|
||||
@@ -170,7 +231,7 @@ public sealed class CareerProfileService : ICareerProfileService
|
||||
return versions.Select(x => new CareerProfileVersionInfo(x.Version, x.Source, x.CreatedAtUtc, x.Version == profile.Version)).ToList();
|
||||
}
|
||||
|
||||
public async Task<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);
|
||||
if (profile is null) return null;
|
||||
@@ -181,7 +242,7 @@ public sealed class CareerProfileService : ICareerProfileService
|
||||
// Re-save the old snapshot as a new version. Non-destructive: the current state stays in
|
||||
// history, so a restore can itself be undone by restoring the version before it.
|
||||
var restored = StructuredCvProfileJson.DeserializePersisted(target.ProfileJson);
|
||||
return await SaveVersionAsync(ownerUserId, restored, $"restore:v{version}", cancellationToken);
|
||||
return await SaveVersionAsync(ownerUserId, restored, $"restore:v{version}", expectedVersion, cancellationToken);
|
||||
}
|
||||
|
||||
// Replace-all: the profile is small and edited as a whole, so wiping and re-inserting the
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user