import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { ImpersonationBanner } from "../portal/ImpersonationBanner.js"; import { AuditLogViewer } from "../portal/AuditLogViewer.js"; import type { ImpersonationSession, ImpersonationAuditLog } from "@groombook/types"; // Spy on the RescheduleFlow so we can assert the sessionId prop it receives // from CustomerPortal without rendering the full flow UI. The real module is // still loaded via importActual; only RescheduleFlow is swapped. const rescheduleFlowSpy = vi.hoisted(() => vi.fn((_props: { sessionId: string | null; appointment: { id: string } }) => null) ); vi.mock("../portal/sections/Appointments.js", async () => { const actual = await vi.importActual( "../portal/sections/Appointments.js" ); return { ...actual, RescheduleFlow: rescheduleFlowSpy, }; }); // Spy on the canonical `signOut()` from the shared auth-client so we can // assert the no-access screen's logout button uses the SAME handler as // `AdminLayout`. We mock at the module boundary — the no-access screen is // the one authenticated surface that renders without the portal chrome, so // a regression here would trap the user. We do NOT use `importActual` // because the real `createAuthClient()` requires a runtime `baseURL` // (Better Auth) that the JSDOM test environment can't supply. const signOutSpy = vi.hoisted(() => vi.fn(async () => undefined)); vi.mock("../lib/auth-client.js", () => ({ signOut: signOutSpy, })); const SESSION: ImpersonationSession = { id: "sess-1", staffId: "staff-1", clientId: "client-1", reason: "Customer reported missing appointment", status: "active", startedAt: new Date(Date.now() - 5 * 60_000).toISOString(), endedAt: null, expiresAt: new Date(Date.now() + 25 * 60_000).toISOString(), createdAt: new Date(Date.now() - 5 * 60_000).toISOString(), }; const AUDIT_LOGS: ImpersonationAuditLog[] = [ { id: "log-1", sessionId: "sess-1", action: "session_started", pageVisited: null, metadata: { reason: "Customer reported missing appointment" }, createdAt: new Date(Date.now() - 5 * 60_000).toISOString(), }, { id: "log-2", sessionId: "sess-1", action: "page_view", pageVisited: "appointments", metadata: null, createdAt: new Date(Date.now() - 3 * 60_000).toISOString(), }, ]; // ─── Shared test fixtures ─────────────────────────────────────────────────── // `brandingResponse` is the mock /api/branding payload used by every test // in this file. Hoisted to module scope so the SSO bridge and the OOBE // describe blocks can both reach it without redefining the same body. const brandingResponse = { ok: true, json: async () => ({ businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, }), } as Response; // ─── ImpersonationBanner ──────────────────────────────────────────────────── describe("ImpersonationBanner", () => { it("renders STAFF VIEW label", () => { render( ); expect(screen.getByText("STAFF VIEW")).toBeInTheDocument(); }); it("displays the session reason", () => { render( ); expect(screen.getByText(/Customer reported missing appointment/)).toBeInTheDocument(); }); it("calls onEnd when End Session is clicked", () => { const onEnd = vi.fn(); render( ); fireEvent.click(screen.getByRole("button", { name: /End Session/i })); expect(onEnd).toHaveBeenCalledOnce(); }); it("calls onShowAudit when Audit is clicked", () => { const onShowAudit = vi.fn(); render( ); fireEvent.click(screen.getByRole("button", { name: /Audit/i })); expect(onShowAudit).toHaveBeenCalledOnce(); }); it("shows Extend button when less than 5 minutes remain and not yet extended", async () => { const nearlyExpiredSession: ImpersonationSession = { ...SESSION, expiresAt: new Date(Date.now() + 3 * 60_000).toISOString(), // 3 min left }; render( ); await waitFor(() => { expect(screen.getByRole("button", { name: /Extend/i })).toBeInTheDocument(); }); }); it("does not show Extend button when already extended", async () => { const nearlyExpiredSession: ImpersonationSession = { ...SESSION, expiresAt: new Date(Date.now() + 3 * 60_000).toISOString(), }; render( ); await waitFor(() => { expect(screen.queryByRole("button", { name: /Extend/i })).not.toBeInTheDocument(); }); }); }); // ─── AuditLogViewer ───────────────────────────────────────────────────────── describe("AuditLogViewer", () => { beforeEach(() => { global.fetch = vi.fn(); }); it("fetches and displays audit log entries", async () => { vi.mocked(global.fetch).mockResolvedValue({ ok: true, json: async () => [...AUDIT_LOGS].reverse(), // API returns newest-first } as Response); render(); await waitFor(() => { // "session started" appears in both the filter dropdown option and the log entry span expect(screen.getAllByText("session started").length).toBeGreaterThanOrEqual(1); }); expect(screen.getByText("appointments")).toBeInTheDocument(); expect(global.fetch).toHaveBeenCalledWith("/api/impersonation/sessions/sess-1/audit-log"); }); it("shows error state when fetch fails", async () => { vi.mocked(global.fetch).mockResolvedValue({ ok: false, status: 403, } as Response); render(); await waitFor(() => { expect(screen.getByText(/Failed to load audit log/i)).toBeInTheDocument(); }); }); it("shows loading state initially", () => { vi.mocked(global.fetch).mockReturnValue(new Promise(() => {})); render(); expect(screen.getByText(/Loading audit log/i)).toBeInTheDocument(); }); it("calls onClose when X button is clicked", async () => { vi.mocked(global.fetch).mockResolvedValue({ ok: true, json: async () => [], } as Response); const onClose = vi.fn(); render(); await waitFor(() => { expect(screen.getByText(/No audit entries/i)).toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: "" })); expect(onClose).toHaveBeenCalledOnce(); }); it("filters entries by action type", async () => { vi.mocked(global.fetch).mockResolvedValue({ ok: true, json: async () => [...AUDIT_LOGS].reverse(), } as Response); render(); await waitFor(() => { expect(screen.getAllByText("session started").length).toBeGreaterThanOrEqual(1); }); // Filter to page_view only const select = screen.getByRole("combobox"); fireEvent.change(select, { target: { value: "page_view" } }); expect(screen.getByText("appointments")).toBeInTheDocument(); // After filtering, the "session started" span (log entry) should be gone // The option in the select still has the text but the log entry span does not const spans = document.querySelectorAll("span.inline-block"); expect(Array.from(spans).every((s) => s.textContent !== "session started")).toBe(true); }); }); // ─── CustomerPortal — session loading ────────────────────────────────────── describe("CustomerPortal session loading", () => { beforeEach(() => { global.fetch = vi.fn((url: string) => { if (url === "/api/branding") { return Promise.resolve({ ok: true, json: async () => ({ businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, }), } as Response); } if (url.startsWith("/api/impersonation/sessions/")) { return Promise.resolve({ ok: true, json: async () => SESSION, } as Response); } return Promise.resolve({ ok: true, json: async () => [] } as Response); }) as unknown as typeof fetch; }); it("loads and displays impersonation banner when sessionId is in URL", async () => { const { CustomerPortal } = await import("../portal/CustomerPortal.js"); render( ); // Wait for the session fetch and banner to appear await waitFor(() => { expect(global.fetch).toHaveBeenCalledWith("/api/impersonation/sessions/sess-1"); }); // Banner "End Session" button is unique to the active impersonation banner await waitFor(() => { expect(screen.getByRole("button", { name: /End Session/i })).toBeInTheDocument(); }); }); it("does not show banner when no sessionId in URL", async () => { vi.mocked(global.fetch).mockClear(); const { CustomerPortal } = await import("../portal/CustomerPortal.js"); render( ); // No impersonation session fetch should happen await new Promise((r) => setTimeout(r, 50)); const impersonationFetches = vi.mocked(global.fetch).mock.calls.filter( ([url]) => typeof url === "string" && url.startsWith("/api/impersonation/") ); expect(impersonationFetches).toHaveLength(0); expect(screen.queryByRole("button", { name: /End Session/i })).not.toBeInTheDocument(); }); it("redirects to /admin/clients after ending impersonation session", async () => { // Mock window.location.href const originalLocation = window.location; Object.defineProperty(window, "location", { value: { href: "" }, writable: true, }); const { CustomerPortal } = await import("../portal/CustomerPortal.js"); render( ); // Wait for banner to appear await waitFor(() => { expect(screen.getByRole("button", { name: /End Session/i })).toBeInTheDocument(); }); // Click "End Session" — this triggers handleEnd which calls the API then redirects fireEvent.click(screen.getByRole("button", { name: /End Session/i })); await waitFor(() => { expect(window.location.href).toBe("/admin/clients"); }); // Restore Object.defineProperty(window, "location", { value: originalLocation, writable: true }); }); }); // ─── CustomerPortal — Better Auth SSO bridge (GRO-1867) ──────────────────── describe("CustomerPortal SSO bridge", () => { beforeEach(() => { // Make sure no dev-user leaks across tests window.localStorage.clear(); // Reset shared signOut() spy so per-test counts are deterministic signOutSpy.mockClear(); }); it("bridges Better Auth session via /api/portal/session-from-auth and uses returned sessionId", async () => { global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => { const url = typeof input === "string" ? input : input.toString(); if (url === "/api/branding") return Promise.resolve(brandingResponse); if (url === "/api/auth/get-session") { return Promise.resolve({ ok: true, json: async () => ({ user: { email: "customer@example.com", role: "customer" } }), } as Response); } if (url === "/api/portal/session-from-auth" && init?.method === "POST") { return Promise.resolve({ ok: true, status: 201, json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }), } as Response); } // Subsequent portal API calls — surface them so we can assert the header return Promise.resolve({ ok: true, json: async () => ({}) } as Response); }) as unknown as typeof fetch; const { CustomerPortal } = await import("../portal/CustomerPortal.js"); render( ); await waitFor(() => { expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" })); }); await waitFor(() => { expect(global.fetch).toHaveBeenCalledWith( "/api/portal/session-from-auth", expect.objectContaining({ method: "POST", credentials: "include" }) ); }); // Client greeting reflects the bridged customer name (proof the response was consumed) await waitFor(() => { expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument(); }); // The impersonation banner must NOT appear — this is the customer themselves expect(screen.queryByRole("button", { name: /End Session/i })).not.toBeInTheDocument(); }); it("routes to /onboarding when session-from-auth returns 404 (GRO-2359)", async () => { // GRO-2359 replaces the P1 no-access fallback for the new-user path. // The post-auth handler must now navigate to /onboarding so the OOBE // component can drive portal creation. The no-access card itself is // reserved for the deep-link deleted-portal case (see the next two // tests, which exercise ?noAccess=deleted-portal). global.fetch = vi.fn((input: RequestInfo) => { const url = typeof input === "string" ? input : input.toString(); if (url === "/api/branding") return Promise.resolve(brandingResponse); if (url === "/api/auth/get-session") { return Promise.resolve({ ok: true, json: async () => ({ user: { email: "stranger@example.com", name: "Stranger", role: "customer" }, }), } as Response); } if (url === "/api/portal/session-from-auth") { return Promise.resolve({ ok: false, status: 404, json: async () => ({ error: "No client record found for this user" }), } as Response); } return Promise.resolve({ ok: true, json: async () => ({}) } as Response); }) as unknown as typeof fetch; // MemoryRouter is required for the React Router context used by // useNavigate inside CustomerPortal. We pass `initialEntries=["/"]` // and let the post-auth handler navigate the router to /onboarding. const { CustomerPortal } = await import("../portal/CustomerPortal.js"); render( ); // The bridge 404 must NOT render the legacy no-access card. The OOBE // form is the new-user surface. await waitFor(() => { expect(screen.getByText(/set up your portal/i)).toBeInTheDocument(); }); expect(screen.queryByText(/Portal access not configured/i)).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: /Create my portal/i })).toBeInTheDocument(); }); it("calls the shared signOut() handler and navigates to /login from the no-access screen (GRO-2358)", async () => { // Reset the spy so previous tests don't leak into this assertion. signOutSpy.mockClear(); // JSDOM throws on window.location.href assignment by default; swap in a // writable stub so the navigation is observable, then restore after. const originalLocation = window.location; Object.defineProperty(window, "location", { value: { href: "" }, writable: true, configurable: true, }); // GRO-2359: the post-auth bridge 404 now routes to /onboarding (OOBE) // on the new-user path. The no-access card itself is reserved for the // deep-link deleted-portal case, which is signalled via // ?noAccess=deleted-portal. A server-side "client disabled" check // (future GRO) is the natural trigger. global.fetch = vi.fn((input: RequestInfo) => { const url = typeof input === "string" ? input : input.toString(); if (url === "/api/branding") return Promise.resolve(brandingResponse); if (url === "/api/auth/get-session") { return Promise.resolve({ ok: true, json: async () => ({ user: { email: "stranger@example.com", role: "customer" } }), } as Response); } // The bridge must NOT succeed (so portalSessionId stays null) and must // NOT be 404 (which would route to /onboarding). A 500 models a // server-side portal-disabled check; the no-access card is mounted // because of the URL param, not because of the bridge. if (url === "/api/portal/session-from-auth") { return Promise.resolve({ ok: false, status: 500, json: async () => ({ error: "Portal disabled" }), } as Response); } return Promise.resolve({ ok: true, json: async () => ({}) } as Response); }) as unknown as typeof fetch; const { CustomerPortal } = await import("../portal/CustomerPortal.js"); render( ); await waitFor(() => { expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument(); }); // Pre-condition: the shared signOut() must NOT have been called yet — the // no-access screen is mounted because of the deleted-portal signal, not // because the user clicked anything. expect(signOutSpy).not.toHaveBeenCalled(); // Drive the click. The handler is the SAME `signOut()` exported from // auth-client that AdminLayout uses, so verifying this call is enough to // prove the no-access screen reaches the canonical sign-out surface. const signOutButton = screen.getByRole("button", { name: /Sign out/i }); fireEvent.click(signOutButton); await waitFor(() => { expect(signOutSpy).toHaveBeenCalledTimes(1); }); // The handler always navigates to /login — even if the network call to // /api/auth/sign-out fails — so a transient auth-server hiccup never // leaves the user trapped on an authenticated screen. await waitFor(() => { expect(window.location.href).toBe("/login"); }); Object.defineProperty(window, "location", { value: originalLocation, configurable: true }); }); it("reaches the same shared signOut() on a deep-link no-access screen (GRO-2358)", async () => { // AC requires verifying the SAME logout handler is reachable from at // least one other authenticated surface — here a deep link to a portal // sub-route (e.g. /appointments) for a user with a Better Auth session // whose portal was deleted. The no-access screen is the only // authenticated surface without a route guard, so the handler must // fire identically. // // GRO-2359: the bridge 404 now routes to /onboarding (OOBE) on the // new-user path; ?noAccess=deleted-portal is the surviving trigger. signOutSpy.mockClear(); const originalLocation = window.location; Object.defineProperty(window, "location", { value: { href: "" }, writable: true, configurable: true, }); global.fetch = vi.fn((input: RequestInfo) => { const url = typeof input === "string" ? input : input.toString(); if (url === "/api/branding") return Promise.resolve(brandingResponse); if (url === "/api/auth/get-session") { return Promise.resolve({ ok: true, json: async () => ({ user: { email: "stranger@example.com", role: "customer" } }), } as Response); } // The bridge must NOT succeed (so portalSessionId stays null) and must // NOT be 404 (which would route to /onboarding). A 500 models a // server-side portal-disabled check; the no-access card is mounted // because of the URL param, not because of the bridge. if (url === "/api/portal/session-from-auth") { return Promise.resolve({ ok: false, status: 500, json: async () => ({ error: "Portal disabled" }), } as Response); } return Promise.resolve({ ok: true, json: async () => ({}) } as Response); }) as unknown as typeof fetch; const { CustomerPortal } = await import("../portal/CustomerPortal.js"); render( ); await waitFor(() => { expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument(); }); const signOutButton = screen.getByRole("button", { name: /Sign out/i }); fireEvent.click(signOutButton); await waitFor(() => { expect(signOutSpy).toHaveBeenCalledTimes(1); }); await waitFor(() => { expect(window.location.href).toBe("/login"); }); Object.defineProperty(window, "location", { value: originalLocation, configurable: true }); }); it("does not call session-from-auth when there is no Better Auth session", async () => { global.fetch = vi.fn((input: RequestInfo) => { const url = typeof input === "string" ? input : input.toString(); if (url === "/api/branding") return Promise.resolve(brandingResponse); if (url === "/api/auth/get-session") { return Promise.resolve({ ok: true, json: async () => null, } as Response); } return Promise.resolve({ ok: true, json: async () => ({}) } as Response); }) as unknown as typeof fetch; const { CustomerPortal } = await import("../portal/CustomerPortal.js"); render( ); await waitFor(() => { expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" })); }); // Wait one tick to ensure no subsequent bridge call is queued await new Promise((r) => setTimeout(r, 30)); const bridgeCalls = vi.mocked(global.fetch).mock.calls.filter( ([u]) => typeof u === "string" && u === "/api/portal/session-from-auth" ); expect(bridgeCalls).toHaveLength(0); }); it("skips the bridge for staff Better Auth sessions", async () => { global.fetch = vi.fn((input: RequestInfo) => { const url = typeof input === "string" ? input : input.toString(); if (url === "/api/branding") return Promise.resolve(brandingResponse); if (url === "/api/auth/get-session") { return Promise.resolve({ ok: true, json: async () => ({ user: { email: "staff@example.com", role: "staff" } }), } as Response); } return Promise.resolve({ ok: true, json: async () => ({}) } as Response); }) as unknown as typeof fetch; const { CustomerPortal } = await import("../portal/CustomerPortal.js"); render( ); await waitFor(() => { expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" })); }); await new Promise((r) => setTimeout(r, 30)); const bridgeCalls = vi.mocked(global.fetch).mock.calls.filter( ([u]) => typeof u === "string" && u === "/api/portal/session-from-auth" ); expect(bridgeCalls).toHaveLength(0); }); it("passes portalSessionId (not null) to RescheduleFlow for SSO bridge customers (GRO-2012)", async () => { rescheduleFlowSpy.mockClear(); global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => { const url = typeof input === "string" ? input : input.toString(); if (url === "/api/branding") return Promise.resolve(brandingResponse); if (url === "/api/auth/get-session") { return Promise.resolve({ ok: true, json: async () => ({ user: { email: "customer@example.com", role: "customer" } }), } as Response); } if (url === "/api/portal/session-from-auth" && init?.method === "POST") { return Promise.resolve({ ok: true, status: 201, json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }), } as Response); } // Dashboard data — return an upcoming appointment so the Reschedule // button is rendered on the dashboard card. if (url === "/api/portal/appointments") { return Promise.resolve({ ok: true, json: async () => ({ appointments: [ { id: "appt-1", date: "2099-01-01", time: "10:00", petName: "Buddy", serviceName: "Bath & Brush", status: "confirmed", }, ], }), } as Response); } if (url === "/api/portal/pets") { return Promise.resolve({ ok: true, json: async () => ({ pets: [] }) } as Response); } if (url === "/api/portal/invoices") { return Promise.resolve({ ok: true, json: async () => ({ invoices: [] }) } as Response); } return Promise.resolve({ ok: true, json: async () => ({}) } as Response); }) as unknown as typeof fetch; const { CustomerPortal } = await import("../portal/CustomerPortal.js"); render( ); // Wait for the Reschedule button to appear on the dashboard card const rescheduleBtn = await screen.findByRole("button", { name: /^Reschedule$/i }); fireEvent.click(rescheduleBtn); // RescheduleFlow should have been invoked with the bridged portalSessionId, // NOT null. Pre-fix, the call would be sessionId={null} for SSO customers. await waitFor(() => { expect(rescheduleFlowSpy).toHaveBeenCalled(); }); const lastProps = rescheduleFlowSpy.mock.lastCall?.[0]; expect(lastProps).toBeDefined(); expect(lastProps!.sessionId).toBe("sso-sess-1"); expect(lastProps!.appointment.id).toBe("appt-1"); }); // GRO-2099 regression: the portal chrome (and Dashboard's `!sessionId` guard) // must NOT render before the SSO bridge resolves. A loading state must be // shown instead. Previously, the Dashboard's redirect-to-/login guard fired // mid-bootstrap, leaving the user with a blank page after sign-in. it("renders a loading state during the SSO bridge (does not flash portal chrome)", async () => { // Slow bridge: resolve get-session and session-from-auth after a tick so // we can observe the loading state mid-bootstrap. let resolveBridge!: (value: Response) => void; const bridgePromise = new Promise((resolve) => { resolveBridge = resolve; }); global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => { const url = typeof input === "string" ? input : input.toString(); if (url === "/api/branding") return Promise.resolve(brandingResponse); if (url === "/api/auth/get-session") { return Promise.resolve({ ok: true, json: async () => ({ user: { email: "customer@example.com", role: "customer" } }), } as Response); } if (url === "/api/portal/session-from-auth" && init?.method === "POST") { return bridgePromise; } return Promise.resolve({ ok: true, json: async () => ({}) } as Response); }) as unknown as typeof fetch; const { CustomerPortal } = await import("../portal/CustomerPortal.js"); render( ); // Loading state is visible while the bridge is in flight. The portal nav // (Home / Appointments / etc.) must NOT be present — its presence would // indicate the chrome is rendering with a null session, which is the // pre-GRO-2099 bug. expect(await screen.findByRole("status")).toHaveTextContent(/Loading/i); expect(screen.queryByText("Home")).not.toBeInTheDocument(); expect(screen.queryByText("Appointments")).not.toBeInTheDocument(); // Resolve the bridge and confirm the portal renders normally. resolveBridge({ ok: true, status: 201, json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }), } as Response); await waitFor(() => { expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument(); }); }); }); describe("OOBE portal-creation flow (GRO-2359)", () => { beforeEach(() => { window.localStorage.clear(); }); // The OOBE is mounted both from the post-auth callback (CustomerPortal // navigates to /onboarding on bridge 404) and from a direct deep-link. // This set of tests exercises the direct-link mount, the form submit, and // the shared signOut() handler. The post-auth routing is covered by the // "routes to /onboarding when session-from-auth returns 404" test above. function setupOOBEAuthMock(opts: { role?: string } = {}) { const role = opts.role ?? "customer"; return vi.fn((input: RequestInfo) => { const url = typeof input === "string" ? input : input.toString(); if (url === "/api/branding") return Promise.resolve(brandingResponse); if (url === "/api/auth/get-session") { return Promise.resolve({ ok: true, json: async () => ({ user: { email: "new-sso@example.com", name: "New SSO", role }, }), } as Response); } return Promise.resolve({ ok: true, json: async () => ({}) } as Response); }) as unknown as typeof fetch; } it("renders the OOBE form when navigated to /onboarding directly (GRO-2359)", async () => { global.fetch = setupOOBEAuthMock(); const { OOBE } = await import("../portal/OOBE.js"); render( ); await waitFor(() => { expect(screen.getByRole("heading", { name: /set up your portal/i })).toBeInTheDocument(); }); // All three primary form fields are present. expect(screen.getByLabelText(/your name/i)).toBeInTheDocument(); expect(screen.getByLabelText(/phone/i)).toBeInTheDocument(); expect(screen.getByLabelText(/address/i)).toBeInTheDocument(); // Submit and shared signOut are both present. expect(screen.getByRole("button", { name: /Create my portal/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument(); }); it("prefills the name field from the Better Auth session (GRO-2359)", async () => { global.fetch = setupOOBEAuthMock(); const { OOBE } = await import("../portal/OOBE.js"); render( ); await waitFor(() => { expect(screen.getByLabelText(/your name/i)).toHaveValue("New SSO"); }); }); it("calls POST /api/portal/clients-from-auth and navigates to / on success (GRO-2359)", async () => { const fetchMock = vi.fn((input: RequestInfo, init?: RequestInit) => { const url = typeof input === "string" ? input : input.toString(); if (url === "/api/branding") return Promise.resolve(brandingResponse); if (url === "/api/auth/get-session") { return Promise.resolve({ ok: true, json: async () => ({ user: { email: "new-sso@example.com", name: "New SSO", role: "customer" }, }), } as Response); } if (url === "/api/portal/clients-from-auth" && init?.method === "POST") { return Promise.resolve({ ok: true, status: 201, json: async () => ({ id: "new-client-id", name: "New SSO", email: "new-sso@example.com", }), } as Response); } return Promise.resolve({ ok: true, json: async () => ({}) } as Response); }) as unknown as typeof fetch; global.fetch = fetchMock; const { OOBE } = await import("../portal/OOBE.js"); render( ); await waitFor(() => { expect(screen.getByLabelText(/your name/i)).toHaveValue("New SSO"); }); // Fill phone + address and submit. fireEvent.change(screen.getByLabelText(/phone/i), { target: { value: "555-1234" }, }); fireEvent.change(screen.getByLabelText(/address/i), { target: { value: "1 Main St" }, }); fireEvent.click(screen.getByRole("button", { name: /Create my portal/i })); // The endpoint must have been called with the form values, normalised // (phone/address trimmed). We don't assert navigation here because the // MemoryRouter would need a history prop to assert a URL change — the // internal `navigate("/")` call is the contract. await waitFor(() => { const calls = vi.mocked(fetchMock).mock.calls; const onboardCall = calls.find(([u]) => typeof u === "string" && (u as string).endsWith("/api/portal/clients-from-auth"), ); expect(onboardCall).toBeDefined(); const body = JSON.parse(((onboardCall?.[1] as RequestInit | undefined)?.body as string) ?? "{}"); expect(body).toEqual({ name: "New SSO", phone: "555-1234", address: "1 Main St", notes: null, }); }); }); it("shows the portal-selection message when the API returns 409 (GRO-2359)", async () => { global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => { const url = typeof input === "string" ? input : input.toString(); if (url === "/api/branding") return Promise.resolve(brandingResponse); if (url === "/api/auth/get-session") { return Promise.resolve({ ok: true, json: async () => ({ user: { email: "new-sso@example.com", name: "New SSO", role: "customer" }, }), } as Response); } if (url === "/api/portal/clients-from-auth" && init?.method === "POST") { return Promise.resolve({ ok: false, status: 409, json: async () => ({ error: "A customer record with this email already exists" }), } as Response); } return Promise.resolve({ ok: true, json: async () => ({}) } as Response); }) as unknown as typeof fetch; const { OOBE } = await import("../portal/OOBE.js"); render( ); await waitFor(() => { expect(screen.getByLabelText(/your name/i)).toHaveValue("New SSO"); }); fireEvent.click(screen.getByRole("button", { name: /Create my portal/i })); await waitFor(() => { expect(screen.getByText(/already exists/i)).toBeInTheDocument(); }); // The submit button is re-enabled after the error so the user can retry. expect(screen.getByRole("button", { name: /Create my portal/i })).not.toBeDisabled(); }); it("requires the name field before submitting (GRO-2359)", async () => { // Use a session WITHOUT a name so the OOBE starts with an empty form. global.fetch = vi.fn((input: RequestInfo) => { const url = typeof input === "string" ? input : input.toString(); if (url === "/api/branding") return Promise.resolve(brandingResponse); if (url === "/api/auth/get-session") { return Promise.resolve({ ok: true, json: async () => ({ user: { email: "noname@example.com", role: "customer" } }), } as Response); } return Promise.resolve({ ok: true, json: async () => ({}) } as Response); }) as unknown as typeof fetch; const { OOBE } = await import("../portal/OOBE.js"); render( ); await waitFor(() => { expect(screen.getByLabelText(/your name/i)).toHaveValue(""); }); fireEvent.click(screen.getByRole("button", { name: /Create my portal/i })); // The name-required error is shown; no API call was made. await waitFor(() => { expect(screen.getByText(/tell us your name/i)).toBeInTheDocument(); }); }); it("uses the shared signOut() handler on the OOBE Sign out button (GRO-2359)", async () => { signOutSpy.mockClear(); const originalLocation = window.location; Object.defineProperty(window, "location", { value: { href: "" }, writable: true, configurable: true, }); global.fetch = setupOOBEAuthMock(); const { OOBE } = await import("../portal/OOBE.js"); render( ); await waitFor(() => { expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: /Sign out/i })); // Same canonical handler as AdminLayout and the no-access card, per // GRO-2358 — never a raw fetch("/api/auth/sign-out"). await waitFor(() => { expect(signOutSpy).toHaveBeenCalledTimes(1); }); await waitFor(() => { expect(window.location.href).toBe("/login"); }); Object.defineProperty(window, "location", { value: originalLocation, configurable: true }); }); it("redirects to /login when no Better Auth session is present (GRO-2359)", async () => { const originalLocation = window.location; Object.defineProperty(window, "location", { value: { href: "" }, writable: true, configurable: true, }); global.fetch = vi.fn((input: RequestInfo) => { const url = typeof input === "string" ? input : input.toString(); if (url === "/api/branding") return Promise.resolve(brandingResponse); if (url === "/api/auth/get-session") { return Promise.resolve({ ok: false, status: 401, json: async () => ({}) } as Response); } return Promise.resolve({ ok: true, json: async () => ({}) } as Response); }) as unknown as typeof fetch; const { OOBE } = await import("../portal/OOBE.js"); render( ); await waitFor(() => { expect(window.location.href).toBe("/login"); }); Object.defineProperty(window, "location", { value: originalLocation, configurable: true }); }); it("reaches the shared signOut() handler from the in-portal chrome sidebar (GRO-2373)", async () => { // Pre-GRO-2373, the customer portal chrome (Home, Appointments, My Pets, // Report Cards, Billing, Messages, Settings) had no visible sign-out // control — only the OOBE and the no-access card exposed one. This // leaves users signed-in with no escape hatch. The fix lands a // "Sign out" button in the sidebar footer that wires to the same // canonical `signOut()` already used by OOBE / no-access / AdminLayout. signOutSpy.mockClear(); const originalLocation = window.location; Object.defineProperty(window, "location", { value: { href: "" }, writable: true, configurable: true, }); global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => { const url = typeof input === "string" ? input : input.toString(); if (url === "/api/branding") return Promise.resolve(brandingResponse); if (url === "/api/auth/get-session") { return Promise.resolve({ ok: true, json: async () => ({ user: { email: "uat-customer@groombook.dev", role: "customer" } }), } as Response); } if (url === "/api/portal/session-from-auth" && init?.method === "POST") { return Promise.resolve({ ok: true, status: 201, json: async () => ({ sessionId: "chrome-sess-1", clientId: "client-1", clientName: "Jane Doe" }), } as Response); } return Promise.resolve({ ok: true, json: async () => ({}) } as Response); }) as unknown as typeof fetch; const { CustomerPortal } = await import("../portal/CustomerPortal.js"); render( ); // Land on the chrome (proof: customer greeting is rendered, no // no-access card, no OOBE). await waitFor(() => { expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument(); }); expect(screen.queryByText(/Portal access not configured/i)).not.toBeInTheDocument(); expect(screen.queryByText(/set up your portal/i)).not.toBeInTheDocument(); // The new chrome sign-out is scoped by data-testid so it doesn't // collide with other surfaces that may also render "Sign out" labels // (e.g. the impersonation banner uses "End Session"). const signOutButton = screen.getByTestId("portal-chrome-signout"); expect(signOutButton).toHaveTextContent(/Sign out/i); fireEvent.click(signOutButton); // Same canonical handler as OOBE / no-access / AdminLayout — never // a raw fetch("/api/auth/sign-out") and never a navigate() without // signOut() (the OOBE/no-access surface uses window.location.href // for a hard reload so cached state is reset). await waitFor(() => { expect(signOutSpy).toHaveBeenCalledTimes(1); }); await waitFor(() => { expect(window.location.href).toBe("/login"); }); Object.defineProperty(window, "location", { value: originalLocation, configurable: true }); }); });