/** * Test de la máquina de estados del bot de WhatsApp SIN base de datos ni Meta. * * Inyecta un Prisma en memoria en `globalThis.prisma` ANTES de importar el bot, * así el singleton de `src/lib/prisma.ts` lo toma y nunca abre una conexión real. * De paso ejercita el scheduling y crearTurno reales (usan el mismo Prisma). * * Correr con: npx tsx scripts/test-whatsapp-bot.ts */ /* eslint-disable @typescript-eslint/no-explicit-any */ type Row = Record; let _id = 0; const nid = (p: string) => `${p}${++_id}`; function cmp(x: any) { return x instanceof Date ? x.getTime() : x; } function match(row: Row, where: Row): boolean { for (const [k, v] of Object.entries(where)) { if (v === undefined) continue; if (k === "AND") { if (!(v as Row[]).every((w) => match(row, w))) return false; continue; } if (k === "OR") { if (!(v as Row[]).some((w) => match(row, w))) return false; continue; } const rv = row[k]; if (v !== null && typeof v === "object" && !(v instanceof Date)) { if ("in" in v && !(v.in as any[]).includes(rv)) return false; if ("lt" in v && !(cmp(rv) < cmp(v.lt))) return false; if ("lte" in v && !(cmp(rv) <= cmp(v.lte))) return false; if ("gt" in v && !(cmp(rv) > cmp(v.gt))) return false; if ("gte" in v && !(cmp(rv) >= cmp(v.gte))) return false; } else if (rv !== v) { return false; } } return true; } function pick(row: Row, select?: Row) { if (!select) return { ...row }; const o: Row = {}; for (const k of Object.keys(select)) if (select[k]) o[k] = row[k]; return o; } // ── Stores en memoria ── const clientes: Row[] = []; const jefes: Row[] = []; const servicios: Row[] = []; const turnos: Row[] = []; const convs: Row[] = []; function modelo(rows: Row[], defaults: () => Row = () => ({})) { return { async findFirst({ where }: any = {}) { const r = rows.find((x) => match(x, where || {})); return r ? { ...r } : null; }, async findUnique({ where, select }: any = {}) { const r = rows.find((x) => match(x, where || {})); return r ? pick(r, select) : null; }, async findMany({ where, orderBy, select }: any = {}) { let rs = rows.filter((x) => match(x, where || {})); if (orderBy) { const [f, dir] = Object.entries(orderBy)[0] as [string, string]; rs = [...rs].sort((a, b) => (a[f] > b[f] ? 1 : a[f] < b[f] ? -1 : 0) * (dir === "desc" ? -1 : 1)); } return rs.map((r) => pick(r, select)); }, async count({ where }: any = {}) { return rows.filter((x) => match(x, where || {})).length; }, async create({ data }: any) { const row = { ...defaults(), ...data }; rows.push(row); return { ...row }; }, async update({ where, data }: any) { const r = rows.find((x) => match(x, where || {})); if (!r) throw new Error("update: fila no encontrada"); Object.assign(r, data); r.updatedAt = new Date(); return { ...r }; }, async upsert({ where, create, update }: any) { const r = rows.find((x) => match(x, where || {})); if (r) { Object.assign(r, update); return { ...r }; } const nuevo = { ...defaults(), ...where, ...create }; rows.push(nuevo); return { ...nuevo }; }, }; } const fakePrisma: any = { cliente: modelo(clientes, () => ({ id: nid("c"), telefonoNorm: null, email: null, createdAt: new Date() })), jefeMecanico: modelo(jefes), tipoServicio: modelo(servicios), conversacionWhatsapp: modelo(convs, () => ({ id: nid("conv"), paso: "INICIO", datos: {}, clienteId: null, createdAt: new Date(), updatedAt: new Date(), })), turno: { async count({ where }: any = {}) { return turnos.filter((x) => match(x, where || {})).length; }, async create({ data, include }: any) { const row: Row = { id: nid("t"), estado: "PENDIENTE", autoId: null, kilometros: null, comentario: null, createdAt: new Date(), ...data, }; turnos.push(row); const out: Row = { ...row }; if (include?.auto) out.auto = row.autoId ? { patente: "?", marca: "?", modelo: "?", anio: 0 } : null; if (include?.tipoServicio) out.tipoServicio = servicios.find((s) => s.id === row.tipoServicioId) ?? null; if (include?.jefeMecanico) { const j = jefes.find((x) => x.id === row.jefeMecanicoId); out.jefeMecanico = j ? { id: j.id, nombre: j.nombre } : null; } return out; }, }, }; (globalThis as any).prisma = fakePrisma; // ── Seed ── jefes.push({ id: "j1", nombre: "Carlos", email: "j1@t.com", activo: true, slotsParalelos: 1 }); jefes.push({ id: "j2", nombre: "Diego", email: "j2@t.com", activo: true, slotsParalelos: 1 }); servicios.push({ id: "s1", nombre: "Cambio de aceite", duracionHoras: 1, activo: true, jefeMecanicoId: "j1" }); servicios.push({ id: "s2", nombre: "Alineacion", duracionHoras: 2, activo: true, jefeMecanicoId: "j1" }); // Trabajo global del taller (lo carga el admin): lo ofrece cualquier jefe. servicios.push({ id: "s3", nombre: "Revision general", duracionHoras: 1, activo: true, jefeMecanicoId: null }); clientes.push({ id: "c1", nombre: "Juan", apellido: "Perez", email: "juan@mail.com", telefono: "5493510000001", telefonoNorm: "5493510000001", createdAt: new Date(), }); // ── Runner de asserts ── let fallos = 0; function ok(cond: boolean, msg: string) { console.log(`${cond ? " ✅" : " ❌"} ${msg}`); if (!cond) fallos++; } const flat = (resp: any[]) => resp .map((x) => (x.tipo === "botones" ? `${x.texto} [${x.botones.map((b: any) => b.titulo).join(",")}]` : x.texto)) .join(" | "); const hayBotones = (resp: any[]) => resp.some((x) => x.tipo === "botones"); const paso = (tel: string) => convs.find((c) => c.telefono === tel)?.paso; async function main() { const { procesarMensaje } = await import("../src/lib/whatsapp-bot"); const env = (tel: string, txt: string) => procesarMensaje(tel, txt); console.log("\n── Escenario A: cliente NUEVO, alta + reserva completa ──"); const A = "5493519999999"; let r = await env(A, "hola"); ok(/asistente/i.test(flat(r)) && /nombre/i.test(flat(r)), "bienvenida + pide nombre"); ok(paso(A) === "ALTA_NOMBRE", "paso=ALTA_NOMBRE"); r = await env(A, "Pedro"); ok(/apellido/i.test(flat(r)), "pide apellido"); r = await env(A, "Gomez"); ok(/email/i.test(flat(r)), "pide email"); r = await env(A, "pedro@mail.com"); ok(/registrado/i.test(flat(r)) && hayBotones(r), "registrado + botones de mecánico"); ok(paso(A) === "ELEGIR_MECANICO", "paso=ELEGIR_MECANICO"); ok(!!clientes.find((c) => c.telefonoNorm === A), "cliente creado con telefonoNorm"); r = await env(A, "j1"); ok(/Cambio de aceite/i.test(flat(r)), "muestra trabajos del jefe elegido"); ok(/Revision general/i.test(flat(r)), "incluye los trabajos globales del taller"); ok(paso(A) === "ELEGIR_TRABAJO", "paso=ELEGIR_TRABAJO"); r = await env(A, "a"); ok(/Horarios/i.test(flat(r)), "muestra horarios"); ok(paso(A) === "ELEGIR_HORARIO", "paso=ELEGIR_HORARIO"); r = await env(A, "a"); ok(/Confirmás/i.test(flat(r)), "pide confirmación"); ok(paso(A) === "CONFIRMAR", "paso=CONFIRMAR"); r = await env(A, "sí"); ok(/confirmado/i.test(flat(r)), "turno confirmado"); ok(paso(A) === "INICIO", "vuelve a INICIO tras reservar"); const nuevoCliente = clientes.find((c) => c.telefonoNorm === A)!; const t = turnos.find((x) => x.clienteId === nuevoCliente.id); ok(!!t, "se creó el turno del cliente"); ok(t?.autoId === null && t?.kilometros === null, "turno sin auto/km (los carga el jefe)"); console.log("\n── Escenario B: cliente EXISTENTE, verificación por email ──"); const B = "5493510000001"; r = await env(B, "hola"); ok(/email/i.test(flat(r)), "pide el email registrado"); ok(paso(B) === "VERIFICAR_EMAIL", "paso=VERIFICAR_EMAIL"); r = await env(B, "nope@x.com"); ok(/no coincide/i.test(flat(r)), "rechaza email incorrecto"); ok(paso(B) === "VERIFICAR_EMAIL", "sigue en VERIFICAR_EMAIL"); r = await env(B, "juan@mail.com"); ok(hayBotones(r), "email correcto → botones de mecánico"); ok(paso(B) === "ELEGIR_MECANICO", "paso=ELEGIR_MECANICO"); console.log("\n── Escenario C: opción inválida + cancelar ──"); r = await env(B, "zzz"); ok(/no entendí/i.test(flat(r)), "opción inválida → 'no entendí'"); ok(paso(B) === "ELEGIR_MECANICO", "no avanza con opción inválida"); r = await env(B, "cancelar"); ok(/cancel/i.test(flat(r)), "cancelar resetea"); ok(paso(B) === "INICIO", "paso=INICIO tras cancelar"); console.log("\n── Escenario D: trabajos por jefe ──"); const D = "5493518888888"; await env(D, "hola"); await env(D, "Ana"); await env(D, "Lopez"); r = await env(D, "ana@mail.com"); ok(hayBotones(r), "alta rápida hasta elegir mecánico"); r = await env(D, "j2"); ok(/Revision general/i.test(flat(r)), "un jefe sin trabajos propios igual ofrece los globales"); ok(!/Cambio de aceite/i.test(flat(r)), "NO ofrece los trabajos propios de otro jefe"); const { crearTurno } = await import("../src/lib/turnos"); const cruzado = await crearTurno({ clienteId: "c1", jefeMecanicoId: "j2", tipoServicioId: "s1", // trabajo propio de j1 fechaInicio: new Date(Date.now() + 24 * 60 * 60 * 1000), }); ok(!cruzado.ok && cruzado.status === 400, "crearTurno rechaza el trabajo de otro jefe (400)"); 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); });