import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest"; import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaClient } from "@/generated/prisma/client"; import { seed, type SeedResult } from "@/lib/seed"; const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); const db = new PrismaClient({ adapter }); let fx: SeedResult; let therapistA: string; let therapistB: string; let roomA: string; let roomB: string; let serviceId: string; let customerId: string; beforeAll(async () => { fx = await seed(db); therapistA = fx.therapists[0].id; therapistB = fx.therapists[1].id; roomA = fx.rooms[0].id; roomB = fx.rooms[1].id; // 60-minute Swedish — both seed therapists have the "swedish" tag and no // room tag is required, so any room/therapist pair works for these tests. serviceId = fx.services.find((s) => s.name.startsWith("60-minute Swedish"))!.id; customerId = fx.customers[0].id; }); afterAll(async () => { await db.$disconnect(); }); beforeEach(async () => { await db.booking.deleteMany(); }); const PG_EXCLUSION_VIOLATION = "23P01"; const D = (iso: string) => new Date(iso); const findPgCode = (e: unknown): string | undefined => { const seen = new Set(); let cur: unknown = e; while (cur && typeof cur === "object" && !seen.has(cur)) { seen.add(cur); const obj = cur as Record; if (typeof obj.code === "string" && /^[0-9A-Z]{5}$/.test(obj.code)) { return obj.code as string; } if (obj.meta && typeof obj.meta === "object") { const meta = obj.meta as Record; if (typeof meta.code === "string") return meta.code; if ( typeof meta.constraint === "string" && meta.constraint.includes("_overlap") ) { return PG_EXCLUSION_VIOLATION; } } cur = (obj.cause as unknown) ?? undefined; } return undefined; }; const expectExclusionViolation = (e: unknown) => { const code = findPgCode(e); if (code !== PG_EXCLUSION_VIOLATION) { throw new Error( `Expected exclusion_violation (23P01); got code=${code ?? ""}; full error:\n${JSON.stringify( e, Object.getOwnPropertyNames(e as object), 2, )}`, ); } }; type BookingArgs = { therapistId?: string; roomId?: string; customerId?: string; serviceId?: string; startsAt: Date; endsAt: Date; roomReleasedAt?: Date; status?: "HOLD" | "CONFIRMED" | "COMPLETED" | "NO_SHOW" | "CANCELLED"; }; const insert = (a: BookingArgs) => db.booking.create({ data: { customerId: a.customerId ?? customerId, therapistId: a.therapistId ?? therapistA, roomId: a.roomId ?? roomA, serviceId: a.serviceId ?? serviceId, startsAt: a.startsAt, endsAt: a.endsAt, roomReleasedAt: a.roomReleasedAt ?? a.endsAt, status: a.status ?? "HOLD", }, }); describe("Booking exclusion constraints", () => { test("non-overlapping bookings on the same therapist succeed", async () => { await insert({ startsAt: D("2026-05-01T10:00:00Z"), endsAt: D("2026-05-01T11:00:00Z"), }); await expect( insert({ roomId: roomB, startsAt: D("2026-05-01T11:00:00Z"), endsAt: D("2026-05-01T12:00:00Z"), }), ).resolves.toBeTruthy(); }); test("overlapping bookings on the same therapist are rejected by the DB", async () => { await insert({ startsAt: D("2026-05-01T10:00:00Z"), endsAt: D("2026-05-01T11:00:00Z"), }); try { await insert({ roomId: roomB, // different room — only therapist conflict startsAt: D("2026-05-01T10:30:00Z"), endsAt: D("2026-05-01T11:30:00Z"), }); throw new Error("expected exclusion_violation"); } catch (e) { expectExclusionViolation(e); } }); test("two therapists, same time slot, different rooms — both succeed", async () => { await insert({ therapistId: therapistA, roomId: roomA, startsAt: D("2026-05-01T10:00:00Z"), endsAt: D("2026-05-01T11:00:00Z"), }); await expect( insert({ therapistId: therapistB, roomId: roomB, startsAt: D("2026-05-01T10:00:00Z"), endsAt: D("2026-05-01T11:00:00Z"), }), ).resolves.toBeTruthy(); }); test("room buffer is enforced — second booking inside the buffer window is rejected", async () => { await insert({ startsAt: D("2026-05-01T10:00:00Z"), endsAt: D("2026-05-01T11:00:00Z"), roomReleasedAt: D("2026-05-01T11:15:00Z"), }); try { await insert({ therapistId: therapistB, // different therapist; only room conflicts startsAt: D("2026-05-01T11:10:00Z"), endsAt: D("2026-05-01T12:10:00Z"), roomReleasedAt: D("2026-05-01T12:25:00Z"), }); throw new Error("expected exclusion_violation"); } catch (e) { expectExclusionViolation(e); } }); test("a booking starting exactly when the room is released is allowed", async () => { await insert({ startsAt: D("2026-05-01T10:00:00Z"), endsAt: D("2026-05-01T11:00:00Z"), roomReleasedAt: D("2026-05-01T11:15:00Z"), }); await expect( insert({ therapistId: therapistB, startsAt: D("2026-05-01T11:15:00Z"), endsAt: D("2026-05-01T12:15:00Z"), roomReleasedAt: D("2026-05-01T12:30:00Z"), }), ).resolves.toBeTruthy(); }); test("CANCELLED bookings do not block new bookings on the same slot", async () => { await insert({ status: "CANCELLED", startsAt: D("2026-05-01T10:00:00Z"), endsAt: D("2026-05-01T11:00:00Z"), }); await expect( insert({ startsAt: D("2026-05-01T10:00:00Z"), endsAt: D("2026-05-01T11:00:00Z"), }), ).resolves.toBeTruthy(); }); test("HOLD blocks CONFIRMED in the same slot — race-condition safety", async () => { await insert({ status: "HOLD", startsAt: D("2026-05-01T10:00:00Z"), endsAt: D("2026-05-01T11:00:00Z"), }); try { await insert({ status: "CONFIRMED", startsAt: D("2026-05-01T10:00:00Z"), endsAt: D("2026-05-01T11:00:00Z"), }); throw new Error("expected exclusion_violation"); } catch (e) { expectExclusionViolation(e); } }); }); describe("Booking foreign-key constraints", () => { test("inserting a booking with a non-existent therapistId fails", async () => { await expect( insert({ therapistId: "no-such-therapist", startsAt: D("2026-05-02T10:00:00Z"), endsAt: D("2026-05-02T11:00:00Z"), }), ).rejects.toThrow(); }); test("inserting a booking with a non-existent roomId fails", async () => { await expect( insert({ roomId: "no-such-room", startsAt: D("2026-05-02T10:00:00Z"), endsAt: D("2026-05-02T11:00:00Z"), }), ).rejects.toThrow(); }); test("inserting a booking with a non-existent customerId fails", async () => { await expect( insert({ customerId: "no-such-customer", startsAt: D("2026-05-02T10:00:00Z"), endsAt: D("2026-05-02T11:00:00Z"), }), ).rejects.toThrow(); }); });