Files
touchbase/test/e2e-booking.test.ts

155 lines
5.1 KiB
TypeScript

// 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 <noreply@touchbase.local>";
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,
);
});
});