import { randomUUID } from "node:crypto"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { PrismaClient } from "./client.js"; import { createDb } from "./client.js"; import { claimDailyNudge, getNotificationSettings, loadNotificationPolicy, updateNotificationSettings, } from "./notification-settings.js"; import { listPushSubscriptions, PUSH_SUBSCRIPTION_LIMIT, removePushSubscription, removePushSubscriptionsOfKind, savePushSubscription, } from "./push-subscriptions.js"; const databaseUrl = process.env.DATABASE_URL; const describePostgres = process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; const keys = { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) }; describePostgres("notification settings and push subscriptions (PostgreSQL)", () => { let prisma: PrismaClient; let close: () => Promise; const userIds: string[] = []; async function newUser() { const id = `ns${randomUUID().replaceAll("-", "")}`; userIds.push(id); await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } }); return id; } beforeAll(() => { const db = createDb(databaseUrl!); prisma = db.prisma; close = async () => { await db.prisma.$disconnect(); await db.pool.end(); }; }); afterAll(async () => { if (!prisma) return; await prisma.user.deleteMany({ where: { id: { in: userIds } } }); await close(); }); it("starts with no quiet hours and nudges on, and applies partial updates", async () => { const userId = await newUser(); await expect(getNotificationSettings(prisma, userId)).resolves.toEqual({ quietHours: null, nudges: true, }); await expect( updateNotificationSettings(prisma, userId, { quietHours: { start: "22:30", end: "07:00" } }), ).resolves.toEqual({ quietHours: { start: "22:30", end: "07:00" }, nudges: true }); const row = await prisma.userNotificationSettings.findUniqueOrThrow({ where: { userId } }); expect([row.quietHoursStart, row.quietHoursEnd]).toEqual([22 * 60 + 30, 7 * 60]); await expect(updateNotificationSettings(prisma, userId, { nudges: false })).resolves.toEqual({ quietHours: { start: "22:30", end: "07:00" }, nudges: false, }); await expect(updateNotificationSettings(prisma, userId, { quietHours: null })).resolves.toEqual( { quietHours: null, nudges: false }, ); }); it("rejects an empty quiet-hours range in the repository and in the database", async () => { const userId = await newUser(); await expect( updateNotificationSettings(prisma, userId, { quietHours: { start: "08:00", end: "08:00" } }), ).rejects.toThrow(); await expect( prisma.userNotificationSettings.create({ data: { userId, quietHoursStart: 60, quietHoursEnd: null }, }), ).rejects.toThrow(); await expect( prisma.userNotificationSettings.create({ data: { userId, quietHoursStart: 1440, quietHoursEnd: 60 }, }), ).rejects.toThrow(); }); it("judges quiet hours in the profile time zone", async () => { const userId = await newUser(); await updateNotificationSettings(prisma, userId, { quietHours: { start: "22:00", end: "07:00" }, }); const now = new Date("2026-07-01T21:30:00Z"); await expect(loadNotificationPolicy(prisma, userId, now)).resolves.toMatchObject({ quiet: false, localDate: "2026-07-01", }); await prisma.userProfile.create({ data: { userId, timeZone: "Europe/Vienna" } }); await expect(loadNotificationPolicy(prisma, userId, now)).resolves.toEqual({ quiet: true, nudges: true, localDate: "2026-07-01", }); }); it("caps nudges per local day, also under concurrency", async () => { const userId = await newUser(); const claims = await Promise.all( Array.from({ length: 6 }, () => claimDailyNudge(prisma, userId, "2026-07-01")), ); expect(claims.filter(Boolean)).toHaveLength(3); await expect(claimDailyNudge(prisma, userId, "2026-07-01")).resolves.toBe(false); await expect(claimDailyNudge(prisma, userId, "2026-07-02")).resolves.toBe(true); const row = await prisma.userNotificationSettings.findUniqueOrThrow({ where: { userId } }); expect(row.nudgeCount).toBe(1); // Claiming never changes the user's settings. await expect(getNotificationSettings(prisma, userId)).resolves.toEqual({ quietHours: null, nudges: true, }); }); it("keeps devices per user, moves a re-registered endpoint and caps the list", async () => { const owner = await newUser(); const other = await newUser(); const endpoint = `https://push.example.test/${randomUUID()}`; await savePushSubscription(prisma, owner, { kind: "webpush", endpoint, ...keys }); await savePushSubscription(prisma, owner, { kind: "expo", endpoint: "ExponentPushToken[a]" }); expect((await listPushSubscriptions(prisma, owner)).map((row) => row.kind)).toEqual([ "webpush", "expo", ]); // Another user cannot remove the owner's device by naming its endpoint. await removePushSubscription(prisma, other, endpoint); expect(await listPushSubscriptions(prisma, owner)).toHaveLength(2); // The same browser signed into another account now delivers to that account. await savePushSubscription(prisma, other, { kind: "webpush", endpoint, ...keys }); expect((await listPushSubscriptions(prisma, owner)).map((row) => row.kind)).toEqual(["expo"]); expect((await listPushSubscriptions(prisma, other)).map((row) => row.endpoint)).toEqual([ endpoint, ]); await removePushSubscriptionsOfKind(prisma, owner, "expo"); expect(await listPushSubscriptions(prisma, owner)).toEqual([]); for (let index = 0; index <= PUSH_SUBSCRIPTION_LIMIT; index += 1) { await savePushSubscription(prisma, owner, { kind: "expo", endpoint: `ExponentPushToken[${owner}-${index}]`, }); } const kept = await listPushSubscriptions(prisma, owner); expect(kept).toHaveLength(PUSH_SUBSCRIPTION_LIMIT); expect(kept.map((row) => row.endpoint)).not.toContain(`ExponentPushToken[${owner}-0]`); }); it("requires keys for web push and removes everything with the account", async () => { const userId = await newUser(); await expect( prisma.pushSubscription.create({ data: { userId, kind: "webpush", endpoint: `https://push.example.test/${userId}` }, }), ).rejects.toThrow(); await expect( prisma.pushSubscription.create({ data: { userId, kind: "sms", endpoint: `sms-${userId}` }, }), ).rejects.toThrow(); await savePushSubscription(prisma, userId, { kind: "expo", endpoint: `ExponentPushToken[${userId}]`, }); await updateNotificationSettings(prisma, userId, { nudges: false }); await prisma.user.delete({ where: { id: userId } }); expect(await prisma.pushSubscription.count({ where: { userId } })).toBe(0); expect(await prisma.userNotificationSettings.count({ where: { userId } })).toBe(0); }); });