From de2fb452210b128a8b5a3729e5971b8e2ddbba7c Mon Sep 17 00:00:00 2001 From: cesnimda Date: Wed, 11 Nov 2026 18:50:25 +0100 Subject: [PATCH] fix(cv): discard library actions after navigation or session loss --- job-tracker-ui/src/cv-builder-page.test.tsx | 142 ++++++++++++++++++++ job-tracker-ui/src/views/CvBuilderPage.tsx | 82 +++++++---- 2 files changed, 201 insertions(+), 23 deletions(-) diff --git a/job-tracker-ui/src/cv-builder-page.test.tsx b/job-tracker-ui/src/cv-builder-page.test.tsx index ba6652d7..771f12ff 100644 --- a/job-tracker-ui/src/cv-builder-page.test.tsx +++ b/job-tracker-ui/src/cv-builder-page.test.tsx @@ -13,6 +13,13 @@ import { emptyCvVariantSettings, getCvExportFailureKey } from './cvBuilder'; import { translations } from './i18n/translations'; const mockNavigate = jest.fn(); +let mockSessionActive = true; +jest.mock('./sessionBoundary', () => ({ + ...jest.requireActual('./sessionBoundary'), + checkSessionCookie: jest.fn(), + getSessionContext: () => 'cv-owner', + isSessionInvalidated: () => !mockSessionActive, +})); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useNavigate: () => mockNavigate, @@ -50,6 +57,7 @@ function renderPage(extra?: React.ReactNode) { } beforeEach(() => { + mockSessionActive = true; window.localStorage.removeItem('uiLanguage'); jest.resetAllMocks(); mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/themes' ? themes : [] }) as any); @@ -94,6 +102,140 @@ async function selectAction(name: string) { fireEvent.click(item); } +test.each(['unmount', 'session'] as const)('late CV creation cannot navigate after %s', async boundary => { + const write = deferred(); + mockedApi.post.mockImplementationOnce(() => write.promise); + const view = renderPage(); + await screen.findByText('No CVs yet'); + fireEvent.click(screen.getByRole('button', { name: 'New CV' })); + fireEvent.click(await screen.findByRole('button', { name: 'Create CV' })); + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1)); + if (boundary === 'unmount') view.unmount(); + else mockSessionActive = false; + await act(async () => { write.resolve({ data: { id: 42 } }); }); + expect(mockNavigate).not.toHaveBeenCalled(); +}); + +test.each(['unmount', 'session'] as const)('late CV PDF cannot download after %s', async boundary => { + const read = deferred(); + mockedApi.get.mockImplementation(async url => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any); + mockedApi.post.mockImplementationOnce(() => read.promise); + const createUrl = jest.fn(() => 'blob:synthetic-cv'); + const oldCreate = URL.createObjectURL; + URL.createObjectURL = createUrl; + try { + const view = renderPage(); + await screen.findByText('Frontend CV'); + await selectAction('Download PDF'); + if (boundary === 'unmount') view.unmount(); + else mockSessionActive = false; + await act(async () => { read.resolve({ data: new Blob(['synthetic']) }); }); + expect(createUrl).not.toHaveBeenCalled(); + } finally { URL.createObjectURL = oldCreate; } +}); + +test('session invalidation while confirming deletion prevents the write', async () => { + mockedApi.get.mockImplementation(async url => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any); + mockedApi.delete.mockResolvedValue({ data: null } as any); + renderPage(); + await screen.findByText('Frontend CV'); + await selectAction('Delete'); + const button = await screen.findByRole('button', { name: 'Delete CV' }); + mockSessionActive = false; + await act(async () => { fireEvent.click(button); }); + expect(mockedApi.delete).not.toHaveBeenCalled(); +}); + +test('session invalidation during the rename read prevents a later revisioned write', async () => { + const read = deferred(); + mockedApi.get.mockImplementation(url => url === '/cv/variants/1' ? read.promise : Promise.resolve({ data: url === '/cv/variants' ? [existingCv] : themes }) as any); + renderPage(); + await screen.findByText('Frontend CV'); + await selectAction('Rename'); + fireEvent.change(await screen.findByRole('textbox', { name: 'Give this CV a clear name.' }), { target: { value: 'Changed CV' } }); + fireEvent.click(screen.getByRole('button', { name: 'Rename' })); + await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/cv/variants/1')); + mockSessionActive = false; + await act(async () => { read.resolve({ data: { ...existingCv, settings: emptyCvVariantSettings('modern') } }); }); + expect(mockedApi.put).not.toHaveBeenCalled(); +}); + +test('a duplicate completing in an invalid session neither reloads nor announces success', async () => { + const write = deferred(); + mockedApi.get.mockImplementation(async url => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any); + mockedApi.post.mockImplementationOnce(() => write.promise); + renderPage(); + await screen.findByText('Frontend CV'); + await selectAction('Duplicate'); + const reads = mockedApi.get.mock.calls.length; + mockSessionActive = false; + await act(async () => { write.resolve({ data: { id: 2 } }); }); + expect(mockedApi.get).toHaveBeenCalledTimes(reads); + expect(screen.queryByText(translations.en.cvDashboardDuplicated)).not.toBeInTheDocument(); +}); + +test('an active CV PDF download retains its filename and releases the synthetic URL', async () => { + mockedApi.get.mockImplementation(async url => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any); + const blob = new Blob(['synthetic PDF bytes']); + mockedApi.post.mockResolvedValueOnce({ data: blob } as any); + const oldCreate = URL.createObjectURL; + const oldRevoke = URL.revokeObjectURL; + const create = jest.fn(() => 'blob:synthetic-cv'); + const revoke = jest.fn(); + URL.createObjectURL = create; + URL.revokeObjectURL = revoke; + const downloads: string[] = []; + const click = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { downloads.push(this.download); }); + try { + renderPage(); + await screen.findByText('Frontend CV'); + await selectAction('Download PDF'); + await waitFor(() => expect(downloads).toEqual(['Frontend CV.pdf'])); + expect(create).toHaveBeenCalledWith(blob); + expect(revoke).toHaveBeenCalledWith('blob:synthetic-cv'); + } finally { URL.createObjectURL = oldCreate; URL.revokeObjectURL = oldRevoke; click.mockRestore(); } +}); + +test('session invalidation while naming a CV prevents the rename read and write', async () => { + mockedApi.get.mockImplementation(async url => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any); + renderPage(); + await screen.findByText('Frontend CV'); + await selectAction('Rename'); + fireEvent.change(await screen.findByRole('textbox', { name: 'Give this CV a clear name.' }), { target: { value: 'Changed CV' } }); + mockSessionActive = false; + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Rename' })); }); + expect(mockedApi.get).not.toHaveBeenCalledWith('/cv/variants/1'); + expect(mockedApi.put).not.toHaveBeenCalled(); +}); + +test('an export error decoded after session invalidation cannot show delayed feedback', async () => { + const text = deferred(); + const body = new Blob(); + Object.defineProperty(body, 'text', { value: () => text.promise }); + mockedApi.get.mockImplementation(async url => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any); + mockedApi.post.mockRejectedValueOnce({ response: { status: 400, data: body } }); + renderPage(); + await screen.findByText('Frontend CV'); + await selectAction('Download PDF'); + mockSessionActive = false; + await act(async () => { text.resolve('{"code":"cv_empty_content"}'); }); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); +}); + +test('a profile outline completing after session invalidation cannot populate the create draft', async () => { + const read = deferred(); + mockedApi.get.mockImplementation(url => url === '/cv/outline' ? read.promise : Promise.resolve({ data: url === '/cv/themes' ? themes : [] }) as any); + renderPage(); + await screen.findByText('No CVs yet'); + fireEvent.click(screen.getByRole('button', { name: 'New CV' })); + const dialog = await screen.findByRole('dialog', { name: 'Create a CV' }); + fireEvent.click(within(dialog).getByRole('radio', { name: 'From Career Profile' })); + mockSessionActive = false; + await act(async () => { read.resolve({ data: { sections: [{ key: 'skills', title: 'Stale profile skills' }] } }); }); + expect(within(dialog).queryByRole('checkbox', { name: 'Stale profile skills' })).not.toBeInTheDocument(); + expect(within(dialog).getByRole('button', { name: 'Create CV' })).toBeDisabled(); +}); + test('blank creation explicitly excludes all profile sections and contact even after template selection', async () => { mockedApi.post.mockResolvedValueOnce({ data: { id: 42 } } as any); renderPage(); diff --git a/job-tracker-ui/src/views/CvBuilderPage.tsx b/job-tracker-ui/src/views/CvBuilderPage.tsx index 425bbe9b..86a2d8db 100644 --- a/job-tracker-ui/src/views/CvBuilderPage.tsx +++ b/job-tracker-ui/src/views/CvBuilderPage.tsx @@ -19,6 +19,7 @@ import type { TranslationKey } from "../i18n/translations"; import { PageHeader } from "../layout/PageLayout"; import ViewStateNotice from "../components/ViewStateNotice"; import { useViewResource } from "../hooks/useViewResource"; +import { checkSessionCookie, getSessionContext, isSessionInvalidated } from "../sessionBoundary"; export default function CvBuilderPage() { const navigate = useNavigate(); @@ -36,6 +37,7 @@ export default function CvBuilderPage() { const contentRef = useRef(null); const createTitleRef = useRef(null); const actionBusy = useRef(false); + const session = useRef({ mounted: true, generation: 0 }); const actionAnchor = useRef(null); const [pending, setPending] = useState(false); const [actionError, setActionError] = useState<{ error?: unknown; key: TranslationKey } | null>(null); @@ -52,12 +54,33 @@ export default function CvBuilderPage() { const [selectedSections, setSelectedSections] = useState([]); const [includeContact, setIncludeContact] = useState(false); + useEffect(() => { + const owner = session.current; + owner.mounted = true; + owner.generation += 1; + return () => { owner.mounted = false; owner.generation += 1; }; + }, []); + + const captureSession = () => { + checkSessionCookie(); + const owner = session.current; + if (!owner.mounted || isSessionInvalidated()) return null; + const context = getSessionContext(); + const generation = owner.generation; + return () => { + checkSessionCookie(); + return owner.mounted && owner.generation === generation && !isSessionInvalidated() && getSessionContext() === context; + }; + }; + const loadProfile = async () => { + const active = captureSession(); + if (!active) return; setProfileLoading(true); setProfileFailed(false); - try { setProfileOutline(await cvBuilderApi.outline()); } - catch { setProfileFailed(true); } - finally { setProfileLoading(false); } + try { const outline = await cvBuilderApi.outline(); if (active()) setProfileOutline(outline); } + catch { if (active()) setProfileFailed(true); } + finally { if (active()) setProfileLoading(false); } }; useEffect(() => { @@ -66,15 +89,16 @@ export default function CvBuilderPage() { }, [pending]); const startAction = (showPending = true) => { - if (actionBusy.current) return false; + const active = captureSession(); + if (!active || actionBusy.current) return null; actionBusy.current = true; actionAnchor.current = menu?.anchor ?? null; setMenu(null); setActionError(null); if (showPending) setPending(true); - return true; + return active; }; - const finishAction = () => { actionBusy.current = false; setPending(false); }; + const finishAction = (active: () => boolean) => { actionBusy.current = false; if (active()) setPending(false); }; const setVariants = (update: (items: CvVariantSummary[]) => CvVariantSummary[]) => { resource.setData((data) => ({ ...data, variants: update(data.variants) })); }; @@ -85,7 +109,8 @@ export default function CvBuilderPage() { }; const confirmCreate = async () => { - if (!newName.trim() || actionBusy.current) return; + const active = captureSession(); + if (!active || !newName.trim() || actionBusy.current) return; actionBusy.current = true; createTitleRef.current?.focus(); setCreateError(null); @@ -97,65 +122,76 @@ export default function CvBuilderPage() { includeProfileContact: contentSource === "profile" && includeContact, sections: contentSource === "profile" ? selectedSections.map((key) => ({ key })) : [], } }); + if (!active()) return; setCreateOpen(false); navigate(`/career/builder/${variant.id}`); } catch (err) { - setCreateError({ error: err }); - } finally { actionBusy.current = false; setCreating(false); } + if (active()) setCreateError({ error: err }); + } finally { actionBusy.current = false; if (active()) setCreating(false); } }; const duplicate = async (id: number) => { - if (!startAction()) return; + const active = startAction(); + if (!active) return; try { await cvBuilderApi.duplicate(id); + if (!active()) return; await resource.reload(); + if (!active()) return; toast(localeRef.current("cvDashboardDuplicated"), "success"); } catch (err) { - setActionError({ error: err, key: "cvDashboardDuplicateFailed" }); + if (active()) setActionError({ error: err, key: "cvDashboardDuplicateFailed" }); } finally { - finishAction(); + finishAction(active); } }; const remove = async (id: number) => { - if (!startAction(false)) return; + const active = startAction(false); + if (!active) return; const variant = variants.find((item) => item.id === id); try { if (!(await confirmAction(t("cvDashboardDeleteMessage", { name: variant?.name ?? t("cvDashboardThisCv") }), { title: t("cvDashboardDeleteCv"), confirmLabel: t("cvDashboardDeleteCv"), destructive: true, - }))) return; + })) || !active()) return; setPending(true); await cvBuilderApi.remove(id); + if (!active()) return; setVariants((v) => v.filter((x) => x.id !== id)); toast(localeRef.current("cvDashboardDeleted"), "success"); } catch (err) { - setActionError({ error: err, key: "cvDashboardDeleteFailed" }); + if (active()) setActionError({ error: err, key: "cvDashboardDeleteFailed" }); } finally { - finishAction(); + finishAction(active); } }; const rename = async (id: number) => { - if (!startAction(false)) return; + const active = startAction(false); + if (!active) return; const current = variants.find((item) => item.id === id); try { const nextName = await promptForValue(t("cvDashboardRenameMessage"), current?.name ?? "", { title: t("cvDashboardRenameCv"), confirmLabel: t("cvDashboardRename") }); - if (!nextName?.trim() || nextName.trim() === current?.name) return; + if (!active() || !nextName?.trim() || nextName.trim() === current?.name) return; setPending(true); const variant = await cvBuilderApi.get(id); + if (!active()) return; const updated = await cvBuilderApi.save(id, { name: nextName.trim(), settings: variant.settings, source: "manual", expectedVersion: variant.version }); + if (!active()) return; setVariants((items) => items.map((item) => item.id === id ? { ...item, name: updated.name, updatedAtUtc: updated.updatedAtUtc, version: updated.version } : item)); toast(localeRef.current("cvDashboardRenamed"), "success"); - } catch (err) { setActionError({ error: err, key: "cvDashboardRenameFailed" }); } - finally { finishAction(); } + } catch (err) { if (active()) setActionError({ error: err, key: "cvDashboardRenameFailed" }); } + finally { finishAction(active); } }; const download = async (id: number, cvName: string) => { - if (!startAction()) return; + const active = startAction(); + if (!active) return; try { const response = await api.post(cvBuilderApi.exportPdfUrl(id), {}, { responseType: "blob" }); + if (!active()) return; const url = URL.createObjectURL(response.data as Blob); const link = document.createElement("a"); link.href = url; @@ -164,8 +200,8 @@ export default function CvBuilderPage() { URL.revokeObjectURL(url); } catch (err) { const key = await getCvExportFailureKey(err); - setActionError({ error: key ? undefined : err, key: key ?? "cvDashboardDownloadFailed" }); - } finally { finishAction(); } + if (active()) setActionError({ error: key ? undefined : err, key: key ?? "cvDashboardDownloadFailed" }); + } finally { finishAction(active); } }; return (