import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, within, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { App } from "../App"; // Mock fetch to return appropriate responses based on URL beforeEach(() => { localStorage.clear(); global.fetch = vi.fn((url: string) => { if (url === "/api/dev/config") { return Promise.resolve({ ok: true, json: async () => ({ authDisabled: false }), } as Response); } if (url === "/api/branding") { return Promise.resolve({ ok: true, json: async () => ({ businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, }), } as Response); } return Promise.resolve({ ok: true, json: async () => [], } as Response); }) as unknown as typeof fetch; }); async function renderApp(route = "/admin") { render( ); // Wait for the config fetch to resolve const nav = await screen.findByRole("navigation"); return nav; } describe("App navigation", () => { // Use authDisabled=true (dev mode) so nav renders without needing Better Auth useSession() mock beforeEach(() => { localStorage.setItem("dev-user", JSON.stringify({ type: "staff", id: "s1", name: "Sarah" })); global.fetch = vi.fn((url: string) => { if (url === "/api/dev/config") { return Promise.resolve({ ok: true, json: async () => ({ authDisabled: true }), } as Response); } if (url === "/api/branding") { return Promise.resolve({ ok: true, json: async () => ({ businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, }), } as Response); } return Promise.resolve({ ok: true, json: async () => [] } as Response); }) as unknown as typeof fetch; }); it("renders the Groom Book brand", async () => { const nav = await renderApp(); expect( within(nav).getByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? "")) ).toBeInTheDocument(); }); it("renders the Book CTA button", async () => { const nav = await renderApp(); expect(within(nav).getByRole("link", { name: "Book" })).toBeInTheDocument(); }); it("renders all primary nav links", async () => { const nav = await renderApp(); const expectedLinks = [ "Appointments", "Clients", "Services", "Staff", "Invoices", "Group Bookings", "Reports", ]; expectedLinks.forEach((label) => { expect(within(nav).getByText(label)).toBeInTheDocument(); }); }); it("highlights the active route link", async () => { const nav = await renderApp("/admin/clients"); const clientsLink = within(nav).getByText("Clients"); // Active links use fontWeight 600 expect(clientsLink).toHaveStyle({ fontWeight: "600" }); }); it("renders customer portal at root", async () => { render( ); // Customer portal should render at root - no admin nav present await waitFor(() => { expect( screen.queryByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? "")) ).not.toBeInTheDocument(); }); }); }); describe("GRO-2011 — setup/status fetch for unauthenticated users", () => { it("calls /api/setup/status for unauthenticated users so needsSetup is never stuck null", async () => { const setupStatusCalls: string[] = []; global.fetch = vi.fn((url: string) => { if (url === "/api/dev/config") { return Promise.resolve({ ok: true, json: async () => ({ authDisabled: false }), } as Response); } if (url === "/api/auth/get-session") { // Better Auth returns 200 with null session for unauthenticated users. return Promise.resolve({ ok: true, json: async () => null, } as unknown as Response); } if (url === "/api/setup/status") { setupStatusCalls.push(url); return Promise.resolve({ ok: true, json: async () => ({ needsSetup: false }), } as Response); } if (url === "/api/branding") { return Promise.resolve({ ok: true, json: async () => ({ businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, }), } as Response); } return Promise.resolve({ ok: true, json: async () => [] } as Response); }) as unknown as typeof fetch; render( ); // The login page should be rendered for the unauthenticated user. await screen.findByText("Sign in to continue"); // Crucially, /api/setup/status must be called even when the user is unauthenticated — // otherwise `needsSetup` stays null and a later code path can short-circuit to a // blank page (GRO-2011). await waitFor(() => { expect(setupStatusCalls.length).toBeGreaterThanOrEqual(1); }); expect(setupStatusCalls[0]).toBe("/api/setup/status"); }); }); describe("Dev login selector", () => { it("redirects to /login when auth is disabled and no user selected", async () => { global.fetch = vi.fn((url: string) => { if (url === "/api/dev/config") { return Promise.resolve({ ok: true, json: async () => ({ authDisabled: true }), } as Response); } if (url === "/api/dev/users") { return Promise.resolve({ ok: true, json: async () => ({ staff: [{ id: "s1", name: "Sarah", email: "sarah@test.com", role: "groomer" }], clients: [{ id: "c1", name: "Client A", email: "a@test.com", petCount: 2 }], }), } as Response); } 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 === "/api/auth/get-session") { return Promise.resolve({ ok: true, json: async () => ({ user: null }), } as Response); } return Promise.resolve({ ok: true, json: async () => [] } as Response); }) as unknown as typeof fetch; render( ); // Should redirect to login selector and show dev login UI await screen.findByText("Dev Login Selector"); expect(screen.getByText("Sarah")).toBeInTheDocument(); expect(screen.getByText("Client A")).toBeInTheDocument(); }); it("does not redirect when a dev user is already selected", async () => { localStorage.setItem("dev-user", JSON.stringify({ type: "staff", id: "s1", name: "Sarah" })); global.fetch = vi.fn((url: string) => { if (url === "/api/dev/config") { return Promise.resolve({ ok: true, json: async () => ({ authDisabled: true }), } as Response); } if (url === "/api/branding") { return Promise.resolve({ ok: true, json: async () => ({ businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, }), } as Response); } return Promise.resolve({ ok: true, json: async () => [] } as Response); }) as unknown as typeof fetch; render( ); // Should show admin nav, not login selector const nav = await screen.findByRole("navigation"); expect( within(nav).getByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? "")) ).toBeInTheDocument(); }); });