Booking Engine

Architecture: Six focused services with the BookingService/SchedulingService overlap resolved by giving SchedulingService exactly one job, the transactional double-booking guard , while BookingService owns the create/reschedule/cancel workflow around it.

59 unit tests, 24 integration tests
This commit is contained in:
Stefan Lazic committed 2026-08-02 18:36:39 +01:00
1 parent 090646fdfd
commit 8b63cc66a9
28 files changed
+4853 -15

No files matched your search

@@ -0,0 +1,600 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { getAvailableDates, getAvailableStaff, getAvailableTimeSlots, validateBookingSlot } from "../availability.service";
import { cancelBooking, createBooking, rescheduleBooking } from "../booking.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "./fixtures";
import type { TestBusiness } from "./fixtures";
/** The next date (at least `minDaysAhead` out) that falls on `target` — avoids hardcoding a calendar date that could go stale or land on a closed day depending on when the suite runs. */
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
let date = addDays(todayIn(timezone), minDaysAhead);
for (let i = 0; i < 8; i++) {
if (weekdayOf(date) === target) return date;
date = addDays(date, 1);
}
throw new Error("unreachable");
}
describe("booking engine (integration)", () => {
let business: TestBusiness;
let monday: CalendarDate;
let sunday: CalendarDate;
beforeEach(async () => {
business = await createTestBusiness();
monday = nextWeekday(business.timezone, Weekday.MONDAY);
sunday = addDays(monday, 6); // the business is closed on Sunday — see fixtures.ts
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
describe("availability calculation", () => {
it("offers slots for a working staff member on a working day", async () => {
const { staffId } = await createTestStaff(business);
const { slots, validation } = await getAvailableTimeSlots(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
date: monday,
});
expect(validation.valid).toBe(true);
expect(slots.length).toBeGreaterThan(0);
expect(slots[0]!.staffOptions).toContain(staffId);
// 09:00 business open, 30 min slot interval — the very first slot should be 09:00.
expect(slots[0]!.startMinutes).toBe(9 * 60);
});
it("lists a date as available only if it actually has openings", async () => {
await createTestStaff(business);
const { dates } = await getAvailableDates(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
fromDate: monday,
toDate: sunday,
});
expect(dates).toContain(monday);
expect(dates).not.toContain(sunday); // business closed Sundays
});
it("getAvailableStaff only returns staff who can perform every requested service", async () => {
const { staffId } = await createTestStaff(business);
// A second staff member who can only do haircuts, not beard trims.
const role = await prisma.role.create({ data: { businessId: business.businessId, name: "Junior" } });
const user = await prisma.user.create({
data: {
email: `junior-${Date.now()}@test.local`,
staffProfile: {
create: {
businessId: business.businessId,
roleId: role.id,
services: { create: [{ serviceId: business.haircutServiceId }] },
},
},
},
include: { staffProfile: true },
});
business.createdUserIds.push(user.id);
const staffForHaircutOnly = await getAvailableStaff(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
});
const staffIds = staffForHaircutOnly.map((s) => s.id);
expect(staffIds).toContain(staffId);
expect(staffIds).toContain(user.staffProfile!.id);
const staffForBoth = await getAvailableStaff(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId, business.beardTrimServiceId],
});
const bothIds = staffForBoth.map((s) => s.id);
expect(bothIds).toContain(staffId);
expect(bothIds).not.toContain(user.staffProfile!.id); // junior can't do beard trims
});
});
describe("business closed", () => {
it("offers no slots on a day the business is closed", async () => {
await createTestStaff(business);
const { slots } = await getAvailableTimeSlots(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
date: sunday,
});
expect(slots).toEqual([]);
});
it("validateBookingSlot reports BUSINESS_CLOSED for a Sunday booking attempt", async () => {
const { staffId } = await createTestStaff(business);
const startAt = wallClockToInstant(sunday, 10 * 60, business.timezone);
const { validation } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt,
});
expect(validation.valid).toBe(false);
expect(validation.issues.map((i) => i.code)).toContain("BUSINESS_CLOSED");
});
});
describe("staff working hours", () => {
it("STAFF_NOT_WORKING when the staff member doesn't work that day, even though the business is open", async () => {
const { staffId } = await createTestStaff(business, { workingDays: [Weekday.TUESDAY] }); // never works Monday
const startAt = wallClockToInstant(monday, 10 * 60, business.timezone);
const { validation } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt,
});
expect(validation.valid).toBe(false);
expect(validation.issues.map((i) => i.code)).toContain("STAFF_NOT_WORKING");
});
});
describe("staff breaks", () => {
it("excludes a lunch break from available slots and flags a candidate inside it", async () => {
const { staffId } = await createTestStaff(business);
const lunchBreak = await prisma.break.create({
data: {
staffId,
label: "Lunch",
startMinutes: 12 * 60,
endMinutes: 13 * 60,
days: { create: [{ dayOfWeek: Weekday.MONDAY }] },
},
});
const { slots } = await getAvailableTimeSlots(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
date: monday,
});
const duringLunch = slots.some((s) => s.startMinutes >= 12 * 60 && s.startMinutes < 13 * 60);
expect(duringLunch).toBe(false);
const { validation } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt: wallClockToInstant(monday, 12 * 60 + 15, business.timezone),
});
expect(validation.valid).toBe(false);
expect(validation.issues.map((i) => i.code)).toContain("STAFF_ON_BREAK");
await prisma.break.delete({ where: { id: lunchBreak.id } });
});
});
describe("time off", () => {
it("blocks the whole day when the staff member has approved leave", async () => {
const { staffId } = await createTestStaff(business);
await prisma.timeOff.create({
data: {
staffId,
startDate: new Date(`${monday}T00:00:00.000Z`),
endDate: new Date(`${monday}T00:00:00.000Z`),
status: "APPROVED",
},
});
const { slots } = await getAvailableTimeSlots(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
date: monday,
});
expect(slots).toEqual([]);
const { validation } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(validation.issues.map((i) => i.code)).toContain("STAFF_ON_LEAVE");
});
it("does not block a day the leave doesn't cover", async () => {
const { staffId } = await createTestStaff(business);
const tuesday = addDays(monday, 1);
await prisma.timeOff.create({
data: {
staffId,
startDate: new Date(`${tuesday}T00:00:00.000Z`),
endDate: new Date(`${tuesday}T00:00:00.000Z`),
status: "APPROVED",
},
});
const { slots } = await getAvailableTimeSlots(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
date: monday,
});
expect(slots.length).toBeGreaterThan(0);
});
});
describe("multiple services", () => {
it("combines durations and requires a window long enough for all requested services", async () => {
await createTestStaff(business);
const { slots: singleService } = await getAvailableTimeSlots(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId], // 30 min
date: monday,
});
const { slots: combined } = await getAvailableTimeSlots(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId, business.beardTrimServiceId], // 30 + 15 = 45 min
date: monday,
});
expect(combined[0]!.endMinutes - combined[0]!.startMinutes).toBe(45);
// The combined-duration slot list can't offer more start times deep near closing than the shorter single-service one.
const lastSingle = singleService[singleService.length - 1]!.startMinutes;
const lastCombined = combined[combined.length - 1]!.startMinutes;
expect(lastCombined).toBeLessThanOrEqual(lastSingle);
});
it("creates one AppointmentService row per requested service, with price/duration snapshotted", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId, business.beardTrimServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.appointment.services).toHaveLength(2);
const total = result.appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
expect(total).toBe(30); // £20 haircut + £10 beard trim
const durationTotal = result.appointment.services.reduce((sum, s) => sum + s.durationAtBookingMinutes, 0);
expect(durationTotal).toBe(45);
});
});
describe("buffer times", () => {
it("blocks a slot that would start inside another appointment's buffer", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
// Give this business a 15-minute buffer after every appointment.
await prisma.bookingRule.update({
where: { businessId: business.businessId },
data: { bufferAfterMinutes: 15 },
});
const first = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId], // 09:00–09:30
startAt: wallClockToInstant(monday, 9 * 60, business.timezone),
});
expect(first.success).toBe(true);
// 09:30 is technically free of the first appointment itself, but inside its 15-min buffer.
const { validation } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt: wallClockToInstant(monday, 9 * 60 + 30, business.timezone),
});
expect(validation.valid).toBe(false);
expect(validation.issues.map((i) => i.code)).toContain("SLOT_UNAVAILABLE");
// 09:45 is clear of the buffer.
const { validation: laterValidation } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt: wallClockToInstant(monday, 9 * 60 + 45, business.timezone),
});
expect(laterValidation.valid).toBe(true);
});
});
describe("booking validation", () => {
it("rejects a booking below the minimum notice period", async () => {
const { staffId } = await createTestStaff(business);
await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { minNoticeMinutes: 120 } });
const { validation } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt: new Date(Date.now() + 5 * 60_000), // 5 minutes from now
});
expect(validation.valid).toBe(false);
expect(validation.issues.map((i) => i.code)).toContain("BELOW_MIN_NOTICE");
});
it("rejects a booking beyond the maximum advance window", async () => {
const { staffId } = await createTestStaff(business);
await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { maxAdvanceDays: 7 } });
const farFuture = addDays(todayIn(business.timezone), 60);
const { validation } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: [business.haircutServiceId],
staffId,
startAt: wallClockToInstant(farFuture, 10 * 60, business.timezone),
});
expect(validation.valid).toBe(false);
expect(validation.issues.map((i) => i.code)).toContain("BEYOND_MAX_ADVANCE");
});
it("rejects once the business's daily appointment cap is reached", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { maxAppointmentsPerDay: 1 } });
const first = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 9 * 60, business.timezone),
});
expect(first.success).toBe(true);
const second = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 11 * 60, business.timezone),
});
expect(second.success).toBe(false);
if (second.success) return;
expect(second.validation.issues.map((i) => i.code)).toContain("BUSINESS_DAILY_LIMIT_REACHED");
});
it("rejects services that don't belong to the business", async () => {
const { staffId } = await createTestStaff(business);
const { validation } = await validateBookingSlot(prisma, {
businessId: business.businessId,
serviceIds: ["not-a-real-service-id"],
staffId,
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(validation.valid).toBe(false);
});
});
describe("double booking prevention", () => {
it("rejects a second booking for the exact same staff/time", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const startAt = wallClockToInstant(monday, 10 * 60, business.timezone);
const first = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt,
});
const second = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt,
});
expect(first.success).toBe(true);
expect(second.success).toBe(false);
if (second.success) return;
expect(second.validation.issues.map((i) => i.code)).toContain("SLOT_UNAVAILABLE");
});
it("under real concurrency, exactly one of two simultaneous requests for the same slot succeeds", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const startAt = wallClockToInstant(monday, 14 * 60, business.timezone);
const [a, b] = await Promise.all([
createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt,
}),
createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt,
}),
]);
const successes = [a, b].filter((r) => r.success);
const failures = [a, b].filter((r) => !r.success);
expect(successes).toHaveLength(1);
expect(failures).toHaveLength(1);
const appointmentCount = await prisma.appointment.count({
where: { businessId: business.businessId, staffId, status: { notIn: ["CANCELLED", "NO_SHOW"] } },
});
expect(appointmentCount).toBe(1);
});
});
describe("rescheduling", () => {
it("moves the appointment to the new time and applies the same validation as a new booking", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(created.success).toBe(true);
if (!created.success) return;
const rescheduled = await rescheduleBooking(prisma, {
appointmentId: created.appointment.id,
startAt: wallClockToInstant(monday, 15 * 60, business.timezone),
});
expect(rescheduled.success).toBe(true);
if (!rescheduled.success) return;
expect(rescheduled.appointment.id).toBe(created.appointment.id);
expect(rescheduled.appointment.startAt.toISOString()).toBe(
wallClockToInstant(monday, 15 * 60, business.timezone).toISOString(),
);
});
it("does not conflict with itself when rescheduled to overlap its own original time", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(created.success).toBe(true);
if (!created.success) return;
// "Reschedule" to 10 minutes later — overlaps its own current slot, must not be treated as a conflict with itself.
const rescheduled = await rescheduleBooking(prisma, {
appointmentId: created.appointment.id,
startAt: wallClockToInstant(monday, 10 * 60 + 10, business.timezone),
});
expect(rescheduled.success).toBe(true);
});
it("rejects rescheduling a completed appointment", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
await prisma.appointment.update({ where: { id: created.appointment.id }, data: { status: "COMPLETED" } });
const rescheduled = await rescheduleBooking(prisma, {
appointmentId: created.appointment.id,
startAt: wallClockToInstant(monday, 15 * 60, business.timezone),
});
expect(rescheduled.success).toBe(false);
});
});
describe("cancellation", () => {
it("never deletes — sets status, cancelledAt, cancelledByUserId, and reason", async () => {
const { staffId } = await createTestStaff(business);
const { customerId, userId: customerUserId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
const result = await cancelBooking(prisma, {
appointmentId: created.appointment.id,
cancelledByUserId: customerUserId,
reason: "Change of plans",
});
expect(result.success).toBe(true);
expect(result.appointment?.status).toBe("CANCELLED");
expect(result.appointment?.cancelledByUserId).toBe(customerUserId);
expect(result.appointment?.cancellationReason).toBe("Change of plans");
expect(result.appointment?.cancelledAt).toBeInstanceOf(Date);
// Still in the database — not deleted.
const stillExists = await prisma.appointment.findUnique({ where: { id: created.appointment.id } });
expect(stillExists).not.toBeNull();
});
it("frees the slot up for a new booking", async () => {
const { staffId } = await createTestStaff(business);
const { customerId, userId: customerUserId } = await createTestCustomer(business);
const startAt = wallClockToInstant(monday, 10 * 60, business.timezone);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt,
});
if (!created.success) throw new Error("setup failed");
await cancelBooking(prisma, { appointmentId: created.appointment.id, cancelledByUserId: customerUserId });
const rebooked = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt,
});
expect(rebooked.success).toBe(true);
});
it("rejects cancelling an already-completed appointment", async () => {
const { staffId } = await createTestStaff(business);
const { customerId, userId: customerUserId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
await prisma.appointment.update({ where: { id: created.appointment.id }, data: { status: "COMPLETED" } });
const result = await cancelBooking(prisma, { appointmentId: created.appointment.id, cancelledByUserId: customerUserId });
expect(result.success).toBe(false);
});
});
});