import React from "react"; import "@testing-library/jest-dom"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { api } from "./api"; import { I18nProvider } from "./i18n/I18nProvider"; import { translations } from "./i18n/translations"; import { emptyStructuredCv } from "./profileCv"; import { ToastProvider } from "./toast"; import CareerProfilePage from "./views/CareerProfilePage"; jest.mock("./api", () => ({ getApiErrorMessage: jest.requireActual("./api").getApiErrorMessage, api: { get: jest.fn(), post: jest.fn(), put: jest.fn(), patch: jest.fn(), delete: jest.fn() }, })); jest.mock("./components/CropImageDialog", () => () => null); const mockedApi = api as jest.Mocked; const en = translations.en; const nb = translations.nb; const profile = { ...emptyStructuredCv(), contact: { fullName: "Recorded owner" }, languages: [{ name: "Norwegian", level: "A2" }] }; const source = { data: { revision: 7, profile, cvText: "Retained canonical raw", completeness: { percent: 50, missing: [], sections: [] } } }; const versions = [ { version: 2, source: "manual", createdAtUtc: "2026-10-12T01:00:00Z", isCurrent: true }, { version: 1, source: "manual", createdAtUtc: "2026-10-11T01:00:00Z", isCurrent: false }, ]; const run = { id: 41, trigger: "upload", status: "pending_review", startedAtUtc: "2026-10-12T01:00:00Z", parserVersion: "offline-fixture", normalizerVersion: "offline-fixture", llmPromptVersion: "offline-fixture" }; const diff = { totalAdded: 1, totalUpdated: 1, totalLowConfidence: 1, hasChanges: true, categories: [{ category: "Languages", unchangedCount: 0, lowConfidenceCount: 1, added: [{ id: "Languages|french", label: "French", confidence: "Low", fieldChanges: [] }], updated: [{ id: "Languages|norwegian", label: "Norwegian", confidence: "Medium", fieldChanges: [{ field: "Level", oldValue: "A2", newValue: "B1" }] }], }], }; let runs: unknown[]; function deferred() { let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } function readCount(url: string) { return mockedApi.get.mock.calls.filter(([requested]) => requested === url).length; } function renderPage() { return render(); } async function ready() { await waitFor(() => expect(screen.getByLabelText(en.profileCvContactFullName)).toBeEnabled()); } function navigate(name: string) { fireEvent.click(screen.getByRole("tab", { name })); } function importView() { return within(document.getElementById("career-cv-import")!); } function historyView() { return within(screen.getByRole("region", { name: /Version history/ })); } async function failure(view: ReturnType, message: string) { await waitFor(() => expect(view.queryAllByRole("alert").filter((alert: HTMLElement) => alert.textContent?.includes(message))).toHaveLength(1)); return view.getAllByRole("alert").find((alert: HTMLElement) => alert.textContent?.includes(message))!; } async function importReady() { await ready(); await waitFor(() => expect(screen.getByRole("button", { name: en.profileCvApplyChanges })).toBeEnabled()); } function chooseLow() { return screen.getByRole("checkbox", { name: /French/ }); } function editDraft() { navigate(en.careerWorkspaceProfileTab); fireEvent.change(screen.getByLabelText(en.profileCvContactFullName), { target: { value: "Unsaved owner" } }); } beforeEach(() => { jest.resetAllMocks(); window.localStorage.clear(); window.history.replaceState({}, "", "/career?section=import"); runs = [run]; mockedApi.get.mockImplementation((url: string) => { if (url === "/career/profile") return Promise.resolve(source as any); if (url === "/auth/me") return Promise.resolve({ data: { id: "owner", provider: "local", email: "owner@example.test" } } as any); if (url === "/career/profile/versions") return Promise.resolve({ data: versions } as any); if (url === "/profile-cv/runs") return Promise.resolve({ data: runs } as any); if (url.endsWith("/diff")) return Promise.resolve({ data: { runId: Number(url.split("/")[3]), status: "pending_review", revision: 7, diff } } as any); return Promise.reject(new Error(`Unexpected GET ${url}`)); }); mockedApi.put.mockResolvedValue(source as any); mockedApi.post.mockResolvedValue(source as any); }); test("failed history reads have a scoped Retry that preserves unsaved source fields", async () => { const read = mockedApi.get.getMockImplementation()!; let failHistory = true; mockedApi.get.mockImplementation((url: string) => url === "/career/profile/versions" && failHistory ? Promise.reject(new Error("History unavailable")) : read(url)); renderPage(); await importReady(); editDraft(); const alert = await failure(historyView(), en.careerHistoryLoadFailed); expect(alert).toHaveTextContent(en.careerHistoryLoadFailed); const reads = readCount("/career/profile"); failHistory = false; fireEvent.click(within(alert).getByRole("button", { name: en.retry })); await waitFor(() => expect(readCount("/career/profile/versions")).toBe(2)); expect(screen.getByLabelText(en.profileCvContactFullName)).toHaveValue("Unsaved owner"); expect(readCount("/career/profile")).toBe(reads); expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled(); }); test("a dirty canonical profile cannot be replaced through history restore", async () => { renderPage(); await importReady(); editDraft(); fireEvent.click(screen.getByRole("button", { name: /Version history/ })); expect(screen.getByRole("button", { name: "Restore v1" })).toBeDisabled(); expect(screen.getByText(en.careerSaveBeforeRestore)).toBeVisible(); fireEvent.click(screen.getByRole("button", { name: "Restore v1" })); expect(mockedApi.post).not.toHaveBeenCalled(); expect(screen.getByLabelText(en.profileCvContactFullName)).toHaveValue("Unsaved owner"); }); test("each failed comparison can retry its own run without refetching canonical data or losing another comparison", async () => { runs = [run, { ...run, id: 42 }]; const read = mockedApi.get.getMockImplementation()!; let fail = true; mockedApi.get.mockImplementation((url: string) => url === "/profile-cv/runs/41/diff" && fail ? Promise.reject(new Error("Comparison unavailable")) : read(url)); renderPage(); await ready(); const error = await failure(importView(), en.careerImportComparisonFailed); expect(error).toHaveTextContent(en.careerImportComparisonFailed); expect(screen.getAllByRole("button", { name: en.profileCvApplyChanges }).filter((b) => !(b as HTMLButtonElement).disabled)).toHaveLength(1); fail = false; const retry = within(error).getByRole("button", { name: en.retry }); retry.focus(); fireEvent.click(retry); expect(screen.getByRole("article", { name: "File import 41" })).toHaveFocus(); await waitFor(() => expect(screen.getAllByRole("button", { name: en.profileCvApplyChanges }).every((b) => !(b as HTMLButtonElement).disabled)).toBe(true)); expect(readCount("/profile-cv/runs/41/diff")).toBe(2); expect(readCount("/profile-cv/runs/42/diff")).toBe(1); expect(readCount("/career/profile")).toBe(1); expect(mockedApi.post).not.toHaveBeenCalled(); }); test("review shows actual current and imported values and discloses fields outside the comparison", async () => { renderPage(); await importReady(); expect(screen.getByText(en.careerImportCurrentValue.replace("{value}", "A2"))).toBeVisible(); expect(screen.getByText(en.careerImportIncomingValue.replace("{value}", "B1"))).toBeVisible(); expect(screen.getByText(en.careerImportComparisonScope)).toBeVisible(); expect(screen.getByRole("heading", { name: en.profileCvExtractionHistory, level: 2 })).toBeVisible(); expect(mockedApi.post).not.toHaveBeenCalled(); }); test("selecting then deselecting low-confidence data sends the explicit empty selection", async () => { renderPage(); await importReady(); expect(chooseLow()).not.toBeChecked(); fireEvent.click(chooseLow()); expect(chooseLow()).toBeChecked(); fireEvent.click(chooseLow()); expect(chooseLow()).not.toBeChecked(); runs = [{ ...run, status: "applied" }]; fireEvent.click(screen.getByRole("button", { name: en.profileCvApplyChanges })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/profile-cv/runs/41/accept", { expectedVersion: 7, acceptedLowConfidenceIds: [] })); expect(mockedApi.post).toHaveBeenCalledTimes(1); }); test("held acceptance locks review choices and all canonical editors and prevents duplicate or overlapping writes", async () => { const held = deferred(); mockedApi.post.mockReturnValue(held.promise); renderPage(); await importReady(); fireEvent.click(chooseLow()); const apply = screen.getByRole("button", { name: en.profileCvApplyChanges }); apply.focus(); fireEvent.click(apply); fireEvent.click(apply); expect(screen.getByRole("article", { name: "File import 41" })).toHaveFocus(); expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.post).toHaveBeenCalledWith("/profile-cv/runs/41/accept", { expectedVersion: 7, acceptedLowConfidenceIds: ["Languages|french"] }); expect(chooseLow()).toBeDisabled(); expect(screen.getByRole("button", { name: en.careerImportApplying })).toBeDisabled(); navigate(en.careerWorkspaceProfileTab); expect(screen.getByLabelText(en.profileCvContactFullName)).toBeDisabled(); expect(screen.getByRole("button", { name: en.profileSaveChanges })).toBeDisabled(); fireEvent.click(screen.getByRole("button", { name: /Version history/ })); expect(screen.getByRole("button", { name: "Restore v1" })).toBeDisabled(); fireEvent.click(screen.getByRole("button", { name: en.profileSaveChanges })); expect(mockedApi.put).not.toHaveBeenCalled(); runs = [{ ...run, status: "applied" }]; await act(async () => held.resolve(source)); await waitFor(() => expect(screen.getByLabelText(en.profileCvContactFullName)).toBeEnabled()); expect(mockedApi.post).toHaveBeenCalledTimes(1); }); test("failed acceptance keeps the chosen stable IDs and shows durable translated recovery without automatically resending", async () => { mockedApi.post.mockRejectedValueOnce({ isAxiosError: true, message: "Request failed with status code 500", response: { status: 500, data: {} } }); renderPage(); await importReady(); fireEvent.click(chooseLow()); fireEvent.click(screen.getByRole("button", { name: en.profileCvApplyChanges })); const error = await failure(importView(), en.profileCvApplyChangesFailed); expect(error).toHaveTextContent(en.profileCvApplyChangesFailed); expect(chooseLow()).toBeChecked(); expect(chooseLow()).toBeEnabled(); await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" }))); await failure(importView(), nb.profileCvApplyChangesFailed); expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(readCount("/career/profile")).toBe(1); runs = [{ ...run, status: "applied" }]; fireEvent.click(screen.getByRole("button", { name: nb.profileCvApplyChanges })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(2)); expect(mockedApi.post.mock.calls[1]).toEqual(mockedApi.post.mock.calls[0]); }); test("discarding an import preserves unsaved structured and raw drafts and does not reload the canonical profile", async () => { renderPage(); await importReady(); editDraft(); navigate(en.careerWorkspaceImportTab); fireEvent.click(screen.getByRole("button", { name: new RegExp(en.profileCvRawPanelTitle) })); fireEvent.change(screen.getByLabelText(en.profileCvTextLabel), { target: { value: "Unsaved raw draft" } }); expect(screen.getByRole("button", { name: en.profileCvApplyChanges })).toBeDisabled(); runs = [{ ...run, status: "discarded" }]; fireEvent.click(screen.getByRole("button", { name: en.profileCvDiscard })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/profile-cv/runs/41/discard")); await waitFor(() => expect(readCount("/profile-cv/runs")).toBe(2)); expect(screen.getByLabelText(en.profileCvTextLabel)).toHaveValue("Unsaved raw draft"); navigate(en.careerWorkspaceProfileTab); expect(screen.getByLabelText(en.profileCvContactFullName)).toHaveValue("Unsaved owner"); expect(readCount("/career/profile")).toBe(1); expect(mockedApi.put).not.toHaveBeenCalled(); }); test("saving canonical changes refreshes history and comparisons and clears choices made against the earlier source", async () => { renderPage(); await importReady(); fireEvent.click(chooseLow()); editDraft(); fireEvent.click(screen.getByRole("button", { name: en.profileSaveChanges })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(1)); await waitFor(() => expect(readCount("/profile-cv/runs/41/diff")).toBe(2)); expect(readCount("/career/profile/versions")).toBe(2); expect(readCount("/career/profile")).toBe(1); navigate(en.careerWorkspaceImportTab); expect(chooseLow()).not.toBeChecked(); expect(mockedApi.post).not.toHaveBeenCalled(); }); test("a failed restore keeps the original source and known history with an explicit retry", async () => { mockedApi.post.mockRejectedValueOnce(new Error("Restore unavailable")); renderPage(); await importReady(); navigate(en.careerWorkspaceProfileTab); fireEvent.click(screen.getByRole("button", { name: /Version history/ })); fireEvent.click(screen.getByRole("button", { name: "Restore v1" })); const error = await failure(historyView(), en.careerRestoreFailed); expect(error).toHaveTextContent(en.careerRestoreFailed); expect(error).toHaveTextContent("Restore unavailable"); expect(screen.getByLabelText(en.profileCvContactFullName)).toHaveValue("Recorded owner"); expect(readCount("/career/profile")).toBe(1); expect(mockedApi.post).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByRole("button", { name: "Restore v1" })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(2)); expect(mockedApi.post.mock.calls[1]).toEqual(mockedApi.post.mock.calls[0]); }); test("an operation cancellation is guarded while pending and leaves an unsaved source draft intact", async () => { runs = [{ ...run, status: "queued", operation: { id: "op-81", status: "queued", canCancel: true, canRetry: false } }]; const held = deferred(); mockedApi.post.mockReturnValue(held.promise); renderPage(); await ready(); editDraft(); navigate(en.careerWorkspaceImportTab); const cancel = screen.getByRole("button", { name: en.profileCvCancelProcessing }); cancel.focus(); fireEvent.click(cancel); fireEvent.click(cancel); expect(screen.getByRole("article", { name: "File import 41" })).toHaveFocus(); expect(cancel).toBeDisabled(); expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.post).toHaveBeenCalledWith("/operations/op-81/cancel"); await act(async () => held.resolve({ data: {} })); navigate(en.careerWorkspaceProfileTab); expect(screen.getByLabelText(en.profileCvContactFullName)).toHaveValue("Unsaved owner"); expect(readCount("/career/profile")).toBe(1); expect(mockedApi.put).not.toHaveBeenCalled(); }); test("a failed operation retry stays scoped and only resends the same operation on explicit user retry", async () => { runs = [{ ...run, status: "failed", operation: { id: "op-81", status: "failed", canCancel: false, canRetry: true } }]; mockedApi.post.mockRejectedValueOnce(new Error("Retry unavailable")); renderPage(); await ready(); fireEvent.click(screen.getByRole("button", { name: en.profileCvRetryProcessing })); const error = await failure(importView(), en.profileCvRetryFailed); expect(error).toHaveTextContent(en.profileCvRetryFailed); expect(error).toHaveTextContent("Retry unavailable"); expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(readCount("/career/profile")).toBe(1); fireEvent.click(screen.getByRole("button", { name: en.profileCvRetryProcessing })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(2)); expect(mockedApi.post.mock.calls).toEqual([["/operations/op-81/retry"], ["/operations/op-81/retry"]]); }); test("Bokmål localizes comparison labels while preserving imported facts named like UI labels or object properties", async () => { localStorage.setItem("uiLanguage", "nb"); const read = mockedApi.get.getMockImplementation()!; mockedApi.get.mockImplementation((url: string) => url.endsWith("/diff") ? Promise.resolve({ data: { runId: 41, revision: 7, diff: { totalAdded: 2, totalUpdated: 0, totalLowConfidence: 1, hasChanges: true, categories: [ { category: "Projects", added: [{ id: "Projects|references", label: "References", confidence: "Medium", fieldChanges: [] }], updated: [], unchangedCount: 0, lowConfidenceCount: 0 }, { category: "Languages", added: [{ id: "Languages|constructor", label: "constructor", confidence: "Low", fieldChanges: [] }], updated: [], unchangedCount: 0, lowConfidenceCount: 1 }, ], } } } as any) : read(url)); renderPage(); await waitFor(() => expect(screen.getByRole("button", { name: nb.profileCvApplyChanges })).toBeEnabled()); expect(screen.getByText(`References · ${nb.careerImportAdded}`)).toBeVisible(); expect(screen.getByText(`constructor · ${nb.careerImportAdded}`)).toBeVisible(); expect(screen.getByRole("checkbox", { name: nb.profileCvIncludeLowConfidence.replace("{category}", nb.cvSectionLanguages).replace("{label}", "constructor") })).toBeEnabled(); expect(screen.getByRole("heading", { name: nb.cvSectionProjects })).toBeVisible(); expect(mockedApi.post).not.toHaveBeenCalled(); }); test("the known low-confidence contact placeholder is localized", async () => { localStorage.setItem("uiLanguage", "nb"); const read = mockedApi.get.getMockImplementation()!; mockedApi.get.mockImplementation((url: string) => url.endsWith("/diff") ? Promise.resolve({ data: { runId: 41, revision: 7, diff: { totalAdded: 0, totalUpdated: 1, totalLowConfidence: 1, hasChanges: true, categories: [ { category: "Contact", added: [], updated: [{ id: "Contact", label: "Contact details", confidence: "Low", fieldChanges: [{ field: "Email", oldValue: "current@example.test", newValue: "pending@example.test" }] }], unchangedCount: 0, lowConfidenceCount: 1 }, ], } } } as any) : read(url)); renderPage(); await waitFor(() => expect(screen.getByRole("button", { name: nb.profileCvApplyChanges })).toBeEnabled()); expect(screen.getByRole("checkbox", { name: nb.profileCvIncludeLowConfidence.replace("{category}", nb.careerProfileContactSection).replace("{label}", nb.careerImportContactDetails) })).toBeEnabled(); expect(screen.getByText(`${nb.careerImportContactDetails} · ${nb.careerImportUpdated}`)).toBeVisible(); expect(screen.getByText(nb.careerImportIncomingValue.replace("{value}", "pending@example.test"))).toBeVisible(); expect(mockedApi.post).not.toHaveBeenCalled(); });