evOo-local/packages/adapters/src/notify-user.postgres.test.ts
Claude 77e822b9a7
Merge main into the Phase 2 integration branch
Main now gives every project its own main agent. Coaching stays with the
user's personal agent, the main bot of the home space: only it gets the
goal, habit, check-in, brief and nudge tools, the coach summary and the
open-onboarding note, and the tools refuse a project's main bot too. The
onboarding questions start with the personal agent only. A project the
scan creates gets its main agent like any other, and the inbox lists asks
from another space's main conversation.

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

232 lines
8.2 KiB
TypeScript

import { randomUUID } from "node:crypto";
import type { PrismaClient } from "@rakazo/db";
import {
bootstrapUserSpace,
createDb,
createSpaceForMember,
ensureMainConversation,
savePushSubscription,
updateNotificationSettings,
} from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { notifyUserFromTool } from "./notify-user-tool.js";
import type { PushPayload, PushSender } from "./push-notifications.js";
import { PushNotificationProvider } from "./push-notifications.js";
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
const signupEnv = { signupsEnabled: "true", signupAllowlist: "" };
const keys = { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) };
describePostgres("push delivery and notify_user (PostgreSQL)", () => {
let prisma: PrismaClient;
let close: () => Promise<void>;
const userIds: string[] = [];
async function newUser() {
const id = `nu${randomUUID().replaceAll("-", "")}`;
userIds.push(id);
await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } });
await bootstrapUserSpace(prisma, { id }, signupEnv, { claimDeploymentOwner: false });
const conversation = await ensureMainConversation(prisma, id);
const main = await prisma.bot.findUniqueOrThrow({
where: { id: conversation.leadBotId },
select: { id: true, name: true, kind: true },
});
return { userId: id, conversation, main };
}
function recordingSender(outcome: "sent" | "gone" = "sent") {
const sent: PushPayload[] = [];
const sender: PushSender = {
send: vi.fn(async (_target, payload: PushPayload) => {
sent.push(payload);
return outcome;
}),
};
return { sender, sent };
}
function toolContext(user: Awaited<ReturnType<typeof newUser>>, bot = user.main) {
return {
userId: user.userId,
spaceId: user.conversation.spaceId,
runId: `run-${randomUUID()}`,
bot,
signal: new AbortController().signal,
};
}
beforeAll(() => {
const db = createDb(databaseUrl!);
prisma = db.prisma;
close = async () => {
await db.prisma.$disconnect();
await db.pool.end();
};
});
afterAll(async () => {
if (!prisma) return;
const memberships = await prisma.member.findMany({
where: { userId: { in: userIds } },
select: { organizationId: true },
});
await prisma.organization.deleteMany({
where: { id: { in: memberships.map((member) => member.organizationId) } },
});
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await close();
});
it("delivers to each device, links the conversation and drops gone devices", async () => {
const user = await newUser();
const browser = `https://push.example.test/${randomUUID()}`;
await savePushSubscription(prisma, user.userId, {
kind: "webpush",
endpoint: browser,
...keys,
});
await savePushSubscription(prisma, user.userId, {
kind: "expo",
endpoint: `ExponentPushToken[${user.userId}]`,
});
const web = recordingSender("sent");
const expo = recordingSender("gone");
await new PushNotificationProvider(prisma, { webpush: web.sender, expo: expo.sender }).send(
{
kind: "completion",
title: "Chief finished",
body: "Done",
botId: user.main.id,
threadId: user.conversation.threadId,
},
{
operationId: "notify",
traceId: "notify",
spaceId: user.conversation.spaceId,
userId: user.userId,
signal: new AbortController().signal,
},
);
expect(web.sent[0]?.url).toBe(`/app/g/${user.conversation.groupId}`);
const devices = await prisma.pushSubscription.findMany({ where: { userId: user.userId } });
expect(devices).toHaveLength(1);
expect(devices[0]).toMatchObject({ endpoint: browser, lastUsedAt: expect.any(Date) });
});
it("lets only the personal main agent nudge, within the user's settings and the daily cap", async () => {
const user = await newUser();
const web = recordingSender();
const deps = {
prisma,
notifications: new PushNotificationProvider(prisma, { webpush: web.sender }),
};
await expect(
notifyUserFromTool(deps, toolContext(user, { ...user.main, kind: "specialist" }), {
body: "Hi",
}),
).resolves.toEqual({ error: "Only the user's main bot can notify the user." });
// A project's main bot leads its project; nudging the user is the personal agent's job.
const project = await createSpaceForMember(prisma, {
currentSpaceId: user.conversation.spaceId,
userId: user.userId,
name: "Launch",
});
const lead = await ensureMainConversation(prisma, user.userId, project.id);
await expect(
notifyUserFromTool(
deps,
{ ...toolContext(user, { ...user.main, id: lead.leadBotId }), spaceId: project.id },
{ body: "Hi" },
),
).resolves.toEqual({ error: "Only the user's main bot can notify the user." });
await expect(notifyUserFromTool(deps, toolContext(user), {})).resolves.toMatchObject({
error: expect.stringContaining("body is required"),
});
await expect(
notifyUserFromTool({ prisma }, toolContext(user), { body: "Hi" }),
).resolves.toMatchObject({ sent: false });
await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({
sent: false,
reason: "The user has no device with notifications turned on.",
});
await savePushSubscription(prisma, user.userId, {
kind: "webpush",
endpoint: `https://push.example.test/${randomUUID()}`,
...keys,
});
await updateNotificationSettings(prisma, user.userId, { nudges: false });
await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({
sent: false,
reason: "The user turned nudges off.",
});
// Quiet hours around the current time.
await updateNotificationSettings(prisma, user.userId, {
nudges: true,
quietHours: quietAroundNow(),
});
await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({
sent: false,
reason: "It is the user's quiet hours.",
});
expect(web.sent).toHaveLength(0);
await updateNotificationSettings(prisma, user.userId, { quietHours: null });
for (let index = 0; index < 3; index += 1) {
await expect(
notifyUserFromTool(deps, toolContext(user), { body: `Check-in ${index}` }),
).resolves.toEqual({ sent: true });
}
await expect(
notifyUserFromTool(deps, toolContext(user), { body: "One too many" }),
).resolves.toEqual({ sent: false, reason: "The daily limit of 3 nudges is reached." });
expect(web.sent).toHaveLength(3);
expect(web.sent[0]).toMatchObject({
kind: "nudge",
title: user.main.name,
body: "Check-in 0",
threadId: user.conversation.threadId,
url: `/app/g/${user.conversation.groupId}`,
});
});
it("reports a nudge no device accepted", async () => {
const user = await newUser();
await savePushSubscription(prisma, user.userId, {
kind: "webpush",
endpoint: `https://push.example.test/${randomUUID()}`,
...keys,
});
const failing: PushSender = {
send: async () => {
throw new Error("Web push failed (500)");
},
};
await expect(
notifyUserFromTool(
{ prisma, notifications: new PushNotificationProvider(prisma, { webpush: failing }) },
toolContext(user),
{ body: "Hi", title: "Evening check-in" },
),
).resolves.toEqual({ sent: false, reason: "No device accepted the notification." });
});
});
/** A UTC range that covers the current minute (users without a time zone use UTC). */
function quietAroundNow() {
const now = new Date();
const minute = now.getUTCHours() * 60 + now.getUTCMinutes();
const clock = (value: number) => {
const wrapped = (value + 1440) % 1440;
return `${String(Math.floor(wrapped / 60)).padStart(2, "0")}:${String(wrapped % 60).padStart(2, "0")}`;
};
return { start: clock(minute - 60), end: clock(minute + 60) };
}