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; const mockedApi = api as jest.Mocked; 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( , ); } 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(); } 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(); });