Revert "PHP Rebuild"

This reverts commit 2ef3654841.
This commit is contained in:
Stefan Lazic committed 2026-09-05 09:32:05 +01:00
1 parent a59f998f2b
commit 2aa8405006
690 files changed
+12045 -33943

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