From f0c58c193cb799ba3aa892bd7a268792ff46f076 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 2 Jun 2026 19:06:15 +0000 Subject: [PATCH] fix(GRO-2105): include serviceId in BookingFlow/RescheduleFlow availability call (#46) --- UAT_PLAYBOOK.md | 18 ++++-- src/__tests__/Appointments.test.tsx | 39 ++++++++++++- src/portal/sections/Appointments.tsx | 86 ++++++++++++++++++++++------ 3 files changed, 118 insertions(+), 25 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 58f2b31..0e27091 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -186,18 +186,26 @@ export const { signIn, signOut, useSession, changePassword } = authClient; | TC-WEB-5.12.3 | Confirm appointment | Click confirm on pending appointment | Appointment status updated to confirmed | | TC-WEB-5.12.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled | -#### 5.12b Dynamic Portal Time Slots (GRO-1793) +#### 5.12b Dynamic Portal Time Slots (GRO-1793, GRO-2105) | # | Scenario | Steps | Expected | |---|----------|-------|----------| -| TC-WEB-5.12.5 | BookingFlow dynamic slots | Open Book New, select pet and service, pick a date | Time slots fetched from API; "Checking availability…" shown while loading | +| TC-WEB-5.12.5 | BookingFlow dynamic slots | Open Book New, select pet and service, pick a date | `GET /api/book/availability?serviceId=&date=`; "Checking availability…" shown while loading; slot list rendered | | TC-WEB-5.12.6 | BookingFlow slots match wizard | Compare BookingFlow slot times with public booking wizard for same date | Same slots displayed | -| TC-WEB-5.12.7 | BookingFlow error state | Mock API failure on availability fetch | "Failed to load time slots" error shown | +| TC-WEB-5.12.7 | BookingFlow error state | Mock API failure on availability fetch (4xx/5xx OR a 200 with non-array body) | "Failed to load time slots" error shown and the page stays interactive (no white screen) | | TC-WEB-5.12.8 | BookingFlow no slots | Select date with no availability | "No available slots on this date" shown | -| TC-WEB-5.12.9 | RescheduleFlow dynamic slots | Open reschedule, pick a new date | Time slots fetched from API; loading state shown | -| TC-WEB-5.12.10 | RescheduleFlow error state | Mock API failure on availability fetch | "Failed to load time slots" error shown | +| TC-WEB-5.12.9 | RescheduleFlow dynamic slots | Open reschedule, pick a new date | `GET /api/book/availability?serviceId=&date=`; loading state shown; slot list rendered | +| TC-WEB-5.12.10 | RescheduleFlow error state | Mock API failure on availability fetch (4xx/5xx OR a 200 with non-array body) | "Failed to load time slots" error shown and the page stays interactive (no white screen) | | TC-WEB-5.12.11 | RescheduleFlow no slots | Select date with no availability | "No available slots on this date" shown | +> **GRO-2105 regression note:** prior to the fix, both `BookingFlow` and +> `RescheduleFlow` called `/api/book/availability` with only `date=…`, so the +> API responded 400 `{error:"serviceId and date are required"}`. The React +> handler then `.map()`'d that error object, throwing `TypeError: ee.map is +> not a function` and wiping `
`. The fix ensures both flows +> include `serviceId` in the query string and surface the API's error string +> (or "Failed to load time slots") instead of crashing. + #### 5.12c Waitlist/Booking Status Badges (GRO-1795) | # | Scenario | Steps | Expected | diff --git a/src/__tests__/Appointments.test.tsx b/src/__tests__/Appointments.test.tsx index 0f6fd76..d17e87b 100644 --- a/src/__tests__/Appointments.test.tsx +++ b/src/__tests__/Appointments.test.tsx @@ -530,7 +530,7 @@ describe("RescheduleFlow dynamic time slots", () => { }); }); - it("calls /api/book/availability with the selected date", async () => { + 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[], @@ -544,7 +544,7 @@ describe("RescheduleFlow dynamic time slots", () => { await waitFor(() => { expect(global.fetch).toHaveBeenCalledWith( - "/api/book/availability?date=2027-02-20", + "/api/book/availability?serviceId=service-1&date=2027-02-20", expect.objectContaining({ headers: expect.objectContaining({ "X-Impersonation-Session-Id": "test-session-id" }), }) @@ -552,6 +552,41 @@ describe("RescheduleFlow dynamic time slots", () => { }); }); + 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({ diff --git a/src/portal/sections/Appointments.tsx b/src/portal/sections/Appointments.tsx index 0a86e2f..1d30e86 100644 --- a/src/portal/sections/Appointments.tsx +++ b/src/portal/sections/Appointments.tsx @@ -2,6 +2,35 @@ import React, { useState, useEffect } from 'react'; import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react'; import { ANALYTICS_EVENTS, fireAnalyticsEvent } from '../../lib/analytics'; +// ─── Availability fetch helper ─────────────────────────────────────────────── +// Returns ISO startTime strings for the given service/date, or an error message. +// Validates HTTP status and that the body is actually an array — the API +// responds with `{error: "..."}` on 4xx, and we must not treat that as slots. +const AVAILABILITY_ERROR_MESSAGE = 'Failed to load time slots'; + +async function fetchAvailability( + params: { serviceId: string; date: string }, + sessionId: string | null, +): Promise<{ times: string[]; error: string | null }> { + const url = `/api/book/availability?${new URLSearchParams(params).toString()}`; + const headers: Record = {}; + if (sessionId) headers['X-Impersonation-Session-Id'] = sessionId; + try { + const res = await fetch(url, { headers }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + return { times: [], error: body.error ?? `${AVAILABILITY_ERROR_MESSAGE} (HTTP ${res.status})` }; + } + const data: unknown = await res.json(); + if (!Array.isArray(data)) { + return { times: [], error: AVAILABILITY_ERROR_MESSAGE }; + } + return { times: data as string[], error: null }; + } catch { + return { times: [], error: AVAILABILITY_ERROR_MESSAGE }; + } +} + export interface Appointment { id: string; petId: string; @@ -595,19 +624,29 @@ export function RescheduleFlow({ useEffect(() => { if (!selectedDate || !sessionId) { setAvailableTimes([]); + setSlotsError(null); return; } - const params = new URLSearchParams({ date: selectedDate }); + if (!appt.serviceId) { + setAvailableTimes([]); + setSlotsError('Failed to load time slots'); + return; + } + let cancelled = false; setSlotsLoading(true); setSlotsError(null); - fetch(`/api/book/availability?${params.toString()}`, { - headers: { "X-Impersonation-Session-Id": sessionId ?? "" }, - }) - .then((r) => r.json() as Promise) - .then(setAvailableTimes) - .catch(() => setSlotsError('Failed to load time slots')) - .finally(() => setSlotsLoading(false)); - }, [selectedDate, sessionId]); + fetchAvailability({ serviceId: appt.serviceId, date: selectedDate }, sessionId).then( + ({ times, error }) => { + if (cancelled) return; + setAvailableTimes(times); + setSlotsError(error); + setSlotsLoading(false); + }, + ); + return () => { + cancelled = true; + }; + }, [selectedDate, sessionId, appt.serviceId]); async function handleSubmit() { if (!selectedDate || !selectedTime) return; @@ -766,19 +805,30 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) { useEffect(() => { if (!selectedDate || !sessionId) { setAvailableTimes([]); + setSlotsError(null); return; } - const params = new URLSearchParams({ date: selectedDate }); + const serviceId = selectedServices[0]?.id; + if (!serviceId) { + setAvailableTimes([]); + setSlotsError('Failed to load time slots'); + return; + } + let cancelled = false; setSlotsLoading(true); setSlotsError(null); - fetch(`/api/book/availability?${params.toString()}`, { - headers: { "X-Impersonation-Session-Id": sessionId ?? "" }, - }) - .then((r) => r.json() as Promise) - .then(setAvailableTimes) - .catch(() => setSlotsError('Failed to load time slots')) - .finally(() => setSlotsLoading(false)); - }, [selectedDate, sessionId]); + fetchAvailability({ serviceId, date: selectedDate }, sessionId).then( + ({ times, error }) => { + if (cancelled) return; + setAvailableTimes(times); + setSlotsError(error); + setSlotsLoading(false); + }, + ); + return () => { + cancelled = true; + }; + }, [selectedDate, sessionId, selectedServices]); useEffect(() => { const fetchData = async () => {