Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bdb96631f | |||
| d06c3b8d31 |
@@ -0,0 +1,154 @@
|
|||||||
|
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<string, unknown>) => ({
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+26
-10
@@ -596,16 +596,32 @@ portalRouter.post(
|
|||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const clientId = c.get("portalClientId");
|
const clientId = c.get("portalClientId");
|
||||||
|
|
||||||
const [entry] = await db
|
let entry;
|
||||||
.insert(waitlistEntries)
|
try {
|
||||||
.values({
|
[entry] = await db
|
||||||
clientId,
|
.insert(waitlistEntries)
|
||||||
petId: body.petId,
|
.values({
|
||||||
serviceId: body.serviceId,
|
clientId,
|
||||||
preferredDate: body.preferredDate,
|
petId: body.petId,
|
||||||
preferredTime: normalizeTime(body.preferredTime),
|
serviceId: body.serviceId,
|
||||||
})
|
preferredDate: body.preferredDate,
|
||||||
.returning();
|
preferredTime: normalizeTime(body.preferredTime),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
} catch (err) {
|
||||||
|
// An exact duplicate active waitlist entry violates the partial unique
|
||||||
|
// index idx_waitlist_active_unique (client_id, pet_id, service_id,
|
||||||
|
// preferred_date, preferred_time WHERE status='active'). postgres-js
|
||||||
|
// surfaces this as SQLSTATE 23505 — return a friendly 409 rather than a
|
||||||
|
// generic 500 (GRO-2235). Unrelated errors still surface as 500.
|
||||||
|
if ((err as { code?: string })?.code === "23505") {
|
||||||
|
return c.json(
|
||||||
|
{ error: "You already have a booking for this pet at that date and time." },
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
return c.json(entry, 201);
|
return c.json(entry, 201);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user