Skip to content

Perfil de Usuario

Módulos API: users · Páginas web: UserSettingsPage, OnboardingWizard Spec Playwright: apps/e2e/tests/auth/user-profile.spec.ts — 📋 pendiente

EndpointMétodoDescripciónEstado
/users/meGETPerfil completo del usuario autenticado✅ Implementado
/users/mePATCHActualizar identidad y perfil profesional✅ Implementado
/users/me/avatarPOSTSubir foto de perfil (S3, 5 MB, PNG/JPEG/WEBP/GIF)✅ Implementado
/users/me/planGETPlan activo, precio y límites de features✅ Implementado
/users/me/onboardingPOSTGuardar datos del wizard y marcar onboarding completado✅ Implementado
/users/me/streakGETRacha actual, mejor racha y días de la semana✅ Implementado
/users/me/continueGETÚltima lección en progreso para continuar✅ Implementado
/users/me/progressGETProgreso completo: rutas, heatmap, fluidity score✅ Implementado
/users/me/exportPOSTSolicitar exportación RGPD (async, 202)✅ Implementado
/users/meDELETEEliminar cuenta (hard delete, 204)✅ Implementado
/users/me/notificationsGETObtener preferencias de notificación✅ Implementado
/users/me/notificationsPATCHActualizar preferencias de notificación✅ Implementado
/users/me/reengagement-contextGETContexto de reenganche (streak risk, welcome back)✅ Implementado
/users/me/bookmarks/:idDELETEEliminar un marcador✅ Implementado

US-UPROF-001: Actualizar datos de identidad

Section titled “US-UPROF-001: Actualizar datos de identidad”

Como usuario autenticado quiero editar mi nombre completo, cargo, empresa y URL de LinkedIn para que mis certificados y valoraciones reflejen información actualizada.

Módulo: users · Endpoint: PATCH /users/me Página: UserSettingsPage > ProfileSection · Schema Zod: updateProfileSchema Estado: ✅ Implementado Prioridad: Alta

AC-UPROF-001.1 — Nombre válido actualiza el perfil

Section titled “AC-UPROF-001.1 — Nombre válido actualiza el perfil”
Given el usuario está autenticado en /settings#profile
When escribe un nombre de 2 a 100 caracteres y pulsa "Guardar"
Then la API responde 200 con el campo fullName actualizado
And el nuevo nombre aparece en la cabecera de la página

AC-UPROF-001.2 — Nombre demasiado largo es rechazado

Section titled “AC-UPROF-001.2 — Nombre demasiado largo es rechazado”
Given el usuario introduce un nombre de 101 caracteres en el campo "Nombre completo"
When pulsa "Guardar"
Then la API responde 400
And no se actualiza el perfil

AC-UPROF-001.3 — LinkedIn sin formato válido muestra aviso en cliente

Section titled “AC-UPROF-001.3 — LinkedIn sin formato válido muestra aviso en cliente”
Given el usuario escribe una URL que no contiene "linkedin.com/in/"
Then el componente muestra el aviso "Usa el formato linkedin.com/in/tu-usuario"
And el campo queda marcado en color ámbar

AC-UPROF-001.4 — Campo vacío envía null (limpia el valor)

Section titled “AC-UPROF-001.4 — Campo vacío envía null (limpia el valor)”
Given el usuario tiene jobTitle guardado
When envía jobTitle como cadena vacía con PATCH /users/me
Then la API convierte el valor a null y lo persiste
And el campo aparece vacío al recargar la página

Como usuario autenticado quiero subir una foto de perfil para personalizar mi identidad en la plataforma y en los certificados.

Módulo: users · Endpoint: POST /users/me/avatar Página: UserSettingsPage > ProfileSection Estado: ✅ Implementado Prioridad: Alta

AC-UPROF-002.1 — Avatar válido se guarda en S3 y la URL se actualiza

Section titled “AC-UPROF-002.1 — Avatar válido se guarda en S3 y la URL se actualiza”
Given el usuario está en /settings#profile
When selecciona un archivo PNG/JPEG/WEBP de menos de 5 MB
Then el cliente redimensiona la imagen a 200x200 px (recorte cuadrado centrado)
And la API responde 200 con { ok: true, avatarUrl: "<s3-url>" }
And el avatar del perfil muestra la nueva foto sin recargar la página

AC-UPROF-002.2 — Archivo demasiado grande es rechazado por el servidor

Section titled “AC-UPROF-002.2 — Archivo demasiado grande es rechazado por el servidor”
Given el usuario intenta subir un archivo de más de 5 MB
When el servidor recibe la petición
Then la API responde 400
And el avatar anterior no se modifica

AC-UPROF-002.3 — Formato no permitido es ignorado por el servidor

Section titled “AC-UPROF-002.3 — Formato no permitido es ignorado por el servidor”
Given el usuario intenta subir un archivo .PDF o .SVG
When el servidor evalúa el mimetype
Then el FileInterceptor rechaza el archivo (cb(null, false))
And la API responde 400 con "No file provided"

AC-UPROF-002.4 — Vista previa optimista antes de confirmar el servidor

Section titled “AC-UPROF-002.4 — Vista previa optimista antes de confirmar el servidor”
Given el usuario selecciona un archivo de imagen válido
Then el componente muestra la vista previa inmediatamente via FileReader
And el spinner "Subiendo..." aparece sobre el botón hasta recibir la respuesta

US-UPROF-003: Completar wizard de onboarding al registrarse

Section titled “US-UPROF-003: Completar wizard de onboarding al registrarse”

Como usuario recién registrado quiero completar el wizard de onboarding para que la plataforma personalice el contenido, el tutor IA y las recomendaciones según mi perfil.

Módulo: users · Endpoint: POST /users/me/onboarding Página: OnboardingWizard (5 pasos) Estado: ✅ Implementado Prioridad: Alta

AC-UPROF-003.1 — Flujo completo de 5 pasos

Section titled “AC-UPROF-003.1 — Flujo completo de 5 pasos”
Given el usuario tiene onboardingCompleted = false y navega a /onboarding
When completa los 5 pasos: Rol → Sector → Nivel IA → Objetivos → Resultado
Then la API recibe POST /users/me/onboarding con { role, industry, aiLevel, goals }
And el campo onboardingCompleted queda a true en la BD
And el usuario es redirigido a /dashboard (o a /pricing?plan=X si viene de un plan)

AC-UPROF-003.2 — Skip onboarding guarda valores nulos

Section titled “AC-UPROF-003.2 — Skip onboarding guarda valores nulos”
Given el usuario pulsa "Saltar" desde cualquier paso del wizard
Then la API recibe { role: null, industry: null, aiLevel: null, goals: [] }
And onboardingCompleted queda a true en la BD
And el usuario es redirigido a /dashboard

AC-UPROF-003.3 — Guard evita revisitar el onboarding si ya está completado

Section titled “AC-UPROF-003.3 — Guard evita revisitar el onboarding si ya está completado”
Given el usuario tiene onboardingCompleted = true
When navega directamente a /onboarding
Then es redirigido automáticamente a /dashboard

AC-UPROF-003.4 — aiLevel acepta solo valores del enum

Section titled “AC-UPROF-003.4 — aiLevel acepta solo valores del enum”
Given se envía POST /users/me/onboarding con aiLevel = "beginner" (no en el enum)
Then la API responde 400
And el campo aiLevel no se modifica

US-UPROF-004: Gestionar preferencias de notificaciones

Section titled “US-UPROF-004: Gestionar preferencias de notificaciones”

Como usuario autenticado quiero controlar qué emails y alertas push recibo para no recibir comunicaciones que no me interesan.

Módulo: users · Endpoints: GET /users/me/notifications, PATCH /users/me/notifications Página: UserSettingsPage > NotificationsSection Estado: ✅ Implementado Prioridad: Media

AC-UPROF-004.1 — Valores por defecto al primer acceso

Section titled “AC-UPROF-004.1 — Valores por defecto al primer acceso”
Given el usuario nunca ha guardado preferencias de notificación
When llama a GET /users/me/notifications
Then la API devuelve los valores por defecto:
emailLearningReminders: true
emailNewPaths: true
emailContentUpdates: true
emailBadgesAchievements: true
emailNewsletter: false
emailPromotions: false
pushStreakRisk: true
pushTutorResponses: true
pushTeamActivity: false

AC-UPROF-004.2 — Toggle aplica autosave con debounce de 2 s

Section titled “AC-UPROF-004.2 — Toggle aplica autosave con debounce de 2 s”
Given el usuario está en /settings#notifications
When activa o desactiva un switch de notificación
Then la UI actualiza el estado del toggle inmediatamente
And tras 2 segundos sin nuevos cambios se llama PATCH /users/me/notifications
And la API responde 200 con las preferencias actualizadas

AC-UPROF-004.3 — pushTeamActivity deshabilitado para usuarios no corporativos

Section titled “AC-UPROF-004.3 — pushTeamActivity deshabilitado para usuarios no corporativos”
Given el usuario tiene role FREE, STARTER, PRO o ELITE
When abre la sección de notificaciones
Then el toggle "Actividad del equipo" está deshabilitado (disabled=true)
And no puede interactuar con él

AC-UPROF-004.4 — PATCH rechaza payload incompleto

Section titled “AC-UPROF-004.4 — PATCH rechaza payload incompleto”
Given se envía PATCH /users/me/notifications con solo 7 de los 9 campos booleanos
Then la API responde 400 (class-validator require IsBoolean en los 9 campos)

US-UPROF-005: Ver racha de días de estudio

Section titled “US-UPROF-005: Ver racha de días de estudio”

Como usuario autenticado quiero ver cuántos días consecutivos llevo estudiando para mantener el hábito y conocer mi mejor racha histórica.

Módulo: users · Endpoint: GET /users/me/streak Página: DashboardPage (widget de racha) Estado: ✅ Implementado Prioridad: Media

AC-UPROF-005.1 — Racha activa si hay actividad hoy o ayer

Section titled “AC-UPROF-005.1 — Racha activa si hay actividad hoy o ayer”
Given el usuario completó al menos una lección hoy o ayer
When llama a GET /users/me/streak
Then current > 0
And hasActivityToday refleja correctamente si completó algo hoy

AC-UPROF-005.2 — Racha se rompe si no hay actividad en 2 días

Section titled “AC-UPROF-005.2 — Racha se rompe si no hay actividad en 2 días”
Given el último día con lección completada fue hace 2 días o más
When llama a GET /users/me/streak
Then current = 0
And si la racha previa fue > 0, prevStreak devuelve ese valor (hasta 7 días atrás)

AC-UPROF-005.3 — Días de la semana con estados correctos

Section titled “AC-UPROF-005.3 — Días de la semana con estados correctos”
Given el usuario tiene actividad en algunos días de la semana actual
When llama a GET /users/me/streak
Then weekDays es un array de 7 elementos con valores de: "done" | "today" | "risk" | "missed" | "future"
And los días futuros a hoy siempre tienen estado "future"
And el día de hoy tiene "risk" solo si currentStreak >= 3 y no hay actividad hoy

AC-UPROF-005.4 — Hito de racha devuelto en milestoneReached

Section titled “AC-UPROF-005.4 — Hito de racha devuelto en milestoneReached”
Given el currentStreak del usuario alcanza exactamente 7, 14, 30, 60 o 100
When llama a GET /users/me/streak
Then la respuesta incluye milestoneReached con ese valor
And en cualquier otro caso milestoneReached no está presente en la respuesta

US-UPROF-006: Exportar mis datos personales (RGPD)

Section titled “US-UPROF-006: Exportar mis datos personales (RGPD)”

Como usuario autenticado quiero solicitar la exportación de todos mis datos para ejercer mi derecho de portabilidad según el RGPD.

Módulo: users · Endpoint: POST /users/me/export Página: UserSettingsPage > PrivacySection Estado: ✅ Implementado (encolado en BullMQ — entrega por email en < 24 h) Prioridad: Media

AC-UPROF-006.1 — Solicitud aceptada con HTTP 202

Section titled “AC-UPROF-006.1 — Solicitud aceptada con HTTP 202”
Given el usuario está autenticado
When llama a POST /users/me/export
Then la API responde 202 con { queued: true }
And en la UI aparece "Solicitud enviada · Recibirás un email en < 24 horas"

AC-UPROF-006.2 — Botón se deshabilita tras solicitar

Section titled “AC-UPROF-006.2 — Botón se deshabilita tras solicitar”
Given el usuario pulsa "Solicitar exportación"
Then el botón queda deshabilitado (disabled=true) para evitar doble envío
And el mensaje de confirmación permanece visible mientras dure la sesión

AC-UPROF-006.3 — Endpoint requiere autenticación

Section titled “AC-UPROF-006.3 — Endpoint requiere autenticación”
Given se llama a POST /users/me/export sin token de autorización
Then la API responde 401

Como usuario autenticado quiero poder eliminar mi cuenta de forma permanente para ejercer mi derecho al olvido según el RGPD.

Módulo: users · Endpoint: DELETE /users/me Página: UserSettingsPage > PrivacySection > DeleteAccountModal Estado: ✅ Implementado (hard delete, HTTP 204) Prioridad: Alta

AC-UPROF-007.1 — Modal de confirmación con retraso de seguridad

Section titled “AC-UPROF-007.1 — Modal de confirmación con retraso de seguridad”
Given el usuario pulsa "Eliminar cuenta" en la zona de peligro
Then se abre un modal de confirmación
And el botón "Sí, eliminar mi cuenta" está deshabilitado durante 1,5 segundos
And solo se activa transcurrido ese tiempo para evitar clics accidentales

AC-UPROF-007.2 — Aviso de cancelación de suscripción para usuarios de pago

Section titled “AC-UPROF-007.2 — Aviso de cancelación de suscripción para usuarios de pago”
Given el usuario tiene role distinto de FREE (STARTER, PRO, ELITE, etc.)
When abre el modal de confirmación de eliminación
Then aparece el aviso "Tu suscripción [plan] activa se cancelará inmediatamente sin reembolso"
And el aviso aparece destacado en rojo

AC-UPROF-007.3 — Cuenta eliminada y sesión cerrada

Section titled “AC-UPROF-007.3 — Cuenta eliminada y sesión cerrada”
Given el usuario confirma la eliminación en el modal
When la API devuelve 204
Then se despacha logout() en Redux
And el usuario es redirigido a "/"
And no puede volver a autenticarse con las mismas credenciales

AC-UPROF-007.4 — Endpoint requiere autenticación y es hard delete

Section titled “AC-UPROF-007.4 — Endpoint requiere autenticación y es hard delete”
Given se llama a DELETE /users/me sin token de autorización
Then la API responde 401
And dado un usuario autenticado que confirma la eliminación
Then el registro User es eliminado permanentemente de la BD (no soft delete)

US-UPROF-008: Ver y actualizar perfil profesional y nivel IA

Section titled “US-UPROF-008: Ver y actualizar perfil profesional y nivel IA”

Como usuario autenticado quiero indicar mi rol profesional, sector, nivel con la IA y objetivos de aprendizaje para que el tutor IA y las recomendaciones se ajusten a mi contexto real.

Módulo: users · Endpoint: PATCH /users/me Página: UserSettingsPage > ProfileSection (tarjetas “Perfil profesional”, “Mi nivel con la IA”, “Objetivos”) Estado: ✅ Implementado Prioridad: Media

AC-UPROF-008.1 — Nivel IA se guarda automáticamente al seleccionar

Section titled “AC-UPROF-008.1 — Nivel IA se guarda automáticamente al seleccionar”
Given el usuario está en la tarjeta "Mi nivel con la IA"
When hace clic en una de las 4 opciones (explorer, curious, practitioner, advanced)
Then se llama inmediatamente PATCH /users/me con { aiLevel: "<opción>" }
And aparece brevemente el chip "Guardado automáticamente"
And no es necesario pulsar ningún botón adicional

AC-UPROF-008.2 — Objetivos múltiples persisten correctamente

Section titled “AC-UPROF-008.2 — Objetivos múltiples persisten correctamente”
Given el usuario selecciona 3 objetivos del total de 8 disponibles
When pulsa "Guardar objetivos"
Then se llama PATCH /users/me con goals: ["clave1", "clave2", "clave3"]
And al recargar la página los 3 objetivos aparecen marcados

AC-UPROF-008.3 — Preferencias de idioma se mapean a locale en BD

Section titled “AC-UPROF-008.3 — Preferencias de idioma se mapean a locale en BD”
Given el usuario selecciona "Español (México)" en el selector de idioma
When guarda las preferencias
Then la API recibe PATCH /users/me con { language: "es-MX" }
And el servicio guarda el valor en el campo locale de la BD

AC-UPROF-008.4 — aiLevel acepta solo valores del enum

Section titled “AC-UPROF-008.4 — aiLevel acepta solo valores del enum”
Given se envía PATCH /users/me con aiLevel = "intermediate" (fuera del enum)
Then la API responde 400
And el campo aiLevel no se modifica

US-UPROF-009: Ver contexto de reenganche tras inactividad

Section titled “US-UPROF-009: Ver contexto de reenganche tras inactividad”

Como usuario que regresa tras varios días sin actividad quiero ver un mensaje contextual de bienvenida o alerta de racha para retomar el aprendizaje desde donde lo dejé.

Módulo: users · Endpoint: GET /users/me/reengagement-context Estado: ✅ Implementado Prioridad: Baja

AC-UPROF-009.1 — Variante streak_lost cuando la racha se perdió recientemente

Section titled “AC-UPROF-009.1 — Variante streak_lost cuando la racha se perdió recientemente”
Given el usuario tenía una racha activa y no estudió ayer ni hoy
And han pasado 3 días o menos desde la pérdida
When llama a GET /users/me/reengagement-context
Then variant = "streak_lost"
And prevStreak > 0
And nextLesson contiene la siguiente lección sin completar

AC-UPROF-009.2 — Variante streak_risk cuando hay racha activa pero sin actividad hoy

Section titled “AC-UPROF-009.2 — Variante streak_risk cuando hay racha activa pero sin actividad hoy”
Given currentStreak > 0 y el usuario no ha completado lecciones hoy
When llama a GET /users/me/reengagement-context
Then variant = "streak_risk"

AC-UPROF-009.3 — Variante welcome_back tras 3 o más días de inactividad sin racha

Section titled “AC-UPROF-009.3 — Variante welcome_back tras 3 o más días de inactividad sin racha”
Given el usuario lleva 3 o más días sin actividad y currentStreak = 0
When llama a GET /users/me/reengagement-context
Then variant = "welcome_back"
And daysSinceActivity >= 3

US-UPROF-010: Eliminar marcadores de lecciones

Section titled “US-UPROF-010: Eliminar marcadores de lecciones”

Como usuario autenticado quiero eliminar marcadores que ya no me son útiles para mantener mi lista de guardados organizada.

Módulo: users · Endpoint: DELETE /users/me/bookmarks/:id Estado: ✅ Implementado Prioridad: Baja

AC-UPROF-010.1 — Marcador propio eliminado con éxito

Section titled “AC-UPROF-010.1 — Marcador propio eliminado con éxito”
Given el usuario tiene un marcador con id "bm_123"
When llama a DELETE /users/me/bookmarks/bm_123
Then la API responde 204
And el marcador desaparece de GET /users/me/progress > bookmarks

AC-UPROF-010.2 — No puede eliminar marcadores de otro usuario

Section titled “AC-UPROF-010.2 — No puede eliminar marcadores de otro usuario”
Given el usuario A tiene el marcador "bm_456" y el usuario B está autenticado
When el usuario B llama a DELETE /users/me/bookmarks/bm_456
Then la API usa deleteMany con filtro userId, por lo que no elimina nada
And la API responde 204 (idempotente) pero el marcador de A no se modifica

TC-UPROF-001 — Acceso no autenticado rechazado en todos los endpoints

Section titled “TC-UPROF-001 — Acceso no autenticado rechazado en todos los endpoints”

Cubre: Todos los endpoints de /users/me/* Tipo: Integration (API)

import { test, expect } from '@playwright/test'
import { apiClient } from '../../utils/api-client'
test.describe('TC-UPROF-001: /users/me endpoints require authentication', () => {
const endpoints: Array<{ method: 'get' | 'patch' | 'post' | 'delete'; path: string; body?: object }> = [
{ method: 'get', path: '/users/me' },
{ method: 'patch', path: '/users/me', body: { fullName: 'Test' } },
{ method: 'get', path: '/users/me/notifications' },
{ method: 'patch', path: '/users/me/notifications', body: {} },
{ method: 'get', path: '/users/me/plan' },
{ method: 'post', path: '/users/me/avatar' },
{ method: 'get', path: '/users/me/streak' },
{ method: 'get', path: '/users/me/continue' },
{ method: 'get', path: '/users/me/progress' },
{ method: 'post', path: '/users/me/export' },
{ method: 'delete', path: '/users/me' },
{ method: 'post', path: '/users/me/onboarding', body: {} },
]
for (const ep of endpoints) {
test(`${ep.method.toUpperCase()} ${ep.path} returns 401 without token`, async () => {
// Arrange: no token passed
// Act
const res = ep.method === 'get'
? await apiClient.get(ep.path)
: await apiClient[ep.method](ep.path, ep.body ?? {})
// Assert
expect(res.status).toBe(401)
})
}
})

TC-UPROF-002 — Actualizar nombre completo con validación de longitud

Section titled “TC-UPROF-002 — Actualizar nombre completo con validación de longitud”

Cubre: AC-UPROF-001.1, AC-UPROF-001.2 Tipo: Integration (API)

import { test, expect } from '@playwright/test'
import { apiClient } from '../../utils/api-client'
import { fixtures } from '../../fixtures/users.fixture'
test.describe('TC-UPROF-002: PATCH /users/me fullName validation', () => {
test('valid name (2-100 chars) updates profile successfully', async () => {
// Arrange
const token = fixtures.free.token
const newName = 'María García López'
// Act
const res = await apiClient.patch('/users/me', { fullName: newName }, token)
// Assert
expect(res.status).toBe(200)
expect(res.data.fullName).toBe(newName)
})
test('name with exactly 1 character is rejected (min 2)', async () => {
// Arrange
const token = fixtures.free.token
// Act
const res = await apiClient.patch('/users/me', { fullName: 'A' }, token)
// Assert
expect(res.status).toBe(400)
})
test('name with 101 characters is rejected (max 100)', async () => {
// Arrange
const token = fixtures.free.token
const longName = 'A'.repeat(101)
// Act
const res = await apiClient.patch('/users/me', { fullName: longName }, token)
// Assert
expect(res.status).toBe(400)
})
test('empty jobTitle is transformed to null and persisted', async () => {
// Arrange
const token = fixtures.free.token
// Act
const res = await apiClient.patch('/users/me', { jobTitle: '' }, token)
// Assert
expect(res.status).toBe(200)
expect(res.data.jobTitle).toBeNull()
})
})

TC-UPROF-003 — Subir avatar: límite de tamaño y formatos

Section titled “TC-UPROF-003 — Subir avatar: límite de tamaño y formatos”

Cubre: AC-UPROF-002.1, AC-UPROF-002.2, AC-UPROF-002.3 Tipo: Integration (API)

import { test, expect } from '@playwright/test'
import { apiClient } from '../../utils/api-client'
import { fixtures } from '../../fixtures/users.fixture'
import * as fs from 'fs'
import * as path from 'path'
test.describe('TC-UPROF-003: POST /users/me/avatar file validation', () => {
test('valid JPEG under 5 MB returns avatarUrl', async () => {
// Arrange
const token = fixtures.free.token
// 1x1 pixel JPEG in base64
const jpegBuffer = Buffer.from(
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABgUEA/8QAHhAAAQQCAwEAAAAAAAAAAAAAAQACAxEEBSExUf/EABUBAQEAAAAAAAAAAAAAAAAAAAAB/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8Aqk0bTdNt0z1HIqe2GKVo3OJPcqkiAiIgIiICIiAiIgIiICIiAiIgIiICIiB//9k=',
'base64',
)
const formData = new FormData()
formData.append('file', new Blob([jpegBuffer], { type: 'image/jpeg' }), 'avatar.jpg')
// Act
const res = await apiClient.postForm('/users/me/avatar', formData, token)
// Assert
expect(res.status).toBe(200)
expect(res.data.ok).toBe(true)
expect(res.data.avatarUrl).toMatch(/^https?:\/\//)
})
test('file larger than 5 MB is rejected by the server', async () => {
// Arrange
const token = fixtures.free.token
const oversizedBuffer = Buffer.alloc(6 * 1024 * 1024) // 6 MB
const formData = new FormData()
formData.append('file', new Blob([oversizedBuffer], { type: 'image/jpeg' }), 'big.jpg')
// Act
const res = await apiClient.postForm('/users/me/avatar', formData, token)
// Assert
expect(res.status).toBe(400)
})
test('unsupported format (PDF) returns 400 no file provided', async () => {
// Arrange
const token = fixtures.free.token
const pdfBuffer = Buffer.from('%PDF-1.4 test content')
const formData = new FormData()
formData.append('file', new Blob([pdfBuffer], { type: 'application/pdf' }), 'doc.pdf')
// Act
const res = await apiClient.postForm('/users/me/avatar', formData, token)
// Assert
expect(res.status).toBe(400)
expect(res.data.message).toContain('No file provided')
})
})

TC-UPROF-004 — Flujo completo del wizard de onboarding (E2E)

Section titled “TC-UPROF-004 — Flujo completo del wizard de onboarding (E2E)”

Cubre: AC-UPROF-003.1, AC-UPROF-003.3 Tipo: E2E (UI)

import { test, expect } from '@playwright/test'
import { fixtures } from '../../fixtures/users.fixture'
import { loginAs } from '../../utils/auth-helpers'
test.describe('TC-UPROF-004: Onboarding wizard full flow', () => {
test('user completes all 5 steps and is redirected to dashboard', async ({ page }) => {
// Arrange: fresh user without onboarding completed
await loginAs(page, fixtures.free)
await page.goto('/onboarding')
// Step 1: Role
await expect(page.getByTestId('onboarding-step-role')).toBeVisible()
await page.getByTestId('role-option-sales').click()
await page.getByTestId('onboarding-next').click()
// Step 2: Sector
await expect(page.getByTestId('onboarding-step-sector')).toBeVisible()
await page.getByTestId('industry-option-tech').click()
await page.getByTestId('onboarding-next').click()
// Step 3: AI Level
await expect(page.getByTestId('onboarding-step-level')).toBeVisible()
await page.getByTestId('level-option-curious').click()
await page.getByTestId('onboarding-next').click()
// Step 4: Goals
await expect(page.getByTestId('onboarding-step-goals')).toBeVisible()
await page.getByTestId('goal-save-time').click()
await page.getByTestId('goal-automate').click()
await page.getByTestId('onboarding-next').click()
// Step 5: Result
await expect(page.getByTestId('onboarding-step-result')).toBeVisible()
await page.getByTestId('onboarding-finish').click()
// Assert: redirected to dashboard
await expect(page).toHaveURL('/dashboard')
})
test('already-onboarded user visiting /onboarding is redirected to dashboard', async ({ page }) => {
// Arrange: user with onboardingCompleted = true
await loginAs(page, fixtures.starter)
await page.goto('/onboarding')
// Assert: immediate redirect
await expect(page).toHaveURL('/dashboard')
})
})

TC-UPROF-005 — Racha: cálculo de días consecutivos

Section titled “TC-UPROF-005 — Racha: cálculo de días consecutivos”

Cubre: AC-UPROF-005.1, AC-UPROF-005.2, AC-UPROF-005.3 Tipo: Integration (API)

import { test, expect } from '@playwright/test'
import { apiClient } from '../../utils/api-client'
import { fixtures } from '../../fixtures/users.fixture'
test.describe('TC-UPROF-005: GET /users/me/streak', () => {
test('returns valid streak structure for authenticated user', async () => {
// Arrange
const token = fixtures.free.token
// Act
const res = await apiClient.get('/users/me/streak', token)
// Assert
expect(res.status).toBe(200)
expect(typeof res.data.current).toBe('number')
expect(typeof res.data.best).toBe('number')
expect(typeof res.data.hasActivityToday).toBe('boolean')
expect(Array.isArray(res.data.weekDays)).toBe(true)
expect(res.data.weekDays).toHaveLength(7)
})
test('weekDays contains only valid status values', async () => {
// Arrange
const token = fixtures.free.token
const validStatuses = new Set(['done', 'today', 'risk', 'missed', 'future'])
// Act
const res = await apiClient.get('/users/me/streak', token)
// Assert
expect(res.status).toBe(200)
for (const day of res.data.weekDays as string[]) {
expect(validStatuses.has(day)).toBe(true)
}
})
test('future days always appear after today in weekDays', async () => {
// Arrange
const token = fixtures.free.token
// Act
const res = await apiClient.get('/users/me/streak', token)
const weekDays: string[] = res.data.weekDays
// Assert: once "future" starts, no non-future days follow
let futureSeen = false
for (const day of weekDays) {
if (day === 'future') futureSeen = true
if (futureSeen) expect(day).toBe('future')
}
})
test('best streak is >= current streak', async () => {
// Arrange
const token = fixtures.free.token
// Act
const res = await apiClient.get('/users/me/streak', token)
// Assert
expect(res.data.best).toBeGreaterThanOrEqual(res.data.current)
})
})

TC-UPROF-006 — Exportación RGPD: respuesta 202 y UI de confirmación

Section titled “TC-UPROF-006 — Exportación RGPD: respuesta 202 y UI de confirmación”

Cubre: AC-UPROF-006.1, AC-UPROF-006.2, AC-UPROF-006.3 Tipo: E2E (UI) + Integration (API)

import { test, expect } from '@playwright/test'
import { apiClient } from '../../utils/api-client'
import { fixtures } from '../../fixtures/users.fixture'
import { loginAs } from '../../utils/auth-helpers'
test.describe('TC-UPROF-006: Data export (GDPR)', () => {
test('POST /users/me/export returns 202 with queued:true', async () => {
// Arrange
const token = fixtures.free.token
// Act
const res = await apiClient.post('/users/me/export', {}, token)
// Assert
expect(res.status).toBe(202)
expect(res.data.queued).toBe(true)
})
test('export button disables after clicking and shows confirmation', async ({ page }) => {
// Arrange
await loginAs(page, fixtures.free)
await page.goto('/settings#privacy')
const exportBtn = page.getByRole('button', { name: /solicitar exportación/i })
await expect(exportBtn).toBeEnabled()
// Act
await exportBtn.click()
// Assert: button disabled
await expect(exportBtn).toBeDisabled()
// Assert: confirmation message visible
await expect(page.getByText(/recibirás un email en/i)).toBeVisible()
})
test('POST /users/me/export returns 401 without auth', async () => {
// Arrange: no token
// Act
const res = await apiClient.post('/users/me/export', {})
// Assert
expect(res.status).toBe(401)
})
})

TC-UPROF-007 — Eliminación de cuenta: modal, retraso y redirección

Section titled “TC-UPROF-007 — Eliminación de cuenta: modal, retraso y redirección”

Cubre: AC-UPROF-007.1, AC-UPROF-007.2, AC-UPROF-007.3, AC-UPROF-007.4 Tipo: E2E (UI)

import { test, expect } from '@playwright/test'
import { fixtures } from '../../fixtures/users.fixture'
import { loginAs } from '../../utils/auth-helpers'
import { apiClient } from '../../utils/api-client'
test.describe('TC-UPROF-007: Account deletion flow', () => {
test('confirm button is disabled for 1.5s after opening modal', async ({ page }) => {
// Arrange
await loginAs(page, fixtures.free)
await page.goto('/settings#privacy')
// Act: open modal
await page.getByRole('button', { name: /eliminar cuenta/i }).click()
// Assert: confirm button disabled immediately
const confirmBtn = page.getByRole('button', { name: /sí, eliminar/i })
await expect(confirmBtn).toBeDisabled()
// Assert: enabled after delay
await page.waitForTimeout(1600)
await expect(confirmBtn).toBeEnabled()
})
test('paid user sees subscription cancellation warning in modal', async ({ page }) => {
// Arrange
await loginAs(page, fixtures.pro)
await page.goto('/settings#privacy')
// Act: open modal
await page.getByRole('button', { name: /eliminar cuenta/i }).click()
// Assert: warning visible
await expect(page.getByText(/suscripción.*activa se cancelará/i)).toBeVisible()
})
test('free user does not see subscription warning', async ({ page }) => {
// Arrange
await loginAs(page, fixtures.free)
await page.goto('/settings#privacy')
// Act: open modal
await page.getByRole('button', { name: /eliminar cuenta/i }).click()
// Assert: no subscription warning
await expect(page.getByText(/suscripción.*activa/i)).not.toBeVisible()
})
test('DELETE /users/me returns 401 without auth', async () => {
// Act
const res = await apiClient.delete('/users/me')
// Assert
expect(res.status).toBe(401)
})
})

TC-UPROF-008 — Preferencias de notificaciones: toggle y autosave

Section titled “TC-UPROF-008 — Preferencias de notificaciones: toggle y autosave”

Cubre: AC-UPROF-004.1, AC-UPROF-004.2, AC-UPROF-004.3, AC-UPROF-004.4 Tipo: Integration (API) + E2E (UI)

import { test, expect } from '@playwright/test'
import { apiClient } from '../../utils/api-client'
import { fixtures } from '../../fixtures/users.fixture'
import { loginAs } from '../../utils/auth-helpers'
test.describe('TC-UPROF-008: Notification preferences', () => {
test('GET /users/me/notifications returns all 9 boolean fields', async () => {
// Arrange
const token = fixtures.free.token
const expectedFields = [
'emailLearningReminders', 'emailNewPaths', 'emailContentUpdates',
'emailBadgesAchievements', 'emailNewsletter', 'emailPromotions',
'pushStreakRisk', 'pushTutorResponses', 'pushTeamActivity',
]
// Act
const res = await apiClient.get('/users/me/notifications', token)
// Assert
expect(res.status).toBe(200)
for (const field of expectedFields) {
expect(typeof res.data[field]).toBe('boolean')
}
})
test('PATCH /users/me/notifications persists updated values', async () => {
// Arrange
const token = fixtures.free.token
const newPrefs = {
emailLearningReminders: false,
emailNewPaths: false,
emailContentUpdates: true,
emailBadgesAchievements: true,
emailNewsletter: true,
emailPromotions: false,
pushStreakRisk: false,
pushTutorResponses: true,
pushTeamActivity: false,
}
// Act
const patchRes = await apiClient.patch('/users/me/notifications', newPrefs, token)
const getRes = await apiClient.get('/users/me/notifications', token)
// Assert
expect(patchRes.status).toBe(200)
expect(getRes.data.emailLearningReminders).toBe(false)
expect(getRes.data.emailNewsletter).toBe(true)
})
test('PATCH /users/me/notifications with missing fields returns 400', async () => {
// Arrange
const token = fixtures.free.token
const incompletePrefs = { emailLearningReminders: true } // only 1 of 9
// Act
const res = await apiClient.patch('/users/me/notifications', incompletePrefs, token)
// Assert
expect(res.status).toBe(400)
})
test('pushTeamActivity toggle is disabled for non-corporate users', async ({ page }) => {
// Arrange
await loginAs(page, fixtures.free)
await page.goto('/settings#notifications')
// Assert: team activity switch is disabled
const teamSwitch = page.getByRole('checkbox', { name: /actividad del equipo/i })
await expect(teamSwitch).toBeDisabled()
})
})

TC-UPROF-009 — Perfil profesional: validaciones de onboarding via PATCH

Section titled “TC-UPROF-009 — Perfil profesional: validaciones de onboarding via PATCH”

Cubre: AC-UPROF-008.1, AC-UPROF-008.3, AC-UPROF-008.4 Tipo: Integration (API)

import { test, expect } from '@playwright/test'
import { apiClient } from '../../utils/api-client'
import { fixtures } from '../../fixtures/users.fixture'
test.describe('TC-UPROF-009: Professional profile and AI level updates', () => {
test('aiLevel "practitioner" is accepted and persisted', async () => {
// Arrange
const token = fixtures.free.token
// Act
const res = await apiClient.patch('/users/me', { aiLevel: 'practitioner' }, token)
// Assert
expect(res.status).toBe(200)
expect(res.data.aiLevel).toBe('practitioner')
})
test('aiLevel outside enum is rejected with 400', async () => {
// Arrange
const token = fixtures.free.token
// Act
const res = await apiClient.patch('/users/me', { aiLevel: 'intermediate' }, token)
// Assert
expect(res.status).toBe(400)
})
test('language field maps to locale in the database', async () => {
// Arrange
const token = fixtures.free.token
// Act
const patchRes = await apiClient.patch('/users/me', { language: 'es-MX' }, token)
const getRes = await apiClient.get('/users/me', token)
// Assert
expect(patchRes.status).toBe(200)
// locale is the stored field — the GET response reflects it
expect(getRes.data.locale).toBe('es-MX')
})
test('goals array with multiple keys is persisted correctly', async () => {
// Arrange
const token = fixtures.free.token
const goals = ['save-time', 'automate', 'analyze-data']
// Act
const res = await apiClient.patch('/users/me', { goals }, token)
// Assert
expect(res.status).toBe(200)
expect(res.data.goals).toEqual(expect.arrayContaining(goals))
})
})

TC-UPROF-010 — UI de perfil: carga de datos, LinkedIn y avatar (E2E)

Section titled “TC-UPROF-010 — UI de perfil: carga de datos, LinkedIn y avatar (E2E)”

Cubre: AC-UPROF-001.3, AC-UPROF-002.4, US-UPROF-001, US-UPROF-002 Tipo: E2E (UI)

import { test, expect } from '@playwright/test'
import { fixtures } from '../../fixtures/users.fixture'
import { loginAs } from '../../utils/auth-helpers'
test.describe('TC-UPROF-010: Profile UI — data load, LinkedIn warn, avatar preview', () => {
test('profile section loads user data from GET /users/me', async ({ page }) => {
// Arrange
await loginAs(page, fixtures.free)
await page.goto('/settings#profile')
// Assert: page shows user data
const nameInput = page.getByRole('textbox', { name: /nombre completo/i })
await expect(nameInput).not.toBeEmpty()
const emailInput = page.getByRole('textbox', { name: /email/i })
await expect(emailInput).not.toBeEmpty()
})
test('LinkedIn URL without "linkedin.com/in/" shows amber warning', async ({ page }) => {
// Arrange
await loginAs(page, fixtures.free)
await page.goto('/settings#profile')
// Act: type invalid LinkedIn URL
const linkedinInput = page.getByRole('textbox', { name: /linkedin/i })
await linkedinInput.fill('https://example.com/profile')
await linkedinInput.blur()
// Assert: warning visible
await expect(page.getByText(/usa el formato linkedin\.com\/in\//i)).toBeVisible()
})
test('avatar preview shows immediately after file selection', async ({ page }) => {
// Arrange
await loginAs(page, fixtures.free)
await page.goto('/settings#profile')
// Act: set a file on the hidden input
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.getByRole('button', { name: /cambiar foto/i }).click(),
])
await fileChooser.setFiles({
name: 'avatar.jpg',
mimeType: 'image/jpeg',
buffer: Buffer.from('fake-jpeg-data'),
})
// Assert: "Subiendo..." appears (optimistic state)
await expect(page.getByRole('button', { name: /subiendo/i })).toBeVisible()
})
test('email field is read-only and cannot be edited', async ({ page }) => {
// Arrange
await loginAs(page, fixtures.free)
await page.goto('/settings#profile')
// Assert: email input has readonly attribute
const emailInput = page.locator('input[type="email"]')
await expect(emailInput).toHaveAttribute('readonly')
})
})

TC-UPROF-011 — Plan info y sesiones activas

Section titled “TC-UPROF-011 — Plan info y sesiones activas”

Cubre: US-UPROF-002 (plan info) Tipo: Integration (API)

import { test, expect } from '@playwright/test'
import { apiClient } from '../../utils/api-client'
import { fixtures } from '../../fixtures/users.fixture'
test.describe('TC-UPROF-011: GET /users/me/plan', () => {
test('returns valid plan structure for free user', async () => {
// Arrange
const token = fixtures.free.token
// Act
const res = await apiClient.get('/users/me/plan', token)
// Assert
expect(res.status).toBe(200)
expect(res.data.plan).toBeDefined()
expect(res.data.planName).toBeDefined()
// nextRenewal is null for FREE users
expect(res.data.nextRenewal).toBeNull()
expect(Array.isArray(res.data.invoices)).toBe(true)
})
test('returns valid plan structure for paid user', async () => {
// Arrange
const token = fixtures.pro.token
// Act
const res = await apiClient.get('/users/me/plan', token)
// Assert
expect(res.status).toBe(200)
expect(res.data.plan).toBe('PRO')
expect(typeof res.data.price).toBe('number')
expect(res.data.features).not.toBeNull()
expect(typeof res.data.features.aiTutor).toBe('string')
})
})

TC-UPROF-012 — Onboarding POST con aiLevel inválido rechazado

Section titled “TC-UPROF-012 — Onboarding POST con aiLevel inválido rechazado”

Cubre: AC-UPROF-003.4 Tipo: Integration (API)

import { test, expect } from '@playwright/test'
import { apiClient } from '../../utils/api-client'
import { fixtures } from '../../fixtures/users.fixture'
test.describe('TC-UPROF-012: POST /users/me/onboarding validation', () => {
test('aiLevel outside enum is rejected with 400', async () => {
// Arrange
const token = fixtures.free.token
// Act
const res = await apiClient.post(
'/users/me/onboarding',
{ aiLevel: 'beginner', goals: [] },
token,
)
// Assert
expect(res.status).toBe(400)
})
test('all null values marks onboarding as completed (skip path)', async () => {
// Arrange
const token = fixtures.free.token
// Act
const res = await apiClient.post(
'/users/me/onboarding',
{ role: null, industry: null, aiLevel: null, goals: [] },
token,
)
// Assert
expect(res.status).toBe(200)
expect(res.data.onboardingCompleted).toBe(true)
})
})