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,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user