131 lines
4.5 KiB
TypeScript
131 lines
4.5 KiB
TypeScript
|
|
// 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 <noreply@touchbase.local>";
|
||
|
|
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");
|
||
|
|
});
|
||
|
|
});
|