mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
test: cover guardian notification preferences
This commit is contained in:
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 },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user