Ingresa tu nombre para poder marcar casos y comentar

Segunda entrega · QA manual

Manual de pruebas DateApp

Guía paso a paso para verificar, de punta a punta, los tres frontends (médico, paciente, web) contra el backend real. Cada caso está redactado a partir del código actual, no de lo que los artifacts de arquitectura sugieren — donde el código y el diseño original no coinciden, el caso lo dice explícitamente.

Casos totales 0 Marcados OK 0 Marcados con falla 0

Actualización — varios hallazgos ya se resolvieron

Antes de esta ronda se corrigieron en el código real: el hallazgo crítico de seguridad del middleware admin, la cancelación de solicitud de atención inmediata, el reporte de liquidación por médico, la validación de email/complejidad de contraseña en ambas apps móviles, el countdown de solicitudes entrantes y los restos de gris genérico en el sitio público. Cada caso afectado quedó marcado RESUELTO abajo con lo que cambió — vuelve a probar esos casos puntuales en vez de asumir el comportamiento original descrito antes.

Lo que no cambió, porque son decisiones de negocio ya resueltas, no bugs: sin reagendar por el cliente, sin botón "Rechazar" en atención inmediata, sin hard-delete de especialidades/clínicas, panel de Citas admin solo lectura, sin pasarela de pago, atajo dev de "olvidé mi contraseña" gateado por APP_ENV.

Descargas y accesos para probar

Instala ambas apps en dispositivos o emuladores Android separados y abre el panel web — los tres ya apuntan al mismo backend de prueba (Contabo).

App Médico (Provider)

APK release, com.dateapp.provider. Instalar directo en Android (activa "Instalar apps desconocidas" para el navegador que uses).

Descargar APK —

App Cliente (Paciente)

APK release, app de pacientes. Instalar directo en Android.

Descargar APK —

Panel Web / Sitio público

Admin, auditoría y sitio público de marketing.

Abrir sitio web ↗

Antes de empezar

Antes de tocar cualquier pantalla, deja el entorno en un estado conocido. El orden de ejecución sugerido es Médico → Cliente → Web, porque varios flujos del cliente (atención inmediata, videoconsulta) requieren un médico ya logueado y disponible para completarse.

Backend

  • alembic upgrade head con la base al día.
  • Seed de datos de desarrollo ejecutado (python -m src.infrastructure.db.seed), APP_ENV distinto de production.
  • Credenciales de prueba: no están fijadas en este manual a propósito — cámbianse entre entregas. Consulta dateapp-backend/src/infrastructure/db/seed.py el mismo día de la sesión de pruebas para confirmar usuarios CLIENT, PROVIDER (uno APPROVED y uno PENDING si el seed lo contempla), ADMIN y AUDITOR.
  • uvicorn src.main:app --reload corriendo y alcanzable desde los 3 clientes.

App Médico y App Cliente

  • Idealmente dos dispositivos o emuladores simultáneos (uno con cada app) — el flujo de atención inmediata (Parte 1 §7 / Parte 2 §4) solo se valida de verdad así.
  • npm run check:all limpio antes de empezar, para no confundir un bug de build con un bug funcional.
  • Permisos de cámara/micrófono del dispositivo aceptados de antemano si vas a probar video.

Sitio Web

  • Para el sitio público y el login: npm run dev es suficiente.
  • Para cualquier prueba del gate de autenticación (Parte 3 §10): usar el build real — npm run build y luego node .next/standalone/server.js. El middleware de Next puede comportarse distinto en dev que en standalone; no des por válido un gate probado solo con npm run dev.

Video (Zoom / LiveKit)

  • El proveedor de video es un switch global de configuración (VIDEO_PROVIDER=zoom|livekit), no por cita — confirma con qué valor está corriendo el backend antes de reportar "video no funciona", porque cambia qué SDK se prueba.
  • LiveKit fue verificado antes en dateapp-mobile-client con conexión y cámara real; Zoom sigue pendiente de credenciales reales para joinSession() con dos participantes — si estás probando Zoom, es territorio nuevo.

Cómo reportar lo que encuentres

Escribe tu nombre arriba antes de empezar — tus marcas y comentarios quedan guardados con tu nombre en el servidor, no se pisan con los de otros testers. Para cada hallazgo, usa el botón de comentario (💬) de la fila del caso e incluye:

  • ID del caso (columna izquierda de cada tabla, ej. PRV-MATCH-03) y app/pantalla.
  • Resultado esperado vs. obtenido, en una línea cada uno.
  • Severidad: crítico (bloquea el flujo o expone datos), alto (rompe una regla de negocio), medio (UX/validación), cosmético.
  • Captura o video corto si es posible — sobre todo para hallazgos de UI (súbelo aparte y referencia el link en el comentario).

Cada fila de las tablas tiene un botón de estado (pendiente) que puedes usar para marcar tu progreso mientras pruebas — cicla entre pendiente → OK → falla. Se guarda en el servidor asociado a tu nombre: puedes cerrar y volver, o cambiar de dispositivo, sin perder el avance. Debajo del botón vas a ver un contador con lo que ya marcaron otros testers en ese mismo caso.

Parte 1 de 3

App Médico (Provider)

Bundle com.dateapp.provider. Login segmentado con client_type=MOBILE_PROVIDER — el backend rechaza con 403 a cualquier rol distinto de PROVIDER antes de emitir tokens.

ACTUALIZADO (2026-09-05) — el drawer lateral y las pestañas inferiores usaban dos esquemas de navegación mezclados (algunos ítems con ícono, otros sin, "Disponibilidad" ocupando una pestaña principal pese a ser una configuración de baja frecuencia). Ahora: "Agenda" e "Historial" son accesos rápidos también desde el drawer (antes solo estaban en las pestañas inferiores), todos los ítems del drawer tienen ícono consistente, "Disponibilidad" se movió de pestaña principal a acceso secundario del drawer, y "Documentos" se oculta del menú una vez el médico está APPROVED (ver PRV-PERFIL-05). Al revisar cualquier caso de esta parte, confirma también que el menú se ve ordenado y sin íconos faltantes.

1. Autenticación

app/(auth)/*.tsx
IDPasosResultado esperadoRef.
PRV-AUTH-01 Login con credenciales válidas de un usuario PROVIDER APPROVED. Entra a la Agenda (tab principal). Tokens guardados en expo-secure-store. POST /auth/login
PRV-AUTH-02 Login con credenciales de un usuario CLIENT real (contraseña correcta) desde esta app. La app debe rechazar el acceso. Verifica qué mensaje aparece: el backend responde 403 antes de emitir tokens (mensaje "Esta cuenta no tiene acceso a esta aplicación"), así que el ErrorBanner debería mostrar ese texto — no el mensaje largo que el código de la app tiene preparado para un escenario de JWT con rol inesperado. Si ves el mensaje "largo" de la app en vez del texto corto del backend, anótalo (indica que el flujo no está pasando por donde se espera). POST /auth/login
PRV-AUTH-03 Login con contraseña incorrecta. 401, mensaje de credenciales inválidas en ErrorBanner. Botón de login no queda en loading infinito. POST /auth/login
PRV-AUTH-04 Login con campos vacíos. Botón "Iniciar sesión" deshabilitado — no llega a llamar al backend. login.tsx
PRV-AUTH-05 Registro ("Postular como profesional") con datos válidos, luego revisar en el panel admin que el usuario quedó PENDING. Auto-login tras registrar. Si se completó especialidad/descripción en el formulario, el perfil se guarda de una vez. El médico no puede recibir citas hasta que un ADMIN lo apruebe (ver Parte 3 §7). POST /auth/register
PRV-AUTH-06 "Olvidé mi contraseña" con un email real, luego revisar si la app navega directo a "Restablecer" con un token precargado. Esto es un atajo de desarrollo (el backend expone dev_token fuera de production). Confirma que en un build apuntando a un backend con APP_ENV=production esto NO ocurre y el usuario debe ir a su correo. POST /auth/forgot-password
PRV-AUTH-07 RESUELTO — En "Restablecer contraseña", usar una clave de 8 caracteres sin mayúscula ni dígito (ej. aaaaaaaa). El formulario ahora valida en el cliente (no solo el backend): error inline "Debe tener mínimo 8 caracteres, con mayúscula, minúscula y dígito" y botón deshabilitado hasta corregir. Confirma que el error aparece y que una clave válida (ej. Abcd1234) sí habilita el botón. src/shared/utils/validators.ts

2. Disponibilidad y bloqueos

availability.tsx · vacations.tsx

Ambas pantallas están bloqueadas (LockedScreen) si el médico no está APPROVED — prueba primero con una cuenta aprobada.

IDPasosResultado esperadoRef.
PRV-DISP-01 REDISEÑADO (2026-09-05) — Ya no es un toggle por día: tocar el primer y el último día que atiendes (rango contiguo, ej. Lunes→Viernes, con los días intermedios resaltados), definir un único horario diario (Desde/Hasta con selector de hora) y, si aplica, activar el switch de descanso (Desde/Hasta del descanso). Guardar. Alert.alert de confirmación con el resumen exacto (ej. "Lunes a Viernes, de 09:00 a 17:00 con descanso de 12:00 a 13:00") antes de guardar (reemplaza el horario completo). Al reabrir la pantalla — incluso tras reiniciar la app — el rango de días, horario y descanso se reconstruyen igual a como se guardaron. PUT /provider/availability
PRV-DISP-02 Reducir el rango de días respecto a lo ya guardado (ej. de Lunes-Viernes a solo Lunes-Martes) y guardar. El texto del Alert.alert de confirmación cambia para advertir que estás reduciendo tu cobertura (menos días que tu configuración actual). availability.tsx
PRV-DISP-03 RESUELTO — Antes, el horario se escribía como texto libre sin selector ni validación de formato, y guardar producía un 422 del backend (formato de hora corrupto por un bug real de padEnd) que crasheaba la app entera ("Object is not valid as a React child"). Ahora Desde/Hasta (horario y descanso) son un TimePicker modal (columnas de hora/minuto) — ya no se puede escribir un valor con formato inválido. Confirma que guardar cualquier combinación de horas funciona y que un error real del backend (ej. descanso fuera del horario diario) se muestra en un ErrorBanner legible, sin crashear la app. TimePicker.tsx · errors.ts
PRV-DISP-04 Ir a Vacaciones, crear un bloqueo tipo "Vacaciones" con fecha desde > fecha hasta. Error inline de validación (no llega a llamar al backend) — el formulario valida fromDate <= toDate antes de enviar. POST /provider/availability/block
PRV-DISP-05 Crear un bloqueo puntual (tipo "Manual") sobre un rango donde ya hay una cita CONFIRMED, luego intentar como paciente reservar en ese mismo horario. El bloqueo no debería cancelar la cita existente; al intentar agendar una cita nueva en ese horario desde la app cliente, el backend debe rechazarla por chocar con el ProviderBlockedSlot. ProviderBlockedSlot

3. Agenda y gestión de citas

(tabs)/index.tsx · appointments/[id].tsx
IDPasosResultado esperadoRef.
PRV-AGE-01 Con una cita PENDING creada desde la app cliente, abrir su detalle y confirmarla. Pasa a CONFIRMED. Se crea la reunión de video (Zoom/LiveKit) y queda guardada — verificable porque luego "Entrar a la sesión" funciona. PATCH /appointments/{id}/confirm
PRV-AGE-02 Cancelar una cita CONFIRMED cuya hora es dentro de las próximas 24h. Alert.alert de confirmación con advertencia de posible penalización. Tras cancelar, si el backend marca lateCancellation: true, aparece un segundo Alert.alert informativo. Banner naranja visible en el detalle mientras la cita esté en esa ventana. PATCH /appointments/{id}/cancel
PRV-AGE-03 Cancelar una cita CONFIRMED con más de 24h de anticipación. Se cancela sin el segundo aviso de penalización tardía. PATCH /appointments/{id}/cancel
PRV-AGE-04 Completar una cita CONFIRMED/IN_PROGRESS: llenar notas, diagnóstico, recomendaciones y una fecha de seguimiento sugerida con formato inválido (ej. "mañana"). Error inline en el campo de fecha (regex AAAA-MM-DD), botón de guardar deshabilitado hasta corregir. PATCH /appointments/{id}/complete
PRV-AGE-05 Completar la misma cita con una fecha de seguimiento válida. Cita pasa a COMPLETED. Aparecen ahora las acciones "Programar seguimiento gratuito" y "Referir a clínica". PATCH /appointments/{id}/complete
PRV-AGE-06 Subir un adjunto (imagen o PDF) desde el detalle de una cita. Aparece en la lista de adjuntos, descargable. Si se intenta subir un tipo no permitido, error va a ErrorBanner (no a un Alert). POST /appointments/{id}/attachments
PRV-AGE-07 Salir de la pantalla de detalle y volver a entrar sin recargar la app, tras cambiar el estado de la cita desde otra app (ej. el paciente la cancela). El detalle se refresca solo al recibir foco (useFocusEffect) — el estado mostrado debe reflejar el cambio sin necesidad de reiniciar la app. appointments/[id].tsx
PRV-AGE-08 RESUELTO — En la pantalla principal "Agenda", arrastrar hacia abajo desde arriba de todo (incluyendo sobre la sección "Hoy", no solo sobre "Próximas"). Antes, "Hoy" no scrolleaba ni tenía refresh propio — solo una sublista escondida en "Próximas" lo tenía. Ahora toda la pantalla es un único scroll con pull-to-refresh real, sin depender solo del push para actualizar. (tabs)/index.tsx

4. Reagendar (exclusivo del médico)

reschedule/[id].tsx
IDPasosResultado esperadoRef.
PRV-REAG-01 Abrir una cita CONFIRMED y reagendarla a un slot libre distinto. Conserva el mismo id de cita (no crea una nueva). Sin penalización — el copy de la pantalla lo confirma. Ambos lados (paciente y médico) deben ver la nueva fecha. PATCH /appointments/{id}/reschedule
PRV-REAG-02 Intentar reagendar una cita PENDING (sin confirmar todavía). La opción "Reagendar" no debe aparecer en el detalle para citas PENDING — solo para CONFIRMED. appointments/[id].tsx
PRV-REAG-03 Elegir un slot que ya está ocupado por otra cita del mismo médico. 400 del backend — el slot no aparece como libre en el selector, o si se fuerza, se rechaza. PATCH /appointments/{id}/reschedule
PRV-REAG-04 Como paciente (en la app cliente), buscar cualquier forma de reagendar una cita propia. No debe existir ningún botón ni pantalla de reagendamiento en la app cliente — es una regla de negocio confirmada (2026-08-18): solo el médico puede iniciar un reagendamiento. Confirma que el paciente solo puede cancelar, nunca reagendar. regla de negocio

5. Seguimiento gratuito

follow-up/[id].tsx
IDPasosResultado esperadoRef.
PRV-SEG-01 Desde una cita COMPLETED, programar un seguimiento gratuito eligiendo fecha y hora. Se crea una cita nueva (distinto id del original), duración fija de 30 min, is_free_follow_up=true. POST /appointments/{id}/schedule-follow-up
PRV-SEG-02 Verificar el cupo mensual de citas del paciente (plan de membresía) antes y después de crear el seguimiento. El seguimiento gratuito no descuenta del límite mensual del plan del paciente — el conteo de citas usadas del mes debe quedar igual. enforce_appointment_limit (no aplica)

6. Videoconsulta

session/[appointmentId].tsx

Requiere una cita CONFIRMED o IN_PROGRESS y, para probar el flujo completo, la app cliente abierta en otro dispositivo con la misma cita.

IDPasosResultado esperadoRef.
PRV-VID-01 Entrar a la sesión de una cita modality=VIDEO antes de que el paciente entre. Chat por WebSocket conecta (estado "conectando" → "conectado"). Overlay "Esperando a que se una la otra persona…" hasta que el paciente entre a la videollamada. wss:///ws/chat/{id}
PRV-VID-02 Presionar "Unirse a la videollamada" y aceptar permisos de cámara/micrófono. Se une como host (el backend siempre asigna role_type=1 al proveedor). Video local y controles de mic/cámara visibles. POST /appointments/{id}/video-token
PRV-VID-03 Con el paciente también conectado, verificar audio y video en ambos sentidos. Sesión estable, sin caídas, mismo session_name (determinístico por cita) para que ambos entren a la misma sala. session_name=appt-{id}
PRV-VID-04 Entrar a la sesión de una cita modality=VOICE. El botón dice "Llamada de voz" (ícono de teléfono, no cámara) — sin video, solo audio. session/[appointmentId].tsx
PRV-VID-05 Entrar a la sesión de una cita modality=CHAT. No debe aparecer ningún botón de llamada — solo el chat de texto. session/[appointmentId].tsx
PRV-VID-06 Cortar la conexión a internet a mitad de la videollamada y luego restaurarla. El chat WebSocket debe reintentar la reconexión automáticamente (backoff exponencial, hasta 5 intentos) y mostrar el estado "reconectando"/"desconectado" con botón "Reintentar" manual si se agotan los intentos. useChatSocket
PRV-VID-07 Provocar que la obtención del token de video falle (ej. backend caído momentáneamente) y volver a intentar. La app no debe crashear — debe degradarse a un mensaje ("La llamada no está disponible por ahora") en vez de tumbar la pantalla completa. setVideoUnavailable

7. Atención inmediata (motor de matching)

IncomingRequestsSection · (tabs)/index.tsx

Pendiente formal de TASK-032

El backend (concurrencia con 50 rondas reales) y el resto del flujo ya están verificados, pero el smoke test visual en 2 dispositivos simultáneos nunca se ejecutó — no había emulador activo en la última sesión de desarrollo. Esta sección es, en la práctica, la primera vez que este flujo se prueba de punta a punta con interfaces reales. Trátalo con prioridad alta.

IDPasosResultado esperadoRef.
PRV-MATCH-01 Activar el switch "Disponible ahora" en la Agenda con una cuenta APPROVED. Cambio optimista inmediato en la UI; si el backend falla, revierte el switch y muestra error en un ErrorBanner separado (nunca un Alert). PATCH /provider/available-now
PRV-MATCH-02 Con el switch activo, desde el otro dispositivo crear una solicitud de atención inmediata como paciente para la misma especialidad. En <5s (intervalo de polling) aparece una tarjeta en la Agenda del médico con modalidad, motivo y un countdown regresivo hasta que expire. GET /appointment-requests/incoming
PRV-MATCH-03 Presionar "Aceptar" en la tarjeta. Navega directo al detalle de la cita recién creada (ya CONFIRMED, con reunión de video creada). El switch "Disponible ahora" se apaga solo. Del lado del paciente, la pantalla de espera debe detectar el match y redirigir automáticamente. POST /appointment-requests/{id}/accept
PRV-MATCH-04 Con dos médicos disponibles para la misma especialidad, hacer que ambos intenten aceptar la misma solicitud casi al mismo tiempo (dos dispositivos, dos cuentas médico). Solo uno de los dos debe ganar la cita. El otro recibe un error (409, "ya la tomó otro médico") en un ErrorBanner local de la tarjeta, y la tarjeta desaparece o se refresca sin dejarlo reintentar sobre la misma solicitud. try_match (atómico)
PRV-MATCH-05 Dejar expirar una solicitud sin aceptarla (esperar el TTL, por defecto 5 minutos, o forzar que corra el job de Celery beat que expira solicitudes). La tarjeta debe desaparecer de la lista de "incoming" al expirar. No hay forma de forzar esto vía API — solo ocurre por el job periódico, así que la prueba requiere esperar el TTL real o coordinar con quien opera Celery beat. expire_due_appointment_requests
PRV-MATCH-06 Buscar un botón "Rechazar" en la tarjeta de solicitud entrante. No existe. Solo hay "Aceptar" — no hay endpoint ni UI de rechazo explícito. La única forma de "rechazar" es ignorar la tarjeta hasta que expire. Esto es consistente con lo implementado, aunque la descripción general de la tarea original mencionaba "aceptar/rechazar" — no lo reportes como bug nuevo, ya está identificado como discrepancia entre el alcance descrito y lo cerrado. appointmentRequest.api.ts
PRV-MATCH-07 Desactivar el switch "Disponible ahora" mientras hay solicitudes visibles en pantalla. El polling debe detenerse y las tarjetas dejar de mostrarse — el médico no debería poder seguir aceptando solicitudes con el switch apagado. (tabs)/index.tsx
PRV-MATCH-08 RESUELTO — Observar el número del countdown en una tarjeta durante 10-15 segundos sin tocar nada. Ahora baja de 1 en 1 cada segundo (se agregó un ticker propio de 1s) — ya no depende de que vuelva el poll de 5s. Confirma que se ve fluido, no a saltos. secondsUntil

8. Notificaciones push

usePushRegistration · notifications.tsx
IDPasosResultado esperadoRef.
PRV-NOTIF-01 Primer login en un dispositivo físico (push no funciona en la mayoría de emuladores). Se pide permiso de notificaciones. Si se concede, el token se registra en el backend — confirmable recibiendo una notificación real más adelante (ej. una solicitud de atención inmediata con el switch activo). POST /user/push-token
PRV-NOTIF-02 Negar el permiso de notificaciones. La app sigue funcionando con normalidad — sin banner de error, sin bloqueo (el registro falla en silencio a propósito). usePushRegistration.ts
PRV-NOTIF-03 Ir a Notificaciones y apagar el switch de "Push". Update optimista inmediato; si falla la llamada, revierte y muestra ErrorBanner. PUT /user/notification-preferences

9. Perfil y documentos

profile.tsx · documents.tsx
IDPasosResultado esperadoRef.
PRV-PERFIL-01 ACTUALIZADO — Editar nombre, especialidad (ahora un SpecialtyPicker contra el catálogo real, ya no texto libre), descripción y ciudad sin cambiar nada más; presionar Guardar sin haber modificado un campo. Botón "Guardar" deshabilitado si no hay cambios (!dirty). Al elegir una especialidad del catálogo y guardar, confirma en el perfil público (app cliente) que aparece esa misma especialidad y que el médico ahora sí es encontrable filtrando por ella en la búsqueda por especialidad (ver CLI-BUSC-07). PUT /provider/profile
PRV-PERFIL-02 Activar el toggle "Atiendes presencial" y elegir una ciudad. Confirma que la modalidad presencial nunca se activa por defecto — debe ser una decisión explícita del médico (regla RULE-009). Guardar y verificar que ahora aparece IN_PERSON como opción al agendar desde la app cliente, solo si el paciente comparte esa ciudad. PUT /provider/profile
PRV-PERFIL-03 Subir un documento de postulación (ej. tipo "Licencia") en formato PDF mientras la cuenta sigue PENDING. Aparece en la lista con ícono de "pendiente de verificar" (reloj) hasta que un ADMIN lo marque como verificado (ícono cambia a verificado). POST /provider/documents
PRV-PERFIL-05 NUEVO — Con una cuenta ya APPROVED, entrar a "Documentos" (desde un deep link directo, ya que el ítem se saca del menú una vez aprobado). La pantalla ya no muestra la lista de documentos ni permite subir más — muestra un EmptyState explicando que esa información ahora solo es visible para el admin desde el portal. documents.tsx
PRV-PERFIL-04 ACTUALIZADO — Buscar el botón "Cerrar sesión" tanto en el drawer lateral como dentro de la pantalla de Perfil. Ya no hay dos botones de logout independientes — solo existe el del drawer (global). Alert.alert de confirmación antes de cerrar sesión (acción irreversible, uso correcto de Alert). logout

10. Métricas

(tabs)/metrics.tsx
IDPasosResultado esperadoRef.
PRV-MET-01 Abrir Métricas tras haber completado al menos una cita con reseña. Muestra calificación promedio, total de citas, tasa de finalización y desglose por estado. Los números deben coincidir con el historial real de citas del médico. GET /provider/metrics
PRV-MET-02 Buscar cualquier cifra de dinero, honorarios o "valor a liquidar" en esta pantalla (app del médico). Sigue sin existir aquí a propósito — esta pantalla es solo desempeño clínico. El reporte de liquidación (ya RESUELTO) vive del lado admin, no en la app del médico — confirma que efectivamente no se agregó nada financiero en esta pantalla de autoservicio. ProviderMetricsResponse

11. Referidos a clínica

referrals/index.tsx

ACTUALIZADO (2026-09-05) — "Referidos enviados" y "Referidos recibidos" eran dos pantallas de menú separadas; ahora es una sola pantalla (referrals/index.tsx) con un resumen de estadísticas (conteo enviados/recibidos) arriba y un selector de tabs para cambiar de dirección, ya que es un solo sistema de referidos clínicos, no dos.

IDPasosResultado esperadoRef.
PRV-REF-01 Desde una cita COMPLETED, crear un referido a una clínica con una nota. El referido incluye el resumen clínico de la cita origen (motivo, diagnóstico, recomendaciones) al verlo luego. Aparece en el tab "Enviados" de la pantalla unificada de Referidos, y el contador de la tarjeta "Enviados" sube en 1. POST /referrals
PRV-REF-02 Cancelar un referido propio activo. Alert.alert de confirmación antes de cancelar (correcto, es irreversible). Pasa a estado cancelado. PATCH /referrals/{id}/cancel
PRV-REF-03 Con una cuenta médico que sí pertenece a una clínica, cambiar al tab "Recibidos" dentro de la misma pantalla de Referidos. Aparece el referido creado por otro médico hacia esa clínica. Con una cuenta sin clínica asignada, la lista debe estar vacía por diseño (no es un error) — el subtítulo lo aclara. GET /referrals/received

Parte 2 de 3

App Cliente (Paciente)

Login segmentado con client_type=MOBILE_CLIENT. A diferencia de la app médico, esta app no valida el rol en el cliente — depende 100% del 403 que debe devolver el backend.

1. Registro y login

app/(auth)/*.tsx
IDPasosResultado esperadoRef.
CLI-AUTH-01 Registrar un paciente nuevo con nombre, email y contraseña de 8+ caracteres. Auto-login tras registrar. El plan de membresía Free se asigna automáticamente sin ningún paso extra. POST /auth/register
CLI-AUTH-02 RESUELTO (parcial) — Registrar con un email con formato inválido (ej. sin-arroba). El formulario ahora valida formato de email en el cliente — error inline "Email inválido" y botón deshabilitado. Sigue sin pedir confirmación de contraseña ni exponer el campo teléfono (eso no cambió, es una decisión de alcance menor, no un bug bloqueante). src/shared/utils/validators.ts
CLI-AUTH-03 Login con credenciales válidas de una cuenta PROVIDER o ADMIN real desde esta app. Como no hay filtro client-side, esto depende enteramente del backend devolviendo 403 (client_type=MOBILE_CLIENT vs. rol real). Es el caso más importante de seguridad de esta sección — si algún rol no-CLIENT logra entrar, es crítico. POST /auth/login
CLI-AUTH-04 Login con campos vacíos. Botón deshabilitado solo si email o contraseña están vacíos — sin validar formato en login (a diferencia de registro). login.tsx
CLI-AUTH-05 RESUELTO — En "Restablecer contraseña", enviar una clave débil de 8 caracteres (sin mayúscula/dígito). Igual que PRV-AUTH-07: ahora hay validación de complejidad en el cliente, con error inline y botón deshabilitado hasta que la clave cumpla mayúscula+minúscula+dígito. src/shared/utils/validators.ts

2. Búsqueda de médicos

(tabs)/index.tsx · specialties/[id].tsx · providers/[id].tsx
IDPasosResultado esperadoRef.
CLI-BUSC-01 Buscar por especialidad (modo por defecto) y entrar al listado de médicos de esa especialidad. Lista de proveedores activos y aprobados para esa especialidad. GET /specialties, GET /providers
CLI-BUSC-02 Cambiar a modo "Por nombre", filtrar por modalidad = "Presencial" sin elegir ciudad. No dispara la búsqueda — muestra un estado vacío pidiendo elegir ciudad primero (regla RULE-009: presencial requiere ciudad compartida). index.tsx
CLI-BUSC-03 Filtrar con "Con cupo hoy" activado en resultados por especialidad. Solo devuelve médicos con disponibilidad real hoy (available_today), distinto del filtro general de "perfil marcado disponible". GET /providers?available_today
CLI-BUSC-04 Abrir el perfil de un médico que está de vacaciones (bloqueo tipo VACATION creado en Parte 1 §2). Banner visible informando que está de vacaciones, con fecha estimada de regreso. GET /providers/{id}/vacation
CLI-BUSC-05 Abrir el perfil de un médico marcado como no disponible (isAvailable=false). Botón "Reservar una cita" deshabilitado. providers/[id].tsx
CLI-BUSC-06 Al entrar al banner "Atención inmediata" en Home teniendo ya una solicitud SEARCHING activa (creada previamente y sin cerrar). Redirige directo a la pantalla de espera existente en vez de dejar crear una segunda solicitud. GET /appointment-requests/active
CLI-BUSC-07 RESUELTO (2026-09-05) — Entrar a resultados por especialidad con varios filtros configurados (fecha, ciudad, modalidad) y verificar que se puede scrollear para ver tanto los filtros completos como los resultados. Antes, el contenedor de filtros no tenía scroll propio y, con suficientes filtros, se comía casi toda la pantalla dejando los resultados inalcanzables ("90% filtros"). Ahora filtros y resultados comparten un único scroll real (filtros como ListHeaderComponent del mismo FlatList). specialties/[id].tsx
CLI-BUSC-08 NUEVO — Filtrar por una especialidad real del catálogo (ej. "Cardiología") y confirmar que aparece un médico cuya especialidad fue asignada con el SpecialtyPicker (ver PRV-PERFIL-01), no con el campo de texto libre legado. El médico aparece en los resultados. Si un médico tiene la especialidad mal escrita o distinta al nombre exacto del catálogo (dato legado, texto libre), no va a aparecer aquí aunque sí aparezca al buscarlo por nombre — no es un bug del filtro, es una consecuencia de tener datos de especialidad no normalizados contra el catálogo. GET /providers?specialty_id

3. Agendar cita programada

booking/[providerId].tsx
IDPasosResultado esperadoRef.
CLI-AGEND-01 Elegir fecha, horario libre, duración y modalidad; confirmar reserva. Cita creada en PENDING, sin cobro. Debe aparecer un aviso fijo de que el pago en línea "estará disponible próximamente" — no debe existir ningún paso de pago/checkout en el flujo. POST /appointments
CLI-AGEND-02 Agendar una cita "para" un familiar registrado (ver Parte 2 §8). La cita se crea asociada al familiar, visible como "Para: {nombre}" en el listado de citas. POST /appointments (family_member_id)
CLI-AGEND-03 Con un plan Free, agendar citas hasta alcanzar el límite mensual y luego intentar una más. El backend debe rechazar la cita adicional una vez alcanzado el límite del plan activo (enforce_appointment_limit). POST /appointments (400)
CLI-AGEND-04 Elegir modalidad presencial (IN_PERSON) con un médico que la ofrece pero en una ciudad distinta a la del paciente. No debe permitirse — presencial exige que cliente y proveedor compartan location_id (RULE-009). POST /appointments (400)
CLI-AGEND-05 NUEVO (2026-09-05) — Entrar a reservar con un médico que no tiene ningún horario configurado todavía. Mensaje "Este proveedor todavía no configuró su horario" — distinto del mensaje de "sin cupo ese día". booking/[providerId].tsx
CLI-AGEND-06 NUEVO — Con un médico que sí tiene horario (ej. Lunes a Viernes), elegir en el selector de fecha un día que esté fuera de su rango (ej. sábado o domingo). Mensaje "Este proveedor no atiende este día (nombrando el día)" — distinto de "sin horario configurado" y de "sin cupo ese día". Cambiar a un día dentro del rango (ej. el lunes siguiente) sí debe mostrar horarios libres reales. booking/[providerId].tsx
CLI-AGEND-07 NUEVO — Con un médico con horario configurado ese día, reservar todos los cupos libres de ese día (o simular que ya están todos ocupados) y volver a entrar a esa fecha. Mensaje "Sin cupos libres ese día" (agenda llena) — el tercer mensaje distinto, para cuando el médico sí atiende ese día pero ya no queda espacio. booking/[providerId].tsx

4. Atención inmediata ("para hoy")

immediate/new.tsx · immediate/[id].tsx

Complementa Parte 1 §7 — para el caso feliz, necesitas un médico con "Disponible ahora" activo en el otro dispositivo.

IDPasosResultado esperadoRef.
CLI-INMED-01 Crear una solicitud eligiendo especialidad, modalidad y motivo. Revisar el contenido antes de enviar. La card de "señales de alarma" (signos que ameritan ir a urgencias) está siempre visible antes del botón de enviar, no es un paso separado ni un checkbox. GET /alarm-signs
CLI-INMED-02 Enviar la solicitud y observar la pantalla de espera. Countdown regresivo hasta el TTL, polling cada 3s. Si un médico acepta (Parte 1 §7), redirige automáticamente al detalle de la cita ya CONFIRMED. GET /appointment-requests/{id}
CLI-INMED-03 Con una solicitud ya activa (SEARCHING), intentar crear una segunda desde otro punto de entrada (doble tap en enviar, o volver atrás y reenviar). 409 del backend; la app debe manejarlo específicamente re-consultando la solicitud activa y redirigiendo ahí, no solo mostrando un error genérico. POST /appointment-requests (409)
CLI-INMED-04 RESUELTO — En la pantalla de espera, presionar "Cancelar búsqueda". Ahora existe: botón "Cancelar búsqueda" visible mientras status=SEARCHING, con Alert.alert de confirmación (acción irreversible). Al confirmar, llama PATCH /appointment-requests/{id}/cancel y regresa al formulario de nueva solicitud. Verifica también que, si el médico llega a aceptarla justo antes de que confirmes la cancelación, el backend responde 409 ("ya no se puede cancelar") en vez de cancelar una cita ya confirmada. PATCH /appointment-requests/{id}/cancel
CLI-INMED-05 Dejar expirar la solicitud sin que ningún médico la acepte. Pantalla pasa a estado "expiró" con botón "Intentar de nuevo" que regresa al formulario de nueva solicitud. ImmediateRequestStatus.EXPIRED
CLI-INMED-06 Elegir modalidad "A domicilio" (HOME) sin elegir ciudad. Igual que presencial: exige ciudad antes de permitir enviar (RULE-009 aplica también a HOME, no solo IN_PERSON). immediate/new.tsx

5. Ver / cancelar citas

(tabs)/appointments.tsx · appointments/[id].tsx
IDPasosResultado esperadoRef.
CLI-CITAS-01 Abrir el listado de citas con al menos una PENDING/CONFIRMED próxima y varias pasadas. La próxima cita aparece destacada arriba; el resto agrupado por médico. Pull-to-refresh fuerza la actualización (la caché normal dura 60s). GET /appointments
CLI-CITAS-02 Exportar historial a PDF desde el listado. Descarga y abre el diálogo de compartir del sistema. Si falla, error propio en su ErrorBanner, sin mezclar con otros errores de la pantalla. GET /client/appointments/export
CLI-CITAS-03 Cancelar una cita CONFIRMED dentro de las 24h previas. Alert.alert de confirmación con advertencia de penalización; tras cancelar, segundo Alert.alert si lateCancellation=true. Banner naranja visible mientras esté en esa ventana. PATCH /appointments/{id}/cancel
CLI-CITAS-04 Buscar cualquier botón de reagendar en el detalle de una cita CONFIRMED propia. No debe existir — confirmado ausente en todo el código de esta app, consistente con la regla de negocio (solo el médico reagenda). Ver también PRV-REAG-04. regla de negocio
CLI-CITAS-05 Sobre una cita COMPLETED sin reseña, dejar una calificación (1-5) y comentario. Se guarda y el formulario de reseña deja de aparecer (ya reseñada). POST /appointments/{id}/review
CLI-CITAS-06 Subir un adjunto de tipo no permitido (ej. un .docx). Error de tipo va a ErrorBanner, no a un Alert — el Alert en esta pantalla se reserva solo para el menú "Galería/Documento". ALLOWED_ATTACHMENT_TYPES

6. Videoconsulta

session/[appointmentId].tsx
IDPasosResultado esperadoRef.
CLI-VID-01 Entrar a la sesión de una cita CONFIRMED con el médico ya conectado (ver PRV-VID-01). Al unirse a la videollamada, entra como participante (role_type=0), no como host. POST /appointments/{id}/video-token
CLI-VID-02 Repetir PRV-VID-04 a PRV-VID-07 (voz, chat, reconexión, degradación) desde el lado del paciente. Mismo comportamiento esperado que en la app médico — el componente de llamada (CallShell) es compartido entre ambas apps. CallShell.tsx

7. Notificaciones push

notifications.tsx
IDPasosResultado esperadoRef.
CLI-NOTIF-01 Verificar que llega una notificación push cuando el médico confirma o cancela una cita. Notificación recibida en el dispositivo del paciente (requiere dispositivo físico y permiso concedido). Expo Push Service
CLI-NOTIF-02 Apagar el switch "Email" en preferencias de notificación. Update optimista con revert en caso de error, igual que en la app médico. PUT /user/notification-preferences

8. Perfil y familiares

profile.tsx · family-members/*.tsx
IDPasosResultado esperadoRef.
CLI-PERFIL-01 Editar teléfono y ciudad en el perfil; guardar sin cambios primero. Botón "Guardar" deshabilitado sin cambios reales, igual que en la app médico. PUT /client/profile
CLI-PERFIL-02 Agregar un familiar nuevo con nombre y parentesco; luego editarlo y finalmente eliminarlo. Crear/editar sin fricción; eliminar pide confirmación vía Alert.alert (correcto, es irreversible). POST/PUT/DELETE /client/family-members
CLI-PERFIL-03 Intentar crear un familiar sin nombre. Validación inline — no permite guardar con el campo vacío. family-members/form.tsx

9. Membresía

(tabs)/membership.tsx
IDPasosResultado esperadoRef.
CLI-MEMB-01 Cambiar del plan Free a un plan superior. Alert.alert de confirmación ("aplica de inmediato"). El cambio es inmediato, sin prorrateo — el plan anterior queda CANCELLED (no EXPIRED), uno nuevo con expires_at a 30 días. POST /user/membership/change-plan
CLI-MEMB-02 Con el uso del mes ya por encima del límite del plan destino, hacer downgrade a un plan más restrictivo. El cambio se permite igual (downgrade sin restricción) — el límite nuevo solo bloqueará citas futuras, no revierte nada retroactivo. Confirmar intentando agendar una cita nueva justo después: debe rechazarla si ya se excede el nuevo límite. enforce_appointment_limit
CLI-MEMB-03 Buscar cualquier paso de pago dentro del cambio de plan. No existe — confirmado por lectura exhaustiva del código, no hay ningún archivo ni endpoint de pago/checkout en toda la app cliente. membership.tsx

10. Artículos médicos

articles/*.tsx
IDPasosResultado esperadoRef.
CLI-ART-01 Abrir el listado de artículos y entrar al detalle de uno. Contenido informativo, sin ninguna acción de escritura — es solo lectura. GET /news, GET /news/{slug}

Parte 3 de 3

Sitio Web

Un único proyecto Next.js sirve el sitio público de marketing y el panel admin (ADMIN/AUDITOR) con client_type=WEB. Todo lo del panel admin que sea mutación debe probarse dos veces: una como ADMIN, otra como AUDITOR (que debe quedar en solo lectura).

1. Landing y contenido público

src/app/*
IDPasosResultado esperadoRef.
PUB-LAND-01 Recorrer landing, Especialidades, Médicos, Noticias y Tarifas sin sesión iniciada. RESUELTO — fíjate en particular en el fondo oscuro de "Sobre nosotros", el Hero, la tarjeta de testimonio destacada y los botones de descarga en register/success. Todo carga público, sin pedir login. Estados vacíos con copy correcto si algún catálogo no tiene datos. Los 4 puntos con gris genérico (bg-slate-900/950/800) que antes desentonaban con la marca ahora usan brand-chrome/primary — confirma que ya no se ve un "negro" distinto al navy de marca en ninguno de esos 4 lugares. GET /specialties, /news, /membership-plans
PUB-LAND-02 Entrar al perfil público de un médico desde el directorio. Muestra reseñas y datos del médico, pero el CTA para agendar dice explícitamente "Agenda desde la app" (App Store/Google Play) — no hay agendamiento desde la web. medicos/[id]/page.tsx
PUB-LAND-03 Filtrar el directorio de médicos por modalidad presencial sin elegir ciudad. Misma regla RULE-009 que en las apps móviles — pide ciudad antes de buscar presencial. medicos/page.tsx
PUB-LAND-04 Comparar los 2 planes visibles en Tarifas contra los planes públicos reales que devuelve el backend. Deben coincidir (Free + Premium). El plan "Clinic" del seed es is_public=false y no debe aparecer en ningún listado público. Esto se re-verifica en cada entrega porque es sincronización manual entre repos. FALLBACK_PLANS / seed.py
PUB-LAND-05 Entrar a Términos, Privacidad, Cookies, Ética y Estados financieros. Son placeholders honestos a propósito (sin texto legal real todavía, con mailto de contacto) — no es un bug, es un gap documentado. "Ética" y "Estados financieros" no están en el menú/footer, solo enlazadas desde la sección "Sobre nosotros" de la landing — confirmar que igual son alcanzables desde ahí. LegalPlaceholder.tsx

2. Login y registro (web)

login/page.tsx · register/*
IDPasosResultado esperadoRef.
PUB-REG-01 Login con una cuenta ADMIN válida. Entra al dashboard admin. POST /auth/login (client_type=WEB)
PUB-REG-02 Login con credenciales válidas de una cuenta CLIENT o PROVIDER real. Rechazado con 403. El texto mostrado en pantalla debe ser el del backend ("Esta cuenta no tiene acceso a esta aplicación") — el mensaje largo hardcodeado en el frontend para "JWT con rol inesperado" es código que en teoría nunca debería dispararse hoy, porque el backend bloquea antes de emitir el token. Si ves ese mensaje largo en vez del corto, es una señal de que algo no está pasando por el camino esperado. POST /auth/login (403)
PUB-REG-03 Con las herramientas de desarrollador (o Postman), enviar POST /auth/register con role: "ADMIN". 422 — el backend solo permite CLIENT/PROVIDER en el registro público, sin importar lo que mande el formulario. RegisterRequest.role_public_only
PUB-REG-04 Registrar un profesional desde "Registro profesional" de la web. Banner ámbar explícito avisando que quedará pendiente de verificación. Mismo regex de contraseña que en las apps móviles. POST /auth/register (role=PROVIDER)

3. Dashboard admin

(admin)/dashboard/page.tsx
IDPasosResultado esperadoRef.
ADM-DASH-01 Entrar como ADMIN y como AUDITOR por separado. Ambos roles ven el dashboard completo (es de lectura para los dos) con los mismos gráficos de métricas globales. GET /admin/metrics
ADM-DASH-02 Verificar los colores de las gráficas (estado de citas, roles) contra la paleta de marca. Los hex usados en Recharts deben coincidir con los tokens brand-* del sistema, no con colores arbitrarios — es una excepción necesaria documentada (Recharts no acepta clases Tailwind), pero los valores deben ser los mismos. STATUS_COLORS / ROLE_COLORS
ADM-DASH-03 RESUELTO (2026-09-05) — Revisar la tarjeta "Top proveedores por volumen". Antes mostraba el UUID truncado del proveedor (ej. 3e9e6a37…) sin contexto. Ahora muestra el nombre completo real del proveedor (join contra su perfil en el backend). GET /admin/metrics

4. Citas (solo lectura)

(admin)/appointments/*
IDPasosResultado esperadoRef.
ADM-CITAS-01 Abrir el listado de citas (200 por página, "cargar más") y el detalle de una. Se ve todo el detalle de la cita, pero no hay ningún control para cambiar su estado — ni botón, ni selector. Esto es intencional: el admin observa, no muta citas (evita bypasear reglas de cancelación/penalización). GET /admin/appointments
ADM-CITAS-02 Intentar, vía herramientas de desarrollador o Postman, un PATCH/PUT/DELETE directo a /admin/appointments/{id}. Debe fallar (404/405) — el router de citas admin del backend solo define rutas GET. Esta prueba confirma que la regla de "solo lectura" no depende únicamente de que la UI no muestre el botón. admin_appointments.py

5. Usuarios

(admin)/users/page.tsx
IDPasosResultado esperadoRef.
ADM-USR-01 Como ADMIN: crear usuario, cambiarle el rol, suspenderlo y reactivarlo. Cada acción refleja el nuevo estado de inmediato en la tabla. POST/PATCH /admin/users
ADM-USR-02 Eliminar (hard delete) un usuario que tiene citas u otros datos asociados. El backend debe rechazar el borrado con un mensaje que sugiera usar "Suspender" en vez de eliminar — confirma que el mensaje real llega a pantalla, no un error genérico. DELETE /admin/users/{id}
ADM-USR-03 Eliminar un usuario de prueba sin datos asociados. Se elimina correctamente, con confirmación previa. DELETE /admin/users/{id}
ADM-USR-04 Repetir esta sección completa logueado como AUDITOR. Banner "Solo lectura: tu rol es AUDITOR"; todos los controles de mutación deshabilitados o ausentes. users/page.tsx

6. Catálogos: especialidades, clínicas, ciudades, señales de alarma

specialties · clinics · locations · alarm-signs

Las cuatro pantallas comparten el mismo patrón: crear, editar y deshabilitar — ninguna tiene botón de eliminar. No agregues uno sin resolver antes la integridad referencial (proveedores/citas ya asociados).

IDPasosResultado esperadoRef.
ADM-CAT-01 Crear una especialidad nueva, editarla, y luego deshabilitarla (toggle is_active). Al deshabilitarla, desaparece de los listados públicos (Parte 3 §1) y de los selectores de las apps móviles, pero sigue existiendo en el admin para poder reactivarla. PATCH /admin/specialties/{id}/status
ADM-CAT-02 En Clínicas, cambiar el estado de verificación de una clínica de PENDING a APPROVED y luego a REJECTED. Queda registrado en Auditoría (Parte 3 §9) solo cuando el estado realmente cambia, no en cada guardado. PATCH /admin/clinics/{id}/verification
ADM-CAT-03 En cualquiera de las 4 pantallas, buscar un botón de eliminar. No debe existir en ninguna — confirmado también del lado backend (sin rutas DELETE). Es la regla de "soft-disable" del proyecto. sin DELETE en backend
ADM-CAT-04 RESUELTO — Sin sesión iniciada, en el build standalone de producción (ver Parte 3 §10), navegar directo a /alarm-signs. Ya se agregó /alarm-signs/:path* al matcher del middleware y se verificó con curl contra el build standalone real: ahora redirige 307 a /login igual que /dashboard. Vuelve a confirmarlo tú mismo — es el caso más importante de re-verificar de toda esta sección. middleware.ts

7. Postulaciones de médicos

(admin)/trabaja-con-nosotros/page.tsx
IDPasosResultado esperadoRef.
ADM-POST-01 Revisar la postulación creada en PRV-AUTH-05 (o cualquier PROVIDER en PENDING), ver sus documentos y aprobarla. Pasa a APPROVED. Desde ese momento el médico puede: activar "Disponible ahora", aparecer en búsquedas, y sus citas dejan de estar bloqueadas. PATCH /admin/providers/{id}/verification
ADM-POST-02 Rechazar una postulación con un motivo de menos de 20 caracteres. Validación bloquea el envío — el motivo de rechazo exige mínimo 20 caracteres. trabaja-con-nosotros/page.tsx
ADM-POST-03 Verificar un documento subido por el médico (ver PRV-PERFIL-03). El ícono en la app del médico cambia de "pendiente" a "verificado" tras refrescar. PATCH .../documents/{id}/verify
ADM-POST-04 Asignar una clínica a un médico que ya tenía otra clínica asignada. La nueva clínica reemplaza a la anterior — un médico pertenece a 0 o 1 clínica, nunca acumula varias. PATCH /admin/providers/{id}/clinic
ADM-POST-06 NUEVO (2026-09-05) — En la columna "Especialidad", corregir la especialidad de un médico cuyo valor no coincide con ninguna del catálogo (o simplemente cambiarla a otra). La columna ahora es un selector editable contra el catálogo real (mismo patrón que "Clínica") — no texto libre. Al guardar, el médico corregido debe empezar a aparecer en la búsqueda por esa especialidad desde la app cliente (ver CLI-BUSC-08). Esta es la vía correcta para arreglar un dato de especialidad mal cargado — nunca un `UPDATE` manual sobre la base de datos. PATCH /admin/providers/{id}/specialty
ADM-POST-05 Abrir el diálogo de "Actividad" de un médico desde esta pantalla y buscar cifras de dinero/liquidación ahí también. Sigue sin traer nada monetario a propósito — solo métricas clínicas. El valor a liquidar ahora vive en Reportes → Liquidación por médico (ver ADM-OTR-03, RESUELTO), no en este diálogo. GET /admin/providers/{id}/metrics

8. Planes de membresía y noticias

(admin)/plans · (admin)/news
IDPasosResultado esperadoRef.
ADM-PLAN-01 Crear un artículo de noticias nuevo. Nace siempre en estado DRAFT — no es visible en el sitio público hasta publicarlo explícitamente. POST /admin/news
ADM-PLAN-02 Publicar el artículo y confirmar que aparece en Parte 3 §1 (Noticias públicas). Luego despublicarlo. Aparece/desaparece del sitio público según el toggle de estado. PATCH /admin/news/{id}/status
ADM-PLAN-03 Crear un plan de membresía de prueba, asignarlo a un usuario de prueba (o simular vía membresía activa), y luego eliminarlo (hard delete real, con confirmación). A diferencia de especialidades/clínicas, Planes sí tiene borrado real pese a que el modelo ya tiene un campo is_public que podría usarse para ocultar sin borrar. No es una violación de ninguna regla explícita, pero al ser dato financiero sensible, confirma qué pasa si el plan tiene usuarios activos: ¿el backend lo rechaza (como con Usuarios) o rompe datos? Repórtalo si rompe algo — si simplemente lo rechaza con un mensaje claro, es correcto. DELETE /admin/membership-plans/{id}
ADM-PLAN-04 Repetir ambas pantallas como AUDITOR. Solo lectura en ambas. plans/page.tsx · news/page.tsx

9. Anuncios, reportes, auditoría y configuración

announcements · reports · audit · settings
IDPasosResultado esperadoRef.
ADM-OTR-01 Como ADMIN, enviar un anuncio push a todos los usuarios (dentro del límite de 128/512 caracteres) y verificar que llega a un dispositivo con la app abierta. Aparece en el historial de anuncios; llega como push real si el dispositivo tiene el token registrado. POST /admin/broadcast
ADM-OTR-02 Como AUDITOR, entrar a Anuncios. Solo ve el historial, con banner "Solo los administradores pueden enviar" — sin acceso al formulario de envío. announcements/page.tsx
ADM-OTR-03 RESUELTO — En Reportes, revisar la tarjeta nueva "Liquidación por médico" (debajo de "Exportar métricas"), con y sin filtro de fecha. Tabla con médico, consultas completadas, tarifa configurada y valor estimado a liquidar (GET /admin/reports/settlement). Un médico sin consultation_fee configurada debe mostrar "Sin tarifa" en vez de $0 — confirma que ningún médico real aparece con $0 solo por no tener tarifa cargada. El revenue_total del export global PDF/XLSX sigue en 0 siempre (no hay tabla de pagos real) — esto es aparte y no cambió, no lo confundas con la liquidación por médico. GET /admin/reports/settlement
ADM-OTR-04 Revisar Auditoría tras haber hecho varias mutaciones en esta sesión (aprobar médico, cambiar verificación de clínica, etc.). Cada mutación relevante queda registrada como entrada de log, visible tanto para ADMIN como AUDITOR (sin filtros disponibles todavía). GET /admin/audit-logs
ADM-OTR-05 En Configuración, cambiar la ventana de cancelación (ej. de 24h a 12h) y luego cancelar una cita del lado de un médico/paciente para confirmar que el nuevo valor aplica. El campo de "ventana de cancelación tardía" debe reflejarse en el comportamiento real de PATCH /appointments/{id}/cancel (Parte 1 §3 / Parte 2 §5). PUT /admin/system-config
ADM-OTR-06 Entrar a Configuración como AUDITOR. Todos los inputs deshabilitados, banner "Solo lectura". settings/page.tsx

10. Seguridad del middleware (gate real de producción)

src/middleware.ts

Ejecuta esta sección con el build standalone, no con npm run dev

El gate real de las rutas admin vive en src/middleware.ts (edge/servidor). El chequeo dentro de (admin)/layout.tsx es solo defensa en profundidad — en next dev ciertos comportamientos de redirect pueden diferir del build de producción real. Levanta con npm run build seguido de node .next/standalone/server.js antes de correr esta sección.

IDPasosResultado esperadoRef.
ADM-SEC-01 Sin ninguna cookie de sesión, contra el build standalone: curl -i http://localhost:3000/dashboard. Redirect (307/302) a /login. middleware.ts matcher
ADM-SEC-02 RESUELTO — Mismo dispositivo/sesión sin cookies: curl -i http://localhost:3000/alarm-signs. Compara la respuesta contra ADM-SEC-01. Ya verificado con curl real contra el build standalone: /alarm-signs ahora responde 307 a /login (antes: 200), igual que /dashboard y /users. Se corrigió agregando /alarm-signs/:path* al matcher de middleware.ts, que cubría las otras 15 rutas admin pero le faltaba esta. Repite el curl tú mismo para confirmarlo de forma independiente. middleware.ts matcher
ADM-SEC-03 Repite ADM-SEC-02 contra cada una de las otras 15 rutas admin (usuarios, especialidades, clínicas, planes, noticias, ciudades, entidades aliadas, anuncios, auditoría, nav-audit, reportes, configuración, trabaja-con-nosotros, appointments, dashboard) — una por una. Todas deben redirigir a /login sin cookie. Si aparece alguna otra ruta con el mismo problema que /alarm-signs, es un hallazgo nuevo adicional a reportar, no solo el ya identificado. middleware.ts matcher
ADM-SEC-04 Fabricar manualmente en el navegador (DevTools → Application → Cookies) una cookie dateapp_admin_role=ADMIN sin las cookies reales de acceso/refresco, contra el build standalone, y navegar a /dashboard. El middleware solo verifica que la cookie de rol exista, no que el token sea válido — así que probablemente pasa el gate de servidor y carga el shell del panel. Lo importante es qué pasa después: las llamadas reales a datos deben fallar con 401 y la app debe redirigir a /login sola (interceptor de axios). Confirma que ninguna pantalla llega a mostrar datos reales en este escenario — si alguna sí los muestra, es un hallazgo crítico de seguridad. middleware.ts + src/lib/api.ts