Skip to content

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

EndpointRol mínimoEstado
GET /admin/forum/statsSUPER_ADMIN✅ Implementado
GET /admin/forum/reportedSUPER_ADMIN✅ Implementado (devuelve [] — Phase 1 log-only)
GET /admin/forum/confusion-alertsSUPER_ADMIN✅ Implementado
POST /admin/forum/confusion-alerts/:alertId/resolveSUPER_ADMIN✅ Implementado
GET /admin/forum/academIA-repliesSUPER_ADMIN✅ Implementado
GET /admin/forum/highlightsSUPER_ADMIN✅ Implementado
POST /admin/forum/seed-starters/:pathIdSUPER_ADMIN✅ Implementado (202 async)
POST /forum/threads/:threadId/pinCONTENT_ADMIN, SUPER_ADMIN✅ Implementado
POST /forum/threads/:threadId/lockCONTENT_ADMIN, SUPER_ADMIN✅ Implementado
POST /forum/threads/:threadId/resolveAutor o admin✅ Implementado
POST /forum/replies/:replyId/officialCONTENT_ADMIN, SUPER_ADMIN✅ Implementado
DELETE /forum/threads/:threadIdAutor o admin✅ Implementado
DELETE /forum/replies/:replyIdAutor o admin✅ Implementado
POST /forum/threads/:threadId/reportAutenticado✅ Phase 1 (log only)
POST /forum/replies/:replyId/reportAutenticado✅ Phase 1 (log only)
GET /admin/forum/reports (resolve via UI)SUPER_ADMIN📋 Phase 2 planificado
POST /admin/forum/bans📋 No implementado

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_ADMIN
When navega a /admin/forum
Then 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 tendencia

AC-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 BD
When el SUPER_ADMIN carga el panel de moderación
Then la tarjeta "Reportados pendientes" muestra "0" sin color de alerta
And 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 semana
When el SUPER_ADMIN visualiza la sección "AcademIA · Esta semana"
Then ve tres métricas: respuestas totales, desglose "alta / media / baja" confianza
And el coste semanal IA muestra una barra de progreso respecto al límite configurado

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/forum
Then ve un estado vacío con el mensaje "Sin posts reportados" y checkmark verde
And no hay filas en la tabla de reportes

AC-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 foro
When hace clic en "Reportar" y envía la razón "Spam"
Then recibe respuesta 200 del endpoint POST /forum/threads/:threadId/report
And el logger del servidor registra: "Forum report [THREAD]: threadId=... reason=Spam"
But no se crea ningún registro en la tabla ForumReport de la BD

US-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á autenticado
And existe un ForumThread con id "thread-123" creado por otro usuario
When hace DELETE /forum/threads/thread-123
Then recibe 204 No Content
And el hilo y todas sus respuestas (cascade) son eliminados de la BD
And 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á autenticado
And existe un ForumThread creado por otro usuario
When hace DELETE /forum/threads/:threadId
Then recibe 403 Forbidden
And el hilo permanece en la BD

AC-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ón
And la tabla muestra al menos un hilo (cuando Phase 2 esté activo)
When hace clic en el botón "Eliminar" de un hilo
Then se invoca DELETE /forum/threads/:threadId
And la fila desaparece de la tabla sin recargar la página

US-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á autenticado
And existe un ForumThread con isPinned = false
When hace POST /forum/threads/:threadId/pin con body { "isPinned": true }
Then recibe 200 con el thread actualizado donde isPinned = true
And el hilo aparece al inicio de la lista ordenada por isPinned DESC

AC-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 = null
And ya no aparece destacado en la lista del foro

AC-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á autenticado
When hace POST /forum/threads/:threadId/pin
Then recibe 403 Forbidden con mensaje de falta de permisos

US-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 = false
And el SUPER_ADMIN está autenticado
When hace POST /forum/threads/:threadId/lock con body { "isLocked": true }
Then recibe 200 con el thread donde isLocked = true
And 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 = true
When el usuario A (rol PRO) intenta hacer PATCH /forum/threads/:threadId
Then recibe 403 Forbidden "This thread is locked"
And el admin sí puede editar el hilo bloqueado

US-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 = false
And el CONTENT_ADMIN está autenticado
When hace POST /forum/replies/:replyId/official
Then recibe 200 con la respuesta donde isOfficial = true
And 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á autenticado
When hace POST /forum/replies/:replyId/official
Then recibe 403 Forbidden

US-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ías
When el SUPER_ADMIN abre la pestaña "Alertas confusión" en /admin/forum
Then ve una fila por cada AIAlert de tipo COLLECTIVE_CONFUSION con isResolved = false
And cada fila muestra: moduleId truncado, mensaje diagnóstico, unansweredCount, quizFailureRate y severidad

AC-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 = false
When el SUPER_ADMIN hace clic en "Resolver"
Then se invoca POST /admin/forum/confusion-alerts/alert-abc/resolve
And la alerta queda con isResolved = true y la fila aparece con opacidad reducida
And el contador "Alertas confusión activas" en el KPI se decrementa

AC-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 quizzes
When el ForumCollectiveConfusionJob ejecuta su cron a las 06:00
Then crea un AIAlert de tipo COLLECTIVE_CONFUSION en la BD
And 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 tipo
And el actionUrl para CONTENT_ADMIN apunta a /creator/paths/:pathId/modules/:moduleId

US-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 BD
When el SUPER_ADMIN abre la pestaña "Respuestas AcademIA"
Then ve una tabla con columnas: Thread, Extracto del cuerpo, Confianza, Upvotes, Generada
And 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.5

AC-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á cargado
When el SUPER_ADMIN visualiza la cabecera de la tabla
Then 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ías

US-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 semana
When 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 autor
And se muestra la fecha de expiración del pin en color ámbar

AC-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:00
And existe un hilo con score (upvotes×3 + replies×2 + views×0.1) ≥ 3
When el job selecciona el hilo ganador para un path
Then otorga el badge con slug "contribuidor-top" al autor si no lo tiene ya
And notifica al autor con tipo "FORUM_WEEKLY_HIGHLIGHT"
And notifica a todos los estudiantes inscritos en esa ruta con el mismo tipo

US-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ón
And hace clic en "Generar arranque" e introduce un pathId válido
When confirma haciendo clic en "Generar"
Then se invoca POST /admin/forum/seed-starters/:pathId
And recibe respuesta 202 con { accepted: true }
And el botón muestra "Generando..." mientras procesa
And los 3 hilos de arranque aparecen eventualmente en el foro de la ruta

AC-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á autenticado
When hace POST /admin/forum/seed-starters/:pathId
Then recibe 403 Forbidden

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')
})

apps/e2e/tests/admin/helpers/forum-helpers.ts
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
}