Skip to content

Progreso y XP

Módulos API: progress, xp, student-badges, users, dashboard Páginas web: StudentProgressPage, StudentMyBadgesPage, DashboardPage Spec Playwright: apps/e2e/tests/student/progress-xp.spec.ts — 📋 pendiente


El motor de progreso y gamificación de NappAI Fluency cubre cinco subsistemas:

SubsistemaEndpoint principalDescripción
Progreso de lecciónPOST /progress/lesson/:id/completeMarca lección completada, dispara XP y comprueba módulo/ruta
XP y nivelescalculado en XpService.getStudentXPSummary()Total de XP = eventos + bonus de badges; nivel 1-10, 500 XP por nivel
RachaGET /users/me/streakDías consecutivos con al menos una lección completada
BadgesGET /users/me/badges · GET /users/me/badges/summary8 categorías automáticas + badges manuales; evaluador cron
DashboardGET /dashboardResumen unificado: stats, paths inscritos, nextLesson, actividad reciente, recomendaciones

Fórmula XP:

  • LESSON_COMPLETED → +10 XP
  • ROUTE_COMPLETED (path completo) → +100 XP
  • QUIZ_PASSED → XP variable
  • Juegos (ESCAPE_ROOM_COMPLETED, CROSSWORD_COMPLETED, TRIVIA_COMPLETED, etc.) → XP variable
  • Badge bronze=+25, silver=+50, gold=+100, platinum=+200, legend=+500
  • Niveles: Math.min(10, Math.floor(xpTotal / 500) + 1) — umbral inferior (nivel-1)*500, umbral superior nivel*500

Nombres de nivel:

  • 1-3 → Aprendiz IA · 4-5 → Practicante IA · 6-7 → Arquitecto IA · 8-9 → Experto IA · 10 → Maestro IA

Racha: Se considera activa si la última lección completada fue hoy o ayer (UTC). Se rompe si no hay actividad en 24 h y el último día activo no fue ayer.

Badges — categorías:

  • LEVEL (dominio IA), PATH (rutas completadas), FLUENCY (score de fluidez), STREAK (racha), QUIZ (quiz performance), SPECIALIZATION, SPEED, HONOR

US-XP-001: Ver mi nivel actual de XP y puntos acumulados

Section titled “US-XP-001: Ver mi nivel actual de XP y puntos acumulados”

Como estudiante autenticado Quiero ver mi nivel XP actual, los puntos acumulados y cuánto me falta para el siguiente nivel Para conocer mi progreso en la gamificación de la plataforma

Criterios: AC-XP-001 · Casos: TC-XP-001, TC-XP-002 · Prioridad: Alta · ✅ Implementado


US-XP-002: Recibir XP al completar una lección

Section titled “US-XP-002: Recibir XP al completar una lección”

Como estudiante autenticado Quiero recibir automáticamente 10 XP cada vez que completo una lección Para ver mi progreso reflejado en el nivel y en el historial

Criterios: AC-XP-002 · Casos: TC-XP-003, TC-XP-004 · Prioridad: Alta · ✅ Implementado


US-XP-003: Recibir XP extra al completar una ruta entera

Section titled “US-XP-003: Recibir XP extra al completar una ruta entera”

Como estudiante que termina todos los módulos de una ruta Quiero recibir 100 XP adicionales al completar la ruta Para ser recompensado por el esfuerzo de finalizar un path completo

Criterios: AC-XP-003 · Casos: TC-XP-005 · Prioridad: Alta · ✅ Implementado


Como estudiante autenticado Quiero ver todos mis badges ganados y los disponibles, filtrados por categoría Para conocer mis logros y entender qué necesito para desbloquear los siguientes

Criterios: AC-XP-004 · Casos: TC-XP-006, TC-XP-007 · Prioridad: Alta · ✅ Implementado


US-XP-005: Obtener un badge automático al cumplir un criterio

Section titled “US-XP-005: Obtener un badge automático al cumplir un criterio”

Como estudiante autenticado Quiero que el sistema me otorgue automáticamente un badge cuando cumplo su criterio Para recibir reconocimiento sin necesidad de solicitarlo manualmente

Criterios: AC-XP-005 · Casos: TC-XP-008 · Prioridad: Alta · ✅ Implementado


US-XP-006: Ver mi racha de días consecutivos

Section titled “US-XP-006: Ver mi racha de días consecutivos”

Como estudiante autenticado Quiero ver cuántos días consecutivos he estudiado y el estado de la semana actual Para mantener mi motivación y no romper la racha

Criterios: AC-XP-006 · Casos: TC-XP-009, TC-XP-010 · Prioridad: Alta · ✅ Implementado


US-XP-007: Racha se rompe sin actividad en 24 h

Section titled “US-XP-007: Racha se rompe sin actividad en 24 h”

Como estudiante autenticado Quiero que el sistema registre correctamente cuándo se rompe mi racha Para recibir un incentivo a volver y reconstruirla

Criterios: AC-XP-007 · Casos: TC-XP-011 · Prioridad: Alta · ✅ Implementado


US-XP-008: Dashboard personalizado con «Continuar donde lo dejé»

Section titled “US-XP-008: Dashboard personalizado con «Continuar donde lo dejé»”

Como estudiante autenticado Quiero que el dashboard me muestre la siguiente lección pendiente de mi ruta más reciente Para retomar el aprendizaje sin buscar dónde me quedé

Criterios: AC-XP-008 · Casos: TC-XP-012, TC-XP-013 · Prioridad: Alta · ✅ Implementado


US-XP-009: Ver progreso detallado por path y módulo

Section titled “US-XP-009: Ver progreso detallado por path y módulo”

Como estudiante autenticado Quiero ver el porcentaje completado de cada path inscrito, desglosado por módulo Para planificar el tiempo de estudio y saber exactamente dónde estoy

Criterios: AC-XP-009 · Casos: TC-XP-014, TC-XP-015 · Prioridad: Alta · ✅ Implementado


US: US-XP-001

AC-XP-001.1 — Resumen XP correcto en dashboard

Dado que soy un estudiante con 750 XP acumulados en eventos
Y no tengo badges otorgados
Cuando llamo a GET /dashboard
Entonces la respuesta contiene stats.xpTotal = 750
Y stats.currentLevel = 2
Y stats.levelName = "Aprendiz IA"
Y stats.xpCurrentLevel = 500
Y stats.xpNextLevel = 1000

AC-XP-001.2 — XP de badges suma al total

Dado que tengo 400 XP de eventos
Y tengo un badge de tier 3 (gold = 100 XP)
Cuando llamo a GET /dashboard
Entonces stats.xpTotal = 500
Y stats.currentLevel = 1

AC-XP-001.3 — Nivel máximo (Maestro IA)

Dado que tengo 5000 XP o más
Cuando llamo a GET /dashboard
Entonces stats.currentLevel = 10
Y stats.levelName = "Maestro IA"
Y stats.xpNextLevel = 5000

AC-XP-002: Recibir XP al completar una lección

Section titled “AC-XP-002: Recibir XP al completar una lección”

US: US-XP-002

AC-XP-002.1 — Evento XP creado con 10 puntos

Dado que soy un estudiante autenticado
Y la lección "lesson-abc" está publicada y no la he completado
Cuando llamo a POST /progress/lesson/lesson-abc/complete
Entonces la respuesta contiene { completed: true }
Y se crea un StudentXPEvent con eventType = "LESSON_COMPLETED" y xp = 10
Y mis stats.xpTotal aumentan en 10

AC-XP-002.2 — Segunda llamada es idempotente (upsert)

Dado que ya completé la lección "lesson-abc"
Cuando llamo de nuevo a POST /progress/lesson/lesson-abc/complete
Entonces la respuesta sigue siendo { completed: true }
Y NO se crea un segundo StudentXPEvent para esa lección

AC-XP-002.3 — Completar con timeSpentSec guarda el tiempo

Dado que completo la lección con body { timeSpentSec: 300 }
Cuando llamo a POST /progress/lesson/lesson-abc/complete
Entonces el registro Progress.timeSpentSec = 300

AC-XP-003: Bonus XP al completar ruta entera

Section titled “AC-XP-003: Bonus XP al completar ruta entera”

US: US-XP-003

AC-XP-003.1 — Evento ROUTE_COMPLETED con 100 XP

Dado que la ruta tiene 3 módulos con 1 lección publicada cada uno
Y ya he completado las 2 primeras lecciones
Cuando completo la tercera lección
Entonces la respuesta contiene { pathCompleted: true }
Y se crea un StudentXPEvent con eventType = "ROUTE_COMPLETED" y xp = 100

AC-XP-003.2 — Certificado emitido al completar si el plan lo permite

Dado que soy usuario STARTER (plan con certificates = true)
Y la ruta tiene una plantilla de certificado configurada
Cuando se completa la ruta
Entonces la respuesta contiene { certificateIssued: true }
Y se crea un registro Certificate con verificationHash SHA-256

AC-XP-003.3 — No se emite certificado si el plan no lo incluye

Dado que soy usuario FREE (plan con certificates = false)
Y la ruta tiene plantilla de certificado
Cuando se completa la ruta
Entonces { certificateIssued: false }
Y no se crea ningún registro Certificate

US: US-XP-004

AC-XP-004.1 — Lista completa de definiciones y grants

Dado que existen badges activos en el sistema
Y tengo 2 badges otorgados
Cuando llamo a GET /users/me/badges
Entonces la respuesta contiene definitions (todas las activas)
Y grants con los 2 badges que tengo
Y cada grant tiene { badgeId, grantedAt, isActive: true }

AC-XP-004.2 — Resumen ligero para cabecera

Dado que tengo 3 badges ganados
Y mi XP total es 350
Cuando llamo a GET /users/me/badges/summary
Entonces la respuesta contiene { earnedCount: 3, xp: 350 }
Y currentLevel es el badge LEVEL de mayor tier que tengo (o null si ninguno)

AC-XP-004.3 — Badge revocado no aparece en grants activos

Dado que tenía un badge de racha y luego la perdí (badge con autoRevoke = true)
Cuando llamo a GET /users/me/badges
Entonces ese grant NO aparece en la lista de grants (revokedAt != null → filtrado)

AC-XP-005: Obtener badge automático al cumplir criterio

Section titled “AC-XP-005: Obtener badge automático al cumplir criterio”

US: US-XP-005

AC-XP-005.1 — Badge de XP otorgado al superar umbral

Dado que existe un badge con criteria.type = "xp" y criteria.minXP = 500
Y mi XP actual es 490
Cuando el evaluador de badges corre (manualmente o vía cron)
Y mi XP llega a 500
Entonces se crea un StudentBadgeGrant para ese badge con grantedBy = "system"
Y el badge aparece en GET /users/me/badges en la sección grants

AC-XP-005.2 — Badge de racha otorgado con 7 días consecutivos

Dado que existe un badge con criteria.type = "streak" y criteria.minDays = 7
Y llevo 7 días con al menos una lección completada cada día
Cuando corre el evaluador
Entonces se otorga el badge de racha

AC-XP-005.3 — Badge de racha auto-revocado al perder la racha

Dado que tengo el badge de racha de 7 días (con autoRevoke = true en criteria)
Y mi racha actual cae a 0
Cuando corre el evaluador
Entonces el grant queda con revokedAt != null
Y el badge desaparece de GET /users/me/badges

AC-XP-005.4 — Badge manual no se otorga automáticamente

Dado que existe un badge con criteria.type = "manual"
Y el estudiante tiene XP suficiente para cualquier badge
Cuando corre el evaluador
Entonces ese badge NO se otorga automáticamente (solo vía SUPER_ADMIN)

AC-XP-006: Ver racha de días consecutivos

Section titled “AC-XP-006: Ver racha de días consecutivos”

US: US-XP-006

AC-XP-006.1 — Racha calculada correctamente (hoy activo)

Dado que completé lecciones el lunes, martes y miércoles (hoy es miércoles UTC)
Cuando llamo a GET /users/me/streak
Entonces current = 3
Y best >= 3
Y hasActivityToday = true

AC-XP-006.2 — Racha mantiene si ayer fue activo pero hoy no

Dado que la última lección completada fue ayer
Y hoy no he hecho nada
Cuando llamo a GET /users/me/streak
Entonces current >= 1
Y hasActivityToday = false

AC-XP-006.3 — weekDays refleja el estado de la semana actual

Dado que hoy es jueves (día 4 de la semana ISO)
Y tengo actividad lunes, martes y miércoles
Cuando llamo a GET /users/me/streak
Entonces weekDays tiene 7 elementos
Y los primeros 3 son "done"
Y el 4º es "today" o "risk" (según si hay actividad hoy)
Y los elementos 5, 6 y 7 son "future"

AC-XP-006.4 — Hito de racha devuelto en la respuesta

Dado que hoy alcanzo exactamente 7 días de racha consecutiva
Cuando llamo a GET /users/me/streak
Entonces la respuesta incluye milestoneReached = 7

AC-XP-007: Racha se rompe sin actividad en 24 h

Section titled “AC-XP-007: Racha se rompe sin actividad en 24 h”

US: US-XP-007

AC-XP-007.1 — Racha = 0 si el último día activo fue antes de ayer

Dado que la última lección completada fue hace 3 días
Y no hay actividad ayer ni hoy
Cuando llamo a GET /users/me/streak
Entonces current = 0
Y hasActivityToday = false

AC-XP-007.2 — prevStreak devuelto si la racha se rompió recientemente

Dado que tenía una racha de 5 días que se rompió hace 2 días
Y user.lastActiveAt fue hace 2 días
Cuando llamo a GET /users/me/streak
Entonces current = 0
Y la respuesta incluye prevStreak = 5

AC-XP-008: Dashboard personalizado — Continuar donde lo dejé

Section titled “AC-XP-008: Dashboard personalizado — Continuar donde lo dejé”

US: US-XP-008

AC-XP-008.1 — nextLesson apunta a la primera lección incompleta del path más reciente

Dado que estoy inscrito en la ruta "ia-en-banca" y la tengo al 40%
Y la siguiente lección sin completar es "Modelos de Lenguaje Avanzados"
Cuando llamo a GET /dashboard
Entonces la respuesta contiene nextLesson.id con el id de esa lección
Y nextLesson.pathSlug = "ia-en-banca"
Y nextLesson.moduleTitle es el módulo al que pertenece

AC-XP-008.2 — nextLesson es null si todas las rutas están completadas

Dado que todas mis rutas inscritas tienen pct = 100
Cuando llamo a GET /dashboard
Entonces nextLesson = null

AC-XP-008.3 — recentActivity incluye las últimas 8 lecciones completadas

Dado que he completado 10 lecciones en los últimos 7 días
Cuando llamo a GET /dashboard
Entonces recentActivity tiene 8 elementos como máximo
Y cada elemento tiene { type: "lesson_completed", description, pathTitle, timestamp }
Y están ordenados por timestamp descendente

AC-XP-008.4 — recommendedPaths excluye rutas ya inscritas

Dado que estoy inscrito en "ia-en-banca"
Y existen otras 5 rutas publicadas
Cuando llamo a GET /dashboard
Entonces recommendedPaths no contiene "ia-en-banca"
Y tiene como máximo 3 rutas

AC-XP-009: Progreso detallado por path y módulo

Section titled “AC-XP-009: Progreso detallado por path y módulo”

US: US-XP-009

AC-XP-009.1 — Porcentaje por path calculado correctamente

Dado que la ruta "ia-en-banca" tiene 10 lecciones publicadas
Y he completado 3
Cuando llamo a GET /users/me/progress
Entonces la ruta "ia-en-banca" en enrolledPaths tiene pct = 30
Y status = "in_progress"

AC-XP-009.2 — Módulo completo cuando todas sus lecciones están completadas

Dado que el módulo "Fundamentos" tiene 3 lecciones publicadas
Y he completado las 3
Cuando llamo a GET /users/me/progress
Entonces ese módulo tiene status = "completed"
Y completedLessons = 3
Y totalLessons = 3

AC-XP-009.3 — Historial XP en actividad reciente

Dado que he completado lecciones y superado quizzes en los últimos días
Cuando llamo a GET /users/me/progress
Entonces recentActivity contiene elementos con type en
["lesson_completed", "quiz_passed", "quiz_failed", "certificate_earned", "path_enrolled"]
Y cada item tiene { type, title, pathTitle, timestamp, meta }
Y están ordenados por timestamp descendente (máximo 30)

AC-XP-009.4 — Heatmap de actividad devuelve entradas por día

Dado que estudié 30 minutos el lunes y 60 el martes
Cuando llamo a GET /users/me/progress
Entonces activityHeatmap contiene entradas para esos días
Y cada entrada tiene { date: "YYYY-MM-DD", timeMinutes }

TC-XP-001: Dashboard devuelve resumen XP correcto

Section titled “TC-XP-001: Dashboard devuelve resumen XP correcto”

AC: AC-XP-001.1 · Prioridad: Alta · Tipo: API · Estado: 📋 Pendiente Spec: apps/e2e/tests/student/progress-xp.spec.ts

Precondiciones: fixtures.pro tiene historial de XP en BD con al menos 500 XP de eventos

const res = await apiClient.get('/dashboard', {
headers: { Authorization: `Bearer ${fixtures.pro.token}` },
})
expect(res.status).toBe(200)
expect(res.data.stats.xpTotal).toBeGreaterThanOrEqual(500)
expect(res.data.stats.currentLevel).toBeGreaterThanOrEqual(1)
expect(res.data.stats.currentLevel).toBeLessThanOrEqual(10)
expect(res.data.stats.levelName).toMatch(/Aprendiz|Practicante|Arquitecto|Experto|Maestro/)
expect(res.data.stats.xpCurrentLevel).toBe((res.data.stats.currentLevel - 1) * 500)
expect(res.data.stats.xpNextLevel).toBe(res.data.stats.currentLevel * 500)

TC-XP-002: Nivel 10 (Maestro IA) con XP >= 5000

Section titled “TC-XP-002: Nivel 10 (Maestro IA) con XP >= 5000”

AC: AC-XP-001.3 · Prioridad: Media · Tipo: API · Estado: 📋 Pendiente

// Requiere fixture con XP >= 5000 o inserción directa en BD de test
const res = await apiClient.get('/dashboard', {
headers: { Authorization: `Bearer ${fixtures.maestro.token}` },
})
expect(res.data.stats.currentLevel).toBe(10)
expect(res.data.stats.levelName).toBe('Maestro IA')
expect(res.data.stats.xpNextLevel).toBe(5000)

TC-XP-003: POST /progress/lesson/:id/complete crea evento XP de 10 puntos

Section titled “TC-XP-003: POST /progress/lesson/:id/complete crea evento XP de 10 puntos”

AC: AC-XP-002.1 · Prioridad: Alta · Tipo: API · Estado: 📋 Pendiente Spec: apps/e2e/tests/student/progress-xp.spec.ts

Precondiciones: La lección fixtures.lesson.id existe, está publicada y el usuario fixtures.free no la ha completado

const xpBefore = await getXpTotal(fixtures.free.userId)
const res = await apiClient.post(
`/progress/lesson/${fixtures.lesson.id}/complete`,
{ timeSpentSec: 180 },
{ headers: { Authorization: `Bearer ${fixtures.free.token}` } },
)
expect(res.status).toBe(201)
expect(res.data.completed).toBe(true)
expect(res.data.lessonId).toBe(fixtures.lesson.id)
const xpAfter = await getXpTotal(fixtures.free.userId)
expect(xpAfter - xpBefore).toBe(10)

TC-XP-004: Completar la misma lección dos veces es idempotente

Section titled “TC-XP-004: Completar la misma lección dos veces es idempotente”

AC: AC-XP-002.2 · Prioridad: Alta · Tipo: API · Estado: 📋 Pendiente

// Primera llamada
await apiClient.post(
`/progress/lesson/${fixtures.lesson.id}/complete`,
{},
{ headers: { Authorization: `Bearer ${fixtures.free.token}` } },
)
const xpMid = await getXpTotal(fixtures.free.userId)
// Segunda llamada — no debe añadir más XP
await apiClient.post(
`/progress/lesson/${fixtures.lesson.id}/complete`,
{},
{ headers: { Authorization: `Bearer ${fixtures.free.token}` } },
)
const xpAfter = await getXpTotal(fixtures.free.userId)
// El XP no debe haber aumentado de nuevo
// Nota: el servicio hace upsert del Progress pero emite XP fire-and-forget;
// verificar via BD que solo existe 1 evento LESSON_COMPLETED para ese userId+lessonId
expect(xpAfter).toBeLessThanOrEqual(xpMid + 10)

TC-XP-005: Completar última lección de ruta emite ROUTE_COMPLETED (+100 XP)

Section titled “TC-XP-005: Completar última lección de ruta emite ROUTE_COMPLETED (+100 XP)”

AC: AC-XP-003.1 · Prioridad: Alta · Tipo: API · Estado: 📋 Pendiente

Precondiciones: fixtures.pro tiene inscripción en una ruta con todas las lecciones completadas menos la última; esa última es fixtures.lastLesson.id

const xpBefore = await getXpTotal(fixtures.pro.userId)
const res = await apiClient.post(
`/progress/lesson/${fixtures.lastLesson.id}/complete`,
{},
{ headers: { Authorization: `Bearer ${fixtures.pro.token}` } },
)
expect(res.data.pathCompleted).toBe(true)
const xpAfter = await getXpTotal(fixtures.pro.userId)
// +10 por lección + 100 por ruta = 110
expect(xpAfter - xpBefore).toBe(110)

TC-XP-006: GET /users/me/badges devuelve definiciones y grants

Section titled “TC-XP-006: GET /users/me/badges devuelve definiciones y grants”

AC: AC-XP-004.1 · Prioridad: Alta · Tipo: API · Estado: 📋 Pendiente

const res = await apiClient.get('/users/me/badges', {
headers: { Authorization: `Bearer ${fixtures.pro.token}` },
})
expect(res.status).toBe(200)
expect(Array.isArray(res.data.definitions)).toBe(true)
expect(Array.isArray(res.data.grants)).toBe(true)
// Estructura de cada definición
const def = res.data.definitions[0]
expect(def).toMatchObject({
id: expect.any(String),
slug: expect.any(String),
name: expect.any(String),
category: expect.any(String),
tier: expect.any(Number),
isActive: true,
})
// Grants del pro deben tener al menos uno
expect(res.data.grants.length).toBeGreaterThan(0)
const grant = res.data.grants[0]
expect(grant).toMatchObject({
badgeId: expect.any(String),
grantedAt: expect.any(String),
isActive: true,
})

TC-XP-007: GET /users/me/badges/summary devuelve conteo y XP

Section titled “TC-XP-007: GET /users/me/badges/summary devuelve conteo y XP”

AC: AC-XP-004.2 · Prioridad: Media · Tipo: API · Estado: 📋 Pendiente

const res = await apiClient.get('/users/me/badges/summary', {
headers: { Authorization: `Bearer ${fixtures.pro.token}` },
})
expect(res.status).toBe(200)
expect(res.data).toMatchObject({
earnedCount: expect.any(Number),
totalCount: expect.any(Number),
xp: expect.any(Number),
})
expect(res.data.earnedCount).toBeLessThanOrEqual(res.data.totalCount)

TC-XP-008: El evaluador de badges otorga badge cuando se cumple criterio

Section titled “TC-XP-008: El evaluador de badges otorga badge cuando se cumple criterio”

AC: AC-XP-005.1 · Prioridad: Alta · Tipo: API · Estado: 📋 Pendiente Nota: Requiere acceso SUPER_ADMIN para disparar el evaluador manualmente vía POST /admin/badges/evaluate

// 1. Verificar que el usuario free no tiene el badge "aprendiz-bronce"
const before = await apiClient.get('/users/me/badges', {
headers: { Authorization: `Bearer ${fixtures.free.token}` },
})
const hadBadge = before.data.grants.some(
(g: any) => g.badgeId === fixtures.badge.aprendizBronceId,
)
// 2. Asegurar que el usuario tiene los XP necesarios para el badge (via BD de test)
// ...
// 3. Disparar el evaluador como SUPER_ADMIN
await apiClient.post(
'/admin/badges/evaluate',
{},
{ headers: { Authorization: `Bearer ${fixtures.superAdmin.token}` } },
)
// 4. Esperar procesamiento asíncrono (polling o sleep)
await new Promise((r) => setTimeout(r, 2000))
// 5. Verificar que el badge fue otorgado
const after = await apiClient.get('/users/me/badges', {
headers: { Authorization: `Bearer ${fixtures.free.token}` },
})
const hasBadgeNow = after.data.grants.some(
(g: any) => g.badgeId === fixtures.badge.aprendizBronceId,
)
if (!hadBadge) {
expect(hasBadgeNow).toBe(true)
}

TC-XP-009: GET /users/me/streak devuelve racha actual correcta

Section titled “TC-XP-009: GET /users/me/streak devuelve racha actual correcta”

AC: AC-XP-006.1 · Prioridad: Alta · Tipo: API · Estado: 📋 Pendiente

const res = await apiClient.get('/users/me/streak', {
headers: { Authorization: `Bearer ${fixtures.pro.token}` },
})
expect(res.status).toBe(200)
expect(res.data).toMatchObject({
current: expect.any(Number),
best: expect.any(Number),
hasActivityToday: expect.any(Boolean),
weekDays: expect.arrayContaining([expect.any(String)]),
})
expect(res.data.weekDays).toHaveLength(7)
// weekDays values must be valid
const validStates = ['done', 'today', 'risk', 'missed', 'future']
res.data.weekDays.forEach((d: string) => {
expect(validStates).toContain(d)
})
expect(res.data.best).toBeGreaterThanOrEqual(res.data.current)

TC-XP-010: Hito de racha devuelve milestoneReached al llegar a 7 días

Section titled “TC-XP-010: Hito de racha devuelve milestoneReached al llegar a 7 días”

AC: AC-XP-006.4 · Prioridad: Media · Tipo: API · Estado: 📋 Pendiente

Nota: Requiere insertar en BD de test registros de progreso con completedAt en los 7 últimos días exactos

// Insertar 7 lecciones completadas en días consecutivos via BD de test
// incluyendo hoy (día 7)
// ...
const res = await apiClient.get('/users/me/streak', {
headers: { Authorization: `Bearer ${fixtures.free.token}` },
})
expect(res.data.current).toBe(7)
expect(res.data.milestoneReached).toBe(7)

TC-XP-011: Racha = 0 cuando la última actividad fue hace más de 2 días

Section titled “TC-XP-011: Racha = 0 cuando la última actividad fue hace más de 2 días”

AC: AC-XP-007.1 · Prioridad: Alta · Tipo: API · Estado: 📋 Pendiente

Precondiciones: Usuario sin actividad en los últimos 3 días (puede usarse fixtures.free recién registrado o usuario con progreso antiguo)

const res = await apiClient.get('/users/me/streak', {
headers: { Authorization: `Bearer ${fixtures.inactive.token}` },
})
expect(res.data.current).toBe(0)
expect(res.data.hasActivityToday).toBe(false)
// weekDays de días pasados deben ser 'missed'
const pastDays = res.data.weekDays.filter((d: string) => d !== 'today' && d !== 'future')
pastDays.forEach((d: string) => {
expect(['missed', 'done']).toContain(d)
})

TC-XP-012: Dashboard muestra nextLesson de la ruta más reciente con progreso pendiente

Section titled “TC-XP-012: Dashboard muestra nextLesson de la ruta más reciente con progreso pendiente”

AC: AC-XP-008.1 · Prioridad: Alta · Tipo: API · Estado: 📋 Pendiente

const res = await apiClient.get('/dashboard', {
headers: { Authorization: `Bearer ${fixtures.pro.token}` },
})
expect(res.status).toBe(200)
if (res.data.nextLesson !== null) {
expect(res.data.nextLesson).toMatchObject({
id: expect.any(String),
title: expect.any(String),
pathSlug: expect.any(String),
pathTitle: expect.any(String),
moduleTitle: expect.any(String),
})
}

TC-XP-013: E2E — El banner “Continuar” dirige a la lección correcta

Section titled “TC-XP-013: E2E — El banner “Continuar” dirige a la lección correcta”

AC: AC-XP-008.1 · Prioridad: Alta · Tipo: E2E · Estado: 📋 Pendiente Spec: apps/e2e/tests/student/progress-xp.spec.ts

// data-testid a confirmar en DashboardPage: 'continue-lesson-btn'
await loginAs(page, fixtures.pro)
await page.goto('/dashboard')
const continueBtn = page.getByTestId('continue-lesson-btn')
if (await continueBtn.isVisible()) {
const href = await continueBtn.getAttribute('href')
expect(href).toMatch(/\/learn\//)
await continueBtn.click()
await expect(page).toHaveURL(/\/learn\//)
}

TC-XP-014: GET /users/me/progress devuelve porcentaje por path

Section titled “TC-XP-014: GET /users/me/progress devuelve porcentaje por path”

AC: AC-XP-009.1 · Prioridad: Alta · Tipo: API · Estado: 📋 Pendiente

const res = await apiClient.get('/users/me/progress', {
headers: { Authorization: `Bearer ${fixtures.pro.token}` },
})
expect(res.status).toBe(200)
expect(Array.isArray(res.data.enrolledPaths)).toBe(true)
for (const ep of res.data.enrolledPaths) {
expect(ep.pct).toBeGreaterThanOrEqual(0)
expect(ep.pct).toBeLessThanOrEqual(100)
expect(['not_started', 'in_progress', 'completed']).toContain(ep.status)
expect(ep.path).toMatchObject({
id: expect.any(String),
title: expect.any(String),
slug: expect.any(String),
})
expect(Array.isArray(ep.modules)).toBe(true)
}

TC-XP-015: Progreso por módulo — lecciones completadas y estado correcto

Section titled “TC-XP-015: Progreso por módulo — lecciones completadas y estado correcto”

AC: AC-XP-009.2 · Prioridad: Alta · Tipo: API · Estado: 📋 Pendiente

const res = await apiClient.get('/users/me/progress', {
headers: { Authorization: `Bearer ${fixtures.pro.token}` },
})
for (const ep of res.data.enrolledPaths) {
for (const mod of ep.modules) {
expect(mod).toMatchObject({
id: expect.any(String),
title: expect.any(String),
status: expect.stringMatching(/not_started|in_progress|completed/),
completedLessons: expect.any(Number),
totalLessons: expect.any(Number),
})
expect(mod.completedLessons).toBeLessThanOrEqual(mod.totalLessons)
if (mod.status === 'completed') {
expect(mod.completedLessons).toBe(mod.totalLessons)
}
}
}

User StoryACsTCsEndpoints cubiertos
US-XP-001AC-XP-001 (3 escenarios)TC-XP-001, TC-XP-002GET /dashboard
US-XP-002AC-XP-002 (3 escenarios)TC-XP-003, TC-XP-004POST /progress/lesson/:id/complete
US-XP-003AC-XP-003 (3 escenarios)TC-XP-005POST /progress/lesson/:id/complete
US-XP-004AC-XP-004 (3 escenarios)TC-XP-006, TC-XP-007GET /users/me/badges, GET /users/me/badges/summary
US-XP-005AC-XP-005 (4 escenarios)TC-XP-008POST /admin/badges/evaluate
US-XP-006AC-XP-006 (4 escenarios)TC-XP-009, TC-XP-010GET /users/me/streak
US-XP-007AC-XP-007 (2 escenarios)TC-XP-011GET /users/me/streak
US-XP-008AC-XP-008 (4 escenarios)TC-XP-012, TC-XP-013GET /dashboard
US-XP-009AC-XP-009 (4 escenarios)TC-XP-014, TC-XP-015GET /users/me/progress