Perfil de Usuario
Módulos API: users · Páginas web: UserSettingsPage, OnboardingWizard
Spec Playwright: apps/e2e/tests/auth/user-profile.spec.ts — 📋 pendiente
Módulos API cubiertos
Section titled “Módulos API cubiertos”| Endpoint | Método | Descripción | Estado |
|---|---|---|---|
/users/me | GET | Perfil completo del usuario autenticado | ✅ Implementado |
/users/me | PATCH | Actualizar identidad y perfil profesional | ✅ Implementado |
/users/me/avatar | POST | Subir foto de perfil (S3, 5 MB, PNG/JPEG/WEBP/GIF) | ✅ Implementado |
/users/me/plan | GET | Plan activo, precio y límites de features | ✅ Implementado |
/users/me/onboarding | POST | Guardar datos del wizard y marcar onboarding completado | ✅ Implementado |
/users/me/streak | GET | Racha actual, mejor racha y días de la semana | ✅ Implementado |
/users/me/continue | GET | Última lección en progreso para continuar | ✅ Implementado |
/users/me/progress | GET | Progreso completo: rutas, heatmap, fluidity score | ✅ Implementado |
/users/me/export | POST | Solicitar exportación RGPD (async, 202) | ✅ Implementado |
/users/me | DELETE | Eliminar cuenta (hard delete, 204) | ✅ Implementado |
/users/me/notifications | GET | Obtener preferencias de notificación | ✅ Implementado |
/users/me/notifications | PATCH | Actualizar preferencias de notificación | ✅ Implementado |
/users/me/reengagement-context | GET | Contexto de reenganche (streak risk, welcome back) | ✅ Implementado |
/users/me/bookmarks/:id | DELETE | Eliminar un marcador | ✅ Implementado |
User Stories
Section titled “User Stories”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#profileWhen escribe un nombre de 2 a 100 caracteres y pulsa "Guardar"Then la API responde 200 con el campo fullName actualizadoAnd el nuevo nombre aparece en la cabecera de la páginaAC-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 400And no se actualiza el perfilAC-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 ámbarAC-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 guardadoWhen envía jobTitle como cadena vacía con PATCH /users/meThen la API convierte el valor a null y lo persisteAnd el campo aparece vacío al recargar la páginaUS-UPROF-002: Subir imagen de avatar
Section titled “US-UPROF-002: Subir imagen de avatar”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#profileWhen selecciona un archivo PNG/JPEG/WEBP de menos de 5 MBThen 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áginaAC-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 MBWhen el servidor recibe la peticiónThen la API responde 400And el avatar anterior no se modificaAC-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 .SVGWhen el servidor evalúa el mimetypeThen 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álidoThen el componente muestra la vista previa inmediatamente via FileReaderAnd el spinner "Subiendo..." aparece sobre el botón hasta recibir la respuestaUS-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 /onboardingWhen completa los 5 pasos: Rol → Sector → Nivel IA → Objetivos → ResultadoThen la API recibe POST /users/me/onboarding con { role, industry, aiLevel, goals }And el campo onboardingCompleted queda a true en la BDAnd 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 wizardThen la API recibe { role: null, industry: null, aiLevel: null, goals: [] }And onboardingCompleted queda a true en la BDAnd el usuario es redirigido a /dashboardAC-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 = trueWhen navega directamente a /onboardingThen es redirigido automáticamente a /dashboardAC-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 400And el campo aiLevel no se modificaUS-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ónWhen llama a GET /users/me/notificationsThen la API devuelve los valores por defecto: emailLearningReminders: true emailNewPaths: true emailContentUpdates: true emailBadgesAchievements: true emailNewsletter: false emailPromotions: false pushStreakRisk: true pushTutorResponses: true pushTeamActivity: falseAC-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#notificationsWhen activa o desactiva un switch de notificaciónThen la UI actualiza el estado del toggle inmediatamenteAnd tras 2 segundos sin nuevos cambios se llama PATCH /users/me/notificationsAnd la API responde 200 con las preferencias actualizadasAC-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 ELITEWhen abre la sección de notificacionesThen el toggle "Actividad del equipo" está deshabilitado (disabled=true)And no puede interactuar con élAC-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 booleanosThen 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 ayerWhen llama a GET /users/me/streakThen current > 0And hasActivityToday refleja correctamente si completó algo hoyAC-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ásWhen llama a GET /users/me/streakThen current = 0And 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 actualWhen llama a GET /users/me/streakThen 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 hoyAC-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 100When llama a GET /users/me/streakThen la respuesta incluye milestoneReached con ese valorAnd en cualquier otro caso milestoneReached no está presente en la respuestaUS-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á autenticadoWhen llama a POST /users/me/exportThen 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íoAnd el mensaje de confirmación permanece visible mientras dure la sesiónAC-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ónThen la API responde 401US-UPROF-007: Eliminar mi cuenta
Section titled “US-UPROF-007: Eliminar mi cuenta”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 peligroThen se abre un modal de confirmaciónAnd el botón "Sí, eliminar mi cuenta" está deshabilitado durante 1,5 segundosAnd solo se activa transcurrido ese tiempo para evitar clics accidentalesAC-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ónThen aparece el aviso "Tu suscripción [plan] activa se cancelará inmediatamente sin reembolso"And el aviso aparece destacado en rojoAC-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 modalWhen la API devuelve 204Then se despacha logout() en ReduxAnd el usuario es redirigido a "/"And no puede volver a autenticarse con las mismas credencialesAC-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ónThen la API responde 401And dado un usuario autenticado que confirma la eliminaciónThen 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 adicionalAC-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 disponiblesWhen pulsa "Guardar objetivos"Then se llama PATCH /users/me con goals: ["clave1", "clave2", "clave3"]And al recargar la página los 3 objetivos aparecen marcadosAC-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 idiomaWhen guarda las preferenciasThen la API recibe PATCH /users/me con { language: "es-MX" }And el servicio guarda el valor en el campo locale de la BDAC-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 400And el campo aiLevel no se modificaUS-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 hoyAnd han pasado 3 días o menos desde la pérdidaWhen llama a GET /users/me/reengagement-contextThen variant = "streak_lost"And prevStreak > 0And nextLesson contiene la siguiente lección sin completarAC-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 hoyWhen llama a GET /users/me/reengagement-contextThen 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 = 0When llama a GET /users/me/reengagement-contextThen variant = "welcome_back"And daysSinceActivity >= 3US-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_123Then la API responde 204And el marcador desaparece de GET /users/me/progress > bookmarksAC-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á autenticadoWhen el usuario B llama a DELETE /users/me/bookmarks/bm_456Then la API usa deleteMany con filtro userId, por lo que no elimina nadaAnd la API responde 204 (idempotente) pero el marcador de A no se modificaTest Cases
Section titled “Test Cases”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) })})