Skip to content

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

EndpointMétodoDescripciónEstado
/notificationsGETListado paginado (limit/offset/category/isRead)✅ Implementado
/notifications/unread-countGETContador de no leídas para el badge del header✅ Implementado
/notifications/:id/readPATCHMarcar una notificación como leída✅ Implementado
/notifications/mark-all-readPOSTMarcar todas como leídas✅ Implementado
/notifications/mark-category-readPOSTMarcar todas las de una categoría como leídas✅ Implementado
/notifications/:idDELETEEliminar (solo severity info/success)✅ Implementado
/notifications/preferencesGETLeer preferencias de notificación del usuario✅ Implementado
/notifications/preferencesPATCHActualizar preferencias (merge parcial)✅ Implementado
/notifications/statsGETEstadísticas diarias (solo SUPER_ADMIN)✅ Implementado

Reglas de negocio clave:

  • Deduplicación: si ya existe una notificación con el mismo userId + type + metadata creada en las últimas 24 horas, send() retorna la existente sin crear duplicado.
  • Severidad critical y warning: 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 critical bypasan 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/warning de categoría SYSTEM).
  • 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 a 99+ en el dropdown.
  • Tabs del dropdown por rol: student ve Todas / Aprendizaje / Logros; creator ve Todas / Contenido / Métricas; corp_admin ve Todas / Equipo / Sistema; super_admin ve Todas / Sistema / Contenido.
  • Ancho del dropdown por rol: student y corp_admin 380 px; creator y super_admin 420 px.

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ída
When renderiza cualquier página de la aplicación
Then el botón de la campana muestra un badge rojo con el número de no leídas
And el badge usa el formato "9+" cuando el conteo supera 9
And el badge usa el formato "99+" en el dropdown cuando supera 99

AC-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 leer
When renderiza cualquier página de la aplicación
Then el botón de la campana no muestra ningún badge

AC-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 conectado
When el backend crea una nueva notificación para ese usuario
Then el frontend recibe el frame "notification:new" por WebSocket
And el badge se actualiza sin necesidad de recargar la página

US-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 autenticada
When hace clic en el botón de la campana
Then se abre el dropdown de notificaciones
And muestra como máximo las 20 notificaciones más recientes del usuario
And el dropdown se cierra al hacer clic fuera de él

AC-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 notificaciones
Then ve las pestañas "Todas", "Aprendizaje" y "Logros"
Given el usuario tiene rol "CONTENT_ADMIN"
When abre el dropdown de notificaciones
Then ve las pestañas "Todas", "Contenido" y "Métricas"
Given el usuario no tiene notificaciones en la categoría seleccionada
When abre el dropdown de notificaciones
Then se muestra el mensaje "No hay notificaciones"
And no se muestra ningún elemento de lista

US-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 /notifications
Then 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 1

AC-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=true
When hace clic sobre ella
Then el cliente navega al actionUrl de la notificación
And NO se realiza ninguna llamada a PATCH /notifications/:id/read

AC-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 separado
When el usuario B intenta hacer PATCH /notifications/<id_notif_de_A>/read
Then la API responde con HTTP 403 Forbidden

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ídas
And el dropdown está abierto con la pestaña "Todas" activa
When hace clic en "Marcar leídas"
Then se llama a POST /notifications/mark-all-read
And todas las notificaciones del usuario quedan con isRead=true
And el badge del header pasa a 0

AC-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" activa
When 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ídas
And las notificaciones de otras categorías mantienen su estado isRead anterior

US-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 ella
Then 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_INVITED
And el actionUrl contiene "/path-instructors/invitations/<token>"
When el usuario hace clic sobre esa notificación
Then 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 directamente

AC-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 /notifications
Then se muestra el texto del actionLabel seguido de "→" en color púrpura

US-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 /notifications
When selecciona la pill "Aprendizaje" y activa el filtro "Sin leer"
Then la API recibe GET /notifications?category=learning&isRead=false&limit=15&offset=0
And la lista muestra únicamente notificaciones de categoría LEARNING no leídas

AC-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 /notifications
Then se muestra la paginación con páginas 1, 2 y 3
And la primera página muestra 15 notificaciones ordenadas por fecha descendente
And al hacer clic en la página 2 la lista hace scroll suave hacia arriba
Given el usuario tiene notificaciones de hoy, ayer y hace 5 días
When visita /notifications
Then 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 grupo
Given el usuario tiene 12 notificaciones totales, 5 sin leer y 7 leídas
When visita /notifications
Then 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 > 0

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 eliminar
Then se llama a DELETE /notifications/:id
And la notificación desaparece del listado
And el total de notificaciones se reduce en 1

AC-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 /notifications
Then 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 directamente
Then la API responde con HTTP 403 Forbidden
And 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ón
Then ve secciones "Email" (recordatorios, logros, contenido, novedades) y "Push" (racha, tutor, quizzes)
And los toggles reflejan el estado guardado en BD

AC-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 guarda
Then la API recibe PATCH /notifications/preferences con el objeto actualizado
And 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 alwaysOn
When visualiza el panel de preferencias
Then el toggle de "Alertas de seguridad" aparece en estado encendido y deshabilitado
And no puede desactivarlo ni mediante la UI ni mediante la API

US-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 preferencias
And tiene configurado un email válido y emailUnsubscribed=false
When el sistema genera una notificación de tipo PLAN_EXPIRED (severity=critical)
Then se crea la notificación en BD
And se encola el email a través de BullMQ con prioridad CRITICAL
And el usuario recibe el email independientemente de la preferencia emailEnabled

AC-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=false
When el usuario intenta desactivar ese tipo de notificación
Then la UI no presenta ese tipo como configurable
And la notificación se crea siempre que ocurre el evento, sin comprobar preferencias de tipo

US-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 horas
When el sistema intenta crear otra notificación del mismo tipo y mismo metadata para ese usuario
Then NotificationService.send() retorna la notificación existente sin crear un duplicado
And la tabla Notification no tiene una nueva fila

AC-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 horas
When el sistema intenta crear una notificación BADGE_EARNED con metadata { badgeId: "xyz" }
Then se crea una nueva notificación en BD
And el usuario ve dos notificaciones de tipo BADGE_EARNED en su bandeja

AC-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 horas
When el sistema genera otra notificación STREAK_RISK con mismo metadata para ese usuario
Then se crea una nueva notificación (la anterior tiene más de 24h)
And el usuario ve las dos notificaciones en su bandeja

US-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ías
And tienen severity "info" o "success"
When se ejecuta el job "cleanup-old-notifications"
Then esas notificaciones se eliminan de la BD
And el job registra en JobExecution el número de ítems procesados

AC-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ías
When se ejecuta el job "cleanup-old-notifications"
Then esas notificaciones NO se eliminan (están en la cláusula NOT del deleteMany)

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