Promote dev → uat: GRO-2211/2218/2207 + GRO-2234 portal Book New (cumulative) (#56)
CI / Lint & Typecheck (push) Successful in 28s
CI / Test (push) Successful in 28s
CI / Build & Push Docker Image (push) Successful in 41s
CI / Test (pull_request) Successful in 21s
CI / Lint & Typecheck (pull_request) Successful in 27s
CI / Build & Push Docker Image (pull_request) Successful in 47s
CI / Lint & Typecheck (push) Successful in 28s
CI / Test (push) Successful in 28s
CI / Build & Push Docker Image (push) Successful in 41s
CI / Test (pull_request) Successful in 21s
CI / Lint & Typecheck (pull_request) Successful in 27s
CI / Build & Push Docker Image (pull_request) Successful in 47s
This commit was merged in pull request #56.
This commit is contained in:
@@ -244,6 +244,16 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||
| TC-WEB-5.12.22 | Slot buttons show formatted label | Sign in as `uat-customer@groombook.dev`, open `Appointments`, click "Book New", select a pet and service, pick a date with availability | Each time-slot button shows a human-readable label like `10:00 AM` (UTC), never a raw ISO timestamp (e.g. not `2026-06-09T10:00:00.000Z`) |
|
||||
| TC-WEB-5.12.23 | Confirmation review shows formatted label | Continue the Book New wizard to the Review step | The "Date & Time" summary and the final confirmation both display the formatted slot label (e.g. `10:00 AM`), not a raw ISO string |
|
||||
| TC-WEB-5.12.24 | Booking submit succeeds (regression) | Complete the Book New wizard and submit the request | Request succeeds with no `500` / `invalid input syntax for type time` error; the booking POST sends `preferredTime` as `HH:MM:SS` (e.g. `10:00:00`); the new appointment appears in the Upcoming list |
|
||||
| TC-WEB-5.12.25 | Slow-wizard submit succeeds (GRO-2234) | Sign in as `uat-customer@groombook.dev`, open `Appointments`, click "Book New", then deliberately pace the wizard (pet → service → groomer → date/slot → review) so that **>2 minutes** elapse before clicking "Confirm Booking". | Submit returns success — **no** "Failed to book appointment. Please try again." error. In DevTools → Network, if the first `POST /api/portal/waitlist` returns `401`, a `POST /api/portal/session-from-auth` fires immediately after and the booking is retried once with the fresh `X-Impersonation-Session-Id`, then returns 201. The appointment appears in the Upcoming list. |
|
||||
|
||||
> **GRO-2234 note:** A deliberately-paced Book New wizard could outlive the
|
||||
> portal impersonation session, so the final `POST /api/portal/waitlist` returned
|
||||
> `401 {"error":"Unauthorized"}` ("Failed to book appointment"). The web fix adds
|
||||
> a transparent one-shot re-mint: on a `401` from the waitlist submit,
|
||||
> `BookingFlow` calls `POST /api/portal/session-from-auth` (the Better Auth
|
||||
> cookie is still valid) and retries the submit once with the fresh session id.
|
||||
> The companion API fix (groombook/api GRO-2234) adds bounded sliding expiration
|
||||
> so active sessions rarely lapse in the first place.
|
||||
|
||||
> **GRO-2211/GRO-2213 note:** The Book New wizard previously rendered the raw
|
||||
> UTC ISO slot string as the button/confirmation label and submitted that same
|
||||
|
||||
@@ -801,4 +801,75 @@ describe("BookingFlow Book New funnel (GRO-2213)", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,28 @@ import { ANALYTICS_EVENTS, fireAnalyticsEvent } from '../../lib/analytics';
|
||||
// responds with `{error: "..."}` on 4xx, and we must not treat that as slots.
|
||||
const AVAILABILITY_ERROR_MESSAGE = 'Failed to load time slots';
|
||||
|
||||
/**
|
||||
* Re-mint an SSO-bridge portal session from the active Better Auth session.
|
||||
* Defense-in-depth for GRO-2234: if a portal call returns 401 mid-flow (the
|
||||
* impersonation session lapsed during a slow wizard), the customer's Better
|
||||
* Auth cookie is still valid, so we can transparently obtain a fresh portal
|
||||
* session id and retry once. Returns the new session id, or null if no Better
|
||||
* Auth session is available (e.g. staff/dev impersonation paths).
|
||||
*/
|
||||
async function remintPortalSession(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch('/api/portal/session-from-auth', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json().catch(() => ({}))) as { sessionId?: string };
|
||||
return data.sessionId ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAvailability(
|
||||
params: { serviceId: string; date: string },
|
||||
sessionId: string | null,
|
||||
@@ -993,26 +1015,40 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/portal/waitlist', {
|
||||
const payload = JSON.stringify({
|
||||
petId: selectedPet.id,
|
||||
serviceId: selectedServices[0]?.id,
|
||||
serviceIds: selectedServices.map((s) => s.id),
|
||||
addOnIds: selectedAddOns.map((s) => s.id),
|
||||
groomerId: selectedGroomer === 'first-available' ? null : selectedGroomer,
|
||||
preferredDate: selectedDate,
|
||||
preferredTime: slotToTime(selectedTime),
|
||||
notes: notes || undefined,
|
||||
recurring: recurring || undefined,
|
||||
});
|
||||
const submitWaitlist = (id: string) =>
|
||||
fetch('/api/portal/waitlist', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Impersonation-Session-Id': sessionId ?? '',
|
||||
'X-Impersonation-Session-Id': id,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
petId: selectedPet.id,
|
||||
serviceId: selectedServices[0]?.id,
|
||||
serviceIds: selectedServices.map((s) => s.id),
|
||||
addOnIds: selectedAddOns.map((s) => s.id),
|
||||
groomerId: selectedGroomer === 'first-available' ? null : selectedGroomer,
|
||||
preferredDate: selectedDate,
|
||||
preferredTime: slotToTime(selectedTime),
|
||||
notes: notes || undefined,
|
||||
recurring: recurring || undefined,
|
||||
}),
|
||||
body: payload,
|
||||
});
|
||||
|
||||
try {
|
||||
let response = await submitWaitlist(sessionId);
|
||||
|
||||
// GRO-2234: a deliberately-paced wizard can outlive the portal session.
|
||||
// The customer's Better Auth session is still valid, so transparently
|
||||
// re-mint a fresh portal session and retry once before surfacing an error.
|
||||
if (response.status === 401) {
|
||||
const freshSessionId = await remintPortalSession();
|
||||
if (freshSessionId) {
|
||||
response = await submitWaitlist(freshSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
setConfirmed(true);
|
||||
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT, { step: "submit", flow: "portal" });
|
||||
|
||||
Reference in New Issue
Block a user