fix(cv): discard library actions after navigation or session loss

This commit is contained in:
cesnimda
2026-11-11 18:50:25 +01:00
parent cef4540456
commit de2fb45221
2 changed files with 201 additions and 23 deletions
+142
View File
@@ -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<any>();
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<any>();
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<any>();
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<any>();
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<string>();
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<any>();
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();
+59 -23
View File
@@ -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<HTMLDivElement>(null);
const createTitleRef = useRef<HTMLHeadingElement>(null);
const actionBusy = useRef(false);
const session = useRef({ mounted: true, generation: 0 });
const actionAnchor = useRef<HTMLElement | null>(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<string[]>([]);
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 (