Skip to content

Casos edge y what-ifs

Escenarios donde el sistema debe comportarse correctamente incluso cuando las cosas salen mal, tarde o en orden inesperado. Son los casos que separan un MVP frágil de uno robusto.

Duración estimada: 45 minutos.

Concurrencia y race conditions

TC-EDGE-001 · Dos recruiters intentan mover el mismo candidato al mismo tiempo

Severidad: 🟠 Alta

Precondiciones: Asignación en stage sourced

Pasos:

  1. Abrir la misma asignación en dos browsers (diferentes recruiters logueados)
  2. Recruiter A: mover a presented
  3. Recruiter B (simultáneo): mover a rejected

Resultado esperado:

  • La primera request gana (queda en el stage que elija)
  • La segunda request: depende de la implementación. Idealmente devuelve 409 "Conflict — la asignación fue modificada, refresca"
  • Sin lock optimista: la segunda puede sobrescribir a la primera (documentar si esto es aceptable en MVP)

Follow-up: considerar implementar updated_at como optimistic lock en Fase 2 — la request debe enviar if_updated_before=<timestamp>.


TC-EDGE-002 · Webhook de Stripe llega antes que la redirección del usuario

Severidad: 🔴 Crítica

Precondiciones: Latencia del servidor alta, o Stripe CLI con delay

Pasos:

  1. Candidato completa Checkout
  2. Webhook llega al backend (membresía se activa)
  3. Frontend aún no ha redirigido a /membership/success
  4. Candidato finalmente aterriza en success page

Resultado esperado:

  • Success page consulta GET /me/membership → ya activa
  • Muestra confirmación "Tu membresía está activa"
  • Sin spinner infinito

Variaciones:

  • Webhook tarda 30 segundos → frontend debe hacer polling o mostrar "Procesando tu pago…"

Idempotencia y duplicados

TC-EDGE-003 · Candidato intenta contratar membresía 2 veces mientras tiene una activa

Severidad: 🟠 Alta

Pasos:

  1. Candidato con membresía activa (TC-CAND-009)
  2. Va a /dashboard/membresia → ¿aparece botón "Contratar"?

Resultado esperado:

  • UI oculta el botón si ya tiene membresía activa
  • Si bypassea con request directo: backend permite (acumula membresía para extender fecha) o rechaza (solo 1 activa a la vez)
  • Documentar la decisión de negocio

TC-EDGE-004 · Webhook de Stripe disparado 5 veces

Severidad: 🔴 Crítica

Pasos: En Stripe Dashboard → evento → "Resend" 5 veces seguidas

Resultado esperado:

  • Solo se crea 1 Membership
  • payments.status se mantiene en succeeded (no cambia entre cada hit)
  • Solo se envía 1 email de activación
  • Los retries responden 200 sin lógica adicional

TC-EDGE-005 · Candidato envía psicométrico dos veces

Severidad: 🟡 Media

Pasos:

  1. Completar Big Five (TC-CAND-018)
  2. Intentar submit otra vez (via network replay o tabs duplicados)

Resultado esperado:

  • Segundo submit devuelve el mismo PsychometricResult (no crea otro)
  • El método score() en el service es idempotente: verifica si ya existe result

Membresía y estados

TC-EDGE-006 · Membresía expira durante entrevista agendada

Severidad: 🟠 Alta

Pasos:

  1. Candidato con membresía y entrevista confirmada para mañana
  2. Forzar expiración:
    php
    \App\Models\Membership::first()->update(['expires_at' => now()->subDay()]);
    \App\Jobs\ExpireMembershipsJob::dispatchSync();
  3. Candidato intenta entrar a /me/entrevistas

Resultado esperado:

  • El candidato sí puede ver su entrevista (no se borra con expire)
  • La entrevista sigue en pie
  • Candidato NO aparece en el directorio para nuevas búsquedas
  • La vacante activa sigue teniéndolo en pipeline

Racional: expirar no debe romper compromisos ya hechos. Solo evita nuevas apariciones.


TC-EDGE-007 · Admin cancela membresía con entrevistas pendientes

Severidad: 🟡 Media

Pasos:

  1. Admin → cancelar membresía de un candidato
  2. Ver el perfil del candidato

Resultado esperado:

  • membership.status = cancelled
  • Entrevistas activas siguen válidas
  • Notificación opcional al candidato + al recruiter asignado

Vacantes y pipeline

TC-EDGE-008 · Vacante se cubre con varios candidatos activos

Severidad: 🔴 Crítica

Precondiciones: Vacante con 4 candidatos en pipeline: 1 finalist, 2 interviewing, 1 presented

Pasos:

  1. Recruiter mueve el finalist a hired

Resultado esperado:

  • vacancy.state = cubierta
  • Los otros 3 candidatos pasan automáticamente a withdrawn con motivo "Vacante cubierta"
  • Entrevistas agendadas para esos 3 → se cancelan
  • Notificación a los otros 3 candidatos (opt-in)
  • Notificación a company_user + admin

TC-EDGE-009 · Vacante cancelada con entrevistas agendadas

Severidad: 🟠 Alta

Pasos:

  1. Vacante en entrevistas_en_curso con 2 entrevistas confirmadas
  2. Company_user cancela la vacante

Resultado esperado:

  • Asignaciones activas → withdrawn
  • Entrevistas agendadas → cancelada con motivo "Vacante cancelada"
  • Notificación a candidatos con el motivo
  • Panel de recruiter muestra vacante como cancelada

TC-EDGE-010 · Publicar vacante sin requisitos mínimos

Severidad: 🟡 Media

Pasos:

  1. Crear vacante solo con título y descripción (sin skills requeridas)
  2. "Publicar"

Resultado esperado:

  • 422 "Agrega al menos 1 skill requerida"
  • Vacante permanece en borrador

Variaciones:

  • Vacante sin salary_min/max → puede ser válida (opcional)
  • Vacante sin ubicación → válida si is_remote=true

Entrevistas — reprogramaciones múltiples

TC-EDGE-011 · Entrevista reprogramada 3 veces

Severidad: 🟡 Media

Pasos:

  1. Agendar entrevista para día 1
  2. Reprogramar para día 3
  3. Reprogramar para día 5
  4. Reprogramar para día 7

Resultado esperado:

  • interview_reschedules tiene 3 rows (histórico completo)
  • Interview.scheduled_at refleja la última fecha (día 7)
  • Cada reprogramación notifica a todas las partes
  • UI muestra el timeline de cambios

Consideración de negocio: después de N reprogramaciones (ej. 5), ¿debería cancelar automáticamente? Documentar como regla si aplica.


TC-EDGE-012 · Candidato cancela entrevista 1 hora antes

Severidad: 🟡 Media

Pasos:

  1. Entrevista confirmada para las 10:00 am
  2. A las 9:00 am, candidato cancela

Resultado esperado:

  • Cancelación permitida
  • Notificación urgente al recruiter y company_user
  • Asignación puede seguir en interviewing stage (no se mueve automáticamente)

Consideración: agregar flag last_minute_cancellation = true si cancelled_at - scheduled_at < 2h para tracking (Fase 2).

Datos sensibles y privacidad

TC-EDGE-013 · Candidato elimina su cuenta con datos pendientes

Severidad: 🔴 Crítica

Pasos:

  1. Candidato con: 1 membresía activa, 2 asignaciones en pipeline, 3 entrevistas agendadas
  2. Dashboard → "Eliminar cuenta"
  3. Confirmar con password

Resultado esperado:

  • users.deleted_at = now() (soft delete)
  • candidate_profiles.deleted_at = now()
  • Sesiones activas revocadas
  • Login bloqueado
  • Recruiter ve al candidato como "[Cuenta eliminada]" en sus asignaciones (no se borran para auditoría)
  • Entrevistas pendientes → el recruiter debe cancelarlas manualmente o se cancelan auto
  • Data se preserva 90 días, después se puede solicitar eliminación completa (GDPR/LFPDPPP)

TC-EDGE-014 · Company_user intenta ver contacto de un candidato NO presentado

Severidad: 🔴 Crítica (security)

Pasos:

  1. Company_user logueado
  2. En DevTools, llamar GET /api/v1/directory/candidates/{id} con un ID arbitrario
  3. O acceder a /recruiter/directorio/{id} modificando la URL

Resultado esperado:

  • 403 "No autorizado"
  • Si bypassea: datos básicos pueden verse, pero datos de contacto (email, phone) NO están en la response (filtrado en Resource según rol)

TC-EDGE-015 · Recruiter intenta modificar datos de otra empresa

Severidad: 🔴 Crítica (security)

Pasos:

  1. Recruiter HUMAE logueado
  2. PATCH /api/v1/companies/{id} con un company_id arbitrario

Resultado esperado:

  • Si el role es recruiter → Policy le permite editar cualquier empresa (es staff HUMAE)
  • Si el role es company_user y NO pertenece a esa company → 403

Sesiones y auth

TC-EDGE-016 · Token Sanctum expirado

Severidad: 🟠 Alta

Pasos:

sql
UPDATE personal_access_tokens
SET expires_at = '2020-01-01'
WHERE id = X;
  1. User hace request con ese token

Resultado esperado:

  • 401 "Unauthenticated"
  • Frontend detecta y redirige a /login
  • Token se elimina del localStorage

TC-EDGE-017 · Login desde 2 dispositivos simultáneamente

Severidad: 🟡 Media

Pasos:

  1. Login en browser A
  2. Login en browser B (mismo user, otras credenciales del mismo account)

Resultado esperado:

  • Ambas sesiones válidas — Sanctum no revoca por default
  • Cada una tiene su propio token en personal_access_tokens
  • Logout de A → B sigue activa

Variaciones: POST /auth/logout-all para cerrar todas.

Performance y carga

TC-EDGE-018 · Directorio con 1000+ candidatos

Severidad: 🟡 Media

Precondiciones:

php
CandidateProfile::factory()->count(1000)->create();
Membership::factory()->count(1000)->create([
    'status' => MembershipStatus::Active,
    'expires_at' => now()->addMonths(3),
]);

Pasos:

  1. Recruiter abre /recruiter/directorio

Resultado esperado:

  • Primera página carga en < 1 segundo
  • Paginación funciona (50 máx por request)
  • Filtros siguen respondiendo en < 500ms

Variaciones:

  • Sin índices DB → queries exceden 1s
  • Con índices (como está en MVP) → mantiene performance

TC-EDGE-019 · Request lento — generación de CV

Severidad: 🟡 Media

Precondiciones: Candidato con 50 experiencias, 20 educaciones, 30 skills

Pasos:

  1. GET /api/v1/me/profile/cv.pdf

Resultado esperado:

  • PDF se genera (< 5 segundos)
  • No timeout de nginx (default 60s)
  • Rate limit permite 30/min

Siguiente

Checklist de regresión →

Manual de usuario HUMAE · Uso interno