feat: extract groombook/web from monorepo
- Copy apps/web/ with all src, components, pages, portal - Inline packages/types/ as local packages/types module - Add tsconfig path aliases for @groombook/types - Port Dockerfile and CI workflow - Image name: ghcr.io/groombook/web Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
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("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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.tsx";
|
||||
|
||||
const UPCOMING_APPT = {
|
||||
id: "appt-1",
|
||||
petId: "pet-1",
|
||||
petName: "Buddy",
|
||||
groomerId: "groomer-1",
|
||||
groomerName: "Sarah",
|
||||
services: ["Bath & Brush"],
|
||||
serviceId: "service-1",
|
||||
addOns: [],
|
||||
date: "2027-01-01",
|
||||
time: "10:00 AM",
|
||||
duration: 60,
|
||||
price: 50,
|
||||
status: "confirmed" as const,
|
||||
notes: "",
|
||||
customerNotes: "",
|
||||
confirmationStatus: "pending" as const,
|
||||
};
|
||||
|
||||
const PAST_APPT = {
|
||||
...UPCOMING_APPT,
|
||||
id: "appt-2",
|
||||
date: "2025-01-01",
|
||||
time: "10:00 AM",
|
||||
status: "completed" as const,
|
||||
};
|
||||
|
||||
describe("parseTimeTo24Hour", () => {
|
||||
it("converts AM times correctly", () => {
|
||||
expect(parseTimeTo24Hour("9:00 AM")).toBe("09:00:00");
|
||||
expect(parseTimeTo24Hour("10:00 AM")).toBe("10:00:00");
|
||||
expect(parseTimeTo24Hour("12:00 AM")).toBe("00:00:00");
|
||||
});
|
||||
|
||||
it("converts PM times correctly", () => {
|
||||
expect(parseTimeTo24Hour("1:00 PM")).toBe("13:00:00");
|
||||
expect(parseTimeTo24Hour("2:00 PM")).toBe("14:00:00");
|
||||
expect(parseTimeTo24Hour("11:00 PM")).toBe("23:00:00");
|
||||
expect(parseTimeTo24Hour("12:00 PM")).toBe("12:00:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUpcoming", () => {
|
||||
it("returns true for future confirmed appointments", () => {
|
||||
expect(isUpcoming(UPCOMING_APPT)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for past appointments", () => {
|
||||
expect(isUpcoming(PAST_APPT)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for cancelled appointments", () => {
|
||||
expect(isUpcoming({ ...UPCOMING_APPT, status: "cancelled" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for completed appointments", () => {
|
||||
expect(isUpcoming({ ...UPCOMING_APPT, status: "completed" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CustomerNotesSection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
it("renders textarea with existing notes", () => {
|
||||
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, customerNotes: "Test note" }} sessionId="test-session-id" />);
|
||||
expect(screen.getByRole("textbox")).toHaveValue("Test note");
|
||||
});
|
||||
|
||||
it("renders Save Notes button", () => {
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
expect(screen.getByRole("button", { name: /Save Notes/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends Authorization header when session exists", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }),
|
||||
} as Response);
|
||||
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "New note" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save Notes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/portal/appointments/appt-1/notes",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"X-Impersonation-Session-Id": "test-session-id",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send Authorization header when sessionId is null", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }),
|
||||
} as Response);
|
||||
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId={null} />);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "New note" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save Notes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/portal/appointments/appt-1/notes",
|
||||
expect.objectContaining({
|
||||
headers: expect.not.objectContaining({
|
||||
"Authorization": expect.anything(),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error message when save fails", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ error: "Unauthorized" }),
|
||||
} as Response);
|
||||
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "New note" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save Notes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Unauthorized/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows success message when save succeeds", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", customerNotes: "Saved", updatedAt: new Date().toISOString() }),
|
||||
} as Response);
|
||||
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Saved note" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save Notes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Saved!/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("disables button when notes unchanged", () => {
|
||||
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, customerNotes: "Existing" }} sessionId="test-session-id" />);
|
||||
expect(screen.getByRole("button", { name: /Save Notes/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enforces 500 character limit", () => {
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
const textarea = screen.getByRole("textbox");
|
||||
const longText = "a".repeat(600);
|
||||
fireEvent.change(textarea, { target: { value: longText } });
|
||||
expect(textarea).toHaveValue("a".repeat(500));
|
||||
});
|
||||
|
||||
it("displays character count", () => {
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
expect(screen.getByText(/0\/500/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows exceeded character count in red when limit exceeded", () => {
|
||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
const textarea = screen.getByRole("textbox");
|
||||
// Type characters one by one to exceed limit
|
||||
const longText = "a".repeat(501);
|
||||
fireEvent.change(textarea, { target: { value: longText } });
|
||||
// The textarea value is truncated to 500, so counter shows 500/500
|
||||
// The class check would need to verify text-red-500 appears
|
||||
// Since the onChange truncates, we test that limit is enforced
|
||||
expect(textarea).toHaveValue("a".repeat(500));
|
||||
});
|
||||
|
||||
it("does not render save button for completed appointments", () => {
|
||||
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, status: "completed" }} sessionId="test-session-id" />);
|
||||
expect(screen.queryByRole("button", { name: /Save Notes/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render save button for cancelled appointments", () => {
|
||||
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, status: "cancelled" }} sessionId="test-session-id" />);
|
||||
expect(screen.queryByRole("button", { name: /Save Notes/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConfirmationSection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.fetch = vi.fn();
|
||||
vi.stubGlobal("confirm", vi.fn(() => true));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders pending badge when confirmationStatus is pending", () => {
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
expect(screen.getByText("Pending confirmation")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders confirmed badge when confirmationStatus is confirmed", () => {
|
||||
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "confirmed" }} sessionId="test-session-id" />);
|
||||
expect(screen.getByText("Confirmed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders cancelled badge when confirmationStatus is cancelled", () => {
|
||||
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "cancelled" }} sessionId="test-session-id" />);
|
||||
expect(screen.getByText("Cancelled")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Confirm Appointment button when status is pending", () => {
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
expect(screen.getByRole("button", { name: /Confirm Appointment/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show Confirm button when already confirmed", () => {
|
||||
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "confirmed" }} sessionId="test-session-id" />);
|
||||
expect(screen.queryByRole("button", { name: /Confirm Appointment/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show Confirm button when cancelled", () => {
|
||||
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "cancelled" }} sessionId="test-session-id" />);
|
||||
expect(screen.queryByRole("button", { name: /Confirm Appointment/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls confirm API and updates local status on success", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
||||
} as Response);
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/portal/appointments/appt-1/confirm",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Confirmed")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("sends Authorization header when session exists", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
||||
} as Response);
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/portal/appointments/appt-1/confirm",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"X-Impersonation-Session-Id": "test-session-id",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send Authorization header when sessionId is null", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
||||
} as Response);
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId={null} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/portal/appointments/appt-1/confirm",
|
||||
expect.objectContaining({
|
||||
headers: expect.not.objectContaining({
|
||||
"Authorization": expect.anything(),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error message when confirm API returns 401", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ error: "Unauthorized" }),
|
||||
} as Response);
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Unauthorized/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error message when confirm API returns 403", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({ error: "Forbidden" }),
|
||||
} as Response);
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Forbidden/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error message when confirm API returns 422 (invalid state)", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 422,
|
||||
json: async () => ({ error: "Cannot confirm - appointment is not in pending state" }),
|
||||
} as Response);
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cannot confirm/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call confirm API if user cancels the confirmation dialog", async () => {
|
||||
vi.stubGlobal("confirm", vi.fn(() => false));
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows loading state while confirming", async () => {
|
||||
vi.mocked(global.fetch).mockReturnValue(new Promise(() => {})); // Never resolves
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
// Get button reference before clicking
|
||||
const btn = screen.getByRole("button", { name: /Confirm Appointment/i });
|
||||
fireEvent.click(btn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Confirming.../i)).toBeInTheDocument();
|
||||
});
|
||||
// Button is disabled while loading
|
||||
expect(btn).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows success message briefly after confirm", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
|
||||
} as Response);
|
||||
|
||||
render(<ConfirmationSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Confirmed!/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { BrandingProvider, useBranding } from "../BrandingContext.js";
|
||||
|
||||
function BrandingConsumer() {
|
||||
const { branding } = useBranding();
|
||||
return (
|
||||
<div data-testid="branding">
|
||||
<span data-testid="primary">{branding.primaryColor}</span>
|
||||
<span data-testid="accent">{branding.accentColor}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.documentElement.style.removeProperty("--color-primary");
|
||||
document.documentElement.style.removeProperty("--color-accent");
|
||||
// Remove any theme-color meta tags
|
||||
document.querySelectorAll("meta[name='theme-color']").forEach((el) => el.remove());
|
||||
});
|
||||
|
||||
describe("BrandingProvider", () => {
|
||||
it("applies CSS vars to document root when branding loads", async () => {
|
||||
const branding = {
|
||||
businessName: "Test Salon",
|
||||
primaryColor: "#123456",
|
||||
accentColor: "#654321",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
};
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: async () => branding } as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(
|
||||
<BrandingProvider>
|
||||
<BrandingConsumer />
|
||||
</BrandingProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.style.getPropertyValue("--color-primary")).toBe("#123456");
|
||||
expect(document.documentElement.style.getPropertyValue("--color-accent")).toBe("#654321");
|
||||
});
|
||||
});
|
||||
|
||||
it("creates and updates meta[name=theme-color]", async () => {
|
||||
const branding = {
|
||||
businessName: "Test Salon",
|
||||
primaryColor: "#abcdef",
|
||||
accentColor: "#fedcba",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
};
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: async () => branding } as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(
|
||||
<BrandingProvider>
|
||||
<BrandingConsumer />
|
||||
</BrandingProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const meta = document.querySelector<HTMLMetaElement>("meta[name='theme-color']");
|
||||
expect(meta).not.toBeNull();
|
||||
expect(meta!.content).toBe("#abcdef");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create duplicate meta[name=theme-color] tags on rerender", async () => {
|
||||
const branding = {
|
||||
businessName: "Test Salon",
|
||||
primaryColor: "#111111",
|
||||
accentColor: "#222222",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
};
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: async () => branding } as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const { rerender } = render(
|
||||
<BrandingProvider>
|
||||
<BrandingConsumer />
|
||||
</BrandingProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector("meta[name='theme-color']")).not.toBeNull();
|
||||
});
|
||||
|
||||
rerender(
|
||||
<BrandingProvider>
|
||||
<BrandingConsumer />
|
||||
</BrandingProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const metas = document.querySelectorAll("meta[name='theme-color']");
|
||||
expect(metas.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { GlobalSearch } from "../components/GlobalSearch.js";
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock("react-router-dom", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-router-dom")>();
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
|
||||
function renderSearch() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<GlobalSearch />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockNavigate.mockReset();
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
describe("GlobalSearch", () => {
|
||||
it("renders the search input with correct aria attributes", () => {
|
||||
renderSearch();
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute("aria-label", "Search clients and pets");
|
||||
expect(input).toHaveAttribute("placeholder", "Search clients & pets…");
|
||||
});
|
||||
|
||||
it("does not fetch when query is empty or whitespace", async () => {
|
||||
renderSearch();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.type(input, " ");
|
||||
// No debounce fires for blank input — verify fetch was never called
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches after debounce and renders client results", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
clients: [{ id: "c1", name: "Alice Johnson", email: "alice@example.com", phone: "555-1234" }],
|
||||
pets: [],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
renderSearch();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await user.type(screen.getByRole("combobox"), "Alice");
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Alice Johnson")).toBeInTheDocument(), {
|
||||
timeout: 1500,
|
||||
});
|
||||
expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining("/api/search?q=Alice"));
|
||||
// Section header should appear
|
||||
expect(screen.getByText("Clients")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fetches after debounce and renders pet results with owner name", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
clients: [],
|
||||
pets: [
|
||||
{ id: "p1", name: "Bella", breed: "Golden Retriever", clientId: "c1", ownerName: "Alice Johnson" },
|
||||
],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
renderSearch();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await user.type(screen.getByRole("combobox"), "Bella");
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Bella")).toBeInTheDocument(), { timeout: 1500 });
|
||||
expect(screen.getByText("Owner: Alice Johnson")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'No results found' for a query that matches nothing", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ clients: [], pets: [] }),
|
||||
} as Response);
|
||||
|
||||
renderSearch();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await user.type(screen.getByRole("combobox"), "xyzzy");
|
||||
|
||||
await waitFor(() => expect(screen.getByText("No results found")).toBeInTheDocument(), {
|
||||
timeout: 1500,
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates to ?highlight=<id> and clears input when a client result is clicked", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
clients: [{ id: "c1", name: "Alice Johnson", email: null, phone: null }],
|
||||
pets: [],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
renderSearch();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.type(input, "Alice");
|
||||
|
||||
await waitFor(() => screen.getByText("Alice Johnson"), { timeout: 1500 });
|
||||
await user.click(screen.getByText("Alice Johnson"));
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/admin/clients?highlight=c1");
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("navigates to owner client ?highlight=<clientId> when a pet result is clicked", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
clients: [],
|
||||
pets: [{ id: "p1", name: "Bella", breed: null, clientId: "c1", ownerName: "Alice" }],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
renderSearch();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.type(input, "Bella");
|
||||
|
||||
await waitFor(() => screen.getByText("Bella"), { timeout: 1500 });
|
||||
await user.click(screen.getByText("Bella"));
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/admin/clients?highlight=c1");
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { ImpersonationBanner } from "../portal/ImpersonationBanner.js";
|
||||
import type { ImpersonationSession } from "@groombook/types";
|
||||
|
||||
function makeSession(overrides: Partial<ImpersonationSession> = {}): ImpersonationSession {
|
||||
const now = new Date();
|
||||
const expires = new Date(now.getTime() + 30 * 60 * 1000); // 30 min from now
|
||||
return {
|
||||
id: "session-uuid-1",
|
||||
staffId: "staff-uuid-1",
|
||||
clientId: "client-uuid-1",
|
||||
reason: "Customer requested help",
|
||||
status: "active",
|
||||
startedAt: now.toISOString(),
|
||||
endedAt: null,
|
||||
expiresAt: expires.toISOString(),
|
||||
createdAt: now.toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ImpersonationBanner", () => {
|
||||
const onEnd = vi.fn();
|
||||
const onExtend = vi.fn();
|
||||
const onShowAudit = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders banner when session is active", () => {
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={makeSession()}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/STAFF VIEW/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onEnd when End Session is clicked", () => {
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={makeSession()}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /end session/i }));
|
||||
expect(onEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onShowAudit when Audit is clicked", () => {
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={makeSession()}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /audit/i }));
|
||||
expect(onShowAudit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onEnd automatically when session expires", async () => {
|
||||
const expiredSoon = new Date(Date.now() + 500);
|
||||
const session = makeSession({ expiresAt: expiredSoon.toISOString() });
|
||||
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={session}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
|
||||
// Advance past expiry
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(onEnd).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows Extend button when warning is active and session not yet extended", () => {
|
||||
// Set expiry to 3 min from now — within warning threshold (< 5 min)
|
||||
const expiresAt = new Date(Date.now() + 3 * 60 * 1000).toISOString();
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={makeSession({ expiresAt })}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
// Tick the timer once to trigger showWarning
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(screen.getByRole("button", { name: /extend/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show Extend button when already extended", () => {
|
||||
const expiresAt = new Date(Date.now() + 3 * 60 * 1000).toISOString();
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={makeSession({ expiresAt })}
|
||||
isExtended={true}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: /extend/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("PetPhotoDisplay", () => {
|
||||
it("shows loading skeleton while fetching", () => {
|
||||
global.fetch = vi.fn(() => new Promise(() => {})) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoDisplay petId="pet-1" />);
|
||||
|
||||
expect(screen.getByLabelText("Loading photo…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders photo img when fetch returns a URL", async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ url: "https://storage.test/pet-1/photo.jpg" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoDisplay petId="pet-1" />);
|
||||
|
||||
const img = await screen.findByRole("img", { name: "Pet photo" });
|
||||
expect(img).toHaveAttribute("src", "https://storage.test/pet-1/photo.jpg");
|
||||
});
|
||||
|
||||
it("shows paw placeholder when API returns 404", async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: false, status: 404 } as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoDisplay petId="pet-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("No photo")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByLabelText("Loading photo…")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows paw placeholder when fetch rejects (network error)", async () => {
|
||||
global.fetch = vi.fn(() => Promise.reject(new Error("network error"))) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoDisplay petId="pet-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("No photo")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows paw placeholder on non-404 error status", async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: false, status: 500 } as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoDisplay petId="pet-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("No photo")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("refetches when petId changes", async () => {
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
const petId = (url as string).match(/\/api\/pets\/([^/]+)\/photo/)?.[1];
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ url: `https://storage.test/${petId}/photo.jpg` }),
|
||||
} as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const { rerender } = render(<PetPhotoDisplay petId="pet-1" />);
|
||||
await screen.findByRole("img");
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/pets/pet-1/photo");
|
||||
|
||||
rerender(<PetPhotoDisplay petId="pet-2" />);
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/pets/pet-2/photo");
|
||||
});
|
||||
});
|
||||
|
||||
it("applies custom size prop to container", async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: false, status: 404 } as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const { container } = render(<PetPhotoDisplay petId="pet-1" size={96} />);
|
||||
|
||||
await screen.findByLabelText("No photo");
|
||||
const div = container.firstChild as HTMLElement;
|
||||
expect(div).toHaveStyle({ width: "96px", height: "96px" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { PetPhotoUpload } from "../components/PetPhotoUpload.js";
|
||||
|
||||
// ── XHR mock ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface XhrMock {
|
||||
upload: { addEventListener: ReturnType<typeof vi.fn> };
|
||||
addEventListener: ReturnType<typeof vi.fn>;
|
||||
open: ReturnType<typeof vi.fn>;
|
||||
setRequestHeader: ReturnType<typeof vi.fn>;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
status: number;
|
||||
// Callbacks stored by the mock so tests can trigger them
|
||||
_triggerLoad: () => void;
|
||||
_triggerError: () => void;
|
||||
_triggerProgress: (loaded: number, total: number) => void;
|
||||
}
|
||||
|
||||
function makeXhrMock(status = 200): XhrMock {
|
||||
const uploadListeners: Record<string, (ev: ProgressEvent) => void> = {};
|
||||
const listeners: Record<string, () => void> = {};
|
||||
|
||||
const mock: XhrMock = {
|
||||
upload: {
|
||||
addEventListener: vi.fn((event: string, cb: (ev: ProgressEvent) => void) => {
|
||||
uploadListeners[event] = cb;
|
||||
}),
|
||||
},
|
||||
addEventListener: vi.fn((event: string, cb: () => void) => {
|
||||
listeners[event] = cb;
|
||||
}),
|
||||
open: vi.fn(),
|
||||
setRequestHeader: vi.fn(),
|
||||
send: vi.fn(),
|
||||
status,
|
||||
_triggerLoad: () => listeners["load"]?.(),
|
||||
_triggerError: () => listeners["error"]?.(),
|
||||
_triggerProgress: (loaded, total) =>
|
||||
uploadListeners["progress"]?.({ lengthComputable: true, loaded, total } as ProgressEvent),
|
||||
};
|
||||
return mock;
|
||||
}
|
||||
|
||||
// ── Canvas mock ───────────────────────────────────────────────────────────────
|
||||
|
||||
// jsdom doesn't implement canvas — provide a minimal stub
|
||||
function mockCanvas(blob: Blob) {
|
||||
const ctx = { drawImage: vi.fn() };
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
|
||||
if (tag === "canvas") {
|
||||
const canvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => ctx,
|
||||
toBlob: (cb: (b: Blob | null) => void) => cb(blob),
|
||||
};
|
||||
return canvas as unknown as HTMLCanvasElement;
|
||||
}
|
||||
return originalCreateElement(tag);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Image mock ────────────────────────────────────────────────────────────────
|
||||
|
||||
function mockImage(width = 800, height = 600) {
|
||||
const originalImage = globalThis.Image;
|
||||
const ImageMock = vi.fn().mockImplementation(() => {
|
||||
const img = {
|
||||
width,
|
||||
height,
|
||||
onload: null as (() => void) | null,
|
||||
onerror: null as (() => void) | null,
|
||||
set src(_v: string) {
|
||||
// trigger onload asynchronously
|
||||
setTimeout(() => img.onload?.(), 0);
|
||||
},
|
||||
};
|
||||
return img;
|
||||
});
|
||||
globalThis.Image = ImageMock as unknown as typeof Image;
|
||||
return () => {
|
||||
globalThis.Image = originalImage;
|
||||
};
|
||||
}
|
||||
|
||||
// ── URL mock ──────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
URL.createObjectURL = vi.fn(() => "blob:mock");
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeFile(type = "image/jpeg", name = "photo.jpg", sizeBytes = 1024): File {
|
||||
const buf = new Uint8Array(sizeBytes);
|
||||
return new File([buf], name, { type });
|
||||
}
|
||||
|
||||
function selectFile(file: File) {
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
Object.defineProperty(input, "files", { value: [file], configurable: true });
|
||||
fireEvent.change(input);
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("PetPhotoUpload", () => {
|
||||
it("renders the upload button in idle state", () => {
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: /upload photo/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows an error for an unsupported file type", async () => {
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={vi.fn()} />);
|
||||
selectFile(makeFile("text/plain", "doc.txt"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/JPEG, PNG, WebP, or GIF/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("disables the button while uploading", async () => {
|
||||
const restoreImage = mockImage();
|
||||
const resizedBlob = new Blob(["x"], { type: "image/jpeg" });
|
||||
mockCanvas(resizedBlob);
|
||||
|
||||
let xhrInstance: XhrMock;
|
||||
const XHRMock = vi.fn().mockImplementation(() => {
|
||||
xhrInstance = makeXhrMock(200);
|
||||
return xhrInstance;
|
||||
});
|
||||
globalThis.XMLHttpRequest = XHRMock as unknown as typeof XMLHttpRequest;
|
||||
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ uploadUrl: "https://storage.test/put", key: "pets/pet-1/123.jpg" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={vi.fn()} />);
|
||||
selectFile(makeFile("image/jpeg"));
|
||||
|
||||
// Button should become disabled during upload
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
restoreImage();
|
||||
});
|
||||
|
||||
it("calls onUploaded and resets after successful upload", async () => {
|
||||
const restoreImage = mockImage();
|
||||
const resizedBlob = new Blob(["x"], { type: "image/jpeg" });
|
||||
mockCanvas(resizedBlob);
|
||||
|
||||
let xhrInstance!: XhrMock;
|
||||
const XHRMock = vi.fn().mockImplementation(() => {
|
||||
xhrInstance = makeXhrMock(200);
|
||||
return xhrInstance;
|
||||
});
|
||||
globalThis.XMLHttpRequest = XHRMock as unknown as typeof XMLHttpRequest;
|
||||
|
||||
const onUploaded = vi.fn();
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if ((url as string).includes("upload-url")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ uploadUrl: "https://storage.test/put", key: "pets/pet-1/123.jpg" }),
|
||||
} as Response);
|
||||
}
|
||||
// confirm
|
||||
return Promise.resolve({ ok: true, json: async () => ({ ok: true }) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={onUploaded} />);
|
||||
selectFile(makeFile("image/jpeg"));
|
||||
|
||||
// Wait for XHR to be set up, then trigger load
|
||||
await waitFor(() => expect(xhrInstance).toBeDefined());
|
||||
xhrInstance._triggerLoad();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUploaded).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
restoreImage();
|
||||
});
|
||||
|
||||
it("shows error message when upload-url request fails", async () => {
|
||||
const restoreImage = mockImage();
|
||||
const resizedBlob = new Blob(["x"], { type: "image/jpeg" });
|
||||
mockCanvas(resizedBlob);
|
||||
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
json: async () => ({ error: "Pet not found" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={vi.fn()} />);
|
||||
selectFile(makeFile("image/jpeg"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Pet not found/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
restoreImage();
|
||||
});
|
||||
|
||||
it("shows error message when XHR upload fails", async () => {
|
||||
const restoreImage = mockImage();
|
||||
const resizedBlob = new Blob(["x"], { type: "image/jpeg" });
|
||||
mockCanvas(resizedBlob);
|
||||
|
||||
let xhrInstance!: XhrMock;
|
||||
const XHRMock = vi.fn().mockImplementation(() => {
|
||||
xhrInstance = makeXhrMock(0);
|
||||
return xhrInstance;
|
||||
});
|
||||
globalThis.XMLHttpRequest = XHRMock as unknown as typeof XMLHttpRequest;
|
||||
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ uploadUrl: "https://storage.test/put", key: "pets/pet-1/123.jpg" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={vi.fn()} />);
|
||||
selectFile(makeFile("image/jpeg"));
|
||||
|
||||
await waitFor(() => expect(xhrInstance).toBeDefined());
|
||||
xhrInstance._triggerError();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/network error/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
restoreImage();
|
||||
});
|
||||
|
||||
it("shows upload progress percentage", async () => {
|
||||
const restoreImage = mockImage();
|
||||
const resizedBlob = new Blob(["x"], { type: "image/jpeg" });
|
||||
mockCanvas(resizedBlob);
|
||||
|
||||
let xhrInstance!: XhrMock;
|
||||
const XHRMock = vi.fn().mockImplementation(() => {
|
||||
xhrInstance = makeXhrMock(200);
|
||||
return xhrInstance;
|
||||
});
|
||||
globalThis.XMLHttpRequest = XHRMock as unknown as typeof XMLHttpRequest;
|
||||
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ uploadUrl: "https://storage.test/put", key: "pets/pet-1/123.jpg" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={vi.fn()} />);
|
||||
selectFile(makeFile("image/jpeg"));
|
||||
|
||||
await waitFor(() => expect(xhrInstance).toBeDefined());
|
||||
xhrInstance._triggerProgress(50, 100);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Uploading 50%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
restoreImage();
|
||||
});
|
||||
|
||||
it("skips canvas resize for GIF files", async () => {
|
||||
const createElementSpy = vi.spyOn(document, "createElement");
|
||||
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ uploadUrl: "https://storage.test/put", key: "pets/pet-1/123.gif" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
let xhrInstance!: XhrMock;
|
||||
const XHRMock = vi.fn().mockImplementation(() => {
|
||||
xhrInstance = makeXhrMock(200);
|
||||
return xhrInstance;
|
||||
});
|
||||
globalThis.XMLHttpRequest = XHRMock as unknown as typeof XMLHttpRequest;
|
||||
|
||||
render(<PetPhotoUpload petId="pet-1" onUploaded={vi.fn()} />);
|
||||
selectFile(makeFile("image/gif", "anim.gif", 512));
|
||||
|
||||
// Wait for XHR to be invoked
|
||||
await waitFor(() => expect(xhrInstance).toBeDefined());
|
||||
|
||||
// canvas should NOT have been created for GIF
|
||||
const canvasCalls = createElementSpy.mock.calls.filter(([tag]) => tag === "canvas");
|
||||
expect(canvasCalls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
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";
|
||||
|
||||
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(),
|
||||
},
|
||||
];
|
||||
|
||||
// ─── ImpersonationBanner ────────────────────────────────────────────────────
|
||||
|
||||
describe("ImpersonationBanner", () => {
|
||||
it("renders STAFF VIEW label", () => {
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={SESSION}
|
||||
isExtended={false}
|
||||
onEnd={vi.fn()}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("STAFF VIEW")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays the session reason", () => {
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={SESSION}
|
||||
isExtended={false}
|
||||
onEnd={vi.fn()}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/Customer reported missing appointment/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onEnd when End Session is clicked", () => {
|
||||
const onEnd = vi.fn();
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={SESSION}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /End Session/i }));
|
||||
expect(onEnd).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onShowAudit when Audit is clicked", () => {
|
||||
const onShowAudit = vi.fn();
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={SESSION}
|
||||
isExtended={false}
|
||||
onEnd={vi.fn()}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<ImpersonationBanner
|
||||
session={nearlyExpiredSession}
|
||||
isExtended={false}
|
||||
onEnd={vi.fn()}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<ImpersonationBanner
|
||||
session={nearlyExpiredSession}
|
||||
isExtended={true}
|
||||
onEnd={vi.fn()}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
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(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);
|
||||
|
||||
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(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);
|
||||
|
||||
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(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);
|
||||
|
||||
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(<AuditLogViewer sessionId="sess-1" onClose={onClose} />);
|
||||
|
||||
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(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);
|
||||
|
||||
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(
|
||||
<MemoryRouter initialEntries={["/?sessionId=sess-1"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// 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(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// 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(
|
||||
<MemoryRouter initialEntries={["/?sessionId=sess-1"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// 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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user