diff --git a/TECH_DEBT.md b/TECH_DEBT.md index dfb560c..d66fe3f 100644 --- a/TECH_DEBT.md +++ b/TECH_DEBT.md @@ -47,15 +47,25 @@ en los 3 PDFs. Si hay truncamiento, evaluar: ## Deuda media — afecta DX o evolución del producto -### [INVESTIGATING] Clave de localStorage del auth E2E vs producción -**Estado actual:** contradicción no resuelta entre dos hechos empíricos verificados: -- En producción, `ProfileSheet.getAccessToken()` lee `supabase.auth.token` y funciona correctamente (Sprint 5 Etapa 4). -- En E2E, `auth-setup.ts` escribe la sesión bajo `sb-${projectRef}-auth-token` y el smoke test "app header shows user avatar" pasa. -**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. -**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. -**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. -**Archivos afectados:** `tests/e2e/setup/auth-setup.ts`, `src/lib/supabase.ts`. -**Origen:** Sprint 5 Etapa 6 diagnóstico de auth-setup. +### [RESOLVED] Clave de localStorage del auth E2E vs producción +**Resultado:** la "contradicción" era un bug real, no solo una discrepancia de documentación. +`tests/e2e/auth-localStorage-key.spec.ts` (Sprint 6 Etapa 3) confirmó empíricamente que la +única clave presente en `localStorage` con sesión real es `sb-${projectRef}-auth-token` +(`sb-xgqbkxcliyjzisoccsur-auth-token` en este proyecto) — `'supabase.auth.token'` (STORAGE_KEY +default de `@supabase/auth-js`'s `GoTrueClient` en aislamiento) **no existe**, porque +`@supabase/supabase-js`'s `createClient()` sobrescribe ese default calculando la clave +`sb-`-prefijada desde la URL del proyecto. + +`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 **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). **Origen:** OBSERVATIONS Sprint 5 Etapa 1 — ítem prometido que faltaba registrar acá. -### [BACKLOG] `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. -**Solución propuesta:** eliminar el `setTimeout`, verificar que el fix en `sheet.tsx` sea suficiente por sí solo. -**Archivos afectados:** `src/components/ProfileSheet.tsx`. -**Origen:** Sprint 5 Etapa 4G (fix intermedio que quedó al resolverse la causa raíz en 4H). +### [RESOLVED] `setTimeout(350)` en ProfileSheet — candidato a limpieza +**Resultado:** `setTimeout` eliminado — `updateUser()` se llama directamente tras +`onOpenChange(false)`. Verificado end-to-end (Sprint 6 Etapa 3): el fix definitivo en +`src/components/ui/sheet.tsx` (`useEffect` sobre el prop `open`, limpieza defensiva de +`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 **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. **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) **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`. diff --git a/src/components/ProfileSheet.tsx b/src/components/ProfileSheet.tsx index 8622a20..d07d66b 100644 --- a/src/components/ProfileSheet.tsx +++ b/src/components/ProfileSheet.tsx @@ -10,10 +10,21 @@ import { useAuth } from '@/auth/AuthContext' // 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 // 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 { 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 const parsed = JSON.parse(raw) as { access_token?: string } return parsed.access_token ?? null @@ -48,16 +59,13 @@ export function ProfileSheet({ open, onOpenChange }: ProfileSheetProps) { const savedName = name.trim() const savedCompany = company.trim() || undefined - // Cerramos el Sheet primero. updateUser() se difiere 350ms — más que la animación - // de cierre de Radix (duration-300 = 300ms) — para que el cleanup de - // @radix-ui/react-remove-scroll restaure pointer-events:none del body ANTES - // de que el re-render de AuthContext vuelva a procesar el Sheet. Si updateUser - // corre durante la animación, Radix no ejecuta el cleanup y body queda con - // pointer-events:none permanente, bloqueando toda interacción fuera del canvas BPMN. + // Cerramos el Sheet y actualizamos el estado local de inmediato — actualización + // optimista. El fix para el bug de Radix/react-remove-scroll (pointer-events:none + // atascado en el body tras cerrar) vive en src/components/ui/sheet.tsx (useEffect + // que observa el prop `open` y limpia el body defensivamente). El defer de 350ms + // que existía acá era redundante una vez resuelta la causa raíz ahí. onOpenChange(false) - setTimeout(() => { - updateUser({ name: savedName, company: savedCompany }) - }, 350) + updateUser({ name: savedName, company: savedCompany }) // Persistir en Supabase en background — fire & forget. // Usamos raw fetch (no el cliente Supabase) para evitar contención con processLock. diff --git a/tests/e2e/auth-localStorage-key.spec.ts b/tests/e2e/auth-localStorage-key.spec.ts new file mode 100644 index 0000000..77ec316 --- /dev/null +++ b/tests/e2e/auth-localStorage-key.spec.ts @@ -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 { + const path = resolve(ROOT, filename) + if (!existsSync(path)) return {} + const out: Record = {} + 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) +}) diff --git a/tests/e2e/auth-real.spec.ts b/tests/e2e/auth-real.spec.ts new file mode 100644 index 0000000..ded7f3e --- /dev/null +++ b/tests/e2e/auth-real.spec.ts @@ -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