159 lines
6.5 KiB
TypeScript
159 lines
6.5 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 { 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);
|
||
});
|
||
});
|