evOo-local/packages/testkit/src/messaging.test.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

531 lines
19 KiB
TypeScript

import { randomInt } from "node:crypto";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ChatSdkMessagingSurface,
createEmulatedSendbluePlatform,
SendBlueEmulator,
} from "@rakazo/adapters";
import { formatMessagingLinkCode, issueMessagingLinkCode } from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
process.env.WAKEUP_DRIVER = "memory";
process.env.SANDBOX_PROVIDER = "fake";
process.env.AGENT_RUNTIME = "scripted";
const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL);
const describeMessaging = hasDb ? describe.sequential : describe.skip;
type App = { request: (input: string | Request, init?: RequestInit) => Promise<Response> };
// Offline journeys: injected SendBlueEmulator fetch, no live vendor or paid line.
describeMessaging("messaging surface journeys", () => {
let app: App;
let stop: () => Promise<void>;
let prisma: any;
const emulator = new SendBlueEmulator();
const platform = createEmulatedSendbluePlatform(emulator);
const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-messaging-"));
// Unique per run: identities, threads, and outbox rows persist in the dev
// database. Random E.164 fixtures — a timestamp suffix repeats within
// hours and collides with earlier runs.
const stamp = Date.now();
const uniqueNumber = () => `+1555${String(randomInt(10_000_000)).padStart(7, "0")}`;
const sender = uniqueNumber();
const dmHandle = `journey-dm-${stamp}`;
// Channels key on the opaque Chat SDK thread id, not the raw vendor group
// id; derive expectations exactly as the production adapter encodes them.
const groupThreadId = (groupId: string) =>
platform.adapter.encodeThreadId({ fromNumber: emulator.phoneNumber, groupId });
const dmThreadId = (address: string) => platform.directThreadId!(address);
const findIdentity = (address: string) =>
prisma.messagingIdentity.findUnique({
where: { provider_address: { provider: "sendblue", address } },
});
beforeAll(async () => {
const { createApp } = await import("../../../apps/api/src/app.ts");
const handles = await createApp({
databaseUrl: process.env.DATABASE_URL!,
dataDir,
sandboxProvider: "fake",
agentRuntime: "scripted",
// These journeys exercise the Poke-style open line; the linking journey
// below covers the default (linking-only) posture explicitly.
messagingOpenSignup: true,
messaging: new ChatSdkMessagingSurface([platform]),
});
app = handles.app;
stop = handles.stop;
prisma = handles.prisma;
});
afterAll(async () => {
await stop?.();
});
it("provisions on first text, runs the bot, and mirrors the reply back out", async () => {
const res = await app.request(
emulator.buildInboundRequest({
fromNumber: sender,
content: "hello there",
handle: dmHandle,
}),
);
expect(res.status).toBe(200);
// The Chat SDK adapter dispatches inbound processing without awaiting it,
// so all effects behind the 200 are eventually consistent.
const identity = await waitForDatabase(async () => findIdentity(sender));
// A non-null provider handle is the last durable signal of the whole
// mirror loop (the claim flips status before the provider call lands).
await waitForDatabase(async () =>
Boolean(
await prisma.messagingOutbound.findFirst({
where: {
identityId: identity.id,
kind: "dm",
status: "sent",
providerHandle: { not: null },
},
}),
),
);
expect(emulator.sent.some((send) => send.kind === "dm" && send.to === sender)).toBe(true);
// The counter increments one statement after providerHandle lands, so
// poll rather than racing the drain's last write.
const refreshed = await waitForDatabase(async () => {
const row = await findIdentity(sender);
return row?.outboundSinceInbound === 1 ? row : null;
});
// The 1:1 conversation id is learned from the inbound webhook; outbound
// DMs resolve through it instead of a provider lookup.
expect(refreshed.dmThreadId).toBe(dmThreadId(sender));
const outbound = await prisma.messagingOutbound.findMany({
where: { identityId: identity.id, kind: "dm" },
});
expect(outbound).toHaveLength(1);
expect(outbound[0].providerHandle).toBeTruthy();
const userMessage = await prisma.message.findFirst({
where: {
threadId: (await prisma.thread.findFirst({ where: { botId: identity.botId } })).id,
role: "user",
},
});
expect(userMessage.clientNonce).toBe(`messaging:sendblue:${dmHandle}`);
});
it("replays the same handle without a duplicate message or send", async () => {
const replay = await app.request(
emulator.buildInboundRequest({
fromNumber: sender,
content: "hello there",
handle: dmHandle,
}),
);
expect(replay.status).toBe(200);
// Give any erroneous duplicate work a chance to appear.
await new Promise((resolve) => setTimeout(resolve, 500));
const identity = await findIdentity(sender);
const thread = await prisma.thread.findFirst({ where: { botId: identity.botId } });
const userMessages = await prisma.message.findMany({
where: { threadId: thread.id, role: "user" },
});
expect(userMessages).toHaveLength(1);
const outbound = await prisma.messagingOutbound.findMany({
where: { identityId: identity.id, kind: "dm" },
});
expect(outbound).toHaveLength(1);
expect(emulator.sent.filter((send) => send.kind === "dm" && send.to === sender)).toHaveLength(
1,
);
});
it("runs the channel loop: discovery, invite, intro, YES, fan-out, attributed post", async () => {
const stranger = uniqueNumber();
const groupId = `grp-${stamp}`;
const threadId = groupThreadId(groupId);
// 1. Discovery: first group message creates the channel, invites the
// linked sender, and posts one intro for the unlinked stranger.
const discovery = await app.request(
emulator.buildInboundRequest({
fromNumber: sender,
content: "hey everyone",
groupId,
participants: [sender, stranger, emulator.phoneNumber],
handle: `grp-discover-${stamp}`,
}),
);
expect(discovery.status).toBe(200);
await waitForDatabase(async () =>
Boolean(
await prisma.messagingOutbound.findFirst({
where: { threadId, kind: "intro", status: "sent", providerHandle: { not: null } },
}),
),
);
const channel = await prisma.messagingChannel.findUnique({ where: { threadId } });
expect(channel).toBeTruthy();
expect(channel.provider).toBe("sendblue");
const members = await prisma.messagingChannelMember.findMany({
where: { channelId: channel.id },
orderBy: { address: "asc" },
});
expect(members).toHaveLength(2);
const senderMember = members.find((m: any) => m.address === sender);
expect(senderMember.status).toBe("invited");
expect(senderMember.identityId).toBeTruthy();
const strangerMember = members.find((m: any) => m.address === stranger);
expect(strangerMember.identityId).toBeNull();
// invite DM went out to the sender, intro went to the group, both once
expect(
emulator.sent.filter((send) => send.kind === "group" && send.groupId === groupId),
).toHaveLength(1);
// the invited-only sender's message was not fanned out to any bot
const identity = await findIdentity(sender);
const runsBefore = await prisma.run.count({ where: { botId: identity.botId } });
// 2. The owner approves by text command.
const yes = await app.request(
emulator.buildInboundRequest({
fromNumber: sender,
content: "YES",
handle: `grp-yes-${stamp}`,
}),
);
expect(yes.status).toBe(200);
await waitForDatabase(async () => {
const member = await prisma.messagingChannelMember.findUnique({
where: { channelId_address: { channelId: channel.id, address: sender } },
});
return member?.status === "approved";
});
// 3. The next group message fans out to the approved bot, whose reply
// is posted back to the group with attribution.
const second = await app.request(
emulator.buildInboundRequest({
fromNumber: sender,
content: "what do you think?",
groupId,
participants: [sender, stranger, emulator.phoneNumber],
handle: `grp-second-${stamp}`,
}),
);
expect(second.status).toBe(200);
await waitForDatabase(async () =>
Boolean(
await prisma.messagingOutbound.findFirst({
where: { threadId, kind: "group", status: "sent", providerHandle: { not: null } },
}),
),
);
const posts = await prisma.messagingOutbound.findMany({
where: { threadId, kind: "group", status: "sent" },
});
expect(posts).toHaveLength(1);
// Messaging-provisioned users default to "<Provider> <last4>".
expect(posts[0].body).toMatch(/^Sendblue's agent: /);
expect(await prisma.run.count({ where: { botId: identity.botId } })).toBeGreaterThan(
runsBefore,
);
});
it("rejects a bad signing secret", async () => {
const intruder = uniqueNumber();
const request = emulator.buildInboundRequest({
fromNumber: intruder,
content: "intruder",
});
const res = await app.request(request.url, {
method: "POST",
headers: { "content-type": "application/json", "sb-signing-secret": "wrong" },
body: await request.text(),
});
expect(res.status).toBe(401);
expect(await findIdentity(intruder)).toBeNull();
});
it("declines a channel invite on NO without waking the bot", async () => {
const owner = uniqueNumber();
const stranger = uniqueNumber();
const groupId = `grp-no-${stamp}`;
const provision = await app.request(
emulator.buildInboundRequest({
fromNumber: owner,
content: "link me",
handle: `no-dm-${stamp}`,
}),
);
expect(provision.status).toBe(200);
const identity = await waitForDatabase(async () => findIdentity(owner));
const runsBefore = await prisma.run.count({ where: { botId: identity.botId } });
const discovery = await app.request(
emulator.buildInboundRequest({
fromNumber: owner,
content: "group hello",
groupId,
participants: [owner, stranger, emulator.phoneNumber],
handle: `no-discover-${stamp}`,
}),
);
expect(discovery.status).toBe(200);
const channel = await waitForDatabase(async () =>
prisma.messagingChannel.findUnique({ where: { threadId: groupThreadId(groupId) } }),
);
const no = await app.request(
emulator.buildInboundRequest({
fromNumber: owner,
content: "NO",
handle: `no-cmd-${stamp}`,
}),
);
expect(no.status).toBe(200);
await waitForDatabase(async () => {
const member = await prisma.messagingChannelMember.findUnique({
where: { channelId_address: { channelId: channel.id, address: owner } },
});
return member?.status === "declined";
});
const ignored = await app.request(
emulator.buildInboundRequest({
fromNumber: owner,
content: "should not fan out",
groupId,
participants: [owner, stranger, emulator.phoneNumber],
handle: `no-ignored-${stamp}`,
}),
);
expect(ignored.status).toBe(200);
await new Promise((resolve) => setTimeout(resolve, 400));
expect(await prisma.run.count({ where: { botId: identity.botId } })).toBe(runsBefore);
});
it("leaves an approved channel on LEAVE and can rejoin via a later invite", async () => {
const owner = uniqueNumber();
const stranger = uniqueNumber();
const groupId = `grp-leave-${stamp}`;
const provision = await app.request(
emulator.buildInboundRequest({
fromNumber: owner,
content: "link me again",
handle: `leave-dm-${stamp}`,
}),
);
expect(provision.status).toBe(200);
await waitForDatabase(async () => findIdentity(owner));
const discovery = await app.request(
emulator.buildInboundRequest({
fromNumber: owner,
content: "group hello",
groupId,
participants: [owner, stranger, emulator.phoneNumber],
handle: `leave-discover-${stamp}`,
}),
);
expect(discovery.status).toBe(200);
const channel = await waitForDatabase(async () =>
prisma.messagingChannel.findUnique({ where: { threadId: groupThreadId(groupId) } }),
);
const yes = await app.request(
emulator.buildInboundRequest({
fromNumber: owner,
content: "YES",
handle: `leave-yes-${stamp}`,
}),
);
expect(yes.status).toBe(200);
await waitForDatabase(async () => {
const member = await prisma.messagingChannelMember.findUnique({
where: { channelId_address: { channelId: channel.id, address: owner } },
});
return member?.status === "approved";
});
const leave = await app.request(
emulator.buildInboundRequest({
fromNumber: owner,
content: "LEAVE",
handle: `leave-cmd-${stamp}`,
}),
);
expect(leave.status).toBe(200);
await waitForDatabase(async () => {
const member = await prisma.messagingChannelMember.findUnique({
where: { channelId_address: { channelId: channel.id, address: owner } },
});
return member?.status === "left";
});
const rejoin = await app.request(
emulator.buildInboundRequest({
fromNumber: owner,
content: "back again",
groupId,
participants: [owner, stranger, emulator.phoneNumber],
handle: `leave-rejoin-${stamp}`,
}),
);
expect(rejoin.status).toBe(200);
await waitForDatabase(async () => {
const member = await prisma.messagingChannelMember.findUnique({
where: { channelId_address: { channelId: channel.id, address: owner } },
});
return member?.status === "invited";
});
});
it("marks a mirrored reply failed when the outbound status webhook reports ERROR", async () => {
const texter = uniqueNumber();
const handle = `fail-status-dm-${stamp}`;
const res = await app.request(
emulator.buildInboundRequest({
fromNumber: texter,
content: "please reply",
handle,
}),
);
expect(res.status).toBe(200);
const identity = await waitForDatabase(async () => findIdentity(texter));
const outbound = await waitForDatabase(async () =>
prisma.messagingOutbound.findFirst({
where: {
identityId: identity.id,
kind: "dm",
status: "sent",
providerHandle: { not: null },
},
}),
);
expect(outbound.providerHandle).toBeTruthy();
const status = await app.request(
emulator.buildStatusRequest({ handle: outbound.providerHandle, status: "ERROR" }),
);
expect(status.status).toBe(200);
await waitForDatabase(async () => {
const row = await prisma.messagingOutbound.findUnique({ where: { id: outbound.id } });
return row?.status === "failed";
});
});
it("still accepts sendblue inbound on the legacy phone webhook path", async () => {
const texter = uniqueNumber();
const source = emulator.buildInboundRequest({
fromNumber: texter,
content: "legacy path",
handle: `legacy-dm-${stamp}`,
});
const res = await app.request("https://rakazo.test/api/v1/phone/webhook", {
method: "POST",
headers: {
"content-type": "application/json",
"sb-signing-secret": emulator.signingSecret,
},
body: await source.text(),
});
expect(res.status).toBe(200);
// Provisioning proves the legacy route reached the sendblue platform.
await waitForDatabase(async () => findIdentity(texter));
});
it("ignores a nested vendor envelope that is not a flat inbound message", async () => {
const stranger = uniqueNumber();
// A mistaken parser that unwraps `data` would treat this as a message and
// provision. The SendBlue inbound shape is flat; nested envelopes are ignored.
const res = await app.request("https://rakazo.test/api/v1/phone/webhook", {
method: "POST",
headers: {
"content-type": "application/json",
"sb-signing-secret": emulator.signingSecret,
},
body: JSON.stringify({
data: {
content: "should not provision",
is_outbound: false,
status: "RECEIVED",
service: "iMessage",
message_handle: `envelope-trap-${stamp}`,
from_number: stranger,
to_number: emulator.phoneNumber,
sendblue_number: emulator.phoneNumber,
participants: [stranger, emulator.phoneNumber],
},
}),
});
expect(res.status).toBe(200);
await new Promise((resolve) => setTimeout(resolve, 300));
expect(await findIdentity(stranger)).toBeNull();
});
// Runs last: it unlinks the first journey's identity to free its bot.
it("links a new address to an existing account via a web-issued code", async () => {
const owner = await findIdentity(sender);
expect(owner).toBeTruthy();
// Unlink (as the web UI would) so the bot is free to link again.
await prisma.messagingIdentity.delete({ where: { id: owner.id } });
const issued = await issueMessagingLinkCode(prisma, {
userId: owner.userId,
spaceId: owner.spaceId,
botId: owner.botId,
});
const newAddress = uniqueNumber();
const response = await app.request(
emulator.buildInboundRequest({
fromNumber: newAddress,
content: formatMessagingLinkCode(issued.code),
}),
);
expect(response.status).toBe(200);
const linked = await waitForDatabase(() => findIdentity(newAddress));
expect(linked.userId).toBe(owner.userId);
expect(linked.botId).toBe(owner.botId);
// The confirmation DM drains through the vendor to the new address.
await waitForDatabase(async () =>
emulator.sent.find(
(message) => message.to === newAddress && message.body.startsWith("Linked"),
),
);
// The code is single-use: a replay never binds another address to the
// issuer's account. (This suite runs the open line, so the replayer
// auto-provisions a fresh account of their own instead.)
const replayAddress = uniqueNumber();
const replay = await app.request(
emulator.buildInboundRequest({
fromNumber: replayAddress,
content: formatMessagingLinkCode(issued.code),
}),
);
expect(replay.status).toBe(200);
const replayIdentity = await waitForDatabase(() => findIdentity(replayAddress));
expect(replayIdentity.userId).not.toBe(owner.userId);
expect(replayIdentity.botId).not.toBe(owner.botId);
});
});
async function waitForDatabase<T>(pred: () => Promise<T | false | null | undefined>): Promise<T> {
const start = Date.now();
while (Date.now() - start < 15_000) {
const value = await pred();
if (value) return value as T;
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error("timeout waiting for database state");
}