// Tests for the reminder *handler* — the pg-boss producer side requires a // running boss instance and is exercised via the worker smoke test, not here. import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest"; import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaClient } from "@/generated/prisma/client"; import { confirmHold, createHold } from "@/lib/booking"; import { handleReminderJob } from "@/lib/reminders"; import { resetEmailTransport } from "@/lib/email"; import { seed, type SeedResult } from "@/lib/seed"; const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); const db = new PrismaClient({ adapter }); const MAILPIT = "http://localhost:8025"; let fx: SeedResult; let serviceId: string; let therapistA: string; let roomA: string; let customerId: string; beforeAll(async () => { fx = await seed(db); serviceId = fx.services.find((s) => s.name === "60-minute Swedish")!.id; therapistA = fx.therapists[0].id; roomA = fx.rooms[0].id; customerId = fx.customers[0].id; process.env.SMTP_HOST = "localhost"; process.env.SMTP_PORT = "1025"; process.env.SMTP_FROM = "TouchBase "; resetEmailTransport(); }); afterAll(async () => { await db.$disconnect(); }); beforeEach(async () => { await db.notification.deleteMany(); await db.booking.deleteMany(); await fetch(`${MAILPIT}/api/v1/messages`, { method: "DELETE" }); }); const D = (iso: string) => new Date(iso); async function makeConfirmedBooking(startsAt: Date) { const hold = await createHold(db, { customerId, serviceId, therapistId: therapistA, roomId: roomA, startsAt, }); await confirmHold(db, hold.id); return hold; } describe("handleReminderJob", () => { test("sends and writes a Notification when CONFIRMED + future + not-yet-reminded", async () => { const future = new Date(Date.now() + 24 * 3600 * 1000); const hold = await makeConfirmedBooking(future); const result = await handleReminderJob(db, { bookingId: hold.id }); expect(result.sent).toBe(true); const noti = await db.notification.findFirst({ where: { bookingId: hold.id, template: "booking_reminder" }, }); expect(noti?.status).toBe("sent"); expect(noti?.userId).toBe(customerId); const res = await fetch(`${MAILPIT}/api/v1/messages`); const json = (await res.json()) as { messages: { Subject: string }[] }; expect(json.messages).toHaveLength(1); expect(json.messages[0].Subject).toMatch(/Reminder:/); }); test("dedupes — second call after a sent reminder is a no-op", async () => { const future = new Date(Date.now() + 24 * 3600 * 1000); const hold = await makeConfirmedBooking(future); await handleReminderJob(db, { bookingId: hold.id }); const second = await handleReminderJob(db, { bookingId: hold.id }); expect(second.sent).toBe(false); expect(second.reason).toBe("already_sent"); const count = await db.notification.count({ where: { bookingId: hold.id, template: "booking_reminder" }, }); expect(count).toBe(1); }); test("skips CANCELLED bookings", async () => { const future = new Date(Date.now() + 24 * 3600 * 1000); const hold = await makeConfirmedBooking(future); await db.booking.update({ where: { id: hold.id }, data: { status: "CANCELLED", cancelledAt: new Date() }, }); const result = await handleReminderJob(db, { bookingId: hold.id }); expect(result.sent).toBe(false); expect(result.reason).toBe("status:CANCELLED"); }); test("skips bookings whose startsAt is already past", async () => { const past = D("2026-04-01T14:00:00Z"); const hold = await makeConfirmedBooking(past); const result = await handleReminderJob(db, { bookingId: hold.id }); expect(result.sent).toBe(false); expect(result.reason).toBe("past"); }); test("returns not_found for unknown bookingId", async () => { const result = await handleReminderJob(db, { bookingId: "no-such" }); expect(result.sent).toBe(false); expect(result.reason).toBe("not_found"); }); test("skips HOLD bookings (only CONFIRMED reminds)", async () => { const future = new Date(Date.now() + 24 * 3600 * 1000); const hold = await createHold(db, { customerId, serviceId, therapistId: therapistA, roomId: roomA, startsAt: future, }); const result = await handleReminderJob(db, { bookingId: hold.id }); expect(result.sent).toBe(false); expect(result.reason).toBe("status:HOLD"); }); });