Files
NextChair/src/services/scheduling/__tests__/reminder-scheduling.integration.test.ts
T
2026-08-03 21:15:24 +01:00

159 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});