Phase 7
This commit is contained in:
189 files changed
+9873
-655
No files matched your search
@@ -0,0 +1,261 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { NotificationType } from "@/generated/prisma/enums";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user