This commit is contained in:
Stefan Lazic committed 2026-08-03 21:15:24 +01:00
1 parent ec5583f972
commit 546613e677
189 files changed
+9873 -655

No files matched your search

@@ -0,0 +1,158 @@
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);
});
});