3d7b247562
CI / Test (push) Successful in 21s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 12s
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 26s
CI / Build & Push Docker Image (pull_request) Successful in 11s
Co-authored-by: Lint Roller <23+gb_lint@noreply.git.farh.net> Co-committed-by: Lint Roller <23+gb_lint@noreply.git.farh.net>
272 lines
8.3 KiB
TypeScript
272 lines
8.3 KiB
TypeScript
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(
|
|
<MemoryRouter initialEntries={[route]}>
|
|
<App />
|
|
</MemoryRouter>
|
|
);
|
|
// 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(
|
|
<MemoryRouter initialEntries={["/"]}>
|
|
<App />
|
|
</MemoryRouter>
|
|
);
|
|
// 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(
|
|
<MemoryRouter initialEntries={["/login"]}>
|
|
<App />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// 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(
|
|
<MemoryRouter initialEntries={["/admin"]}>
|
|
<App />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// 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(
|
|
<MemoryRouter initialEntries={["/admin"]}>
|
|
<App />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// 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();
|
|
});
|
|
});
|