Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa1fe00c51 | |||
| f0c58c193c | |||
| 4600dcf950 | |||
| 7daa9c480a | |||
| 746fad635f |
+22
-8
@@ -54,6 +54,8 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
|||||||
| TC-WEB-5.1.3 | Logout | Click logout button | Session cleared, redirected to login page |
|
| TC-WEB-5.1.3 | Logout | Click logout button | Session cleared, redirected to login page |
|
||||||
| TC-WEB-5.1.4 | Session indicator | After successful login | User info/initials visible in UI indicating active session |
|
| TC-WEB-5.1.4 | Session indicator | After successful login | User info/initials visible in UI indicating active session |
|
||||||
| TC-WEB-5.1.5 | Unauthenticated `/login` renders the form (GRO-2011) | In a private/incognito window with no session cookie, navigate to UAT `/login` | React root mounts; the GroomBook sign-in card with the OIDC button is visible. Network tab shows `/api/auth/get-session` 200, `/api/setup/status` 200, and the login form is rendered (NOT a blank white viewport). |
|
| TC-WEB-5.1.5 | Unauthenticated `/login` renders the form (GRO-2011) | In a private/incognito window with no session cookie, navigate to UAT `/login` | React root mounts; the GroomBook sign-in card with the OIDC button is visible. Network tab shows `/api/auth/get-session` 200, `/api/setup/status` 200, and the login form is rendered (NOT a blank white viewport). |
|
||||||
|
| TC-WEB-5.1.6 | Swallowed render error surfaces in DOM (GRO-2094) | Trigger a render-time exception in the React tree (e.g. via temporary throw in a child component on a test build) and load `/login` in a clean context | Either the login form renders normally (happy path) OR the top-level `ErrorBoundary` testid `error-boundary` is visible with a populated `error-boundary-message` pre block showing the exception name/message/stack. **NEVER** a blank `<div id="root">` with no error indicator. Browser console must contain either zero render errors or a `[ErrorBoundary]` line plus the raw exception. |
|
||||||
|
| TC-WEB-5.1.7 | Global `error` and `unhandledrejection` listeners are wired (GRO-2094) | In a clean browser context, load `/login`, then trigger `setTimeout(() => { throw new Error("synthetic") }, 0)` from the console and `Promise.reject(new Error("synthetic-promise"))` | Browser console shows `[window.error]` and `[unhandledrejection]` log lines with the thrown values. Confirms global listeners are active in production. |
|
||||||
|
|
||||||
### 5.2 Authentication — VITE_API_URL Set
|
### 5.2 Authentication — VITE_API_URL Set
|
||||||
|
|
||||||
@@ -180,22 +182,34 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
|||||||
| # | Scenario | Steps | Expected |
|
| # | Scenario | Steps | Expected |
|
||||||
|---|----------|-------|----------|
|
|---|----------|-------|----------|
|
||||||
| TC-WEB-5.12.1 | Client-facing view | Log in as client persona | Customer portal UI displayed |
|
| TC-WEB-5.12.1 | Client-facing view | Log in as client persona | Customer portal UI displayed |
|
||||||
| TC-WEB-5.12.2 | Appointment list | View client portal appointments | List of client's appointments visible |
|
| TC-WEB-5.12.2 | Appointment list loads | Sign in as `uat-customer@groombook.dev`, open **Appointments** | List of the customer's appointments renders. **No** "Failed to load appointments" error and **no** Retry button. (GRO-2180) |
|
||||||
| TC-WEB-5.12.3 | Confirm appointment | Click confirm on pending appointment | Appointment status updated to confirmed |
|
| TC-WEB-5.12.3 | Date/time display | Inspect each appointment card | Each card shows a human-readable date and time derived from the API `startTime` (e.g. "Mon, Jun 1, 2026" / "10:00 AM"); no `undefined` or blank date/time. (GRO-2180) |
|
||||||
| TC-WEB-5.12.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
|
| TC-WEB-5.12.4 | Book New reachable | On the loaded Appointments view (non-readonly), look for the **Book New** button | "Book New" button is visible and opens the booking modal. (GRO-2180) |
|
||||||
|
| TC-WEB-5.12.5 | Upcoming/Past split | Toggle the **Upcoming** and **Past** tabs | Future appointments appear under Upcoming; completed/cancelled/past appear under Past. (GRO-2180) |
|
||||||
|
| TC-WEB-5.12.6 | Confirm appointment | Click confirm on pending appointment | Appointment status updated to confirmed |
|
||||||
|
| TC-WEB-5.12.7 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
|
||||||
|
| TC-WEB-5.12.8 | Reschedule display | Open **Reschedule** on an upcoming appointment | Summary header shows the current appointment's date and time (from `startTime`); no `undefined`. (GRO-2180) |
|
||||||
|
|
||||||
#### 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 |
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Component } from "react";
|
||||||
|
import type { ErrorInfo, ReactNode } from "react";
|
||||||
|
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-level ErrorBoundary — renders the error visibly so the actual exception
|
||||||
|
* appears in the DOM (and therefore in the Playwright snapshot) instead of
|
||||||
|
* React 18+ unmounting the entire tree to a blank `<div id="root">`.
|
||||||
|
*
|
||||||
|
* Background: GRO-2094. The bundle was executing but never painting, with
|
||||||
|
* the failure swallowed. Surfacing the error here is the first step; the
|
||||||
|
* real fix is in the underlying component that threw.
|
||||||
|
*/
|
||||||
|
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
state: ErrorBoundaryState = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||||
|
// Also surface to the console — this is what the test harness greps for.
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("[ErrorBoundary] Uncaught render error:", error, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.error) {
|
||||||
|
const err = this.state.error;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="error-boundary"
|
||||||
|
style={{
|
||||||
|
padding: "2rem",
|
||||||
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||||
|
color: "#7f1d1d",
|
||||||
|
background: "#fef2f2",
|
||||||
|
minHeight: "100vh",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 style={{ fontSize: 18, margin: "0 0 0.5rem" }}>Something went wrong</h1>
|
||||||
|
<p style={{ margin: "0 0 1rem", color: "#991b1b" }}>
|
||||||
|
The app failed to render. The full error is shown below — please share this
|
||||||
|
output when reporting the bug.
|
||||||
|
</p>
|
||||||
|
<pre
|
||||||
|
data-testid="error-boundary-message"
|
||||||
|
style={{
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #fecaca",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{err.name}: {err.message}
|
||||||
|
{"\n\n"}
|
||||||
|
{err.stack ?? "(no stack)"}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection, StatusBadge } from "../portal/sections/Appointments.tsx";
|
import { parseTimeTo24Hour, isUpcoming, getAppointmentStart, formatAppointmentDate, formatAppointmentTime, CustomerNotesSection, ConfirmationSection, StatusBadge } from "../portal/sections/Appointments.tsx";
|
||||||
|
|
||||||
const UPCOMING_APPT = {
|
const UPCOMING_APPT = {
|
||||||
id: "appt-1",
|
id: "appt-1",
|
||||||
@@ -29,6 +29,26 @@ const PAST_APPT = {
|
|||||||
status: "completed" as const,
|
status: "completed" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// GRO-2180: the /api/portal/appointments contract returns ISO startTime/endTime
|
||||||
|
// and no date/time fields. These fixtures mirror that shape exactly.
|
||||||
|
const API_UPCOMING_APPT = {
|
||||||
|
id: "appt-api-1",
|
||||||
|
petId: "pet-1",
|
||||||
|
serviceId: "service-1",
|
||||||
|
groomerId: null,
|
||||||
|
startTime: "2099-01-01T10:00:00.000Z",
|
||||||
|
endTime: "2099-01-01T10:45:00.000Z",
|
||||||
|
status: "confirmed" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_PAST_APPT = {
|
||||||
|
...API_UPCOMING_APPT,
|
||||||
|
id: "appt-api-2",
|
||||||
|
startTime: "2020-01-01T10:00:00.000Z",
|
||||||
|
endTime: "2020-01-01T10:45:00.000Z",
|
||||||
|
status: "completed" as const,
|
||||||
|
};
|
||||||
|
|
||||||
describe("parseTimeTo24Hour", () => {
|
describe("parseTimeTo24Hour", () => {
|
||||||
it("converts AM times correctly", () => {
|
it("converts AM times correctly", () => {
|
||||||
expect(parseTimeTo24Hour("9:00 AM")).toBe("09:00:00");
|
expect(parseTimeTo24Hour("9:00 AM")).toBe("09:00:00");
|
||||||
@@ -42,6 +62,13 @@ describe("parseTimeTo24Hour", () => {
|
|||||||
expect(parseTimeTo24Hour("11:00 PM")).toBe("23:00:00");
|
expect(parseTimeTo24Hour("11:00 PM")).toBe("23:00:00");
|
||||||
expect(parseTimeTo24Hour("12:00 PM")).toBe("12:00:00");
|
expect(parseTimeTo24Hour("12:00 PM")).toBe("12:00:00");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GRO-2180 regression: must not throw on undefined/empty input.
|
||||||
|
it("returns a safe default for missing input", () => {
|
||||||
|
expect(() => parseTimeTo24Hour(undefined)).not.toThrow();
|
||||||
|
expect(parseTimeTo24Hour(undefined)).toBe("00:00:00");
|
||||||
|
expect(parseTimeTo24Hour("")).toBe("00:00:00");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isUpcoming", () => {
|
describe("isUpcoming", () => {
|
||||||
@@ -60,6 +87,63 @@ describe("isUpcoming", () => {
|
|||||||
it("returns false for completed appointments", () => {
|
it("returns false for completed appointments", () => {
|
||||||
expect(isUpcoming({ ...UPCOMING_APPT, status: "completed" })).toBe(false);
|
expect(isUpcoming({ ...UPCOMING_APPT, status: "completed" })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GRO-2180 regression: the API contract uses ISO startTime with no date/time.
|
||||||
|
// Previously isUpcoming threw a TypeError on this shape, breaking the page.
|
||||||
|
it("does not throw on the API startTime/endTime shape", () => {
|
||||||
|
expect(() => isUpcoming(API_UPCOMING_APPT)).not.toThrow();
|
||||||
|
expect(() => isUpcoming(API_PAST_APPT)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for future appointments using startTime", () => {
|
||||||
|
expect(isUpcoming(API_UPCOMING_APPT)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for past appointments using startTime", () => {
|
||||||
|
expect(isUpcoming(API_PAST_APPT)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false (not throw) when neither startTime nor date is present", () => {
|
||||||
|
const { startTime, endTime, ...noDate } = API_UPCOMING_APPT;
|
||||||
|
void startTime;
|
||||||
|
void endTime;
|
||||||
|
expect(() => isUpcoming(noDate)).not.toThrow();
|
||||||
|
expect(isUpcoming(noDate)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAppointmentStart / display helpers (GRO-2180)", () => {
|
||||||
|
it("resolves the start instant from ISO startTime", () => {
|
||||||
|
const start = getAppointmentStart(API_UPCOMING_APPT);
|
||||||
|
expect(start).not.toBeNull();
|
||||||
|
expect(start?.toISOString()).toBe("2099-01-01T10:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to legacy date + time when startTime is absent", () => {
|
||||||
|
const start = getAppointmentStart(UPCOMING_APPT);
|
||||||
|
expect(start).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when there is no usable date", () => {
|
||||||
|
const { startTime, endTime, ...noDate } = API_UPCOMING_APPT;
|
||||||
|
void startTime;
|
||||||
|
void endTime;
|
||||||
|
expect(getAppointmentStart(noDate)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats date/time without throwing on the API shape", () => {
|
||||||
|
expect(() => formatAppointmentDate(API_UPCOMING_APPT)).not.toThrow();
|
||||||
|
expect(() => formatAppointmentTime(API_UPCOMING_APPT)).not.toThrow();
|
||||||
|
expect(formatAppointmentDate(API_UPCOMING_APPT)).not.toBe("");
|
||||||
|
expect(formatAppointmentTime(API_UPCOMING_APPT)).not.toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty display strings when there is no usable date", () => {
|
||||||
|
const { startTime, endTime, ...noDate } = API_UPCOMING_APPT;
|
||||||
|
void startTime;
|
||||||
|
void endTime;
|
||||||
|
expect(formatAppointmentDate(noDate)).toBe("");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("CustomerNotesSection", () => {
|
describe("CustomerNotesSection", () => {
|
||||||
@@ -530,7 +614,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 +628,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 +636,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({
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, cleanup } from "@testing-library/react";
|
||||||
|
import { ErrorBoundary } from "../ErrorBoundary";
|
||||||
|
|
||||||
|
function ThrowingChild(): never {
|
||||||
|
throw new Error("synthetic render-time failure for GRO-2094");
|
||||||
|
}
|
||||||
|
|
||||||
|
function GoodChild() {
|
||||||
|
return <div data-testid="good-child">ok</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ErrorBoundary (GRO-2094)", () => {
|
||||||
|
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// React 18+ logs caught render errors to console.error via React's own
|
||||||
|
// instrumentation; suppress it so test output is clean but capture it
|
||||||
|
// for an assertion below.
|
||||||
|
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders children when nothing throws", () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<GoodChild />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("good-child")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("error-boundary")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the error visibly when a child throws during render", () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ThrowingChild />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
|
||||||
|
const fallback = screen.getByTestId("error-boundary");
|
||||||
|
expect(fallback).toBeInTheDocument();
|
||||||
|
const message = screen.getByTestId("error-boundary-message");
|
||||||
|
// The actual exception is shown — no more silent blank root.
|
||||||
|
expect(message.textContent).toContain("synthetic render-time failure for GRO-2094");
|
||||||
|
// The boundary also calls console.error so it shows up in the Playwright
|
||||||
|
// console log even if the DOM-rendered fallback is somehow missed.
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
+37
-3
@@ -2,9 +2,41 @@ import { StrictMode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { App } from "./App.js";
|
import { App } from "./App.js";
|
||||||
|
import { ErrorBoundary } from "./ErrorBoundary.js";
|
||||||
import { installDevFetchInterceptor } from "./lib/devFetch.js";
|
import { installDevFetchInterceptor } from "./lib/devFetch.js";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// Global error capture (GRO-2094).
|
||||||
|
//
|
||||||
|
// Symptom: React root stays empty at /login — bundle parses, no console
|
||||||
|
// errors, no error boundary fallback. Some failure is being swallowed
|
||||||
|
// before it reaches React's commit phase. These listeners make sure any
|
||||||
|
// thrown error or unhandled promise rejection is at least visible in
|
||||||
|
// the console (and in the Playwright network/console log) instead of
|
||||||
|
// vanishing into the void.
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
function reportGlobalError(kind: string, payload: unknown): void {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`[${kind}]`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("error", (event) => {
|
||||||
|
reportGlobalError("window.error", {
|
||||||
|
message: event.message,
|
||||||
|
filename: event.filename,
|
||||||
|
lineno: event.lineno,
|
||||||
|
colno: event.colno,
|
||||||
|
error: event.error,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("unhandledrejection", (event) => {
|
||||||
|
reportGlobalError("unhandledrejection", {
|
||||||
|
reason: event.reason,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
installDevFetchInterceptor();
|
installDevFetchInterceptor();
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
@@ -12,8 +44,10 @@ if (!root) throw new Error("Root element not found");
|
|||||||
|
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<ErrorBoundary>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
</BrowserRouter>
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</ErrorBoundary>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,13 +2,48 @@ 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;
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
groomerId: string | null;
|
groomerId: string | null;
|
||||||
date: string;
|
// The /api/portal/appointments contract returns ISO `startTime`/`endTime`.
|
||||||
time: string;
|
// `date`/`time` are the legacy display shape, still produced locally by some
|
||||||
|
// flows (e.g. test fixtures), so both shapes are optional and code reads
|
||||||
|
// `startTime` first, falling back to `date` + `time`.
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
date?: string;
|
||||||
|
time?: string;
|
||||||
status: 'scheduled' | 'confirmed' | 'pending' | 'waitlisted' | 'completed' | 'cancelled' | 'no-show';
|
status: 'scheduled' | 'confirmed' | 'pending' | 'waitlisted' | 'completed' | 'cancelled' | 'no-show';
|
||||||
petName?: string;
|
petName?: string;
|
||||||
serviceName?: string;
|
serviceName?: string;
|
||||||
@@ -62,7 +97,8 @@ export function formatDate(dateStr: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseTimeTo24Hour(time: string): string {
|
export function parseTimeTo24Hour(time: string | null | undefined): string {
|
||||||
|
if (!time) return '00:00:00';
|
||||||
const parts = time.split(' ');
|
const parts = time.split(' ');
|
||||||
const hoursMinutes = parts[0] ?? '';
|
const hoursMinutes = parts[0] ?? '';
|
||||||
const period = parts[1] ?? '';
|
const period = parts[1] ?? '';
|
||||||
@@ -75,10 +111,41 @@ export function parseTimeTo24Hour(time: string): string {
|
|||||||
return `${hours24.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`;
|
return `${hours24.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an appointment's start instant from either the API contract shape
|
||||||
|
* (ISO `startTime`) or the legacy `date` + `time` shape. Returns null when no
|
||||||
|
* usable date is present or the value is unparseable, so callers never throw.
|
||||||
|
*/
|
||||||
|
export function getAppointmentStart(appt: Appointment): Date | null {
|
||||||
|
const raw = appt.startTime
|
||||||
|
? appt.startTime
|
||||||
|
: appt.date
|
||||||
|
? `${appt.date}T${parseTimeTo24Hour(appt.time)}`
|
||||||
|
: null;
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = new Date(raw);
|
||||||
|
return isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
export function isUpcoming(appt: Appointment): boolean {
|
export function isUpcoming(appt: Appointment): boolean {
|
||||||
const now = new Date();
|
const start = getAppointmentStart(appt);
|
||||||
const apptDate = new Date(`${appt.date}T${parseTimeTo24Hour(appt.time)}`);
|
if (!start) return false;
|
||||||
return apptDate > now && appt.status !== 'cancelled' && appt.status !== 'completed';
|
return start > new Date() && appt.status !== 'cancelled' && appt.status !== 'completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display date string, preferring the ISO `startTime` contract shape. */
|
||||||
|
export function formatAppointmentDate(appt: Appointment): string {
|
||||||
|
const start = getAppointmentStart(appt);
|
||||||
|
return start ? formatDate(start.toISOString()) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display time string, preferring the ISO `startTime` contract shape. */
|
||||||
|
export function formatAppointmentTime(appt: Appointment): string {
|
||||||
|
const start = getAppointmentStart(appt);
|
||||||
|
if (start) {
|
||||||
|
return start.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
return appt.time ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
@@ -309,11 +376,11 @@ function AppointmentCard({
|
|||||||
<div className="flex items-center gap-3 text-xs text-stone-500 mt-0.5">
|
<div className="flex items-center gap-3 text-xs text-stone-500 mt-0.5">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Calendar size={12} />
|
<Calendar size={12} />
|
||||||
{formatDate(appt.date)}
|
{formatAppointmentDate(appt)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock size={12} />
|
<Clock size={12} />
|
||||||
{appt.time}
|
{formatAppointmentTime(appt)}
|
||||||
</span>
|
</span>
|
||||||
<span>with {appt.groomerName || 'First Available'}</span>
|
<span>with {appt.groomerName || 'First Available'}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -595,19 +662,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;
|
||||||
@@ -671,7 +748,7 @@ export function RescheduleFlow({
|
|||||||
{appt.petName || 'Pet'} — {appt.serviceName || 'Service'}
|
{appt.petName || 'Pet'} — {appt.serviceName || 'Service'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-stone-500 mt-0.5">
|
<p className="text-stone-500 mt-0.5">
|
||||||
{formatDate(appt.date)} at {appt.time} with{' '}
|
{formatAppointmentDate(appt)} at {formatAppointmentTime(appt)} with{' '}
|
||||||
{appt.groomerName || 'First Available'}
|
{appt.groomerName || 'First Available'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -766,19 +843,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