687 lines
27 KiB
TypeScript
687 lines
27 KiB
TypeScript
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);
|
||
});
|
||
});
|
||
});
|