250 lines
7.1 KiB
TypeScript
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();
|
|
});
|
|
});
|