import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { NotificationType } from "@/generated/prisma/enums"; import { cleanupTestBusiness, createTestAppointment, createTestBusiness, createTestCustomer, createTestStaff, prisma, } from "@/services/scheduling/__tests__/fixtures"; import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures"; import { MockEmailProvider } from "../providers/mock-email-provider"; import { MockPushProvider } from "../providers/mock-push-provider"; import { notify } from "../notification.service"; import { subscribeToPush } from "../push-subscription.service"; import { processDueNotificationDeliveries } from "../reminder-worker"; /** * Exercises the background processor end to end against a real database, * with MockEmailProvider standing in for Brevo — no real network calls, * matching the Phase 7 brief's "no real API calls in automated tests". */ describe("processDueNotificationDeliveries (integration)", () => { let business: TestBusiness; let provider: MockEmailProvider; beforeEach(async () => { business = await createTestBusiness(); provider = new MockEmailProvider(); }); afterEach(async () => { await cleanupTestBusiness(business); }); async function createDueEmailNotification(customerId: string, idempotencyKey: string) { const { notification } = await notify(prisma, { businessId: business.businessId, type: NotificationType.LOYALTY_STAMP_ADDED, recipientCustomerId: customerId, bodyText: "You've earned a stamp.", idempotencyKey, }); return notification; } it("sends a due PENDING EMAIL delivery and marks it SENT with the provider message id", async () => { const { customerId } = await createTestCustomer(business); const notification = await createDueEmailNotification(customerId, "test:worker:sent"); const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId }); expect(result.sent).toBe(1); expect(provider.sentEmails).toHaveLength(1); const delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "EMAIL" }, }); expect(delivery.status).toBe("SENT"); expect(delivery.providerMessageId).toBe("mock-message-id"); expect(delivery.providerName).toBe("mock"); expect(delivery.attemptCount).toBe(1); }); it("does not send a delivery scheduled for the future", async () => { const { customerId } = await createTestCustomer(business); const { notification } = await notify(prisma, { businessId: business.businessId, type: NotificationType.BOOKING_REMINDER, recipientCustomerId: customerId, bodyText: "Reminder", idempotencyKey: "test:worker:future", scheduledFor: new Date(Date.now() + 60 * 60_000), }); const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId }); expect(result.claimed).toBe(0); expect(provider.sentEmails).toHaveLength(0); const delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "EMAIL" }, }); expect(delivery.status).toBe("SCHEDULED"); }); it("retries a retryable failure with backoff, then eventually gives up after MAX_ATTEMPTS", async () => { const { customerId } = await createTestCustomer(business); const notification = await createDueEmailNotification(customerId, "test:worker:retry"); provider.setDefaultResult({ status: "failed", retryable: true, reason: "Connection timed out" }); // Attempt 1: retried with backoff — scheduledFor pushed into the future, so it won't be "due" again immediately. const first = await processDueNotificationDeliveries(prisma, { emailProvider: provider, now: new Date(), businessId: business.businessId }); expect(first.retried).toBe(1); let delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "EMAIL" } }); expect(delivery.status).toBe("PENDING"); expect(delivery.attemptCount).toBe(1); expect(delivery.scheduledFor).not.toBeNull(); // Simulate time passing past the backoff window on each subsequent attempt. for (let i = 0; i < 2; i++) { const future = new Date(delivery.scheduledFor!.getTime() + 1000); const run = await processDueNotificationDeliveries(prisma, { emailProvider: provider, now: future, businessId: business.businessId }); expect(run.retried).toBe(1); delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "EMAIL" } }); } expect(delivery.attemptCount).toBe(3); // 4th attempt (MAX_ATTEMPTS) exhausts the retry budget — permanent FAILED, not retried again. const finalRun = await processDueNotificationDeliveries(prisma, { emailProvider: provider, now: new Date(delivery.scheduledFor!.getTime() + 1000), businessId: business.businessId, }); expect(finalRun.failed).toBe(1); expect(finalRun.retried).toBe(0); delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "EMAIL" } }); expect(delivery.status).toBe("FAILED"); expect(delivery.attemptCount).toBe(4); }); it("never retries a permanent (non-retryable) failure, even on the first attempt", async () => { const { customerId } = await createTestCustomer(business); const notification = await createDueEmailNotification(customerId, "test:worker:permanent"); provider.setDefaultResult({ status: "failed", retryable: false, reason: "Mailbox does not exist" }); const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId }); expect(result.failed).toBe(1); expect(result.retried).toBe(0); const delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "EMAIL" } }); expect(delivery.status).toBe("FAILED"); expect(delivery.attemptCount).toBe(1); expect(delivery.failureReason).toBe("Mailbox does not exist"); }); it("fails a delivery cleanly when the recipient has no email address at all, without calling the provider", async () => { // NotificationService.notify() never creates an EMAIL delivery for a recipient with no // resolvable address in the first place (see resolveChannels) — so this scenario is only // reachable if the address changes between creation and processing (e.g. a customer record // edited afterward). Constructed directly here to exercise the worker's own defensive guard // in isolation, bypassing notify()'s channel resolution. const orphanCustomer = await prisma.customerProfile.create({ data: { firstName: "No", lastName: "Email", email: "", phone: null, isGuest: true }, }); try { const notification = await prisma.notification.create({ data: { businessId: business.businessId, recipientCustomerId: orphanCustomer.id, type: "LOYALTY_STAMP_ADDED", bodyText: "You've earned a stamp.", idempotencyKey: "test:worker:no-email", deliveries: { create: [{ channel: "EMAIL", status: "PENDING" }] }, }, }); const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId }); expect(result.failed).toBe(1); expect(provider.sentEmails).toHaveLength(0); const delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "EMAIL" } }); expect(delivery.status).toBe("FAILED"); } finally { await prisma.notification.deleteMany({ where: { recipientCustomerId: orphanCustomer.id } }); await prisma.customerProfile.delete({ where: { id: orphanCustomer.id } }); } }); it("does not double-send when a delivery has already been claimed (simulated concurrent worker)", async () => { const { customerId } = await createTestCustomer(business); const notification = await createDueEmailNotification(customerId, "test:worker:concurrent"); const delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "EMAIL" } }); // Simulate another worker having already claimed this row a moment ago. await prisma.notificationDelivery.update({ where: { id: delivery.id }, data: { status: "PROCESSING" } }); const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId }); expect(result.claimed).toBe(0); expect(provider.sentEmails).toHaveLength(0); }); it("raises OWNER_REPEATED_DELIVERY_FAILURE once several EMAIL deliveries fail for the same business in one run", async () => { const role = await prisma.role.create({ data: { businessId: business.businessId, name: "Test Owner", permissions: { create: [{ permission: "VIEW_REPORTING" }] } }, }); const ownerUser = await prisma.user.create({ data: { email: `${crypto.randomUUID()}@test.local`, staffProfile: { create: { businessId: business.businessId, roleId: role.id } }, }, }); provider.setDefaultResult({ status: "failed", retryable: false, reason: "Rejected" }); const customers = await Promise.all([createTestCustomer(business), createTestCustomer(business), createTestCustomer(business)]); for (const [i, c] of customers.entries()) { await createDueEmailNotification(c.customerId, `test:worker:repeated-failure-${i}`); } const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId }); expect(result.failed).toBe(3); const ownerNotice = await prisma.notification.findFirst({ where: { recipientUserId: ownerUser.id, type: "OWNER_REPEATED_DELIVERY_FAILURE" }, }); expect(ownerNotice).not.toBeNull(); }); describe("PUSH channel", () => { let pushProvider: MockPushProvider; beforeEach(() => { pushProvider = new MockPushProvider(); }); async function createDuePushNotification(customerId: string, userId: string, idempotencyKey: string) { const { notification } = await notify(prisma, { businessId: business.businessId, type: NotificationType.LOYALTY_STAMP_ADDED, recipientUserId: userId, recipientCustomerId: customerId, bodyText: "You've earned a stamp.", idempotencyKey, }); return notification; } it("fans out to every active subscription and marks the delivery SENT if at least one succeeds", async () => { const { customerId, userId } = await createTestCustomer(business); await subscribeToPush(prisma, { businessId: business.businessId, userId, customerId, endpoint: "https://push.example.com/device-1", p256dhKey: "k1", authKey: "a1" }); await subscribeToPush(prisma, { businessId: business.businessId, userId, customerId, endpoint: "https://push.example.com/device-2", p256dhKey: "k2", authKey: "a2" }); const notification = await createDuePushNotification(customerId, userId, "test:worker:push-fanout"); const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, pushProvider, businessId: business.businessId }); expect(pushProvider.sentPushes).toHaveLength(2); const delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "PUSH" } }); expect(delivery.status).toBe("SENT"); expect(result.sent).toBeGreaterThanOrEqual(1); }); it("revokes a subscription the push service reports as gone (410), independent of the overall delivery outcome", async () => { const { customerId, userId } = await createTestCustomer(business); await subscribeToPush(prisma, { businessId: business.businessId, userId, customerId, endpoint: "https://push.example.com/stale-device", p256dhKey: "k1", authKey: "a1" }); pushProvider.setDefaultResult({ status: "failed", retryable: false, subscriptionInvalid: true, reason: "Gone" }); await createDuePushNotification(customerId, userId, "test:worker:push-revoke"); await processDueNotificationDeliveries(prisma, { emailProvider: provider, pushProvider, businessId: business.businessId }); const subscription = await prisma.pushSubscription.findUniqueOrThrow({ where: { endpoint: "https://push.example.com/stale-device" } }); expect(subscription.revokedAt).not.toBeNull(); }); it("fails cleanly with no push provider configured, without touching subscriptions", async () => { const { customerId, userId } = await createTestCustomer(business); await subscribeToPush(prisma, { businessId: business.businessId, userId, customerId, endpoint: "https://push.example.com/no-provider", p256dhKey: "k", authKey: "a" }); const notification = await createDuePushNotification(customerId, userId, "test:worker:push-no-provider"); await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId }); const delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notificationId: notification.id, channel: "PUSH" } }); expect(delivery.status).toBe("PENDING"); // retryable — a future run with a configured provider can still succeed const subscription = await prisma.pushSubscription.findUniqueOrThrow({ where: { endpoint: "https://push.example.com/no-provider" } }); expect(subscription.revokedAt).toBeNull(); }); }); describe("stale reminder guard", () => { async function dueReminderFor(appointmentStartAt: Date, status: "CONFIRMED" | "CANCELLED") { const { staffId } = await createTestStaff(business); const { customerId } = await createTestCustomer(business); const { appointmentId } = await createTestAppointment(business, { customerId, staffId, startAt: appointmentStartAt }); await prisma.appointment.update({ where: { id: appointmentId }, data: { status } }); await notify(prisma, { businessId: business.businessId, type: NotificationType.BOOKING_REMINDER, recipientCustomerId: customerId, appointmentId, bodyText: "Reminder", idempotencyKey: `test:worker:stale:${appointmentId}`, scheduledFor: new Date(Date.now() - 60_000), }); return appointmentId; } it("still sends a due reminder for an upcoming confirmed appointment", async () => { await dueReminderFor(new Date(Date.now() + 3 * 60 * 60_000), "CONFIRMED"); const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId }); expect(result.sent).toBe(1); expect(result.skippedStale).toBe(0); }); it("drops — doesn't send, doesn't fail — a reminder whose appointment has already started", async () => { // e.g. the processor was down long enough that a 24h reminder came due after the appointment itself. const appointmentId = await dueReminderFor(new Date(Date.now() - 30 * 60_000), "CONFIRMED"); const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId }); expect(result.sent).toBe(0); expect(result.failed).toBe(0); expect(result.skippedStale).toBe(1); expect(provider.sentEmails).toHaveLength(0); const delivery = await prisma.notificationDelivery.findFirstOrThrow({ where: { notification: { appointmentId } } }); expect(delivery.status).toBe("CANCELLED"); }); it("drops a reminder whose appointment is no longer confirmed", async () => { await dueReminderFor(new Date(Date.now() + 3 * 60 * 60_000), "CANCELLED"); const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId }); expect(result.sent).toBe(0); expect(result.skippedStale).toBe(1); expect(provider.sentEmails).toHaveLength(0); }); }); });