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); }); it("a partial-day (Block Time) entry only blocks its own window, not the whole day", 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", reason: "Team meeting", startMinutes: 12 * 60, endMinutes: 13 * 60, }, }); const { slots } = await getAvailableTimeSlots(prisma, { businessId: business.businessId, serviceIds: [business.haircutServiceId], date: monday, }); // Slots outside the block still exist — this is not a whole-day block. expect(slots.length).toBeGreaterThan(0); const duringBlock = slots.some((s) => s.startMinutes >= 12 * 60 && s.startMinutes < 13 * 60); expect(duringBlock).toBe(false); const { validation: insideBlock } = await validateBookingSlot(prisma, { businessId: business.businessId, serviceIds: [business.haircutServiceId], staffId, startAt: wallClockToInstant(monday, 12 * 60 + 15, business.timezone), }); expect(insideBlock.valid).toBe(false); expect(insideBlock.issues.map((i) => i.code)).toContain("STAFF_ON_LEAVE"); const { validation: outsideBlock } = await validateBookingSlot(prisma, { businessId: business.businessId, serviceIds: [business.haircutServiceId], staffId, startAt: wallClockToInstant(monday, 14 * 60, business.timezone), }); expect(outsideBlock.valid).toBe(true); }); }); 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); }); // Regression test for a real bug found via a MySQL E2E run (see // docs/phase-7-summary.md): with an asymmetric buffer // (bufferBeforeMinutes 0, bufferAfterMinutes > 0 — the seeded // production default), assertNoConflict in scheduling.service.ts was // applying the wrong side's buffer to the candidate appointment, // wrongly rejecting a slot placed immediately BEFORE an existing // appointment using that appointment's *after* buffer instead of its // (zero) *before* buffer. The test above only exercises // validateBookingSlot (AvailabilityService) for a candidate placed // AFTER an existing appointment; createBooking's actual DB-level // conflict check (assertNoConflict) was never exercised for the // "before" direction until now. it("does not apply a later appointment's after-buffer to a candidate slot placed immediately before it", async () => { const { staffId } = await createTestStaff(business); const { customerId } = await createTestCustomer(business); await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { bufferBeforeMinutes: 0, bufferAfterMinutes: 5 }, }); // Book the later appointment first: 10:30–11:00. const later = await createBooking(prisma, { businessId: business.businessId, customerId, staffId, serviceIds: [business.haircutServiceId], // 30 min startAt: wallClockToInstant(monday, 10 * 60 + 30, business.timezone), }); expect(later.success).toBe(true); // A candidate ending exactly when the later appointment starts (10:00–10:30) needs no gap — // only the LATER appointment's own trailing (after) buffer applies once IT finishes, not // before it starts, since bufferBeforeMinutes is 0. const earlier = await createBooking(prisma, { businessId: business.businessId, customerId, staffId, serviceIds: [business.haircutServiceId], startAt: wallClockToInstant(monday, 10 * 60, business.timezone), }); expect(earlier.success).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); }); }); });