155 lines
5.1 KiB
TypeScript
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,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|