import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest"; import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaClient } from "@/generated/prisma/client"; import { BookingConflictError, confirmHold, createHold, expireStaleHolds, } from "@/lib/booking"; 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 serviceId: string; let therapistA: string; let therapistB: string; let roomA: string; let roomB: string; let customerId: string; beforeAll(async () => { fx = await seed(db); serviceId = fx.services.find((s) => s.name === "60-minute Swedish")!.id; therapistA = fx.therapists[0].id; therapistB = fx.therapists[1].id; roomA = fx.rooms[0].id; roomB = fx.rooms[1].id; customerId = fx.customers[0].id; }); afterAll(async () => { await db.$disconnect(); }); beforeEach(async () => { await db.booking.deleteMany(); }); const D = (iso: string) => new Date(iso); describe("createHold", () => { test("inserts a HOLD with computed endsAt and roomReleasedAt", async () => { const startsAt = D("2026-05-05T14:00:00Z"); const hold = await createHold(db, { customerId, serviceId, therapistId: therapistA, roomId: roomA, startsAt, }); // 60-min Swedish, 15-min buffer expect(hold.endsAt).toEqual(D("2026-05-05T15:00:00Z")); expect(hold.roomReleasedAt).toEqual(D("2026-05-05T15:15:00Z")); expect(hold.priceCents).toBe(9000); expect(hold.depositCents).toBe(2000); const row = await db.booking.findUnique({ where: { id: hold.id } }); expect(row?.status).toBe("HOLD"); expect(row?.holdExpiresAt).toBeTruthy(); }); test("conflicting createHold (same therapist, overlapping) throws BookingConflictError", async () => { await createHold(db, { customerId, serviceId, therapistId: therapistA, roomId: roomA, startsAt: D("2026-05-05T14:00:00Z"), }); await expect( createHold(db, { customerId, serviceId, therapistId: therapistA, // same therapist roomId: roomB, // different room startsAt: D("2026-05-05T14:30:00Z"), }), ).rejects.toBeInstanceOf(BookingConflictError); }); test("conflicting createHold (same room within buffer) throws BookingConflictError", async () => { await createHold(db, { customerId, serviceId, therapistId: therapistA, roomId: roomA, startsAt: D("2026-05-05T14:00:00Z"), }); // Next slot after 11:00 is 11:15 because of the buffer. await expect( createHold(db, { customerId, serviceId, therapistId: therapistB, roomId: roomA, // same room startsAt: D("2026-05-05T15:00:00Z"), // exactly at end — within buffer }), ).rejects.toBeInstanceOf(BookingConflictError); }); test("non-overlapping createHold succeeds after the room buffer", async () => { await createHold(db, { customerId, serviceId, therapistId: therapistA, roomId: roomA, startsAt: D("2026-05-05T14:00:00Z"), }); const hold2 = await createHold(db, { customerId, serviceId, therapistId: therapistB, roomId: roomA, startsAt: D("2026-05-05T15:15:00Z"), // exactly when buffer ends }); expect(hold2.id).toBeTruthy(); }); test("inactive service is rejected", async () => { await db.service.update({ where: { id: serviceId }, data: { active: false }, }); try { await expect( createHold(db, { customerId, serviceId, therapistId: therapistA, roomId: roomA, startsAt: D("2026-05-05T14:00:00Z"), }), ).rejects.toThrow(/inactive/); } finally { await db.service.update({ where: { id: serviceId }, data: { active: true }, }); } }); test("custom holdMinutes is honored", async () => { const before = Date.now(); const hold = await createHold(db, { customerId, serviceId, therapistId: therapistA, roomId: roomA, startsAt: D("2026-05-05T14:00:00Z"), holdMinutes: 30, }); const expected = before + 30 * 60_000; // Allow 5 seconds of drift for the test execution time. expect(hold.holdExpiresAt.getTime()).toBeGreaterThanOrEqual(expected - 5000); expect(hold.holdExpiresAt.getTime()).toBeLessThanOrEqual(expected + 5000); }); }); describe("confirmHold", () => { test("flips a HOLD to CONFIRMED and clears holdExpiresAt", async () => { const hold = await createHold(db, { customerId, serviceId, therapistId: therapistA, roomId: roomA, startsAt: D("2026-05-05T14:00:00Z"), }); await confirmHold(db, hold.id); const row = await db.booking.findUnique({ where: { id: hold.id } }); expect(row?.status).toBe("CONFIRMED"); expect(row?.holdExpiresAt).toBeNull(); }); }); describe("expireStaleHolds", () => { test("expires only HOLDs whose holdExpiresAt is past", async () => { // Create one fresh hold (10 min default) and manually backdate another. const fresh = await createHold(db, { customerId, serviceId, therapistId: therapistA, roomId: roomA, startsAt: D("2026-05-05T14:00:00Z"), }); const stale = await createHold(db, { customerId, serviceId, therapistId: therapistB, roomId: roomB, startsAt: D("2026-05-05T16:00:00Z"), }); await db.booking.update({ where: { id: stale.id }, data: { holdExpiresAt: new Date(Date.now() - 60_000) }, }); const count = await expireStaleHolds(db); expect(count).toBe(1); const freshRow = await db.booking.findUnique({ where: { id: fresh.id } }); const staleRow = await db.booking.findUnique({ where: { id: stale.id } }); expect(freshRow?.status).toBe("HOLD"); expect(staleRow?.status).toBe("CANCELLED"); expect(staleRow?.cancelReason).toBe("hold expired"); }); });