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:
@@ -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