915a310e0a
A deliberately-paced Book New wizard could outlive the portal impersonation session, so the final POST /api/portal/waitlist returned 401 and the UI showed "Failed to book appointment. Please try again." BookingFlow now retries once on a 401: it re-mints a fresh portal session via POST /api/portal/session-from-auth (the customer's Better Auth cookie is still valid) and resubmits the waitlist request with the new X-Impersonation-Session-Id. Falls through to the existing error if no Better Auth session is available (staff/dev impersonation paths). - Appointments.tsx: remintPortalSession() helper; handleConfirmBooking submits via submitWaitlist(id) and retries once after a 401 re-mint. - Test: first waitlist POST 401 -> re-mint -> retry with fresh id -> success; asserts exactly one re-mint and the header sequence. - UAT_PLAYBOOK.md 5.12e: TC-WEB-5.12.25 slow-wizard submit succeeds. Companion to groombook/api GRO-2234 (bounded sliding expiration). Co-Authored-By: Paperclip <noreply@paperclip.ing>
876 lines
34 KiB
TypeScript
876 lines
34 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, CustomerNotesSection, ConfirmationSection, StatusBadge, formatSlotLabel, slotToTime, BookingFlow } 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");
|
|
});
|
|
|
|
it("does not throw on undefined/null/empty input (GRO-2180)", () => {
|
|
expect(() => parseTimeTo24Hour(undefined)).not.toThrow();
|
|
expect(() => parseTimeTo24Hour(null)).not.toThrow();
|
|
expect(parseTimeTo24Hour(undefined)).toBe("00:00:00");
|
|
expect(parseTimeTo24Hour("")).toBe("00:00:00");
|
|
});
|
|
});
|
|
|
|
// GRO-2180: `/api/portal/appointments` returns ISO `startTime`/`endTime` + nested
|
|
// pet/service/staff objects, not the flat date/time/petName shape the UI renders.
|
|
describe("normalizeAppointment (API startTime shape — GRO-2180)", () => {
|
|
const RAW_API_APPT = {
|
|
id: "a0000001-0000-0000-0000-000000000001",
|
|
startTime: "2026-06-01T10:00:00.000Z",
|
|
endTime: "2026-06-01T10:45:00.000Z",
|
|
status: "completed" as const,
|
|
confirmationStatus: "confirmed" as const,
|
|
customerNotes: "Please be gentle",
|
|
notes: null,
|
|
pet: { id: "c0000001-0000-0000-0000-000000000001", name: "UAT Pup Alpha", photo: null },
|
|
service: { id: "b0000001-0000-0000-0000-000000000001", name: "Full Groom" },
|
|
staff: { id: "00000000-0000-0000-0000-000000000004", name: "UAT Staff Groomer" },
|
|
};
|
|
|
|
it("maps nested pet/service/staff and ISO startTime without throwing", () => {
|
|
const appt = normalizeAppointment(RAW_API_APPT);
|
|
expect(appt.id).toBe("a0000001-0000-0000-0000-000000000001");
|
|
expect(appt.petId).toBe("c0000001-0000-0000-0000-000000000001");
|
|
expect(appt.serviceId).toBe("b0000001-0000-0000-0000-000000000001");
|
|
expect(appt.groomerId).toBe("00000000-0000-0000-0000-000000000004");
|
|
expect(appt.petName).toBe("UAT Pup Alpha");
|
|
expect(appt.serviceName).toBe("Full Groom");
|
|
expect(appt.groomerName).toBe("UAT Staff Groomer");
|
|
expect(appt.startTime).toBe("2026-06-01T10:00:00.000Z");
|
|
expect(appt.customerNotes).toBe("Please be gentle");
|
|
});
|
|
|
|
it("derives duration in minutes from start/end delta", () => {
|
|
expect(normalizeAppointment(RAW_API_APPT).duration).toBe(45);
|
|
});
|
|
|
|
it("produces a date/time pair that does not crash isUpcoming or formatDate", () => {
|
|
const appt = normalizeAppointment(RAW_API_APPT);
|
|
expect(typeof appt.date).toBe("string");
|
|
expect(typeof appt.time).toBe("string");
|
|
expect(() => isUpcoming(appt)).not.toThrow();
|
|
});
|
|
|
|
it("classifies a past completed appointment as not upcoming", () => {
|
|
expect(isUpcoming(normalizeAppointment(RAW_API_APPT))).toBe(false);
|
|
});
|
|
|
|
it("classifies a future scheduled appointment as upcoming via startTime", () => {
|
|
const future = normalizeAppointment({
|
|
...RAW_API_APPT,
|
|
startTime: "2099-01-01T10:00:00.000Z",
|
|
endTime: "2099-01-01T11:00:00.000Z",
|
|
status: "confirmed",
|
|
});
|
|
expect(isUpcoming(future)).toBe(true);
|
|
});
|
|
|
|
it("tolerates null nested objects without throwing", () => {
|
|
const appt = normalizeAppointment({
|
|
id: "a2",
|
|
startTime: "2099-01-01T10:00:00.000Z",
|
|
endTime: "2099-01-01T11:00:00.000Z",
|
|
status: "scheduled",
|
|
pet: null,
|
|
service: null,
|
|
staff: null,
|
|
});
|
|
expect(appt.petId).toBe("");
|
|
expect(appt.serviceId).toBe("");
|
|
expect(appt.groomerId).toBeNull();
|
|
expect(appt.petName).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("StatusBadge", () => {
|
|
it("renders Confirmed for confirmed status", () => {
|
|
render(<StatusBadge status="confirmed" />);
|
|
expect(screen.getByText("Confirmed")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders Pending for pending status", () => {
|
|
render(<StatusBadge status="pending" />);
|
|
expect(screen.getByText("Pending")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders Waitlisted for waitlisted status", () => {
|
|
render(<StatusBadge status="waitlisted" />);
|
|
expect(screen.getByText("Waitlisted")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders Completed for completed status", () => {
|
|
render(<StatusBadge status="completed" />);
|
|
expect(screen.getByText("Completed")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders Cancelled for cancelled status", () => {
|
|
render(<StatusBadge status="cancelled" />);
|
|
expect(screen.getByText("Cancelled")).toBeInTheDocument();
|
|
});
|
|
|
|
it("falls back to status string for unknown status", () => {
|
|
render(<StatusBadge status="custom-status" />);
|
|
expect(screen.getByText("custom-status")).toBeInTheDocument();
|
|
});
|
|
|
|
it("uses correct CSS class for confirmed status", () => {
|
|
render(<StatusBadge status="confirmed" />);
|
|
const badge = screen.getByText("Confirmed").closest('span');
|
|
expect(badge?.className).toContain("bg-green-100");
|
|
expect(badge?.className).toContain("text-green-700");
|
|
});
|
|
|
|
it("uses correct CSS class for waitlisted status", () => {
|
|
render(<StatusBadge status="waitlisted" />);
|
|
const badge = screen.getByText("Waitlisted").closest('span');
|
|
expect(badge?.className).toContain("bg-blue-100");
|
|
expect(badge?.className).toContain("text-blue-600");
|
|
});
|
|
|
|
it("uses correct CSS class for pending status", () => {
|
|
render(<StatusBadge status="pending" />);
|
|
const badge = screen.getByText("Pending").closest('span');
|
|
expect(badge?.className).toContain("bg-amber-100");
|
|
expect(badge?.className).toContain("text-amber-600");
|
|
});
|
|
|
|
it("uses fallback styling for unknown status", () => {
|
|
render(<StatusBadge status="unknown" />);
|
|
const badge = screen.getByText("unknown").closest('span');
|
|
expect(badge?.className).toContain("bg-stone-100");
|
|
expect(badge?.className).toContain("text-stone-600");
|
|
});
|
|
});
|
|
|
|
describe("RescheduleFlow dynamic time slots", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
global.fetch = vi.fn();
|
|
});
|
|
|
|
const RESCHEDULE_APPT = {
|
|
id: "appt-r1",
|
|
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: "confirmed" as const,
|
|
};
|
|
|
|
it("shows loading state while fetching availability", async () => {
|
|
vi.mocked(global.fetch).mockReturnValue(new Promise(() => {})); // Never resolves
|
|
|
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
|
|
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
|
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Checking availability/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("displays fetched time slots from API", async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ["9:00 AM", "10:00 AM", "2:00 PM"],
|
|
} as Response);
|
|
|
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
|
|
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
|
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("9:00 AM")).toBeInTheDocument();
|
|
expect(screen.getByText("10:00 AM")).toBeInTheDocument();
|
|
expect(screen.getByText("2:00 PM")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("shows error state when availability fetch fails", async () => {
|
|
vi.mocked(global.fetch).mockRejectedValue(new Error("Network error"));
|
|
|
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
|
|
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
|
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Failed to load time slots/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("shows no slots message when API returns empty array", async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: true,
|
|
json: async () => [] as string[],
|
|
} as Response);
|
|
|
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
|
|
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
|
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/No available slots on this date/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("calls /api/book/availability with the serviceId and selected date", async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ["9:00 AM"] as string[],
|
|
} as Response);
|
|
|
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
|
|
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
|
fireEvent.change(dateInput, { target: { value: "2027-02-20" } });
|
|
|
|
await waitFor(() => {
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
"/api/book/availability?serviceId=service-1&date=2027-02-20",
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({ "X-Impersonation-Session-Id": "test-session-id" }),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
it("shows error message when API returns a 4xx error object instead of an array", async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: false,
|
|
status: 400,
|
|
json: async () => ({ error: "serviceId and date are required" }),
|
|
} as Response);
|
|
|
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
|
|
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
|
fireEvent.change(dateInput, { target: { value: "2027-02-20" } });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/serviceId and date are required/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("shows generic error when API returns 200 but body is not an array", async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ error: "serviceId and date are required" }),
|
|
} as Response);
|
|
|
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
|
|
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
|
fireEvent.change(dateInput, { target: { value: "2027-02-20" } });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Failed to load time slots/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("re-fetches slots when date changes", async () => {
|
|
vi.mocked(global.fetch)
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ["9:00 AM"] as string[],
|
|
} as Response)
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ["11:00 AM", "1:00 PM"] as string[],
|
|
} as Response);
|
|
|
|
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
|
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
|
|
|
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
|
|
|
fireEvent.change(dateInput, { target: { value: "2027-01-10" } });
|
|
await waitFor(() => expect(screen.getByText("9:00 AM")).toBeInTheDocument());
|
|
|
|
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
|
await waitFor(() => {
|
|
expect(screen.getByText("11:00 AM")).toBeInTheDocument();
|
|
expect(screen.getByText("1:00 PM")).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
describe("slot helpers (GRO-2213)", () => {
|
|
it("formatSlotLabel formats a canonical UTC ISO slot to a UTC clock label", () => {
|
|
expect(formatSlotLabel("2026-06-09T10:00:00.000Z")).toBe("10:00 AM");
|
|
expect(formatSlotLabel("2026-06-09T14:30:00.000Z")).toBe("2:30 PM");
|
|
expect(formatSlotLabel("2026-06-09T09:00:00.000Z")).toBe("9:00 AM");
|
|
});
|
|
|
|
it("formatSlotLabel never echoes a raw ISO string", () => {
|
|
expect(formatSlotLabel("2026-06-09T10:00:00.000Z")).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
|
|
});
|
|
|
|
it("formatSlotLabel passes through an already-formatted label unchanged", () => {
|
|
expect(formatSlotLabel("10:00 AM")).toBe("10:00 AM");
|
|
});
|
|
|
|
it("slotToTime extracts the UTC HH:MM:SS time component from an ISO slot", () => {
|
|
expect(slotToTime("2026-06-09T10:00:00.000Z")).toBe("10:00:00");
|
|
expect(slotToTime("2026-06-09T14:30:00.000Z")).toBe("14:30:00");
|
|
expect(slotToTime("2026-06-09T10:00:00.000Z")).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
|
});
|
|
|
|
it("slotToTime guards a value that is already HH:MM:SS", () => {
|
|
expect(slotToTime("10:00:00")).toBe("10:00:00");
|
|
});
|
|
|
|
it("slotToTime converts a 12-hour label fallback to HH:MM:SS", () => {
|
|
expect(slotToTime("9:00 AM")).toBe("09:00:00");
|
|
expect(slotToTime("2:30 PM")).toBe("14:30:00");
|
|
});
|
|
});
|
|
|
|
describe("BookingFlow Book New funnel (GRO-2213)", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
global.fetch = vi.fn();
|
|
});
|
|
|
|
function routedFetch(captured: { waitlistBody?: Record<string, unknown> }) {
|
|
return (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
if (url.includes("/api/portal/pets")) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: async () => ({ pets: [{ id: "pet-1", name: "Buddy", breed: "Lab" }] }),
|
|
} as Response);
|
|
}
|
|
if (url.includes("/api/portal/services")) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: async () => ({
|
|
services: [{ id: "service-1", name: "Bath & Brush", isAddOn: false, duration: 60, price: 50 }],
|
|
}),
|
|
} as Response);
|
|
}
|
|
if (url.includes("/api/book/availability")) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: async () => ["2026-06-09T10:00:00.000Z", "2026-06-09T14:30:00.000Z"],
|
|
} as Response);
|
|
}
|
|
if (url.includes("/api/portal/waitlist")) {
|
|
captured.waitlistBody = JSON.parse((init?.body as string) ?? "{}");
|
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
|
}
|
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
|
};
|
|
}
|
|
|
|
it("renders formatted slot labels (not raw ISO) and submits preferredTime as HH:MM:SS", async () => {
|
|
const captured: { waitlistBody?: Record<string, unknown> } = {};
|
|
vi.mocked(global.fetch).mockImplementation(routedFetch(captured) as typeof fetch);
|
|
|
|
render(<BookingFlow onClose={() => {}} sessionId="test-session-id" />);
|
|
|
|
// Step 1 — pick pet (auto-advances to step 2)
|
|
await waitFor(() => expect(screen.getByText("Buddy")).toBeInTheDocument());
|
|
fireEvent.click(screen.getByText("Buddy"));
|
|
|
|
// Step 2 — pick service, then Next
|
|
await waitFor(() => expect(screen.getByText("Bath & Brush")).toBeInTheDocument());
|
|
fireEvent.click(screen.getByText("Bath & Brush"));
|
|
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
|
|
|
// Step 3 — groomer, Next
|
|
await waitFor(() => expect(screen.getByText("First Available")).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
|
|
|
// Step 4 — date + slot
|
|
await waitFor(() => expect(screen.getByLabelText(/date/i)).toBeInTheDocument());
|
|
fireEvent.change(screen.getByLabelText(/date/i), { target: { value: "2026-06-09" } });
|
|
|
|
// Slot button shows the formatted UTC label, never the raw ISO
|
|
await waitFor(() => expect(screen.getByText("10:00 AM")).toBeInTheDocument());
|
|
expect(screen.queryByText(/2026-06-09T10:00:00/)).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByText("10:00 AM"));
|
|
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
|
|
|
// Step 5 — review shows the formatted label
|
|
await waitFor(() => expect(screen.getByText(/Review & Confirm/i)).toBeInTheDocument());
|
|
expect(screen.getByText(/10:00 AM/)).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /Confirm Booking/i }));
|
|
|
|
await waitFor(() => expect(captured.waitlistBody).toBeDefined());
|
|
const body = captured.waitlistBody ?? {};
|
|
expect(body.preferredTime).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
|
expect(body.preferredTime).toBe("10:00:00");
|
|
expect(body.preferredDate).toBe("2026-06-09");
|
|
});
|
|
|
|
it("re-mints the portal session and retries once when waitlist returns 401 (GRO-2234)", async () => {
|
|
const calls = { waitlist: 0, remint: 0 };
|
|
const waitlistHeaders: string[] = [];
|
|
const routed = (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
if (url.includes("/api/portal/pets")) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: async () => ({ pets: [{ id: "pet-1", name: "Buddy", breed: "Lab" }] }),
|
|
} as Response);
|
|
}
|
|
if (url.includes("/api/portal/services")) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: async () => ({
|
|
services: [{ id: "service-1", name: "Bath & Brush", isAddOn: false, duration: 60, price: 50 }],
|
|
}),
|
|
} as Response);
|
|
}
|
|
if (url.includes("/api/book/availability")) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: async () => ["2026-06-09T10:00:00.000Z"],
|
|
} as Response);
|
|
}
|
|
if (url.includes("/api/portal/session-from-auth")) {
|
|
calls.remint += 1;
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: async () => ({ sessionId: "fresh-session-id", clientId: "c1", clientName: "Jane" }),
|
|
} as Response);
|
|
}
|
|
if (url.includes("/api/portal/waitlist")) {
|
|
calls.waitlist += 1;
|
|
const headers = (init?.headers ?? {}) as Record<string, string>;
|
|
waitlistHeaders.push(headers["X-Impersonation-Session-Id"] ?? "");
|
|
// First attempt: session lapsed → 401. Retry after re-mint: success.
|
|
if (calls.waitlist === 1) {
|
|
return Promise.resolve({ ok: false, status: 401, json: async () => ({ error: "Unauthorized" }) } as Response);
|
|
}
|
|
return Promise.resolve({ ok: true, status: 201, json: async () => ({}) } as Response);
|
|
}
|
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
|
};
|
|
global.fetch = vi.fn().mockImplementation(routed as typeof fetch);
|
|
|
|
render(<BookingFlow onClose={() => {}} sessionId="stale-session-id" />);
|
|
|
|
await waitFor(() => expect(screen.getByText("Buddy")).toBeInTheDocument());
|
|
fireEvent.click(screen.getByText("Buddy"));
|
|
await waitFor(() => expect(screen.getByText("Bath & Brush")).toBeInTheDocument());
|
|
fireEvent.click(screen.getByText("Bath & Brush"));
|
|
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
|
await waitFor(() => expect(screen.getByText("First Available")).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
|
await waitFor(() => expect(screen.getByLabelText(/date/i)).toBeInTheDocument());
|
|
fireEvent.change(screen.getByLabelText(/date/i), { target: { value: "2026-06-09" } });
|
|
await waitFor(() => expect(screen.getByText("10:00 AM")).toBeInTheDocument());
|
|
fireEvent.click(screen.getByText("10:00 AM"));
|
|
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
|
|
await waitFor(() => expect(screen.getByText(/Review & Confirm/i)).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole("button", { name: /Confirm Booking/i }));
|
|
|
|
// Re-mint happened exactly once, waitlist retried with the fresh id, and the
|
|
// booking succeeded (no error surfaced).
|
|
await waitFor(() => expect(calls.waitlist).toBe(2));
|
|
expect(calls.remint).toBe(1);
|
|
expect(waitlistHeaders).toEqual(["stale-session-id", "fresh-session-id"]);
|
|
expect(screen.queryByText(/Failed to book appointment/i)).not.toBeInTheDocument();
|
|
});
|
|
});
|