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
185 lines
7.0 KiB
TypeScript
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);
|
|
});
|
|
});
|