import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, normalizeService, formatServicePrice, CustomerNotesSection, ConfirmationSection, StatusBadge, normalizeStatusKey, deriveDisplayStatus, 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(); expect(screen.getByRole("textbox")).toHaveValue("Test note"); }); it("renders Save Notes button", () => { render(); 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(); 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(); 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(); 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(); 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(); expect(screen.getByRole("button", { name: /Save Notes/i })).toBeDisabled(); }); it("enforces 500 character limit", () => { render(); 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(); expect(screen.getByText(/0\/500/)).toBeInTheDocument(); }); it("shows exceeded character count in red when limit exceeded", () => { render(); 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(); expect(screen.queryByRole("button", { name: /Save Notes/i })).not.toBeInTheDocument(); }); it("does not render save button for cancelled appointments", () => { render(); 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(); expect(screen.getByText("Pending confirmation")).toBeInTheDocument(); }); it("renders confirmed badge when confirmationStatus is confirmed", () => { render(); expect(screen.getByText("Confirmed")).toBeInTheDocument(); }); it("renders cancelled badge when confirmationStatus is cancelled", () => { render(); expect(screen.getByText("Cancelled")).toBeInTheDocument(); }); it("shows Confirm Appointment button when status is pending", () => { render(); expect(screen.getByRole("button", { name: /Confirm Appointment/i })).toBeInTheDocument(); }); it("does not show Confirm button when already confirmed", () => { render(); expect(screen.queryByRole("button", { name: /Confirm Appointment/i })).not.toBeInTheDocument(); }); it("does not show Confirm button when cancelled", () => { render(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); // 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(); 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(); expect(screen.getByText("Confirmed")).toBeInTheDocument(); }); it("renders Pending for pending status", () => { render(); expect(screen.getByText("Pending")).toBeInTheDocument(); }); it("renders Waitlisted for waitlisted status", () => { render(); expect(screen.getByText("Waitlisted")).toBeInTheDocument(); }); it("renders Completed for completed status", () => { render(); expect(screen.getByText("Completed")).toBeInTheDocument(); }); it("renders Cancelled for cancelled status", () => { render(); expect(screen.getByText("Cancelled")).toBeInTheDocument(); }); it("falls back to status string for unknown status", () => { render(); expect(screen.getByText("custom-status")).toBeInTheDocument(); }); it("uses correct CSS class for confirmed status", () => { render(); 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(); 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(); 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(); const badge = screen.getByText("unknown").closest('span'); expect(badge?.className).toContain("bg-stone-100"); expect(badge?.className).toContain("text-stone-600"); }); // GRO-2319 item 1: DB stores `no_show` (underscore) but the palette key is // `no-show` (hyphen) — without normalization it rendered raw gray text. it("renders the styled No-show badge for DB `no_show` status", () => { render(); const badge = screen.getByText("No-show").closest('span'); expect(badge?.className).toContain("bg-yellow-100"); expect(badge?.className).toContain("text-yellow-700"); }); }); describe("normalizeStatusKey (GRO-2319 item 1)", () => { it("maps underscore status keys to the hyphen palette key", () => { expect(normalizeStatusKey("no_show")).toBe("no-show"); }); it("leaves already-hyphenated / single-word keys unchanged", () => { expect(normalizeStatusKey("no-show")).toBe("no-show"); expect(normalizeStatusKey("confirmed")).toBe("confirmed"); }); }); describe("deriveDisplayStatus (GRO-2319 item 2)", () => { it("derives Pending for an upcoming, unconfirmed appointment", () => { expect( deriveDisplayStatus({ ...UPCOMING_APPT, status: "scheduled", confirmationStatus: "pending" }), ).toBe("pending"); }); it("keeps the raw status when the appointment is confirmed", () => { expect( deriveDisplayStatus({ ...UPCOMING_APPT, status: "confirmed", confirmationStatus: "confirmed" }), ).toBe("confirmed"); }); it("does not derive Pending for a past appointment", () => { expect( deriveDisplayStatus({ ...PAST_APPT, status: "completed", confirmationStatus: "pending" }), ).toBe("completed"); }); it("always shows Waitlisted for a waitlist-backed entry", () => { expect( deriveDisplayStatus({ ...UPCOMING_APPT, status: "waitlisted", confirmationStatus: undefined }), ).toBe("waitlisted"); }); }); 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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 }) { 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 } = {}; vi.mocked(global.fetch).mockImplementation(routedFetch(captured) as typeof fetch); render( {}} 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; 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( {}} 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(); }); }); describe("normalizeService", () => { it("maps API basePriceCents/durationMinutes to price (dollars)/duration", () => { const svc = normalizeService({ id: "svc-1", name: "Full Groom", basePriceCents: 4500, durationMinutes: 60, }); expect(svc.price).toBe(45); expect(svc.duration).toBe(60); }); it("preserves an already-normalized payload (price/duration)", () => { const svc = normalizeService({ id: "svc-2", name: "Bath", price: 30, duration: 30, }); expect(svc.price).toBe(30); expect(svc.duration).toBe(30); }); it("leaves price/duration undefined when both source shapes are absent", () => { const svc = normalizeService({ id: "svc-3", name: "Mystery" }); expect(svc.price).toBeUndefined(); expect(svc.duration).toBeUndefined(); }); it("coerces null fields to undefined", () => { const svc = normalizeService({ id: "svc-4", name: "Nail Trim", basePriceCents: null, durationMinutes: null, description: null, }); expect(svc.price).toBeUndefined(); expect(svc.duration).toBeUndefined(); expect(svc.description).toBeUndefined(); }); }); describe("formatServicePrice", () => { it("prefers an explicit priceRange string", () => { expect(formatServicePrice({ priceRange: "$40–$60", price: 45 })).toBe("$40–$60"); }); it("formats integer dollars without trailing zeros", () => { expect(formatServicePrice({ price: 45 })).toBe("$45"); }); it("formats fractional dollars to cents", () => { expect(formatServicePrice({ price: 45.5 })).toBe("$45.50"); }); it("returns null when no price is available (never '$undefined')", () => { expect(formatServicePrice({})).toBeNull(); expect(formatServicePrice({ price: undefined })).toBeNull(); }); });