import React from "react"; import "@testing-library/jest-dom"; import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { MemoryRouter, useLocation } from "react-router-dom"; import OnboardingChecklist from "./components/OnboardingChecklist"; import { I18nProvider } from "./i18n/I18nProvider"; import { translations } from "./i18n/translations"; import { api } from "./api"; jest.mock("./api", () => ({ getApiErrorMessage: jest.requireActual("./api").getApiErrorMessage, api: { get: jest.fn(), post: jest.fn(), put: jest.fn(), patch: jest.fn(), delete: jest.fn(), interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, }, })); const mockedApi = api as jest.Mocked; const sources = ["/auth/me", "/career/profile", "/gmail/status", "/microsoft-graph/status", "/imap/status"]; let account: any; let career: any; let connectedProvider: string | null; function sourceResponse(url: string) { if (url === "/auth/me") return { data: account }; if (url === "/career/profile") return { data: career }; if (sources.includes(url)) return { data: { connected: url === connectedProvider } }; throw new Error(`Unexpected read: ${url}`); } function Destination() { return {useLocation().pathname}; } function Checklist({ hasJobs = false }: { hasJobs?: boolean | null }) { return ; } function expectNoWrites() { for (const method of [mockedApi.post, mockedApi.put, mockedApi.patch, mockedApi.delete]) expect(method).not.toHaveBeenCalled(); } beforeEach(() => { jest.resetAllMocks(); window.localStorage.clear(); window.localStorage.setItem("authUserKey", "owner-a"); account = { firstName: "Ada" }; career = { cvText: "", profile: { jobs: [{ title: "Engineer" }] } }; connectedProvider = "/gmail/status"; mockedApi.get.mockImplementation((url: string) => Promise.resolve(sourceResponse(url) as any)); }); afterEach(() => { jest.restoreAllMocks(); window.localStorage.clear(); }); test("uses structured career data and connected inbox status for four source-backed onboarding steps", async () => { render(); expect(await screen.findByText("3 / 4")).toBeInTheDocument(); expect(screen.getByRole("progressbar", { name: "3 of 4 done" })).toHaveAttribute("aria-valuenow", "75"); const region = screen.getByRole("region", { name: translations.en.onboardingTitle }); expect(within(region).getByRole("heading", { name: translations.en.onboardingTitle, level: 2 })).toBeInTheDocument(); expect(within(region).getAllByRole("listitem")).toHaveLength(4); expect(screen.queryByText(translations.en.onboardingStepSignup)).not.toBeInTheDocument(); expect(screen.queryByText(translations.en.onboardingStepVerify)).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: translations.en.onboardingStepCvAction })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: translations.en.onboardingStepProfileAction })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: translations.en.onboardingStepEmailAction })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: translations.en.onboardingStepJobAction })).toBeInTheDocument(); expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(sources); expectNoWrites(); }); test.each(["/microsoft-graph/status", "/imap/status"])("a stored %s connection counts without connecting or syncing", async (provider) => { connectedProvider = provider; render(); expect(await screen.findByText("3 / 4")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: translations.en.onboardingStepEmailAction })).not.toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledTimes(5); expectNoWrites(); }); test.each([ ["manual skills", { cvText: "", profile: { skills: ["SQL"] } }, true], ["raw career text", { cvText: " Existing resume text ", profile: {} }, true], ["contact only", { cvText: " ", profile: { contact: { fullName: "Career Name" } } }, false], ])("%s preserves the existing career-content criterion without claiming a CV import", async (_name, data, done) => { career = data; render(); expect(await screen.findByText(done ? "3 / 4" : "2 / 4")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: translations.en.onboardingStepCvAction }) !== null).toBe(!done); expect(mockedApi.get.mock.calls.some(([url]) => String(url).includes("cv-variant") || String(url).includes("profile-cv"))).toBe(false); expectNoWrites(); }); test("career names and account email alone do not count as account identity details", async () => { account = { email: "owner@example.test", firstName: " ", lastName: "", displayName: " " }; career = { cvText: "", profile: { contact: { fullName: "Career Name" }, skills: ["SQL"] } }; render(); expect(await screen.findByText("2 / 4")).toBeInTheDocument(); expect(screen.getByRole("button", { name: translations.en.onboardingStepProfileAction })).toBeInTheDocument(); expectNoWrites(); }); test("unknown job summary has indeterminate progress, then responds to known jobs without refetching sources", async () => { const view = render(); const unknown = await screen.findByText(translations.en.onboardingJobStatusUnknown); expect(unknown).toHaveAttribute("role", "status"); expect(screen.getByRole("progressbar")).not.toHaveAttribute("aria-valuenow"); expect(screen.queryByText(/\d \/ 4/)).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: translations.en.onboardingStepJobAction })).not.toBeInTheDocument(); view.rerender(); expect(await screen.findByText("3 / 4")).toBeInTheDocument(); expect(screen.getByRole("button", { name: translations.en.onboardingStepJobAction })).toBeInTheDocument(); view.rerender(); expect(screen.queryByRole("region", { name: translations.en.onboardingTitle })).not.toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledTimes(5); expectNoWrites(); }); test("all four recorded steps hide the optional checklist after the sources settle", async () => { render(); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(screen.queryByRole("region", { name: translations.en.onboardingTitle })).not.toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledTimes(5); expectNoWrites(); }); test.each(sources)("failed %s status stays unknown and retries only the same source reads", async (failedSource) => { let rejectFirst = true; mockedApi.get.mockImplementation((url: string) => url === failedSource && rejectFirst ? Promise.reject({ response: { status: 503, data: { detail: "Setup source unavailable." } } }) : Promise.resolve(sourceResponse(url) as any)); render(); const alert = await screen.findByRole("alert"); expect(alert).toHaveTextContent("Setup source unavailable."); expect(screen.queryByText(/\d \/ 4/)).not.toBeInTheDocument(); expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: translations.en.onboardingStepEmailAction })).not.toBeInTheDocument(); expectNoWrites(); rejectFirst = false; const retry = within(alert).getByRole("button", { name: translations.en.retry }); retry.focus(); fireEvent.click(retry); expect(await screen.findByText("3 / 4")).toBeInTheDocument(); expect(screen.getByRole("button", { name: translations.en.onboardingDismiss })).toHaveFocus(); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual([...sources, ...sources]); expectNoWrites(); }); test.each([ ["onboardingStepProfileAction", "/profile"], ["onboardingStepCvAction", "/career"], ["onboardingStepEmailAction", "/settings/connected-accounts"], ["onboardingStepJobAction", "/jobs"], ] as const)("%s keeps its source-owned destination", async (key, destination) => { account = {}; career = { cvText: "", profile: {} }; connectedProvider = null; render(); await screen.findByText("0 / 4"); fireEvent.click(screen.getByRole("button", { name: translations.en[key] })); expect(screen.getByLabelText("Destination")).toHaveTextContent(destination); expectNoWrites(); }); test("a legacy owner dismissal skips all reads on mount", async () => { localStorage.setItem("onboardingDismissed:owner-a", "true"); render(); await act(async () => { await Promise.resolve(); }); expect(screen.queryByRole("region", { name: translations.en.onboardingTitle })).not.toBeInTheDocument(); expect(mockedApi.get).not.toHaveBeenCalled(); expectNoWrites(); }); test("dismissal survives reload for its owner and stays separate from another owner's checklist and Dashboard flags", async () => { const flags = JSON.stringify({ cards: false, activity: true, funnel: true, companies: true, skills: true }); localStorage.setItem("dashboardPrefs:owner-a", flags); const first = render(); await screen.findByText("3 / 4"); fireEvent.click(screen.getByRole("button", { name: translations.en.onboardingDismiss })); expect(screen.queryByRole("region", { name: translations.en.onboardingTitle })).not.toBeInTheDocument(); expect(localStorage.getItem("onboardingDismissed:owner-a")).toBe("true"); expect(localStorage.getItem("dashboardPrefs:owner-a")).toBe(flags); first.unmount(); mockedApi.get.mockClear(); const reloaded = render(); await act(async () => { await Promise.resolve(); }); expect(mockedApi.get).not.toHaveBeenCalled(); reloaded.unmount(); localStorage.setItem("authUserKey", "owner-b"); const second = render(); expect(await screen.findByText("3 / 4")).toBeInTheDocument(); expect(localStorage.getItem("onboardingDismissed:owner-b")).toBeNull(); second.unmount(); mockedApi.get.mockClear(); localStorage.setItem("authUserKey", "owner-a"); render(); await act(async () => { await Promise.resolve(); }); expect(mockedApi.get).not.toHaveBeenCalled(); expect(screen.queryByRole("region", { name: translations.en.onboardingTitle })).not.toBeInTheDocument(); expectNoWrites(); }); test("unavailable dismissal storage still permits setup and an in-memory dismissal", async () => { const getItem = Storage.prototype.getItem; const setItem = Storage.prototype.setItem; jest.spyOn(Storage.prototype, "getItem").mockImplementation(function (this: Storage, key: string) { if (key.startsWith("onboardingDismissed:")) throw new Error("Storage unavailable"); return getItem.call(this, key); }); jest.spyOn(Storage.prototype, "setItem").mockImplementation(function (this: Storage, key: string, value: string) { if (key.startsWith("onboardingDismissed:")) throw new Error("Storage unavailable"); return setItem.call(this, key, value); }); render(); expect(await screen.findByText("3 / 4")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: translations.en.onboardingDismiss })); expect(screen.queryByRole("region", { name: translations.en.onboardingTitle })).not.toBeInTheDocument(); expectNoWrites(); }); test("loading is announced without guessed progress and dismissal prevents late failure from reopening the checklist", async () => { let rejectCareer!: (reason: unknown) => void; mockedApi.get.mockImplementation((url: string) => url === "/career/profile" ? new Promise((_resolve, reject) => { rejectCareer = reject; }) : Promise.resolve(sourceResponse(url) as any)); render(); const region = screen.getByRole("region", { name: translations.en.onboardingTitle }); expect(within(region).getByRole("status")).toHaveTextContent(translations.en.loading); expect(screen.getByRole("progressbar")).not.toHaveAttribute("aria-valuenow"); expect(screen.queryByText(/\d \/ 4/)).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: translations.en.onboardingDismiss })); await act(async () => rejectCareer(new Error("Late failure"))); expect(screen.queryByRole("region", { name: translations.en.onboardingTitle })).not.toBeInTheDocument(); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledTimes(5); expectNoWrites(); }); test("Bokmål guidance keeps semantic list and progress names without additional source reads", async () => { localStorage.setItem("uiLanguage", "nb"); render(); expect(await screen.findByText("3 / 4")).toBeInTheDocument(); const region = screen.getByRole("region", { name: translations.nb.onboardingTitle }); expect(within(region).getByRole("heading", { name: translations.nb.onboardingTitle, level: 2 })).toBeInTheDocument(); expect(within(region).getAllByRole("listitem")).toHaveLength(4); expect(screen.getByRole("progressbar", { name: translations.nb.checklistProgress.replace("{completed}", "3").replace("{total}", "4") })).toHaveAttribute("aria-valuenow", "75"); expect(within(region).getByText(translations.nb.onboardingStepProfile)).toBeInTheDocument(); expect(within(region).getByText(translations.nb.onboardingStepCv)).toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledTimes(5); expectNoWrites(); });