Files
touchbase/test/booking-exclusion.test.ts
2026-05-01 18:24:09 -04:00

250 lines
7.1 KiB
TypeScript

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<unknown>();
let cur: unknown = e;
while (cur && typeof cur === "object" && !seen.has(cur)) {
seen.add(cur);
const obj = cur as Record<string, unknown>;
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<string, unknown>;
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 ?? "<none>"}; 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();
});
});