feat(onboarding): migrate source-backed setup guidance
This commit is contained in:
@@ -1,36 +1,261 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-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", () => ({
|
||||
api: { get: jest.fn(), interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } } },
|
||||
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 renderChecklist() {
|
||||
return render(<MemoryRouter><I18nProvider><OnboardingChecklist hasJobs={false} /></I18nProvider></MemoryRouter>);
|
||||
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}`);
|
||||
}
|
||||
|
||||
test("uses structured career data and connected inbox status for onboarding progress", async () => {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === "/auth/me") return Promise.resolve({ data: { firstName: "Ada" } } as any);
|
||||
if (url === "/career/profile") return Promise.resolve({ data: { cvText: "", profile: { jobs: [{ title: "Engineer" }] } } } as any);
|
||||
return Promise.resolve({ data: { connected: url === "/gmail/status" } } as any);
|
||||
});
|
||||
function Destination() {
|
||||
return <output aria-label="Destination">{useLocation().pathname}</output>;
|
||||
}
|
||||
|
||||
renderChecklist();
|
||||
function Checklist({ hasJobs = false }: { hasJobs?: boolean | null }) {
|
||||
return <MemoryRouter><I18nProvider><OnboardingChecklist hasJobs={hasJobs} /><Destination /></I18nProvider></MemoryRouter>;
|
||||
}
|
||||
|
||||
expect(await screen.findByText("5 / 6")).toBeInTheDocument();
|
||||
expect(screen.getByRole("progressbar", { name: "5 of 6 done" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Add CV" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Open profile" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Connect email" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Add job" })).toBeInTheDocument();
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith("/career/profile"));
|
||||
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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user