CHEFMANAGER IA · V3.12.0 · VALIDACIÓN
RESERVAS POR ENLACE DE WHATSAPP
================================================================================

METODOLOGÍA

Igual que en entregas anteriores: servidor PHP real (`php -S
127.0.0.1:8096`) sobre una copia aislada del paquete (/tmp/httptest/
maestro, nunca el paquete real que se entrega), con `force_https`
desactivado solo en esa copia. Datos sembrados con un script de fixture
(`fixture_reservations.php`) que fija `$_SERVER['DOCUMENT_ROOT']` para
escribir en el mismo SQLite que usa el servidor web. Todo el flujo
administrativo y público se probó con peticiones HTTP reales (curl con
cookies y cabeceras CSRF, tanto la del panel `X-CSRF-Token` como la
pública `X-Public-CSRF`). La página pública se probó además con un
navegador real (Chromium vía Playwright), interactuando con el DOM tal
como lo haría un cliente. Se verificó sintaxis de los archivos PHP
modificados con `php -l` y del JavaScript nuevo/modificado
(`reserva.js`, `app.js`) con `node --check` antes de considerar
cualquier cambio completo.

Datos de la corrida: restaurante "Rsv Test" (id=6), sucursal principal
(id=44, código MAIN), dos mesas activas (Mesa 1 capacidad 4, Mesa 2
capacidad 2 → capacidad total real de la sucursal = 6), usuario dueño
con permiso `reservations.manage`.

1) ADMINISTRACIÓN DE ENLACES (panel, autenticado)

- POST /api/index.php?path=reservation-links con
  {"customer_name":"Juan Perez","customer_phone":"70011223",
  "max_uses":3,"expires_days":30} → 201, devuelve `token` y `public_url`
  (null en esta corrida porque `app.url` no está configurado en el
  entorno de prueba; en un despliegue real con `config/app.php` apuntando
  al dominio del negocio, `public_url` se arma con ese dominio).
- GET /api/index.php?path=reservation-links → 200, el enlace aparece con
  `is_active:true`, `used_count:0`, `reservations_count:0`.
- DELETE /api/index.php?path=reservation-links/1 → 200 "Enlace
  revocado.". Confirmado que el enlace revocado ya no sirve (punto 3).

2) RESOLUCIÓN PÚBLICA DEL ENLACE Y HORARIOS REALES

- GET /api/public.php?action=reservation-link&token=<token válido> → 200,
  devuelve `restaurant_name`, `branch_name`, `customer_name`/
  `customer_phone` precargados, y `max_guests: 6` (exactamente la suma
  de capacidad de las dos mesas configuradas — confirma que
  `branchCapacity()` lee `restaurant_tables` en vivo, no un valor fijo).
- GET /api/public.php?action=reservation-slots&token=...&date=<mañana>
  &guests=2 → 200, 26 horarios entre 08:00 y 20:30 cada 30 minutos
  (turnos de 90 min, último inicio a las 20:30 para cerrar a las 22:00),
  todos con `remaining_capacity:6` y `available:true` antes de reservar
  nada — coincide exactamente con el horario por defecto (08:00–22:00)
  porque el restaurante de prueba no configuró horarios propios.
- GET /api/public.php?action=reservation-link&token=<token con formato
  inválido> → 404 "Enlace de reserva no válido." (rechazado antes de
  tocar la base de datos, por longitud del token).

3) CICLO DE VIDA DEL ENLACE: revocado / vencido / agotado

- Tras revocar el enlace #1 (punto 1), GET reservation-link con su token
  → 410 "Este enlace de reserva fue desactivado. Solicita uno nuevo al
  negocio." (ya no crea ni permite consultar nada).
- Enlace nuevo creado con `max_uses:1`: primera reserva → 201 (ver punto
  4). Segunda consulta del mismo token → 410 "Este enlace de reserva ya
  fue utilizado." — confirmado que `used_count` se incrementó a 1/1 tras
  el único uso permitido.

4) CREACIÓN DE RESERVA Y CONTROL REAL DE CUPO (la corrección central de
   esta entrega)

Escenario numérico exacto, capacidad real = 6 personas (Mesa 1: 4,
Mesa 2: 2), mismo horario (mañana 13:00, turno de 90 min):

a) POST /api/public.php?action=reservation con {guests:4, reserved_at:
   "<mañana> 13:00", customer_name:"Juan Perez", phone:"70011223"} →
   201, `id:1`. Verificado en SQLite: fila en `reservations` con
   `guests:4`, `reserved_at:"<mañana> 13:00:00"`,
   `reservation_link_id:1`, `confirmed_via:"whatsapp_link"`; se creó
   automáticamente el cliente en `customers` (no existía antes, se buscó
   por teléfono). `reservation_links.used_count` pasó de 0 a 1.

b) Cupo restante tras (a): 6 − 4 = 2. POST con {guests:4, mismo horario,
   otro cliente "Ana"/70099999} → RECHAZADO con 409 exacto: "Ese horario
   ya no tiene cupo suficiente para 4 persona(s). Elige otro horario o
   reduce la cantidad de comensales." Confirmado en SQLite que NO se creó
   ninguna fila nueva en `reservations` (la transacción no llegó a
   ejecutar el INSERT: `assertSlotAvailable()` lanza antes).

c) Mismo horario, mismo cliente "Ana", pero {guests:2} (exactamente el
   cupo restante) → 201, `id:2`. Confirmado: `reservation_links.
   used_count` pasó a 2/3.

Este es exactamente el comportamiento que antes de esta entrega NO
existía en ningún punto del sistema: (b) se habría aceptado sin ningún
aviso, dejando 8 personas confirmadas para una capacidad real de 6.

5) MENSAJE DE CONFIRMACIÓN POR WHATSAPP (cola `integration_jobs`)

- Con WhatsApp Business SIN configurar para el restaurante de prueba: la
  reserva del punto 4a se creó con éxito y NO se generó ninguna fila en
  `integration_jobs` — confirmado que la ausencia de integración no
  bloquea ni rompe la creación de la reserva (comportamiento esperado:
  `onReservationCreated()` retorna en silencio si el proveedor no está
  `configured()`).
- Se configuró `integration_settings` para el restaurante de prueba
  (`provider:'whatsapp_business'`, `status:'active'`, credenciales de
  prueba cifradas con `Crypto::encrypt`) y se creó una tercera reserva
  (cliente "Marta Lopez", 70055555, 2 personas, otro día a las 19:00) →
  201, `id:3`. Verificado en SQLite: se generó exactamente una fila en
  `integration_jobs` con `job_type:'reservation_confirmed'`,
  `status:'pending'`, `dedupe_key:'wa-reservation-3'` (evita duplicados
  si el mismo evento se procesara dos veces), y `payload_json` con el
  mensaje ya resuelto: "Hola Marta Lopez, registramos tu reserva para 2
  persona(s) el <fecha real> a las 19:00 en Sucursal Principal. Te
  confirmaremos en breve." — las cinco variables ({cliente}, {personas},
  {fecha}, {hora}, {sucursal}) se sustituyeron correctamente con los
  datos reales de la reserva, no con valores de ejemplo.
- El envío real del mensaje (llamada a la API de Meta) queda a cargo del
  worker existente (`cron/worker.php` → `IntegrationService::process()`),
  sin cambios en esta entrega; esta validación confirma que el job se
  encola correctamente con el payload correcto, que es el límite de
  responsabilidad de esta funcionalidad.

6) PÁGINA PÚBLICA `reserva.php` — PRUEBA CON NAVEGADOR REAL (Playwright/
   Chromium, no simulación de peticiones)

Con un enlace genérico nuevo (precargado con "Browser Test"/70088877):

- La página carga, resuelve el enlace y muestra el nombre del
  restaurante ("Rsv Test") y los campos de nombre/teléfono ya
  precargados con los valores del enlace, editables.
- Al elegir una fecha (4 días adelante) se dispara la consulta de
  horarios automáticamente: se renderizaron 26 botones de horario.
- El contador de personas responde a los clics en "+" (2 → 4 tras dos
  clics).
- Al hacer clic en un horario disponible, el botón recibe la clase
  `selected` (confirmado leyendo el atributo `class` real del DOM, no
  solo que el clic no lanzó error).
- Al enviar el formulario, aparece la pantalla de confirmación con el
  texto exacto: "¡Reserva registrada! Te esperamos el <fecha elegida> a
  las 08:00 para 4 persona(s) en Rsv Test · Sucursal Principal...".
- Cero errores de JavaScript en consola durante todo el flujo (capturado
  con listeners de `pageerror`/`console` de Playwright, no solo
  ausencia de excepción del script de prueba).
- Verificado en SQLite tras la prueba de navegador: la reserva quedó
  guardada con `guests:4`, el horario y la fecha exactos elegidos en el
  DOM, `reservation_link_id` apuntando al enlace usado, y
  `confirmed_via:'whatsapp_link'`.

7) INTEGRIDAD DE LA BASE DE DATOS

Tras toda la corrida (creación/revocación de enlaces, reservas exitosas
y rechazadas, configuración de integración): `PRAGMA integrity_check` →
`ok`. `PRAGMA foreign_key_check` → sin filas (sin violaciones de llave
foránea).

8) SINTAXIS

`php -l` sin errores en: `app/Support/Database.php`,
`app/Services/ReservationLinkService.php`,
`app/Services/ExternalChannelsService.php`, `api/index.php`,
`api/public.php`, `reserva.php`. `node --check` sin errores en
`public/assets/reserva.js` y `public/assets/app.js` (incluye la nueva
página "Reservas por WhatsApp" del panel y los campos nuevos de
plantilla en la página de WhatsApp).

ALCANCE NO PROBADO EN ESTA RONDA (documentado, no un defecto)

- El envío real de un mensaje de WhatsApp a un número real vía Meta
  Cloud API: el job se encola correctamente (punto 5); el envío en sí lo
  ejecuta `IntegrationService`, código no tocado en esta entrega y ya
  validado en rondas anteriores para pedidos/delivery.
- Un segundo negocio concurrente reservando el mismo instante exacto
  (condición de carrera real a nivel de fila de base de datos): la
  validación de cupo se ejecuta dentro de la misma petición antes del
  INSERT, sin un bloqueo explícito de fila; en el volumen típico de
  reservas de un restaurante (a diferencia de, por ejemplo, venta de
  entradas de un evento masivo) el riesgo de dos reservas simultáneas al
  milisegundo exacto para el mismo horario es bajo, pero no es
  matemáticamente imposible. Se documenta como mejora futura (bloqueo
  pesimista o `UNIQUE` compuesto con reintento) en vez de asumir que esta
  entrega lo cubre.
