Notificaciones
Módulos API: notifications
Páginas web: NotificationsPage (/notifications), NotificationBell + NotificationDropdown (header widget), NotificationPreferencesPanel (settings)
Spec Playwright: apps/e2e/tests/student/notifications.spec.ts — 📋 pendiente
Estado del módulo
Section titled “Estado del módulo”| Endpoint | Método | Descripción | Estado |
|---|---|---|---|
/notifications | GET | Listado paginado (limit/offset/category/isRead) | ✅ Implementado |
/notifications/unread-count | GET | Contador de no leídas para el badge del header | ✅ Implementado |
/notifications/:id/read | PATCH | Marcar una notificación como leída | ✅ Implementado |
/notifications/mark-all-read | POST | Marcar todas como leídas | ✅ Implementado |
/notifications/mark-category-read | POST | Marcar todas las de una categoría como leídas | ✅ Implementado |
/notifications/:id | DELETE | Eliminar (solo severity info/success) | ✅ Implementado |
/notifications/preferences | GET | Leer preferencias de notificación del usuario | ✅ Implementado |
/notifications/preferences | PATCH | Actualizar preferencias (merge parcial) | ✅ Implementado |
/notifications/stats | GET | Estadísticas diarias (solo SUPER_ADMIN) | ✅ Implementado |
Contexto técnico
Section titled “Contexto técnico”Reglas de negocio clave:
- Deduplicación: si ya existe una notificación con el mismo
userId + type + metadatacreada en las últimas 24 horas,send()retorna la existente sin crear duplicado. - Severidad
criticalywarning: no pueden ser eliminadas por el usuario (HTTP 403). El cron de limpieza (cleanup-old-notifications, domingos 03:00 UTC) tampoco las borra aunque superen 90 días. - Notificaciones
criticalbypasan preferencias de email: se envían aunque el usuario tenga desactivado el canal email en sus preferencias. - Paginación del dropdown: 20 ítems por petición (RTK Query, filtrado por categoría según pestaña activa).
- Paginación de la página
/notifications: 15 ítems por página, con paginación numérica. - Limpieza automática: notificaciones leídas de más de 90 días se eliminan en el cron semanal (excepto
critical/warningde categoríaSYSTEM). - Tipos de notificación: 101 tipos definidos en
notification-type.config.ts, organizados en categorías:LEARNING,ACHIEVEMENT,CONTENT,CREATOR,TEAM,SYSTEM,AI_TASK,COMMUNITY. - Badge del header: muestra el número de no leídas; trunca a
9+en el bell y a99+en el dropdown. - Tabs del dropdown por rol:
studentve Todas / Aprendizaje / Logros;creatorve Todas / Contenido / Métricas;corp_adminve Todas / Equipo / Sistema;super_adminve Todas / Sistema / Contenido. - Ancho del dropdown por rol:
studentycorp_admin380 px;creatorysuper_admin420 px.
User Stories
Section titled “User Stories”US-NOTIF-001: Contador de no leídas en el header
Section titled “US-NOTIF-001: Contador de no leídas en el header”Como usuario autenticado quiero ver cuántas notificaciones tengo pendientes de leer en el icono del header para saber rápidamente si hay novedades sin tener que abrir el panel.
Módulo: notifications · Endpoint: GET /notifications/unread-count
Estado: ✅ Implementado
Prioridad: Alta
AC-NOTIF-001.1 — Badge visible cuando hay no leídas
Section titled “AC-NOTIF-001.1 — Badge visible cuando hay no leídas”Given el usuario tiene al menos 1 notificación no leídaWhen renderiza cualquier página de la aplicaciónThen el botón de la campana muestra un badge rojo con el número de no leídasAnd el badge usa el formato "9+" cuando el conteo supera 9And el badge usa el formato "99+" en el dropdown cuando supera 99AC-NOTIF-001.2 — Badge oculto cuando todo está leído
Section titled “AC-NOTIF-001.2 — Badge oculto cuando todo está leído”Given el usuario no tiene ninguna notificación sin leerWhen renderiza cualquier página de la aplicaciónThen el botón de la campana no muestra ningún badgeAC-NOTIF-001.3 — Actualización en tiempo real
Section titled “AC-NOTIF-001.3 — Actualización en tiempo real”Given el usuario tiene el WebSocket de notificaciones conectadoWhen el backend crea una nueva notificación para ese usuarioThen el frontend recibe el frame "notification:new" por WebSocketAnd el badge se actualiza sin necesidad de recargar la páginaUS-NOTIF-002: Panel de notificaciones en el header
Section titled “US-NOTIF-002: Panel de notificaciones en el header”Como usuario autenticado quiero abrir el panel de notificaciones desde el header para revisar rápidamente las últimas novedades sin salir de la página actual.
Módulo: notifications · Endpoint: GET /notifications
Estado: ✅ Implementado
Prioridad: Alta
AC-NOTIF-002.1 — Apertura y cierre del dropdown
Section titled “AC-NOTIF-002.1 — Apertura y cierre del dropdown”Given el usuario está en cualquier página autenticadaWhen hace clic en el botón de la campanaThen se abre el dropdown de notificacionesAnd muestra como máximo las 20 notificaciones más recientes del usuarioAnd el dropdown se cierra al hacer clic fuera de élAC-NOTIF-002.2 — Tabs de categoría por rol
Section titled “AC-NOTIF-002.2 — Tabs de categoría por rol”Given el usuario tiene rol "FREE", "STARTER", "PRO", "ELITE" o "CORPORATE_USER"When abre el dropdown de notificacionesThen ve las pestañas "Todas", "Aprendizaje" y "Logros"
Given el usuario tiene rol "CONTENT_ADMIN"When abre el dropdown de notificacionesThen ve las pestañas "Todas", "Contenido" y "Métricas"AC-NOTIF-002.3 — Estado vacío
Section titled “AC-NOTIF-002.3 — Estado vacío”Given el usuario no tiene notificaciones en la categoría seleccionadaWhen abre el dropdown de notificacionesThen se muestra el mensaje "No hay notificaciones"And no se muestra ningún elemento de listaUS-NOTIF-003: Marcar una notificación como leída
Section titled “US-NOTIF-003: Marcar una notificación como leída”Como usuario autenticado quiero marcar una notificación individual como leída para gestionar mi bandeja y saber qué ya he revisado.
Módulo: notifications · Endpoint: PATCH /notifications/:id/read
Estado: ✅ Implementado
Prioridad: Alta
AC-NOTIF-003.1 — Clic en notificación la marca como leída y navega
Section titled “AC-NOTIF-003.1 — Clic en notificación la marca como leída y navega”Given el usuario tiene una notificación no leída con actionUrl "/paths/intro-ia"When hace clic sobre esa notificación en el dropdown o en la página /notificationsThen la notificación se marca como leída en BD (isRead=true, readAt=ahora)And el usuario navega a la ruta "/paths/intro-ia"And el badge del header se decrementa en 1AC-NOTIF-003.2 — Clic en notificación ya leída no genera petición redundante
Section titled “AC-NOTIF-003.2 — Clic en notificación ya leída no genera petición redundante”Given el usuario tiene una notificación con isRead=trueWhen hace clic sobre ellaThen el cliente navega al actionUrl de la notificaciónAnd NO se realiza ninguna llamada a PATCH /notifications/:id/readAC-NOTIF-003.3 — Ownership check: otro usuario no puede marcar
Section titled “AC-NOTIF-003.3 — Ownership check: otro usuario no puede marcar”Given un usuario A y un usuario B autenticados por separadoWhen el usuario B intenta hacer PATCH /notifications/<id_notif_de_A>/readThen la API responde con HTTP 403 ForbiddenUS-NOTIF-004: Marcar todas como leídas
Section titled “US-NOTIF-004: Marcar todas como leídas”Como usuario autenticado quiero marcar todas mis notificaciones como leídas de una sola acción para limpiar la bandeja rápidamente.
Módulo: notifications · Endpoints: POST /notifications/mark-all-read, POST /notifications/mark-category-read
Estado: ✅ Implementado
Prioridad: Alta
AC-NOTIF-004.1 — “Marcar leídas” en el dropdown marca todas o la categoría activa
Section titled “AC-NOTIF-004.1 — “Marcar leídas” en el dropdown marca todas o la categoría activa”Given el usuario tiene notificaciones no leídasAnd el dropdown está abierto con la pestaña "Todas" activaWhen hace clic en "Marcar leídas"Then se llama a POST /notifications/mark-all-readAnd todas las notificaciones del usuario quedan con isRead=trueAnd el badge del header pasa a 0AC-NOTIF-004.2 — “Marcar leídas” con categoría activa filtra por categoría
Section titled “AC-NOTIF-004.2 — “Marcar leídas” con categoría activa filtra por categoría”Given el dropdown está abierto con la pestaña "Aprendizaje" activaWhen el usuario hace clic en "Marcar leídas"Then se llama a POST /notifications/mark-category-read con body { category: "learning" }And solo las notificaciones de categoría LEARNING quedan como leídasAnd las notificaciones de otras categorías mantienen su estado isRead anteriorUS-NOTIF-005: Navegar al recurso vinculado de una notificación
Section titled “US-NOTIF-005: Navegar al recurso vinculado de una notificación”Como usuario autenticado quiero hacer clic en una notificación para ir directamente al recurso relacionado para actuar sobre lo que me notifica sin buscar manualmente.
Módulo: notifications · Frontend: NotificationItem, NotificationsPage
Estado: ✅ Implementado
Prioridad: Alta
AC-NOTIF-005.1 — Navegación a actionUrl
Section titled “AC-NOTIF-005.1 — Navegación a actionUrl”Given una notificación con actionUrl "/paths/prompt-engineering/modules/intro"When el usuario hace clic sobre ellaThen el router navega a "/paths/prompt-engineering/modules/intro"And el dropdown se cierra (si estaba abierto)AC-NOTIF-005.2 — Invitación co-instructor abre modal inline
Section titled “AC-NOTIF-005.2 — Invitación co-instructor abre modal inline”Given una notificación de tipo PATH_INSTRUCTOR_INVITEDAnd el actionUrl contiene "/path-instructors/invitations/<token>"When el usuario hace clic sobre esa notificaciónThen se abre un modal inline con los detalles de la invitación (ruta, rol, permisos)And el modal tiene botones "Aceptar invitación" y "Declinar invitación"And NO navega al actionUrl directamenteAC-NOTIF-005.3 — Indicador visual de destino
Section titled “AC-NOTIF-005.3 — Indicador visual de destino”Given una notificación tiene actionLabel "Empezar primera lección"When el usuario visualiza la notificación en /notificationsThen se muestra el texto del actionLabel seguido de "→" en color púrpuraUS-NOTIF-006: Página completa de notificaciones
Section titled “US-NOTIF-006: Página completa de notificaciones”Como usuario autenticado quiero acceder a una página dedicada /notifications para ver, filtrar y gestionar todo mi historial de notificaciones con paginación.
Módulo: notifications · Frontend: NotificationsPage
Estado: ✅ Implementado
Prioridad: Media
AC-NOTIF-006.1 — Filtro por categoría y estado de lectura
Section titled “AC-NOTIF-006.1 — Filtro por categoría y estado de lectura”Given el usuario está en /notificationsWhen selecciona la pill "Aprendizaje" y activa el filtro "Sin leer"Then la API recibe GET /notifications?category=learning&isRead=false&limit=15&offset=0And la lista muestra únicamente notificaciones de categoría LEARNING no leídasAC-NOTIF-006.2 — Paginación con 15 ítems por página
Section titled “AC-NOTIF-006.2 — Paginación con 15 ítems por página”Given el usuario tiene 40 notificaciones de categoría "Todas"When está en la página /notificationsThen se muestra la paginación con páginas 1, 2 y 3And la primera página muestra 15 notificaciones ordenadas por fecha descendenteAnd al hacer clic en la página 2 la lista hace scroll suave hacia arribaAC-NOTIF-006.3 — Agrupación por fecha
Section titled “AC-NOTIF-006.3 — Agrupación por fecha”Given el usuario tiene notificaciones de hoy, ayer y hace 5 díasWhen visita /notificationsThen las notificaciones se agrupan en secciones "Hoy", "Ayer" y "Esta semana"And cada sección muestra un encabezado con el número de ítems del grupoAC-NOTIF-006.4 — Stats en el encabezado
Section titled “AC-NOTIF-006.4 — Stats en el encabezado”Given el usuario tiene 12 notificaciones totales, 5 sin leer y 7 leídasWhen visita /notificationsThen ve tres tarjetas de estadísticas: "5 Sin leer", "12 Total", "7 Leídas"And la tarjeta "Sin leer" está destacada en púrpura si el valor es > 0US-NOTIF-007: Eliminar una notificación
Section titled “US-NOTIF-007: Eliminar una notificación”Como usuario autenticado quiero eliminar notificaciones informativas para mantener limpia mi bandeja.
Módulo: notifications · Endpoint: DELETE /notifications/:id
Estado: ✅ Implementado
Prioridad: Media
AC-NOTIF-007.1 — Eliminación de notificaciones no críticas
Section titled “AC-NOTIF-007.1 — Eliminación de notificaciones no críticas”Given una notificación con severity "info" o "success"When el usuario hace hover sobre su fila en /notifications y pulsa el botón de eliminarThen se llama a DELETE /notifications/:idAnd la notificación desaparece del listadoAnd el total de notificaciones se reduce en 1AC-NOTIF-007.2 — Notificaciones critical y warning no se pueden eliminar
Section titled “AC-NOTIF-007.2 — Notificaciones critical y warning no se pueden eliminar”Given una notificación con severity "critical" (ej. PLAN_EXPIRED)When el usuario visualiza esa notificación en /notificationsThen el botón de eliminar NO aparece para esa fila (no se renderiza)AC-NOTIF-007.3 — API bloquea eliminación de critical/warning
Section titled “AC-NOTIF-007.3 — API bloquea eliminación de critical/warning”Given una notificación con severity "critical" o "warning"When cualquier cliente llama a DELETE /notifications/:id directamenteThen la API responde con HTTP 403 ForbiddenAnd el mensaje indica "Critical and warning notifications cannot be deleted"US-NOTIF-008: Preferencias de notificación por canal
Section titled “US-NOTIF-008: Preferencias de notificación por canal”Como usuario autenticado quiero configurar qué tipos de notificaciones quiero recibir por email y push para evitar ruido innecesario y recibir solo lo que me importa.
Módulo: notifications · Endpoints: GET /notifications/preferences, PATCH /notifications/preferences
Estado: ✅ Implementado
Prioridad: Media
AC-NOTIF-008.1 — Panel de preferencias filtrado por rol
Section titled “AC-NOTIF-008.1 — Panel de preferencias filtrado por rol”Given un usuario con rol "STARTER"When accede al panel de preferencias de notificaciónThen ve secciones "Email" (recordatorios, logros, contenido, novedades) y "Push" (racha, tutor, quizzes)And los toggles reflejan el estado guardado en BDAC-NOTIF-008.2 — Guardar preferencias hace merge parcial
Section titled “AC-NOTIF-008.2 — Guardar preferencias hace merge parcial”Given el usuario tiene guardado { email_achievements: true }When desactiva "Recordatorios de aprendizaje" (email_learning_reminders) y guardaThen la API recibe PATCH /notifications/preferences con el objeto actualizadoAnd BD almacena el merge: { email_achievements: true, email_learning_reminders: false }And se muestra un toast "Preferencias guardadas"AC-NOTIF-008.3 — Preferencias “Siempre activo” no son editables
Section titled “AC-NOTIF-008.3 — Preferencias “Siempre activo” no son editables”Given un usuario SUPER_ADMIN con la preferencia "Alertas de seguridad" marcada como alwaysOnWhen visualiza el panel de preferenciasThen el toggle de "Alertas de seguridad" aparece en estado encendido y deshabilitadoAnd no puede desactivarlo ni mediante la UI ni mediante la APIUS-NOTIF-009: Severidad critical bypasa preferencias de email
Section titled “US-NOTIF-009: Severidad critical bypasa preferencias de email”Como usuario quiero asegurarme de recibir siempre las notificaciones críticas (vencimiento de plan, alertas de seguridad, etc.) para no perderme eventos importantes aunque haya desactivado el email.
Módulo: notifications · Lógica en: notification.service.ts
Estado: ✅ Implementado
Prioridad: Alta
AC-NOTIF-009.1 — Notificación critical llega aunque emailEnabled=false
Section titled “AC-NOTIF-009.1 — Notificación critical llega aunque emailEnabled=false”Given un usuario tiene { emailEnabled: false } en sus preferenciasAnd tiene configurado un email válido y emailUnsubscribed=falseWhen el sistema genera una notificación de tipo PLAN_EXPIRED (severity=critical)Then se crea la notificación en BDAnd se encola el email a través de BullMQ con prioridad CRITICALAnd el usuario recibe el email independientemente de la preferencia emailEnabledAC-NOTIF-009.2 — Notificación critical no puede ser deshabilitada
Section titled “AC-NOTIF-009.2 — Notificación critical no puede ser deshabilitada”Given la configuración del tipo PLAN_EXPIRED tiene disableable=falseWhen el usuario intenta desactivar ese tipo de notificaciónThen la UI no presenta ese tipo como configurableAnd la notificación se crea siempre que ocurre el evento, sin comprobar preferencias de tipoUS-NOTIF-010: Deduplicación de notificaciones en ventana de 24h
Section titled “US-NOTIF-010: Deduplicación de notificaciones en ventana de 24h”Como sistema quiero que no se creen notificaciones duplicadas del mismo evento en menos de 24 horas para evitar spam en la bandeja del usuario.
Módulo: notifications · Lógica en: notification.service.ts
Estado: ✅ Implementado
Prioridad: Alta
AC-NOTIF-010.1 — Segunda llamada a send() en menos de 24h retorna la existente
Section titled “AC-NOTIF-010.1 — Segunda llamada a send() en menos de 24h retorna la existente”Given el usuario tiene una notificación de tipo BADGE_EARNED con metadata { badgeId: "abc" } creada hace 12 horasWhen el sistema intenta crear otra notificación del mismo tipo y mismo metadata para ese usuarioThen NotificationService.send() retorna la notificación existente sin crear un duplicadoAnd la tabla Notification no tiene una nueva filaAC-NOTIF-010.2 — Mismo tipo pero metadata diferente crea nueva notificación
Section titled “AC-NOTIF-010.2 — Mismo tipo pero metadata diferente crea nueva notificación”Given el usuario tiene una notificación BADGE_EARNED con metadata { badgeId: "abc" } creada hace 6 horasWhen el sistema intenta crear una notificación BADGE_EARNED con metadata { badgeId: "xyz" }Then se crea una nueva notificación en BDAnd el usuario ve dos notificaciones de tipo BADGE_EARNED en su bandejaAC-NOTIF-010.3 — Deduplicación respeta el límite de 24h
Section titled “AC-NOTIF-010.3 — Deduplicación respeta el límite de 24h”Given el usuario tiene una notificación STREAK_RISK creada hace 25 horasWhen el sistema genera otra notificación STREAK_RISK con mismo metadata para ese usuarioThen se crea una nueva notificación (la anterior tiene más de 24h)And el usuario ve las dos notificaciones en su bandejaUS-NOTIF-011: Limpieza automática de notificaciones antiguas
Section titled “US-NOTIF-011: Limpieza automática de notificaciones antiguas”Como plataforma quiero eliminar automáticamente las notificaciones leídas y antiguas para que la BD no crezca indefinidamente.
Módulo: notifications · Job: CleanupNotificationsJob (cron domingos 03:00 UTC)
Estado: ✅ Implementado
Prioridad: Baja
AC-NOTIF-011.1 — Notificaciones leídas de más de 90 días se eliminan
Section titled “AC-NOTIF-011.1 — Notificaciones leídas de más de 90 días se eliminan”Given existen notificaciones con isRead=true y createdAt < hace 90 díasAnd tienen severity "info" o "success"When se ejecuta el job "cleanup-old-notifications"Then esas notificaciones se eliminan de la BDAnd el job registra en JobExecution el número de ítems procesadosAC-NOTIF-011.2 — Notificaciones critical/warning SYSTEM no se eliminan
Section titled “AC-NOTIF-011.2 — Notificaciones critical/warning SYSTEM no se eliminan”Given existen notificaciones con severity "critical" y category "SYSTEM" leídas de más de 90 díasWhen se ejecuta el job "cleanup-old-notifications"Then esas notificaciones NO se eliminan (están en la cláusula NOT del deleteMany)Test Cases
Section titled “Test Cases”TC-NOTIF-001 — Badge muestra el conteo de no leídas correcto
Section titled “TC-NOTIF-001 — Badge muestra el conteo de no leídas correcto”Cubre: AC-NOTIF-001.1, AC-NOTIF-001.2 Tipo: E2E
test('TC-NOTIF-001: badge muestra conteo de no leídas y desaparece al marcar todas', async ({ page, request }) => { // Arrange — autenticar como estudiante con notificaciones no leídas await page.goto('/login') await page.fill('[data-testid="email"]', 'student@test.com') await page.fill('[data-testid="password"]', 'Test1234!') await page.click('[data-testid="submit"]') await page.waitForURL('/dashboard')
// Act — verificar que el badge es visible const badge = page.locator('[aria-label="Abrir notificaciones"] span').first() await expect(badge).toBeVisible() const badgeText = await badge.textContent() expect(Number(badgeText?.replace('+', '')) || badgeText).toBeTruthy()
// Act — marcar todas como leídas via API const token = await page.evaluate(() => localStorage.getItem('token')) await request.post('/api/notifications/mark-all-read', { headers: { Authorization: `Bearer ${token}` }, })
// Assert — badge desaparece await page.reload() await expect(badge).not.toBeVisible()})TC-NOTIF-002 — Apertura del dropdown y pestañas por rol estudiante
Section titled “TC-NOTIF-002 — Apertura del dropdown y pestañas por rol estudiante”Cubre: AC-NOTIF-002.1, AC-NOTIF-002.2 Tipo: E2E
test('TC-NOTIF-002: dropdown se abre con las tabs correctas para rol estudiante', async ({ page }) => { await page.goto('/dashboard')
// Act — abrir el dropdown await page.click('[aria-label="Abrir notificaciones"]')
// Assert — el dialog aparece const dropdown = page.locator('[role="dialog"][aria-label="Centro de notificaciones"]') await expect(dropdown).toBeVisible()
// Assert — tabs del rol student await expect(page.getByRole('button', { name: 'Todas' })).toBeVisible() await expect(page.getByRole('button', { name: 'Aprendizaje' })).toBeVisible() await expect(page.getByRole('button', { name: 'Logros' })).toBeVisible()
// Assert — cierre al clic externo await page.click('body', { position: { x: 10, y: 10 } }) await expect(dropdown).not.toBeVisible()})TC-NOTIF-003 — Marcar una notificación como leída al hacer clic
Section titled “TC-NOTIF-003 — Marcar una notificación como leída al hacer clic”Cubre: AC-NOTIF-003.1, AC-NOTIF-003.2 Tipo: E2E + Integration
test('TC-NOTIF-003: clic en notificación la marca como leída y navega al actionUrl', async ({ page, request }) => { // Arrange — obtener token y crear notificación de prueba const loginRes = await request.post('/api/auth/login', { data: { email: 'student@test.com', password: 'Test1234!' }, }) const { access_token } = await loginRes.json()
// Seed notificación no leída await request.post('/api/notifications/seed-test', { headers: { Authorization: `Bearer ${access_token}` }, })
await page.goto('/notifications')
// Assert — hay al menos una notificación no leída (fondo púrpura) const unreadRow = page.locator('[style*="F5F3FF"]').first() await expect(unreadRow).toBeVisible()
// Act — hacer clic en la notificación await unreadRow.click()
// Assert — se navega al actionUrl await expect(page).not.toHaveURL('/notifications')
// Assert — al volver, la notificación ya está leída await page.goBack() await expect(page.locator('[style*="F5F3FF"]')).toHaveCount(0)})TC-NOTIF-004 — Marcar todas como leídas desde el dropdown
Section titled “TC-NOTIF-004 — Marcar todas como leídas desde el dropdown”Cubre: AC-NOTIF-004.1 Tipo: E2E
test('TC-NOTIF-004: "Marcar leídas" en el dropdown pone el contador a cero', async ({ page }) => { await page.goto('/dashboard')
// Verificar que hay no leídas const badge = page.locator('[aria-label="Abrir notificaciones"] span').first() await expect(badge).toBeVisible()
// Abrir dropdown await page.click('[aria-label="Abrir notificaciones"]') const dropdown = page.locator('[role="dialog"]') await expect(dropdown).toBeVisible()
// Click "Marcar leídas" await page.getByRole('button', { name: 'Marcar leídas' }).click()
// Assert — badge desaparece await expect(badge).not.toBeVisible()
// Assert — el contador en el header del dropdown es 0 (el span de conteo no existe) const unreadBadgeInDropdown = dropdown.locator('span').filter({ hasText: /^\d+$/ }).first() await expect(unreadBadgeInDropdown).not.toBeVisible()})TC-NOTIF-005 — Filtro por categoría en la página /notifications
Section titled “TC-NOTIF-005 — Filtro por categoría en la página /notifications”Cubre: AC-NOTIF-006.1 Tipo: E2E
test('TC-NOTIF-005: filtro por categoría solo muestra notificaciones del tipo seleccionado', async ({ page, request }) => { const loginRes = await request.post('/api/auth/login', { data: { email: 'student@test.com', password: 'Test1234!' }, }) const { access_token } = await loginRes.json()
await page.goto('/notifications')
// Act — seleccionar categoría "Logros" await page.getByRole('button', { name: 'Logros' }).click()
// Assert — la URL o el estado refleja el filtro (la petición API incluye category) await page.waitForResponse(res => res.url().includes('/notifications') && res.url().includes('category=achievement'), )
// Assert — todos los ítems visibles pertenecen a la categoría ACHIEVEMENT const categoryLabels = page.locator('span', { hasText: 'Logro' }) const count = await categoryLabels.count() // Si hay notificaciones, todas deben ser de categoría Logro if (count > 0) { for (let i = 0; i < count; i++) { await expect(categoryLabels.nth(i)).toBeVisible() } }})TC-NOTIF-006 — Eliminar notificación no crítica
Section titled “TC-NOTIF-006 — Eliminar notificación no crítica”Cubre: AC-NOTIF-007.1, AC-NOTIF-007.2 Tipo: E2E
test('TC-NOTIF-006: botón eliminar visible en hover y elimina la notificación', async ({ page }) => { await page.goto('/notifications')
// Buscar una fila con severity info (no critical) — el botón delete solo existe en ellas const row = page.locator('.np-row').first() await expect(row).toBeVisible()
// Hover sobre la fila para revelar el botón await row.hover()
const deleteBtn = row.locator('button[title="Eliminar"]') if (await deleteBtn.isVisible()) { const initialCount = await page.locator('.np-row').count()
// Act — eliminar await deleteBtn.click()
// Assert — el ítem desaparece await expect(page.locator('.np-row')).toHaveCount(initialCount - 1) }})TC-NOTIF-007 — API rechaza eliminación de notificación critical
Section titled “TC-NOTIF-007 — API rechaza eliminación de notificación critical”Cubre: AC-NOTIF-007.3 Tipo: Integration
test('TC-NOTIF-007: DELETE en notificación critical devuelve 403', async ({ request }) => { const loginRes = await request.post('/api/auth/login', { data: { email: 'student@test.com', password: 'Test1234!' }, }) const { access_token } = await loginRes.json()
// Obtener notificaciones del usuario const notifsRes = await request.get('/api/notifications', { headers: { Authorization: `Bearer ${access_token}` }, }) const { items } = await notifsRes.json() const criticalNotif = items.find((n: { severity: string }) => n.severity === 'critical')
if (criticalNotif) { // Act — intentar eliminarla const deleteRes = await request.delete(`/api/notifications/${criticalNotif.id}`, { headers: { Authorization: `Bearer ${access_token}` }, })
// Assert expect(deleteRes.status()).toBe(403) const body = await deleteRes.json() expect(body.message).toContain('Critical and warning notifications cannot be deleted') }})TC-NOTIF-008 — Guardar preferencias de notificación
Section titled “TC-NOTIF-008 — Guardar preferencias de notificación”Cubre: AC-NOTIF-008.1, AC-NOTIF-008.2 Tipo: E2E + Integration
test('TC-NOTIF-008: actualizar preferencia persiste en BD y muestra toast de confirmación', async ({ page, request }) => { await page.goto('/settings/notifications')
// Verificar que el panel de preferencias está visible await expect(page.getByText('Recordatorios de aprendizaje')).toBeVisible()
// Act — desactivar el toggle de "Recordatorios de aprendizaje" const toggle = page.locator('button[aria-pressed]').first() const initialState = await toggle.getAttribute('aria-pressed') await toggle.click()
// Guardar cambios await page.getByRole('button', { name: 'Guardar cambios' }).click()
// Assert — toast de confirmación await expect(page.getByText('Preferencias guardadas')).toBeVisible()
// Assert — el cambio persiste al recargar await page.reload() const toggleAfter = page.locator('button[aria-pressed]').first() const newState = await toggleAfter.getAttribute('aria-pressed') expect(newState).not.toBe(initialState)})TC-NOTIF-009 — Deduplicación de notificaciones en 24h
Section titled “TC-NOTIF-009 — Deduplicación de notificaciones en 24h”Cubre: AC-NOTIF-010.1, AC-NOTIF-010.2 Tipo: Integration
test('TC-NOTIF-009: send() dos veces con mismo tipo y metadata en menos de 24h retorna la misma notificación', async ({ request }) => { const loginRes = await request.post('/api/auth/login', { data: { email: 'student@test.com', password: 'Test1234!' }, }) const { access_token } = await loginRes.json()
// Primera llamada via seed-test const first = await request.post('/api/notifications/seed-test', { headers: { Authorization: `Bearer ${access_token}` }, }) const firstBody = await first.json()
// Segunda llamada inmediata (mismo tipo BADGE_EARNED, mismo metadata {test:true}) const second = await request.post('/api/notifications/seed-test', { headers: { Authorization: `Bearer ${access_token}` }, }) const secondBody = await second.json()
// Assert — misma notificación (mismo id) expect(secondBody.id).toBe(firstBody.id)
// Assert — la BD tiene un solo ítem del tipo const listRes = await request.get('/api/notifications?limit=100', { headers: { Authorization: `Bearer ${access_token}` }, }) const { items } = await listRes.json() const testNotifs = items.filter((n: { type: string; metadata: { test?: boolean } }) => n.type === 'BADGE_EARNED' && n.metadata?.test === true ) expect(testNotifs.length).toBe(1)})TC-NOTIF-010 — WebSocket entrega notificación en tiempo real
Section titled “TC-NOTIF-010 — WebSocket entrega notificación en tiempo real”Cubre: AC-NOTIF-001.3 Tipo: E2E
test('TC-NOTIF-010: nueva notificación via WebSocket actualiza el badge sin reload', async ({ page, request }) => { await page.goto('/dashboard')
// Verificar estado inicial del badge const badge = page.locator('[aria-label="Abrir notificaciones"] span').first() const initialVisible = await badge.isVisible()
// Act — crear notificación desde otra sesión (simula evento de servidor) const loginRes = await request.post('/api/auth/login', { data: { email: 'student@test.com', password: 'Test1234!' }, }) const { access_token } = await loginRes.json()
// Primero marcar todas leídas para partir de 0 await request.post('/api/notifications/mark-all-read', { headers: { Authorization: `Bearer ${access_token}` }, })
// Esperar a que el badge desaparezca if (initialVisible) { await expect(badge).not.toBeVisible({ timeout: 10_000 }) }
// Seed genera nueva notificación via API await request.post('/api/notifications/seed-test', { headers: { Authorization: `Bearer ${access_token}` }, })
// Assert — el badge aparece sin reload (el WS invalida las tags RTK Query) await expect(badge).toBeVisible({ timeout: 10_000 })})TC-NOTIF-011 — Marcar categoría como leída en /notifications
Section titled “TC-NOTIF-011 — Marcar categoría como leída en /notifications”Cubre: AC-NOTIF-004.2 Tipo: E2E
test('TC-NOTIF-011: "Marcar Aprendizaje leídas" solo afecta a la categoría seleccionada', async ({ page, request }) => { const loginRes = await request.post('/api/auth/login', { data: { email: 'student@test.com', password: 'Test1234!' }, }) const { access_token } = await loginRes.json()
await page.goto('/notifications')
// Seleccionar la categoría Aprendizaje await page.getByRole('button', { name: 'Aprendizaje' }).click()
// Click "Marcar Aprendizaje leídas" const markBtn = page.getByRole('button', { name: /Marcar.*leídas/i }) await expect(markBtn).toBeVisible() await markBtn.click()
// Verificar via API que LEARNING está a cero y otras categorías tienen no leídas const countRes = await request.get('/api/notifications?category=learning&isRead=false&limit=1', { headers: { Authorization: `Bearer ${access_token}` }, }) const { total } = await countRes.json() expect(total).toBe(0)})TC-NOTIF-012 — Ownership: usuario no puede leer ni eliminar notificación ajena
Section titled “TC-NOTIF-012 — Ownership: usuario no puede leer ni eliminar notificación ajena”Cubre: AC-NOTIF-003.3, AC-NOTIF-007.3 Tipo: Integration
test('TC-NOTIF-012: acceso a notificación de otro usuario devuelve 403', async ({ request }) => { // Login usuario A const loginA = await request.post('/api/auth/login', { data: { email: 'student_a@test.com', password: 'Test1234!' }, }) const { access_token: tokenA } = await loginA.json()
// Crear notificación para A await request.post('/api/notifications/seed-test', { headers: { Authorization: `Bearer ${tokenA}` }, })
// Obtener id de la notificación const notifsA = await request.get('/api/notifications?limit=1', { headers: { Authorization: `Bearer ${tokenA}` }, }) const { items } = await notifsA.json() const notifId = items[0]?.id
// Login usuario B const loginB = await request.post('/api/auth/login', { data: { email: 'student_b@test.com', password: 'Test1234!' }, }) const { access_token: tokenB } = await loginB.json()
// B intenta marcar notificación de A como leída const patchRes = await request.patch(`/api/notifications/${notifId}/read`, { headers: { Authorization: `Bearer ${tokenB}` }, }) expect(patchRes.status()).toBe(403)
// B intenta eliminar notificación de A const deleteRes = await request.delete(`/api/notifications/${notifId}`, { headers: { Authorization: `Bearer ${tokenB}` }, }) expect(deleteRes.status()).toBe(403)})TC-NOTIF-013 — Preferencias: toggle “Siempre activo” no es interactivo
Section titled “TC-NOTIF-013 — Preferencias: toggle “Siempre activo” no es interactivo”Cubre: AC-NOTIF-008.3 Tipo: E2E
test('TC-NOTIF-013: toggle marcado como alwaysOn aparece en ON y está deshabilitado', async ({ page }) => { // Login como SUPER_ADMIN que tiene alwaysOn en "Alertas de seguridad" await page.goto('/login') await page.fill('[data-testid="email"]', 'admin@test.com') await page.fill('[data-testid="password"]', 'Admin1234!') await page.click('[data-testid="submit"]')
await page.goto('/settings/notifications')
// Buscar la fila con el badge "Siempre activo" const alwaysOnRow = page.locator('span', { hasText: 'Siempre activo' }).locator('..') await expect(alwaysOnRow).toBeVisible()
// El toggle de esa fila debe estar pressed=true y disabled const toggle = alwaysOnRow.locator('button[aria-pressed="true"]') await expect(toggle).toBeVisible() await expect(toggle).toHaveCSS('cursor', 'not-allowed')
// Intentar click no cambia el estado await toggle.click({ force: true }) await expect(toggle).toHaveAttribute('aria-pressed', 'true')})TC-NOTIF-014 — Paginación en /notifications
Section titled “TC-NOTIF-014 — Paginación en /notifications”Cubre: AC-NOTIF-006.2 Tipo: E2E
test('TC-NOTIF-014: paginación muestra 15 ítems por página y navega correctamente', async ({ page, request }) => { const loginRes = await request.post('/api/auth/login', { data: { email: 'student_heavy@test.com', password: 'Test1234!' }, }) const { access_token } = await loginRes.json()
// Obtener total de notificaciones const countRes = await request.get('/api/notifications?limit=1&offset=0', { headers: { Authorization: `Bearer ${access_token}` }, }) const { total } = await countRes.json()
if (total > 15) { await page.goto('/notifications')
// Verificar que hay paginación const paginationButtons = page.locator('button').filter({ hasText: /^[0-9]+$/ }) await expect(paginationButtons).not.toHaveCount(0)
// Contar ítems en página 1 const rows = page.locator('.np-row') await expect(rows).toHaveCount(Math.min(15, total))
// Navegar a página 2 await page.getByRole('button', { name: '2' }).click()
// Verificar scroll y nuevos ítems await page.waitForResponse(res => res.url().includes('offset=15')) await expect(rows).toHaveCount(Math.min(15, total - 15)) }})