12-testing.md
Testing · visión
Obrador Mel tiene 175 tests y 32 conversaciones de extremo a extremo, todos con node --test, sin librerías de testing. La suite unitaria tarda menos de medio segundo y no necesita nada levantado.
| Capa | Ficheros | Pruebas | Qué cubre |
|---|---|---|---|
| Unitarios | 12 | 139 | Lectura de mensajes, cálculo, un turno completo, validación de datos y rutas |
| Integración | 2 | 36 | El recorrido entero contra Postgres, y el servidor web como proceso real |
| Conversaciones | 1 | 32 | Conversaciones por HTTP contra el motor en marcha |
| CI | — | — | Todo lo anterior en cada push, menos la conversación que necesita el modelo |
Comandos
npm --prefix engine test # unitarios: nada levantado
npm --prefix engine run test:integration # necesita Postgres
npm --prefix web test # el servidor de producción
./scripts/eval.sh # necesita el motor en marcha
Convenciones
- El nombre del test es una frase del comportamiento, escrita como lo diría el negocio: «un encargo solo se cierra cuando una persona lo confirma», «lo que no se sabe pasa a una persona en vez de inventarse».
- El reloj se fija.
turn,chatTurnystaffActrecibennow, así que los tests no dependen del día en que corren. La mayoría usa el jueves 15 de octubre de 2026. - Sin mocks del dominio. Los tests usan los datos reales del obrador, y la integración, un Postgres real.
- Lo externo se sustituye en el borde. El modelo se inyecta:
turnrecibekbAnsweryanswerFromKbrecibecomplete. Nada simula la base de datos. - Sin modelo a propósito. La integración apunta Ollama y Qdrant a un puerto cerrado. Si el motor dependiera de ellos para algo importante, fallaría.
- Negocios de prueba solo para lo que el obrador no usa. Las reservas se prueban con un restaurante y una peluquería inventados en
engine/test/fixtures/. - Un error, un test. Cada error de los que aparecen en Retos y resultados tiene el suyo.
Qué no está cubierto
- La interfaz no tiene tests automáticos: componentes y plantillas se comprueban con el build de producción, que verifica los tipos de las plantillas, y a mano en el navegador.
- La calidad de lo que escribe el modelo no se puede fijar en un test. Las conversaciones comprueban que la respuesta contenga lo esperado, y las pruebas unitarias, que las barreras funcionan con un modelo simulado.
13-testing-unit.md
Testing · unitarios
Funciones sin base de datos, sin red y sin procesos. 12 ficheros, 139 tests, menos de medio segundo.
| Fichero | Tests | Cubre |
|---|---|---|
biz/dialog.test.mjs | 38 | Turnos enteros: encargos, dudas a mitad, horario, rechazos, gallego y reservas |
biz/kb.test.mjs | 16 | Trozos, búsqueda por palabras, comprobación de cifras y respuestas del modelo |
biz/orders.test.mjs | 14 | Cantidades, sabores sin repetir el nombre, erratas, carrito y precios |
biz/avail.test.mjs | 13 | Horario, huecos, plazos, ocupación, mesas, citas y agenda |
when.test.mjs | 12 | Días en texto libre, en los dos idiomas, y husos de Madrid |
biz/nlu.test.mjs | 11 | Idioma, horas ambiguas, intención, temas, dietas y datos de contacto |
biz/routes.test.mjs | 9 | Rutas por HTTP: validación, cuerpos enormes y JSON roto |
biz/store.test.mjs | 8 | Validación de los datos del negocio y perfil público |
pii.test.mjs | 6 | Tarjetas, DNI, resúmenes y nivel de datos personales |
biz/text.test.mjs | 6 | Normalización, números escritos y generador con semilla |
biz/format.test.mjs | 4 | Importes, fechas y horario semanal en cada idioma |
calendar.test.mjs | 2 | Sin approval_id no hay evento |
routes.test.mjs levanta un servidor HTTP de verdad en un puerto libre, pero sus casos se rechazan antes de llegar a la base de datos.
Un turno sin levantar nada
turn() no toca la base de datos, así que una conversación entera es un bucle. Es el test más valioso del proyecto: cuesta milisegundos y prueba lo mismo que un cliente.
const noKb = async () => ({ no_data: true, reason: 'prueba' });
async function talk(steps, { now = NOW, kbAnswer = noKb, found = mel, own = [] } = {}) {
const { business, catalog } = found;
let state = null;
const log = [];
for (const step of steps) {
const input = typeof step === 'string' ? { text: step } : { action: step };
const out = await turn({ biz: business, catalog, state, input, now, own, kbAnswer });
state = out.state;
log.push(out);
}
return log;
}
test('encargo completo: queda PENDIENTE, con el total calculado y sin confirmar nada', async () => {
const log = await talk(['una caja de macarons y dos palmeras para mañana a las 11', 'Iria', '655444333', 'no', { type: 'confirm' }]);
assert.ok(log.slice(0, -1).every((o) => !o.request), 'nada se envía hasta confirmar');
const last = log.at(-1);
assert.equal(last.request.status, 'pending');
assert.equal(last.request.total_cents, 1450 + 2 * 220);
});
La misma pregunta de muchas formas
Una tabla de frases y lo que se espera de cada una. Si una regla nueva rompe una forma de preguntar, se ve aquí.
const cases = [
['¿A qué hora abrís?', /Hoy abrimos de 09:00 a 14:00 y de 16:30 a 20:00/],
['¿Abrís el domingo?', /Sí, el domingo.*09:30 a 14:00/],
['¿El lunes estará abierto?', /No, el lunes.*cerramos/],
['¿Qué días abrís?', /Abrimos de martes a domingo. Cerramos el lunes/],
['¿Cerráis a mediodía?', /hay pausa a mediodía/],
['Cando abrides?', /abrimos de 09:00 a 14:00 e de 16:30 a 20:00/],
];
El modelo, inyectado
answerFromKb recibe la función que llama al modelo, y las barreras se prueban con respuestas escritas a mano:
const ask = (content) => async () => ({ model: 'prueba', content, tokens_in: 10, tokens_out: 5, ms: 1 });
test('respuesta: si el modelo inventa una cifra se usa el texto de la fuente', async () => {
const out = await answer(ask('Las clásicas, con 72 horas de antelación.'));
assert.equal(out.fallback, true);
assert.equal(out.text, 'Las tartas clásicas se encargan con 48 horas de antelación. La de Santiago, con 24.');
assert.deepEqual(out.unsupported, ['72 hora']);
});
Las reservas, con negocios de prueba
El obrador no reserva, pero el motor sí. engine/test/fixtures/ tiene un restaurante y una peluquería mínimos, que pasan la misma validación que el obrador:
test('citas: solo los profesionales que hacen todos los servicios', () => {
const { business, catalog } = pelu;
const ids = ['corte-mujer', 'tinte-raiz'];
const duration = ids.reduce((n, id) => n + catalog.byId.get(id).duration, 0);
const av = staffAvailability(business, { ymd: '2026-10-21', duration, serviceIds: ids, now: NOW });
assert.ok(av.slots.every((s) => s.resource.id === 'marta'), 'solo Marta hace corte de mujer y tinte');
});
Los datos también se prueban
store.test.mjs falla si el dataset del obrador tiene un alérgeno desconocido, un horario al revés o una categoría que no existe. Editar la carta está cubierto por la misma suite que el código.
14-testing-integration.md
Testing · integración
Dos ficheros que prueban piezas reales juntas: el motor contra un Postgres de verdad, y el servidor web como un proceso aparte.
El recorrido contra Postgres (engine/test/flow.test.mjs)
26 tests. Llaman directamente a chatTurn y staffAct, las funciones que hay detrás de las rutas, con el reloj fijado y una sesión nueva por test, así que pueden correr sobre una base con datos.
./scripts/up.sh
npm --prefix engine run test:integration
Ollama y Qdrant apuntan a un puerto cerrado a propósito:
process.env.OLLAMA_BASE_URL = 'http://127.0.0.1:9';
process.env.QDRANT_URL = 'http://127.0.0.1:9';
| Grupo | Tests | Comprueba |
|---|---|---|
| Confirmar y cerrar | 5 | Solo se cierra si alguien confirma; rastro en orden; markdown; rechazar; no decidir dos veces |
| Otra hora | 4 | Aceptar mueve el pedido y su tarjeta; rechazar lo devuelve; idioma; huecos inexistentes |
| Una persona responde | 3 | Pregunta sin respuesta, revisión y respuesta sin modelo |
| Varias conversaciones | 4 | Pregunta aparte, otro pedido, tras cerrar, y la agenda |
| Aislamiento | 2 | Cada visitante solo ve lo suyo; entradas inválidas |
| Datos personales | 6 | Tarjetas, DNI y resúmenes en todas las tablas |
| Límites | 2 | Eventos solo de añadir; tope de expedientes |
Lo que más vale
La prueba de privacidad no mira una columna: vuelca todo lo que se ha guardado de un expediente y busca el dato.
async function everything(caseId) {
const q = async (sql) => JSON.stringify(await rows(sql, caseId));
return (await Promise.all([
q('SELECT summary FROM cases WHERE id = $1'),
q('SELECT content, meta FROM messages WHERE case_id = $1'),
q('SELECT data, dialog FROM biz_requests WHERE case_id = $1'),
q('SELECT payload FROM events WHERE case_id = $1'),
q('SELECT draft FROM proposals WHERE case_id = $1'),
])).join('\n');
}
test('una tarjeta en el primer mensaje no llega a la base de datos', async () => {
const out = await say(newSession(), undefined, `hola, mi tarjeta es ${CARD}`);
assert.equal(out.state, 'refused');
const stored = await everything(out.case_id);
assert.ok(!stored.includes('4111'), stored);
assert.match(stored, /\[TARJETA\]/);
});
Mirar todas las tablas, y no solo messages, es lo que hace que esta prueba vea un resumen sin tapar.
El servidor web (web/serve.test.mjs)
10 tests. Arrancan serve.mjs como proceso aparte contra un motor de mentira que apunta lo que le llega, porque lo importante es lo que no deja pasar.
npm --prefix web test
| Test | Comprueba |
|---|---|
| Lista cerrada | Las rutas fuera de la lista dan 404 y el motor no recibe nada |
| Cabeceras | Cookies, Authorization y X-Forwarded-For no llegan al motor |
| Cuerpo grande | Se corta antes del motor |
| Ficheros | Portada, caché por tipo y sin salirse de la carpeta, también con %2f |
| Rutas y ficheros que no existen | 404 para un fichero y portada para una ruta |
| Cabeceras de seguridad | En todas las respuestas, también en los errores |
| Motor caído | 502 con un mensaje que se entiende |
| Ritmo | 40 mensajes por minuto, y X-Forwarded-For inventado no se lo salta |
| Proxy de confianza | Con TRUST_PROXY=1, cada dirección tiene su límite |
15-testing-e2e.md
Testing · conversaciones de extremo a extremo
evals/conversations.jsonl guarda 32 conversaciones y evals/run.mjs las pasa contra el motor en marcha, por HTTP, igual que la web. Es la única prueba con el modelo y Qdrant de verdad.
./scripts/eval.sh # todas
./scripts/eval.sh tarta # solo las que llevan «tarta» en el id
PASS encargo-completo (623 ms)
PASS plazo-de-tarta (41 ms)
PASS conocimiento-congelar (3599 ms)
...
scored 32 pass 32 fail 0
Formato
Cada línea es una conversación con sus pasos: lo que dice el cliente, un botón o una decisión del local, con lo que se espera.
{"id":"encargo-completo","steps":[
{"say":"Quiero una tarta de queso para 12 personas {{pickup}}","expect":{"reply":"30,00"}},
{"say":"Laura"},
{"say":"655444333"},
{"say":"Feliz cumple Mateo"},
{"action":{"type":"confirm"},"expect":{"request":{"kind":"order","status":"pending","total_cents":3000}}},
{"staff":"accept","expect":{"status":"confirmed"}}
]}
| Expectativa | Comprueba |
|---|---|
reply | Expresión regular sobre todas las respuestas del turno |
card | Que haya una tarjeta de ese tipo |
chips_min | Número mínimo de botones |
request | Campos de la solicitud |
no_request | Que no se haya abierto nada |
no_order | Que no se haya abierto un pedido |
refused | Que el expediente haya quedado rechazado |
status | Resultado de una decisión del local |
Además, si una conversación se confirma, el runner comprueba que el expediente quedó en emit y que el cliente recibió el aviso del local.
{{pickup}} se sustituye por un día con plazo de sobra y las 11:00 libres. Se le pregunta al propio motor, porque la ocupación depende del día en que corre la prueba.
Qué cubren
| Grupo | Conversaciones | Ejemplos |
|---|---|---|
| Encargos | 8 | Completo, otra hora, rechazado, plazos, dos tartas, ambigüedad |
| A una persona | 2 | Boda, «quiero hablar con una persona» |
| Información | 12 | Horario, dirección, precio, varias preguntas, alérgenos |
| Conocimiento | 4 | Congelar, aparcar, envíos, llevar la tarta en el coche |
| Gallego | 2 | Horario y pedido |
| Límites | 4 | Tarjeta, inyección, fuera de tema, reservar sin reservas |
Sin modelo
Con EVAL_NO_MODEL=1 se saltan las conversaciones marcadas con "model": true, y el resto pasan con Ollama y Qdrant apagados. Solo una necesita de verdad el modelo: «¿Puedo llevar una tarta en el coche una hora?», porque la mejor sección por palabras es la de aparcar, y hay que leer el contexto para contestar bien.
Esas 31 conversaciones son las que corren en la integración continua.
La interfaz
No hay pruebas automáticas de navegador en el repositorio. Los cambios de interfaz se comprueban recorriendo un encargo completo en el navegador, de la web al panel y de vuelta al chat, en escritorio y en móvil, sin errores del navegador.
16-testing-ci.md
Testing · CI
GitHub Actions corre en cada push a main y en cada pull request. El workflow es .github/workflows/ci.yml y tiene dos trabajos que no dependen entre sí.
engine
Levanta un Postgres 16 como servicio en el mismo puerto que usa en local (55432), con los valores por defecto de .env.example, así que no hacen falta secretos.
| Paso | Qué para |
|---|---|
npm ci | Un lockfile desfasado |
npm test | Una regla rota, o datos del negocio inválidos |
npm run schema | Un error en db/schema.sql |
npm run test:integration | Un fallo en el recorrido, en el aislamiento o en la privacidad |
| Conversaciones, sin modelo ni Qdrant | Un fallo por HTTP, o algo que dependa del modelo sin deber |
El último paso arranca el motor con Ollama y Qdrant apuntando a un puerto cerrado y pasa 31 de las 32 conversaciones. Que pasen sin modelo es parte de lo que se prueba: los encargos no pueden depender de la pieza que más puede fallar.
web
| Paso | Qué para |
|---|---|
npm ci | Un lockfile desfasado |
npm test | Una ruta que se abre de más en serve.mjs |
npm run build | Un error de TypeScript o de plantilla, o superar el presupuesto de 1 MB |
| Sin scripts en línea | Un <script> en index.html, que la política de seguridad bloquearía |
Lo que se queda fuera
- La respuesta del modelo: necesitaría un modelo de varios GB en cada ejecución. La conversación que lo necesita se pasa a mano con
./scripts/eval.sh. - Los recorridos en el navegador, que se comprueban a mano.
El otro workflow
.github/workflows/portfolio-docs.yml no prueba nada. Cuando cambia docs/ en main, avisa al portafolio para que se reconstruya, porque lee esta documentación al compilar. Si no está configurado el secreto PORTFOLIO_BUILD_HOOK, deja un aviso y termina bien.