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:
28 files changed
+4853
-15
No files matched your search
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AppointmentStatus } from "@/generated/prisma/enums";
|
||||
import { canTransition, validateTransition } from "../appointment.service";
|
||||
|
||||
const ALL_STATUSES = Object.values(AppointmentStatus);
|
||||
|
||||
describe("canTransition", () => {
|
||||
it("allows the documented happy path", () => {
|
||||
expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.CONFIRMED)).toBe(true);
|
||||
expect(canTransition(AppointmentStatus.CONFIRMED, AppointmentStatus.CHECKED_IN)).toBe(true);
|
||||
expect(canTransition(AppointmentStatus.CHECKED_IN, AppointmentStatus.IN_PROGRESS)).toBe(true);
|
||||
expect(canTransition(AppointmentStatus.IN_PROGRESS, AppointmentStatus.COMPLETED)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows cancellation from PENDING, CONFIRMED, and CHECKED_IN", () => {
|
||||
expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.CANCELLED)).toBe(true);
|
||||
expect(canTransition(AppointmentStatus.CONFIRMED, AppointmentStatus.CANCELLED)).toBe(true);
|
||||
expect(canTransition(AppointmentStatus.CHECKED_IN, AppointmentStatus.CANCELLED)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows NO_SHOW only from CONFIRMED — once checked in, the customer clearly did show up", () => {
|
||||
expect(canTransition(AppointmentStatus.CONFIRMED, AppointmentStatus.NO_SHOW)).toBe(true);
|
||||
expect(canTransition(AppointmentStatus.CHECKED_IN, AppointmentStatus.NO_SHOW)).toBe(false);
|
||||
expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.NO_SHOW)).toBe(false);
|
||||
expect(canTransition(AppointmentStatus.IN_PROGRESS, AppointmentStatus.NO_SHOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats COMPLETED, CANCELLED, and NO_SHOW as terminal — no transitions out", () => {
|
||||
for (const terminal of [AppointmentStatus.COMPLETED, AppointmentStatus.CANCELLED, AppointmentStatus.NO_SHOW]) {
|
||||
for (const target of ALL_STATUSES) {
|
||||
expect(canTransition(terminal, target)).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("never allows a no-op transition to the same status", () => {
|
||||
for (const status of ALL_STATUSES) {
|
||||
expect(canTransition(status, status)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("disallows skipping straight from PENDING to CHECKED_IN or later", () => {
|
||||
expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.CHECKED_IN)).toBe(false);
|
||||
expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.IN_PROGRESS)).toBe(false);
|
||||
expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.COMPLETED)).toBe(false);
|
||||
});
|
||||
|
||||
it("disallows going backwards", () => {
|
||||
expect(canTransition(AppointmentStatus.IN_PROGRESS, AppointmentStatus.CHECKED_IN)).toBe(false);
|
||||
expect(canTransition(AppointmentStatus.CHECKED_IN, AppointmentStatus.CONFIRMED)).toBe(false);
|
||||
expect(canTransition(AppointmentStatus.COMPLETED, AppointmentStatus.IN_PROGRESS)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTransition", () => {
|
||||
it("returns a valid result for an allowed transition", () => {
|
||||
const result = validateTransition(AppointmentStatus.CONFIRMED, AppointmentStatus.CHECKED_IN);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.issues).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns a specific error code for a disallowed transition", () => {
|
||||
const result = validateTransition(AppointmentStatus.COMPLETED, AppointmentStatus.CANCELLED);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_STATUS_TRANSITION");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
checkBusinessDailyLimit,
|
||||
checkMaxAdvance,
|
||||
checkMinNotice,
|
||||
checkStaffDailyLimit,
|
||||
combinedServiceDuration,
|
||||
computeEffectiveBuffer,
|
||||
isLateCancellation,
|
||||
isLateReschedule,
|
||||
} from "../business-rules.service";
|
||||
|
||||
describe("checkMinNotice", () => {
|
||||
const now = new Date("2026-08-10T10:00:00Z");
|
||||
|
||||
it("fails when the candidate start is too soon", () => {
|
||||
const candidate = new Date("2026-08-10T10:30:00Z"); // 30 min from now
|
||||
const result = checkMinNotice(candidate, now, 120);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("BELOW_MIN_NOTICE");
|
||||
});
|
||||
|
||||
it("passes when the candidate start is far enough ahead", () => {
|
||||
const candidate = new Date("2026-08-10T13:00:00Z"); // 3h from now
|
||||
expect(checkMinNotice(candidate, now, 120).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("passes exactly at the boundary", () => {
|
||||
const candidate = new Date("2026-08-10T12:00:00Z"); // exactly 120 min
|
||||
expect(checkMinNotice(candidate, now, 120).valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkMaxAdvance", () => {
|
||||
it("fails when the date is beyond the max advance window", () => {
|
||||
const result = checkMaxAdvance("2026-11-01", "2026-08-10", 60);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("BEYOND_MAX_ADVANCE");
|
||||
});
|
||||
|
||||
it("passes when the date is within the window", () => {
|
||||
expect(checkMaxAdvance("2026-08-20", "2026-08-10", 60).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("passes exactly at the boundary", () => {
|
||||
expect(checkMaxAdvance("2026-10-09", "2026-08-10", 60).valid).toBe(true); // exactly 60 days
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkBusinessDailyLimit / checkStaffDailyLimit", () => {
|
||||
it("null limit means unlimited", () => {
|
||||
expect(checkBusinessDailyLimit(999, null).valid).toBe(true);
|
||||
expect(checkStaffDailyLimit(999, null).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("fails once the existing count reaches the limit", () => {
|
||||
expect(checkBusinessDailyLimit(10, 10).valid).toBe(false);
|
||||
expect(checkStaffDailyLimit(5, 5).valid).toBe(false);
|
||||
});
|
||||
|
||||
it("passes below the limit", () => {
|
||||
expect(checkBusinessDailyLimit(9, 10).valid).toBe(true);
|
||||
expect(checkStaffDailyLimit(4, 5).valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEffectiveBuffer", () => {
|
||||
it("sums the business baseline with the largest per-service buffer", () => {
|
||||
const result = computeEffectiveBuffer(
|
||||
{ bufferBeforeMinutes: 5, bufferAfterMinutes: 5 },
|
||||
[
|
||||
{ bufferBeforeMinutes: 0, bufferAfterMinutes: 15 }, // e.g. colour treatment
|
||||
{ bufferBeforeMinutes: 0, bufferAfterMinutes: 0 },
|
||||
],
|
||||
);
|
||||
expect(result).toEqual({ beforeMinutes: 5, afterMinutes: 20 });
|
||||
});
|
||||
|
||||
it("returns just the business baseline when no service adds a buffer", () => {
|
||||
expect(computeEffectiveBuffer({ bufferBeforeMinutes: 0, bufferAfterMinutes: 5 }, [])).toEqual({
|
||||
beforeMinutes: 0,
|
||||
afterMinutes: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLateCancellation / isLateReschedule", () => {
|
||||
const now = new Date("2026-08-10T10:00:00Z");
|
||||
|
||||
it("flags a cancellation inside the free window as late", () => {
|
||||
const appointmentStart = new Date("2026-08-10T20:00:00Z"); // 10h away, window is 24h
|
||||
expect(isLateCancellation(appointmentStart, now, 24)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag a cancellation outside the free window", () => {
|
||||
const appointmentStart = new Date("2026-08-12T10:00:00Z"); // 48h away
|
||||
expect(isLateCancellation(appointmentStart, now, 24)).toBe(false);
|
||||
});
|
||||
|
||||
it("applies the same logic to reschedules with their own window", () => {
|
||||
const appointmentStart = new Date("2026-08-10T11:00:00Z"); // 1h away, window is 24h
|
||||
expect(isLateReschedule(appointmentStart, now, 24)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combinedServiceDuration", () => {
|
||||
it("sums durations across multiple services", () => {
|
||||
expect(combinedServiceDuration([{ durationMinutes: 30 }, { durationMinutes: 15 }])).toBe(45);
|
||||
});
|
||||
|
||||
it("returns 0 for an empty list", () => {
|
||||
expect(combinedServiceDuration([])).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import "dotenv/config";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* Integration-test fixtures — real Postgres, real transactions. Not used
|
||||
* by the unit test suite (`npm test`); only by `npm run test:integration`.
|
||||
* See docs/phase-3-summary.md for how to point this at a database.
|
||||
*/
|
||||
|
||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
||||
export const prisma = new PrismaClient({ adapter });
|
||||
|
||||
let counter = 0;
|
||||
function uniqueId(prefix: string): string {
|
||||
counter += 1;
|
||||
return `${prefix}-${Date.now()}-${counter}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
export interface TestBusiness {
|
||||
businessId: string;
|
||||
timezone: string;
|
||||
categoryId: string;
|
||||
haircutServiceId: string;
|
||||
beardTrimServiceId: string;
|
||||
createdUserIds: string[];
|
||||
}
|
||||
|
||||
/** Mon–Sat 09:00–18:00, Sunday closed. Booking rule defaults are deliberately permissive (no min notice, no daily caps) so most tests don't have to fight them — tests that specifically exercise a rule override it. */
|
||||
export async function createTestBusiness(): Promise<TestBusiness> {
|
||||
const slug = uniqueId("test-biz");
|
||||
const timezone = "Europe/London";
|
||||
|
||||
const business = await prisma.business.create({
|
||||
data: {
|
||||
slug,
|
||||
name: "Test Barbershop",
|
||||
timezone,
|
||||
hours: {
|
||||
create: [
|
||||
{ dayOfWeek: Weekday.MONDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
|
||||
{ dayOfWeek: Weekday.TUESDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
|
||||
{ dayOfWeek: Weekday.WEDNESDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
|
||||
{ dayOfWeek: Weekday.THURSDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
|
||||
{ dayOfWeek: Weekday.FRIDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
|
||||
{ dayOfWeek: Weekday.SATURDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
|
||||
{ dayOfWeek: Weekday.SUNDAY, isClosed: true },
|
||||
],
|
||||
},
|
||||
bookingRule: {
|
||||
create: {
|
||||
minNoticeMinutes: 0,
|
||||
maxAdvanceDays: 365,
|
||||
slotIntervalMinutes: 30,
|
||||
bufferBeforeMinutes: 0,
|
||||
bufferAfterMinutes: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const category = await prisma.serviceCategory.create({
|
||||
data: { businessId: business.id, name: "Hair" },
|
||||
});
|
||||
|
||||
const haircut = await prisma.service.create({
|
||||
data: {
|
||||
businessId: business.id,
|
||||
categoryId: category.id,
|
||||
name: "Haircut",
|
||||
durationMinutes: 30,
|
||||
price: 20,
|
||||
},
|
||||
});
|
||||
|
||||
const beardTrim = await prisma.service.create({
|
||||
data: {
|
||||
businessId: business.id,
|
||||
categoryId: category.id,
|
||||
name: "Beard Trim",
|
||||
durationMinutes: 15,
|
||||
price: 10,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
businessId: business.id,
|
||||
timezone,
|
||||
categoryId: category.id,
|
||||
haircutServiceId: haircut.id,
|
||||
beardTrimServiceId: beardTrim.id,
|
||||
createdUserIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** A staff member who works Mon–Sat 09:00–18:00 (matching the business), can perform both fixture services. */
|
||||
export async function createTestStaff(
|
||||
business: TestBusiness,
|
||||
opts: { workingDays?: Weekday[] } = {},
|
||||
): Promise<{ staffId: string; userId: string }> {
|
||||
const workingDays = opts.workingDays ?? [
|
||||
Weekday.MONDAY,
|
||||
Weekday.TUESDAY,
|
||||
Weekday.WEDNESDAY,
|
||||
Weekday.THURSDAY,
|
||||
Weekday.FRIDAY,
|
||||
Weekday.SATURDAY,
|
||||
];
|
||||
|
||||
const role = await prisma.role.create({
|
||||
data: { businessId: business.businessId, name: uniqueId("Barber") },
|
||||
});
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `${uniqueId("staff")}@test.local`,
|
||||
name: "Test Barber",
|
||||
staffProfile: {
|
||||
create: {
|
||||
businessId: business.businessId,
|
||||
roleId: role.id,
|
||||
workingHours: {
|
||||
create: Object.values(Weekday).map((dayOfWeek) =>
|
||||
workingDays.includes(dayOfWeek)
|
||||
? { dayOfWeek, startMinutes: 9 * 60, endMinutes: 18 * 60 }
|
||||
: { dayOfWeek, isOff: true },
|
||||
),
|
||||
},
|
||||
services: {
|
||||
create: [{ serviceId: business.haircutServiceId }, { serviceId: business.beardTrimServiceId }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { staffProfile: true },
|
||||
});
|
||||
|
||||
business.createdUserIds.push(user.id);
|
||||
return { staffId: user.staffProfile!.id, userId: user.id };
|
||||
}
|
||||
|
||||
export async function createTestCustomer(business: TestBusiness): Promise<{ customerId: string; userId: string }> {
|
||||
const email = `${uniqueId("customer")}@test.local`;
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: "Test Customer",
|
||||
customerProfile: {
|
||||
create: { firstName: "Test", lastName: "Customer", email, isGuest: false },
|
||||
},
|
||||
},
|
||||
include: { customerProfile: true },
|
||||
});
|
||||
business.createdUserIds.push(user.id);
|
||||
return { customerId: user.customerProfile!.id, userId: user.id };
|
||||
}
|
||||
|
||||
export async function cleanupTestBusiness(business: TestBusiness): Promise<void> {
|
||||
await prisma.business.delete({ where: { id: business.businessId } }).catch(() => {});
|
||||
if (business.createdUserIds.length > 0) {
|
||||
await prisma.user.deleteMany({ where: { id: { in: business.createdUserIds } } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeFreeWindows,
|
||||
expandWithBuffer,
|
||||
generateSlotStarts,
|
||||
intersectWindows,
|
||||
mergeWindows,
|
||||
subtractWindows,
|
||||
} from "../slot-calculator";
|
||||
import type { TimeWindow } from "../types";
|
||||
|
||||
const w = (startMinutes: number, endMinutes: number): TimeWindow => ({ startMinutes, endMinutes });
|
||||
|
||||
describe("mergeWindows", () => {
|
||||
it("merges overlapping and touching windows, drops invalid/zero-length ones", () => {
|
||||
expect(mergeWindows([w(0, 60), w(30, 90), w(90, 120)])).toEqual([w(0, 120)]);
|
||||
expect(mergeWindows([w(0, 30), w(40, 60)])).toEqual([w(0, 30), w(40, 60)]);
|
||||
expect(mergeWindows([w(10, 10), w(20, 15)])).toEqual([]); // zero-length / inverted dropped
|
||||
});
|
||||
|
||||
it("sorts unordered input before merging", () => {
|
||||
expect(mergeWindows([w(100, 120), w(0, 30)])).toEqual([w(0, 30), w(100, 120)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subtractWindows", () => {
|
||||
it("removes a window entirely contained within a base window, splitting it in two", () => {
|
||||
expect(subtractWindows([w(0, 120)], [w(30, 60)])).toEqual([w(0, 30), w(60, 120)]);
|
||||
});
|
||||
|
||||
it("removes from the start and end", () => {
|
||||
expect(subtractWindows([w(0, 120)], [w(0, 30)])).toEqual([w(30, 120)]);
|
||||
expect(subtractWindows([w(0, 120)], [w(90, 120)])).toEqual([w(0, 90)]);
|
||||
});
|
||||
|
||||
it("removes the whole window when the cut fully covers it", () => {
|
||||
expect(subtractWindows([w(30, 60)], [w(0, 120)])).toEqual([]);
|
||||
});
|
||||
|
||||
it("no-ops when there's no overlap", () => {
|
||||
expect(subtractWindows([w(0, 60)], [w(60, 120)])).toEqual([w(0, 60)]);
|
||||
expect(subtractWindows([w(60, 120)], [w(0, 60)])).toEqual([w(60, 120)]);
|
||||
});
|
||||
|
||||
it("applies multiple cuts, including overlapping ones", () => {
|
||||
expect(subtractWindows([w(0, 480)], [w(60, 90), w(70, 120), w(300, 330)])).toEqual([
|
||||
w(0, 60),
|
||||
w(120, 300),
|
||||
w(330, 480),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("intersectWindows", () => {
|
||||
it("intersects business hours with staff hours", () => {
|
||||
expect(intersectWindows([w(600, 1140)], [w(540, 1080)])).toEqual([w(600, 1080)]);
|
||||
});
|
||||
|
||||
it("returns nothing when there's no overlap", () => {
|
||||
expect(intersectWindows([w(0, 60)], [w(120, 180)])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeFreeWindows", () => {
|
||||
const fullDayOpen = w(9 * 60, 18 * 60); // 09:00–18:00
|
||||
|
||||
it("returns nothing if the business is closed", () => {
|
||||
expect(
|
||||
computeFreeWindows({ businessOpen: null, staffWorking: fullDayOpen, breaks: [], onLeave: false, busy: [] }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns nothing if the staff member isn't scheduled to work", () => {
|
||||
expect(
|
||||
computeFreeWindows({ businessOpen: fullDayOpen, staffWorking: null, breaks: [], onLeave: false, busy: [] }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns nothing if the staff member is on approved leave, even if otherwise free", () => {
|
||||
expect(
|
||||
computeFreeWindows({
|
||||
businessOpen: fullDayOpen,
|
||||
staffWorking: fullDayOpen,
|
||||
breaks: [],
|
||||
onLeave: true,
|
||||
busy: [],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("intersects business and staff hours when staff works shorter hours", () => {
|
||||
const result = computeFreeWindows({
|
||||
businessOpen: w(9 * 60, 18 * 60),
|
||||
staffWorking: w(10 * 60, 16 * 60),
|
||||
breaks: [],
|
||||
onLeave: false,
|
||||
busy: [],
|
||||
});
|
||||
expect(result).toEqual([w(10 * 60, 16 * 60)]);
|
||||
});
|
||||
|
||||
it("subtracts a lunch break", () => {
|
||||
const result = computeFreeWindows({
|
||||
businessOpen: fullDayOpen,
|
||||
staffWorking: fullDayOpen,
|
||||
breaks: [w(12 * 60, 13 * 60)],
|
||||
onLeave: false,
|
||||
busy: [],
|
||||
});
|
||||
expect(result).toEqual([w(9 * 60, 12 * 60), w(13 * 60, 18 * 60)]);
|
||||
});
|
||||
|
||||
it("subtracts buffered busy time from existing appointments", () => {
|
||||
const result = computeFreeWindows({
|
||||
businessOpen: fullDayOpen,
|
||||
staffWorking: fullDayOpen,
|
||||
breaks: [],
|
||||
onLeave: false,
|
||||
busy: [w(10 * 60 - 5, 10 * 60 + 45 + 5)], // 10:00–10:45 appointment with 5min buffer each side
|
||||
});
|
||||
expect(result).toEqual([w(9 * 60, 10 * 60 - 5), w(10 * 60 + 50, 18 * 60)]);
|
||||
});
|
||||
|
||||
it("combines breaks and busy time in one pass", () => {
|
||||
const result = computeFreeWindows({
|
||||
businessOpen: fullDayOpen,
|
||||
staffWorking: fullDayOpen,
|
||||
breaks: [w(12 * 60, 13 * 60)],
|
||||
onLeave: false,
|
||||
busy: [w(9 * 60, 9 * 60 + 30)],
|
||||
});
|
||||
expect(result).toEqual([w(9 * 60 + 30, 12 * 60), w(13 * 60, 18 * 60)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateSlotStarts", () => {
|
||||
it("generates grid-aligned starts that fit the requested duration", () => {
|
||||
// 09:00–10:00 window, 30 min slots, 30 min service → 09:00 and 09:30 only.
|
||||
expect(generateSlotStarts([w(540, 600)], { slotIntervalMinutes: 30, durationMinutes: 30 })).toEqual([540, 570]);
|
||||
});
|
||||
|
||||
it("excludes a start that would run past the end of the window", () => {
|
||||
// 09:00–09:45 window, 30 min slots, 30 min service → only 09:00 fits (09:30+30=10:00 doesn't).
|
||||
expect(generateSlotStarts([w(540, 585)], { slotIntervalMinutes: 30, durationMinutes: 30 })).toEqual([540]);
|
||||
});
|
||||
|
||||
it("aligns to the global grid, not the window's own start", () => {
|
||||
// Window starts at 09:05 (e.g. after a short buffer); with a 30-min grid the first candidate is 09:30, not 09:05.
|
||||
expect(generateSlotStarts([w(545, 630)], { slotIntervalMinutes: 30, durationMinutes: 30 })).toEqual([570, 600]);
|
||||
});
|
||||
|
||||
it("handles a multi-service duration longer than the slot interval", () => {
|
||||
// 09:00–10:30, 15-min grid, 45-min combined service duration.
|
||||
const starts = generateSlotStarts([w(540, 630)], { slotIntervalMinutes: 15, durationMinutes: 45 });
|
||||
expect(starts).toEqual([540, 555, 570, 585]); // last start 09:45 -> ends 10:30 exactly
|
||||
});
|
||||
|
||||
it("returns nothing when the window is shorter than the requested duration", () => {
|
||||
expect(generateSlotStarts([w(540, 560)], { slotIntervalMinutes: 15, durationMinutes: 30 })).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects non-positive interval or duration", () => {
|
||||
expect(() => generateSlotStarts([w(0, 60)], { slotIntervalMinutes: 0, durationMinutes: 30 })).toThrow();
|
||||
expect(() => generateSlotStarts([w(0, 60)], { slotIntervalMinutes: 30, durationMinutes: 0 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandWithBuffer", () => {
|
||||
it("pads both sides", () => {
|
||||
expect(expandWithBuffer(w(600, 630), { beforeMinutes: 5, afterMinutes: 10 })).toEqual(w(595, 640));
|
||||
});
|
||||
|
||||
it("clamps at day boundaries instead of going negative or past 1440", () => {
|
||||
expect(expandWithBuffer(w(0, 30), { beforeMinutes: 15, afterMinutes: 0 })).toEqual(w(0, 30));
|
||||
expect(expandWithBuffer(w(1420, 1440), { beforeMinutes: 0, afterMinutes: 30 })).toEqual(w(1420, 1440));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user