evOo-local/packages/adapters/src/push-notifications.test.ts
Claude 27f02c05af
style: use top-level type imports in the coach changes
AGENTS.md asks for top-level `import type` instead of inline `type`
specifiers; this applies it to the import statements the coach work added
or changed.

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

180 lines
5.8 KiB
TypeScript

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