docs(GRO-2105): clarify §5.12 test cases require serviceId + non-array error path
This commit is contained in:
+13
-5
@@ -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.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 |
|
| 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 |
|
| # | 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=<selected>&date=<picked>`; "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.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.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.9 | RescheduleFlow dynamic slots | Open reschedule, pick a new date | `GET /api/book/availability?serviceId=<appt.serviceId>&date=<picked>`; loading state shown; slot list rendered |
|
||||||
| 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.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 |
|
| 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 `<div id="root">`. 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)
|
#### 5.12c Waitlist/Booking Status Badges (GRO-1795)
|
||||||
|
|
||||||
| # | Scenario | Steps | Expected |
|
| # | Scenario | Steps | Expected |
|
||||||
|
|||||||
@@ -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({
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ["9:00 AM"] as string[],
|
json: async () => ["9:00 AM"] as string[],
|
||||||
@@ -544,7 +544,7 @@ describe("RescheduleFlow dynamic time slots", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(global.fetch).toHaveBeenCalledWith(
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
"/api/book/availability?date=2027-02-20",
|
"/api/book/availability?serviceId=service-1&date=2027-02-20",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: expect.objectContaining({ "X-Impersonation-Session-Id": "test-session-id" }),
|
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(<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 () => {
|
it("re-fetches slots when date changes", async () => {
|
||||||
vi.mocked(global.fetch)
|
vi.mocked(global.fetch)
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
|
|||||||
@@ -2,6 +2,35 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
|
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
|
||||||
import { ANALYTICS_EVENTS, fireAnalyticsEvent } from '../../lib/analytics';
|
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<string, string> = {};
|
||||||
|
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 {
|
export interface Appointment {
|
||||||
id: string;
|
id: string;
|
||||||
petId: string;
|
petId: string;
|
||||||
@@ -595,19 +624,29 @@ export function RescheduleFlow({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedDate || !sessionId) {
|
if (!selectedDate || !sessionId) {
|
||||||
setAvailableTimes([]);
|
setAvailableTimes([]);
|
||||||
|
setSlotsError(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const params = new URLSearchParams({ date: selectedDate });
|
if (!appt.serviceId) {
|
||||||
|
setAvailableTimes([]);
|
||||||
|
setSlotsError('Failed to load time slots');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
setSlotsLoading(true);
|
setSlotsLoading(true);
|
||||||
setSlotsError(null);
|
setSlotsError(null);
|
||||||
fetch(`/api/book/availability?${params.toString()}`, {
|
fetchAvailability({ serviceId: appt.serviceId, date: selectedDate }, sessionId).then(
|
||||||
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
|
({ times, error }) => {
|
||||||
})
|
if (cancelled) return;
|
||||||
.then((r) => r.json() as Promise<string[]>)
|
setAvailableTimes(times);
|
||||||
.then(setAvailableTimes)
|
setSlotsError(error);
|
||||||
.catch(() => setSlotsError('Failed to load time slots'))
|
setSlotsLoading(false);
|
||||||
.finally(() => setSlotsLoading(false));
|
},
|
||||||
}, [selectedDate, sessionId]);
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [selectedDate, sessionId, appt.serviceId]);
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!selectedDate || !selectedTime) return;
|
if (!selectedDate || !selectedTime) return;
|
||||||
@@ -766,19 +805,30 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedDate || !sessionId) {
|
if (!selectedDate || !sessionId) {
|
||||||
setAvailableTimes([]);
|
setAvailableTimes([]);
|
||||||
|
setSlotsError(null);
|
||||||
return;
|
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);
|
setSlotsLoading(true);
|
||||||
setSlotsError(null);
|
setSlotsError(null);
|
||||||
fetch(`/api/book/availability?${params.toString()}`, {
|
fetchAvailability({ serviceId, date: selectedDate }, sessionId).then(
|
||||||
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
|
({ times, error }) => {
|
||||||
})
|
if (cancelled) return;
|
||||||
.then((r) => r.json() as Promise<string[]>)
|
setAvailableTimes(times);
|
||||||
.then(setAvailableTimes)
|
setSlotsError(error);
|
||||||
.catch(() => setSlotsError('Failed to load time slots'))
|
setSlotsLoading(false);
|
||||||
.finally(() => setSlotsLoading(false));
|
},
|
||||||
}, [selectedDate, sessionId]);
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [selectedDate, sessionId, selectedServices]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user