fix(cv): make initial revision atomic
CI and Deploy / test (push) Failing after 8m11s
CI and Deploy / deploy (push) Has been skipped

This commit is contained in:
cesnimda
2026-08-29 02:13:05 +02:00
parent 9cfe622103
commit a3b4941447
4 changed files with 42 additions and 10 deletions
+21
View File
@@ -1,6 +1,7 @@
using JobTrackerApi.Data; using JobTrackerApi.Data;
using JobTrackerApi.Models; using JobTrackerApi.Models;
using JobTrackerApi.Services; using JobTrackerApi.Services;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Moq; using Moq;
using Xunit; using Xunit;
@@ -366,6 +367,26 @@ public sealed class CvBuilderTests
Assert.True(versions[0].IsCurrent); Assert.True(versions[0].IsCurrent);
} }
[Fact]
public async Task Create_persists_variant_and_initial_history_on_a_relational_store()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(service => service.UserId).Returns("user-1");
await using var db = new JobTrackerContext(options, currentUser.Object);
await db.Database.EnsureCreatedAsync();
var service = new CvVariantService(db, new CareerProfileService(db), new ThemedCvRenderer());
var created = await service.CreateAsync("user-1", "Atomic CV", null, new CvVariantSettings(), default);
Assert.Single(await db.CvVariants.IgnoreQueryFilters().Where(item => item.Id == created.Id).ToListAsync());
var history = await db.CvVariantVersions.IgnoreQueryFilters().Where(item => item.CvVariantId == created.Id).ToListAsync();
Assert.Single(history);
Assert.Equal(1, history[0].Version);
}
[Fact] [Fact]
public async Task Restore_reapplies_old_settings_as_a_new_version() public async Task Restore_reapplies_old_settings_as_a_new_version()
{ {
@@ -81,6 +81,12 @@ public sealed class CvVariantService : ICvVariantService
{ {
if (jobApplicationId is not null && !await CanAssociateJobAsync(ownerUserId, jobApplicationId.Value, ct)) if (jobApplicationId is not null && !await CanAssociateJobAsync(ownerUserId, jobApplicationId.Value, ct))
throw new ArgumentException("The job application is unavailable.", nameof(jobApplicationId)); throw new ArgumentException("The job application is unavailable.", nameof(jobApplicationId));
// The first save is required to obtain the generated variant id used by its initial
// history row. Keep both saves in one relational transaction so a concurrent autosave
// cannot slip between them and leave a created CV without version 1.
await using var transaction = _db.Database.IsRelational()
? await _db.Database.BeginTransactionAsync(ct)
: null;
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
var normalized = CvVariantSettingsJson.Normalize(settings); var normalized = CvVariantSettingsJson.Normalize(settings);
var variant = new CvVariant var variant = new CvVariant
@@ -98,6 +104,7 @@ public sealed class CvVariantService : ICvVariantService
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
AppendVersion(variant, "create"); AppendVersion(variant, "create");
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
if (transaction is not null) await transaction.CommitAsync(ct);
return variant; return variant;
} }
+1
View File
@@ -20,6 +20,7 @@
- [x] Diagnosed release 273's assertion-free Node exit 139 and added a single full-suite retry only for that runner crash signature; ordinary frontend test failures remain blocking. - [x] Diagnosed release 273's assertion-free Node exit 139 and added a single full-suite retry only for that runner crash signature; ordinary frontend test failures remain blocking.
- [x] Fixed fresh SQLite/MySQL startup ordering so the migration-owned UI-language column is not pre-created by schema reconciliation, then proved the full fresh-database path in Playwright. - [x] Fixed fresh SQLite/MySQL startup ordering so the migration-owned UI-language column is not pre-created by schema reconciliation, then proved the full fresh-database path in Playwright.
- [x] Visually verified the revised builder at 375 px, 768 px, and 1440 px in light/dark mode, including multiple expanded sections, add-content focus return, Customize/AI surfaces, responsive overflow, multi-page preview and searchable PDF export. - [x] Visually verified the revised builder at 375 px, 768 px, and 1440 px in light/dark mode, including multiple expanded sections, add-content focus return, Customize/AI surfaces, responsive overflow, multi-page preview and searchable PDF export.
- [x] Updated the full browser suite for the consolidated Workspace and revised Builder controls, then fixed CV creation to atomically persist the document and initial history revision under concurrent autosave load; all 10 Playwright flows pass.
- [x] Rechecked branch/status, recent CV work, existing tests/build tooling, and current builder architecture. - [x] Rechecked branch/status, recent CV work, existing tests/build tooling, and current builder architecture.
- [x] Traced builder persistence, shared preview/PDF rendering, AI endpoint, extraction pipeline, confidence/diff review, and reference CV assets. - [x] Traced builder persistence, shared preview/PDF rendering, AI endpoint, extraction pipeline, confidence/diff review, and reference CV assets.
- [x] Rendered and inspected the supplied two-page reference PDF and reviewed its HTML typography/theme tokens without modifying the originals. - [x] Rendered and inspected the supplied two-page reference PDF and reviewed its HTML typography/theme tokens without modifying the originals.
+13 -10
View File
@@ -236,11 +236,11 @@ test("the dedicated application workspace survives deep links, long data and uns
await page.goForward(); await page.goForward();
await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}$`)); await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}$`));
const workspaceNav = page.getByRole("navigation", { name: "Workspace sections" }); const workspaceNav = page.getByRole("tablist", { name: "Workspace sections" });
await workspaceNav.getByRole("button", { name: "Cover Letter", exact: true }).click(); await workspaceNav.getByRole("tab", { name: "Cover Letter", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}\\?section=cover-letter$`)); await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}\\?section=cover-letter$`));
await page.getByLabel("Application answer").fill("A reviewed answer that must not be lost."); await page.getByLabel("Application answer").fill("A reviewed answer that must not be lost.");
await workspaceNav.getByRole("button", { name: "Match", exact: true }).click(); await workspaceNav.getByRole("tab", { name: "Analysis", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Unsaved application changes" })).toBeVisible(); await expect(page.getByRole("dialog", { name: "Unsaved application changes" })).toBeVisible();
await page.getByRole("button", { name: "Keep editing" }).click(); await page.getByRole("button", { name: "Keep editing" }).click();
await expect(page.getByLabel("Application answer")).toHaveValue("A reviewed answer that must not be lost."); await expect(page.getByLabel("Application answer")).toHaveValue("A reviewed answer that must not be lost.");
@@ -268,8 +268,9 @@ test("the dedicated application workspace survives deep links, long data and uns
expect(overflow).toBeLessThanOrEqual(1); expect(overflow).toBeLessThanOrEqual(1);
} }
await workspaceNav.getByRole("button", { name: "Job Details", exact: true }).click(); await workspaceNav.getByRole("tab", { name: "Overview", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}\\?section=job-details$`)); await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}\\?section=overview$`));
await page.getByRole("button", { name: "Job details", exact: true }).click();
await expect(page.getByText("Ask about platform ownership and the incident response rotation.")).toBeVisible(); await expect(page.getByText("Ask about platform ownership and the incident response rotation.")).toBeVisible();
await expect(page.getByText("<<<APPLICATION_ANSWER_DRAFT>>>")).toHaveCount(0); await expect(page.getByText("<<<APPLICATION_ANSWER_DRAFT>>>")).toHaveCount(0);
@@ -315,25 +316,27 @@ test("Career Workspace loads from the authenticated application shell", async ({
await expect(page).toHaveURL(/\/career\/builder\/\d+$/); await expect(page).toHaveURL(/\/career\/builder\/\d+$/);
await expect(page.getByLabel("CV name")).toBeVisible(); await expect(page.getByLabel("CV name")).toBeVisible();
await page.getByRole("tab", { name: "Content" }).click();
await page.getByRole("button", { name: "Expand Professional Summary" }).click(); await page.getByRole("button", { name: "Expand Professional Summary" }).click();
await page.getByRole("button", { name: "Expand Experience" }).click(); await page.getByRole("button", { name: "Expand Experience" }).click();
await expect(page.getByRole("button", { name: "Collapse Professional Summary" })).toHaveAttribute("aria-expanded", "true"); await expect(page.getByRole("button", { name: "Collapse Professional Summary" })).toHaveAttribute("aria-expanded", "true");
await expect(page.getByRole("button", { name: "Collapse Experience" })).toHaveAttribute("aria-expanded", "true"); await expect(page.getByRole("button", { name: "Collapse Experience" })).toHaveAttribute("aria-expanded", "true");
await page.getByLabel("Section type").click(); await page.getByRole("button", { name: "Add content" }).click();
await page.getByRole("option", { name: "Additional Experience" }).click(); const addContentDialog = page.getByRole("dialog", { name: "Add content" });
await page.getByRole("button", { name: "Add section" }).click(); const additionalExperienceCard = addContentDialog.getByRole("heading", { name: "Additional Experience" }).locator("..");
await additionalExperienceCard.getByRole("button", { name: "Add", exact: true }).click();
await page.getByRole("button", { name: "Expand Additional Experience" }).click(); await page.getByRole("button", { name: "Expand Additional Experience" }).click();
await page.getByRole("button", { name: "Add entry" }).click(); await page.getByRole("button", { name: "Add entry" }).click();
await page.getByRole("textbox", { name: "Entry 1", exact: true }).fill("Maintained a community software project."); await page.getByRole("textbox", { name: "Entry 1", exact: true }).fill("Maintained a community software project.");
await page.getByRole("tab", { name: "Template" }).click(); await page.getByRole("tab", { name: "Template" }).click();
await page.getByRole("button").filter({ hasText: /^CodeTechnical/ }).click(); await page.getByRole("button").filter({ hasText: /^CodeTechnical/ }).click();
await page.getByRole("tab", { name: "Design" }).click(); await page.getByRole("tab", { name: "Customize" }).click();
await page.getByLabel("Skills presentation").click(); await page.getByLabel("Skills presentation").click();
await page.getByRole("option", { name: "Bullet list" }).click(); await page.getByRole("option", { name: "Bullet list" }).click();
await page.locator('input[type="color"]').fill("#126b55"); await page.locator('input[type="color"]').fill("#126b55");
await expect(page.getByRole("tab", { name: "AI" })).toHaveCount(0); await expect(page.getByRole("tab", { name: "AI Tools" })).toBeVisible();
await page.evaluate(() => window.localStorage.setItem("jobtracker.themeMode", "dark")); await page.evaluate(() => window.localStorage.setItem("jobtracker.themeMode", "dark"));
await page.reload(); await page.reload();