import type { NotificationMessage } from "@rakazo/adapter-kit"; import type { PrismaClient } from "@rakazo/db"; import { describe, expect, it, vi } from "vitest"; import type { PushOutcome, PushPayload, PushSender } from "./push-notifications.js"; import { PushNotificationProvider } from "./push-notifications.js"; const context = { operationId: "notify", traceId: "t", spaceId: "space-1", userId: "user-1", signal: new AbortController().signal, }; const message: NotificationMessage = { kind: "completion", title: "Chief finished", body: "Booked the table for Friday.", botId: "bot-1", threadId: "thread-1", }; function fakeDb(options: { devices: Array<{ id: string; kind: "webpush" | "expo" }>; quietHours?: { start: number; end: number }; timeZone?: string; groupId?: string | null; }) { const forgotten: string[] = []; const used: string[] = []; const prisma = { userNotificationSettings: { findUnique: async () => options.quietHours ? { quietHoursStart: options.quietHours.start, quietHoursEnd: options.quietHours.end, nudges: true, } : null, }, userProfile: { findUnique: async () => ({ timeZone: options.timeZone ?? null }) }, pushSubscription: { findMany: async () => options.devices.map((device) => ({ ...device, endpoint: `endpoint-${device.id}`, p256dh: null, auth: null, })), deleteMany: async ({ where }: { where: { id: string } }) => { forgotten.push(where.id); return { count: 1 }; }, updateMany: async ({ where }: { where: { id: string } }) => { used.push(where.id); return { count: 1 }; }, }, thread: { findUnique: async () => ({ groupId: options.groupId ?? null }) }, } as unknown as PrismaClient; return { prisma, forgotten, used }; } function fakeSender(outcome: PushOutcome | Error) { const sent: PushPayload[] = []; const sender: PushSender = { send: vi.fn(async (_target, payload) => { sent.push(payload); if (outcome instanceof Error) throw outcome; return outcome; }), }; return { sender, sent }; } // 23:30 in Vienna (UTC+2 in summer). const lateEvening = () => new Date("2026-07-01T21:30:00Z"); describe("push notification fan-out", () => { it("sends to every device with a link to the conversation", async () => { const { prisma, used } = fakeDb({ devices: [ { id: "browser", kind: "webpush" }, { id: "phone", kind: "expo" }, ], groupId: "group-1", }); const web = fakeSender("sent"); const expo = fakeSender("sent"); await new PushNotificationProvider(prisma, { webpush: web.sender, expo: expo.sender }).send( message, context, ); expect(web.sent).toEqual([{ ...message, url: "/app/g/group-1" }]); expect(expo.sent).toHaveLength(1); expect(used.sort()).toEqual(["browser", "phone"]); }); it("links a direct thread to its bot and shortens long bodies", async () => { const { prisma } = fakeDb({ devices: [{ id: "browser", kind: "webpush" }] }); const web = fakeSender("sent"); await new PushNotificationProvider(prisma, { webpush: web.sender }).send( { ...message, body: "x".repeat(1_000) }, context, ); expect(web.sent[0]?.url).toBe("/app/bot-1"); expect(web.sent[0]?.body.length).toBeLessThanOrEqual(240); }); it("holds every push back during quiet hours in the user's time zone", async () => { const quiet = fakeDb({ devices: [{ id: "browser", kind: "webpush" }], quietHours: { start: 22 * 60, end: 7 * 60 }, timeZone: "Europe/Vienna", }); const web = fakeSender("sent"); await new PushNotificationProvider(quiet.prisma, { webpush: web.sender }, lateEvening).send( message, context, ); expect(web.sent).toHaveLength(0); // The same moment is mid-afternoon in Los Angeles. const awake = fakeDb({ devices: [{ id: "browser", kind: "webpush" }], quietHours: { start: 22 * 60, end: 7 * 60 }, timeZone: "America/Los_Angeles", }); await new PushNotificationProvider(awake.prisma, { webpush: web.sender }, lateEvening).send( message, context, ); expect(web.sent).toHaveLength(1); }); it("drops devices the push service no longer knows", async () => { const { prisma, forgotten, used } = fakeDb({ devices: [ { id: "old-browser", kind: "webpush" }, { id: "phone", kind: "expo" }, ], }); await new PushNotificationProvider(prisma, { webpush: fakeSender("gone").sender, expo: fakeSender("sent").sender, }).send(message, context); expect(forgotten).toEqual(["old-browser"]); expect(used).toEqual(["phone"]); }); it("succeeds when one device works and fails only when all of them fail", async () => { const mixed = fakeDb({ devices: [ { id: "browser", kind: "webpush" }, { id: "phone", kind: "expo" }, ], }); await expect( new PushNotificationProvider(mixed.prisma, { webpush: fakeSender(new Error("Web push failed (500)")).sender, expo: fakeSender("sent").sender, }).send(message, context), ).resolves.toBeUndefined(); const broken = fakeDb({ devices: [{ id: "browser", kind: "webpush" }] }); await expect( new PushNotificationProvider(broken.prisma, { webpush: fakeSender(new Error("Web push failed (500)")).sender, }).send(message, context), ).rejects.toThrow("Push delivery failed"); }); it("does nothing without devices or senders", async () => { const { prisma } = fakeDb({ devices: [{ id: "phone", kind: "expo" }] }); const web = fakeSender("sent"); await new PushNotificationProvider(prisma, { webpush: web.sender }).send(message, context); expect(web.sent).toHaveLength(0); }); });