import { describe, it, expect, vi, beforeEach } from "vitest"; import { Hono } from "hono"; // GRO-2235: a duplicate active waitlist entry violates the partial unique index // idx_waitlist_active_unique. postgres-js surfaces it as SQLSTATE 23505 — the // handler must return a friendly 409, not a generic 500. The first insert still // returns 201, and unrelated errors still surface as 500. const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001"; const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003"; const PET_ID = "880e8400-e29b-41d4-a716-446655440004"; const SERVICE_ID = "990e8400-e29b-41d4-a716-446655440005"; const futureDate = () => new Date(Date.now() + 30 * 60 * 1000); const ACTIVE_SESSION = { id: SESSION_ID, clientId: CLIENT_ID, status: "active" as const, reason: "manual", startedAt: new Date(), expiresAt: futureDate(), createdAt: new Date(), }; // Behaviour knob for the waitlist insert: "ok" returns a row, "duplicate" throws // a postgres-js-shaped unique-violation, "other" throws an unrelated error. let waitlistInsertMode: "ok" | "duplicate" | "other" = "ok"; function resetMock() { waitlistInsertMode = "ok"; } function tableProxy(name: string) { return new Proxy( { _name: name }, { get: (t, p) => (p === "_name" ? name : { table: name, column: p }) } ); } vi.mock("@groombook/db", () => { function makeChainable(data: unknown[]): unknown { const arr = [...data]; const chain = new Proxy(arr, { get(target, prop) { if (prop === "where" || prop === "orderBy" || prop === "limit") { return () => chain; } // @ts-expect-error proxy return target[prop]; }, }); return chain; } const impersonationSessions = tableProxy("impersonationSessions"); const waitlistEntries = tableProxy("waitlistEntries"); const impersonationAuditLogs = tableProxy("impersonationAuditLogs"); return { getDb: () => ({ select: () => ({ from: (table: { _name: string }) => { if (table._name === "impersonationSessions") { return makeChainable([ACTIVE_SESSION]); } return makeChainable([]); }, }), insert: (table: { _name: string }) => ({ values: (vals: Record) => ({ returning: () => { if (table._name === "waitlistEntries") { if (waitlistInsertMode === "duplicate") { throw Object.assign(new Error("duplicate key value"), { code: "23505" }); } if (waitlistInsertMode === "other") { throw Object.assign(new Error("not null violation"), { code: "23502" }); } return [{ id: "entry-1", ...vals }]; } // impersonationAuditLogs and anything else: succeed silently. return [{ id: "audit-1", ...vals }]; }, }), }), update: () => ({ set: () => ({ where: () => Promise.resolve() }), }), }), impersonationSessions, waitlistEntries, impersonationAuditLogs, appointments: tableProxy("appointments"), clients: tableProxy("clients"), pets: tableProxy("pets"), services: tableProxy("services"), staff: tableProxy("staff"), invoices: tableProxy("invoices"), invoiceLineItems: tableProxy("invoiceLineItems"), eq: vi.fn(), and: vi.fn(), inArray: vi.fn(), }; }); const { portalRouter } = await import("../routes/portal.js"); const app = new Hono(); app.route("/portal", portalRouter); function postWaitlist(body: unknown) { return app.request("/portal/waitlist", { method: "POST", headers: { "Content-Type": "application/json", "X-Impersonation-Session-Id": SESSION_ID, }, body: JSON.stringify(body), }); } const VALID_BODY = { petId: PET_ID, serviceId: SERVICE_ID, preferredDate: "2026-07-01", preferredTime: "09:00", }; beforeEach(() => resetMock()); describe("POST /portal/waitlist duplicate handling (GRO-2235)", () => { it("returns 201 for the first insert", async () => { waitlistInsertMode = "ok"; const res = await postWaitlist(VALID_BODY); expect(res.status).toBe(201); }); it("returns 409 with a friendly message for a duplicate (23505)", async () => { waitlistInsertMode = "duplicate"; const res = await postWaitlist(VALID_BODY); expect(res.status).toBe(409); const json = (await res.json()) as { error: string }; expect(json.error).toBe( "You already have a booking for this pet at that date and time." ); }); it("still surfaces unrelated DB errors as 500", async () => { waitlistInsertMode = "other"; const res = await postWaitlist(VALID_BODY); expect(res.status).toBe(500); }); });