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
Endpoints implementados
Section titled “Endpoints implementados”| Método | Ruta | Auth | Guard extra | Descripción |
|---|---|---|---|---|
GET | /paths/:pathId/forum | Opcional | — | Listar hilos de una ruta (paginado, filtros, búsqueda) |
GET | /paths/:pathId/forum/module/:moduleId | Opcional | — | Listar hilos filtrados por módulo |
GET | /paths/:pathId/forum/stats | No | — | Estadísticas del foro de la ruta |
POST | /paths/:pathId/forum | Sí | ForumAccessGuard (matrícula) | Crear nuevo hilo |
GET | /forum/threads/:threadId | Opcional | — | Detalle del hilo con respuestas |
GET | /forum/threads/:threadId/related | No | — | Hilos y lecciones relacionados |
PATCH | /forum/threads/:threadId | Sí | Autor o admin | Editar título/cuerpo del hilo |
DELETE | /forum/threads/:threadId | Sí | Autor o admin | Eliminar hilo (cascade en replies) |
POST | /forum/threads/:threadId/replies | Sí | ForumAccessGuard (matrícula) | Crear respuesta |
PATCH | /forum/replies/:replyId | Sí | Autor o admin | Editar cuerpo de respuesta |
DELETE | /forum/replies/:replyId | Sí | Autor o admin | Eliminar respuesta |
POST | /forum/threads/:threadId/upvote | Sí | — | Toggle upvote en hilo |
POST | /forum/replies/:replyId/upvote | Sí | — | Toggle upvote en respuesta |
POST | /forum/threads/:threadId/resolve | Sí | Autor o admin | Marcar hilo como resuelto |
POST | /forum/threads/:threadId/report | Sí | — | Reportar hilo (Phase 1: solo log) |
POST | /forum/replies/:replyId/report | Sí | — | Reportar respuesta (Phase 1: solo log) |
GET | /forum/my-stats | Sí | — | Preguntas sin responder en rutas del usuario |
POST | /forum/threads/:threadId/pin | Sí | CONTENT_ADMIN, SUPER_ADMIN | Toggle pin del hilo |
POST | /forum/threads/:threadId/lock | Sí | CONTENT_ADMIN, SUPER_ADMIN | Toggle lock del hilo |
POST | /forum/replies/:replyId/official | Sí | CONTENT_ADMIN, SUPER_ADMIN | Marcar respuesta como oficial |
Tipos de post (ForumPostType)
Section titled “Tipos de post (ForumPostType)”El campo type en CreateThreadDto acepta los valores del enum Prisma ForumPostType:
| Valor | Etiqueta UI |
|---|---|
QUESTION | ❓ Preguntas |
REFLECTION | 💡 Reflexiones |
REAL_CASE | 🏢 Casos reales |
RESOURCE | 🔗 Recursos |
User Stories
Section titled “User Stories”US-FOR-001: Explorar el foro de una ruta
Section titled “US-FOR-001: Explorar el foro de una ruta”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/forumWhen se carga la páginaThen 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 descAC-FOR-001.2 — Filtros por tab
Section titled “AC-FOR-001.2 — Filtros por tab”Given el usuario está en la página del foroWhen selecciona el tab "Sin respuesta"Then la lista muestra solo hilos con replyCount = 0When selecciona el tab "Resueltos"Then la lista muestra solo hilos con isResolved = trueWhen selecciona el tab "AcademIA"Then la lista muestra solo hilos que tienen al menos una respuesta de la IAAC-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 foroThen la barra lateral derecha muestra: nº de estudiantes, posts activos, % respondidos y respuestas de AcademIA esta semanaAnd el widget "Top contribuidores" lista hasta 5 usuarios ordenados por upvotes totalesAnd el widget "Módulos más activos" muestra hasta 4 módulos con barra de actividad relativaAC-FOR-001.4 — Highlight semanal
Section titled “AC-FOR-001.4 — Highlight semanal”Given existe un hilo con isPinned = true y pinnedUntil en el futuroWhen el usuario carga el foroThen aparece un banner "Debate de la semana" destacado sobre la lista de hilosAnd al hacer click navega al detalle del hilo destacadoUS-FOR-002: Buscar y filtrar hilos
Section titled “US-FOR-002: Buscar y filtrar hilos”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
AC-FOR-002.1 — Búsqueda por texto
Section titled “AC-FOR-002.1 — Búsqueda por texto”Given el usuario está en el foro de una rutaWhen escribe un término en el campo de búsquedaThen 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 foroWhen selecciona un módulo específicoThen la URL se actualiza con ?moduleId=<id>And la lista muestra solo los hilos asociados a ese móduloAnd el sidebar muestra el contador de hilos por móduloAC-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 izquierdaWhen selecciona el tipo "QUESTION"Then la lista muestra solo hilos de tipo QUESTIONWhen el usuario cambia la ordenación a "Más votado"Then la lista se reordena por upvoteCount descUS-FOR-003: Crear un nuevo hilo
Section titled “US-FOR-003: Crear un nuevo hilo”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 caracteresThen la acción es rechazada con un error de validaciónWhen el título tiene entre 10 y 200 caracteres y el cuerpo entre 20 y 10.000 caracteresThen el hilo se crea correctamente y la lista se actualizaAC-FOR-003.2 — Asociación a módulo
Section titled “AC-FOR-003.2 — Asociación a módulo”Given el usuario crea un hiloWhen selecciona un módulo del selector opcionalThen el hilo queda asociado a ese módulo y el filtro por módulo lo incluye en los resultadosAC-FOR-003.3 — Rate limiting
Section titled “AC-FOR-003.3 — Rate limiting”Given el usuario ha creado 5 hilos en la última horaWhen intenta crear un hilo adicionalThen 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 usuarioAC-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 rutaWhen intenta hacer POST /paths/:pathId/forumThen 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 hiloThen ve el título, cuerpo, tipo, módulo (si aplica), autor con avatar, fecha de publicación y contador de vistasAnd las respuestas se muestran ordenadas por votos por defecto (AcademIA primero)And el viewCount del hilo se incrementa en 1 de forma asíncronaAC-FOR-004.2 — Respuestas de AcademIA
Section titled “AC-FOR-004.2 — Respuestas de AcademIA”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 realAnd si la respuesta tiene academIAConf < 0.5 se muestra un indicador de baja confianzaAC-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 hiloThen la barra lateral derecha muestra hasta 5 lecciones relacionadas del mismo módulo (o ruta)And muestra hasta 5 hilos relacionados ordenados por upvotes + repliesAnd cada lección enlaza a /learn/:pathSlug/:lessonIdUS-FOR-005: Responder a un hilo
Section titled “US-FOR-005: Responder a un hilo”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
AC-FOR-005.1 — Crear respuesta válida
Section titled “AC-FOR-005.1 — Crear respuesta válida”Given el usuario autenticado y matriculado está en el detalle de un hilo no bloqueadoWhen escribe una respuesta de al menos 10 caracteres y la envíaThen la respuesta aparece en la lista y el replyCount del hilo se incrementa en 1And el usuario recibe 5 XP por el evento FORUM_REPLY_CREATEDAC-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) respondeWhen se procesa la respuestaThen el usuario A recibe una notificación de tipo FORUM_REPLY_ON_MY_THREADAnd el asunto es "Alguien respondió en tu hilo" con enlace al hiloAC-FOR-005.3 — Hilo bloqueado
Section titled “AC-FOR-005.3 — Hilo bloqueado”Given un hilo tiene isLocked = trueWhen el usuario intenta crear una respuestaThen 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 hiloWhen hace click en el botón de upvoteThen el contador se incrementa de forma optimista en el cliente inmediatamenteAnd la API persiste el voto y retorna { upvoted: true }When vuelve a hacer click en el mismo hiloThen el voto se elimina (toggle) y el contador decreceAC-FOR-006.2 — Restricción autor
Section titled “AC-FOR-006.2 — Restricción autor”Given el usuario es el autor de un hiloWhen intenta hacer click en el botón de upvote de su propio hiloThen el botón está deshabilitado (opacity: 0.5, cursor: not-allowed)And no se realiza ninguna llamada a la APIAC-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 hiloWhen el usuario B (distinto) upvotea esa respuestaThen el usuario A recibe 10 XP por el evento FORUM_REPLY_UPVOTEDAnd el usuario A recibe una notificación de tipo FORUM_REPLY_UPVOTEDUS-FOR-007: Marcar un hilo como resuelto
Section titled “US-FOR-007: Marcar un hilo como resuelto”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
AC-FOR-007.1 — Marcar resuelto (autor)
Section titled “AC-FOR-007.1 — Marcar resuelto (autor)”Given el usuario es el autor del hilo y el hilo no está resueltoWhen hace click en el botón "✅ Marcar resuelto"Then el hilo queda con isResolved = true y resolvedById = userIdAnd la UI muestra el banner verde "Pregunta resuelta" con descripciónAnd el botón "Marcar resuelto" desaparece del hiloAC-FOR-007.2 — Notificaciones al resolver
Section titled “AC-FOR-007.2 — Notificaciones al resolver”Given el administrador resuelve el hilo de otro usuarioWhen se procesa la resoluciónThen el autor del hilo recibe una notificación de tipo FORUM_THREAD_RESOLVEDAnd cada contributor (distinto del autor y del resolvedor) recibe una notificación de tipo FORUM_THREAD_RESOLVED_CONTRIBUTEDAC-FOR-007.3 — Restricción de permisos
Section titled “AC-FOR-007.3 — Restricción de permisos”Given un usuario NO es el autor del hilo ni tiene rol CONTENT_ADMIN/SUPER_ADMINWhen intenta hacer POST /forum/threads/:threadId/resolveThen 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
AC-FOR-008.1 — Edición de hilo propio
Section titled “AC-FOR-008.1 — Edición de hilo propio”Given el usuario es el autor del hilo y el hilo no está bloqueadoWhen envía PATCH /forum/threads/:threadId con { title, body }Then el hilo se actualiza con los nuevos valores y updatedAt se registraAnd la UI refleja el campo "Editado hace X" en la meta rowAC-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 hiloWhen hace DELETE /forum/threads/:threadIdThen el hilo y todas sus respuestas y upvotes se eliminan en cascadaAnd la lista del foro se refresca sin el hilo eliminadoAC-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 adminWhen intenta hacer PATCH /forum/threads/:threadIdThen 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 hiloWhen hace click en "⚑ Reportar"Then se abre un modal con un selector de motivo (5 opciones predefinidas) y un textarea opcionalWhen selecciona un motivo y confirmaThen se envía POST /forum/threads/:threadId/report con { reason }And el modal se cierra y la UI no cambia visualmente el hilo reportadoAC-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 hiloWhen intenta hacer POST /forum/threads/:threadId/reportThen la API devuelve HTTP 401And la UI no muestra el botón de reporte a usuarios no autenticadosUS-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
AC-FOR-010.1 — Filtro “Mis posts”
Section titled “AC-FOR-010.1 — Filtro “Mis posts””Given el usuario autenticado está en el foro de una rutaWhen hace click en "✍️ Mis posts" en la barra lateral izquierdaThen la URL se actualiza con ?myPosts=trueAnd la lista muestra solo los hilos donde authorId = userIdAnd 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íasWhen se carga cualquier página con el sidebar principalThen el icono del foro muestra un badge numérico con la cuenta de unansweredCountAnd GET /forum/my-stats devuelve { unansweredCount: N }Test Cases
Section titled “Test Cases”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');});TC-FOR-005 — Rate limit al crear hilos
Section titled “TC-FOR-005 — Rate limit al crear hilos”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();});TC-FOR-010 — Marcar hilo como resuelto
Section titled “TC-FOR-010 — Marcar hilo como resuelto”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);});TC-FOR-012 — Reportar un hilo
Section titled “TC-FOR-012 — Reportar un hilo”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);});Notas de implementación
Section titled “Notas de implementación”- AcademIA: al crear un hilo se llama
ForumAcademIAService.handleNewThread()en fire-and-forget. La IA puede publicar una respuesta automática conisAcademIA = truey un campoacademIAConf(0–1). - Indexación semántica: hilos y respuestas se indexan en Qdrant via
ForumIndexingServicede forma asíncrona (no bloquea la respuesta HTTP). - Reportes (Phase 1): los reportes solo generan un log de warning en NestJS. La entidad
Reporty las notificaciones alSUPER_ADMINestán planificadas para Phase 2. - Favoritos / seguir hilo: los endpoints
/bookmarky/followestá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
ForumReplyyForumUpvotea nivel de base de datos.