import { randomUUID } from "node:crypto"; import type { PrismaClient } from "@rakazo/db"; import { bootstrapUserSpace, createDb, createSpaceForMember, ensureMainConversation, savePushSubscription, updateNotificationSettings, } from "@rakazo/db"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { notifyUserFromTool } from "./notify-user-tool.js"; import type { PushPayload, PushSender } from "./push-notifications.js"; import { PushNotificationProvider } from "./push-notifications.js"; const databaseUrl = process.env.DATABASE_URL; const describePostgres = process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; const signupEnv = { signupsEnabled: "true", signupAllowlist: "" }; const keys = { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) }; describePostgres("push delivery and notify_user (PostgreSQL)", () => { let prisma: PrismaClient; let close: () => Promise; const userIds: string[] = []; async function newUser() { const id = `nu${randomUUID().replaceAll("-", "")}`; userIds.push(id); await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } }); await bootstrapUserSpace(prisma, { id }, signupEnv, { claimDeploymentOwner: false }); const conversation = await ensureMainConversation(prisma, id); const main = await prisma.bot.findUniqueOrThrow({ where: { id: conversation.leadBotId }, select: { id: true, name: true, kind: true }, }); return { userId: id, conversation, main }; } function recordingSender(outcome: "sent" | "gone" = "sent") { const sent: PushPayload[] = []; const sender: PushSender = { send: vi.fn(async (_target, payload: PushPayload) => { sent.push(payload); return outcome; }), }; return { sender, sent }; } function toolContext(user: Awaited>, bot = user.main) { return { userId: user.userId, spaceId: user.conversation.spaceId, runId: `run-${randomUUID()}`, bot, signal: new AbortController().signal, }; } beforeAll(() => { const db = createDb(databaseUrl!); prisma = db.prisma; close = async () => { await db.prisma.$disconnect(); await db.pool.end(); }; }); afterAll(async () => { if (!prisma) return; const memberships = await prisma.member.findMany({ where: { userId: { in: userIds } }, select: { organizationId: true }, }); await prisma.organization.deleteMany({ where: { id: { in: memberships.map((member) => member.organizationId) } }, }); await prisma.user.deleteMany({ where: { id: { in: userIds } } }); await close(); }); it("delivers to each device, links the conversation and drops gone devices", async () => { const user = await newUser(); const browser = `https://push.example.test/${randomUUID()}`; await savePushSubscription(prisma, user.userId, { kind: "webpush", endpoint: browser, ...keys, }); await savePushSubscription(prisma, user.userId, { kind: "expo", endpoint: `ExponentPushToken[${user.userId}]`, }); const web = recordingSender("sent"); const expo = recordingSender("gone"); await new PushNotificationProvider(prisma, { webpush: web.sender, expo: expo.sender }).send( { kind: "completion", title: "Chief finished", body: "Done", botId: user.main.id, threadId: user.conversation.threadId, }, { operationId: "notify", traceId: "notify", spaceId: user.conversation.spaceId, userId: user.userId, signal: new AbortController().signal, }, ); expect(web.sent[0]?.url).toBe(`/app/g/${user.conversation.groupId}`); const devices = await prisma.pushSubscription.findMany({ where: { userId: user.userId } }); expect(devices).toHaveLength(1); expect(devices[0]).toMatchObject({ endpoint: browser, lastUsedAt: expect.any(Date) }); }); it("lets only the personal main agent nudge, within the user's settings and the daily cap", async () => { const user = await newUser(); const web = recordingSender(); const deps = { prisma, notifications: new PushNotificationProvider(prisma, { webpush: web.sender }), }; await expect( notifyUserFromTool(deps, toolContext(user, { ...user.main, kind: "specialist" }), { body: "Hi", }), ).resolves.toEqual({ error: "Only the user's main bot can notify the user." }); // A project's main bot leads its project; nudging the user is the personal agent's job. const project = await createSpaceForMember(prisma, { currentSpaceId: user.conversation.spaceId, userId: user.userId, name: "Launch", }); const lead = await ensureMainConversation(prisma, user.userId, project.id); await expect( notifyUserFromTool( deps, { ...toolContext(user, { ...user.main, id: lead.leadBotId }), spaceId: project.id }, { body: "Hi" }, ), ).resolves.toEqual({ error: "Only the user's main bot can notify the user." }); await expect(notifyUserFromTool(deps, toolContext(user), {})).resolves.toMatchObject({ error: expect.stringContaining("body is required"), }); await expect( notifyUserFromTool({ prisma }, toolContext(user), { body: "Hi" }), ).resolves.toMatchObject({ sent: false }); await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({ sent: false, reason: "The user has no device with notifications turned on.", }); await savePushSubscription(prisma, user.userId, { kind: "webpush", endpoint: `https://push.example.test/${randomUUID()}`, ...keys, }); await updateNotificationSettings(prisma, user.userId, { nudges: false }); await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({ sent: false, reason: "The user turned nudges off.", }); // Quiet hours around the current time. await updateNotificationSettings(prisma, user.userId, { nudges: true, quietHours: quietAroundNow(), }); await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({ sent: false, reason: "It is the user's quiet hours.", }); expect(web.sent).toHaveLength(0); await updateNotificationSettings(prisma, user.userId, { quietHours: null }); for (let index = 0; index < 3; index += 1) { await expect( notifyUserFromTool(deps, toolContext(user), { body: `Check-in ${index}` }), ).resolves.toEqual({ sent: true }); } await expect( notifyUserFromTool(deps, toolContext(user), { body: "One too many" }), ).resolves.toEqual({ sent: false, reason: "The daily limit of 3 nudges is reached." }); expect(web.sent).toHaveLength(3); expect(web.sent[0]).toMatchObject({ kind: "nudge", title: user.main.name, body: "Check-in 0", threadId: user.conversation.threadId, url: `/app/g/${user.conversation.groupId}`, }); }); it("reports a nudge no device accepted", async () => { const user = await newUser(); await savePushSubscription(prisma, user.userId, { kind: "webpush", endpoint: `https://push.example.test/${randomUUID()}`, ...keys, }); const failing: PushSender = { send: async () => { throw new Error("Web push failed (500)"); }, }; await expect( notifyUserFromTool( { prisma, notifications: new PushNotificationProvider(prisma, { webpush: failing }) }, toolContext(user), { body: "Hi", title: "Evening check-in" }, ), ).resolves.toEqual({ sent: false, reason: "No device accepted the notification." }); }); }); /** A UTC range that covers the current minute (users without a time zone use UTC). */ function quietAroundNow() { const now = new Date(); const minute = now.getUTCHours() * 60 + now.getUTCMinutes(); const clock = (value: number) => { const wrapped = (value + 1440) % 1440; return `${String(Math.floor(wrapped / 60)).padStart(2, "0")}:${String(wrapped % 60).padStart(2, "0")}`; }; return { start: clock(minute - 60), end: clock(minute + 60) }; }