added availability
This commit is contained in:
249
test/booking-exclusion.test.ts
Normal file
249
test/booking-exclusion.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user