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, markComplete, markNoShow, rescheduleBooking, } 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"); }); }); describe("rescheduleBooking", () => { test("cancels old + creates new at the new time, atomically", async () => { const original = await createHold(db, { customerId, serviceId, therapistId: therapistA, roomId: roomA, startsAt: D("2026-05-05T14:00:00Z"), }); await confirmHold(db, original.id); const result = await rescheduleBooking(db, { oldBookingId: original.id, newStartsAt: D("2026-05-06T14:00:00Z"), cancelledByUserId: customerId, }); expect(result.oldBookingId).toBe(original.id); expect(result.newBookingId).not.toBe(original.id); const oldRow = await db.booking.findUnique({ where: { id: original.id } }); const newRow = await db.booking.findUnique({ where: { id: result.newBookingId } }); expect(oldRow?.status).toBe("CANCELLED"); expect(oldRow?.cancelReason).toBe("rescheduled"); expect(newRow?.status).toBe("CONFIRMED"); expect(newRow?.startsAt).toEqual(D("2026-05-06T14:00:00Z")); }); test("rolls back if the new slot conflicts (old booking stays CONFIRMED)", async () => { // Block the target slot with a CONFIRMED booking on therapistA at 11:00. const blocker = await createHold(db, { customerId, serviceId, therapistId: therapistA, roomId: roomA, startsAt: D("2026-05-06T15:00:00Z"), }); await confirmHold(db, blocker.id); const original = await createHold(db, { customerId, serviceId, therapistId: therapistA, roomId: roomA, startsAt: D("2026-05-05T14:00:00Z"), }); await confirmHold(db, original.id); await expect( rescheduleBooking(db, { oldBookingId: original.id, newStartsAt: D("2026-05-06T15:00:00Z"), // collides with blocker cancelledByUserId: customerId, }), ).rejects.toBeInstanceOf(BookingConflictError); // Both bookings unchanged const orig = await db.booking.findUnique({ where: { id: original.id } }); const blk = await db.booking.findUnique({ where: { id: blocker.id } }); expect(orig?.status).toBe("CONFIRMED"); expect(blk?.status).toBe("CONFIRMED"); }); test("rejects rescheduling a CANCELLED booking", async () => { const original = await createHold(db, { customerId, serviceId, therapistId: therapistA, roomId: roomA, startsAt: D("2026-05-05T14:00:00Z"), }); await cancelBooking(db, { bookingId: original.id, cancelledByUserId: customerId, }); await expect( rescheduleBooking(db, { oldBookingId: original.id, newStartsAt: D("2026-05-06T14:00:00Z"), cancelledByUserId: customerId, }), ).rejects.toThrow(/Cannot reschedule/); }); test("can change therapist/room/service on reschedule", async () => { const original = await createHold(db, { customerId, serviceId, therapistId: therapistA, roomId: roomA, startsAt: D("2026-05-05T14:00:00Z"), }); await confirmHold(db, original.id); const result = await rescheduleBooking(db, { oldBookingId: original.id, newStartsAt: D("2026-05-06T14:00:00Z"), newTherapistId: therapistB, newRoomId: roomB, cancelledByUserId: customerId, }); const newRow = await db.booking.findUnique({ where: { id: result.newBookingId } }); expect(newRow?.therapistId).toBe(therapistB); expect(newRow?.roomId).toBe(roomB); }); }); describe("markComplete / markNoShow", () => { test("markComplete: CONFIRMED → COMPLETED", 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 result = await markComplete(db, hold.id); expect(result.ok).toBe(true); const row = await db.booking.findUnique({ where: { id: hold.id } }); expect(row?.status).toBe("COMPLETED"); }); test("markComplete is idempotent", async () => { const hold = await createHold(db, { customerId, serviceId, therapistId: therapistA, roomId: roomA, startsAt: D("2026-05-05T14:00:00Z"), }); await confirmHold(db, hold.id); await markComplete(db, hold.id); const second = await markComplete(db, hold.id); expect(second.ok).toBe(true); }); test("markComplete rejects HOLD bookings", async () => { const hold = await createHold(db, { customerId, serviceId, therapistId: therapistA, roomId: roomA, startsAt: D("2026-05-05T14:00:00Z"), }); const result = await markComplete(db, hold.id); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toMatch(/HOLD/); }); test("markNoShow: CONFIRMED → NO_SHOW", 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 result = await markNoShow(db, hold.id); expect(result.ok).toBe(true); const row = await db.booking.findUnique({ where: { id: hold.id } }); expect(row?.status).toBe("NO_SHOW"); }); });