Apariencia
Flujo del reclutador
15 casos ejecutables: directorio, pipeline kanban, asignaciones, entrevistas, favoritos, cierre de vacante con auto-retiros. Ejecuta estos tests con el user reclutador creado en Plan general.
Duración estimada: 45 minutos.
Directorio de talento
TC-REC-001 · Acceder al directorio
Severidad: 🔴 Crítica
Precondiciones:
- Recruiter logueado
- Al menos 3 candidatos con membresía activa (ejecutar TC-CAND-009 × 3 si no existen)
Pasos:
- Nav → "Reclutamiento" → "Directorio" (
/recruiter/directorio)
Resultado esperado:
- Lista de candidatos con membresía activa (por default).
- Cada card muestra: foto, nombre, headline, ubicación, años exp, skills principales, rango salarial.
- Sidebar de filtros visible.
Variaciones:
- Acceder como candidato →
AuthGuardbloquea y redirige. - Sin candidatos que cumplan filtros → empty state.
TC-REC-002 · Filtrar por skill único
Severidad: 🟠 Alta
Pasos:
- Sidebar → "Habilidades" → buscar "React" → marcar el checkbox.
Resultado esperado:
- Lista se restringe a candidatos que tengan React.
- URL refleja el filtro:
?skills[]=X.
TC-REC-003 · Filtrar por múltiples skills (AND)
Severidad: 🟠 Alta
Pasos:
- Marcar skills:
React+TypeScript+AWS.
Resultado esperado: Solo candidatos con las 3 skills (AND semántico en backend vía whereHas múltiple).
TC-REC-004 · Filtrar por salario máximo
Severidad: 🟠 Alta
Pasos:
- Campo "Presupuesto máximo (MXN/mes)" =
40000.
Resultado esperado:
- Aparecen candidatos con
expected_salary_min ≤ 40000oexpected_salary_min = null.
TC-REC-005 · Búsqueda de texto (q)
Severidad: 🟡 Media
Pasos:
- Input "Buscar" → escribir "backend" → enter.
Resultado esperado: Candidatos cuyo first_name, last_name, headline o summary contengan "backend".
TC-REC-005b · Filtrar por categoría empleado/practicante
Severidad: 🔴 Crítica (PDF cosasfaltanteshumae punto 2)
Precondiciones: Correr php artisan db:seed --class=PdfDemoSeeder para tener 3 empleados + 2 practicantes demo, y cualquiera con membresía activa.
Pasos:
/recruiter/directorio.- En el sidebar, sección "Categoría" → click "Practicante".
Resultado esperado:
- Query string contiene
?candidate_kind=intern. - La lista muestra solo a Pablo y María (los 2 practicantes del seed).
- Cada card muestra badge ámbar Practicante.
- Click en "Empleado" → muestra Juan, Sofía y Lucía con badge color marca.
- Click en "Todos" → vuelve a 5 candidatos.
Variaciones:
- Combinar con otros filtros (skill + categoría) → ambos aplican como AND a nivel de servicio.
- Candidato sin
candidate_kind→ no aparece bajo ningún filtro distinto de "Todos".
TC-REC-005c · Filtrar por áreas de interés (multi-select OR)
Severidad: 🔴 Crítica (PDF cosasfaltanteshumae punto 1)
Precondiciones: Demo seeder corrido. Catálogo con áreas Producción, Calidad, Sistemas, Logística, Almacén, RH.
Pasos:
- Sidebar → expandir el bloque "Áreas de interés".
- Marcar el checkbox de Sistemas.
Resultado esperado: Aparece solo María Torres (única con Sistemas como principal).
- Marcar también Producción y Calidad.
Resultado esperado:
- Query string
?functional_area_ids[]=...&functional_area_ids[]=...&functional_area_ids[]=.... - Aparecen María (Sistemas) + Juan (Producción y Calidad) + Pablo (Producción secundaria).
- En el detalle de cada uno, el card "Áreas de interés" muestra los chips con la principal marcada con ⭐.
Variaciones:
- Desmarcar todas → vuelve al listado completo.
- Filtrar por un área que ningún candidato tiene → empty state con "No hay candidatos para los filtros aplicados".
TC-REC-006 · Ver detalle de candidato
Severidad: 🟠 Alta
Pasos:
- Clic en una card del directorio.
Resultado esperado:
- Ruta
/recruiter/directorio/{id}. - Secciones: datos básicos (nombre, headline, contacto), resumen, experiencias, educación, habilidades, idiomas.
- Botones: "Asignar a vacante", "Favorito", "Descargar CV".
TC-REC-007 · Agregar a favoritos + ver listado
Severidad: 🟡 Media
Pasos:
- Detalle del candidato → botón "Favorito".
- Nav → "Reclutamiento" → "Favoritos" (
/recruiter/favoritos).
Resultado esperado:
POST /api/v1/directory/candidates/{id}/favorite— toggle.- Row en
directory_favoritesconrecruiter_id+candidate_profile_id. /recruiter/favoritoslista al candidato con el mismo card del directorio.
Variaciones:
- Toggle doble → el endpoint alterna.
- Candidato sin membresía activa → sigue apareciendo en favoritos (el filtro desactiva
has_active_membershipdefault).
TC-REC-008 · Descargar CV del candidato
Severidad: 🟠 Alta
Pasos:
- Detalle del candidato → "Descargar CV".
Resultado esperado:
GET /api/v1/directory/candidates/{id}/cv.pdf→ 200.- PDF descarga con filename
CV_Nombre_Apellido.pdf.
Pipeline — asignaciones
TC-REC-009 · Asignar candidato a vacante
Severidad: 🔴 Crítica
Precondiciones:
- Candidato activo en el directorio.
- Vacante en estado
activao derivados (creada por el reclutador o por la empresa).
Pasos:
- Desde la card del candidato en el directorio o desde el detalle (
/recruiter/directorio/{id}) → botón "Asignar a vacante". - Modal con selector de vacantes activas.
- Elegir vacante + prioridad (
low/normal/high/urgent) + nota interna opcional. - Confirmar.
Resultado esperado:
- Toast "Candidato asignado".
- Row en
vacancy_assignmentsconstage=sourced. - Si era la primera asignación:
Vacancy.statepasa deactivaacon_candidatos_asignados. - El candidato aparece en
/recruiter/vacantes/{id}/pipeline, columna Sourced.
Variaciones:
- Asignar candidato ya asignado a la misma vacante → la vacante ya no aparece en el selector (filtro
excluding_assigned_candidate_id). Si se forzara desde API, 422. - Vacante
cubiertaocancelada→ no aparece en el selector.
TC-REC-010 · Mover candidato a stage siguiente (sourced → presented)
Severidad: 🔴 Crítica
Pasos:
/recruiter/vacantes/{id}/pipeline- Arrastrar la card desde la columna Identificado hasta la columna Presentado y soltar.
Resultado esperado:
VacancyAssignment.stage = presented,presented_at = now().- La card aparece en la columna Presentado tras el
invalidateQueries. - Toast "Movido a Presentado".
- Mientras se arrastra, las columnas que no están en
allowed_transitionsse atenúan a 50% opacidad.
Variaciones:
- Soltar en una columna no permitida (ej.
sourced→hired) → toast "Transición no permitida desde esta etapa". Sin llamada al backend. - Teclado: Tab a la card, Space para tomarla, flechas para mover entre columnas, Enter para soltar.
TC-REC-011 · Mover a rejected con motivo obligatorio
Severidad: 🟠 Alta
Pasos:
- Arrastrar la card a la columna Rechazado.
- Se abre modal "Rechazar candidato" con textarea obligatoria (mínimo 3 caracteres).
- Escribir motivo → clic "Rechazar".
Resultado esperado:
stage = rejected,rejected_at = now(),rejection_reasonguardado.- Toast "Candidato movido a rechazado".
- La card aparece en la columna Rechazado (terminal).
Variaciones:
- Cerrar el modal sin completarlo → la card vuelve visualmente a su columna original (no se commitea nada).
- Motivo vacío → botón "Rechazar" queda deshabilitado.
TC-REC-011b · Retroceder una etapa por error
Severidad: 🟡 Media
Pasos:
- Card en columna Presentado.
- Arrastrar la card de regreso a Identificado.
Resultado esperado:
stage = sourced. La card aparece en Identificado.presented_atno se resetea (queda como histórico de "primera vez que llegó a presentado").- Toast "Movido a Identificado".
Variaciones:
- Retroceder más de un paso (ej.
interviewing → sourced) → toast "Transición no permitida desde esta etapa". Sin llamada al backend. - Retroceder desde terminal (
hired,rejected,withdrawn) → bloqueado. Para revivir un descarte se elimina la asignación y se crea una nueva. - Cerrar sin guardar → la asignación no cambia.
TC-REC-012 · Cerrar vacante (hire final) — auto-withdraw + notificaciones
Severidad: 🔴 Crítica
Precondiciones: Asignación en stage finalist con otras asignaciones activas (sourced|presented|interviewing).
Pasos:
- Arrastrar la card desde la columna Finalista hasta la columna Contratado y soltar.
Resultado esperado (HireService transaccional):
assignment.stage = hired,hired_at = now().vacancy.state = cubierta,filled_at = now().- Otras asignaciones activas pasan a
withdrawnconrejection_reason = "Vacante cubierta por otro candidato"ywithdrawn_at = now(). - Notificaciones (database + mail):
CandidateHiredNotificational candidato hired.CandidateHiredNotificationa owners/managers de la empresa.AssignmentRejectedNotificationa cada candidato auto-retirado.
- Las asignaciones que ya estaban
rejectedno se tocan.
Variaciones:
- Asignación no está en
finalist(ej. sourced) → la columna Contratado se atenúa al arrastrar y muestra "No permitido"; el drop dispara toast "Transición no permitida desde esta etapa". Vía API → 409.
TC-REC-013 · Agregar nota al pipeline + toggle visible para empresa
Severidad: 🟡 Media
Pasos:
- En la card de la asignación → icono mensaje → abre form.
- Escribir nota + marcar "Visible para la empresa" → "Guardar nota".
- Clic en "Ver notas anteriores" → se expande la lista con badge "Empresa" / "Interna".
Resultado esperado:
POST /api/v1/assignments/{id}/notesconvisibility=company|internal.- Badge correspondiente en la lista.
- Company_user ve la nota si
visibility=company; oculta siinternal.
Variaciones:
- Sin marcar el toggle → nota se guarda como
internaly la empresa no la ve.
Sugerencias de candidatos (matching)
TC-REC-013b · Ver candidatos sugeridos para una vacante
Severidad: 🔴 Crítica (PDF cosasfaltanteshumae, "Ajuste en la lógica de matching")
Precondiciones: Demo seeder corrido. Vacante "Practicante de Ingeniería Industrial" (HUM-DEMO-0001) en estado activa.
Pasos:
/recruiter/vacantes/{id}/pipelinepara esa vacante.- En el header, click en el botón
✨ Ver sugerencias.
Resultado esperado:
- Petición
GET /api/v1/vacancies/{id}/suggested-candidates. - Aparece un panel arriba del kanban con candidatos ordenados por score descendente.
- Pablo Sánchez (intern · principal Ingeniería · secundaria Producción · 0 años) está primero con score ≈ 95.
- Cada card muestra: avatar, nombre, badge categoría, chips de áreas (estrella en la principal), barra de score con color según umbral, número del score y botón "+ Asignar".
- Hover sobre la barra muestra tooltip nativo con el breakdown completo (
Categoría: 25 · Áreas: 25 · Educación: 15 · …).
Variaciones:
- Cambiar filtro de score (botones
Todos / ≥50 / ≥70 / ≥85) → solo aparecen los que cumplen el umbral. - Click en
+ Asignarde Pablo → toast "Pablo Sánchez asignada/o a la vacante", el card desaparece de las sugerencias y aparece en la columnasourceddel kanban. - Refrescar → Pablo ya no vuelve a la lista de sugerencias (excluido por la query
whereDoesntHave('assignmentsForVacancy')). - Vacante con
target_candidate_kind = any→ todos los candidatos válidos pasan; el eje "Categoría" da 60 % parcial. - Vacante sin
functional_area_id→ eje "Áreas" da 40 % neutro. - Cliente role candidate intentando llamar el endpoint → 403.
TC-REC-013c · Verificar que el matching es determinista
Severidad: 🟠 Alta
Pasos:
- Llamar
GET /vacancies/{id}/suggested-candidates3 veces seguidas sin tocar nada. - Comparar
data[*].scoreydata[*].breakdown.
Resultado esperado: Idénticos en cada llamada (mismas reglas, mismos datos, mismo resultado). No hay aleatoriedad.
Entrevistas — gestión
TC-REC-014 · Agendar entrevista
Severidad: 🔴 Crítica
Precondiciones: Asignación en stage presented o interviewing.
Pasos:
- En la card → icono calendario → dialog "Proponer entrevista".
- Fecha+hora (
scheduled_at), modo (online/presencial/telefonica).- Si online:
meeting_url. - Si presencial:
location.
- Si online:
- Enviar.
Resultado esperado:
POST /api/v1/interviews→ 201.Interview.state = propuesta,round = 1.- Notificaciones: candidato, owners/managers de la empresa, recruiter asignado.
Variaciones:
scheduled_aten el pasado → 422.mode=onlinesinmeeting_url→ 422.- Vacante en
activa/borrador→ 409 "La vacante no está en un estado que admita entrevistas". Recordatorio: la vacante avanza automáticamente acon_candidatos_asignadosapenas se crea la primera asignación (verPipelineService::assign); si ves este 409 en producción, revisa que la asignación se haya creado correctamente.
TC-REC-014b · Confirmar entrevista propuesta
Severidad: 🔴 Crítica
Precondiciones: Entrevista en estado propuesta.
Pasos:
/me/entrevistas→ card de la entrevista → botón "Confirmar".
Resultado esperado:
POST /api/v1/interviews/{id}/confirm→ 200.Interview.state = confirmada.InterviewConfirmedNotification(database + mail) al candidato, owners/managers de la empresa, y al recruiter de la asignación.- La card cambia su badge de "Propuesta" a "Confirmada".
Variaciones:
- Entrevista ya en
confirmada→ idempotente: el service devuelve la misma entrevista sin cambios ni notificación duplicada. - Entrevista en estado terminal (
realizada,cancelada,no_asisto) → 409 "La entrevista no puede confirmarse en este estado". - Confirma el
company_user→ permitido (ambos lados pueden confirmar lo agendado por el otro). - Confirma un usuario sin acceso a la asignación → 403.
TC-REC-014c · Cancelar entrevista (con motivo opcional)
Severidad: 🟠 Alta
Precondiciones: Entrevista en propuesta o confirmada.
Pasos:
/me/entrevistas→ card de la entrevista → botón "Cancelar".- Prompt de motivo (opcional, máx 500 chars).
- Confirmar.
Resultado esperado:
POST /api/v1/interviews/{id}/cancelcon{ "reason": "…" }→ 200.Interview.state = cancelada(terminal).- Si se pasó
reason: se appendea arecruiter_feedbackcon prefijo[cancelado]. InterviewCancelledNotification(database + mail) al candidato, owners/managers de la empresa, y al recruiter de la asignación. El motivo viaja en la notificación.
Variaciones:
- Sin motivo → la cancelación se acepta; la notificación va sin reason.
- Motivo > 500 chars → 422.
- Entrevista en
realizada/cancelada/no_asisto→ 409 "La entrevista ya no puede cancelarse". - Cancelar entrevista que ya tenía feedback → el
[cancelado] motivose appendea preservando el feedback existente.
TC-REC-015 · Marcar entrevista como realizada con feedback
Severidad: 🟠 Alta
Precondiciones: Entrevista en confirmada.
Pasos:
/me/entrevistas→ card de la entrevista → botón "Marcar realizada".- Dialog "Cerrar entrevista" con:
- Textarea de feedback (obligatorio, ≥ 5 chars).
- Select de recomendación:
advance/hold/reject. - Input opcional de rating 0–10.
- "Guardar y cerrar".
Resultado esperado:
POST /api/v1/interviews/{id}/complete→ 200.state = realizada(terminal),recruiter_feedback,recommendation,ratingpersistidos.
Variaciones:
- Feedback vacío o < 5 chars → botón deshabilitado / 422.
- Recomendación no en enum → 422.
- Entrevista en
propuesta(no confirmada) → 409 "no puede marcarse como realizada". - Company_user intenta → 403.
TC-REC-016 · Ver feedback de la entrevista realizada
Severidad: 🟡 Media
Precondiciones: Entrevista en realizada con recruiter_feedback, recommendation y opcionalmente rating ya capturados.
Pasos:
/me/entrevistas→ tab "Historial" o filtro de estadorealizada.- Card de la entrevista → click en la sección "Resultado de la entrevista".
Resultado esperado:
- La sección expande mostrando:
- Badge de recomendación (verde "Avanzar", ámbar "Mantener en evaluación", rojo "Rechazar").
- Rating con icono de estrella (
X/10) cuando existe. - Texto íntegro de
recruiter_feedback(preserva saltos de línea). - Texto íntegro de
company_feedbackcuando lo hay.
- La sección se inicializa expandida automáticamente cuando la entrevista está en
realizada.
Variaciones:
- Sin feedback escrito (sólo recomendación + rating) → la sección sigue visible mostrando la metadata; bajo el divisor aparece "Aún no hay feedback escrito".
- Candidato logueado en su propia entrevista → los campos
recruiter_feedback,company_feedback,recommendationyratingno se devuelven en el JSON (gated enInterviewResourcepor rol). La sección no se renderiza. - Entrevista en
canceladacon motivo → el[cancelado] motivoqueda anexado alrecruiter_feedbacky se muestra ahí, útil para auditoría.
TC-REC-017 · Filtrar listado de entrevistas
Severidad: 🟡 Media
Precondiciones: Recruiter con ≥ 5 entrevistas en distintos estados y rangos de fecha.
Pasos:
/me/entrevistas.- Tab "Historial" → muestra sólo
realizada/cancelada/no_asisto. - Input "Buscar" → escribir parte del nombre del candidato → la lista se reduce client-side.
- Select "Estado" → "Propuesta" → llama al endpoint con
?state=propuesta. - Inputs "Desde" / "Hasta" → seleccionar rango → llama al endpoint con
?from=…&to=…. - Botón "Limpiar filtros" → reset.
Resultado esperado:
- Cada cambio dispara una nueva query (key
[\"interviews\", filtros]). - El contador
(N resultados)aparece cuando hay algún filtro activo. - Empty state contextual: "No hay entrevistas que coincidan con tus filtros" + CTA "Limpiar filtros".
Variaciones:
- Tab "Todas" sin filtros → trae los 30 primeros (paginación backend default).
from>to→ backend acepta y devuelve lista vacía (no validación cruzada en MVP).

