Explorar la API con Bruno
La API se explora con Bruno: un cliente HTTP cuyas colecciones son archivos de texto versionados en el repo, no un espacio en la nube de alguien.
La colección vive en api/bruno/aquazaku/.
Qué cubre
Sección titulada «Qué cubre»| Carpeta | Qué prueba |
|---|---|
01-Auth |
Señal de vida, login y perfil con permisos resueltos |
02-Usuarios |
Listar, crear, asignar roles y la protección del último admin |
03-Auditoria |
Consulta, filtro de denegados y validación de filtros |
04-Productos |
Catálogo, piso de precio, auditoría del cambio y un pos recibiendo 403 |
05-Stock |
Lotes, ajuste, descarte, el libro y un contador recibiendo 403 |
06-Insumos |
Alta, entrada en unidades y en kilos, la conversión rechazada sin equivalencia, y un seller recibiendo 403 |
07-Produccion |
El cierre del día con sus cuatro escritos, la reposición sin cantidad, la reconciliación que no escribe, y un pos que puede anotar pero no ajustar |
08-Clientes |
El dígito de verificación calculado, el cruce CC/NIT que advierte sin bloquear, el crédito rechazado sin verificación, la desactivación que devuelve bases y botellones al stock físico, y un contador recibiendo 403 |
09-Ventas |
El precio congelado, el piso que no se perfora, la anulación que devuelve al mismo lote, la corrección que revierte los botellones como la anulación, el cobro que no puede pasarse de la deuda, y un contador recibiendo 403 |
10-Retornables |
La ley de conservación del parque, la entrega que escribe dos filas con signo opuesto, la base que se presta a una dirección, el daño que genera recargo, la venta que despacha un envase y lo deja a nombre del cliente, y un seller que mira y no opera |
11-Proveedores |
La compra que escribe el documento y el inventario juntos, el crédito modelado sin ejercerse, el aviso de vencidas que se apaga al pagar, y un pos que compra pero no da de alta proveedores |
12-Sesion |
Cierre de sesión y que la credencial deje de servir |
Las carpetas llevan número porque el orden importa: el login deja la cookie que usan los requests siguientes.
Renumerarla cada vez es más trabajo que dejarla fija, y es a propósito: el
número es lo que hace visible el orden en el árbol de archivos. Un 99-Sesion
que nunca se mueve esconde que hay un orden.
:::
El único lugar donde otro rol pega contra api/
Sección titulada «El único lugar donde otro rol pega contra api/»Seis carpetas cambian de sesión a mitad de camino y vuelven a admin al terminar.
04-Productos entra como el pos que creó 02-Usuarios, verifica que no
pueda crear productos (403) y que sí pueda leer el catálogo (200).
05-Stock crea su propio contador, y no por gusto: el usuario de
02-Usuarios termina con pos + seller, y como los roles se suman
(RN-ACC-01), el pos puede ajustar stock. Ese usuario no sirve para probar un
rechazo. El contador es el testigo externo —mira todo, no modifica nada— y es
el rol correcto para verificar que lectura y escritura están separadas de
verdad.
06-Insumos entra como un seller: no toca la planta, así que no ve insumos.
07-Produccion entra como un pos, y es la carpeta donde la separación se ve
mejor. El pos sí puede anotar que llegó agua —es un hecho que observa
parado en la planta— y no puede ajustar un saldo que no cuadra. Son dos
permisos y no uno porque corregir un saldo no es contar un hecho: es decidir
cuál de dos números es el bueno, y un registro que quien opera puede cuadrar
solo deja de servir como registro.
08-Clientes entra como un contador: ve la cartera —la necesita para saber
cuánto debe cada uno— y no puede registrar ni verificar. Verificar un documento
es afirmar que se lo tuvo en la mano, y el contador no está en el mostrador ni
en la calle.
09-Ventas vuelve a entrar como contador, y por una razón distinta: acá el
alcance no es «ve o no ve», es propio contra todo. Un pos ve y anula
sus ventas y no las de otro, y eso lo recorta scopedCondition a partir de la
sesión. Es la primera carpeta donde el alcance —y no el permiso— es lo que se
está probando.
Los tests unitarios ya verifican que la matriz le niegue el permiso, pero esos
no atraviesan un socket. Los dos peores bugs de M0 —el header Origin y el
logout faltante— vivían justamente en costuras entre capas, donde ninguna suite
unitaria mira por diseño.
El request 13-Volver-como-admin no es ceremonia: sin él, 05-Sesion cerraría
la sesión del pos y su verificación probaría algo distinto de lo que dice
probar.
Correrla a mano
Sección titulada «Correrla a mano»cd api/bruno/aquazaku && bru run . -r --env localbru 4.x exige que el path apunte a la colección, no a una ruta relativa desde
otro lado. bru run bruno/aquazaku desde api/ responde:
You can run only at the root of a collectionY el flag --recursive ya no existe: es -r. Con la forma vieja el comando
imprime la ayuda y sale con código 1, o sea que el pipeline se pone rojo sin
haber corrido ni un request — fácil de confundir con un test que falla.
Correr la colección contra una instancia en un puerto distinto —por ejemplo una
levantada contra aquazaku_test para no tocar la base de desarrollo— falla
entera con 401 desde el login, y el mensaje no dice por qué.
La causa es que Better-Auth valida su propia base URL: si BETTER_AUTH_URL
sigue apuntando al puerto de siempre, el sign-in se rechaza aunque la
contraseña esté bien. Los 401 que siguen son consecuencia de no tener cookie, y
mandan a buscar el problema al lugar equivocado.
DATABASE_URL='...aquazaku_test' PORT=3099 BETTER_AUTH_URL='http://localhost:3099' \ pnpm tsx --env-file-if-exists=.env src/server.tsY ojo con el limitador de login: son 5 intentos por IP+email cada 15 minutos. Un login exitoso limpia el contador, pero una tanda de fallidos deja la colección en 429 — que se confunde con un test roto. Vive en memoria, así que reiniciar el proceso lo borra.
Para correr la colección contra una base limpia es tentador hacer
DROP SCHEMA public CASCADE; CREATE SCHEMA public;. Hace dos daños, y los dos
se reportan como éxito.
1. El registro de migraciones sobrevive. Drizzle lo guarda en el esquema
drizzle, no en public. Después del drop, pnpm db:migrate:test imprime
✓ migraciones aplicadas sin crear una sola tabla: ve todas las
migraciones registradas y no aplica ninguna. El síntoma aparece recién en el
login, como un 500 que dice relation "users" does not exist.
2. Se lleva los GRANTS. Y esos grants no son burocracia: son la mitad de la
garantía append-only. 0001_audit_append_only.sql le da a aquazaku_app solo
SELECT, INSERT sobre audit_log, y 0003_stock.sql le revoca
UPDATE, DELETE sobre movimientos_stock. Restaurarlos «a ojo» con un
GRANT ... ON ALL TABLES los aplana todos y deja la base con permisos de más:
17 tests de inmutabilidad se ponen rojos porque la operación que debía fallar
termina bien.
Los grants viven en las migraciones a propósito, junto a la tabla que protegen. No hay un archivo de setup aparte que haya que acordarse de correr — y por eso tampoco hay que escribirlos a mano nunca.
# Reset total: los DOS esquemas, y que las migraciones pongan los permisos.DROP SCHEMA IF EXISTS public CASCADE;DROP SCHEMA IF EXISTS drizzle CASCADE;CREATE SCHEMA public;Para un reset entre corridas —mucho más barato— alcanza con TRUNCATE de
todas las tablas de public: deja esquema, permisos y migraciones intactos.
audit_log queda afuera porque su trigger rechaza el TRUNCATE, y está bien
que lo haga.
drizzle/seed.ts arranca con if (process.argv[1]?.endsWith('seed.ts')).
Importarlo desde un wrapper con otro nombre —para apuntarlo a otra base, por
ejemplo— no siembra nada y sale con código 0. Un éxito falso.
Si hace falta envolverlo, hay que pisar process.argv[1] antes del import. Y
verificar que el admin existe antes de correr la colección: un seed vacío se
manifiesta como 175 requests fallando con 401, que manda a buscar el problema a
la autenticación.
El entorno local deja adminPassword como variable secreta: no está en el
archivo. Se pasa al correr, o se completa desde la interfaz de Bruno.
bru run . -r --env local --env-var adminPassword=tu-contraseñaSEED_ADMIN_PASSWORD es la contraseña inicial. El admin nace con
mustChangePassword, así que lo primero que hace al entrar es cambiarla — y
desde ese momento el valor del .env ya no sirve para entrar.
Va la contraseña actual. Si se perdió, se recupera por el flujo de
/forgot-password, que en dev deja el mail en
Mailpit.
Los tests no son decorativos
Sección titulada «Los tests no son decorativos»Cada request lleva aserciones. Algunas verifican el camino feliz; otras están para que fallar sea el resultado correcto:
2-Usuarios/04-Ultimo-admin (409 Conflict) ✓ responde 409 ✓ el código dice cuál es el problema ✓ el mensaje explica qué hacer, no solo que fallóEse request intenta quitarle el rol admin al único administrador activo. Si
alguna vez devuelve 200, no es un éxito: es que se rompió
RN-ACC-06 y el sistema quedó a un click de ser
inadministrable.
Otro par que vale la pena mirar juntos: 2-Usuarios/04 genera un acceso
denegado, y 3-Auditoria/02-Solo-denegados lo busca en la bitácora. Entre
los dos verifican el ciclo completo de RN-ACC-04 — la acción ocurre, queda
registrada y se puede encontrar.
El header Origin
Sección titulada «El header Origin»La colección lo pone sola, en un script de nivel colección:
req.setHeader("Origin", bru.getEnvVar("webOrigin"));No es opcional. Better-Auth rechaza con 403 MISSING_OR_NULL_ORIGIN toda
petición que cambie estado y llegue sin él. Está en la colección y no en cada
request para que ninguno nuevo nazca sin él — el mismo problema
nos rompió todo el login una vez.
El workflow de api/ corre la colección contra un servidor de verdad, después
de migrar y sembrar. Es la única verificación del proyecto que atraviesa un
socket real: el resto de los tests usan app.inject(), que no pasa por la red.
Esa diferencia no es teórica. El bug del header Origin vivía justo ahí.
Agregar un endpoint nuevo
Sección titulada «Agregar un endpoint nuevo»- Crear el
.bruen la carpeta que corresponda, con unseqque respete el orden de dependencias. - Escribirle tests. Un request sin aserciones documenta la forma de la petición, pero no verifica nada.
- Correr la colección local antes de abrir el pull request.
Este error de sintaxis hace que Bruno saltee el archivo entero con un warning fácil de pasar por alto, y la corrida sigue en verde con menos requests de los que debería:
params:query { ← nunca adentro del bloque get { } limite: 10}Si el resumen dice Skipped, mirá el warning: hay un archivo que no se está
corriendo.
Cinco formas de romper la colección sin tocar api/
Sección titulada «Cinco formas de romper la colección sin tocar api/»El 22-sep-2026 la colección quedó con 42 peticiones rojas, y api-ci con
ella. Ninguna causa era del sistema. Están todas acá porque las cinco son
fáciles de repetir.
1 · Un cambio de rol es un préstamo
Sección titulada «1 · Un cambio de rol es un préstamo»08-Clientes terminaba con dos pedidos que entran como contador y nunca
volvía a admin. Toda la carpeta 09-Ventas arrancaba con esa sesión y caía
con 403 — y detrás, media colección.
Lo notable es que ya existía una petición para exactamente eso, con su
documentación diciendo «sin esto, 09-Ventas arrancaría con la sesión del pos».
Alguien agregó pedidos al final de la carpeta y heredó la deuda sin
enterarse.
Aparece tres carpetas más abajo, y con un código —403— que apunta al lugar equivocado. Quien lo investiga empieza mirando permisos de ventas, que están bien.
Quien pide un rol prestado lo devuelve en la misma carpeta. Y si agregás un pedido al final de una carpeta, fijate qué sesión quedó abierta.
2 · bru.sendRequest no es fetch
Sección titulada «2 · bru.sendRequest no es fetch»Es configuración de axios:
// ❌ el pedido sale VACÍO y api/ contesta 400 de Zodawait bru.sendRequest({ url, method: "POST", body: JSON.stringify(datos) });
// ✅const r = await bru.sendRequest({ url, method: "POST", data: (datos) });r.data.id; // la respuesta también es `.data`, no `.body`3 · …y tampoco comparte el cookie jar
Sección titulada «3 · …y tampoco comparte el cookie jar»Los pedidos normales llevan la sesión solos. sendRequest no: sale sin
cookie y devuelve 401, el script revienta, y con él todos los pedidos que
venían después.
La colección guarda la cookie en un script:post-response a nivel colección
—no del login— para que siga al rol actual: la colección cambia de usuario
varias veces, y capturarla una sola vez apuntaría a la sesión equivocada desde
el segundo cambio.
headers: { "content-type": "application/json", cookie: bru.getVar("cookieSesion") }4 · Un POST con content-type: application/json y sin cuerpo
Sección titulada «4 · Un POST con content-type: application/json y sin cuerpo»Fastify lo rechaza con 400. Si la ruta no lee ningún cuerpo, hay que mandar
data: ({}) igual.
5 · Escenarios que se pisan
Sección titulada «5 · Escenarios que se pisan»La colección es una narración sobre estado global. Un script de preparación
que compra botellones rompe a 10-Retornables/01-La-bodega-arranca-vacia, que
afirma que el parque arranca en cero.
Cuando un pedido necesita ensuciar algo compartido, va después de todo lo que necesita eso limpio — aunque su carpeta natural esté antes.
86_400_000 en un script tira Unexpected token ILLEGAL: el sandbox de Bruno
usa un parser que no los entiende, y el error no dice en qué línea.
Correrla en local sin mentirte
Sección titulada «Correrla en local sin mentirte»Dos condiciones, y el orden entre ellas importa.
La base se recrea, y el servidor se levanta DESPUÉS
Sección titulada «La base se recrea, y el servidor se levanta DESPUÉS»Recrear la base debajo de un servidor vivo deja su pool apuntando a una base que ya no existe. Los pedidos fallan por eso y no por el código.
Medido en un mismo commit, cambiando solo el orden: 42, 48 y 134 fallas en tres corridas. Con esos números casi reporto que había roto seis peticiones que nunca rompí.
# 1. base nueva 2. migrar y sembrar 3. recién ahí, el servidorLa configuración api-bruno de .claude/launch.json levanta en el 3003 contra
aquazaku_bruno, que no comparte con vitest. Antes se usaba la base de
tests, y eso obligaba a elegir: o se siembra —y el siguiente vitest run la
trunca— o se corre la suite y Bruno se queda sin admin.
Lo que da verde en tu máquina puede fallar en CI
Sección titulada «Lo que da verde en tu máquina puede fallar en CI»El caso 13-Reportes/02-El-extracto comparaba el día de la planta (UTC−5)
contra el día UTC del movimiento. Entre las 19:00 de Bogotá y la medianoche
son días distintos.
Fallaba cinco horas al día y pasaba diecinueve. Diez corridas locales cayeron fuera de esa ventana; CI la agarró a las 21:37 de la planta.
pnpm bruno:cobertura dice qué rutas no tienen ninguna petición. Puede marcar
100% y no ejercer la forma que acabás de agregar: cuenta rutas, no cuerpos.
