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
180 lines
5.8 KiB
TypeScript
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);
|
|
});
|
|
});
|