Files
taller_automotriz/scripts/test-whatsapp-envio.ts
T
Beldor 91112862a5
Revisión con IA / ai-review (pull_request) Successful in 29s
fix: los cuatro hallazgos reales de la revision con IA
- crearTurno verificaba disponibilidad y creaba el turno en dos operaciones
  sueltas: dos clientes podian pasar la verificacion a la vez y sobrevender el
  mismo horario. Ahora va todo en una transaccion, serializada por jefe con
  pg_advisory_xact_lock. slotEstaDisponible acepta el cliente de la transaccion.
  Probado contra un Postgres real: 5 reservas simultaneas del mismo slot con
  capacidad 1 dan 1 ok + 4 conflictos y una sola fila; sacando el lock, entran
  las 5.
- La deduplicacion del webhook estaba en memoria y solo servia con una
  instancia. Pasa a la base con el modelo MensajeWhatsappProcesado: el insert
  del id es atomico por el indice unico, asi que aguanta reintentos
  concurrentes de Meta (verificado). Se limpian los ids de mas de 7 dias.
- postMensaje no reintentaba: ahora reintenta 3 veces ante 429, 5xx y fallas
  de red, con backoff exponencial y jitter. Un 4xx nuestro no se reintenta.
- /api/cliente/servicios devolvia los trabajos de un jefe dado de baja; ahora
  responde 404 si el jefe no existe o no esta activo.

test:envio suma los casos de reintento (35 asserts). El Prisma en memoria del
test del bot ahora entiende $transaction y $executeRaw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:21:35 -03:00

182 lines
7.0 KiB
TypeScript

/**
* 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[] = [];
// Códigos a devolver, en orden. Si se vacía, responde 200.
let respuestas: number[] = [];
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
const headers = (init?.headers ?? {}) as Record<string, string>;
llamadas.push({
url: String(url),
auth: headers["Authorization"] ?? "",
body: JSON.parse(String(init?.body ?? "{}")),
});
const status = respuestas.shift() ?? 200;
return { ok: status < 400, status, text: async () => "detalle" } 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── Salida: reintentos ──");
llamadas = [];
respuestas = [429];
await enviarRespuestas("549351", [{ tipo: "texto", texto: "con rate limit" }]);
ok(llamadas.length === 2, "reintenta ante un 429 y sale bien en el segundo intento");
llamadas = [];
respuestas = [500, 503];
await enviarRespuestas("549351", [{ tipo: "texto", texto: "con 5xx" }]);
ok(llamadas.length === 3, "reintenta ante 5xx hasta que uno sale bien");
llamadas = [];
respuestas = [500, 500, 500];
await enviarRespuestas("549351", [{ tipo: "texto", texto: "siempre 5xx" }]);
ok(llamadas.length === 3, "no reintenta más de 3 veces");
llamadas = [];
respuestas = [400];
await enviarRespuestas("549351", [{ tipo: "texto", texto: "payload malo" }]);
ok(llamadas.length === 1, "un 400 no se reintenta (es error nuestro)");
llamadas = [];
respuestas = [401];
await enviarRespuestas("549351", [{ tipo: "texto", texto: "token vencido" }]);
ok(llamadas.length === 1, "un 401 no se reintenta");
respuestas = [];
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);
});