test: cover guardian notification preferences

This commit is contained in:
Marc-André Bombeck committed 2026-08-06 05:30:49 +02:00
1 parent a78189b7f6
commit be4ee9ac6d
2 files changed
+360 -2

No files matched your search

@@ -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<typeof vi.fn>
).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<typeof vi.fn>
).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<typeof vi.fn>
).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,
}),
);
});
});
@@ -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 },
]),
);
});
});