home
  • Español
  • English
  • Português
  • How I work
  • star Featured Projects
  • Experience
  • mail Contact
arrow_back Back to project Home

Project testing

Testing · Obrador Mel

5 markdown files (overview, unit, integration, e2e, GitHub Actions).

On this page Testing · visión 0% expand_more
Testing · visión Comandos Convenciones Qué no está cubierto Testing · unitarios Un turno sin levantar nada La misma pregunta de muchas formas El modelo, inyectado Las reservas, con negocios de prueba Los datos también se prueban Testing · integración El recorrido contra Postgres (engine/test/flow.test.mjs) Lo que más vale El servidor web (web/serve.test.mjs) Testing · conversaciones Formato Qué cubren Sin modelo La interfaz Testing · CI engine web Lo que se queda fuera El otro workflow

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.

CapaFicherosPruebasQué cubre
Unitarios12139Lectura de mensajes, cálculo, un turno completo, validación de datos y rutas
Integración236El recorrido entero contra Postgres, y el servidor web como proceso real
Conversaciones132Conversaciones 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, chatTurn y staffAct reciben now, 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: turn recibe kbAnswer y answerFromKb recibe complete. 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.

FicheroTestsCubre
biz/dialog.test.mjs38Turnos enteros: encargos, dudas a mitad, horario, rechazos, gallego y reservas
biz/kb.test.mjs16Trozos, búsqueda por palabras, comprobación de cifras y respuestas del modelo
biz/orders.test.mjs14Cantidades, sabores sin repetir el nombre, erratas, carrito y precios
biz/avail.test.mjs13Horario, huecos, plazos, ocupación, mesas, citas y agenda
when.test.mjs12Días en texto libre, en los dos idiomas, y husos de Madrid
biz/nlu.test.mjs11Idioma, horas ambiguas, intención, temas, dietas y datos de contacto
biz/routes.test.mjs9Rutas por HTTP: validación, cuerpos enormes y JSON roto
biz/store.test.mjs8Validación de los datos del negocio y perfil público
pii.test.mjs6Tarjetas, DNI, resúmenes y nivel de datos personales
biz/text.test.mjs6Normalización, números escritos y generador con semilla
biz/format.test.mjs4Importes, fechas y horario semanal en cada idioma
calendar.test.mjs2Sin 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';
GrupoTestsComprueba
Confirmar y cerrar5Solo se cierra si alguien confirma; rastro en orden; markdown; rechazar; no decidir dos veces
Otra hora4Aceptar mueve el pedido y su tarjeta; rechazar lo devuelve; idioma; huecos inexistentes
Una persona responde3Pregunta sin respuesta, revisión y respuesta sin modelo
Varias conversaciones4Pregunta aparte, otro pedido, tras cerrar, y la agenda
Aislamiento2Cada visitante solo ve lo suyo; entradas inválidas
Datos personales6Tarjetas, DNI y resúmenes en todas las tablas
Límites2Eventos 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
TestComprueba
Lista cerradaLas rutas fuera de la lista dan 404 y el motor no recibe nada
CabecerasCookies, Authorization y X-Forwarded-For no llegan al motor
Cuerpo grandeSe corta antes del motor
FicherosPortada, caché por tipo y sin salirse de la carpeta, también con %2f
Rutas y ficheros que no existen404 para un fichero y portada para una ruta
Cabeceras de seguridadEn todas las respuestas, también en los errores
Motor caído502 con un mensaje que se entiende
Ritmo40 mensajes por minuto, y X-Forwarded-For inventado no se lo salta
Proxy de confianzaCon 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"}}
]}
ExpectativaComprueba
replyExpresión regular sobre todas las respuestas del turno
cardQue haya una tarjeta de ese tipo
chips_minNúmero mínimo de botones
requestCampos de la solicitud
no_requestQue no se haya abierto nada
no_orderQue no se haya abierto un pedido
refusedQue el expediente haya quedado rechazado
statusResultado 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

GrupoConversacionesEjemplos
Encargos8Completo, otra hora, rechazado, plazos, dos tartas, ambigüedad
A una persona2Boda, «quiero hablar con una persona»
Información12Horario, dirección, precio, varias preguntas, alérgenos
Conocimiento4Congelar, aparcar, envíos, llevar la tarta en el coche
Gallego2Horario y pedido
Límites4Tarjeta, 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.

PasoQué para
npm ciUn lockfile desfasado
npm testUna regla rota, o datos del negocio inválidos
npm run schemaUn error en db/schema.sql
npm run test:integrationUn fallo en el recorrido, en el aislamiento o en la privacidad
Conversaciones, sin modelo ni QdrantUn 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

PasoQué para
npm ciUn lockfile desfasado
npm testUna ruta que se abre de más en serve.mjs
npm run buildUn error de TypeScript o de plantilla, o superar el presupuesto de 1 MB
Sin scripts en líneaUn <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.

On this page

Testing · visión Comandos Convenciones Qué no está cubierto Testing · unitarios Un turno sin levantar nada La misma pregunta de muchas formas El modelo, inyectado Las reservas, con negocios de prueba Los datos también se prueban Testing · integración El recorrido contra Postgres (engine/test/flow.test.mjs) Lo que más vale El servidor web (web/serve.test.mjs) Testing · conversaciones Formato Qué cubren Sin modelo La interfaz Testing · CI engine web Lo que se queda fuera El otro workflow

0% read

folder_zip Download all (ZIP)
Ask about Cristian AI assistant, public data only

This is an automated assistant and it can be wrong. For anything important, write to Cristian.