evOo-local/packages/db/src/notification-settings.postgres.test.ts
Claude d63f7b3bf1
test(notifications): cover quiet hours, delivery, nudges and the notification RPCs
The quiet-hours CHECK accepted a row with only one bound set, because a NULL comparison made
the whole condition NULL; it now requires both bounds explicitly. The migration has not shipped
yet, so it is corrected in place.

- Unit: quiet-hours math across midnight, time zones and daylight saving; VAPID key handling
  (configured, generated once and shared, invalid); the web push sender (encrypted VAPID-signed
  request, gone on 404/410, no private or non-https endpoints); the fan-out with fake senders.
- PostgreSQL: settings and subscription repositories (constraints, per-user removal, endpoint
  hand-over, device cap, concurrent nudge cap, cascade on account deletion), delivery and
  notify_user against a real main conversation, and every notification RPC through the API
  including cross-user isolation and endpoint validation.
- .env.example documents the optional VAPID keys and VAPID_SUBJECT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:34:14 +00:00

185 lines
7.0 KiB
TypeScript

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<void>;
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);
});
});