Saltar a contenido

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.md y documentation_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 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.pytranslation_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), ruff y mypy limpios sobre 188 archivos
  • 191/191 admin-panel, tsc, lint, build y 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_task y el de la fecha del correo)