c1b4ad5ad2
- lib/turnos.ts: crearTurno() compartido (valida servicio/jefe/slot); cliente/turnos POST lo reutiliza - lib/telefono.ts: normalización de teléfono (E.164 sin '+') para matchear el wa_id - lib/whatsapp-bot.ts: procesarMensaje(telefono, texto), state machine completa: identidad por teléfono + verificación por email registrado / alta nueva, elegir mecánico (botones) -> trabajo (letras) -> horario (letras, con "más") -> confirmar, crea turno sin auto/km; expiración 30min, "cancelar" global, manejo de inválidos y 409. Devuelve objetos-respuesta (texto/botones) -> independiente de Meta (lo traduce F0). - scripts/test-whatsapp-bot.ts + npm run test:bot: corre la máquina de estados con un Prisma en memoria (sin DB ni Meta), ejercitando scheduling y crearTurno reales. 27/27 OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
236 lines
8.4 KiB
TypeScript
236 lines
8.4 KiB
TypeScript
/**
|
|
* 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<string, any>;
|
|
|
|
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;
|
|
}
|
|
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" });
|
|
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(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${fallos === 0 ? "✅ TODO OK" : `❌ ${fallos} assert(s) fallaron`}\n`);
|
|
process.exit(fallos === 0 ? 0 : 1);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error(e);
|
|
process.exit(1);
|
|
});
|