wacrm/src/lib/whatsapp/meta-api.test.ts
Arnas Donauskas dc245d5914
feat(flows): foundation for interactive WhatsApp messages (PR 1/4)
Foundation for the upcoming WhatsApp chatbot flow builder. No runner,
no UI yet — just the plumbing that lets the codebase SEND interactive
messages (button replies + lists) and RECEIVE the taps as structured
message rows. Plan: ~/.claude/plans/understood-research-such-functionality-binary-lake.md

## What's in this PR

### Migration 010_flows.sql
- New tables: flows, flow_nodes, flow_runs, flow_run_events
- Linchpin partial unique index on flow_runs(user_id, contact_id) WHERE status='active' — at most one active run per contact, makes concurrent webhook deliveries idempotent at the DB layer
- Widens messages.content_type CHECK to allow 'interactive'
- New messages.interactive_reply_id column for the tapped button/row id
- RLS on every table; flow_runs added to supabase_realtime publication
- Idempotent throughout — matches the 009_message_actions.sql template

### Interactive Meta senders (src/lib/whatsapp/meta-api.ts)
- sendInteractiveButtons: type='interactive', interactive.type='button', up to 3 reply buttons
- sendInteractiveList: type='interactive', interactive.type='list', up to 10 rows across sections
- Limits exported as INTERACTIVE_LIMITS so flow validation can share the constants
- All Meta caps validated BEFORE the network call (button title ≤20 chars, list row title ≤24 chars, ≤10 total rows, no duplicate row ids, body ≤1024 chars, etc.) — misconfigured flows fail at save time instead of as a customer-facing 400 mid-conversation

### Webhook (src/app/api/whatsapp/webhook/route.ts)
- Extends WhatsAppMessage interface with `interactive?: { type, button_reply?, list_reply? }`
- parseMessageContent returns a new `interactiveReplyId` field; refactored to use a shared `empty` base so every return site picks up the new field without 9 explicit copies
- New `case 'interactive'`: uses the tapped option's TITLE as content_text (so the inbox bubble reads "Existing customer", not a UUID) and stashes the id separately
- 'interactive' added to ALLOWED_CONTENT_TYPES so the messages insert no longer coerces it to 'text'

### Inbox bubble (src/components/inbox/message-bubble.tsx)
- New `case "interactive"` in MessageContent renders the tap with a small "↩ Button reply" affordance so agents reading the inbox can tell at a glance that this was a tap rather than the customer typing the same words

### Types (src/types/index.ts)
- ContentType union extended with 'interactive'
- Message.interactive_reply_id field

### Engine helpers (src/lib/flows/)
- admin-client.ts: thin service-role client wrapper, mirrors automations/admin-client.ts
- meta-send.ts: engineSendInteractiveButtons / engineSendInteractiveList — adapts the Meta senders for service-role use, persists messages with sender_type='bot' content_type='interactive', uses the same phone-variant retry as automations/meta-send.ts. Unused in PR #1; ready for the runner in PR #2

### Tests
- 13 new vitest cases in meta-api.test.ts covering Meta's limit validation + payload-shape assertions for both interactive senders

## Validation
- npm test → 102/102 (was 89; 13 new)
- npm run lint → 0 errors
- npm run typecheck → clean
- npm run build → clean

## What's NOT in this PR
- The runner (dispatchInboundToFlows) — PR #2
- Builder UI / API surface — PR #3
- collect_input / condition / set_tag nodes + templates — PR #4
- react-flow canvas + http_fetch — v2 (PR #5)

## Risks tracked from the plan
- messages.content_type CHECK widening: grep'd every consumer; message-bubble.tsx, reply-quote.tsx, send/route.ts, automations/meta-send.ts all handle the new variant correctly (or accept it as a defaulted fallthrough)
- engineSendText / engineSendInteractive share ~80% of code; deliberately not pre-factored yet — let both stabilize, extract a shared base in PR #2 once the shape settles

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:12:27 +03:00

270 lines
7.1 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
INTERACTIVE_LIMITS,
sendInteractiveButtons,
sendInteractiveList,
} from "./meta-api";
// All assertions in this file run BEFORE the network call. We stub fetch
// to a never-resolving mock so a test that accidentally falls through to
// the request body would hang (and fail) rather than silently hit
// graph.facebook.com.
const neverFetch = () =>
new Promise<Response>(() => {
/* intentionally never resolves */
});
const BASE_ARGS = {
phoneNumberId: "test-phone",
accessToken: "test-token",
to: "1234567890",
bodyText: "Body text",
} as const;
describe("sendInteractiveButtons — validation", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn(neverFetch));
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("rejects an empty buttons array", async () => {
await expect(
sendInteractiveButtons({ ...BASE_ARGS, buttons: [] }),
).rejects.toThrow(/1-3 buttons/);
});
it(`rejects more than ${INTERACTIVE_LIMITS.maxButtons} buttons (Meta cap)`, async () => {
await expect(
sendInteractiveButtons({
...BASE_ARGS,
buttons: [
{ id: "a", title: "A" },
{ id: "b", title: "B" },
{ id: "c", title: "C" },
{ id: "d", title: "D" },
],
}),
).rejects.toThrow(/1-3 buttons/);
});
it("rejects a button title longer than 20 chars (Meta cap)", async () => {
await expect(
sendInteractiveButtons({
...BASE_ARGS,
buttons: [
{ id: "a", title: "x".repeat(INTERACTIVE_LIMITS.buttonTitleMaxLength + 1) },
],
}),
).rejects.toThrow(/exceeds 20 chars/);
});
it("rejects a button missing its id", async () => {
await expect(
sendInteractiveButtons({
...BASE_ARGS,
buttons: [{ id: "", title: "Choose me" }],
}),
).rejects.toThrow(/missing id/);
});
it("rejects an empty body text", async () => {
await expect(
sendInteractiveButtons({
...BASE_ARGS,
bodyText: "",
buttons: [{ id: "a", title: "A" }],
}),
).rejects.toThrow(/requires bodyText/);
});
it("rejects a header text over the limit", async () => {
await expect(
sendInteractiveButtons({
...BASE_ARGS,
headerText: "x".repeat(INTERACTIVE_LIMITS.headerTextMaxLength + 1),
buttons: [{ id: "a", title: "A" }],
}),
).rejects.toThrow(/headerText exceeds/);
});
it("sends the right payload shape when all inputs are valid", async () => {
let captured: { url: string; body: unknown; method: string } | null = null;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init: RequestInit) => {
captured = {
url,
method: init.method ?? "GET",
body: JSON.parse(String(init.body)),
};
return new Response(
JSON.stringify({ messages: [{ id: "wamid.PASS" }] }),
{ status: 200 },
);
}),
);
const result = await sendInteractiveButtons({
...BASE_ARGS,
headerText: "Hello",
footerText: "Tap one",
buttons: [
{ id: "yes", title: "Yes" },
{ id: "no", title: "No" },
],
});
expect(result).toEqual({ messageId: "wamid.PASS" });
expect(captured).not.toBeNull();
expect(captured!.method).toBe("POST");
expect(captured!.url).toContain("test-phone/messages");
expect(captured!.body).toMatchObject({
messaging_product: "whatsapp",
recipient_type: "individual",
to: "1234567890",
type: "interactive",
interactive: {
type: "button",
body: { text: "Body text" },
header: { type: "text", text: "Hello" },
footer: { text: "Tap one" },
action: {
buttons: [
{ type: "reply", reply: { id: "yes", title: "Yes" } },
{ type: "reply", reply: { id: "no", title: "No" } },
],
},
},
});
});
});
describe("sendInteractiveList — validation", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn(neverFetch));
});
afterEach(() => {
vi.unstubAllGlobals();
});
const ROW = { id: "r1", title: "Row 1" };
it("rejects zero sections", async () => {
await expect(
sendInteractiveList({
...BASE_ARGS,
buttonLabel: "Open",
sections: [],
}),
).rejects.toThrow(/1-10 sections/);
});
it(`rejects more than ${INTERACTIVE_LIMITS.maxListRowsTotal} rows total across sections (Meta cap)`, async () => {
const rows = Array.from({ length: 11 }, (_, i) => ({
id: `r${i}`,
title: `Row ${i}`,
}));
await expect(
sendInteractiveList({
...BASE_ARGS,
buttonLabel: "Open",
sections: [{ rows }],
}),
).rejects.toThrow(/1-10 rows total/);
});
it("rejects a row title longer than 24 chars (Meta cap)", async () => {
await expect(
sendInteractiveList({
...BASE_ARGS,
buttonLabel: "Open",
sections: [
{
rows: [
{
id: "r1",
title: "x".repeat(INTERACTIVE_LIMITS.listRowTitleMaxLength + 1),
},
],
},
],
}),
).rejects.toThrow(/exceeds 24 chars/);
});
it("rejects duplicate row ids across sections", async () => {
await expect(
sendInteractiveList({
...BASE_ARGS,
buttonLabel: "Open",
sections: [
{ rows: [{ id: "dupe", title: "First" }] },
{ rows: [{ id: "dupe", title: "Second" }] },
],
}),
).rejects.toThrow(/duplicate row id/);
});
it("rejects an empty buttonLabel", async () => {
await expect(
sendInteractiveList({
...BASE_ARGS,
buttonLabel: "",
sections: [{ rows: [ROW] }],
}),
).rejects.toThrow(/requires a buttonLabel/);
});
it("sends the right payload shape when valid", async () => {
let captured: { body: unknown } | null = null;
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init: RequestInit) => {
captured = { body: JSON.parse(String(init.body)) };
return new Response(
JSON.stringify({ messages: [{ id: "wamid.LIST" }] }),
{ status: 200 },
);
}),
);
const result = await sendInteractiveList({
...BASE_ARGS,
buttonLabel: "Open menu",
sections: [
{
title: "Orders",
rows: [
{ id: "order_1", title: "Order #1", description: "€12" },
{ id: "order_2", title: "Order #2" },
],
},
],
});
expect(result).toEqual({ messageId: "wamid.LIST" });
expect(captured).not.toBeNull();
expect(captured!.body).toMatchObject({
type: "interactive",
interactive: {
type: "list",
body: { text: "Body text" },
action: {
button: "Open menu",
sections: [
{
title: "Orders",
rows: [
{ id: "order_1", title: "Order #1", description: "€12" },
{ id: "order_2", title: "Order #2" },
],
},
],
},
},
});
});
});