250 lines
14 KiB
TypeScript
250 lines
14 KiB
TypeScript
import React from "react";
|
|
import "@testing-library/jest-dom";
|
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
|
|
import { cvBuilderApi } from "./cvBuilder";
|
|
import CareerWorkspaceOverview, { CareerWorkspaceImportRun } from "./views/career/CareerWorkspaceOverview";
|
|
import { I18nProvider } from "./i18n/I18nProvider";
|
|
import { translations } from "./i18n/translations";
|
|
import { api } from "./api";
|
|
import { ToastProvider } from "./toast";
|
|
import { ConfirmProvider } from "./confirm";
|
|
import CareerProfilePage from "./views/CareerProfilePage";
|
|
|
|
jest.mock("./cvBuilder", () => ({
|
|
cvBuilderApi: { list: jest.fn() },
|
|
}));
|
|
|
|
const mockedList = cvBuilderApi.list as jest.MockedFunction<typeof cvBuilderApi.list>;
|
|
const mockedApi = api as jest.Mocked<typeof api>;
|
|
const careerResponse = { profile: { version: "1", contact: { fullName: "Stored career name" }, skills: ["Stored C#"] }, completeness: { percent: 70, missing: ["Projects"], sections: [] }, cvText: "Stored raw text" };
|
|
|
|
function renderOverview({
|
|
percent = 0,
|
|
missing = [],
|
|
runs = [],
|
|
loading = false,
|
|
loadError = null,
|
|
runsLoading = false,
|
|
runsLoadError = null,
|
|
onRetryProfile,
|
|
onRetryRuns,
|
|
profileDirty = false,
|
|
}: {
|
|
percent?: number;
|
|
missing?: string[];
|
|
runs?: CareerWorkspaceImportRun[];
|
|
loading?: boolean;
|
|
loadError?: string | null;
|
|
runsLoading?: boolean;
|
|
runsLoadError?: string | null;
|
|
onRetryProfile?: () => void;
|
|
onRetryRuns?: () => void;
|
|
profileDirty?: boolean;
|
|
} = {}) {
|
|
return render(
|
|
<I18nProvider>
|
|
<CareerWorkspaceOverview
|
|
completeness={{ percent, missing }}
|
|
runs={runs}
|
|
loading={loading}
|
|
loadError={loadError}
|
|
runsLoading={runsLoading}
|
|
runsLoadError={runsLoadError}
|
|
onRetryProfile={onRetryProfile}
|
|
onRetryRuns={onRetryRuns}
|
|
profileDirty={profileDirty}
|
|
/>
|
|
</I18nProvider>,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
window.history.replaceState({}, "", "/career");
|
|
window.localStorage.removeItem("uiLanguage");
|
|
mockedList.mockReset().mockResolvedValue([]);
|
|
mockedApi.get.mockReset().mockImplementation((url: string) => {
|
|
if (url === "/career/profile") return Promise.resolve({ data: careerResponse } as any);
|
|
if (url === "/auth/me") return Promise.resolve({ data: { id: "career-owner", provider: "local", displayName: "Account name" } } as any);
|
|
if (["/profile-cv/runs", "/career/profile/versions", "/career/evidence"].includes(url)) return Promise.resolve({ data: [] } as any);
|
|
return Promise.reject(new Error(`Unexpected read: ${url}`));
|
|
});
|
|
for (const method of [mockedApi.post, mockedApi.put, mockedApi.patch, mockedApi.delete]) method.mockReset().mockRejectedValue(new Error("Unexpected write"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
window.localStorage.removeItem("uiLanguage");
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
test("first-run workspace presents concise profile, import, general CV and job-specific actions", async () => {
|
|
renderOverview();
|
|
|
|
expect(screen.getByRole("heading", { name: "Overview", level: 2 })).toBeInTheDocument();
|
|
expect(await screen.findByText(/start by adding career information manually or importing a cv/i)).toBeInTheDocument();
|
|
expect(screen.queryByText(/job-specific CVs stay separate and never overwrite/i)).not.toBeInTheDocument();
|
|
expect(screen.getByRole("link", { name: "Edit career profile" })).toHaveAttribute("href", "/career?section=profile");
|
|
expect(screen.getAllByRole("link", { name: "Import a CV" })[0]).toHaveAttribute("href", "/career?section=import");
|
|
expect(screen.getByRole("link", { name: "Create a general CV" })).toHaveAttribute("href", "/career/builder");
|
|
expect(screen.getByRole("link", { name: "Choose a job" })).toHaveAttribute("href", "/jobs");
|
|
expect(await screen.findByText("No CV documents yet.")).toBeInTheDocument();
|
|
});
|
|
|
|
test("career workspace overview and import state render in Bokmål", async () => {
|
|
window.localStorage.setItem("uiLanguage", "nb");
|
|
renderOverview({ runs: [{ id: 7, status: "pending_review" }] });
|
|
|
|
expect(screen.getByRole("heading", { name: "Oversikt", level: 2 })).toBeInTheDocument();
|
|
expect(screen.getAllByText("Se gjennom importert CV", { selector: "a" })).toHaveLength(2);
|
|
expect(screen.getByText("Én import venter på godkjenning.")).toBeInTheDocument();
|
|
expect(screen.getByRole("navigation", { name: "Handlinger i karriereområdet" })).toBeInTheDocument();
|
|
expect(await screen.findByText("Ingen CV-dokumenter ennå.")).toBeInTheDocument();
|
|
});
|
|
|
|
test("returning workspace shows missing profile information and recent general and job-specific CVs", async () => {
|
|
mockedList.mockResolvedValue([
|
|
{ id: 8, name: "Backend CV", themeId: "modern", publicSlug: "backend", isPublic: false, version: 2, jobApplicationId: 42, updatedAtUtc: "2026-08-08T10:00:00Z" },
|
|
{ id: 7, name: "General CV", themeId: "modern", publicSlug: "general", isPublic: false, version: 1, jobApplicationId: null, updatedAtUtc: "2026-08-07T10:00:00Z" },
|
|
]);
|
|
|
|
renderOverview({ percent: 70, missing: ["Projects", "Education", "Languages", "Interests"] });
|
|
|
|
expect(screen.getByText("70%")).toBeInTheDocument();
|
|
expect(screen.getByText("Add next: Projects, Education, Languages")).toBeInTheDocument();
|
|
expect(await screen.findByText("Backend CV")).toBeInTheDocument();
|
|
expect(screen.getAllByText("General CV").length).toBeGreaterThan(1);
|
|
expect(screen.getByText("Job-specific")).toBeInTheDocument();
|
|
expect(screen.getAllByRole("link", { name: /^Open$/ })[0]).toHaveAttribute("href", "/career/builder/8");
|
|
});
|
|
|
|
test.each([
|
|
["pending_review", "Review imported CV", /waiting for your approval/i],
|
|
["queued", "View CV processing", /is processing/i],
|
|
["failed", "Resolve CV import", /needs attention/i],
|
|
])("workspace exposes the %s import state as a resumable action", async (status, label, message) => {
|
|
renderOverview({ runs: [{ id: 13, status }] });
|
|
|
|
expect(screen.getAllByRole("link", { name: label })[0]).toHaveAttribute("href", "/career?section=import");
|
|
expect(screen.getByText(message)).toBeInTheDocument();
|
|
await waitFor(() => expect(mockedList).toHaveBeenCalledTimes(1));
|
|
});
|
|
|
|
test("workspace represents loading and profile-load errors without inventing status", async () => {
|
|
renderOverview({ loading: true, loadError: "offline" });
|
|
|
|
expect(screen.getByRole("progressbar", { name: "Career profile completeness" })).toBeInTheDocument();
|
|
expect(screen.getByText(/workspace status is unavailable/i)).toBeInTheDocument();
|
|
expect(screen.queryByText(/start by adding career information/i)).not.toBeInTheDocument();
|
|
await waitFor(() => expect(mockedList).toHaveBeenCalledTimes(1));
|
|
});
|
|
|
|
test("a settled failed profile read hides the completeness score and retries only through its existing owner", async () => {
|
|
const retry = jest.fn();
|
|
renderOverview({ percent: 70, loadError: "Profile unavailable", onRetryProfile: retry });
|
|
const region = screen.getByRole("region", { name: translations.en.careerOverviewProfileStatus });
|
|
expect(within(region).queryByRole("progressbar")).not.toBeInTheDocument();
|
|
expect(within(region).queryByText("70%")).not.toBeInTheDocument();
|
|
expect(within(region).getByRole("alert")).toHaveTextContent(translations.en.careerOverviewUnavailable);
|
|
fireEvent.click(within(region).getByRole("button", { name: translations.en.retry }));
|
|
expect(retry).toHaveBeenCalledTimes(1);
|
|
expect(mockedApi.put).not.toHaveBeenCalled();
|
|
await screen.findByText(translations.en.careerOverviewNoCvs);
|
|
});
|
|
|
|
test("an unknown import read cannot claim no imports, completion or first-run guidance", async () => {
|
|
renderOverview({ runsLoading: true });
|
|
const region = screen.getByRole("region", { name: translations.en.careerOverviewCvImport });
|
|
expect(within(region).getByRole("status")).toHaveTextContent(translations.en.loading);
|
|
expect(screen.queryByText(translations.en.careerOverviewNoImport)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(translations.en.careerOverviewImportComplete)).not.toBeInTheDocument();
|
|
await screen.findByText(translations.en.careerOverviewNoCvs);
|
|
expect(screen.queryByText(translations.en.careerOverviewFirstRun)).not.toBeInTheDocument();
|
|
});
|
|
|
|
test("a failed import read stays unknown and keeps its own retry separate from the profile read", async () => {
|
|
const retryRuns = jest.fn();
|
|
const retryProfile = jest.fn();
|
|
renderOverview({ runsLoadError: "Run read unavailable", onRetryRuns: retryRuns, onRetryProfile: retryProfile });
|
|
const region = screen.getByRole("region", { name: translations.en.careerOverviewCvImport });
|
|
expect(within(region).getByRole("alert")).toHaveTextContent(translations.en.careerOverviewImportUnavailable);
|
|
expect(screen.queryByText(translations.en.careerOverviewNoImport)).not.toBeInTheDocument();
|
|
fireEvent.click(within(region).getByRole("button", { name: translations.en.retry }));
|
|
expect(retryRuns).toHaveBeenCalledTimes(1);
|
|
expect(retryProfile).not.toHaveBeenCalled();
|
|
await screen.findByText(translations.en.careerOverviewNoCvs);
|
|
expect(screen.queryByText(translations.en.careerOverviewFirstRun)).not.toBeInTheDocument();
|
|
});
|
|
|
|
test("failed recent-CV reads do not imply first-run and retry the same list without writes", async () => {
|
|
mockedList.mockRejectedValueOnce(new Error("Recent list unavailable"));
|
|
renderOverview();
|
|
const region = screen.getByRole("region", { name: translations.en.careerOverviewRecentCvs });
|
|
expect(await within(region).findByRole("alert")).toHaveTextContent(translations.en.careerOverviewRecentFailed);
|
|
expect(screen.queryByText(translations.en.careerOverviewFirstRun)).not.toBeInTheDocument();
|
|
expect(within(region).queryByText(translations.en.careerOverviewNoCvs)).not.toBeInTheDocument();
|
|
fireEvent.click(within(region).getByRole("button", { name: translations.en.retry }));
|
|
expect(await within(region).findByText(translations.en.careerOverviewNoCvs)).toBeVisible();
|
|
expect(mockedList).toHaveBeenCalledTimes(2);
|
|
expect(mockedApi.post).not.toHaveBeenCalled();
|
|
expect(mockedApi.put).not.toHaveBeenCalled();
|
|
});
|
|
|
|
function renderCareer() {
|
|
return render(<ToastProvider><I18nProvider><ConfirmProvider><CareerProfilePage /></ConfirmProvider></I18nProvider></ToastProvider>);
|
|
}
|
|
|
|
test("the parent exposes failed import-read retry without canonical saves or history restores", async () => {
|
|
let fail = true;
|
|
const originalGet = mockedApi.get.getMockImplementation()!;
|
|
mockedApi.get.mockImplementation((url: string) => url === "/profile-cv/runs" && fail
|
|
? Promise.reject(new Error("Run read unavailable")) : originalGet(url));
|
|
renderCareer();
|
|
const region = screen.getByRole("region", { name: translations.en.careerOverviewCvImport });
|
|
expect(await within(region).findByRole("alert")).toHaveTextContent(translations.en.careerOverviewImportUnavailable);
|
|
const careerReads = mockedApi.get.mock.calls.filter(([url]) => url === "/career/profile").length;
|
|
fail = false;
|
|
fireEvent.click(within(region).getByRole("button", { name: translations.en.retry }));
|
|
expect(await within(region).findByText(translations.en.careerOverviewNoImport)).toBeVisible();
|
|
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/profile-cv/runs")).toHaveLength(2);
|
|
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/career/profile")).toHaveLength(careerReads);
|
|
expect(mockedApi.post).not.toHaveBeenCalled();
|
|
expect(mockedApi.put).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("changing language in overview cannot refetch or overwrite a dirty canonical draft", async () => {
|
|
renderCareer();
|
|
await screen.findByText("70%");
|
|
fireEvent.click(screen.getByRole("tab", { name: translations.en.careerWorkspaceProfileTab }));
|
|
fireEvent.change(screen.getByLabelText("Full name"), { target: { value: "Unsaved canonical name" } });
|
|
fireEvent.click(screen.getByRole("tab", { name: translations.en.careerWorkspaceOverviewTab }));
|
|
const reads = mockedApi.get.mock.calls.length;
|
|
act(() => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })));
|
|
const region = await screen.findByRole("region", { name: translations.nb.careerOverviewProfileStatus });
|
|
expect(within(region).queryByRole("alert")).not.toBeInTheDocument();
|
|
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
|
fireEvent.click(screen.getByRole("tab", { name: translations.nb.careerWorkspaceProfileTab }));
|
|
expect(screen.getByLabelText("Fullt navn")).toHaveValue("Unsaved canonical name");
|
|
expect(mockedApi.put).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("visited evidence keeps its draft across sections without an unvisited or repeated list request", async () => {
|
|
renderCareer();
|
|
await screen.findByText("70%");
|
|
expect(mockedApi.get).not.toHaveBeenCalledWith("/career/evidence");
|
|
fireEvent.click(screen.getByRole("tab", { name: translations.en.careerWorkspaceEvidenceTab }));
|
|
await screen.findByText(translations.en.careerEvidenceEmpty);
|
|
fireEvent.change(screen.getByLabelText(translations.en.careerEvidenceItemTitle, { exact: false }), { target: { value: "Unsaved evidence title" } });
|
|
fireEvent.change(screen.getByLabelText(translations.en.careerEvidenceStatement, { exact: false }), { target: { value: "Unsaved factual statement" } });
|
|
fireEvent.change(screen.getByLabelText(translations.en.careerEvidenceTags), { target: { value: "C#, reliability" } });
|
|
fireEvent.click(screen.getByRole("tab", { name: translations.en.careerWorkspaceProfileTab }));
|
|
expect(screen.queryByRole("region", { name: translations.en.careerEvidenceTitle })).not.toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole("tab", { name: translations.en.careerWorkspaceEvidenceTab }));
|
|
expect(screen.getByLabelText(translations.en.careerEvidenceItemTitle, { exact: false })).toHaveValue("Unsaved evidence title");
|
|
expect(screen.getByLabelText(translations.en.careerEvidenceStatement, { exact: false })).toHaveValue("Unsaved factual statement");
|
|
expect(screen.getByLabelText(translations.en.careerEvidenceTags)).toHaveValue("C#, reliability");
|
|
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/career/evidence")).toHaveLength(1);
|
|
expect(mockedApi.post).not.toHaveBeenCalled();
|
|
expect(mockedApi.put).not.toHaveBeenCalled();
|
|
});
|