import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest"; import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaClient } from "@/generated/prisma/client"; import { BookingConflictError, cancelBooking, 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("cancelBooking", () => { async function makeBooking(status: "HOLD" | "CONFIRMED" | "CANCELLED" | "COMPLETED" | "NO_SHOW" = "CONFIRMED") { const hold = await createHold(db, { customerId, serviceId, therapistId: therapistA, roomId: roomA, startsAt: D("2026-05-05T14:00:00Z"), }); if (status !== "HOLD") { await db.booking.update({ where: { id: hold.id }, data: { status, holdExpiresAt: null }, }); } return hold; } test("cancels a CONFIRMED booking and records audit fields", async () => { const hold = await makeBooking("CONFIRMED"); const result = await cancelBooking(db, { bookingId: hold.id, cancelledByUserId: customerId, reason: "test cancel", }); expect(result).toEqual({ kind: "cancelled", alreadyCancelled: false }); const row = await db.booking.findUnique({ where: { id: hold.id } }); expect(row?.status).toBe("CANCELLED"); expect(row?.cancelledBy).toBe(customerId); expect(row?.cancelReason).toBe("test cancel"); expect(row?.cancelledAt).toBeTruthy(); }); test("cancels a HOLD booking", async () => { const hold = await makeBooking("HOLD"); const result = await cancelBooking(db, { bookingId: hold.id, cancelledByUserId: customerId, }); expect(result.kind).toBe("cancelled"); const row = await db.booking.findUnique({ where: { id: hold.id } }); expect(row?.status).toBe("CANCELLED"); }); test("is idempotent — cancelling an already-CANCELLED booking returns alreadyCancelled", async () => { const hold = await makeBooking("CANCELLED"); const result = await cancelBooking(db, { bookingId: hold.id, cancelledByUserId: customerId, }); expect(result).toEqual({ kind: "cancelled", alreadyCancelled: true }); }); test("rejects cancelling a COMPLETED booking", async () => { const hold = await makeBooking("COMPLETED"); const result = await cancelBooking(db, { bookingId: hold.id, cancelledByUserId: customerId, }); expect(result).toEqual({ kind: "rejected", reason: "completed" }); const row = await db.booking.findUnique({ where: { id: hold.id } }); expect(row?.status).toBe("COMPLETED"); // unchanged }); test("rejects cancelling a NO_SHOW booking", async () => { const hold = await makeBooking("NO_SHOW"); const result = await cancelBooking(db, { bookingId: hold.id, cancelledByUserId: customerId, }); expect(result).toEqual({ kind: "rejected", reason: "no_show" }); }); test("after cancelling, the slot becomes bookable again (DB exclusion no longer blocks)", async () => { const hold = await makeBooking("CONFIRMED"); await cancelBooking(db, { bookingId: hold.id, cancelledByUserId: customerId, }); // Same therapist + same time should now succeed const newHold = await createHold(db, { customerId, serviceId, therapistId: therapistA, roomId: roomA, startsAt: D("2026-05-05T14:00:00Z"), }); expect(newHold.id).toBeTruthy(); expect(newHold.id).not.toBe(hold.id); }); test("throws on unknown bookingId", async () => { await expect( cancelBooking(db, { bookingId: "no-such-booking", cancelledByUserId: customerId, }), ).rejects.toThrow(/not found/); }); }); 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"); }); });