Apariencia
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:
- Abrir la misma asignación en dos browsers (diferentes recruiters logueados)
- Recruiter A: mover a
presented - 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:
- Candidato completa Checkout
- Webhook llega al backend (membresía se activa)
- Frontend aún no ha redirigido a
/membership/success - 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:
- Candidato con membresía activa (TC-CAND-009)
- 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.statusse mantiene ensucceeded(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:
- Completar Big Five (TC-CAND-018)
- 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:
- Candidato con membresía y entrevista
confirmadapara mañana - Forzar expiración:php
\App\Models\Membership::first()->update(['expires_at' => now()->subDay()]); \App\Jobs\ExpireMembershipsJob::dispatchSync(); - 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:
- Admin → cancelar membresía de un candidato
- 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:
- Recruiter mueve el finalist a
hired
Resultado esperado:
vacancy.state = cubierta- Los otros 3 candidatos pasan automáticamente a
withdrawncon 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:
- Vacante en
entrevistas_en_cursocon 2 entrevistasconfirmadas - Company_user cancela la vacante
Resultado esperado:
- Asignaciones activas →
withdrawn - Entrevistas agendadas →
canceladacon 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:
- Crear vacante solo con título y descripción (sin skills requeridas)
- "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:
- Agendar entrevista para día 1
- Reprogramar para día 3
- Reprogramar para día 5
- Reprogramar para día 7
Resultado esperado:
interview_reschedulestiene 3 rows (histórico completo)Interview.scheduled_atrefleja 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:
- Entrevista
confirmadapara las 10:00 am - 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
interviewingstage (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:
- Candidato con: 1 membresía activa, 2 asignaciones en pipeline, 3 entrevistas agendadas
- Dashboard → "Eliminar cuenta"
- 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:
- Company_user logueado
- En DevTools, llamar
GET /api/v1/directory/candidates/{id}con un ID arbitrario - 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:
- Recruiter HUMAE logueado
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_usery 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;- 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:
- Login en browser A
- 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:
- 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:
- 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

