262 lines
13 KiB
TypeScript
262 lines
13 KiB
TypeScript
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<typeof api>;
|
|
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 <output aria-label="Destination">{useLocation().pathname}</output>;
|
|
}
|
|
|
|
function Checklist({ hasJobs = false }: { hasJobs?: boolean | null }) {
|
|
return <MemoryRouter><I18nProvider><OnboardingChecklist hasJobs={hasJobs} /><Destination /></I18nProvider></MemoryRouter>;
|
|
}
|
|
|
|
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(<Checklist />);
|
|
|
|
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(<Checklist />);
|
|
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(<Checklist />);
|
|
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(<Checklist />);
|
|
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(<Checklist hasJobs={null} />);
|
|
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(<Checklist hasJobs={false} />);
|
|
expect(await screen.findByText("3 / 4")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: translations.en.onboardingStepJobAction })).toBeInTheDocument();
|
|
view.rerender(<Checklist hasJobs />);
|
|
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(<Checklist hasJobs />);
|
|
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(<Checklist />);
|
|
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(<Checklist />);
|
|
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(<Checklist />);
|
|
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(<Checklist />);
|
|
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(<Checklist />);
|
|
await act(async () => { await Promise.resolve(); });
|
|
expect(mockedApi.get).not.toHaveBeenCalled();
|
|
reloaded.unmount();
|
|
localStorage.setItem("authUserKey", "owner-b");
|
|
const second = render(<Checklist />);
|
|
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(<Checklist />);
|
|
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(<Checklist />);
|
|
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(<Checklist />);
|
|
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(<Checklist />);
|
|
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();
|
|
});
|