fix(sprint-6/etapa-3): localStorage key en ProfileSheet + eliminar setTimeout(350) + specs E2E de auth
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
This commit is contained in:
51
TECH_DEBT.md
51
TECH_DEBT.md
@@ -47,15 +47,25 @@ en los 3 PDFs. Si hay truncamiento, evaluar:
|
|||||||
|
|
||||||
## Deuda media — afecta DX o evolución del producto
|
## Deuda media — afecta DX o evolución del producto
|
||||||
|
|
||||||
### [INVESTIGATING] Clave de localStorage del auth E2E vs producción
|
### [RESOLVED] Clave de localStorage del auth E2E vs producción
|
||||||
**Estado actual:** contradicción no resuelta entre dos hechos empíricos verificados:
|
**Resultado:** la "contradicción" era un bug real, no solo una discrepancia de documentación.
|
||||||
- En producción, `ProfileSheet.getAccessToken()` lee `supabase.auth.token` y funciona correctamente (Sprint 5 Etapa 4).
|
`tests/e2e/auth-localStorage-key.spec.ts` (Sprint 6 Etapa 3) confirmó empíricamente que la
|
||||||
- En E2E, `auth-setup.ts` escribe la sesión bajo `sb-${projectRef}-auth-token` y el smoke test "app header shows user avatar" pasa.
|
única clave presente en `localStorage` con sesión real es `sb-${projectRef}-auth-token`
|
||||||
**Hipótesis:** el cliente Supabase de la app (`src/lib/supabase.ts`) y el de `auth-setup.ts` podrían estar usando `storageKey` distinto. La documentación inline en `auth-setup.ts` explica el comportamiento observado pero no reconcilia la contradicción.
|
(`sb-xgqbkxcliyjzisoccsur-auth-token` en este proyecto) — `'supabase.auth.token'` (STORAGE_KEY
|
||||||
**Impacto:** si el auth E2E no funciona realmente (sesión vacía), todos los specs con `storageState` serían inválidos — pasarían por ausencia de assertions fuertes, no porque el auth funcione.
|
default de `@supabase/auth-js`'s `GoTrueClient` en aislamiento) **no existe**, porque
|
||||||
**Acción:** en Sprint 6, comparar el `localStorage` real en producción vs el `auth-state.json` del E2E. Ejecutar un spec que haga una query autenticada a Supabase y afirme que devuelve datos reales del usuario de test.
|
`@supabase/supabase-js`'s `createClient()` sobrescribe ese default calculando la clave
|
||||||
**Archivos afectados:** `tests/e2e/setup/auth-setup.ts`, `src/lib/supabase.ts`.
|
`sb-`-prefijada desde la URL del proyecto.
|
||||||
**Origen:** Sprint 5 Etapa 6 diagnóstico de auth-setup.
|
|
||||||
|
`ProfileSheet.getAccessToken()` leía `'supabase.auth.token'` — la clave vieja, inexistente.
|
||||||
|
Esto significaba que **el guardado de perfil (nombre, empresa) nunca llegaba a Supabase**:
|
||||||
|
`getAccessToken()` retornaba `null`, el `if (!token) return` silenciaba el error, y el usuario
|
||||||
|
veía el cambio solo en memoria (`updateUser()`) sin persistir. Bug real, no solo deuda de
|
||||||
|
documentación.
|
||||||
|
|
||||||
|
**Fix:** `getAccessToken()` ahora calcula `sb-${projectRef}-auth-token` igual que
|
||||||
|
`auth-setup.ts`. Verificado end-to-end: PATCH a `/rest/v1/users` devuelve 204, y tras un
|
||||||
|
reload completo de página el valor guardado se relee correctamente desde la DB.
|
||||||
|
**Sprint cerrado en:** Sprint 6 Etapa 3.
|
||||||
|
|
||||||
### [BACKLOG] `getCurrentUser()` en SupabaseAuthService — sin callers activos
|
### [BACKLOG] `getCurrentUser()` en SupabaseAuthService — sin callers activos
|
||||||
**Estado actual:** `SupabaseAuthService.getCurrentUser()` usa `getSession()` directamente (sin timeout, sin processLock workaround). Es la única excepción documentada a la regla — justificada porque necesita el objeto sesión completo, no solo una barrera. Sin callers activos hoy.
|
**Estado actual:** `SupabaseAuthService.getCurrentUser()` usa `getSession()` directamente (sin timeout, sin processLock workaround). Es la única excepción documentada a la regla — justificada porque necesita el objeto sesión completo, no solo una barrera. Sin callers activos hoy.
|
||||||
@@ -64,11 +74,14 @@ en los 3 PDFs. Si hay truncamiento, evaluar:
|
|||||||
**Archivos afectados:** `src/auth/SupabaseAuthService.ts` (~línea 27, comentario explicativo presente).
|
**Archivos afectados:** `src/auth/SupabaseAuthService.ts` (~línea 27, comentario explicativo presente).
|
||||||
**Origen:** OBSERVATIONS Sprint 5 Etapa 1 — ítem prometido que faltaba registrar acá.
|
**Origen:** OBSERVATIONS Sprint 5 Etapa 1 — ítem prometido que faltaba registrar acá.
|
||||||
|
|
||||||
### [BACKLOG] `setTimeout(350)` en ProfileSheet — candidato a limpieza
|
### [RESOLVED] `setTimeout(350)` en ProfileSheet — candidato a limpieza
|
||||||
**Estado actual:** `ProfileSheet.handleSave()` difiere `updateUser` 350ms como mitigación de la race condition con Radix. El fix definitivo vive en `src/components/ui/sheet.tsx` (cleanup defensivo de `pointer-events`). El setTimeout es redundante pero inofensivo.
|
**Resultado:** `setTimeout` eliminado — `updateUser()` se llama directamente tras
|
||||||
**Solución propuesta:** eliminar el `setTimeout`, verificar que el fix en `sheet.tsx` sea suficiente por sí solo.
|
`onOpenChange(false)`. Verificado end-to-end (Sprint 6 Etapa 3): el fix definitivo en
|
||||||
**Archivos afectados:** `src/components/ProfileSheet.tsx`.
|
`src/components/ui/sheet.tsx` (`useEffect` sobre el prop `open`, limpieza defensiva de
|
||||||
**Origen:** Sprint 5 Etapa 4G (fix intermedio que quedó al resolverse la causa raíz en 4H).
|
`pointer-events`) es suficiente por sí solo — `document.body.style.pointerEvents` queda
|
||||||
|
vacío tras guardar, y la navegación posterior (click en "Importar BPMN") funciona sin
|
||||||
|
bloqueos.
|
||||||
|
**Sprint cerrado en:** Sprint 6 Etapa 3.
|
||||||
|
|
||||||
### [RESOLVED] E2E specs con Dexie directo — reescribir para Supabase
|
### [RESOLVED] E2E specs con Dexie directo — reescribir para Supabase
|
||||||
**Resultado:** `export-pdf.spec.ts` (test "Heatmap con gradiente real") y `validate-dod-etapa4.spec.ts` reescritos para inyectar datos vía `@supabase/supabase-js` con `SUPABASE_SERVICE_ROLE_KEY` (bypasa RLS), en lugar de IndexedDB directo. `afterAll` borra el proceso creado por cada test (cascada en `activities`/`simulations`). Si `SUPABASE_SERVICE_ROLE_KEY` no está configurada en `.env.test`, ambos tests hacen `test.skip()` con mensaje descriptivo en vez de fallar — ver `.env.example`. Diagnóstico previo de `auth-setup.ts` confirmó que la storageKey (`sb-${projectRef}-auth-token`) ya era correcta (no era parte del problema): `@supabase/supabase-js`'s `createClient()` sobrescribe el `STORAGE_KEY` default de `auth-js` con esa clave calculada desde la URL.
|
**Resultado:** `export-pdf.spec.ts` (test "Heatmap con gradiente real") y `validate-dod-etapa4.spec.ts` reescritos para inyectar datos vía `@supabase/supabase-js` con `SUPABASE_SERVICE_ROLE_KEY` (bypasa RLS), en lugar de IndexedDB directo. `afterAll` borra el proceso creado por cada test (cascada en `activities`/`simulations`). Si `SUPABASE_SERVICE_ROLE_KEY` no está configurada en `.env.test`, ambos tests hacen `test.skip()` con mensaje descriptivo en vez de fallar — ver `.env.example`. Diagnóstico previo de `auth-setup.ts` confirmó que la storageKey (`sb-${projectRef}-auth-token`) ya era correcta (no era parte del problema): `@supabase/supabase-js`'s `createClient()` sobrescribe el `STORAGE_KEY` default de `auth-js` con esa clave calculada desde la URL.
|
||||||
@@ -92,6 +105,16 @@ en los 3 PDFs. Si hay truncamiento, evaluar:
|
|||||||
**Costo:** bajo, mecánico — mismo cambio repetido 9 veces.
|
**Costo:** bajo, mecánico — mismo cambio repetido 9 veces.
|
||||||
**Origen:** Sprint 5 Etapa 6 — identificado durante el grep de diagnóstico, no aplicado por respeto al scope explícito del prompt.
|
**Origen:** Sprint 5 Etapa 6 — identificado durante el grep de diagnóstico, no aplicado por respeto al scope explícito del prompt.
|
||||||
|
|
||||||
|
### [BACKLOG] CumulativeSavingsChart — derivación de inversión base desde cumulativeSavingsHorizon
|
||||||
|
**Estado actual:** cuando `roiBase` está presente (modo sensibilidad activo), el componente
|
||||||
|
recupera la inversión original algebraicamente: `investment_base = roiBase.annualSavings * horizonYears - roiBase.cumulativeSavingsHorizon`.
|
||||||
|
Es correcto mientras `horizonYears` del chart coincida con el usado en `calculateRoi` (hoy siempre es el mismo).
|
||||||
|
**Riesgo:** si en el futuro el chart recibe un horizonte diferente al del cálculo de ROI, el valor
|
||||||
|
de referencia del eje Y para la línea base será incorrecto, sin error visible.
|
||||||
|
**Solución propuesta:** agregar prop `investmentBase?: number` a `CumulativeSavingsChartProps`
|
||||||
|
y pasar `process.automationInvestment` directamente desde `ReportPage`.
|
||||||
|
**Costo:** bajo. **Origen:** Sprint 6 Etapa 2 — detectado en auditoría post-implementación.
|
||||||
|
|
||||||
### [INVESTIGATING] Dexie live queries (sincronización store ↔ db)
|
### [INVESTIGATING] Dexie live queries (sincronización store ↔ db)
|
||||||
|
|
||||||
**Estado actual:** la invalidación del resultado de simulación se garantiza vía ESLint rule (`no-restricted-imports`) + convención: todas las mutaciones pasan por `useProcessStore`.
|
**Estado actual:** la invalidación del resultado de simulación se garantiza vía ESLint rule (`no-restricted-imports`) + convención: todas las mutaciones pasan por `useProcessStore`.
|
||||||
|
|||||||
@@ -10,10 +10,21 @@ import { useAuth } from '@/auth/AuthContext'
|
|||||||
// Lee el access token directamente de localStorage sin pasar por el cliente Supabase.
|
// Lee el access token directamente de localStorage sin pasar por el cliente Supabase.
|
||||||
// Evita contención con processLock — el save de perfil no necesita refresh de token
|
// Evita contención con processLock — el save de perfil no necesita refresh de token
|
||||||
// porque es una operación rápida y el token está fresco en sesiones activas.
|
// porque es una operación rápida y el token está fresco en sesiones activas.
|
||||||
// Clave verificada: STORAGE_KEY = 'supabase.auth.token' en @supabase/auth-js GoTrueClient.
|
//
|
||||||
|
// Clave real confirmada por diagnóstico (Sprint 6 Etapa 3, tests/e2e/auth-localStorage-key.spec.ts):
|
||||||
|
// 'supabase.auth.token' es el STORAGE_KEY default de @supabase/auth-js's GoTrueClient en
|
||||||
|
// aislamiento, pero @supabase/supabase-js's createClient() SOBRESCRIBE ese default calculando
|
||||||
|
// `sb-${projectRef}-auth-token` desde la URL del proyecto (projectRef = hostname.split('.')[0]),
|
||||||
|
// salvo que el caller pase un storageKey explícito — src/lib/supabase.ts no lo hace. El spec de
|
||||||
|
// diagnóstico confirmó empíricamente: 'supabase.auth.token' NO existe en localStorage; la única
|
||||||
|
// clave presente es `sb-${projectRef}-auth-token` con un access_token válido. Con la clave vieja,
|
||||||
|
// getAccessToken() siempre retornaba null → el persist a Supabase nunca se ejecutaba (línea 68
|
||||||
|
// silenciaba el error) — el usuario veía el cambio en UI pero no se guardaba.
|
||||||
function getAccessToken(): string | null {
|
function getAccessToken(): string | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem('supabase.auth.token')
|
const projectRef = new URL(SUPABASE_URL).hostname.split('.')[0]
|
||||||
|
const storageKey = `sb-${projectRef}-auth-token`
|
||||||
|
const raw = localStorage.getItem(storageKey)
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
const parsed = JSON.parse(raw) as { access_token?: string }
|
const parsed = JSON.parse(raw) as { access_token?: string }
|
||||||
return parsed.access_token ?? null
|
return parsed.access_token ?? null
|
||||||
@@ -48,16 +59,13 @@ export function ProfileSheet({ open, onOpenChange }: ProfileSheetProps) {
|
|||||||
const savedName = name.trim()
|
const savedName = name.trim()
|
||||||
const savedCompany = company.trim() || undefined
|
const savedCompany = company.trim() || undefined
|
||||||
|
|
||||||
// Cerramos el Sheet primero. updateUser() se difiere 350ms — más que la animación
|
// Cerramos el Sheet y actualizamos el estado local de inmediato — actualización
|
||||||
// de cierre de Radix (duration-300 = 300ms) — para que el cleanup de
|
// optimista. El fix para el bug de Radix/react-remove-scroll (pointer-events:none
|
||||||
// @radix-ui/react-remove-scroll restaure pointer-events:none del body ANTES
|
// atascado en el body tras cerrar) vive en src/components/ui/sheet.tsx (useEffect
|
||||||
// de que el re-render de AuthContext vuelva a procesar el Sheet. Si updateUser
|
// que observa el prop `open` y limpia el body defensivamente). El defer de 350ms
|
||||||
// corre durante la animación, Radix no ejecuta el cleanup y body queda con
|
// que existía acá era redundante una vez resuelta la causa raíz ahí.
|
||||||
// pointer-events:none permanente, bloqueando toda interacción fuera del canvas BPMN.
|
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
setTimeout(() => {
|
updateUser({ name: savedName, company: savedCompany })
|
||||||
updateUser({ name: savedName, company: savedCompany })
|
|
||||||
}, 350)
|
|
||||||
|
|
||||||
// Persistir en Supabase en background — fire & forget.
|
// Persistir en Supabase en background — fire & forget.
|
||||||
// Usamos raw fetch (no el cliente Supabase) para evitar contención con processLock.
|
// Usamos raw fetch (no el cliente Supabase) para evitar contención con processLock.
|
||||||
|
|||||||
72
tests/e2e/auth-localStorage-key.spec.ts
Normal file
72
tests/e2e/auth-localStorage-key.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Diagnóstico Sprint 6 Etapa 3 Parte A: ¿bajo qué clave de localStorage escribe
|
||||||
|
* supabase-js v2 la sesión en este entorno? ProfileSheet.getAccessToken() lee
|
||||||
|
* 'supabase.auth.token' (STORAGE_KEY default de @supabase/auth-js GoTrueClient),
|
||||||
|
* pero auth-setup.ts documenta que @supabase/supabase-js's createClient() sobrescribe
|
||||||
|
* ese default calculando `sb-${projectRef}-auth-token` desde la URL del proyecto —
|
||||||
|
* a menos que el caller pase un storageKey explícito (src/lib/supabase.ts no lo hace).
|
||||||
|
*
|
||||||
|
* Este spec confirma empíricamente cuál de las dos claves existe realmente en el
|
||||||
|
* localStorage de una sesión autenticada real, antes de tocar ProfileSheet.tsx.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { readFileSync, existsSync } from 'fs'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
test.use({ storageState: 'tests/e2e/setup/auth-state.json' })
|
||||||
|
|
||||||
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||||
|
const ROOT = resolve(__dirname, '../..')
|
||||||
|
|
||||||
|
// Mismo patrón manual que auth-setup.ts / export-pdf.spec.ts — no hay dotenv
|
||||||
|
// cargando process.env automáticamente en el runner de Playwright.
|
||||||
|
function loadEnvFile(filename: string): Record<string, string> {
|
||||||
|
const path = resolve(ROOT, filename)
|
||||||
|
if (!existsSync(path)) return {}
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
for (const line of readFileSync(path, 'utf-8').split('\n')) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue
|
||||||
|
const idx = trimmed.indexOf('=')
|
||||||
|
if (idx === -1) continue
|
||||||
|
out[trimmed.slice(0, idx)] = trimmed.slice(idx + 1)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
test('diagnóstico: confirmar la clave real de localStorage para la sesión Supabase', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
// Esperar a que la app cargue (AuthContext lee el storageState y monta el header)
|
||||||
|
await page.waitForSelector('header button[title]', { timeout: 10_000 })
|
||||||
|
|
||||||
|
const localEnv = loadEnvFile('.env.local')
|
||||||
|
const supabaseUrl = localEnv.VITE_SUPABASE_URL
|
||||||
|
const projectRef = supabaseUrl ? new URL(supabaseUrl).hostname.split('.')[0] : null
|
||||||
|
|
||||||
|
const diagnosis = await page.evaluate((ref: string | null) => {
|
||||||
|
const keys = Object.keys(localStorage)
|
||||||
|
const authKeys = keys.filter((k) => k.includes('auth') || k.startsWith('sb-'))
|
||||||
|
|
||||||
|
const legacyRaw = localStorage.getItem('supabase.auth.token')
|
||||||
|
const v2Key = ref ? `sb-${ref}-auth-token` : null
|
||||||
|
const v2Raw = v2Key ? localStorage.getItem(v2Key) : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
allAuthRelatedKeys: authKeys,
|
||||||
|
legacyKeyExists: legacyRaw !== null,
|
||||||
|
v2KeyName: v2Key,
|
||||||
|
v2KeyExists: v2Raw !== null,
|
||||||
|
legacyHasAccessToken: legacyRaw ? Boolean((JSON.parse(legacyRaw) as { access_token?: string }).access_token) : false,
|
||||||
|
v2HasAccessToken: v2Raw ? Boolean((JSON.parse(v2Raw) as { access_token?: string }).access_token) : false,
|
||||||
|
}
|
||||||
|
}, projectRef)
|
||||||
|
|
||||||
|
console.log('[diagnóstico localStorage]', JSON.stringify(diagnosis, null, 2))
|
||||||
|
|
||||||
|
// Afirmación central: la clave v2 (sb-{projectRef}-auth-token) es la que existe y
|
||||||
|
// tiene un access_token real. La clave legacy ('supabase.auth.token') NO existe.
|
||||||
|
expect(diagnosis.v2KeyExists, `Esperaba que ${diagnosis.v2KeyName} exista en localStorage`).toBe(true)
|
||||||
|
expect(diagnosis.v2HasAccessToken, 'La clave v2 debe contener un access_token no vacío').toBe(true)
|
||||||
|
expect(diagnosis.legacyKeyExists, "'supabase.auth.token' (clave legacy que lee ProfileSheet hoy) NO debería existir").toBe(false)
|
||||||
|
})
|
||||||
56
tests/e2e/auth-real.spec.ts
Normal file
56
tests/e2e/auth-real.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Sprint 6 Etapa 3 Parte C — Validación de que el auth E2E funciona realmente
|
||||||
|
* (no solo que el storageState se carga, sino que Supabase devuelve datos reales
|
||||||
|
* del usuario autenticado).
|
||||||
|
*
|
||||||
|
* No existe data-testid="app-header-user-avatar" en AppHeader.tsx — el trigger del
|
||||||
|
* dropdown es un <button title={user.name}> dentro del <header> (mismo selector que
|
||||||
|
* ya usa tests/e2e/sprint-4-smoke.spec.ts: 'header button[title]').
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { readFileSync, existsSync } from 'fs'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
test.use({ storageState: 'tests/e2e/setup/auth-state.json' })
|
||||||
|
|
||||||
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||||
|
const ROOT = resolve(__dirname, '../..')
|
||||||
|
|
||||||
|
function loadEnvFile(filename: string): Record<string, string> {
|
||||||
|
const path = resolve(ROOT, filename)
|
||||||
|
if (!existsSync(path)) return {}
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
for (const line of readFileSync(path, 'utf-8').split('\n')) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue
|
||||||
|
const idx = trimmed.indexOf('=')
|
||||||
|
if (idx === -1) continue
|
||||||
|
out[trimmed.slice(0, idx)] = trimmed.slice(idx + 1)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const testEnv = loadEnvFile('.env.test')
|
||||||
|
const TEST_USER_EMAIL = testEnv.TEST_USER_EMAIL
|
||||||
|
|
||||||
|
test('auth E2E: Supabase devuelve datos reales del usuario autenticado', async ({ page }) => {
|
||||||
|
test.skip(!TEST_USER_EMAIL, 'TEST_USER_EMAIL no configurada en .env.test — ver .env.example')
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
// Esperar a que AppHeader muestre el trigger del dropdown de usuario
|
||||||
|
// (confirma que AuthContext cargó la sesión y el avatar renderiza con user.name real)
|
||||||
|
const avatarTrigger = page.locator('header button[title]').first()
|
||||||
|
await expect(avatarTrigger).toBeVisible({ timeout: 10_000 })
|
||||||
|
|
||||||
|
// El título del botón es el nombre real del usuario — no debe estar vacío
|
||||||
|
const titleAttr = await avatarTrigger.getAttribute('title')
|
||||||
|
expect(titleAttr, 'el trigger debe tener el nombre real del usuario, no vacío').toBeTruthy()
|
||||||
|
|
||||||
|
// Abrir el dropdown y confirmar que el email del usuario de test aparece —
|
||||||
|
// esto requiere que AuthContext haya construido el AuthUser real desde la sesión
|
||||||
|
// de Supabase (no un mock ni un valor placeholder).
|
||||||
|
await avatarTrigger.click()
|
||||||
|
await expect(page.getByText(TEST_USER_EMAIL)).toBeVisible({ timeout: 5_000 })
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user