Programa de Instructores
Módulos API: instructor-applications, creators, path-instructors
Páginas web: InstructorProgramPage, AdminApplicationsPage, CreatorProfileSettingsPage, CreatorDashboardPage, CreatorProfilePage
Spec Playwright: apps/e2e/tests/creator/instructor-program.spec.ts — 📋 pendiente
Módulos API cubiertos
Section titled “Módulos API cubiertos”| Endpoint | Módulo | Roles | Descripción |
|---|---|---|---|
POST /instructor-applications | instructor-applications | Público (sin auth) | Enviar solicitud de instructor |
GET /admin/applications | instructor-applications | SUPER_ADMIN | Listar solicitudes con filtro por estado |
GET /admin/applications/stats | instructor-applications | SUPER_ADMIN | KPIs de solicitudes |
PATCH /admin/applications/:id | instructor-applications | SUPER_ADMIN | Marcar en revisión / actualizar notas |
POST /admin/applications/:id/approve | instructor-applications | SUPER_ADMIN | Aprobar solicitud y crear CreatorProfile |
POST /admin/applications/:id/reject | instructor-applications | SUPER_ADMIN | Rechazar solicitud con motivo |
POST /admin/applications/:id/request-info | instructor-applications | SUPER_ADMIN | Solicitar información adicional |
GET /creator/me/profile | creators | CONTENT_ADMIN, SUPER_ADMIN | Obtener perfil del creador autenticado |
PATCH /creator/me/profile | creators | CONTENT_ADMIN, SUPER_ADMIN | Actualizar bio, especialidades, redes |
POST /creator/me/avatar | creators | CONTENT_ADMIN, SUPER_ADMIN | Subir avatar a S3 |
POST /creator/me/banner | creators | CONTENT_ADMIN, SUPER_ADMIN | Subir banner a S3 |
GET /creator/me/stats | creators | CONTENT_ADMIN, SUPER_ADMIN | KPIs del dashboard (estudiantes, rating, rutas) |
GET /creator/me/badges | creators | CONTENT_ADMIN, SUPER_ADMIN | Progreso de badges de instructor |
GET /creators/:username | creators | Público | Perfil público de un creador |
GET /paths/:pathId/instructors | path-instructors | CONTENT_ADMIN, SUPER_ADMIN | Listar co-instructores de una ruta |
POST /paths/:pathId/instructors/invite | path-instructors | CONTENT_ADMIN, SUPER_ADMIN | Invitar co-instructor a una ruta |
PATCH /paths/:pathId/instructors/:userId/permissions | path-instructors | CONTENT_ADMIN, SUPER_ADMIN | Actualizar rol/permisos de co-instructor |
DELETE /paths/:pathId/instructors/:userId | path-instructors | CONTENT_ADMIN, SUPER_ADMIN | Revocar acceso de co-instructor |
POST /path-instructors/invitations/:token/accept | path-instructors | Autenticado | Aceptar invitación de co-instructor |
POST /path-instructors/invitations/:token/decline | path-instructors | Autenticado | Rechazar invitación de co-instructor |
GET /path-instructors/my-collaborations | path-instructors | Autenticado | Mis colaboraciones activas como co-instructor |
Estados de una solicitud
Section titled “Estados de una solicitud”PENDING → IN_REVIEW → APPROVED → REJECTED → WITHDRAWN (retirada por el solicitante)Los motivos de rechazo válidos son: no_experience, generic, covered, no_sector, linkedin, other.
US-INST-001: Solicitar convertirme en instructor
Section titled “US-INST-001: Solicitar convertirme en instructor”US-INST-001: Solicitud pública de instructor
Section titled “US-INST-001: Solicitud pública de instructor”Como visitante o usuario registrado quiero completar un formulario de solicitud para unirme al programa de instructores de AcademIA para que el equipo evalúe mi perfil y propuesta de contenido.
Módulo: instructor-applications · Endpoint: POST /instructor-applications
Página: /instructors → InstructorProgramPage
Estado: ✅ Implementado
Prioridad: Alta
AC-INST-001.1 — Solicitud enviada con datos válidos
Section titled “AC-INST-001.1 — Solicitud enviada con datos válidos”Given que accedo a /instructors y relleno el formulario con | fullName | Ana García López | | email | ana@empresa.com | | linkedinUrl | linkedin.com/in/anagarcia | | currentRole | Data Scientist Senior en Banco Santander | | courseProposal | Curso de 10 lecciones sobre LLMs aplicados a análisis de riesgo crediticio… (>100 chars) |When envío el formularioThen recibo HTTP 201And el body contiene { id: string, message: "Solicitud recibida…" }And la solicitud queda con status PENDING en BDAnd el historial statusHistory contiene un evento { type: "RECEIVED" }AC-INST-001.2 — Duplicado bloqueado para email con solicitud activa
Section titled “AC-INST-001.2 — Duplicado bloqueado para email con solicitud activa”Given que ya existe una solicitud con status PENDING para "ana@empresa.com"When envío una nueva solicitud con el mismo emailThen recibo HTTP 409And el mensaje de error contiene "Ya existe una solicitud activa"AC-INST-001.3 — Validación de campos obligatorios
Section titled “AC-INST-001.3 — Validación de campos obligatorios”Given que omito el campo "courseProposal" en el formularioWhen intento enviar la solicitudThen recibo HTTP 400And la respuesta lista los campos con error de validaciónUS-INST-002: Revisión de solicitudes por el SUPER_ADMIN
Section titled “US-INST-002: Revisión de solicitudes por el SUPER_ADMIN”Como SUPER_ADMIN quiero ver la lista de solicitudes de instructores con su estado y filtrarlas para gestionar el pipeline de incorporación de nuevos creadores.
Módulo: instructor-applications · Endpoints: GET /admin/applications, GET /admin/applications/stats
Página: /admin/applications → AdminApplicationsPage
Estado: ✅ Implementado
Prioridad: Alta
AC-INST-002.1 — Listar todas las solicitudes
Section titled “AC-INST-002.1 — Listar todas las solicitudes”Given que estoy autenticado como SUPER_ADMINWhen hago GET /admin/applicationsThen recibo HTTP 200 con un array de solicitudes ordenadas por createdAt descAnd cada item contiene fullName, email, linkedinUrl, currentRole, courseProposal, statusAC-INST-002.2 — Filtrar por estado
Section titled “AC-INST-002.2 — Filtrar por estado”Given que hay solicitudes en estados PENDING, IN_REVIEW y APPROVEDWhen hago GET /admin/applications?status=PENDINGThen recibo solo las solicitudes con status PENDINGAC-INST-002.3 — KPIs de solicitudes
Section titled “AC-INST-002.3 — KPIs de solicitudes”Given que hay solicitudes en distintos estadosWhen hago GET /admin/applications/statsThen recibo { total, pending, inReview, approved, rejected, withdrawn, weeklyNew }AC-INST-002.4 — Acceso denegado para roles no-admin
Section titled “AC-INST-002.4 — Acceso denegado para roles no-admin”Given que estoy autenticado como CONTENT_ADMINWhen hago GET /admin/applicationsThen recibo HTTP 403US-INST-003: Marcar solicitud en revisión
Section titled “US-INST-003: Marcar solicitud en revisión”Como SUPER_ADMIN quiero marcar una solicitud como IN_REVIEW para señalar que estoy evaluándola activamente.
Módulo: instructor-applications · Endpoint: PATCH /admin/applications/:id
Estado: ✅ Implementado
Prioridad: Media
AC-INST-003.1 — Transición PENDING → IN_REVIEW
Section titled “AC-INST-003.1 — Transición PENDING → IN_REVIEW”Given que existe una solicitud con status PENDING (id = "app-1")When hago PATCH /admin/applications/app-1 con body { status: "IN_REVIEW" }Then recibo HTTP 200And la solicitud tiene status IN_REVIEWAnd statusHistory contiene un evento { type: "IN_REVIEW", actorEmail: <admin_id> }AC-INST-003.2 — No se puede marcar IN_REVIEW desde APPROVED
Section titled “AC-INST-003.2 — No se puede marcar IN_REVIEW desde APPROVED”Given que existe una solicitud con status APPROVEDWhen hago PATCH /admin/applications/:id con body { status: "IN_REVIEW" }Then recibo HTTP 400 con mensaje "Solo se puede marcar en revisión desde PENDING"US-INST-004: Aprobar solicitud y activar acceso Creator Studio
Section titled “US-INST-004: Aprobar solicitud y activar acceso Creator Studio”Como SUPER_ADMIN quiero aprobar una solicitud de instructor y vincularla con un usuario existente de la plataforma para que ese usuario reciba el rol CONTENT_ADMIN y acceda al Creator Studio.
Módulo: instructor-applications · Endpoint: POST /admin/applications/:id/approve
Estado: ✅ Implementado
Prioridad: Alta
AC-INST-004.1 — Aprobación con userId vinculado
Section titled “AC-INST-004.1 — Aprobación con userId vinculado”Given que existe una solicitud en estado IN_REVIEWAnd existe un usuario con id "user-42" en la plataformaWhen hago POST /admin/applications/:id/approve con body { userId: "user-42", reviewerEmail: "admin@nappai.com" }Then recibo HTTP 201And el usuario "user-42" tiene role CONTENT_ADMINAnd se crea un CreatorProfile para "user-42" con isPublic=falseAnd se dispara la notificación CREATOR_APPLICATION_APPROVED al usuarioAnd el audit log registra la acción CREATOR_APPLICATION_APPROVEDAC-INST-004.2 — Aprobación sin userId (solicitante sin cuenta)
Section titled “AC-INST-004.2 — Aprobación sin userId (solicitante sin cuenta)”Given que existe una solicitud en estado PENDINGWhen hago POST /admin/applications/:id/approve sin body (o con userId omitido)Then recibo HTTP 201 y la solicitud queda APPROVEDAnd no se crea CreatorProfile ni se modifica ningún usuarioAC-INST-004.3 — No se puede aprobar una solicitud ya REJECTED
Section titled “AC-INST-004.3 — No se puede aprobar una solicitud ya REJECTED”Given que existe una solicitud con status REJECTEDWhen hago POST /admin/applications/:id/approveThen recibo HTTP 400 con "Solo se puede aprobar desde PENDING o IN_REVIEW"US-INST-005: Rechazar solicitud con motivo
Section titled “US-INST-005: Rechazar solicitud con motivo”Como SUPER_ADMIN quiero rechazar una solicitud indicando un motivo estandarizado para mantener coherencia en la comunicación y posibilitar re-aplicaciones futuras.
Módulo: instructor-applications · Endpoint: POST /admin/applications/:id/reject
Estado: ✅ Implementado
Prioridad: Alta
AC-INST-005.1 — Rechazo con motivo válido
Section titled “AC-INST-005.1 — Rechazo con motivo válido”Given que existe una solicitud en estado IN_REVIEWWhen hago POST /admin/applications/:id/reject con body { reason: "no_experience", message: "Necesitas experiencia real en empresa", internalNotes: "Perfil junior" }Then recibo HTTP 201And la solicitud tiene status REJECTEDAnd si la solicitud tiene un userId → se dispara notificación CREATOR_APPLICATION_REJECTEDAnd el audit log registra CREATOR_APPLICATION_REJECTEDAC-INST-005.2 — Motivo de rechazo inválido rechazado
Section titled “AC-INST-005.2 — Motivo de rechazo inválido rechazado”Given que existe una solicitud en estado PENDINGWhen hago POST /admin/applications/:id/reject con { reason: "motivo_inventado" }Then recibo HTTP 400 con "Motivo de rechazo no válido"US-INST-006: Completar perfil de instructor tras aprobación
Section titled “US-INST-006: Completar perfil de instructor tras aprobación”Como instructor aprobado (CONTENT_ADMIN) quiero completar mi perfil público con bio, foto, especialidades y redes sociales para que los estudiantes puedan conocerme antes de inscribirse a mis rutas.
Módulo: creators · Endpoints: GET /creator/me/profile, PATCH /creator/me/profile, POST /creator/me/avatar
Página: /creator/profile → CreatorProfileSettingsPage
Estado: ✅ Implementado
Prioridad: Alta
AC-INST-006.1 — Guardar cambios de perfil
Section titled “AC-INST-006.1 — Guardar cambios de perfil”Given que estoy autenticado como CONTENT_ADMIN con CreatorProfile existenteWhen hago PATCH /creator/me/profile con { displayName: "Ana García", headline: "LLM Expert", bio: "...", specialties: ["LLMs", "NLP"], yearsExperience: 8 }Then recibo HTTP 200 con el perfil actualizadoAnd los campos se persisten en CreatorProfileAC-INST-006.2 — Subida de avatar (imagen cuadrada)
Section titled “AC-INST-006.2 — Subida de avatar (imagen cuadrada)”Given que estoy autenticado como CONTENT_ADMINWhen hago POST /creator/me/avatar con un archivo JPEG de 200×200px vía multipart/form-dataThen recibo HTTP 201 con { ok: true, avatarUrl: "https://..." }And el campo avatarUrl del CreatorProfile apunta a la URL S3AC-INST-006.3 — Rechazo de archivos de avatar demasiado grandes
Section titled “AC-INST-006.3 — Rechazo de archivos de avatar demasiado grandes”Given que intento subir un avatar de 6 MB (> límite de 5 MB)When hago POST /creator/me/avatarThen recibo HTTP 413 o error de validación de multerAC-INST-006.4 — Especialidades máximo 6
Section titled “AC-INST-006.4 — Especialidades máximo 6”Given que mi perfil ya tiene 6 especialidades seleccionadasWhen intento seleccionar una séptima especialidad en el frontendThen el botón está deshabilitado y no se envía la mutaciónUS-INST-007: Ver estadísticas de instructor en el dashboard
Section titled “US-INST-007: Ver estadísticas de instructor en el dashboard”Como instructor aprobado quiero ver mis KPIs principales (estudiantes, rating, rutas publicadas, tasa de completado) para evaluar el impacto de mi contenido.
Módulo: creators · Endpoint: GET /creator/me/stats
Página: /creator/dashboard → CreatorDashboardPage
Estado: ✅ Implementado
Prioridad: Alta
AC-INST-007.1 — Dashboard devuelve métricas reales
Section titled “AC-INST-007.1 — Dashboard devuelve métricas reales”Given que estoy autenticado como CONTENT_ADMINAnd tengo 3 rutas publicadas con 150 estudiantes en totalWhen hago GET /creator/me/statsThen recibo HTTP 200 con { totalStudents: 150, avgRating: number, publishedPaths: 3, draftPaths: number, completionRate: number, studentsThisMonth: number, activeStudents30d: number }AC-INST-007.2 — Dashboard vacío para instructor sin rutas
Section titled “AC-INST-007.2 — Dashboard vacío para instructor sin rutas”Given que soy CONTENT_ADMIN con CreatorProfile pero sin rutasWhen hago GET /creator/me/statsThen recibo HTTP 200 con todos los contadores a 0And no se produce error 500US-INST-008: Progreso de badges de instructor
Section titled “US-INST-008: Progreso de badges de instructor”Como instructor quiero ver mis badges obtenidos y el progreso hacia los siguientes para entender cómo avanzar en el programa de reconocimiento de AcademIA.
Módulo: creators · Endpoint: GET /creator/me/badges
Página: /creator/badges → CreatorBadgesPage
Estado: ✅ Implementado
Prioridad: Media
AC-INST-008.1 — Obtener progreso de badges
Section titled “AC-INST-008.1 — Obtener progreso de badges”Given que estoy autenticado como CONTENT_ADMINWhen hago GET /creator/me/badgesThen recibo un array donde cada item contiene { badge: { slug, name, tier, iconEmoji }, granted: boolean, progress: number (0-100), currentValue, targetValue }AC-INST-008.2 — Auto-otorgamiento de badge al superar criterio
Section titled “AC-INST-008.2 — Auto-otorgamiento de badge al superar criterio”Given que tengo 100 estudiantes (criterio de "primeros-estudiantes" = 50 estudiantes)When hago GET /creator/me/badgesThen el badge "primeros-estudiantes" tiene granted=trueAnd se ha creado el registro CreatorBadgeGrant en BD automáticamenteUS-INST-009: Perfil público del instructor
Section titled “US-INST-009: Perfil público del instructor”Como visitante o estudiante quiero ver el perfil público de un instructor para conocer su experiencia, especialidades y rutas disponibles antes de inscribirme.
Módulo: creators · Endpoint: GET /creators/:username
Página: /creators/:username → CreatorProfilePage
Estado: ✅ Implementado
Prioridad: Media
AC-INST-009.1 — Perfil público accesible sin autenticación
Section titled “AC-INST-009.1 — Perfil público accesible sin autenticación”Given que existe un CreatorProfile con username "ana-garcia" e isPublic=trueWhen hago GET /creators/ana-garcia (sin JWT)Then recibo HTTP 200 con displayName, bio, specialties, badges, paths publicados y stats realesAC-INST-009.2 — Perfil privado no visible al público
Section titled “AC-INST-009.2 — Perfil privado no visible al público”Given que existe un CreatorProfile con isPublic=false (recién aprobado)When hago GET /creators/ana-garcia (sin JWT)Then recibo HTTP 404US-INST-010: Invitar co-instructor a una ruta
Section titled “US-INST-010: Invitar co-instructor a una ruta”Como instructor coordinador (CONTENT_ADMIN) quiero invitar a otro instructor a colaborar en una de mis rutas asignándole un rol y permisos específicos para distribuir el trabajo de creación de contenido.
Módulo: path-instructors · Endpoints: POST /paths/:pathId/instructors/invite, GET /paths/:pathId/instructors
Estado: ✅ Implementado
Prioridad: Media
AC-INST-010.1 — Invitar co-instructor con email y rol
Section titled “AC-INST-010.1 — Invitar co-instructor con email y rol”Given que soy coordinador de la ruta "path-ia-banca"When hago POST /paths/path-ia-banca/instructors/invite con { email: "colega@empresa.com", role: "CONTRIBUTOR", permissions: { canEditContent: true } }Then recibo HTTP 201 con { ok: true }And se crea un registro PathInstructor con inviteStatus=PENDINGAnd se envía notificación al destinatario (PATH_INSTRUCTOR_INVITED)And el audit log registra PATH_INSTRUCTOR_INVITEDAC-INST-010.2 — Listar co-instructores de una ruta
Section titled “AC-INST-010.2 — Listar co-instructores de una ruta”Given que la ruta "path-ia-banca" tiene 2 co-instructoresWhen hago GET /paths/path-ia-banca/instructorsThen recibo un array con id, userId, role, inviteStatus, permissions y user.fullName de cada unoUS-INST-011: Aceptar o rechazar invitación de co-instructor
Section titled “US-INST-011: Aceptar o rechazar invitación de co-instructor”Como instructor invitado quiero aceptar o rechazar una invitación para colaborar en una ruta para tener control sobre las colaboraciones en las que participo.
Módulo: path-instructors · Endpoints: POST /path-instructors/invitations/:token/accept, POST /path-instructors/invitations/:token/decline
Estado: ✅ Implementado
Prioridad: Media
AC-INST-011.1 — Aceptar invitación con token válido
Section titled “AC-INST-011.1 — Aceptar invitación con token válido”Given que tengo un token de invitación válido y no expiradoAnd estoy autenticado como CONTENT_ADMINWhen hago POST /path-instructors/invitations/:token/acceptThen recibo HTTP 201 con { ok: true, pathSlug: "path-ia-banca" }And el PathInstructor tiene inviteStatus=ACCEPTED y acceptedAt != nullAnd el audit log registra PATH_INSTRUCTOR_ACCEPTEDAC-INST-011.2 — Rechazar invitación
Section titled “AC-INST-011.2 — Rechazar invitación”Given que tengo un token de invitación válidoWhen hago POST /path-instructors/invitations/:token/declineThen recibo HTTP 201 con { ok: true }And el PathInstructor tiene inviteStatus=DECLINEDAnd el audit log registra PATH_INSTRUCTOR_DECLINEDAC-INST-011.3 — Token expirado no procesable
Section titled “AC-INST-011.3 — Token expirado no procesable”Given que el token de invitación ha expiradoWhen hago POST /path-instructors/invitations/:token/acceptThen recibo HTTP 400 o 404 indicando que la invitación no es válidaTest Cases
Section titled “Test Cases”TC-INST-001 — Flujo completo de solicitud pública
Section titled “TC-INST-001 — Flujo completo de solicitud pública”Cubre: AC-INST-001.1, AC-INST-001.2 Tipo: E2E (API)
test('TC-INST-001: submit instructor application — success and duplicate blocked', async ({ request }) => { const payload = { fullName: 'Ana García López', email: `ana+${Date.now()}@test.com`, linkedinUrl: 'linkedin.com/in/anagarcia', currentRole: 'Data Scientist Senior en empresa financiera con 8 años de experiencia', courseProposal: 'Propongo un curso de 10 lecciones prácticas sobre aplicación de LLMs en análisis de riesgo crediticio, con casos reales del sector bancario español y ejercicios hands-on con Python y LangChain.', }
// Primera solicitud — debe funcionar const res1 = await request.post('/instructor-applications', { data: payload }) expect(res1.status()).toBe(201) const body1 = await res1.json() expect(body1).toHaveProperty('id') expect(body1.message).toContain('Solicitud recibida')
// Segunda solicitud con el mismo email — debe bloquearse const res2 = await request.post('/instructor-applications', { data: payload }) expect(res2.status()).toBe(409) const body2 = await res2.json() expect(body2.message).toContain('Ya existe una solicitud activa')})TC-INST-002 — Validación de campos del formulario
Section titled “TC-INST-002 — Validación de campos del formulario”Cubre: AC-INST-001.3 Tipo: E2E (API)
test('TC-INST-002: reject application with missing required fields', async ({ request }) => { // courseProposal muy corto (< 100 chars) const res = await request.post('/instructor-applications', { data: { fullName: 'Test User', email: 'test@example.com', linkedinUrl: 'linkedin.com/in/test', currentRole: 'Developer with 5 years of AI experience in multiple projects', courseProposal: 'Demasiado corto', }, }) expect(res.status()).toBe(400)
// fullName vacío const res2 = await request.post('/instructor-applications', { data: { fullName: 'A', email: 'test2@example.com', linkedinUrl: 'linkedin.com/in/test', currentRole: 'Descripción válida del rol con suficiente contenido', courseProposal: 'x'.repeat(110), }, }) expect(res2.status()).toBe(400)})TC-INST-003 — Listar y filtrar solicitudes como SUPER_ADMIN
Section titled “TC-INST-003 — Listar y filtrar solicitudes como SUPER_ADMIN”Cubre: AC-INST-002.1, AC-INST-002.2, AC-INST-002.4 Tipo: E2E (API)
test('TC-INST-003: admin can list and filter applications', async ({ request }) => { // Autenticar como SUPER_ADMIN const loginRes = await request.post('/auth/login', { data: { email: process.env.SUPER_ADMIN_EMAIL, password: process.env.SUPER_ADMIN_PASSWORD }, }) const { accessToken } = await loginRes.json() const authHeaders = { Authorization: `Bearer ${accessToken}` }
// Listar todas const listRes = await request.get('/admin/applications', { headers: authHeaders }) expect(listRes.status()).toBe(200) const apps = await listRes.json() expect(Array.isArray(apps)).toBe(true)
// Filtrar por PENDING const pendingRes = await request.get('/admin/applications?status=PENDING', { headers: authHeaders }) expect(pendingRes.status()).toBe(200) const pending = await pendingRes.json() pending.forEach((a: any) => expect(a.status).toBe('PENDING'))
// Acceso denegado sin auth const noAuthRes = await request.get('/admin/applications') expect(noAuthRes.status()).toBe(401)})TC-INST-004 — Aprobación completa de solicitud con usuario vinculado
Section titled “TC-INST-004 — Aprobación completa de solicitud con usuario vinculado”Cubre: AC-INST-004.1 Tipo: E2E (API)
test('TC-INST-004: approve application links user as CONTENT_ADMIN and creates CreatorProfile', async ({ request }) => { const adminLogin = await request.post('/auth/login', { data: { email: process.env.SUPER_ADMIN_EMAIL, password: process.env.SUPER_ADMIN_PASSWORD }, }) const { accessToken: adminToken } = await adminLogin.json() const auth = { Authorization: `Bearer ${adminToken}` }
// Crear solicitud const appRes = await request.post('/instructor-applications', { data: { fullName: 'Carlos Instructor', email: `carlos+${Date.now()}@test.com`, linkedinUrl: 'linkedin.com/in/carlos', currentRole: 'Senior ML Engineer con amplia trayectoria en banca y fintech', courseProposal: 'Propongo un itinerario completo sobre MLOps aplicado a modelos de scoring crediticio, incluyendo monitorización, drift detection y compliance regulatorio en el sector financiero europeo.', }, }) const { id: appId } = await appRes.json()
// Aprobar sin userId (no requiere usuario existente para el test básico) const approveRes = await request.post(`/admin/applications/${appId}/approve`, { headers: auth, data: { reviewerEmail: process.env.SUPER_ADMIN_EMAIL }, }) expect(approveRes.status()).toBe(201) const approvedApp = await approveRes.json() expect(approvedApp.status).toBe('APPROVED')})TC-INST-005 — Rechazo de solicitud con motivo válido e inválido
Section titled “TC-INST-005 — Rechazo de solicitud con motivo válido e inválido”Cubre: AC-INST-005.1, AC-INST-005.2 Tipo: E2E (API)
test('TC-INST-005: reject application with valid and invalid reasons', async ({ request }) => { const adminLogin = await request.post('/auth/login', { data: { email: process.env.SUPER_ADMIN_EMAIL, password: process.env.SUPER_ADMIN_PASSWORD }, }) const { accessToken } = await adminLogin.json() const auth = { Authorization: `Bearer ${accessToken}` }
// Crear solicitud para rechazar const appRes = await request.post('/instructor-applications', { data: { fullName: 'Luis Candidato', email: `luis+${Date.now()}@test.com`, linkedinUrl: 'linkedin.com/in/luis', currentRole: 'Junior Developer sin experiencia real en proyectos de producción', courseProposal: 'Quiero enseñar Python básico para principiantes sin conocimiento previo de programación, cubriendo variables y funciones desde cero con ejemplos sencillos y ejercicios fáciles.', }, }) const { id: appId } = await appRes.json()
// Rechazar con motivo válido const rejectRes = await request.post(`/admin/applications/${appId}/reject`, { headers: auth, data: { reason: 'no_experience', message: 'Perfil sin experiencia real demostrable', internalNotes: 'Junior sin proyectos relevantes' }, }) expect(rejectRes.status()).toBe(201)
// Crear segunda solicitud para probar motivo inválido const appRes2 = await request.post('/instructor-applications', { data: { fullName: 'María Test', email: `maria+${Date.now()}@test.com`, linkedinUrl: 'linkedin.com/in/maria', currentRole: 'Product Manager con experiencia en startups de IA y equipos de datos multidisciplinares', courseProposal: 'Propongo un curso sobre product management para productos de IA, incluyendo cómo definir métricas, gestionar equipos de datos y hacer roadmaps orientados al impacto del negocio.', }, }) const { id: appId2 } = await appRes2.json()
const badRejectRes = await request.post(`/admin/applications/${appId2}/reject`, { headers: auth, data: { reason: 'motivo_inventado', message: 'Mensaje de prueba' }, }) expect(badRejectRes.status()).toBe(400)})TC-INST-006 — Actualización de perfil de instructor
Section titled “TC-INST-006 — Actualización de perfil de instructor”Cubre: AC-INST-006.1, AC-INST-006.4 Tipo: E2E (API)
test('TC-INST-006: CONTENT_ADMIN can update creator profile', async ({ request }) => { // Login como CONTENT_ADMIN const loginRes = await request.post('/auth/login', { data: { email: process.env.CONTENT_ADMIN_EMAIL, password: process.env.CONTENT_ADMIN_PASSWORD }, }) const { accessToken } = await loginRes.json() const auth = { Authorization: `Bearer ${accessToken}` }
const updateRes = await request.patch('/creator/me/profile', { headers: auth, data: { displayName: 'Ana García López', headline: 'LLM Expert & AI Educator', bio: 'Especialista en aplicaciones prácticas de LLMs en el sector bancario con 8 años de experiencia.', specialties: ['LLMs', 'NLP', 'Python', 'Data Science', 'Machine Learning', 'MLOps'], yearsExperience: 8, linkedinUrl: 'https://linkedin.com/in/anagarcia', }, }) expect(updateRes.status()).toBe(200) const profile = await updateRes.json() expect(profile.displayName).toBe('Ana García López') expect(profile.specialties).toHaveLength(6)
// Verificar que el GET devuelve los cambios const getRes = await request.get('/creator/me/profile', { headers: auth }) expect(getRes.status()).toBe(200) const fetched = await getRes.json() expect(fetched.headline).toBe('LLM Expert & AI Educator') expect(fetched.yearsExperience).toBe(8)})TC-INST-007 — Perfil público inaccesible si isPublic=false
Section titled “TC-INST-007 — Perfil público inaccesible si isPublic=false”Cubre: AC-INST-009.2 Tipo: E2E (API)
test('TC-INST-007: private creator profile returns 404 to public', async ({ request }) => { // Este test asume que existe un CreatorProfile con isPublic=false y username conocido // En entorno de CI usar un fixture de BD o crear el perfil con un helper const res = await request.get('/creators/instructor-draft-profile') expect(res.status()).toBe(404)})TC-INST-008 — Dashboard de stats del instructor
Section titled “TC-INST-008 — Dashboard de stats del instructor”Cubre: AC-INST-007.1, AC-INST-007.2 Tipo: E2E (API)
test('TC-INST-008: creator dashboard stats endpoint', async ({ request }) => { const loginRes = await request.post('/auth/login', { data: { email: process.env.CONTENT_ADMIN_EMAIL, password: process.env.CONTENT_ADMIN_PASSWORD }, }) const { accessToken } = await loginRes.json() const auth = { Authorization: `Bearer ${accessToken}` }
const statsRes = await request.get('/creator/me/stats', { headers: auth }) expect(statsRes.status()).toBe(200)
const stats = await statsRes.json() expect(stats).toHaveProperty('totalStudents') expect(stats).toHaveProperty('avgRating') expect(stats).toHaveProperty('publishedPaths') expect(stats).toHaveProperty('draftPaths') expect(stats).toHaveProperty('completionRate') expect(stats).toHaveProperty('activeStudents30d') expect(typeof stats.totalStudents).toBe('number') expect(typeof stats.avgRating).toBe('number')})TC-INST-009 — Invitar co-instructor a una ruta
Section titled “TC-INST-009 — Invitar co-instructor a una ruta”Cubre: AC-INST-010.1, AC-INST-010.2 Tipo: E2E (API)
test('TC-INST-009: coordinator can invite co-instructor to a path', async ({ request }) => { const coordinatorLogin = await request.post('/auth/login', { data: { email: process.env.CONTENT_ADMIN_EMAIL, password: process.env.CONTENT_ADMIN_PASSWORD }, }) const { accessToken } = await coordinatorLogin.json() const auth = { Authorization: `Bearer ${accessToken}` }
// Obtener pathId de una de las rutas del coordinador const pathsRes = await request.get('/creator/me/paths', { headers: auth }) expect(pathsRes.status()).toBe(200) const paths = await pathsRes.json() expect(paths.length).toBeGreaterThan(0) const pathId = paths[0].id
// Invitar co-instructor const inviteRes = await request.post(`/paths/${pathId}/instructors/invite`, { headers: auth, data: { email: process.env.COLLABORATOR_EMAIL, role: 'CONTRIBUTOR', permissions: { canEditContent: true, canViewStudents: false }, }, }) expect(inviteRes.status()).toBe(201)
// Listar co-instructores const listRes = await request.get(`/paths/${pathId}/instructors`, { headers: auth }) expect(listRes.status()).toBe(200) const instructors = await listRes.json() const invited = instructors.find((i: any) => i.user.email === process.env.COLLABORATOR_EMAIL) expect(invited).toBeDefined() expect(invited.role).toBe('CONTRIBUTOR') expect(invited.inviteStatus).toBe('PENDING')})TC-INST-010 — KPIs de solicitudes disponibles para SUPER_ADMIN
Section titled “TC-INST-010 — KPIs de solicitudes disponibles para SUPER_ADMIN”Cubre: AC-INST-002.3 Tipo: E2E (API)
test('TC-INST-010: application stats endpoint returns all counters', async ({ request }) => { const loginRes = await request.post('/auth/login', { data: { email: process.env.SUPER_ADMIN_EMAIL, password: process.env.SUPER_ADMIN_PASSWORD }, }) const { accessToken } = await loginRes.json() const auth = { Authorization: `Bearer ${accessToken}` }
const statsRes = await request.get('/admin/applications/stats', { headers: auth }) expect(statsRes.status()).toBe(200)
const stats = await statsRes.json() expect(stats).toHaveProperty('total') expect(stats).toHaveProperty('pending') expect(stats).toHaveProperty('inReview') expect(stats).toHaveProperty('approved') expect(stats).toHaveProperty('rejected') expect(stats).toHaveProperty('withdrawn') expect(stats).toHaveProperty('weeklyNew') // Todos los valores deben ser enteros no negativos Object.values(stats).forEach((v) => { expect(typeof v).toBe('number') expect(v as number).toBeGreaterThanOrEqual(0) })})Diagrama de flujo del programa
Section titled “Diagrama de flujo del programa”flowchart TD V[Visitante / Usuario] -->|POST /instructor-applications| A[Solicitud PENDING] A -->|PATCH status=IN_REVIEW| B[Solicitud IN_REVIEW] A -->|POST /approve| C[Solicitud APPROVED] B -->|POST /approve| C B -->|POST /reject| D[Solicitud REJECTED] B -->|POST /request-info| B A -->|POST /reject| D C -->|userId vinculado| E[User.role = CONTENT_ADMIN] E --> F[CreatorProfile isPublic=false] F -->|PATCH /creator/me/profile| G[Perfil completado] G -->|SUPER_ADMIN activa isPublic| H[Perfil público visible] E --> I[Acceso Creator Studio /creator/*]