import { describe, it, expect } from "vitest"; import { Hono } from "hono"; import { normalizeWhatsAppPayload, createWhatsAppAdapter, WHATSAPP_ERROR_REPLY, } from "../src/channels/whatsapp/webhook.js"; import type { WaToolboxPayload } from "../src/channels/whatsapp/webhook.js"; import { RATE_LIMITED_REPLY } from "../src/core/message-service.js"; import type { MessageService } from "../src/core/message-service.js"; describe("normalizeWhatsAppPayload", () => { it("keys identity on m_phone and conversation on the sender @lid", () => { const p: WaToolboxPayload = { m_phone: "60175885290", m_user: "144311035379840@lid", m_text: "testing message", }; const r = normalizeWhatsAppPayload(p); expect(r.channel).toBe("whatsapp"); expect(r.externalUserId).toBe("60175885290"); expect(r.externalConversationId).toBe("144311035379840@lid"); expect(r.text).toBe("testing message"); }); it("falls back to m_user when m_phone is absent", () => { const r = normalizeWhatsAppPayload({ m_user: "144311035379840@lid", m_text: "hi", }); expect(r.externalUserId).toBe("144311035379840@lid"); }); it("uses the group id as the conversation key in group chats", () => { const r = normalizeWhatsAppPayload({ m_phone: "60175885290", m_user: "144311035379840@lid", m_gid: "120363020166629872@g.us", m_text: "hello group", }); expect(r.externalConversationId).toBe("120363020166629872@g.us"); expect(r.externalUserId).toBe("60175885290"); }); it("reads text from m_text, then m_content, then msg", () => { expect(normalizeWhatsAppPayload({ m_text: "a" }).text).toBe("a"); expect(normalizeWhatsAppPayload({ m_content: "b" }).text).toBe("b"); expect(normalizeWhatsAppPayload({ msg: "c" }).text).toBe("c"); expect(normalizeWhatsAppPayload({}).text).toBeUndefined(); }); }); describe("createWhatsAppAdapter handler", () => { function makeAdapter( handle: MessageService["handle"], trialReply?: string, ) { const messages = { handle } as unknown as MessageService; const trial = { handleMessage: async () => trialReply } as never; const logger = { info: () => {}, warn: () => {}, error: () => {}, } as never; const adapter = createWhatsAppAdapter({ messages, logger, trial }); const app = new Hono(); app.post("/wa", (c) => adapter.handler(c)); return app; } it("routes trial-flow messages before the LLM", async () => { const app = makeAdapter( async () => ({ handled: true as const, reply: "LLM", conversationId: 1 }), "TRIAL FLOW REPLY", ); const res = await post(app, { m_phone: "60103181872", m_text: "I want try CZH03" }); expect(res.status).toBe(200); expect(await res.json()).toEqual({ msg: "TRIAL FLOW REPLY" }); }); async function post(app: Hono, body: unknown) { return app.request("/wa", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); } it("returns the reply in the msg field", async () => { const app = makeAdapter(async () => ({ handled: true as const, reply: "Hello from the bot", conversationId: 1, })); const res = await post(app, { m_phone: "60175885290", m_text: "hi" }); expect(res.status).toBe(200); expect(await res.json()).toEqual({ msg: "Hello from the bot" }); }); it("returns the rate-limited notice once, then silence", async () => { const app = makeAdapter(async () => ({ handled: false as const, reason: "rate_limited" as const, retryAfterMs: 5000, notify: true, })); const res = await post(app, { m_phone: "60175885290", m_text: "spam" }); expect(await res.json()).toEqual({ msg: RATE_LIMITED_REPLY }); const app2 = makeAdapter(async () => ({ handled: false as const, reason: "rate_limited" as const, retryAfterMs: 5000, notify: false, })); const res2 = await post(app2, { m_phone: "60175885290", m_text: "spam" }); expect(await res2.json()).toEqual({ msg: "" }); }); it("answers 200 with a friendly error when the pipeline throws", async () => { const app = makeAdapter(async () => { throw new Error("boom"); }); const res = await post(app, { m_phone: "60175885290", m_text: "hi" }); expect(res.status).toBe(200); expect(await res.json()).toEqual({ msg: WHATSAPP_ERROR_REPLY }); }); it("rejects a non-JSON body with 400 and empty msg", async () => { const app = makeAdapter(async () => ({ handled: true as const, reply: "x", conversationId: 1, })); const res = await app.request("/wa", { method: "POST", headers: { "content-type": "application/json" }, body: "not-json", }); expect(res.status).toBe(400); expect(await res.json()).toEqual({ msg: "" }); }); });