Moderación de Foro
Módulo API: forum (controller único forum.controller.ts)
Página web: AdminForumModerationPage (apps/web/src/pages/admin/AdminForumModerationPage.tsx)
RTK Query: forumApi.ts — endpoints admin bajo /admin/forum/
Spec Playwright: apps/e2e/tests/admin/forum-moderation.spec.ts — 📋 pendiente
Endpoints cubiertos
Section titled “Endpoints cubiertos”| Endpoint | Rol mínimo | Estado |
|---|---|---|
GET /admin/forum/stats | SUPER_ADMIN | ✅ Implementado |
GET /admin/forum/reported | SUPER_ADMIN | ✅ Implementado (devuelve [] — Phase 1 log-only) |
GET /admin/forum/confusion-alerts | SUPER_ADMIN | ✅ Implementado |
POST /admin/forum/confusion-alerts/:alertId/resolve | SUPER_ADMIN | ✅ Implementado |
GET /admin/forum/academIA-replies | SUPER_ADMIN | ✅ Implementado |
GET /admin/forum/highlights | SUPER_ADMIN | ✅ Implementado |
POST /admin/forum/seed-starters/:pathId | SUPER_ADMIN | ✅ Implementado (202 async) |
POST /forum/threads/:threadId/pin | CONTENT_ADMIN, SUPER_ADMIN | ✅ Implementado |
POST /forum/threads/:threadId/lock | CONTENT_ADMIN, SUPER_ADMIN | ✅ Implementado |
POST /forum/threads/:threadId/resolve | Autor o admin | ✅ Implementado |
POST /forum/replies/:replyId/official | CONTENT_ADMIN, SUPER_ADMIN | ✅ Implementado |
DELETE /forum/threads/:threadId | Autor o admin | ✅ Implementado |
DELETE /forum/replies/:replyId | Autor o admin | ✅ Implementado |
POST /forum/threads/:threadId/report | Autenticado | ✅ Phase 1 (log only) |
POST /forum/replies/:replyId/report | Autenticado | ✅ Phase 1 (log only) |
GET /admin/forum/reports (resolve via UI) | SUPER_ADMIN | 📋 Phase 2 planificado |
POST /admin/forum/bans | — | 📋 No implementado |
User Stories
Section titled “User Stories”US-ADM-FOR-001: Ver KPIs globales del foro
Section titled “US-ADM-FOR-001: Ver KPIs globales del foro”Como SUPER_ADMIN quiero ver métricas globales del foro (posts totales, reportados, actividad AcademIA, alertas de confusión) para tener visibilidad del estado de la comunidad en un solo vistazo.
Módulo: forum · Endpoint: GET /admin/forum/stats
Estado: ✅ Implementado
Prioridad: Alta
AC-ADM-FOR-001.1 — KPIs visibles en el panel de moderación
Section titled “AC-ADM-FOR-001.1 — KPIs visibles en el panel de moderación”Given un usuario autenticado con rol SUPER_ADMINWhen navega a /admin/forumThen ve cinco tarjetas KPI: "Posts totales", "Reportados pendientes", "Respuestas AcademIA/semana", "Satisfacción IA" y "Alertas confusión activas"And cada KPI muestra un valor numérico y un indicador de tendenciaAC-ADM-FOR-001.2 — KPI de reportados muestra cero cuando no hay pendientes
Section titled “AC-ADM-FOR-001.2 — KPI de reportados muestra cero cuando no hay pendientes”Given no existen ForumReport con status PENDING en la BDWhen el SUPER_ADMIN carga el panel de moderaciónThen la tarjeta "Reportados pendientes" muestra "0" sin color de alertaAnd el texto delta dice "Sin pendientes"AC-ADM-FOR-001.3 — Panel AcademIA desglosa por nivel de confianza
Section titled “AC-ADM-FOR-001.3 — Panel AcademIA desglosa por nivel de confianza”Given el servicio AcademIA ha generado respuestas esta semanaWhen el SUPER_ADMIN visualiza la sección "AcademIA · Esta semana"Then ve tres métricas: respuestas totales, desglose "alta / media / baja" confianzaAnd el coste semanal IA muestra una barra de progreso respecto al límite configuradoUS-ADM-FOR-002: Revisar posts reportados
Section titled “US-ADM-FOR-002: Revisar posts reportados”Como SUPER_ADMIN quiero ver la lista de hilos reportados pendientes de moderación para tomar acción sobre contenido que viola las normas.
Módulo: forum · Endpoint: GET /admin/forum/reported
Estado: ✅ Implementado (Phase 1: devuelve []; ForumReport entity en schema.prisma lista para Phase 2)
Prioridad: Alta
AC-ADM-FOR-002.1 — La pestaña “Reportados” muestra estado vacío en Phase 1
Section titled “AC-ADM-FOR-002.1 — La pestaña “Reportados” muestra estado vacío en Phase 1”Given el sistema está en Phase 1 (reportes solo en logs)When el SUPER_ADMIN abre la pestaña "Reportados" en /admin/forumThen ve un estado vacío con el mensaje "Sin posts reportados" y checkmark verdeAnd no hay filas en la tabla de reportesAC-ADM-FOR-002.2 — Un usuario puede reportar un hilo (log-only)
Section titled “AC-ADM-FOR-002.2 — Un usuario puede reportar un hilo (log-only)”Given un usuario autenticado navega a un hilo de foroWhen hace clic en "Reportar" y envía la razón "Spam"Then recibe respuesta 200 del endpoint POST /forum/threads/:threadId/reportAnd el logger del servidor registra: "Forum report [THREAD]: threadId=... reason=Spam"But no se crea ningún registro en la tabla ForumReport de la BDUS-ADM-FOR-003: Eliminar un hilo o respuesta como moderador
Section titled “US-ADM-FOR-003: Eliminar un hilo o respuesta como moderador”Como CONTENT_ADMIN o SUPER_ADMIN quiero eliminar un hilo o respuesta que viola las normas para mantener la calidad de la comunidad.
Módulo: forum
Endpoints: DELETE /forum/threads/:threadId, DELETE /forum/replies/:replyId
Estado: ✅ Implementado
Prioridad: Alta
AC-ADM-FOR-003.1 — El admin elimina cualquier hilo sin ser el autor
Section titled “AC-ADM-FOR-003.1 — El admin elimina cualquier hilo sin ser el autor”Given el SUPER_ADMIN está autenticadoAnd existe un ForumThread con id "thread-123" creado por otro usuarioWhen hace DELETE /forum/threads/thread-123Then recibe 204 No ContentAnd el hilo y todas sus respuestas (cascade) son eliminados de la BDAnd el logger registra "Forum thread thread-123 deleted by user <adminId>"AC-ADM-FOR-003.2 — Un usuario sin permisos no puede eliminar hilos ajenos
Section titled “AC-ADM-FOR-003.2 — Un usuario sin permisos no puede eliminar hilos ajenos”Given un usuario con rol FREE está autenticadoAnd existe un ForumThread creado por otro usuarioWhen hace DELETE /forum/threads/:threadIdThen recibe 403 ForbiddenAnd el hilo permanece en la BDAC-ADM-FOR-003.3 — El admin puede eliminar directamente desde la tabla de reportados
Section titled “AC-ADM-FOR-003.3 — El admin puede eliminar directamente desde la tabla de reportados”Given el SUPER_ADMIN está en la pestaña "Reportados" del panel de moderaciónAnd la tabla muestra al menos un hilo (cuando Phase 2 esté activo)When hace clic en el botón "Eliminar" de un hiloThen se invoca DELETE /forum/threads/:threadIdAnd la fila desaparece de la tabla sin recargar la páginaUS-ADM-FOR-004: Fijar y desfijar un hilo (pin)
Section titled “US-ADM-FOR-004: Fijar y desfijar un hilo (pin)”Como CONTENT_ADMIN o SUPER_ADMIN quiero marcar un hilo como fijado para que aparezca en primer lugar en la lista del foro y sea visible para todos los estudiantes.
Módulo: forum · Endpoint: POST /forum/threads/:threadId/pin
Estado: ✅ Implementado
Prioridad: Alta
AC-ADM-FOR-004.1 — El admin puede fijar un hilo
Section titled “AC-ADM-FOR-004.1 — El admin puede fijar un hilo”Given el CONTENT_ADMIN está autenticadoAnd existe un ForumThread con isPinned = falseWhen hace POST /forum/threads/:threadId/pin con body { "isPinned": true }Then recibe 200 con el thread actualizado donde isPinned = trueAnd el hilo aparece al inicio de la lista ordenada por isPinned DESCAC-ADM-FOR-004.2 — Desfijar un hilo limpia pinnedUntil
Section titled “AC-ADM-FOR-004.2 — Desfijar un hilo limpia pinnedUntil”Given existe un ForumThread con isPinned = true y pinnedUntil = <fecha futura>When el admin hace POST /forum/threads/:threadId/pin con body { "isPinned": false }Then el hilo queda con isPinned = false y pinnedUntil = nullAnd ya no aparece destacado en la lista del foroAC-ADM-FOR-004.3 — Un estudiante sin rol admin no puede fijar hilos
Section titled “AC-ADM-FOR-004.3 — Un estudiante sin rol admin no puede fijar hilos”Given un usuario con rol STARTER está autenticadoWhen hace POST /forum/threads/:threadId/pinThen recibe 403 Forbidden con mensaje de falta de permisosUS-ADM-FOR-005: Bloquear y desbloquear un hilo (lock)
Section titled “US-ADM-FOR-005: Bloquear y desbloquear un hilo (lock)”Como CONTENT_ADMIN o SUPER_ADMIN quiero bloquear un hilo para impedir que los estudiantes añadan nuevas respuestas sin eliminar el contenido existente.
Módulo: forum · Endpoint: POST /forum/threads/:threadId/lock
Estado: ✅ Implementado
Prioridad: Alta
AC-ADM-FOR-005.1 — Admin bloquea un hilo activo
Section titled “AC-ADM-FOR-005.1 — Admin bloquea un hilo activo”Given existe un ForumThread con isLocked = falseAnd el SUPER_ADMIN está autenticadoWhen hace POST /forum/threads/:threadId/lock con body { "isLocked": true }Then recibe 200 con el thread donde isLocked = trueAnd al intentar crear una respuesta en ese hilo se recibe 403 "This thread is locked"AC-ADM-FOR-005.2 — Un usuario no-admin no puede editar su propio hilo bloqueado
Section titled “AC-ADM-FOR-005.2 — Un usuario no-admin no puede editar su propio hilo bloqueado”Given existe un ForumThread creado por el usuario A con isLocked = trueWhen el usuario A (rol PRO) intenta hacer PATCH /forum/threads/:threadIdThen recibe 403 Forbidden "This thread is locked"And el admin sí puede editar el hilo bloqueadoUS-ADM-FOR-006: Marcar una respuesta como oficial
Section titled “US-ADM-FOR-006: Marcar una respuesta como oficial”Como CONTENT_ADMIN o SUPER_ADMIN quiero marcar una respuesta de estudiante como oficial para destacarla como la respuesta validada del instructor.
Módulo: forum · Endpoint: POST /forum/replies/:replyId/official
Estado: ✅ Implementado
Prioridad: Media
AC-ADM-FOR-006.1 — Marcar respuesta oficial notifica al autor
Section titled “AC-ADM-FOR-006.1 — Marcar respuesta oficial notifica al autor”Given existe un ForumReply con isOfficial = falseAnd el CONTENT_ADMIN está autenticadoWhen hace POST /forum/replies/:replyId/officialThen recibe 200 con la respuesta donde isOfficial = trueAnd se envía una notificación al autor del tipo "FORUM_REPLY_MARKED_OFFICIAL"And la notificación lleva el título "Tu respuesta fue marcada como oficial"AC-ADM-FOR-006.2 — Solo CONTENT_ADMIN y SUPER_ADMIN pueden marcar respuestas oficiales
Section titled “AC-ADM-FOR-006.2 — Solo CONTENT_ADMIN y SUPER_ADMIN pueden marcar respuestas oficiales”Given un usuario con rol PRO está autenticadoWhen hace POST /forum/replies/:replyId/officialThen recibe 403 ForbiddenUS-ADM-FOR-007: Gestionar alertas de confusión colectiva
Section titled “US-ADM-FOR-007: Gestionar alertas de confusión colectiva”Como SUPER_ADMIN quiero ver y resolver alertas de confusión colectiva detectadas por el job diario para identificar módulos con alta densidad de preguntas sin responder y fallos en quizzes.
Módulo: forum · Endpoints: GET /admin/forum/confusion-alerts, POST /admin/forum/confusion-alerts/:alertId/resolve
Job: ForumCollectiveConfusionJob — cron diario a las 06:00
Estado: ✅ Implementado
Prioridad: Alta
AC-ADM-FOR-007.1 — La tabla de alertas muestra módulos problemáticos
Section titled “AC-ADM-FOR-007.1 — La tabla de alertas muestra módulos problemáticos”Given el job ha detectado al menos un módulo con ≥5 preguntas sin responder en 7 díasWhen el SUPER_ADMIN abre la pestaña "Alertas confusión" en /admin/forumThen ve una fila por cada AIAlert de tipo COLLECTIVE_CONFUSION con isResolved = falseAnd cada fila muestra: moduleId truncado, mensaje diagnóstico, unansweredCount, quizFailureRate y severidadAC-ADM-FOR-007.2 — Resolver una alerta la marca como procesada
Section titled “AC-ADM-FOR-007.2 — Resolver una alerta la marca como procesada”Given existe un AIAlert con id "alert-abc" de tipo COLLECTIVE_CONFUSION e isResolved = falseWhen el SUPER_ADMIN hace clic en "Resolver"Then se invoca POST /admin/forum/confusion-alerts/alert-abc/resolveAnd la alerta queda con isResolved = true y la fila aparece con opacidad reducidaAnd el contador "Alertas confusión activas" en el KPI se decrementaAC-ADM-FOR-007.3 — El job dispara notificaciones a CONTENT_ADMIN y SUPER_ADMIN
Section titled “AC-ADM-FOR-007.3 — El job dispara notificaciones a CONTENT_ADMIN y SUPER_ADMIN”Given un módulo supera el umbral: ≥5 preguntas sin responder Y ≥40% fallo en quizzesWhen el ForumCollectiveConfusionJob ejecuta su cron a las 06:00Then crea un AIAlert de tipo COLLECTIVE_CONFUSION en la BDAnd envía una notificación a todos los usuarios CONTENT_ADMIN con tipo "FORUM_COLLECTIVE_CONFUSION"And envía una notificación a todos los usuarios SUPER_ADMIN con el mismo tipoAnd el actionUrl para CONTENT_ADMIN apunta a /creator/paths/:pathId/modules/:moduleIdUS-ADM-FOR-008: Monitorizar respuestas generadas por AcademIA
Section titled “US-ADM-FOR-008: Monitorizar respuestas generadas por AcademIA”Como SUPER_ADMIN quiero ver el historial de respuestas automáticas generadas por AcademIA con su nivel de confianza y engagement para supervisar la calidad de la IA del foro.
Módulo: forum · Endpoint: GET /admin/forum/academIA-replies?page=1&limit=20
Estado: ✅ Implementado
Prioridad: Media
AC-ADM-FOR-008.1 — La tabla muestra respuestas IA con badge de confianza
Section titled “AC-ADM-FOR-008.1 — La tabla muestra respuestas IA con badge de confianza”Given existen ForumReply con isAcademIA = true en la BDWhen el SUPER_ADMIN abre la pestaña "Respuestas AcademIA"Then ve una tabla con columnas: Thread, Extracto del cuerpo, Confianza, Upvotes, GeneradaAnd cada fila muestra un badge de color: - verde "Alta" si academIAConf > 0.8 - ámbar "Media" si academIAConf entre 0.5 y 0.8 - rojo "Baja" si academIAConf < 0.5AC-ADM-FOR-008.2 — La cabecera muestra total de respuestas en la semana
Section titled “AC-ADM-FOR-008.2 — La cabecera muestra total de respuestas en la semana”Given el panel AcademIA está cargadoWhen el SUPER_ADMIN visualiza la cabecera de la tablaThen ve el texto "X respuestas generadas · Página 1"And el total corresponde al count de replies donde isAcademIA = true creadas en los últimos 7 díasUS-ADM-FOR-009: Ver y gestionar los destacados semanales
Section titled “US-ADM-FOR-009: Ver y gestionar los destacados semanales”Como SUPER_ADMIN quiero ver qué hilos están actualmente fijados como destacados de la semana para saber qué contenido el sistema ha seleccionado automáticamente.
Módulo: forum · Endpoint: GET /admin/forum/highlights
Job: ForumWeeklyHighlightJob — cron cada lunes a las 08:00
Estado: ✅ Implementado
Prioridad: Media
AC-ADM-FOR-009.1 — La pestaña muestra los hilos con pinnedUntil activo
Section titled “AC-ADM-FOR-009.1 — La pestaña muestra los hilos con pinnedUntil activo”Given el ForumWeeklyHighlightJob ha fijado al menos un hilo esta semanaWhen el SUPER_ADMIN abre la pestaña "Destacados semana"Then ve una tarjeta por cada ForumThread donde isPinned = true y pinnedUntil > now()And cada tarjeta muestra: título, extracto del body, upvotes, replies, views, path y autorAnd se muestra la fecha de expiración del pin en color ámbarAC-ADM-FOR-009.2 — El job otorga el badge “contribuidor-top” al autor del hilo ganador
Section titled “AC-ADM-FOR-009.2 — El job otorga el badge “contribuidor-top” al autor del hilo ganador”Given el ForumWeeklyHighlightJob se ejecuta el lunes a las 08:00And existe un hilo con score (upvotes×3 + replies×2 + views×0.1) ≥ 3When el job selecciona el hilo ganador para un pathThen otorga el badge con slug "contribuidor-top" al autor si no lo tiene yaAnd notifica al autor con tipo "FORUM_WEEKLY_HIGHLIGHT"And notifica a todos los estudiantes inscritos en esa ruta con el mismo tipoUS-ADM-FOR-010: Generar hilos de arranque (cold start) con AcademIA
Section titled “US-ADM-FOR-010: Generar hilos de arranque (cold start) con AcademIA”Como SUPER_ADMIN quiero generar 3 hilos de arranque AcademIA en una ruta recién creada para activar la comunidad del foro antes de que los estudiantes publiquen el primer post.
Módulo: forum · Endpoint: POST /admin/forum/seed-starters/:pathId (202 Accepted async)
Estado: ✅ Implementado
Prioridad: Media
AC-ADM-FOR-010.1 — El SUPER_ADMIN puede disparar el cold start para un path
Section titled “AC-ADM-FOR-010.1 — El SUPER_ADMIN puede disparar el cold start para un path”Given el SUPER_ADMIN está en el panel de moderaciónAnd hace clic en "Generar arranque" e introduce un pathId válidoWhen confirma haciendo clic en "Generar"Then se invoca POST /admin/forum/seed-starters/:pathIdAnd recibe respuesta 202 con { accepted: true }And el botón muestra "Generando..." mientras procesaAnd los 3 hilos de arranque aparecen eventualmente en el foro de la rutaAC-ADM-FOR-010.2 — El endpoint es accesible solo para SUPER_ADMIN
Section titled “AC-ADM-FOR-010.2 — El endpoint es accesible solo para SUPER_ADMIN”Given un usuario con rol CONTENT_ADMIN está autenticadoWhen hace POST /admin/forum/seed-starters/:pathIdThen recibe 403 ForbiddenTest Cases
Section titled “Test Cases”TC-ADM-FOR-001 — Panel de moderación carga KPIs globales
Section titled “TC-ADM-FOR-001 — Panel de moderación carga KPIs globales”Cubre: AC-ADM-FOR-001.1, AC-ADM-FOR-001.2 Tipo: E2E
test('TC-ADM-FOR-001: panel de moderación muestra KPIs del foro', async ({ page }) => { // Arrange: login como SUPER_ADMIN await page.goto('/login') await page.fill('[name="email"]', process.env.SUPER_ADMIN_EMAIL!) await page.fill('[name="password"]', process.env.SUPER_ADMIN_PASSWORD!) await page.click('button[type="submit"]') await page.waitForURL('/admin')
// Act await page.goto('/admin/forum') await page.waitForLoadState('networkidle')
// Assert: 5 KPI cards visibles const kpiCards = page.locator('text=Posts totales').first() await expect(kpiCards).toBeVisible() await expect(page.getByText('Reportados pendientes')).toBeVisible() await expect(page.getByText('Respuestas AcademIA/semana')).toBeVisible() await expect(page.getByText('Satisfacción IA')).toBeVisible() await expect(page.getByText('Alertas confusión activas')).toBeVisible()})TC-ADM-FOR-002 — Pestaña reportados muestra estado vacío en Phase 1
Section titled “TC-ADM-FOR-002 — Pestaña reportados muestra estado vacío en Phase 1”Cubre: AC-ADM-FOR-002.1 Tipo: E2E
test('TC-ADM-FOR-002: tab reportados muestra estado vacío (Phase 1 log-only)', async ({ page }) => { await loginAsSuperAdmin(page) await page.goto('/admin/forum') await page.waitForLoadState('networkidle')
// La pestaña "Reportados" debe estar activa por defecto await expect(page.getByText('Sin posts reportados')).toBeVisible() await expect(page.getByText('La comunidad está en orden.')).toBeVisible()})TC-ADM-FOR-003 — Reportar un hilo devuelve 200 y no crea ForumReport
Section titled “TC-ADM-FOR-003 — Reportar un hilo devuelve 200 y no crea ForumReport”Cubre: AC-ADM-FOR-002.2 Tipo: Integration (API)
test('TC-ADM-FOR-003: reportar hilo es log-only en Phase 1', async ({ request }) => { const authToken = await getAuthToken(request, 'student') const threadId = await getExistingThreadId(request, authToken)
// Act: reportar el hilo const response = await request.post(`/forum/threads/${threadId}/report`, { headers: { Authorization: `Bearer ${authToken}` }, data: { reason: 'Contenido inapropiado' }, })
// Assert: 200 OK expect(response.status()).toBe(200)
// Assert: No se crea ForumReport en BD (verificar via admin endpoint que sigue vacío) const adminToken = await getAuthToken(request, 'superAdmin') const reportedResponse = await request.get('/admin/forum/reported', { headers: { Authorization: `Bearer ${adminToken}` }, }) const reported = await reportedResponse.json() expect(Array.isArray(reported)).toBe(true) expect(reported).toHaveLength(0)})TC-ADM-FOR-004 — Admin elimina hilo de otro usuario
Section titled “TC-ADM-FOR-004 — Admin elimina hilo de otro usuario”Cubre: AC-ADM-FOR-003.1 Tipo: Integration (API)
test('TC-ADM-FOR-004: SUPER_ADMIN elimina hilo de cualquier usuario', async ({ request }) => { const adminToken = await getAuthToken(request, 'superAdmin') const studentToken = await getAuthToken(request, 'student')
// Crear un hilo como estudiante const createResp = await request.post(`/paths/${TEST_PATH_ID}/forum`, { headers: { Authorization: `Bearer ${studentToken}` }, data: { type: 'QUESTION', title: 'Pregunta de prueba', body: 'Cuerpo de prueba' }, }) expect(createResp.status()).toBe(201) const thread = await createResp.json()
// Admin elimina el hilo ajeno const deleteResp = await request.delete(`/forum/threads/${thread.id}`, { headers: { Authorization: `Bearer ${adminToken}` }, }) expect(deleteResp.status()).toBe(204)
// Verificar que el hilo no existe const getResp = await request.get(`/forum/threads/${thread.id}`) expect(getResp.status()).toBe(404)})TC-ADM-FOR-005 — Estudiante no puede eliminar hilo ajeno
Section titled “TC-ADM-FOR-005 — Estudiante no puede eliminar hilo ajeno”Cubre: AC-ADM-FOR-003.2 Tipo: Integration (API)
test('TC-ADM-FOR-005: estudiante recibe 403 al eliminar hilo ajeno', async ({ request }) => { const studentAToken = await getAuthToken(request, 'studentA') const studentBToken = await getAuthToken(request, 'studentB')
// Crear hilo como studentA const createResp = await request.post(`/paths/${TEST_PATH_ID}/forum`, { headers: { Authorization: `Bearer ${studentAToken}` }, data: { type: 'DISCUSSION', title: 'Mi hilo', body: 'Contenido' }, }) const thread = await createResp.json()
// studentB intenta eliminar el hilo de studentA const deleteResp = await request.delete(`/forum/threads/${thread.id}`, { headers: { Authorization: `Bearer ${studentBToken}` }, }) expect(deleteResp.status()).toBe(403)})TC-ADM-FOR-006 — Pin y unpin de hilo con limpieza de pinnedUntil
Section titled “TC-ADM-FOR-006 — Pin y unpin de hilo con limpieza de pinnedUntil”Cubre: AC-ADM-FOR-004.1, AC-ADM-FOR-004.2 Tipo: Integration (API)
test('TC-ADM-FOR-006: pin/unpin limpia pinnedUntil correctamente', async ({ request }) => { const adminToken = await getAuthToken(request, 'contentAdmin') const threadId = await getExistingThreadId(request, adminToken)
// Pin const pinResp = await request.post(`/forum/threads/${threadId}/pin`, { headers: { Authorization: `Bearer ${adminToken}` }, data: { isPinned: true }, }) expect(pinResp.status()).toBe(200) const pinned = await pinResp.json() expect(pinned.isPinned).toBe(true)
// Unpin — pinnedUntil debe quedar null const unpinResp = await request.post(`/forum/threads/${threadId}/pin`, { headers: { Authorization: `Bearer ${adminToken}` }, data: { isPinned: false }, }) expect(unpinResp.status()).toBe(200) const unpinned = await unpinResp.json() expect(unpinned.isPinned).toBe(false) expect(unpinned.pinnedUntil).toBeNull()})TC-ADM-FOR-007 — Lock impide crear nuevas respuestas
Section titled “TC-ADM-FOR-007 — Lock impide crear nuevas respuestas”Cubre: AC-ADM-FOR-005.1 Tipo: Integration (API)
test('TC-ADM-FOR-007: hilo bloqueado rechaza nuevas respuestas con 403', async ({ request }) => { const adminToken = await getAuthToken(request, 'superAdmin') const studentToken = await getAuthToken(request, 'enrolledStudent') const threadId = await getExistingThreadId(request, adminToken)
// Admin bloquea el hilo const lockResp = await request.post(`/forum/threads/${threadId}/lock`, { headers: { Authorization: `Bearer ${adminToken}` }, data: { isLocked: true }, }) expect(lockResp.status()).toBe(200)
// Estudiante intenta responder const replyResp = await request.post(`/forum/threads/${threadId}/replies`, { headers: { Authorization: `Bearer ${studentToken}` }, data: { body: 'Intentando responder en hilo bloqueado' }, }) expect(replyResp.status()).toBe(403) const body = await replyResp.json() expect(body.message).toContain('locked')})TC-ADM-FOR-008 — Marcar respuesta oficial y notificar al autor
Section titled “TC-ADM-FOR-008 — Marcar respuesta oficial y notificar al autor”Cubre: AC-ADM-FOR-006.1 Tipo: Integration (API)
test('TC-ADM-FOR-008: marcar respuesta oficial actualiza isOfficial y dispara notificación', async ({ request }) => { const adminToken = await getAuthToken(request, 'contentAdmin') const replyId = await getExistingReplyId(request, adminToken)
const officialResp = await request.post(`/forum/replies/${replyId}/official`, { headers: { Authorization: `Bearer ${adminToken}` }, }) expect(officialResp.status()).toBe(200) const reply = await officialResp.json() expect(reply.isOfficial).toBe(true)
// Verificar notificación (via admin notifications endpoint si disponible) // En su defecto, verificar que el endpoint devuelve isOfficial = true es suficiente para integración})TC-ADM-FOR-009 — Resolver alerta de confusión colectiva
Section titled “TC-ADM-FOR-009 — Resolver alerta de confusión colectiva”Cubre: AC-ADM-FOR-007.2 Tipo: E2E
test('TC-ADM-FOR-009: SUPER_ADMIN resuelve una alerta de confusión', async ({ page, request }) => { const adminToken = await getAuthToken(request, 'superAdmin')
// Crear una AIAlert de tipo COLLECTIVE_CONFUSION directamente en BD (seed de test) // En un entorno real se haría via el job o un endpoint de seed de test // Asumir que ya existe al menos una alerta activa
await loginAsSuperAdmin(page) await page.goto('/admin/forum') await page.waitForLoadState('networkidle')
// Abrir pestaña confusión await page.getByRole('button', { name: /Alertas confusión/ }).click() await page.waitForLoadState('networkidle')
// Si hay alertas, resolver la primera const resolveBtn = page.getByRole('button', { name: 'Resolver' }).first() if (await resolveBtn.isVisible()) { await resolveBtn.click() // El botón debe desaparecer y reemplazarse por "Resuelto" await expect(page.getByText('Resuelto').first()).toBeVisible({ timeout: 5000 }) } else { // Sin alertas activas: verificar estado vacío await expect(page.getByText('Sin alertas de confusión')).toBeVisible() }})TC-ADM-FOR-010 — Cold start: seed de hilos de arranque en ruta
Section titled “TC-ADM-FOR-010 — Cold start: seed de hilos de arranque en ruta”Cubre: AC-ADM-FOR-010.1, AC-ADM-FOR-010.2 Tipo: Integration (API)
test('TC-ADM-FOR-010: seed-starters devuelve 202 y CONTENT_ADMIN recibe 403', async ({ request }) => { const adminToken = await getAuthToken(request, 'superAdmin') const contentAdminToken = await getAuthToken(request, 'contentAdmin')
// SUPER_ADMIN puede lanzar el seed const seedResp = await request.post(`/admin/forum/seed-starters/${TEST_PATH_ID}`, { headers: { Authorization: `Bearer ${adminToken}` }, }) expect(seedResp.status()).toBe(202) const body = await seedResp.json() expect(body.accepted).toBe(true)
// CONTENT_ADMIN no puede const forbiddenResp = await request.post(`/admin/forum/seed-starters/${TEST_PATH_ID}`, { headers: { Authorization: `Bearer ${contentAdminToken}` }, }) expect(forbiddenResp.status()).toBe(403)})TC-ADM-FOR-011 — Panel AcademIA muestra badge de confianza por nivel
Section titled “TC-ADM-FOR-011 — Panel AcademIA muestra badge de confianza por nivel”Cubre: AC-ADM-FOR-008.1 Tipo: E2E
test('TC-ADM-FOR-011: respuestas AcademIA muestran badge de confianza correcto', async ({ page }) => { await loginAsSuperAdmin(page) await page.goto('/admin/forum') await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: /Respuestas AcademIA/ }).click() await page.waitForLoadState('networkidle')
// Verificar estado vacío o tabla con datos const emptyState = page.getByText('Sin respuestas AcademIA') const table = page.locator('table')
if (await table.isVisible()) { // Si hay datos, verificar que existe al menos un badge de confianza const confBadge = page.locator('text=/Alta|Media|Baja/').first() await expect(confBadge).toBeVisible() } else { await expect(emptyState).toBeVisible() }})TC-ADM-FOR-012 — Rate limit: máximo 5 hilos por hora por usuario
Section titled “TC-ADM-FOR-012 — Rate limit: máximo 5 hilos por hora por usuario”Cubre: Comportamiento de servicio (RATE_LIMIT_THREADS_PER_HOUR = 5)
Tipo: Integration (API)
test('TC-ADM-FOR-012: usuario recibe 429 al superar 5 hilos por hora', async ({ request }) => { const studentToken = await getAuthToken(request, 'enrolledStudent')
const createThread = () => request.post(`/paths/${TEST_PATH_ID}/forum`, { headers: { Authorization: `Bearer ${studentToken}` }, data: { type: 'QUESTION', title: `Pregunta rate limit ${Date.now()}`, body: 'Body de prueba', }, })
// Crear 5 hilos (dentro del límite) for (let i = 0; i < 5; i++) { const resp = await createThread() expect(resp.status()).toBe(201) }
// El sexto debe ser rechazado const sixthResp = await createThread() expect(sixthResp.status()).toBe(429) const body = await sixthResp.json() expect(body.message).toContain('Rate limit exceeded')})Helpers de test reutilizables
Section titled “Helpers de test reutilizables”export async function loginAsSuperAdmin(page: Page) { await page.goto('/login') await page.fill('[name="email"]', process.env.SUPER_ADMIN_EMAIL!) await page.fill('[name="password"]', process.env.SUPER_ADMIN_PASSWORD!) await page.click('button[type="submit"]') await page.waitForURL('/admin')}
export async function getAuthToken( request: APIRequestContext, role: 'superAdmin' | 'contentAdmin' | 'student' | 'studentA' | 'studentB' | 'enrolledStudent',): Promise<string> { const credentials: Record<string, { email: string; password: string }> = { superAdmin: { email: process.env.SUPER_ADMIN_EMAIL!, password: process.env.SUPER_ADMIN_PASSWORD! }, contentAdmin: { email: process.env.CONTENT_ADMIN_EMAIL!, password: process.env.CONTENT_ADMIN_PASSWORD! }, student: { email: process.env.STUDENT_EMAIL!, password: process.env.STUDENT_PASSWORD! }, studentA: { email: process.env.STUDENT_A_EMAIL!, password: process.env.STUDENT_A_PASSWORD! }, studentB: { email: process.env.STUDENT_B_EMAIL!, password: process.env.STUDENT_B_PASSWORD! }, enrolledStudent: { email: process.env.ENROLLED_STUDENT_EMAIL!, password: process.env.ENROLLED_STUDENT_PASSWORD! }, } const resp = await request.post('/auth/login', { data: credentials[role] }) const body = await resp.json() return body.access_token}
export async function getExistingThreadId( request: APIRequestContext, token: string,): Promise<string> { const resp = await request.get(`/paths/${process.env.TEST_PATH_ID}/forum`, { headers: { Authorization: `Bearer ${token}` }, }) const body = await resp.json() if (!body.data?.[0]?.id) throw new Error('No threads found in test path') return body.data[0].id}
export async function getExistingReplyId( request: APIRequestContext, token: string,): Promise<string> { const threadId = await getExistingThreadId(request, token) const resp = await request.get(`/forum/threads/${threadId}`, { headers: { Authorization: `Bearer ${token}` }, }) const body = await resp.json() if (!body.replies?.[0]?.id) throw new Error('No replies found in test thread') return body.replies[0].id}