feat(cv): redesign document editing with revision-safe letters and writing tools

This commit is contained in:
cesnimda
2026-10-25 22:44:14 +01:00
parent 01e10996c5
commit 2bfaf6c14b
57 changed files with 2859 additions and 118 deletions
@@ -0,0 +1,193 @@
using System.Security.Claims;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Moq;
using Xunit;
namespace JobTrackerApi.MariaDb.Tests;
public sealed partial class CoverLetterWriterTests
{
[Fact]
public async Task Document_schema_upgrade_preserves_legacy_letter_history_and_frozen_rows_on_mariadb()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
var job = await Seed(db, " old exact letter ");
db.CoverLetterVersions.Add(new() { OwnerUserId = Owner, JobApplicationId = job.Id, Version = 7, Text = " old history ", Source = CoverLetterSources.Ai });
db.SubmittedApplicationPackages.Add(new() { OwnerUserId = Owner, JobApplicationId = job.Id, Version = 1, CoverLetterText = " old frozen letter ", RenderedCvHtml = "<p>unchanged CV</p>" });
await db.SaveChangesAsync();
await db.GetService<IMigrator>().MigrateAsync("20261023090000_AddJobRevision");
db.ChangeTracker.Clear();
await db.GetService<IMigrator>().MigrateAsync();
var actual = await db.JobApplications.AsNoTracking().SingleAsync();
Assert.Equal(" old exact letter ", actual.CoverLetterText);
Assert.Equal(1, actual.Version);
Assert.Null(actual.CoverLetterDocumentSettingsJson);
var historical = await db.CoverLetterVersions.AsNoTracking().SingleAsync();
Assert.Equal(7, historical.Version);
Assert.Equal(" old history ", historical.Text);
Assert.Equal(CoverLetterSources.Ai, historical.Source);
Assert.Null(historical.DocumentSettingsJson);
var frozen = await db.SubmittedApplicationPackages.AsNoTracking().SingleAsync();
Assert.Equal(" old frozen letter ", frozen.CoverLetterText);
Assert.Equal("<p>unchanged CV</p>", frozen.RenderedCvHtml);
Assert.Null(frozen.CoverLetterDocumentSettingsJson);
Assert.Null(frozen.RenderedCoverLetterHtml);
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
}
[Fact]
public async Task Document_settings_only_saves_advance_shared_revision_preserve_omission_and_restore_old_defaults()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
var job = await Seed(db, "same body");
var service = Assets(db);
var saved = await service.SaveCoverLetterAsync(Owner, job.Id, "same body", CoverLetterSources.Manual, null, job.Version, default,
new() { SenderName = "Reviewed sender", ThemeId = "minimal", AccentColor = "#123456", SourceCvId = 42 });
Assert.Equal(2, saved!.Revision);
Assert.Equal(2, saved.CurrentVersion);
Assert.Equal("Reviewed sender", saved.DocumentSettings!.SenderName);
Assert.Equal("same body", saved.Text);
var baseline = await db.CoverLetterVersions.SingleAsync(x => x.Version == 1);
Assert.Null(baseline.DocumentSettingsJson);
Assert.Equal(CoverLetterSources.Legacy, baseline.Source);
var settingsJson = job.CoverLetterDocumentSettingsJson;
await Update(db, job, "generic body");
await Controller(db).SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(null, "notes", null, ExpectedVersion: job.Version), default);
await service.SaveCoverLetterAsync(Owner, job.Id, "dedicated body", CoverLetterSources.Manual, null, job.Version, default);
Assert.Equal(settingsJson, job.CoverLetterDocumentSettingsJson);
var noOpRevision = job.Version;
var noOp = await service.SaveCoverLetterAsync(Owner, job.Id, "dedicated body", CoverLetterSources.Manual, null, job.Version, default,
CoverLetterDocumentSettingsJson.Deserialize(settingsJson));
Assert.Equal(noOpRevision + 1, noOp!.Revision);
Assert.Equal(4, noOp.CurrentVersion);
var restored = await service.RestoreCoverLetterAsync(Owner, job.Id, 1, job.Version, default);
Assert.Equal("same body", restored!.Text);
Assert.Equal("modern", restored.DocumentSettings!.ThemeId);
Assert.Equal("", restored.DocumentSettings.SenderName);
Assert.Null(restored.DocumentSettings.SourceCvId);
Assert.Equal(5, restored.CurrentVersion);
Assert.Equal(settingsJson, (await db.CoverLetterVersions.SingleAsync(x => x.Version == 2)).DocumentSettingsJson);
var restoredDesign = await service.RestoreCoverLetterAsync(Owner, job.Id, 2, job.Version, default);
Assert.Equal("same body", restoredDesign!.Text);
Assert.Equal("Reviewed sender", restoredDesign.DocumentSettings!.SenderName);
Assert.Equal(6, restoredDesign.CurrentVersion);
}
[Fact]
public async Task Preview_is_read_only_and_copied_settings_never_follow_later_cv_changes()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
var job = await Seed(db);
var cv = new CvVariant { OwnerUserId = Owner, Name = "Source CV", PublicSlug = Guid.NewGuid().ToString("N"), SettingsJson = CvVariantSettingsJson.Serialize(new() { AccentColor = "#123456" }) };
db.CvVariants.Add(cv);
await db.SaveChangesAsync();
var settings = new CoverLetterDocumentSettings { SenderName = "Copied name", SourceCvId = cv.Id, AccentColor = "#123456" };
var service = Assets(db);
await service.SaveCoverLetterAsync(Owner, job.Id, "approved body", CoverLetterSources.Manual, null, job.Version, default, settings);
var revision = job.Version;
var before = job.CoverLetterDocumentSettingsJson;
var preview = await service.PreviewCoverLetterAsync(Owner, job.Id, "unapproved preview **bold**", new() { Subject = "Preview only" }, default);
Assert.Contains("unapproved preview <strong>bold</strong>", preview!.Html);
cv.SettingsJson = CvVariantSettingsJson.Serialize(new() { AccentColor = "#abcdef" });
await db.SaveChangesAsync();
var render = await service.RenderSavedCoverLetterAsync(Owner, job.Id, revision, default);
Assert.Contains("#123456", render!.Html);
Assert.Contains("Copied name", render.Html);
Assert.Contains("approved body", render.Html);
Assert.DoesNotContain("unapproved preview", render.Html);
Assert.Equal(before, job.CoverLetterDocumentSettingsJson);
Assert.Equal(revision, job.Version);
Assert.Single(await db.CoverLetterVersions.ToListAsync());
Assert.Null(await service.PreviewCoverLetterAsync("foreign", job.Id, "stolen", settings, default));
Assert.Null(await service.RenderSavedCoverLetterAsync("foreign", job.Id, revision, default));
}
[Theory]
[InlineData(null)]
[InlineData(0)]
[InlineData(-1)]
[InlineData(99)]
public async Task Export_requires_current_positive_revision_before_allocating_pdf(int? expectedVersion)
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
var job = await Seed(db, "saved body");
var pdf = new Mock<ICvPdfExporter>(MockBehavior.Strict);
var user = new ApplicationUser { Id = Owner };
var users = TestHostFactory.CreateUserManager(user);
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var controller = new ApplicationAssetsController(users.Object, Assets(db), Mock.Of<ISubmittedApplicationPackageService>(), pdf.Object)
{
ControllerContext = new() { HttpContext = new DefaultHttpContext() },
};
Assert.IsType<ConflictObjectResult>(await controller.ExportCoverLetterPdf(job.Id, default, expectedVersion));
pdf.VerifyNoOtherCalls();
pdf.Setup(x => x.ExportAsync(Owner, It.Is<TailoredCvRenderResult>(r => r.Html.Contains("saved body")), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CvPdfArtifact("cover-letter.pdf", "unused.pdf", [37, 80, 68, 70]));
Assert.IsType<FileContentResult>(await controller.ExportCoverLetterPdf(job.Id, default, job.Version));
pdf.Verify(x => x.ExportAsync(Owner, It.IsAny<TailoredCvRenderResult>(), It.IsAny<CancellationToken>()), Times.Once);
Assert.IsType<NotFoundResult>(await controller.ExportCoverLetterPdf(int.MaxValue, default, job.Version));
pdf.VerifyNoOtherCalls();
}
[Fact]
public async Task Stale_settings_save_and_unsupported_schema_cannot_change_approved_design()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
var job = await Seed(db);
var service = Assets(db);
await service.SaveCoverLetterAsync(Owner, job.Id, "approved", CoverLetterSources.Manual, null, 1, default, new() { Subject = "Approved subject" });
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => service.SaveCoverLetterAsync(Owner, job.Id, "stale", CoverLetterSources.Manual, null, 1, default, new() { Subject = "Stale" }));
Assert.Equal("Approved subject", (await service.GetCoverLetterAsync(Owner, job.Id, default))!.DocumentSettings!.Subject);
var user = new ApplicationUser { Id = Owner };
var users = TestHostFactory.CreateUserManager(user);
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var controller = new ApplicationAssetsController(users.Object, service, Mock.Of<ISubmittedApplicationPackageService>());
Assert.IsType<BadRequestObjectResult>((await controller.SaveCoverLetter(job.Id,
new("bad", null, null, job.Version, new() { SchemaVersion = 2 }), default)).Result);
Assert.IsType<BadRequestObjectResult>((await controller.PreviewCoverLetter(job.Id,
new("bad", new() { SchemaVersion = 2 }), default)).Result);
Assert.Single(await db.CoverLetterVersions.ToListAsync());
}
[Fact]
public async Task Submitted_letter_html_and_design_remain_exact_after_live_edits_and_old_packages_stay_null()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
var job = await Seed(db);
var service = Assets(db);
await service.SaveCoverLetterAsync(Owner, job.Id, "frozen body", CoverLetterSources.Manual, null, job.Version, default,
new() { SenderName = "Frozen sender", Subject = "Frozen subject" });
var old = new SubmittedApplicationPackage { OwnerUserId = Owner, JobApplicationId = job.Id, Version = 1, CoverLetterText = "old text-only" };
db.Add(old);
await db.SaveChangesAsync();
var packages = new SubmittedApplicationPackageService(db, Mock.Of<ICvVariantService>(), Mock.Of<IAttachmentStorage>());
var captured = await packages.CaptureAsync(Owner, job.Id, new("Profile name must not replace sender", null), default);
var stored = await db.SubmittedApplicationPackages.AsNoTracking().SingleAsync(x => x.Id == captured!.Id);
Assert.Contains("frozen body", stored.RenderedCoverLetterHtml);
Assert.Contains("Frozen sender", stored.RenderedCoverLetterHtml);
Assert.DoesNotContain("Profile name must not replace sender", stored.RenderedCoverLetterHtml);
await service.SaveCoverLetterAsync(Owner, job.Id, "new body", CoverLetterSources.Manual, null, job.Version, default,
new() { SenderName = "New sender", Subject = "New subject" });
var detail = await packages.GetAsync(Owner, job.Id, captured!.Id, default);
Assert.Equal(stored.RenderedCoverLetterHtml, detail!.RenderedCoverLetterHtml);
Assert.Equal("Frozen sender", detail.CoverLetterDocumentSettings!.SenderName);
Assert.Equal("frozen body", detail.CoverLetterText);
var oldDetail = await packages.GetAsync(Owner, job.Id, old.Id, default);
Assert.Equal("old text-only", oldDetail!.CoverLetterText);
Assert.Null(oldDetail.RenderedCoverLetterHtml);
Assert.Null(oldDetail.CoverLetterDocumentSettings);
}
}
@@ -0,0 +1,198 @@
using System.Security.Claims;
using System.Text.Json;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moq;
using Xunit;
namespace JobTrackerApi.MariaDb.Tests;
public sealed partial class CoverLetterWriterTests
{
private const string Owner = "letter-owner";
private static JobTrackerContext Context(MariaDbDatabase connection, string owner = Owner) => connection.Context(owner);
private static JobApplicationsController Controller(JobTrackerContext db) => new(db,
Mock.Of<ISummarizerService>(), TestHostFactory.CreateUserManager(new ApplicationUser { Id = Owner }).Object)
{
ControllerContext = new() { HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, Owner)], "test")),
} },
};
private static ApplicationAssetsService Assets(JobTrackerContext db) => new(db, Mock.Of<ICvVariantService>(), Mock.Of<IApplicationIntelligenceService>());
private static T Request<T>(object value) => JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(value), new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
private static async Task<JobApplication> Seed(JobTrackerContext db, string? text = null)
{
var job = new JobApplication { OwnerUserId = Owner, Company = new Company { OwnerUserId = Owner, Name = "Letters" }, JobTitle = "Role", Status = "Prospect", CoverLetterText = text };
db.Add(job);
await db.SaveChangesAsync();
return job;
}
private static Task<IActionResult> Update(JobTrackerContext db, JobApplication job, string? text, int? revision = null) =>
Controller(db).Update(job.Id, Request<UpdateJobApplicationRequest>(new
{
jobTitle = job.JobTitle, companyId = job.CompanyId, status = job.Status, coverLetterText = text,
expectedVersion = revision ?? job.Version,
}), default);
[Fact]
public async Task Create_generic_draft_and_dedicated_writes_share_history_and_restore()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
var seed = await Seed(db);
var created = await Controller(db).Create(Request<CreateJobApplicationRequest>(new { jobTitle = "Created", companyId = seed.CompanyId, coverLetterText = " initial body " }), default);
Assert.IsType<CreatedAtActionResult>(created.Result);
var job = await db.JobApplications.SingleAsync(x => x.JobTitle == "Created");
Assert.Equal(" initial body ", (await db.CoverLetterVersions.SingleAsync(x => x.JobApplicationId == job.Id)).Text);
Assert.IsType<NoContentResult>(await Update(db, job, " generic body "));
Assert.Equal(" generic body ", job.CoverLetterText);
Assert.IsType<NoContentResult>(await Controller(db).SaveApplicationDrafts(job.Id,
new SaveApplicationDraftsRequest(" draft body ", null, null, ExpectedVersion: job.Version), default));
Assert.Equal("draft body", job.CoverLetterText);
var saved = await Assets(db).SaveCoverLetterAsync(Owner, job.Id, " approved AI body ", CoverLetterSources.Ai, "improve", job.Version, default);
Assert.Equal(4, saved!.CurrentVersion);
var restored = await Assets(db).RestoreCoverLetterAsync(Owner, job.Id, 3, job.Version, default);
Assert.Equal("draft body", restored!.Text);
Assert.Equal(5, restored.CurrentVersion);
var history = await db.CoverLetterVersions.Where(x => x.JobApplicationId == job.Id).OrderBy(x => x.Version).ToListAsync();
Assert.Equal(new[] { " initial body ", " generic body ", "draft body", "approved AI body", "draft body" }, history.Select(x => x.Text));
Assert.Equal(CoverLetterSources.Ai, history[3].Source);
Assert.Equal("improve", history[3].AiAction);
Assert.Equal(CoverLetterSources.Restore, history[4].Source);
}
[Fact]
public async Task Draft_null_preserves_generic_null_clears_and_no_ops_acknowledge_without_duplicate_history()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
var job = await Seed(db);
await Update(db, job, "first");
var firstRevision = job.Version;
await Controller(db).SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(null, null, null, ExpectedVersion: job.Version), default);
Assert.Equal("first", job.CoverLetterText);
Assert.Equal(firstRevision + 1, job.Version);
await Update(db, job, "first");
Assert.Single(await db.CoverLetterVersions.ToListAsync());
await Update(db, job, null);
Assert.Null(job.CoverLetterText);
Assert.Equal(new[] { "first", "" }, await db.CoverLetterVersions.OrderBy(x => x.Version).Select(x => x.Text).ToArrayAsync());
var revision = job.Version;
var saved = await Assets(db).SaveCoverLetterAsync(Owner, job.Id, " ", CoverLetterSources.Manual, null, revision, default);
Assert.Equal(revision + 1, saved!.Revision);
Assert.Equal(2, saved.CurrentVersion);
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => Assets(db).SaveCoverLetterAsync(Owner, job.Id, "stale", CoverLetterSources.Manual, null, revision, default));
Assert.Equal(2, await db.CoverLetterVersions.CountAsync());
}
[Theory]
[InlineData("generic")]
[InlineData("draft")]
[InlineData("dedicated")]
public async Task Legacy_text_gets_a_recoverable_baseline_before_first_replacement(string writer)
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
var job = await Seed(db, " legacy original ");
await Write(db, job, writer, "replacement");
Assert.Equal(new[] { " legacy original ", "replacement" }, await db.CoverLetterVersions.OrderBy(x => x.Version).Select(x => x.Text).ToArrayAsync());
var restored = await Assets(db).RestoreCoverLetterAsync(Owner, job.Id, 1, job.Version, default);
Assert.Equal("legacy original", restored!.Text);
Assert.Equal(3, restored.CurrentVersion);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Legacy_change_after_existing_history_is_preserved_before_replacement(bool cleared)
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
var job = await Seed(db, cleared ? null : "legacy replacement");
db.CoverLetterVersions.Add(new CoverLetterVersion
{
OwnerUserId = Owner, JobApplicationId = job.Id, Version = 4, Text = "older saved version",
Source = CoverLetterSources.Ai, AiAction = "generate",
});
await db.SaveChangesAsync();
await Update(db, job, "new approved version");
var versions = await db.CoverLetterVersions.OrderBy(x => x.Version).ToListAsync();
Assert.Equal(new[] { 4, 5, 6 }, versions.Select(x => x.Version));
Assert.Equal(new[] { "older saved version", cleared ? "" : "legacy replacement", "new approved version" }, versions.Select(x => x.Text));
Assert.Equal(CoverLetterSources.Ai, versions[0].Source);
Assert.Equal("generate", versions[0].AiAction);
}
private static async Task Write(JobTrackerContext db, JobApplication job, string writer, string text)
{
if (writer == "generic") Assert.IsType<NoContentResult>(await Update(db, job, text));
else if (writer == "draft") Assert.IsType<NoContentResult>(await Controller(db).SaveApplicationDrafts(job.Id,
new SaveApplicationDraftsRequest(text, "new notes", null, ExpectedVersion: job.Version), default));
else await Assets(db).SaveCoverLetterAsync(Owner, job.Id, text, CoverLetterSources.Manual, null, job.Version, default);
}
[Theory]
[InlineData("generic")]
[InlineData("draft")]
[InlineData("dedicated")]
public async Task History_failure_rolls_back_letter_parent_revision_and_other_fields(string writer)
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
var job = await Seed(db, "original");
await db.Database.ExecuteSqlRawAsync("CREATE TRIGGER reject_letter BEFORE INSERT ON CoverLetterVersions FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'letter history failure';");
await Assert.ThrowsAsync<DbUpdateException>(() => Write(db, job, writer, "replacement"));
Assert.Empty(db.ChangeTracker.Entries());
await db.SaveChangesAsync();
await using var verify = Context(connection);
var actual = await verify.JobApplications.SingleAsync();
Assert.Equal("original", actual.CoverLetterText);
Assert.Null(actual.Notes);
Assert.Equal(1, actual.Version);
Assert.Empty(await verify.CoverLetterVersions.ToListAsync());
}
[Fact]
public async Task Create_history_failure_cannot_leave_a_job_without_its_initial_snapshot()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
var seed = await Seed(db);
await db.Database.ExecuteSqlRawAsync("CREATE TRIGGER reject_initial_letter BEFORE INSERT ON CoverLetterVersions FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'initial history failure';");
await Assert.ThrowsAsync<DbUpdateException>(() => Controller(db).Create(Request<CreateJobApplicationRequest>(new
{
jobTitle = "Failed create", companyId = seed.CompanyId, coverLetterText = "initial letter",
}), default));
Assert.Empty(db.ChangeTracker.Entries());
await db.SaveChangesAsync();
await using var verify = Context(connection);
Assert.Single(await verify.JobApplications.ToListAsync());
Assert.Empty(await verify.CoverLetterVersions.ToListAsync());
Assert.Empty(await verify.JobEvents.ToListAsync());
}
[Fact]
public async Task Other_tenant_cannot_write_or_read_letter_history_through_any_writer()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
var job = await Seed(db, "private");
await using var other = Context(connection, "other");
Assert.IsType<NotFoundResult>(await Update(other, job, "stolen"));
Assert.IsType<NotFoundResult>(await Controller(other).SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest("stolen", null, null, ExpectedVersion: 1), default));
Assert.Null(await Assets(other).SaveCoverLetterAsync("other", job.Id, "stolen", CoverLetterSources.Manual, null, 1, default));
Assert.Null(await Assets(other).GetCoverLetterAsync("other", job.Id, default));
Assert.Empty(await db.CoverLetterVersions.ToListAsync());
Assert.Equal("private", (await db.JobApplications.AsNoTracking().SingleAsync()).CoverLetterText);
}
}
@@ -64,7 +64,7 @@ public sealed class Rd21JobPersistenceTests
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);
var pending = controller.Update(id, Request(companyId, "Delayed title", null, 1) with { Status = "Offer", CoverLetterText = "Uncommitted letter" }, default);
await reached.Task.WaitAsync(TimeSpan.FromSeconds(10));
try
{
@@ -83,6 +83,8 @@ public sealed class Rd21JobPersistenceTests
Assert.Equal("Prospect", saved.Status);
Assert.Equal("Original job title", (await verify.Jobs.SingleAsync()).JobTitle);
Assert.Empty(await verify.JobEvents.ToListAsync());
Assert.Empty(await verify.CoverLetterVersions.ToListAsync());
Assert.Null(saved.CoverLetterText);
Assert.Empty(delayed.ChangeTracker.Entries());
// A deliberate reload/review uses the winner's content and new revision, never a blind retry.
@@ -242,7 +244,7 @@ public sealed class Rd21JobPersistenceTests
await using var verify = Context(connection);
var saved = await verify.JobApplications.SingleAsync();
Assert.Equal("First approved text", coverLetter ? saved.CoverLetterText : saved.Notes);
if (coverLetter) Assert.Single(await verify.CoverLetterVersions.ToListAsync());
if (coverLetter) Assert.Equal(2, await verify.CoverLetterVersions.CountAsync()); // Legacy baseline plus approved replacement.
}
[Theory]
@@ -79,10 +79,10 @@ public sealed class StartupAndLockTests(ITestOutputHelper output)
db.Add(job);
await db.SaveChangesAsync();
var migrations = (await db.Database.GetAppliedMigrationsAsync()).ToArray();
Assert.Equal("20261023090000_AddJobRevision", migrations[^1]);
Assert.Equal("20261025090000_AddCoverLetterDocumentDesign", migrations[^1]);
// Only this test's newly-created schema is downgraded; no existing database is selected.
await db.GetService<IMigrator>().MigrateAsync(migrations[^2]);
Assert.Equal(new[] { migrations[^1] }, await db.Database.GetPendingMigrationsAsync());
await db.GetService<IMigrator>().MigrateAsync(migrations[^3]);
Assert.Equal(migrations[^2..], await db.Database.GetPendingMigrationsAsync());
db.ChangeTracker.Clear();
await database.App.InitializeJobTrackerAsync();
await using var verify = database.Context();