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

This commit is contained in:
cesnimda
2026-10-23 08:07:23 +02:00
parent 8f2cad67f7
commit 12f342c09c
32 changed files with 1185 additions and 277 deletions
+14 -14
View File
@@ -201,7 +201,7 @@ public sealed class ApplicationAssetsTests
await using var _ = db;
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));
}
}
+4 -4
View File
@@ -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>()))
+2 -2
View File
@@ -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()
{
+12 -12
View File
@@ -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));