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 { cancelBooking, createBooking, rescheduleBooking } from "../booking.service"; import { cleanupTestBusiness, createTestBusiness, createTestCustomer, createTestStaff, prisma } from "./fixtures"; import type { TestBusiness } from "./fixtures"; 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"); } async function reminderDeliveries(appointmentId: string) { return prisma.notificationDelivery.findMany({ where: { notification: { appointmentId, type: { in: ["BOOKING_REMINDER", "STAFF_APPOINTMENT_REMINDER"] } }, channel: { in: ["EMAIL", "PUSH"] }, }, include: { notification: true }, }); } describe("reminder scheduling (integration)", () => { let business: TestBusiness; let monday: CalendarDate; beforeEach(async () => { business = await createTestBusiness(); monday = nextWeekday(business.timezone, Weekday.MONDAY); }); afterEach(async () => { await cleanupTestBusiness(business); }); it("schedules 24h and 2h EMAIL reminders for both customer and staff, as SCHEDULED (not PENDING)", async () => { const { staffId } = await createTestStaff(business); const { customerId } = await createTestCustomer(business); const result = await createBooking(prisma, { businessId: business.businessId, customerId, staffId, serviceIds: [business.haircutServiceId], startAt: wallClockToInstant(monday, 10 * 60, business.timezone), }); if (!result.success) throw new Error("setup failed"); const deliveries = await reminderDeliveries(result.appointment.id); expect(deliveries).toHaveLength(4); // 2 offsets × (customer + staff) for (const d of deliveries) { expect(d.status).toBe("SCHEDULED"); expect(d.scheduledFor).not.toBeNull(); expect(d.scheduledFor!.getTime()).toBeLessThan(result.appointment.startAt.getTime()); } }); it("does not schedule reminders for a PENDING (requireApproval) booking", async () => { await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { requireApproval: true } }); const { staffId } = await createTestStaff(business); const { customerId } = await createTestCustomer(business); const result = await createBooking(prisma, { businessId: business.businessId, customerId, staffId, serviceIds: [business.haircutServiceId], startAt: wallClockToInstant(monday, 10 * 60, business.timezone), }); if (!result.success) throw new Error("setup failed"); const deliveries = await reminderDeliveries(result.appointment.id); expect(deliveries).toHaveLength(0); }); it("does not schedule a reminder offset that would already be in the past", async () => { const { staffId } = await createTestStaff(business); const { customerId } = await createTestCustomer(business); // Book for 1 hour from now — the 24h and 2h-before offsets are both already past. const result = await createBooking(prisma, { businessId: business.businessId, customerId, staffId, serviceIds: [business.haircutServiceId], startAt: new Date(Date.now() + 3 * 60 * 60_000), // 3h out, staff working hours permitting — see note below }).catch(() => null); // This may fail validation depending on min-notice/working-hours fixtures; only assert the // reminder-skipping behaviour when the booking itself actually succeeded. if (!result || !result.success) return; const deliveries = await reminderDeliveries(result.appointment.id); // 3h out: the 2h-before offset is still in the future, the 24h-before offset is in the past. expect(deliveries.length).toBeLessThanOrEqual(2); expect(deliveries.every((d) => d.notification.idempotencyKey.includes("reminder-2h"))).toBe(true); }); it("rescheduling cancels the old reminders and schedules fresh ones for the new 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), }); if (!created.success) throw new Error("setup failed"); const originalDeliveries = await reminderDeliveries(created.appointment.id); expect(originalDeliveries).toHaveLength(4); const rescheduled = await rescheduleBooking(prisma, { appointmentId: created.appointment.id, startAt: wallClockToInstant(monday, 14 * 60, business.timezone), }); expect(rescheduled.success).toBe(true); if (!rescheduled.success) return; const allDeliveries = await reminderDeliveries(created.appointment.id); const cancelledCount = allDeliveries.filter((d) => d.status === "CANCELLED").length; const scheduledCount = allDeliveries.filter((d) => d.status === "SCHEDULED").length; expect(cancelledCount).toBe(4); // the original 4, now superseded expect(scheduledCount).toBe(4); // 4 fresh ones for the new time const freshOnes = allDeliveries.filter((d) => d.status === "SCHEDULED"); for (const d of freshOnes) { expect(d.scheduledFor!.getTime()).toBeLessThan(rescheduled.appointment.startAt.getTime()); } }); it("cancelling the appointment cancels its scheduled reminders", 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"); const cancelled = await cancelBooking(prisma, { appointmentId: created.appointment.id }); expect(cancelled.success).toBe(true); const deliveries = await reminderDeliveries(created.appointment.id); expect(deliveries.every((d) => d.status === "CANCELLED")).toBe(true); expect(deliveries).toHaveLength(4); }); });