booking flow, loader, email, admin cli
This commit is contained in:
154
test/e2e-booking.test.ts
Normal file
154
test/e2e-booking.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
// 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user