Skip to content

Foro y Comunidad

Módulo API: apps/api/src/modules/forum/ Páginas web: ForumThreadListPage, ThreadDetailPage, PathForumPage, MyForumPage Componentes: ThreadCard, ReplyCard, ReplyComposer, NewThreadModal, ForumLayout Spec Playwright: apps/e2e/tests/student/forum.spec.ts — 📋 pendiente

MétodoRutaAuthGuard extraDescripción
GET/paths/:pathId/forumOpcionalListar hilos de una ruta (paginado, filtros, búsqueda)
GET/paths/:pathId/forum/module/:moduleIdOpcionalListar hilos filtrados por módulo
GET/paths/:pathId/forum/statsNoEstadísticas del foro de la ruta
POST/paths/:pathId/forumForumAccessGuard (matrícula)Crear nuevo hilo
GET/forum/threads/:threadIdOpcionalDetalle del hilo con respuestas
GET/forum/threads/:threadId/relatedNoHilos y lecciones relacionados
PATCH/forum/threads/:threadIdAutor o adminEditar título/cuerpo del hilo
DELETE/forum/threads/:threadIdAutor o adminEliminar hilo (cascade en replies)
POST/forum/threads/:threadId/repliesForumAccessGuard (matrícula)Crear respuesta
PATCH/forum/replies/:replyIdAutor o adminEditar cuerpo de respuesta
DELETE/forum/replies/:replyIdAutor o adminEliminar respuesta
POST/forum/threads/:threadId/upvoteToggle upvote en hilo
POST/forum/replies/:replyId/upvoteToggle upvote en respuesta
POST/forum/threads/:threadId/resolveAutor o adminMarcar hilo como resuelto
POST/forum/threads/:threadId/reportReportar hilo (Phase 1: solo log)
POST/forum/replies/:replyId/reportReportar respuesta (Phase 1: solo log)
GET/forum/my-statsPreguntas sin responder en rutas del usuario
POST/forum/threads/:threadId/pinCONTENT_ADMIN, SUPER_ADMINToggle pin del hilo
POST/forum/threads/:threadId/lockCONTENT_ADMIN, SUPER_ADMINToggle lock del hilo
POST/forum/replies/:replyId/officialCONTENT_ADMIN, SUPER_ADMINMarcar respuesta como oficial

El campo type en CreateThreadDto acepta los valores del enum Prisma ForumPostType:

ValorEtiqueta UI
QUESTION❓ Preguntas
REFLECTION💡 Reflexiones
REAL_CASE🏢 Casos reales
RESOURCE🔗 Recursos

Como estudiante matriculado quiero acceder al foro de una ruta de aprendizaje para ver las conversaciones de la comunidad y encontrar respuestas a mis dudas.

Módulo: forum · Endpoint: GET /paths/:pathId/forum Páginas: PathForumPage, ForumThreadListPage Estado: ✅ Implementado Prioridad: Alta

AC-FOR-001.1 — Listado paginado de hilos

Section titled “AC-FOR-001.1 — Listado paginado de hilos”
Given un usuario (autenticado o no) accede a /paths/:slug/forum
When se carga la página
Then ve una lista paginada de hilos con: título, tipo, autor, fecha, nº votos, nº respuestas y estado (resuelto/abierto)
And los hilos están ordenados por defecto con los pinneados primero, después por updatedAt desc
Given el usuario está en la página del foro
When selecciona el tab "Sin respuesta"
Then la lista muestra solo hilos con replyCount = 0
When selecciona el tab "Resueltos"
Then la lista muestra solo hilos con isResolved = true
When selecciona el tab "AcademIA"
Then la lista muestra solo hilos que tienen al menos una respuesta de la IA

AC-FOR-001.3 — Barra lateral de estadísticas

Section titled “AC-FOR-001.3 — Barra lateral de estadísticas”
Given el usuario está en la página del foro
Then la barra lateral derecha muestra: nº de estudiantes, posts activos, % respondidos y respuestas de AcademIA esta semana
And el widget "Top contribuidores" lista hasta 5 usuarios ordenados por upvotes totales
And el widget "Módulos más activos" muestra hasta 4 módulos con barra de actividad relativa
Given existe un hilo con isPinned = true y pinnedUntil en el futuro
When el usuario carga el foro
Then aparece un banner "Debate de la semana" destacado sobre la lista de hilos
And al hacer click navega al detalle del hilo destacado

Como estudiante quiero buscar hilos por texto y filtrar por módulo o tipo para encontrar rápidamente discusiones relevantes a lo que estoy aprendiendo.

Módulo: forum · Endpoint: GET /paths/:pathId/forum?search=...&moduleId=...&type=... Estado: ✅ Implementado Prioridad: Alta

Given el usuario está en el foro de una ruta
When escribe un término en el campo de búsqueda
Then la lista se filtra mostrando hilos cuyo título o cuerpo contiene el término (insensible a mayúsculas)
And si no hay resultados se muestra el estado vacío "Sé el primero en iniciar una conversación"

AC-FOR-002.2 — Filtro por módulo desde la barra lateral izquierda

Section titled “AC-FOR-002.2 — Filtro por módulo desde la barra lateral izquierda”
Given el usuario está en la barra lateral izquierda del foro
When selecciona un módulo específico
Then la URL se actualiza con ?moduleId=<id>
And la lista muestra solo los hilos asociados a ese módulo
And el sidebar muestra el contador de hilos por módulo

AC-FOR-002.3 — Filtro por tipo y ordenación

Section titled “AC-FOR-002.3 — Filtro por tipo y ordenación”
Given el usuario está en la barra lateral izquierda
When selecciona el tipo "QUESTION"
Then la lista muestra solo hilos de tipo QUESTION
When el usuario cambia la ordenación a "Más votado"
Then la lista se reordena por upvoteCount desc

Como estudiante matriculado quiero publicar un hilo nuevo con título, cuerpo y tipo para iniciar una conversación con la comunidad.

Módulo: forum · Endpoint: POST /paths/:pathId/forum Componente: NewThreadModal Estado: ✅ Implementado Prioridad: Alta

AC-FOR-003.1 — Validaciones del formulario

Section titled “AC-FOR-003.1 — Validaciones del formulario”
Given el usuario abre el modal "Nuevo post"
When intenta enviar el formulario con el título con menos de 10 caracteres
Then la acción es rechazada con un error de validación
When el título tiene entre 10 y 200 caracteres y el cuerpo entre 20 y 10.000 caracteres
Then el hilo se crea correctamente y la lista se actualiza
Given el usuario crea un hilo
When selecciona un módulo del selector opcional
Then el hilo queda asociado a ese módulo y el filtro por módulo lo incluye en los resultados
Given el usuario ha creado 5 hilos en la última hora
When intenta crear un hilo adicional
Then la API devuelve HTTP 429 con el mensaje "Rate limit exceeded: maximum 5 threads per hour"
And el formulario muestra un mensaje de error al usuario

AC-FOR-003.4 — Acceso restringido a matriculados

Section titled “AC-FOR-003.4 — Acceso restringido a matriculados”
Given un usuario autenticado NO está matriculado en la ruta
When intenta hacer POST /paths/:pathId/forum
Then la API devuelve HTTP 403 con el mensaje "You must be enrolled in this path to participate in the forum"

US-FOR-004: Leer el detalle de un hilo y sus respuestas

Section titled “US-FOR-004: Leer el detalle de un hilo y sus respuestas”

Como cualquier usuario quiero abrir el detalle de un hilo para leer la pregunta completa, todas las respuestas y el contenido relacionado.

Módulo: forum · Endpoint: GET /forum/threads/:threadId Página: ThreadDetailPage Estado: ✅ Implementado Prioridad: Alta

AC-FOR-004.1 — Visualización del detalle

Section titled “AC-FOR-004.1 — Visualización del detalle”
Given el usuario abre un hilo
Then ve el título, cuerpo, tipo, módulo (si aplica), autor con avatar, fecha de publicación y contador de vistas
And las respuestas se muestran ordenadas por votos por defecto (AcademIA primero)
And el viewCount del hilo se incrementa en 1 de forma asíncrona
Given el hilo tiene una respuesta de AcademIA (isAcademIA = true)
Then esa respuesta aparece con el avatar degradado púrpura "AI" y sin nombre de autor real
And si la respuesta tiene academIAConf < 0.5 se muestra un indicador de baja confianza

AC-FOR-004.3 — Contenido relacionado en sidebar

Section titled “AC-FOR-004.3 — Contenido relacionado en sidebar”
Given el usuario está en el detalle de un hilo
Then la barra lateral derecha muestra hasta 5 lecciones relacionadas del mismo módulo (o ruta)
And muestra hasta 5 hilos relacionados ordenados por upvotes + replies
And cada lección enlaza a /learn/:pathSlug/:lessonId

Como estudiante matriculado quiero añadir una respuesta a un hilo existente para ayudar a otros compañeros y ganar XP por participar.

Módulo: forum · Endpoint: POST /forum/threads/:threadId/replies Componente: ReplyComposer Estado: ✅ Implementado Prioridad: Alta

Given el usuario autenticado y matriculado está en el detalle de un hilo no bloqueado
When escribe una respuesta de al menos 10 caracteres y la envía
Then la respuesta aparece en la lista y el replyCount del hilo se incrementa en 1
And el usuario recibe 5 XP por el evento FORUM_REPLY_CREATED

AC-FOR-005.2 — Notificación al autor del hilo

Section titled “AC-FOR-005.2 — Notificación al autor del hilo”
Given el usuario A creó el hilo y el usuario B (distinto) responde
When se procesa la respuesta
Then el usuario A recibe una notificación de tipo FORUM_REPLY_ON_MY_THREAD
And el asunto es "Alguien respondió en tu hilo" con enlace al hilo
Given un hilo tiene isLocked = true
When el usuario intenta crear una respuesta
Then la API devuelve HTTP 403 "This thread is locked"
And la UI muestra el mensaje "🔒 Este hilo está cerrado. No se pueden añadir más respuestas."

US-FOR-006: Votar (upvote) hilos y respuestas

Section titled “US-FOR-006: Votar (upvote) hilos y respuestas”

Como estudiante autenticado quiero votar a favor de los hilos y respuestas que encuentro útiles para destacar el contenido más valioso y premiar a sus autores.

Módulo: forum · Endpoints: POST /forum/threads/:threadId/upvote, POST /forum/replies/:replyId/upvote Estado: ✅ Implementado Prioridad: Alta

AC-FOR-006.1 — Toggle de upvote optimista

Section titled “AC-FOR-006.1 — Toggle de upvote optimista”
Given el usuario no ha votado un hilo
When hace click en el botón de upvote
Then el contador se incrementa de forma optimista en el cliente inmediatamente
And la API persiste el voto y retorna { upvoted: true }
When vuelve a hacer click en el mismo hilo
Then el voto se elimina (toggle) y el contador decrece
Given el usuario es el autor de un hilo
When intenta hacer click en el botón de upvote de su propio hilo
Then el botón está deshabilitado (opacity: 0.5, cursor: not-allowed)
And no se realiza ninguna llamada a la API

AC-FOR-006.3 — XP y notificación por upvote a respuesta

Section titled “AC-FOR-006.3 — XP y notificación por upvote a respuesta”
Given el usuario A tiene una respuesta en un hilo
When el usuario B (distinto) upvotea esa respuesta
Then el usuario A recibe 10 XP por el evento FORUM_REPLY_UPVOTED
And el usuario A recibe una notificación de tipo FORUM_REPLY_UPVOTED

Como autor de un hilo o administrador quiero marcar el hilo como resuelto para indicar a la comunidad que la pregunta tiene solución.

Módulo: forum · Endpoint: POST /forum/threads/:threadId/resolve Estado: ✅ Implementado Prioridad: Media

Given el usuario es el autor del hilo y el hilo no está resuelto
When hace click en el botón "✅ Marcar resuelto"
Then el hilo queda con isResolved = true y resolvedById = userId
And la UI muestra el banner verde "Pregunta resuelta" con descripción
And el botón "Marcar resuelto" desaparece del hilo

AC-FOR-007.2 — Notificaciones al resolver

Section titled “AC-FOR-007.2 — Notificaciones al resolver”
Given el administrador resuelve el hilo de otro usuario
When se procesa la resolución
Then el autor del hilo recibe una notificación de tipo FORUM_THREAD_RESOLVED
And cada contributor (distinto del autor y del resolvedor) recibe una notificación de tipo FORUM_THREAD_RESOLVED_CONTRIBUTED
Given un usuario NO es el autor del hilo ni tiene rol CONTENT_ADMIN/SUPER_ADMIN
When intenta hacer POST /forum/threads/:threadId/resolve
Then la API devuelve HTTP 403 "Only the thread author or an admin can resolve this thread"

US-FOR-008: Editar y eliminar contenido propio

Section titled “US-FOR-008: Editar y eliminar contenido propio”

Como autor de un hilo o respuesta quiero poder editar o eliminar mi contenido para corregir errores o retirar publicaciones que ya no son relevantes.

Módulo: forum · Endpoints: PATCH /forum/threads/:threadId, DELETE /forum/threads/:threadId, PATCH /forum/replies/:replyId, DELETE /forum/replies/:replyId Estado: ✅ Implementado Prioridad: Media

Given el usuario es el autor del hilo y el hilo no está bloqueado
When envía PATCH /forum/threads/:threadId con { title, body }
Then el hilo se actualiza con los nuevos valores y updatedAt se registra
And la UI refleja el campo "Editado hace X" en la meta row

AC-FOR-008.2 — Eliminación de hilo (cascade)

Section titled “AC-FOR-008.2 — Eliminación de hilo (cascade)”
Given el usuario es el autor de un hilo
When hace DELETE /forum/threads/:threadId
Then el hilo y todas sus respuestas y upvotes se eliminan en cascada
And la lista del foro se refresca sin el hilo eliminado

AC-FOR-008.3 — Edición bloqueada en hilo locked

Section titled “AC-FOR-008.3 — Edición bloqueada en hilo locked”
Given un hilo tiene isLocked = true y el usuario no es admin
When intenta hacer PATCH /forum/threads/:threadId
Then la API devuelve HTTP 403 "This thread is locked"

US-FOR-009: Reportar contenido inapropiado

Section titled “US-FOR-009: Reportar contenido inapropiado”

Como estudiante autenticado quiero reportar un hilo o respuesta que incumpla las normas de la comunidad para contribuir a un entorno de aprendizaje respetuoso.

Módulo: forum · Endpoints: POST /forum/threads/:threadId/report, POST /forum/replies/:replyId/report Estado: ✅ Implementado (Phase 1 — log only; Phase 2 planificada con entidad Report) Prioridad: Media

AC-FOR-009.1 — Flujo del modal de reporte

Section titled “AC-FOR-009.1 — Flujo del modal de reporte”
Given el usuario está en el detalle de un hilo
When hace click en "⚑ Reportar"
Then se abre un modal con un selector de motivo (5 opciones predefinidas) y un textarea opcional
When selecciona un motivo y confirma
Then se envía POST /forum/threads/:threadId/report con { reason }
And el modal se cierra y la UI no cambia visualmente el hilo reportado

AC-FOR-009.2 — Reporte requiere autenticación

Section titled “AC-FOR-009.2 — Reporte requiere autenticación”
Given un usuario no autenticado visita el detalle de un hilo
When intenta hacer POST /forum/threads/:threadId/report
Then la API devuelve HTTP 401
And la UI no muestra el botón de reporte a usuarios no autenticados

US-FOR-010: Ver mis posts y actividad de foro

Section titled “US-FOR-010: Ver mis posts y actividad de foro”

Como estudiante autenticado quiero ver solo mis publicaciones en el foro para hacer seguimiento de mis contribuciones a la comunidad.

Módulo: forum · Endpoint: GET /paths/:pathId/forum?myThreads=true Página: MyForumPage, ForumThreadListPage Estado: ✅ Implementado Prioridad: Media

Given el usuario autenticado está en el foro de una ruta
When hace click en "✍️ Mis posts" en la barra lateral izquierda
Then la URL se actualiza con ?myPosts=true
And la lista muestra solo los hilos donde authorId = userId
And el encabezado dice "Mis posts" con la descripción "Todos los posts que has publicado en este foro"

AC-FOR-010.2 — Badge de preguntas sin responder

Section titled “AC-FOR-010.2 — Badge de preguntas sin responder”
Given el usuario tiene rutas matriculadas con preguntas de tipo QUESTION sin responder en los últimos 7 días
When se carga cualquier página con el sidebar principal
Then el icono del foro muestra un badge numérico con la cuenta de unansweredCount
And GET /forum/my-stats devuelve { unansweredCount: N }

TC-FOR-001 — Listar hilos públicos sin autenticación

Section titled “TC-FOR-001 — Listar hilos públicos sin autenticación”

Cubre: AC-FOR-001.1 Tipo: Integration

test('TC-FOR-001: GET /paths/:pathId/forum devuelve hilos sin token', async ({ request }) => {
// Arrange
const pathId = process.env.TEST_PATH_ID!;
// Act
const res = await request.get(`/paths/${pathId}/forum`);
// Assert
expect(res.status()).toBe(200);
const body = await res.json();
expect(body).toHaveProperty('data');
expect(Array.isArray(body.data)).toBe(true);
expect(body).toHaveProperty('total');
expect(body).toHaveProperty('totalPages');
// Pinned threads come first
if (body.data.length > 1) {
const pinnedIdx = body.data.findIndex((t: { isPinned: boolean }) => t.isPinned);
if (pinnedIdx >= 0) expect(pinnedIdx).toBe(0);
}
});

TC-FOR-002 — Tabs de filtrado (unanswered, resolved, academIA)

Section titled “TC-FOR-002 — Tabs de filtrado (unanswered, resolved, academIA)”

Cubre: AC-FOR-001.2 Tipo: Integration

test('TC-FOR-002: tab=unanswered devuelve solo hilos sin respuesta', async ({ request }) => {
const pathId = process.env.TEST_PATH_ID!;
const res = await request.get(`/paths/${pathId}/forum?tab=unanswered`);
expect(res.status()).toBe(200);
const body = await res.json();
for (const thread of body.data) {
expect(thread.replyCount).toBe(0);
}
});
test('TC-FOR-002b: tab=resolved devuelve solo hilos resueltos', async ({ request }) => {
const pathId = process.env.TEST_PATH_ID!;
const res = await request.get(`/paths/${pathId}/forum?tab=resolved`);
expect(res.status()).toBe(200);
const body = await res.json();
for (const thread of body.data) {
expect(thread.isResolved).toBe(true);
}
});

TC-FOR-003 — Crear hilo como estudiante matriculado

Section titled “TC-FOR-003 — Crear hilo como estudiante matriculado”

Cubre: AC-FOR-003.1, AC-FOR-003.2 Tipo: E2E

test('TC-FOR-003: crear hilo QUESTION en foro de ruta', async ({ page }) => {
// Arrange — autenticar como estudiante matriculado
await page.goto('/auth/login');
await page.fill('[name="email"]', process.env.TEST_STUDENT_EMAIL!);
await page.fill('[name="password"]', process.env.TEST_STUDENT_PASSWORD!);
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
// Act — navegar al foro y crear hilo
const pathSlug = process.env.TEST_PATH_SLUG!;
await page.goto(`/paths/${pathSlug}/forum`);
await page.click('button:has-text("Nuevo post")');
await page.selectOption('select[name="type"]', 'QUESTION');
await page.fill('[name="title"]', 'TC-FOR-003: ¿Cómo funciona el attention mechanism en Transformers?');
await page.fill('[name="body"]', 'Estoy estudiando el módulo 3 y no entiendo por qué los pesos de atención se normalizan con softmax antes de multiplicar por V.');
await page.click('button:has-text("Publicar")');
// Assert
await expect(page.locator('text=TC-FOR-003: ¿Cómo funciona')).toBeVisible({ timeout: 5000 });
});

TC-FOR-004 — Rechazo a usuario no matriculado

Section titled “TC-FOR-004 — Rechazo a usuario no matriculado”

Cubre: AC-FOR-003.4 Tipo: Integration

test('TC-FOR-004: usuario no matriculado recibe 403 al crear hilo', async ({ request }) => {
// Arrange — token de usuario sin matrícula en esta ruta
const token = process.env.TEST_UNENROLLED_USER_TOKEN!;
const pathId = process.env.TEST_PATH_ID!;
// Act
const res = await request.post(`/paths/${pathId}/forum`, {
headers: { Authorization: `Bearer ${token}` },
data: {
type: 'QUESTION',
title: 'Pregunta de un usuario no matriculado',
body: 'Este cuerpo tiene suficiente longitud para pasar la validación de 20 caracteres mínimos.',
},
});
// Assert
expect(res.status()).toBe(403);
const body = await res.json();
expect(body.message).toContain('enrolled in this path');
});

Cubre: AC-FOR-003.3 Tipo: Integration

test('TC-FOR-005: 6º hilo en una hora retorna 429', async ({ request }) => {
const token = process.env.TEST_ENROLLED_TOKEN!;
const pathId = process.env.TEST_PATH_ID!;
const createThread = () =>
request.post(`/paths/${pathId}/forum`, {
headers: { Authorization: `Bearer ${token}` },
data: {
type: 'REFLECTION',
title: 'Hilo de prueba de rate limiting (longitud ok)',
body: 'Cuerpo de prueba con suficiente contenido para superar los 20 caracteres mínimos.',
},
});
// Crear 5 hilos (deben pasar)
for (let i = 0; i < 5; i++) {
const res = await createThread();
expect(res.status()).toBe(201);
}
// El 6º debe fallar
const overLimit = await createThread();
expect(overLimit.status()).toBe(429);
const body = await overLimit.json();
expect(body.message).toContain('Rate limit exceeded');
});

TC-FOR-006 — Ver detalle de hilo e incremento de vistas

Section titled “TC-FOR-006 — Ver detalle de hilo e incremento de vistas”

Cubre: AC-FOR-004.1 Tipo: Integration

test('TC-FOR-006: GET /forum/threads/:threadId incrementa viewCount', async ({ request }) => {
const threadId = process.env.TEST_THREAD_ID!;
// Obtener viewCount inicial
const res1 = await request.get(`/forum/threads/${threadId}`);
expect(res1.status()).toBe(200);
const before = (await res1.json()).viewCount as number;
// Segunda visita
await request.get(`/forum/threads/${threadId}`);
// Verificar incremento (puede ser asíncrono, pequeño delay tolerable)
await new Promise((r) => setTimeout(r, 300));
const res3 = await request.get(`/forum/threads/${threadId}`);
const after = (await res3.json()).viewCount as number;
expect(after).toBeGreaterThan(before);
});

TC-FOR-007 — Responder a un hilo y recibir XP

Section titled “TC-FOR-007 — Responder a un hilo y recibir XP”

Cubre: AC-FOR-005.1, AC-FOR-005.2 Tipo: E2E

test('TC-FOR-007: responder a un hilo actualiza replyCount', async ({ page, request }) => {
// Arrange
await page.goto('/auth/login');
await page.fill('[name="email"]', process.env.TEST_STUDENT_EMAIL!);
await page.fill('[name="password"]', process.env.TEST_STUDENT_PASSWORD!);
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
const pathSlug = process.env.TEST_PATH_SLUG!;
const threadId = process.env.TEST_THREAD_ID!;
await page.goto(`/paths/${pathSlug}/forum/${threadId}`);
// Obtener replyCount actual
const res = await request.get(`/forum/threads/${threadId}`);
const countBefore = (await res.json()).replyCount as number;
// Act — escribir y enviar respuesta
await page.fill('textarea[placeholder*="respuesta"]', 'TC-FOR-007: Esta respuesta de prueba tiene más de diez caracteres.');
await page.click('button:has-text("Enviar respuesta")');
// Assert — replyCount incrementado en la UI
await expect(page.locator(`text=${countBefore + 1} respuesta`)).toBeVisible({ timeout: 5000 });
});

TC-FOR-008 — Rechazo de respuesta en hilo bloqueado

Section titled “TC-FOR-008 — Rechazo de respuesta en hilo bloqueado”

Cubre: AC-FOR-005.3 Tipo: Integration

test('TC-FOR-008: respuesta en hilo locked retorna 403', async ({ request }) => {
const token = process.env.TEST_ENROLLED_TOKEN!;
const lockedThreadId = process.env.TEST_LOCKED_THREAD_ID!;
const res = await request.post(`/forum/threads/${lockedThreadId}/replies`, {
headers: { Authorization: `Bearer ${token}` },
data: { body: 'Este texto tiene suficientes caracteres para la validación mínima requerida.' },
});
expect(res.status()).toBe(403);
const body = await res.json();
expect(body.message).toContain('locked');
});

TC-FOR-009 — Toggle de upvote en hilo (optimistic UI)

Section titled “TC-FOR-009 — Toggle de upvote en hilo (optimistic UI)”

Cubre: AC-FOR-006.1, AC-FOR-006.2 Tipo: E2E

test('TC-FOR-009: upvote toggle actualiza contador y revierte si falla', async ({ page }) => {
await page.goto('/auth/login');
await page.fill('[name="email"]', process.env.TEST_STUDENT_EMAIL!);
await page.fill('[name="password"]', process.env.TEST_STUDENT_PASSWORD!);
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
const pathSlug = process.env.TEST_PATH_SLUG!;
const threadId = process.env.TEST_THREAD_ID!;
await page.goto(`/paths/${pathSlug}/forum/${threadId}`);
// Leer contador inicial
const upvoteBtn = page.locator('button[aria-label="Votar a favor del hilo"]');
const initialText = await upvoteBtn.innerText();
const initialCount = parseInt(initialText.replace('', '').trim(), 10);
// Hacer upvote
await upvoteBtn.click();
await expect(upvoteBtn).toHaveText(`${initialCount + 1}`, { timeout: 3000 });
// Deshacer upvote
await upvoteBtn.click();
await expect(upvoteBtn).toHaveText(`${initialCount}`, { timeout: 3000 });
});
test('TC-FOR-009b: autor no puede votar su propio hilo', async ({ page }) => {
// El botón de upvote debe estar deshabilitado para el autor
const upvoteBtn = page.locator('button[aria-label="Votar a favor del hilo"]');
await expect(upvoteBtn).toBeDisabled();
});

Cubre: AC-FOR-007.1, AC-FOR-007.3 Tipo: E2E

test('TC-FOR-010: autor puede marcar hilo como resuelto', async ({ page }) => {
await page.goto('/auth/login');
await page.fill('[name="email"]', process.env.TEST_THREAD_AUTHOR_EMAIL!);
await page.fill('[name="password"]', process.env.TEST_THREAD_AUTHOR_PASSWORD!);
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
const pathSlug = process.env.TEST_PATH_SLUG!;
const ownThreadId = process.env.TEST_OWN_THREAD_ID!;
await page.goto(`/paths/${pathSlug}/forum/${ownThreadId}`);
// Act
await page.click('button:has-text("Marcar resuelto")');
// Assert
await expect(page.locator('text=Pregunta resuelta')).toBeVisible({ timeout: 5000 });
await expect(page.locator('button:has-text("Marcar resuelto")')).not.toBeVisible();
});
test('TC-FOR-010b: no-autor recibe 403 al resolver', async ({ request }) => {
const token = process.env.TEST_OTHER_USER_TOKEN!;
const threadId = process.env.TEST_THREAD_ID!;
const res = await request.post(`/forum/threads/${threadId}/resolve`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status()).toBe(403);
});

TC-FOR-011 — Búsqueda de hilos por texto

Section titled “TC-FOR-011 — Búsqueda de hilos por texto”

Cubre: AC-FOR-002.1 Tipo: Integration

test('TC-FOR-011: search filtra por título y cuerpo (case insensitive)', async ({ request }) => {
const pathId = process.env.TEST_PATH_ID!;
// Crear un hilo con título conocido
const token = process.env.TEST_ENROLLED_TOKEN!;
const uniqueTerm = `termino-unico-${Date.now()}`;
await request.post(`/paths/${pathId}/forum`, {
headers: { Authorization: `Bearer ${token}` },
data: {
type: 'REFLECTION',
title: `Reflexión sobre ${uniqueTerm} en IA`,
body: 'Cuerpo con suficientes caracteres para superar el mínimo de veinte caracteres.',
},
});
// Buscar
const res = await request.get(`/paths/${pathId}/forum?search=${uniqueTerm}`);
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.data.length).toBeGreaterThanOrEqual(1);
const titles = body.data.map((t: { title: string }) => t.title.toLowerCase());
expect(titles.some((t: string) => t.includes(uniqueTerm.toLowerCase()))).toBe(true);
});

Cubre: AC-FOR-009.1, AC-FOR-009.2 Tipo: E2E + Integration

test('TC-FOR-012: modal de reporte funciona y envía la razón', async ({ page }) => {
await page.goto('/auth/login');
await page.fill('[name="email"]', process.env.TEST_STUDENT_EMAIL!);
await page.fill('[name="password"]', process.env.TEST_STUDENT_PASSWORD!);
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
const pathSlug = process.env.TEST_PATH_SLUG!;
const threadId = process.env.TEST_THREAD_ID!;
await page.goto(`/paths/${pathSlug}/forum/${threadId}`);
// Abrir modal de reporte
await page.click('button:has-text("Reportar")');
await expect(page.locator('text=Reportar post')).toBeVisible();
// Seleccionar motivo y enviar
await page.selectOption('select', 'Spam o publicidad no solicitada');
await page.click('button:has-text("Enviar reporte")');
// El modal se cierra
await expect(page.locator('text=Reportar post')).not.toBeVisible({ timeout: 3000 });
});
test('TC-FOR-012b: reporte sin token retorna 401', async ({ request }) => {
const threadId = process.env.TEST_THREAD_ID!;
const res = await request.post(`/forum/threads/${threadId}/report`, {
data: { reason: 'Spam' },
});
expect(res.status()).toBe(401);
});

TC-FOR-013 — Editar y eliminar hilo propio

Section titled “TC-FOR-013 — Editar y eliminar hilo propio”

Cubre: AC-FOR-008.1, AC-FOR-008.2 Tipo: Integration

test('TC-FOR-013: autor puede editar y luego eliminar su propio hilo', async ({ request }) => {
const token = process.env.TEST_ENROLLED_TOKEN!;
const pathId = process.env.TEST_PATH_ID!;
// Crear hilo
const createRes = await request.post(`/paths/${pathId}/forum`, {
headers: { Authorization: `Bearer ${token}` },
data: {
type: 'RESOURCE',
title: 'TC-FOR-013: Hilo para editar y borrar después',
body: 'Cuerpo inicial con suficiente longitud para la validación mínima requerida.',
},
});
expect(createRes.status()).toBe(201);
const thread = await createRes.json();
// Editar
const patchRes = await request.patch(`/forum/threads/${thread.id}`, {
headers: { Authorization: `Bearer ${token}` },
data: { title: 'TC-FOR-013: Título editado correctamente' },
});
expect(patchRes.status()).toBe(200);
const patched = await patchRes.json();
expect(patched.title).toBe('TC-FOR-013: Título editado correctamente');
// Eliminar
const deleteRes = await request.delete(`/forum/threads/${thread.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(deleteRes.status()).toBe(204);
// Verificar que ya no existe
const getRes = await request.get(`/forum/threads/${thread.id}`);
expect(getRes.status()).toBe(404);
});

TC-FOR-014 — Contenido relacionado en hilo

Section titled “TC-FOR-014 — Contenido relacionado en hilo”

Cubre: AC-FOR-004.3 Tipo: Integration

test('TC-FOR-014: GET /forum/threads/:threadId/related devuelve lecciones e hilos', async ({ request }) => {
const threadId = process.env.TEST_THREAD_ID!;
const res = await request.get(`/forum/threads/${threadId}/related`);
expect(res.status()).toBe(200);
const body = await res.json();
expect(body).toHaveProperty('threads');
expect(body).toHaveProperty('lessons');
expect(Array.isArray(body.threads)).toBe(true);
expect(Array.isArray(body.lessons)).toBe(true);
// Cada lección relacionada tiene pathSlug para construir el enlace
for (const lesson of body.lessons) {
expect(lesson).toHaveProperty('pathSlug');
expect(lesson).toHaveProperty('title');
expect(lesson).toHaveProperty('durationMin');
}
});

TC-FOR-015 — Badge de preguntas sin responder en sidebar

Section titled “TC-FOR-015 — Badge de preguntas sin responder en sidebar”

Cubre: AC-FOR-010.2 Tipo: Integration

test('TC-FOR-015: GET /forum/my-stats devuelve unansweredCount', async ({ request }) => {
const token = process.env.TEST_ENROLLED_TOKEN!;
const res = await request.get('/forum/my-stats', {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(body).toHaveProperty('unansweredCount');
expect(typeof body.unansweredCount).toBe('number');
expect(body.unansweredCount).toBeGreaterThanOrEqual(0);
});

  • AcademIA: al crear un hilo se llama ForumAcademIAService.handleNewThread() en fire-and-forget. La IA puede publicar una respuesta automática con isAcademIA = true y un campo academIAConf (0–1).
  • Indexación semántica: hilos y respuestas se indexan en Qdrant via ForumIndexingService de forma asíncrona (no bloquea la respuesta HTTP).
  • Reportes (Phase 1): los reportes solo generan un log de warning en NestJS. La entidad Report y las notificaciones al SUPER_ADMIN están planificadas para Phase 2.
  • Favoritos / seguir hilo: los endpoints /bookmark y /follow están definidos en el RTK Query slice pero no tienen controlador backend aún — retornan 404 actualmente.
  • Eliminación de hilo: es un hard delete que cascada a ForumReply y ForumUpvote a nivel de base de datos.