274 lines
18 KiB
TypeScript
274 lines
18 KiB
TypeScript
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<typeof api>;
|
|
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<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason: unknown) => void;
|
|
const promise = new Promise<T>((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(<ToastProvider><I18nProvider><CareerProfilePage /></I18nProvider></ToastProvider>); }
|
|
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<typeof within>, 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<any>(); 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<any>(); 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();
|
|
});
|