email reminders
This commit is contained in:
130
test/reminders.test.ts
Normal file
130
test/reminders.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user