Files
NextChair/src/services/notifications/__tests__/reminder-worker.integration.test.ts
T
Stefan Lazic 1442d33d55
CI / ci (push) Canceled after 0s
Fixes
Guest merge

Guest history now joins the account when the email is proven, in three places: OTP sign-in, Google sign-in, and staff adding a client. Unverified emails are never matched.

What moves: appointments, notes, push subscriptions and notifications go onto the account's profile, and the guest profile is deleted.
One email, several guests: if the email has several guest profiles (one per phone number), all are merged.
No profile yet: the oldest guest profile becomes the account's profile.
Account wins: it keeps its own name and phone, and only gaps get filled.
Stamps: summed, with the ledger entries moved across. If the combined total reaches the threshold, the reward is earned and the customer is notified.
Safety: it runs in one transaction and is audit-logged. A failure never blocks sign-in; the next sign-in retries.

Reminders
Staff: one reminder, 2h before; the 24h one is gone. Push and in-app are on by default and email is opt-in. Defaults are now per channel, and the settings page shows email off, push and in-app on.
Customer: one reminder at 24h. If booked too late for that, they get one at 2h instead, and none if the appointment is under 2h away.
In-app reminders: held back and released by the worker at the reminder time, so they no longer appear the moment you book. Cancelling or rescheduling clears them, and "mark all read" no longer marks unseen ones.
Guest links: every guest email now carries a working link. The booking-time token is unchanged, and later emails use one derived from the appointment id and AUTH_SECRET. Revoking a guest appointment's access kills both.
Reward expiry: two emails per reward at most, at 7 days and 1 day before.
Stale guard: the worker drops a reminder, without counting a failure, if the appointment has started or isn't confirmed.

OTP rate-limit tests (2): the failures came from my scratch environment, not the app. I had E2E_TEST_MODE=true in the scratch .env, which turns off the OTP rate limits, so the tests had nothing to assert on. I'd also used MariaDB, which throws error 1020 on concurrent upserts, while your real stack runs mysql:8.0. On MySQL 8 without the flag they pass every time. I also pinned that flag off in the integration config, so a .env with it set can't break them again. I tested that by turning it on in .env, and the OTP tests still passed.
Authorization and nav-role suites (2): this was a real bug. Both files import @/lib/auth/permissions, which builds the database connection when it's imported, before the test fixtures load .env. The suites crashed before running a single test, so the owner-permission checks (barbers can't do owner actions, nothing leaks across businesses) hadn't been running at all. I now load dotenv once in the integration config, so no test depends on import order. They run and pass (13 tests).
2026-10-07 16:39:12 +01:00

314 lines
16 KiB
TypeScript

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);
});
});
});