Auditoría de Travel — 2026-07-30
Alcance: toda la vertical Travel (backend + los 3 frontends + documentación) contra
SV_Standard_Backend.md,SV_Standard_Frontend.mdydocumentation_standard.md, más cuatro auditorías temáticas (clean code, complejidad computacional, Atomic Design, heurísticas UX + Refactoring UI) y una auditoría de usabilidad móvil a 375px.Método: 8 agentes auditores en dos tandas + una síntesis, y la auditoría móvil hecha midiendo en un navegador real (Playwright a 375px), no leyendo código. Cada hallazgo se verificó contra el código antes de actuar — varios reportes traían errores (ver "Sobre los reportes" al final).
Resultado: 5 PRs mergeados a
main(#202–#206). Lo que no se hizo queda abajo con números medidos y con la razón, no como impresión.
Veredicto
| Área | Veredicto | Detalle |
|---|---|---|
| SV_Standard_Backend | 🟡 → ✅ tras fixes | 13 hallazgos; corregidos los 2 críticos y la violación de capas. Deuda estructural declarada (billing/service.py en 917 líneas) |
| SV_Standard_Frontend | 🟡 | 25 hallazgos; corregidos los de usuario visible. Deuda de sistema de diseño declarada con números |
| documentation_standard | 🔴 → ✅ | La documentación mentía sobre el estado del producto: marketing-site se declaraba "pre-construcción, implementación no iniciada" estando live |
| Clean code | 🟡 → ✅ | 3 duplicaciones que el propio código admitía en sus docstrings |
| Complejidad computacional | 🟡 → ✅ | El calendario truncaba en 100 reservas sin ninguna señal |
| Atomic Design | 🟡 | Corregido el mínimo táctil en los átomos. Tokens en app/index.css queda como deuda |
| UX / accesibilidad | 🟡 | Contraste corregido solo en el dashboard; 155 usos medidos siguen fallando |
| Usabilidad móvil (375px) | 🔴 → ✅ | El panel no era operable con el pulgar, que es como trabaja el dueño |
Corregido
🔴 El "hoy" del servidor no era el del hotel — se rechazaban reservas reales (#205)
Reportado por el fundador, no por un agente. El contenedor corre en UTC (no hay TZ en
el Dockerfile ni en el compose) y el guard de "no se reserva en el pasado" usaba
date.today(), que lee la zona del proceso. En Colombia (UTC−5), desde las 19:00 y
hasta medianoche el servidor ya cree que es mañana: un walk-in a las 8pm para esa misma
noche daba InvalidReservationDatesError. Cinco horas cada día, justo la franja de los
walk-ins nocturnos.
Peor: el formulario del admin sí valida en hora local, así que aceptaba la fecha y el backend la tumbaba después con un error que no le decía nada al hotelero. Frontend y backend no estaban de acuerdo en qué día era hoy.
properties.timezone existía desde el principio, es editable desde el panel y no la leía
ningún código. Ahora la leen hoy_en() y fecha_local() (core/datetimes.py), que cubren
las 2 altas de reserva y los 4 correos de billing que corrían la fecha un día. Decisión
completa y alternativas descartadas en ADR-015.
🔴 Los emails de confirmación podían desaparecer a mitad de envío (#203)
property_profile/router.py guardaba las tasks en una lista local, con un comentario que
afirmaba que así se evitaba el GC. La lista moría en el return tres líneas más abajo.
Extraído a core/background_tasks.py — translation_service.py ya había resuelto el mismo
gotcha — con un guardarraíl que lee el fuente y falla si vuelve asyncio.create_task; hace
falta porque los emails son fire-and-forget y su desaparición es invisible para cualquier
test funcional.
🔴 El calendario mostraba 100 de N reservas sin ninguna señal (#202)
Habitaciones vendidas se veían libres. getAllInWindow ahora recorre todas las páginas con
un cinturón de 30 y avisa en pantalla si lo topa, no solo en un console.warn que nadie
mira.
🔴 El panel no era usable con el pulgar a 375px (#206)
- 58 elementos bajo el mínimo táctil de 44px, el peor el botón "ver detalle" de cada fila
de reservas en 32×32 siendo la acción de la fila. Corregido en los compartidos
(
Button,liquid-input,WorkspaceTabs) para que no vuelva. - 149 usos de texto entre 9px y 11px en 64 archivos, incluida la barra lateral en 9px.
- Los KPIs partían las palabras en tres líneas (
HABIT / ACION / ES). Segunda vez que se tocaba esa línea: la causa nunca fue la estrategia de corte sino la aritmética — 81px útiles contra ~102px que pide el label.
Cero scroll horizontal en las 5 pantallas: el trabajo responsive previo aguanta.
🟡 Otros corregidos
| Qué | PR |
|---|---|
El huésped pagaba un recargo por persona extra que nunca veía antes de reservar, y GET /availability mostraba "Agotado" habitaciones que sí lo admitían |
#198 |
| Compartir el link de un hotel no mostraba preview (SPA sin SSR): endpoint OG + ruteo de bots por User-Agent en nginx | #198 |
| "Confirmada" vs "En hotel" daban ΔE 0.3 en deuteranopia — indistinguibles para daltónicos; y el calendario pintaba en cyan hardcodeado desconectado de los tokens de la lista (misma reserva, dos colores según la vista) | #199 |
La lista de reservas mostraba el UUID crudo de la habitación en vez de room_number |
#202 |
| Documentación que mentía sobre el estado del producto; índice de ADRs congelado en 009 con 4 ADRs existentes sin listar (incluido el de RLS) | #200 |
Dos except Exception sin log; violación de capas en platform; VITE_PROPERTY_ID fail-closed |
#200 |
| Tres duplicaciones que el código admitía en sus docstrings: guard anti doble-cobro (×2), snapshot de auditoría (×3), normalización UTC (×5) | #204 |
| Contraste de los KPIs del dashboard: 1.93:1 claro / 2.69:1 oscuro → 6.82:1 / 7.13:1 | #203 |
| Tests inestables (dos veces, ver abajo) y sidebar de escritorio colapsable | #199, #206 |
Deuda declarada, con números
Contraste text-white/{20,30,40,50} — 155 usos en 49 archivos
Medido sobre la superficie real (glass-card compuesto sobre --theme-bg), no sobre el
color nominal:
| opacidad | claro | oscuro | usos |
|---|---|---|---|
/20 |
1.53:1 ❌ | 1.80:1 ❌ | 22 |
/30 |
1.96:1 ❌ | 2.63:1 ❌ | 42 |
/40 |
2.55:1 ❌ | 3.79:1 ❌ | 75 |
/50 |
3.41:1 ❌ | 5.36:1 ✅ | 15 |
Solo /50 en tema oscuro pasa AA. Se corrigió únicamente DashboardPage.tsx por ser la
pantalla más vista y la que se midió; el resto son 155 decisiones de qué token semántico
corresponde en cada caso, archivo por archivo.
Matiza lo que dijo la auditoría de Atomic Design sobre la inversión de
--color-white: es un buen mecanismo para fondos y bordes, pero no salva al texto, porque el problema es la opacidad, no el tono.
Resto
| Deuda | Números / razón |
|---|---|
billing/service.py sin partir |
917 líneas |
Tokens del design system en app/index.css |
sin capa propia de tokens |
public-hotel en Tailwind v3 sin ADR |
391 style={{}} inline; los otros dos frontends van en v4 |
client.ts parsea localStorage['nido-auth'] a mano |
debe usar useAuthStore.getState(). Toca auth: un parseo sutilmente distinto rompe la sesión sin que ningún test unitario lo note |
Login de marketing-site sin <Field> ni aria-invalid |
cobertura cero: el fix necesita el test E2E primero |
Paginación Page[T] en las listas crudas |
cambia la forma del JSON → PR coordinado front+back |
| Guard anti doble-cobro in-process | válido con 1 worker; multi-worker exige idempotency_keys DB-backed (Fase 1b). La unificación de #204 deja un sitio que migrar en vez de dos |
| UX sin auditar | faltan estados vacío y de error; el móvil ya quedó cubierto |
| Fases 6–7 del plan | XState en checkout, TanStack Query en marketing-site |
Decidido NO hacer
| Qué | Por qué |
|---|---|
Rate limit en payments |
El orquestador sale por una IP compartida → sería auto-DoS en la ruta del dinero |
page/size decorativos en /knowledge |
Invitarían a un loop de paginación infinito |
| Cabecera CSP | Tumba el widget de Wompi; necesita inventario de orígenes y prueba en staging. Se dejó un test guardarraíl invertido |
TZ=America/Bogota en el contenedor |
El arreglo de una línea: haría que el mismo código diera resultados distintos en local, CI y producción, y dejaría properties.timezone igual de muerta (ADR-015) |
| Storybook obligatorio | El fundador bajó la exigencia; queda anotado como deuda, no como regla que se incumple |
Sobre los reportes (y sobre los fixes)
Lo que más costó de esta auditoría no fueron los hallazgos sino verificarlos. Vale la pena dejarlo escrito porque se repitió con demasiada regularidad.
Los comentarios mentían, y eso era peor que el bug. Dos de los tres hallazgos críticos tenían encima un comentario que afirmaba justo lo contrario de lo que hacía el código: "esto evita que el GC recolecte la task" (no lo evitaba) y "asume server tz = tz de la propiedad" (el contenedor corre en UTC). Un comentario falso no es ruido: garantiza que nadie vaya a mirar ahí cuando el síntoma aparezca.
Los fakes describían modelos que no existen. Tres veces: un mock de habitación con
id: 'room-1' —donde .split('-').pop() devuelve "1" y parece plausible— que escondió el
bug del UUID; un fake de reserva con guest.full_name cuando el backend expone guest.name;
y tres fakes de Property sin timezone, campo que en el modelo real es nullable=False.
Un fake que miente sobre la forma del modelo convierte la suite en decorado.
Dos fixes míos introdujeron el problema siguiente. El fail-closed de VITE_PROPERTY_ID
quedó detrás de import.meta.env.PROD, que evalúa en el navegador y no durante el build:
el build pasaba verde y la app habría quedado en blanco en producción. Y el arreglo de los
tests inestables puso asyncUtilTimeout en el mismo valor que testTimeout, con lo que
un waitFor lento se come el presupuesto entero del test y tapa el diagnóstico de Testing
Library. Los dos se detectaron ejecutando de verdad, no leyendo.
Los arreglos interactúan. Subir el texto a 12px (piso de legibilidad) empeora el problema de los KPIs partidos, porque el label queda más ancho en la misma tarjeta. Por eso ese se arregló atacando el ancho —quitar ícono y versalitas en móvil— y no el tamaño: bajar la letra habría sido cambiar un problema por otro.
Los reportes traían errores. Un agente afirmó que POST .../alliance-code/revoke no tenía
ningún test (existe, test_revocacion_corta_el_descuento), y otro contó 23 dominios de
backend donde hay 20. Un hallazgo de auditoría es una hipótesis hasta que se verifica contra
el código.
Y un falso positivo que era el comportamiento correcto. El medidor móvil reportó
"Saltar al contenido principal" como un target de 1×1: es el skip-link sr-only, invisible
por diseño hasta recibir foco. Se excluyó del criterio en vez de "arreglarlo" —
reportarlo como defecto habría sido reportar que la accesibilidad funciona.
Verificación
Todo en local: el CI de GitHub Actions sigue bloqueado por facturación desde el 2026-07-21,
así que cada PR se mergeó con --admin tras correr los gates a mano.
- 632/632 backend (596 al empezar la sesión),
ruffymypylimpios sobre 188 archivos - 191/191 admin-panel,
tsc,lint,buildy paridad i18n (866 claves) - Contraste y tamaños medidos en el navegador real, en ambos temas y a 375px
- Regresiones simuladas para confirmar que los dos guardarraíles nuevos fallan con el
código viejo (el de
asyncio.create_tasky el de la fecha del correo)