From 1b89a779d038caeae49c8e0c10db93723bc7d3a4 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 23 Oct 2026 12:51:36 +0200 Subject: [PATCH] fix(rd21): expose committed revisions and refresh answer sources --- JobTrackerApi/Program.cs | 1 + .../diagnostics/persistence.spec.ts | 66 +++++++++++++++++++ .../application-workspace-overlay.test.tsx | 57 +++++++++++++++- .../src/views/ApplicationWorkspacePage.tsx | 23 +++++-- 4 files changed, 141 insertions(+), 6 deletions(-) diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 144fe8b..c313d73 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -148,6 +148,7 @@ builder.Services.AddCors(options => policy.WithOrigins(corsOrigins.Select(x => x.Trim().TrimEnd('/')).Where(x => x.Length > 0).ToArray()) .AllowAnyMethod() .AllowAnyHeader() + .WithExposedHeaders("X-Job-Version") .AllowCredentials(); }); }); diff --git a/docs/redesign/phase-9/rd21-remediation/diagnostics/persistence.spec.ts b/docs/redesign/phase-9/rd21-remediation/diagnostics/persistence.spec.ts index 6fdf2e3..8733488 100644 --- a/docs/redesign/phase-9/rd21-remediation/diagnostics/persistence.spec.ts +++ b/docs/redesign/phase-9/rd21-remediation/diagnostics/persistence.spec.ts @@ -89,6 +89,72 @@ async function createJob(request: APIRequestContext, owner: Owner) { data: { companyId: company.id, jobTitle: 'Original job title', status: 'Saved', notes: 'Retain original notes', location: 'Trondheim' } }), 201); } +test('RD21 cross-origin browser exposes committed revisions and native Answers saves advance consecutively', async ({ page, owner }, info) => { + const job = await createJob(page.request, owner); + await page.goto(`/jobs/${job.id}?section=application&sub=answers`); + const answer = page.getByRole('textbox', { name: t.assetsApplicationAnswer, exact: true }); + await expect(answer).toBeEditable(); + const browserCommit = await page.evaluate(async ({ api, job, headers }) => { + const response = await fetch(`${api}/jobapplications/${job.id}/application-drafts`, { method: 'PUT', credentials: 'include', + headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ applicationAnswerDraft: 'Browser-visible commit', recruiterMessageDraft: '', expectedVersion: job.version }) }); + return { status: response.status, revision: response.headers.get('X-Job-Version') }; + }, { api, job, headers: owner.headers }); + expect(browserCommit.status).toBe(204); + expect(db(info, owner, job.id).job).toMatchObject({ Version: job.version + 1, Notes: expect.stringContaining('<<>>\nBrowser-visible commit\n<<>>') }); + expect(browserCommit.revision).toBe(String(job.version + 1)); + await page.reload(); + await expect(answer).toHaveValue('Browser-visible commit'); + for (const [index, text] of ['First native answer', 'Second native answer'].entries()) { + await answer.fill(text); + const responsePromise = page.waitForResponse(response => response.url().endsWith(`/jobapplications/${job.id}/application-drafts`) && response.request().method() === 'PUT'); + await page.getByRole('button', { name: t.assetsSaveDrafts, exact: true }).click(); + const response = await responsePromise; + expect(response.status()).toBe(204); + expect(Number(response.headers()['x-job-version'])).toBe(job.version + index + 2); + await expect(page.getByRole('button', { name: t.assetsSaveDrafts, exact: true })).toBeDisabled(); + await expect(page.getByText('Missing committed job revision.', { exact: true })).toHaveCount(0); + expect(db(info, owner, job.id).job).toMatchObject({ Version: job.version + index + 2, Notes: expect.stringContaining(`<<>>\n${text}\n<<>>`) }); + } + await attach(info, 'rd21-cross-origin-native-answers', { browserCommit, database: db(info, owner, job.id) }); +}); + +test('RD21 cover-letter save then Answers entry waits for fresh coherent source before a native save', async ({ page, owner }, info) => { + const job = await createJob(page.request, owner); + await page.goto(`/jobs/${job.id}?section=application&sub=letter`); + const letter = page.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true }); + await expect(letter).toBeEditable(); + await letter.fill('Acknowledged cover letter retained'); + const saved = page.waitForResponse(response => response.url().endsWith(`/jobapplications/${job.id}/cover-letter`) && response.request().method() === 'PUT'); + await page.getByRole('button', { name: t.save, exact: true }).click(); + const letterResponse = await saved; + expect(letterResponse.status()).toBe(200); + const letterRevision = (await letterResponse.json()).revision; + await expect(page.getByRole('button', { name: t.save, exact: true })).toBeDisabled(); + expect(db(info, owner, job.id).job).toMatchObject({ Version: letterRevision, CoverLetterText: 'Acknowledged cover letter retained' }); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let started!: () => void; + const requested = new Promise(resolve => { started = resolve; }); + await page.route(`**/api/jobapplications/${job.id}/workspace`, async route => { started(); await gate; await route.continue(); }); + await page.getByRole('tab', { name: t.workspaceAnswers, exact: true }).click(); + await expect(page.getByRole('tab', { name: t.workspaceAnswers, exact: true })).toHaveAttribute('aria-selected', 'true'); + const answer = page.getByRole('textbox', { name: t.assetsApplicationAnswer, exact: true }); + try { + await expect(answer).toHaveCount(0); + await requested; + } finally { release(); } + await expect(answer).toBeEditable(); + await answer.fill('Answer after cover letter'); + const answerSaved = page.waitForResponse(response => response.url().endsWith(`/jobapplications/${job.id}/application-drafts`) && response.request().method() === 'PUT'); + await page.getByRole('button', { name: t.assetsSaveDrafts, exact: true }).click(); + const response = await answerSaved; + expect(response.request().postDataJSON().expectedVersion).toBe(letterRevision); + expect(response.status()).toBe(204); + await expect(page.getByRole('button', { name: t.assetsSaveDrafts, exact: true })).toBeDisabled(); + expect(db(info, owner, job.id).job).toMatchObject({ Version: letterRevision + 1, CoverLetterText: 'Acknowledged cover letter retained', Notes: expect.stringContaining('<<>>\nAnswer after cover letter\n<<>>') }); + await attach(info, 'rd21-cover-letter-answers-entry', { letterRevision, committedRevision: response.headers()['x-job-version'], database: db(info, owner, job.id) }); +}); + test('RD21 original two-session ordering rejects stale snapshots and preserves each acknowledged SQLite commit across navigation and a fresh session', async ({ page, owner, second, playwright, email }, info) => { await seedCareer(page.request, owner); const careerA = await json(await page.request.get(`${api}/career/profile`)); diff --git a/job-tracker-ui/src/application-workspace-overlay.test.tsx b/job-tracker-ui/src/application-workspace-overlay.test.tsx index e9d2d9a..6b13092 100644 --- a/job-tracker-ui/src/application-workspace-overlay.test.tsx +++ b/job-tracker-ui/src/application-workspace-overlay.test.tsx @@ -27,7 +27,7 @@ jest.mock("./components/ApplicationAssets", () => ({ ), ApplicationCvSection: () =>
CV section
, - ApplicationPackageDraftsSection: ({ jobId, initialApplicationAnswer, initialRecruiterMessage, onDirtyChange }: { jobId: number; initialApplicationAnswer: string; initialRecruiterMessage: string; onDirtyChange?: (dirty: boolean) => void }) =>
Application drafts section{jobId}|{initialApplicationAnswer}|{initialRecruiterMessage}
, + ApplicationPackageDraftsSection: ({ jobId, initialApplicationAnswer, initialRecruiterMessage, initialRevision, onDirtyChange }: { jobId: number; initialApplicationAnswer: string; initialRecruiterMessage: string; initialRevision: number; onDirtyChange?: (dirty: boolean) => void }) =>
Application drafts section{jobId}|{initialApplicationAnswer}|{initialRecruiterMessage}|{initialRevision}
, SubmittedPackagesSection: () =>
Frozen packages section
, })); jest.mock("./components/InterviewPrep", () => ({ ApplicationInterviewPrep: () =>
Interview section
})); @@ -77,6 +77,7 @@ const job = { const overview = { id: 42, + revision: 7, jobTitle: "Backend Developer", company: "Acme", location: "Oslo", @@ -151,6 +152,7 @@ function renderTable(path = "/jobs") { } beforeEach(() => { + mockedApi.get.mockReset(); window.localStorage.removeItem("uiLanguage"); mockedApi.get.mockImplementation((url: string) => { if (url === "/companies") return Promise.resolve({ data: [{ id: 1, name: "Acme" }] } as any); @@ -411,3 +413,56 @@ test("holds answers until their aggregate matches the route and ignores a late p expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.put).not.toHaveBeenCalled(); }); + +test("entering Answers waits for a fresh coherent snapshot after sibling writes", async () => { + renderTable("/jobs/42?section=application&sub=letter"); + await screen.findByRole("heading", { name: "Backend Developer" }); + let resolve!: (value: any) => void; + const pending = new Promise(done => { resolve = done; }); + mockedApi.get.mockReturnValueOnce(pending); + fireEvent.click(screen.getByRole("tab", { name: "Answers & message draft" })); + expect(screen.queryByTestId("answer-props")).not.toBeInTheDocument(); + await act(async () => { resolve({ data: { ...overview, revision: 8, applicationAnswerDraft: "Fresh answer", recruiterMessageDraft: "Fresh message" } }); await pending; }); + expect(await screen.findByTestId("answer-props")).toHaveTextContent("42|Fresh answer|Fresh message|8"); + expect(mockedApi.get.mock.calls.filter(([url]) => url === "/jobapplications/42/workspace")).toHaveLength(2); +}); + +test("a late previous Answers-entry read cannot enable the current editor", async () => { + const { router } = renderTable("/jobs/42?section=application&sub=letter"); + await screen.findByRole("heading", { name: "Backend Developer" }); + let resolveOld!: (value: any) => void; + let resolveNew!: (value: any) => void; + const oldRead = new Promise(done => { resolveOld = done; }); + const newRead = new Promise(done => { resolveNew = done; }); + mockedApi.get.mockReturnValueOnce(oldRead).mockReturnValueOnce(newRead); + await act(async () => { await router.navigate("/jobs/42?section=application&sub=answers"); }); + await act(async () => { await router.navigate("/jobs/42?section=application&sub=letter"); }); + await act(async () => { await router.navigate("/jobs/42?section=application&sub=answers"); }); + await act(async () => { resolveOld({ data: { ...overview, revision: 8 } }); await oldRead; }); + expect(screen.queryByTestId("answer-props")).not.toBeInTheDocument(); + await act(async () => { resolveNew({ data: { ...overview, revision: 9, applicationAnswerDraft: "Current answer" } }); await newRead; }); + expect(await screen.findByTestId("answer-props")).toHaveTextContent("42|Current answer|Saved recruiter message|9"); +}); + +test("a failed Answers-entry read stays closed until a successful retry", async () => { + renderTable("/jobs/42?section=application&sub=letter"); + await screen.findByRole("heading", { name: "Backend Developer" }); + mockedApi.get.mockRejectedValueOnce({ response: { status: 503 } }); + fireEvent.click(screen.getByRole("tab", { name: "Answers & message draft" })); + const retry = await screen.findByRole("button", { name: "Retry" }); + expect(screen.queryByTestId("answer-props")).not.toBeInTheDocument(); + mockedApi.get.mockResolvedValueOnce({ data: { ...overview, revision: 9, applicationAnswerDraft: "Recovered answer" } }); + fireEvent.click(retry); + expect(await screen.findByTestId("answer-props")).toHaveTextContent("42|Recovered answer|Saved recruiter message|9"); +}); + +test("leaving a pending direct Answers entry still loads the workspace header", async () => { + let resolve!: (value: any) => void; + const pending = new Promise(done => { resolve = done; }); + mockedApi.get.mockReturnValueOnce(pending); + const { router } = renderTable("/jobs/42?section=application&sub=answers"); + await act(async () => { await router.navigate("/jobs/42?section=application&sub=letter"); }); + expect(await screen.findByRole("heading", { name: "Backend Developer" })).toBeInTheDocument(); + await act(async () => { resolve({ data: { ...overview, jobTitle: "Late header" } }); await pending; }); + expect(screen.queryByRole("heading", { name: "Late header" })).not.toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx index 198f31f..37ae6bb 100644 --- a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx +++ b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx @@ -78,6 +78,9 @@ export function ApplicationWorkspace({ const [overlaySubsection, setOverlaySubsection] = useState<{ section: WorkspaceSectionKey; value: string } | null>(null); const subsection = onSectionChange && overlaySubsection?.section === section ? overlaySubsection.value : workspaceSubsection(sectionOverride ?? params.get("section"), params.get("sub")); + const answersActive = section === "application" && subsection === "answers"; + const answersEntry = useMemo(() => ({ jobId, answersActive }), [jobId, answersActive]); + const [readyAnswersEntry, setReadyAnswersEntry] = useState(null); const sectionId = useId(); const secondaryId = useId(); const panelRef = useRef(null); @@ -85,6 +88,7 @@ export function ApplicationWorkspace({ const [loadedOverview, setOverview] = useState(null); const overview = loadedOverview?.id === jobId ? loadedOverview : null; const overviewRequest = useRef(0); + const overviewLoadedJob = useRef(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [editOpen, setEditOpen] = useState(false); @@ -136,7 +140,11 @@ export function ApplicationWorkspace({ setError(null); setLoading(true); const next = await applicationWorkspaceApi.overview(jobId); - if (request === overviewRequest.current) setOverview(next); + if (request === overviewRequest.current) { + setOverview(next); + overviewLoadedJob.current = next.id; + setReadyAnswersEntry(answersEntry); + } } catch (err) { if (request !== overviewRequest.current) return; const status = (err as { response?: { status?: number } })?.response?.status; @@ -144,12 +152,17 @@ export function ApplicationWorkspace({ } finally { if (request === overviewRequest.current) setLoading(false); } - }, [jobId, t]); + }, [jobId, t, answersEntry]); + const previousOverviewSource = useRef<{ jobId: number; t: typeof t; answersActive: boolean } | null>(null); useEffect(() => { - void load(); + const previous = previousOverviewSource.current; + previousOverviewSource.current = { jobId, t, answersActive }; + // Each Answers entry needs a coherent source; leaving it does not need another aggregate read. + if (answersActive || !previous?.answersActive || previous.jobId !== jobId || previous.t !== t || overviewLoadedJob.current !== jobId) void load(); + else setLoading(false); return () => { overviewRequest.current += 1; }; - }, [load]); + }, [load, answersActive, jobId, t]); useEffect(() => { // Direct entry gets a named target; keyboard tab activation keeps tab focus. @@ -261,7 +274,7 @@ export function ApplicationWorkspace({ {section === "application" && subsection === "cv" && jobId > 0 && } {section === "application" && subsection === "letter" && jobId > 0 && } - {section === "application" && subsection === "answers" && overview && jobId > 0 && 0 &&