91112862a5
Revisión con IA / ai-review (pull_request) Successful in 29s
- 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>
271 lines
9.8 KiB
TypeScript
271 lines
9.8 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;
|
|
}
|
|
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;
|
|
},
|
|
},
|
|
// `crearTurno` verifica y crea dentro de una transacción con un lock por jefe.
|
|
// Acá corre todo sobre el mismo cliente falso, que ya es secuencial.
|
|
async $transaction(fn: any) {
|
|
return fn(fakePrisma);
|
|
},
|
|
async $executeRaw() {
|
|
return 0;
|
|
},
|
|
};
|
|
|
|
(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);
|
|
});
|