fix(cv): preserve attachment flags across generic letter writers

This commit is contained in:
cesnimda
2026-10-26 08:47:47 +01:00
parent 7bf06ede52
commit fc93dfb950
5 changed files with 109 additions and 2 deletions
@@ -53,17 +53,22 @@ public sealed partial class CoverLetterWriterTests
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.False(job.HasCoverLetter);
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.False(job.HasCoverLetter);
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);
Assert.False(job.HasCoverLetter);
var saved = await Assets(db).SaveCoverLetterAsync(Owner, job.Id, " approved AI body ", CoverLetterSources.Ai, "improve", job.Version, default);
Assert.Equal(4, saved!.CurrentVersion);
Assert.True(job.HasCoverLetter);
var restored = await Assets(db).RestoreCoverLetterAsync(Owner, job.Id, 3, job.Version, default);
Assert.Equal("draft body", restored!.Text);
Assert.Equal(5, restored.CurrentVersion);
Assert.True(job.HasCoverLetter);
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);
@@ -71,6 +76,47 @@ public sealed partial class CoverLetterWriterTests
Assert.Equal(CoverLetterSources.Restore, history[4].Source);
}
[Theory]
[InlineData("generic", false)]
[InlineData("generic", true)]
[InlineData("draft", false)]
[InlineData("draft", true)]
public async Task Generic_and_draft_letter_changes_preserve_existing_attachment_flag(string writer, bool hasCoverLetter)
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
var job = await Seed(db);
job.HasCoverLetter = hasCoverLetter;
await db.SaveChangesAsync();
await Write(db, job, writer, "Intended body");
Assert.Equal(hasCoverLetter, job.HasCoverLetter);
if (writer == "generic") Assert.IsType<NoContentResult>(await Update(db, job, null));
else await Write(db, job, writer, "");
Assert.Null(job.CoverLetterText);
Assert.Equal(hasCoverLetter, job.HasCoverLetter);
Assert.Equal(new[] { "Intended body", "" }, await db.CoverLetterVersions.OrderBy(value => value.Version).Select(value => value.Text).ToArrayAsync());
}
[Fact]
public async Task Dedicated_letter_no_op_keeps_flag_but_changed_nonempty_body_sets_it_and_clear_preserves_it()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
var job = await Seed(db, "Existing body");
var service = Assets(db);
var revision = job.Version;
var unchanged = await service.SaveCoverLetterAsync(Owner, job.Id, " Existing body ", CoverLetterSources.Manual, null, revision, default);
Assert.False(job.HasCoverLetter);
Assert.Equal(revision + 1, unchanged!.Revision);
Assert.Empty(await db.CoverLetterVersions.ToListAsync());
await service.SaveCoverLetterAsync(Owner, job.Id, "Changed body", CoverLetterSources.Manual, null, job.Version, default);
Assert.True(job.HasCoverLetter);
var cleared = await service.SaveCoverLetterAsync(Owner, job.Id, null, CoverLetterSources.Manual, null, job.Version, default);
Assert.Null(job.CoverLetterText);
Assert.True(job.HasCoverLetter);
Assert.Equal(3, cleared!.CurrentVersion);
}
[Fact]
public async Task Draft_null_preserves_generic_null_clears_and_no_ops_acknowledge_without_duplicate_history()
{
@@ -62,17 +62,22 @@ public sealed partial class CoverLetterWriterTests
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.False(job.HasCoverLetter);
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.False(job.HasCoverLetter);
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);
Assert.False(job.HasCoverLetter);
var saved = await Assets(db).SaveCoverLetterAsync(Owner, job.Id, " approved AI body ", CoverLetterSources.Ai, "improve", job.Version, default);
Assert.Equal(4, saved!.CurrentVersion);
Assert.True(job.HasCoverLetter);
var restored = await Assets(db).RestoreCoverLetterAsync(Owner, job.Id, 3, job.Version, default);
Assert.Equal("draft body", restored!.Text);
Assert.Equal(5, restored.CurrentVersion);
Assert.True(job.HasCoverLetter);
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);
@@ -80,6 +85,49 @@ public sealed partial class CoverLetterWriterTests
Assert.Equal(CoverLetterSources.Restore, history[4].Source);
}
[Theory]
[InlineData("generic", false)]
[InlineData("generic", true)]
[InlineData("draft", false)]
[InlineData("draft", true)]
public async Task Generic_and_draft_letter_changes_preserve_existing_attachment_flag(string writer, bool hasCoverLetter)
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await using var db = Context(connection);
var job = await Seed(db);
job.HasCoverLetter = hasCoverLetter;
await db.SaveChangesAsync();
await Write(db, job, writer, "Intended body");
Assert.Equal(hasCoverLetter, job.HasCoverLetter);
if (writer == "generic") Assert.IsType<NoContentResult>(await Update(db, job, null));
else await Write(db, job, writer, "");
Assert.Null(job.CoverLetterText);
Assert.Equal(hasCoverLetter, job.HasCoverLetter);
Assert.Equal(new[] { "Intended body", "" }, await db.CoverLetterVersions.OrderBy(value => value.Version).Select(value => value.Text).ToArrayAsync());
}
[Fact]
public async Task Dedicated_letter_no_op_keeps_flag_but_changed_nonempty_body_sets_it_and_clear_preserves_it()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await using var db = Context(connection);
var job = await Seed(db, "Existing body");
var service = Assets(db);
var revision = job.Version;
var unchanged = await service.SaveCoverLetterAsync(Owner, job.Id, " Existing body ", CoverLetterSources.Manual, null, revision, default);
Assert.False(job.HasCoverLetter);
Assert.Equal(revision + 1, unchanged!.Revision);
Assert.Empty(await db.CoverLetterVersions.ToListAsync());
await service.SaveCoverLetterAsync(Owner, job.Id, "Changed body", CoverLetterSources.Manual, null, job.Version, default);
Assert.True(job.HasCoverLetter);
var cleared = await service.SaveCoverLetterAsync(Owner, job.Id, null, CoverLetterSources.Manual, null, job.Version, default);
Assert.Null(job.CoverLetterText);
Assert.True(job.HasCoverLetter);
Assert.Equal(3, cleared!.CurrentVersion);
}
[Fact]
public async Task Draft_null_preserves_generic_null_clears_and_no_ops_acknowledge_without_duplicate_history()
{
@@ -266,6 +266,8 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
}
await StageCoverLetterAsync(_db, job, next.Length == 0 ? null : next, source, aiAction, ct, documentSettings);
// Retain dedicated-save draft readiness; generic/import/draft writers leave attachment flags alone.
if (next.Length > 0) job.HasCoverLetter = true;
try { await _db.SaveChangesAsync(ct); }
catch { _db.ChangeTracker.Clear(); throw; }
@@ -358,7 +360,6 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
string.IsNullOrWhiteSpace(aiAction) ? null : aiAction.Trim(), nextSettings);
job.CoverLetterText = text;
if (settings is not null) job.CoverLetterDocumentSettingsJson = nextSettings;
if (!string.IsNullOrWhiteSpace(text)) job.HasCoverLetter = true;
}
private Task<JobApplication?> LoadJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) =>
+10
View File
@@ -174,3 +174,13 @@ The shared renderer now omits blank optional headings, supplies an escaped local
Two new EN/NB regression cases failed before the renderer fix. Fresh full Release backend validation passed 1,229/1,229 with zero skips (`.artifacts/cv-ux-01/letter-empty-backend-successor.trx`); all 11 original application-form browser cases passed (`letter-legacy-forms-01.json`). Guarded actual MariaDB 12.2.2 passed 59/59 with zero skips, matching before/after source hashes, runner exit 0 and positive owned-service cleanup (`provider-final-04`). Independent review found no material issue. These totals overlap earlier focused/full runs and must not be added together.
The frozen aggregate and current-source Linux image/PDF checks must be rerun after this checkpoint. Earlier aggregate failures and pre-fix images do not establish final acceptance. Full frontend `frontend-final-03` remains current at 981 passing assertions across 107 suites because this checkpoint changes no frontend product or unit-test source. Final acceptance remains pending.
## 26 October: generic writer flag preservation
Frozen `final-07` passed the full backend (1,229 cases) and ordinary shard 2 (87/87), then failed shard 3 (86/88). Both source and runtime hashes were stable. Native JSON identifies two failures: the Documents editor-save request counter included its read-only letter preview POST, and the JSON job-import round trip found an unexpected `HasCoverLetter` flag promotion. This aggregate remains failed; its passing subset does not replace a complete successor.
The flag failure was a product regression introduced by moving dedicated letter-save history into the shared writer. The accepted baseline promotes the flag only for a changed nonempty dedicated letter Save, while create, generic update and application-draft writers preserve existing attachment flags. Restore that side effect to the dedicated Save after its no-op branch; retain atomic text/design/history persistence, clear semantics and the original import expectation. No backfill is performed. SQLite and MariaDB writer tests cover both existing flag values, clear, dedicated no-op, promotion and restore.
Regression evidence: three failing flag cases before the fix, then 67/67 focused cases and 1,234/1,234 full Release backend cases passed with zero skips (`letter-flag-red-01`, `letter-flag-green-01`, `letter-flag-backend-full-01`). Actual guarded MariaDB 12.2.2 passed 64/64 with source hashes unchanged, no audit override, process exit 0 and positive owned-service cleanup (`provider-final-05`). Independent diff review found no material issue.
The Documents fixture correction excludes only the current job's exact preview POST from the final editor-write count. Listing checks continue to forbid implicit rendering, preview and writes; exact Save body, expected revision, history and durable metadata readbacks remain asserted. Its entire original browser suite passed 10/10 (`documents-preview-fixture-01`). The unchanged JSON job-import suite passed all four cases (`import-flag-browser-01`), including the actual PascalCase export/native chooser round trip, graph/owner boundaries and uncertain-write recovery. Final aggregate, current-source Linux and native PDF acceptance remain pending.
+3 -1
View File
@@ -150,7 +150,9 @@ test('[P7-07 Documents] actual sign-out clears private rows and a separate owner
test('[P7-07 Documents] existing letter source keeps dirty edits on navigation Cancel and reindexes only the explicit saved revision', async ({ page, session }, info) => {
test.setTimeout(150_000); const parent = await job(page.request, session.owner); const original = await json<CoverLetter>(await page.request.put(`${apiUrl}/jobapplications/${parent.id}/cover-letter`, { headers: session.owner.headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/jobapplications/${parent.id}/cover-letter`)).json()).revision, text: 'Original source letter — æøå', source: 'manual' } })); await enter(page, session.owner); const item = (await readIndex(page.request)).items[0]; const from = indexPath(page); await keyboard(page, row(page, item).getByRole('link', { name: copy('documentsOpenSource'), exact: true })); await expect(page).toHaveURL(new RegExp(`/jobs/${parent.id}\\?section=application&sub=letter$`)); const field = page.getByRole('textbox', { name: copy('jobDetailsCoverLetter'), exact: true }); await expect(field).toHaveValue(original.text!); const dirty = 'Reviewed local source change survives Documents navigation Cancel — æøå'; await field.fill(dirty);
const navigation = page.getByRole('button', { name: copy('documentsTitle'), exact: true }); await keyboard(page, navigation); const dialog = page.getByRole('dialog', { name: copy('workspaceUnsavedTitle'), exact: true }); await keyboard(page, dialog.getByRole('button', { name: copy('workspaceKeepEditing'), exact: true })); await expect(dialog).toHaveCount(0); await expect(field).toHaveValue(dirty); expect(count(session, 'PUT', `/api/jobapplications/${parent.id}/cover-letter`)).toHaveLength(0); expect(await json<CoverLetter>(await page.request.get(`${apiUrl}/jobapplications/${parent.id}/cover-letter`))).toEqual(original); await capture(page, info, desktop, 'existing-letter-dirty-navigation-cancel');
const body = page.getByRole('region', { name: copy('workspaceCoverLetter'), exact: true }); const saved = waitResponse(page, 'PUT', `/api/jobapplications/${parent.id}/cover-letter`); await keyboard(page, body.getByRole('button', { name: copy('save'), exact: true })); const revision = await json<CoverLetter>(await saved); expect(revision.text).toBe(dirty); expect(revision.currentVersion).toBe(2); await expect(body.getByRole('button', { name: copy('save'), exact: true })).toBeDisabled(); expect(count(session, 'PUT', `/api/jobapplications/${parent.id}/cover-letter`).map(value => value.body)).toEqual([{ text: dirty, source: 'manual', expectedVersion: original.revision }]); await page.goBack(); await expect(results(page)).toBeVisible(); expect(indexPath(page)).toBe(from); const indexed = (await readIndex(page.request)).items[0]; expect(indexed).toMatchObject({ id: item.id, historyCount: 2, version: 2, hasCurrentContent: true }); await expect(row(page, indexed)).toContainText(copy('documentsHistoryVersions', { count: 2 })); expect(writes(session)).toHaveLength(1);
const body = page.getByRole('region', { name: copy('workspaceCoverLetter'), exact: true }); const saved = waitResponse(page, 'PUT', `/api/jobapplications/${parent.id}/cover-letter`); await keyboard(page, body.getByRole('button', { name: copy('save'), exact: true })); const revision = await json<CoverLetter>(await saved); expect(revision.text).toBe(dirty); expect(revision.currentVersion).toBe(2); await expect(body.getByRole('button', { name: copy('save'), exact: true })).toBeDisabled(); expect(count(session, 'PUT', `/api/jobapplications/${parent.id}/cover-letter`).map(value => value.body)).toEqual([{ text: dirty, source: 'manual', expectedVersion: original.revision }]); await page.goBack(); await expect(results(page)).toBeVisible(); expect(indexPath(page)).toBe(from); const indexed = (await readIndex(page.request)).items[0]; expect(indexed).toMatchObject({ id: item.id, historyCount: 2, version: 2, hasCurrentContent: true }); await expect(row(page, indexed)).toContainText(copy('documentsHistoryVersions', { count: 2 }));
// The editor preview is read-only; listing checks still forbid all implicit preview work.
expect(writes(session).filter(value => !(value.method === 'POST' && value.path === `/api/jobapplications/${parent.id}/cover-letter/preview`))).toHaveLength(1);
});
test('[P7-07 Documents] eight finite bilingual theme widths retain long metadata keyboard actions and complete axe vectors', async ({ page, session }, info) => {