// End-to-end: simulate "admin takes a phone booking on behalf of a customer". // Exercises load → find → hold → confirm → email in one flow, then validates // the DB-level safety net catches a concurrent attempt at the same slot. import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest"; import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaClient } from "@/generated/prisma/client"; import { findSlots } from "@/lib/availability"; import { loadAvailabilityState } from "@/lib/availability-loader"; import { BookingConflictError, confirmHold, createHold, } from "@/lib/booking"; import { resetEmailTransport, sendBookingConfirmation } from "@/lib/email"; import { seed, type SeedResult } from "@/lib/seed"; const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); const db = new PrismaClient({ adapter }); const MAILPIT = "http://localhost:8025"; const TZ = "America/Detroit"; let fx: SeedResult; let serviceId: string; let customer: { id: string; email: string }; beforeAll(async () => { fx = await seed(db); serviceId = fx.services.find((s) => s.name === "60-minute Swedish")!.id; customer = fx.customers[0]; process.env.SMTP_HOST = "localhost"; process.env.SMTP_PORT = "1025"; process.env.SMTP_FROM = "TouchBase "; process.env.APP_TZ = TZ; resetEmailTransport(); }); afterAll(async () => { await db.$disconnect(); }); beforeEach(async () => { await db.notification.deleteMany(); await db.booking.deleteMany(); await fetch(`${MAILPIT}/api/v1/messages`, { method: "DELETE" }); }); // 2026-05-05 10:00 America/Detroit (EDT) = 14:00 UTC const SLOT_LOCAL_10AM_TUE = new Date("2026-05-05T14:00:00Z"); describe("end-to-end: admin takes a booking", () => { test("happy path: load → find → hold → confirm → email → notification recorded", async () => { // Window: 2 hours starting at 10:00 local Tue. const from = SLOT_LOCAL_10AM_TUE; const to = new Date("2026-05-05T16:00:00Z"); // 12:00 local const state = await loadAvailabilityState(db, { from, to, serviceId }); expect(state).not.toBeNull(); const slots = findSlots({ service: state!.service, therapists: state!.therapists, rooms: state!.rooms, practiceTz: TZ, from, to, }); expect(slots.length).toBeGreaterThan(0); const slot = slots.find((s) => s.startsAt.getTime() === from.getTime())!; expect(slot).toBeDefined(); expect(slot.candidateTherapistIds.length).toBeGreaterThan(0); expect(slot.candidateRoomIds.length).toBeGreaterThan(0); const hold = await createHold(db, { customerId: customer.id, serviceId, therapistId: slot.candidateTherapistIds[0], roomId: slot.candidateRoomIds[0], startsAt: slot.startsAt, }); await confirmHold(db, hold.id); const result = await sendBookingConfirmation({ db, bookingId: hold.id }); expect(result.status).toBe("sent"); // Booking is CONFIRMED in DB const dbRow = await db.booking.findUnique({ where: { id: hold.id } }); expect(dbRow?.status).toBe("CONFIRMED"); expect(dbRow?.holdExpiresAt).toBeNull(); // Notification row recorded with status=sent const noti = await db.notification.findUnique({ where: { id: result.notificationId }, }); expect(noti?.status).toBe("sent"); // Mailpit received exactly one message addressed to the customer const res = await fetch(`${MAILPIT}/api/v1/messages`); const json = (await res.json()) as { messages: { To: { Address: string }[] }[] }; expect(json.messages).toHaveLength(1); expect(json.messages[0].To[0].Address).toBe(customer.email); }); test("a second concurrent createHold for the same slot is rejected by the DB", async () => { const from = SLOT_LOCAL_10AM_TUE; const to = new Date("2026-05-05T16:00:00Z"); const state = await loadAvailabilityState(db, { from, to, serviceId }); const slots = findSlots({ service: state!.service, therapists: state!.therapists, rooms: state!.rooms, practiceTz: TZ, from, to, }); const slot = slots[0]; // Race: two callers see the slot as available and both try to create a HOLD. const therapist = slot.candidateTherapistIds[0]; const room = slot.candidateRoomIds[0]; const [firstResult, secondResult] = await Promise.allSettled([ createHold(db, { customerId: customer.id, serviceId, therapistId: therapist, roomId: room, startsAt: slot.startsAt, }), createHold(db, { customerId: fx.customers[1].id, serviceId, therapistId: therapist, roomId: room, startsAt: slot.startsAt, }), ]); // Exactly one wins; the other is rejected as a conflict. const fulfilled = [firstResult, secondResult].filter( (r) => r.status === "fulfilled", ); const rejected = [firstResult, secondResult].filter( (r) => r.status === "rejected", ); expect(fulfilled).toHaveLength(1); expect(rejected).toHaveLength(1); expect((rejected[0] as PromiseRejectedResult).reason).toBeInstanceOf( BookingConflictError, ); }); });