diff --git a/src/__tests__/notification-delivery-identity.test.ts b/src/__tests__/notification-delivery-identity.test.ts index 7cd591d7d..f29c3f9b2 100644 --- a/src/__tests__/notification-delivery-identity.test.ts +++ b/src/__tests__/notification-delivery-identity.test.ts @@ -10,6 +10,7 @@ vi.mock("@/lib/db", () => ({ upsert: vi.fn(), }, user: { findUnique: vi.fn() }, + accountGrant: { findMany: vi.fn() }, auditLog: { create: vi.fn() }, $transaction: (...args: unknown[]) => transactionMock(...args), }, @@ -32,15 +33,17 @@ vi.mock("@/lib/logging/context", () => ({ })); const sendViaWebPushMock = vi.fn(); +const sendViaNtfyMock = vi.fn(); +const sendViaApnsMock = vi.fn(); vi.mock("@/lib/notifications/senders/apns", () => ({ - sendViaApns: vi.fn(), + sendViaApns: (...args: unknown[]) => sendViaApnsMock(...args), })); vi.mock("@/lib/notifications/senders/email", () => ({ sendViaEmail: vi.fn(), })); vi.mock("@/lib/notifications/senders/ntfy", () => ({ - sendViaNtfy: vi.fn(), + sendViaNtfy: (...args: unknown[]) => sendViaNtfyMock(...args), })); vi.mock("@/lib/notifications/senders/telegram", () => ({ sendViaTelegram: vi.fn(), @@ -94,6 +97,8 @@ beforeEach(() => { }), ); sendViaWebPushMock.mockResolvedValue({ ok: true }); + sendViaNtfyMock.mockResolvedValue({ ok: true }); + sendViaApnsMock.mockResolvedValue({ ok: true }); }); describe("notification delivery identity", () => { @@ -180,4 +185,155 @@ describe("notification delivery identity", () => { ); expect(sendViaWebPushMock).not.toHaveBeenCalled(); }); + + it("renders for each Guardian while isolating channel preferences", async () => { + const firstGuardian = "guardian-en"; + const secondGuardian = "guardian-de"; + + ( + prisma.user.findUnique as unknown as ReturnType + ).mockImplementation(async ({ where }: { where: { id: string } }) => { + if (where.id === recordUserId) { + return { managedProfileAt: new Date() } as never; + } + if (where.id === firstGuardian) { + return { + managedProfileAt: null, + locale: "en", + notificationPrefs: { medication: { clientManaged: true } }, + } as never; + } + return { + managedProfileAt: null, + locale: "de", + notificationPrefs: { medication: { clientManaged: true } }, + } as never; + }); + vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([ + { granteeId: firstGuardian, grantee: { managedProfileAt: null } }, + { granteeId: secondGuardian, grantee: { managedProfileAt: null } }, + ] as never); + ( + prisma.notificationChannel.findMany as unknown as ReturnType + ).mockImplementation(async ({ where }: { where: { userId: string } }) => { + if (where.userId === firstGuardian) { + return [ + { + id: "first-web-disabled", + userId: firstGuardian, + type: "WEB_PUSH", + config: "{}", + nextRetryAt: null, + preferences: [{ eventType: "MEDICATION_REMINDER", enabled: false }], + }, + { + id: "first-ntfy-enabled", + userId: firstGuardian, + type: "NTFY", + config: "{}", + nextRetryAt: null, + preferences: [], + }, + ] as never; + } + return [ + { + id: "second-web-enabled", + userId: secondGuardian, + type: "WEB_PUSH", + config: "{}", + nextRetryAt: null, + preferences: [], + }, + { + id: "second-ntfy-disabled", + userId: secondGuardian, + type: "NTFY", + config: "{}", + nextRetryAt: null, + preferences: [{ eventType: "MEDICATION_REMINDER", enabled: false }], + }, + ] as never; + }); + + const outcome = await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: recordUserId, + title: "record-language-title", + message: "record-language-message", + renderForRecipient: (locale) => ({ + title: `title-${locale}`, + message: `message-${locale}`, + }), + }); + + expect(outcome).toEqual({ + dispatched: true, + channelsAttempted: 2, + channelsSucceeded: 2, + }); + expect(sendViaNtfyMock).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + recipientUserId: firstGuardian, + title: "title-en", + message: "message-en", + }), + ); + expect(sendViaWebPushMock).toHaveBeenCalledWith( + secondGuardian, + expect.objectContaining({ + recipientUserId: secondGuardian, + title: "title-de", + message: "message-de", + }), + ); + expect(sendViaWebPushMock).toHaveBeenCalledTimes(1); + expect(sendViaNtfyMock).toHaveBeenCalledTimes(1); + }); + + it("bypasses client-managed APNs suppression only for managed Guardian delivery", async () => { + const guardian = "guardian-apns"; + ( + prisma.user.findUnique as unknown as ReturnType + ).mockImplementation( + async ({ where }: { where: { id: string } }) => + (where.id === recordUserId + ? { managedProfileAt: new Date() } + : { + managedProfileAt: null, + notificationPrefs: { medication: { clientManaged: true } }, + }) as never, + ); + vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([ + { granteeId: guardian, grantee: { managedProfileAt: null } }, + ] as never); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + { + id: "guardian-apns", + userId: guardian, + type: "APNS", + config: "{}", + nextRetryAt: null, + preferences: [], + }, + ] as never); + + await expect( + dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: recordUserId, + title: "Record content", + message: "Record schedule", + }), + ).resolves.toMatchObject({ dispatched: true, channelsSucceeded: 1 }); + + expect(sendViaApnsMock).toHaveBeenCalledWith( + guardian, + expect.objectContaining({ + recordUserId, + recipientUserId: guardian, + }), + ); + }); }); diff --git a/tests/integration/guardian-notification-fanout.test.ts b/tests/integration/guardian-notification-fanout.test.ts new file mode 100644 index 000000000..469b1449f --- /dev/null +++ b/tests/integration/guardian-notification-fanout.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const sent = vi.hoisted(() => ({ + ntfy: [] as Array<{ + recipientUserId: string; + title: string; + message: string; + }>, + apns: [] as Array<{ recipientUserId: string; recordUserId?: string }>, +})); + +vi.mock("@/lib/notifications/senders/ntfy", () => ({ + sendViaNtfy: async ( + _config: unknown, + payload: { recipientUserId?: string; title: string; message: string }, + ) => { + sent.ntfy.push({ + recipientUserId: payload.recipientUserId ?? "missing", + title: payload.title, + message: payload.message, + }); + return { ok: true }; + }, +})); + +vi.mock("@/lib/notifications/senders/apns", () => ({ + sendViaApns: async ( + recipientUserId: string, + payload: { recordUserId?: string }, + ) => { + sent.apns.push({ recipientUserId, recordUserId: payload.recordUserId }); + return { ok: true }; + }, +})); + +import { encrypt } from "@/lib/crypto"; +import type { Locale } from "@/lib/i18n/config"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +let sequence = 0; + +async function createUser(input: { + label: string; + locale: "en" | "de"; + managed?: boolean; + clientManaged?: boolean; +}) { + const suffix = sequence++; + return getPrismaClient().user.create({ + data: { + username: `${input.label}-${suffix}`, + email: `${input.label}-${suffix}@example.test`, + locale: input.locale, + managedProfileAt: input.managed ? new Date() : null, + notificationPrefs: input.clientManaged + ? { medication: { clientManaged: true } } + : undefined, + }, + }); +} + +async function addGuardian(recordUserId: string, recipientUserId: string) { + await getPrismaClient().accountGrant.create({ + data: { + grantorId: recordUserId, + granteeId: recipientUserId, + access: "MANAGE", + acceptedAt: new Date(), + }, + }); +} + +async function addChannel( + userId: string, + type: "NTFY" | "APNS", + enabled = true, +) { + await getPrismaClient().notificationChannel.create({ + data: { + userId, + type, + enabled, + config: encrypt("{}"), + }, + }); +} + +beforeEach(async () => { + sent.ntfy.length = 0; + sent.apns.length = 0; + await truncateAllTables(getPrismaClient()); +}); + +describe("Guardian fan-out (real Postgres)", () => { + it("isolates recipient locale and global preference across two Guardians and two records", async () => { + const [recordA, recordB, guardianEn, guardianDe] = await Promise.all([ + createUser({ label: "record-a", locale: "en", managed: true }), + createUser({ label: "record-b", locale: "de", managed: true }), + createUser({ label: "guardian-en", locale: "en" }), + createUser({ label: "guardian-de", locale: "de" }), + ]); + await Promise.all([ + addGuardian(recordA.id, guardianEn.id), + addGuardian(recordA.id, guardianDe.id), + addGuardian(recordB.id, guardianEn.id), + addGuardian(recordB.id, guardianDe.id), + addChannel(guardianEn.id, "NTFY"), + addChannel(guardianDe.id, "NTFY"), + ]); + + const renderForRecipient = (locale: Locale) => ({ + title: `title-${locale}`, + message: `message-${locale}`, + }); + await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: recordA.id, + title: "record-a-title", + message: "record-a-message", + renderForRecipient, + }); + + expect(sent.ntfy).toEqual( + expect.arrayContaining([ + { + recipientUserId: guardianEn.id, + title: "title-en", + message: "message-en", + }, + { + recipientUserId: guardianDe.id, + title: "title-de", + message: "message-de", + }, + ]), + ); + + await getPrismaClient().notificationPreference.create({ + data: { + channel: { + connect: { userId_type: { userId: guardianEn.id, type: "NTFY" } }, + }, + eventType: "MEDICATION_REMINDER", + enabled: false, + }, + }); + sent.ntfy.length = 0; + + await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: recordB.id, + title: "record-b-title", + message: "record-b-message", + renderForRecipient, + }); + + expect(sent.ntfy).toEqual([ + { + recipientUserId: guardianDe.id, + title: "title-de", + message: "message-de", + }, + ]); + }); + + it("bypasses client-managed suppression for each Guardian but not self delivery", async () => { + const [record, guardianEn, guardianDe] = await Promise.all([ + createUser({ label: "record", locale: "en", managed: true }), + createUser({ + label: "guardian-en", + locale: "en", + clientManaged: true, + }), + createUser({ + label: "guardian-de", + locale: "de", + clientManaged: true, + }), + ]); + await Promise.all([ + addGuardian(record.id, guardianEn.id), + addGuardian(record.id, guardianDe.id), + addChannel(guardianEn.id, "APNS"), + addChannel(guardianDe.id, "APNS"), + ]); + + await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: record.id, + title: "record-title", + message: "record-message", + }); + + expect(sent.apns).toEqual( + expect.arrayContaining([ + { recipientUserId: guardianEn.id, recordUserId: record.id }, + { recipientUserId: guardianDe.id, recordUserId: record.id }, + ]), + ); + }); +});