/** * Test de la capa que habla con Meta (F0) SIN base de datos ni credenciales reales. * * Cubre las dos direcciones de `src/lib/whatsapp.ts`: * - salida: RespuestaBot → payload de la Graph API (con `fetch` stubbeado) * - entrada: payload del webhook → mensajes/texto * - firma X-Hub-Signature-256 * * Correr con: npx tsx scripts/test-whatsapp-envio.ts */ import crypto from "crypto"; process.env.WHATSAPP_TOKEN = "token-de-prueba"; process.env.WHATSAPP_PHONE_NUMBER_ID = "111222333"; process.env.WHATSAPP_APP_SECRET = "secreto-de-prueba"; import { enviarRespuestas, firmaValida, mensajesDelPayload, textoDeMensaje, whatsappConfigurado, } from "../src/lib/whatsapp"; let fallos = 0; function ok(cond: boolean, msg: string) { console.log(` ${cond ? "✅" : "❌"} ${msg}`); if (!cond) fallos++; } // ── Stub de fetch: guarda lo que se le manda a Meta en vez de salir a internet ── type Llamada = { url: string; auth: string; body: any }; let llamadas: Llamada[] = []; globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { const headers = (init?.headers ?? {}) as Record; llamadas.push({ url: String(url), auth: headers["Authorization"] ?? "", body: JSON.parse(String(init?.body ?? "{}")), }); return { ok: true, status: 200, text: async () => "" } as Response; }) as typeof fetch; async function main() { console.log("\n── Config ──"); ok(whatsappConfigurado(), "detecta las credenciales presentes"); console.log("\n── Salida: texto ──"); llamadas = []; await enviarRespuestas("5493511234567", [{ tipo: "texto", texto: "Hola *mundo*" }]); ok(llamadas.length === 1, "manda un solo request"); ok(llamadas[0].url.includes("/111222333/messages"), "pega al phone_number_id configurado"); ok(llamadas[0].auth === "Bearer token-de-prueba", "manda el bearer token"); ok(llamadas[0].body.messaging_product === "whatsapp", "incluye messaging_product"); ok(llamadas[0].body.type === "text", "type=text"); ok(llamadas[0].body.to === "5493511234567", "va al teléfono correcto"); ok(llamadas[0].body.text.body === "Hola *mundo*", "respeta el markup de WhatsApp"); console.log("\n── Salida: botones ──"); llamadas = []; await enviarRespuestas("549351", [ { tipo: "botones", texto: "¿Con qué mecánico?", botones: [ { id: "j1", titulo: "Carlos" }, { id: "j2", titulo: "Un nombre larguísimo que no entra" }, { id: "j3", titulo: "Ana" }, { id: "j4", titulo: "Cuarto" }, ], }, ]); const inter = llamadas[0].body.interactive; ok(llamadas[0].body.type === "interactive", "type=interactive"); ok(inter.type === "button", "interactive.type=button"); ok(inter.body.text === "¿Con qué mecánico?", "el texto va en body.text"); ok(inter.action.buttons.length === 3, "recorta a los 3 botones que permite Meta"); ok(inter.action.buttons[1].reply.title.length <= 20, "trunca títulos a 20 caracteres"); ok(inter.action.buttons[0].reply.id === "j1", "conserva el id de la opción"); ok(inter.action.buttons[0].type === "reply", "los botones son de tipo reply"); console.log("\n── Salida: casos borde ──"); llamadas = []; await enviarRespuestas("549351", [{ tipo: "botones", texto: "sin opciones", botones: [] }]); ok(llamadas[0].body.type === "text", "sin botones cae a mensaje de texto"); llamadas = []; await enviarRespuestas("549351", [{ tipo: "texto", texto: "x".repeat(5000) }]); ok(llamadas[0].body.text.body.length === 4096, "trunca el texto al máximo de Meta"); llamadas = []; await enviarRespuestas("549351", [ { tipo: "texto", texto: "uno" }, { tipo: "texto", texto: "dos" }, ]); ok(llamadas.length === 2, "manda las respuestas múltiples en orden"); ok(llamadas[0].body.text.body === "uno" && llamadas[1].body.text.body === "dos", "respeta el orden"); console.log("\n── Firma del webhook ──"); const cuerpo = JSON.stringify({ hola: "chau", acento: "ñandú" }); const firma = "sha256=" + crypto.createHmac("sha256", "secreto-de-prueba").update(cuerpo, "utf8").digest("hex"); ok(firmaValida(cuerpo, firma), "acepta la firma correcta"); ok(!firmaValida(cuerpo + " ", firma), "rechaza si el cuerpo fue alterado"); ok(!firmaValida(cuerpo, "sha256=" + "0".repeat(64)), "rechaza una firma inválida"); ok(!firmaValida(cuerpo, null), "rechaza si no viene el header"); ok(!firmaValida(cuerpo, firma.slice("sha256=".length)), "rechaza sin el prefijo sha256="); console.log("\n── Entrada: payload del webhook ──"); const payload = { entry: [ { changes: [ { value: { messages: [ { from: "549351", id: "wamid.1", type: "text", text: { body: "hola" } }, { from: "549351", id: "wamid.2", type: "interactive", interactive: { button_reply: { id: "jefe-abc", title: "Carlos" } }, }, { from: "549351", id: "wamid.3", type: "audio", audio: { id: "x" } }, ], }, }, ], }, ], }; const msgs = mensajesDelPayload(payload); ok(msgs.length === 3, "aplana los 3 mensajes del payload"); ok(textoDeMensaje(msgs[0]) === "hola", "extrae el texto plano"); ok(textoDeMensaje(msgs[1]) === "jefe-abc", "de un botón extrae el id (lo que espera el bot)"); ok(textoDeMensaje(msgs[2]) === null, "un audio devuelve null → el bot no lo procesa"); ok(mensajesDelPayload({}).length === 0, "payload vacío no rompe"); ok( mensajesDelPayload({ entry: [{ changes: [{ value: {} }] }] }).length === 0, "un evento de status (sin messages) no genera mensajes" ); console.log(`\n${fallos === 0 ? "✅ TODO OK" : `❌ ${fallos} assert(s) fallaron`}\n`); process.exit(fallos === 0 ? 0 : 1); } main().catch((e) => { console.error(e); process.exit(1); });