fix(cv): discard library actions after navigation or session loss
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user