diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 221183a..c5c6ec4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,25 +109,34 @@ jobs: # Se sirve en background dentro del mismo step (así el proceso sigue vivo # durante los tests) y se CALIENTA antes de lanzar Jest. # - # El calentamiento manda `apikey`, y eso importa: sin esa cabecera la función - # devuelve 401 en su primer check ("Missing Authorization header") sin llegar a - # crear el cliente Supabase, así que el probe daría "listo" sin haber pagado el - # arranque en frío (resolución de los imports remotos de esm.sh). Con `apikey`, - # Kong inyecta el Authorization y la función recorre createClient + getUser: - # su 401 "Unauthorized" prueba que el camino caro ya está caliente. Así el cold - # start se paga aquí y no dentro de un timeout de Jest. + # El calentamiento manda un JWT (la anon key) en `Authorization`, y eso importa: + # sin esa cabecera la función corta en su primer check ("Missing Authorization + # header") sin llegar a crear el cliente Supabase, así que el probe daría "listo" + # sin haber pagado el arranque en frío. Con el Bearer, la función recorre + # createClient + getUser y responde 401 "Unauthorized": eso prueba que el camino + # caro ya está caliente, y el cold start se paga aquí y no en un timeout de Jest. - name: Serve edge functions and run integration tests run: | supabase functions serve track-engagement --no-verify-jwt > /tmp/functions.log 2>&1 & - ANON_KEY=$(supabase status -o env | grep '^ANON_KEY=' | cut -d= -f2- | tr -d '"') - for i in $(seq 1 60); do + + # `status -o json` es estable entre versiones de la CLI; el parseo de + # `-o env` no lo era y dejaba ANON_KEY vacío en silencio. + ANON_KEY=$(supabase status -o json \ + | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).ANON_KEY))") + if [ -z "$ANON_KEY" ]; then + echo "::error::no se pudo leer ANON_KEY de 'supabase status -o json'"; exit 1 + fi + + for i in $(seq 1 30); do body=$(curl -s -m 30 -X POST http://127.0.0.1:54321/functions/v1/track-engagement \ - -H "apikey: $ANON_KEY" -H "Content-Type: application/json" -d '[]' || true) + -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY" \ + -H "Content-Type: application/json" -d '[]' || true) if echo "$body" | grep -q 'Unauthorized'; then echo "edge function warm (createClient + getUser exercised)"; break fi echo "warming edge function... ($body)"; sleep 2 done + pnpm test -- --ci --testPathPattern="__tests__/integration/" - if: always() run: cat /tmp/functions.log || true diff --git a/__tests__/integration/engagementDashboard.test.ts b/__tests__/integration/engagementDashboard.test.ts new file mode 100644 index 0000000..eccc723 --- /dev/null +++ b/__tests__/integration/engagementDashboard.test.ts @@ -0,0 +1,158 @@ +/** + * Integration test — requires local Supabase running: + * npx supabase start + * + * Run with: npx jest --testPathPattern="integration/engagementDashboard" --no-coverage + * + * Test e2e del DoD de F-N04-03 (#175): siembra sesiones de engagement reales, + * fuerza el refresco manual de la vista materializada y valida que los números que + * consume la pantalla (listPostEngagement) son los esperados. Ejercita la cadena + * completa: engagement_sessions → MV private.post_engagement_daily → vista pública + * → listPostEngagement(). Incluye el RBAC (staff no ve nada ni puede refrescar). + */ +import { createClient } from '@supabase/supabase-js'; + +import { listPostEngagement } from '@/lib/supabase/queries/engagement'; +import type { Database } from '@/lib/database.types'; + +jest.mock('@/lib/supabase', () => ({ supabase: {} })); + +jest.setTimeout(30000); + +const LOCAL_URL = 'http://127.0.0.1:54321'; +const LOCAL_ANON_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRFA0NiK7ACcShDMkTBHHAN4vqu6S25ULXF-V70J4fM'; +// Las sesiones solo se pueden escribir con service_role (RLS + la RPC del tracking): +// es el mismo camino que usa la Edge Function track-engagement. +const LOCAL_SERVICE_ROLE_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'; + +const MANAGER = { email: 'manager@nun-ibiza.dev', password: 'password123' }; +const STAFF = { email: 'staff@nun-ibiza.dev', password: 'password123' }; +const RUN_MARKER = `engagement_dashboard_it_${Date.now()}`; + +const managerClient = createClient(LOCAL_URL, LOCAL_ANON_KEY); +const staffClient = createClient(LOCAL_URL, LOCAL_ANON_KEY); +const serviceClient = createClient(LOCAL_URL, LOCAL_SERVICE_ROLE_KEY, { + auth: { persistSession: false }, +}); + +describe('engagement dashboard (integration)', () => { + let postId: string; + let managerUserId: string; + let staffUserId: string; + + beforeAll(async () => { + const mgr = await managerClient.auth.signInWithPassword(MANAGER); + if (mgr.error) throw mgr.error; + managerUserId = mgr.data.user!.id; + + const staff = await staffClient.auth.signInWithPassword(STAFF); + if (staff.error) throw staff.error; + staffUserId = staff.data.user!.id; + + const { data: profile, error: pErr } = await managerClient + .from('profiles') + .select('id') + .eq('email', MANAGER.email) + .single(); + if (pErr) throw pErr; + + // Borrador a propósito: Jest corre los ficheros en paralelo y un post publicado + // entraría en el feed que listPublishedPosts está paginando, rompiendo sus + // invariantes. El dashboard no depende del status del post (lee por id). + const { data: post, error: postErr } = await managerClient + .from('posts') + .insert({ + author_id: profile.id, + title: `${RUN_MARKER} post`, + external_url: 'https://example.com/dashboard', + status: 'draft', + }) + .select('id') + .single(); + if (postErr) throw postErr; + postId = post!.id; + + // Sesiones: staff clica el enlace (10s, scroll 0.8); manager solo lo ve (20s, 0.4). + // → 2 lectores únicos, 1 clic ⇒ click_rate 0.5 · avg_seconds 15 · avg_scroll 0.6 + const seed = async (userId: string, event: Record) => { + const { error } = await serviceClient.rpc('apply_engagement_events', { + p_user_id: userId, + p_events: [{ session_id: crypto.randomUUID(), post_id: postId, ...event }], + }); + if (error) throw error; + }; + await seed(staffUserId, { link_clicked: true, focused_seconds_delta: 10, max_scroll_pct: 0.8 }); + await seed(managerUserId, { focused_seconds_delta: 20, max_scroll_pct: 0.4 }); + + // Valoraciones (staff 5, manager 3 ⇒ media 4) y una reacción del staff. + const { error: r1 } = await staffClient + .from('post_ratings') + .insert({ post_id: postId, user_id: staffUserId, rating: 5 }); + if (r1) throw r1; + const { error: r2 } = await managerClient + .from('post_ratings') + .insert({ post_id: postId, user_id: managerUserId, rating: 3 }); + if (r2) throw r2; + const { error: rx } = await staffClient + .from('post_reactions') + .insert({ post_id: postId, user_id: staffUserId, type: 'like' }); + if (rx) throw rx; + }); + + afterAll(async () => { + if (postId) { + await managerClient + .from('posts') + .update({ deleted_at: new Date().toISOString() }) + .eq('id', postId); + } + // scope 'local': un signOut global revocaría las sesiones de estos usuarios en el + // servidor, y Jest corre los ficheros en paralelo — tumbaría el JWT que otro test + // (trackEngagement) está validando contra GoTrue. + await managerClient.auth.signOut({ scope: 'local' }); + await staffClient.auth.signOut({ scope: 'local' }); + }); + + it('does not surface the fresh activity until the view is refreshed', async () => { + // El dashboard lee datos materializados: hasta que no se refresca, la actividad + // recién registrada no aparece (lag documentado de hasta 1h). + const rows = await listPostEngagement({ client: managerClient }); + + expect(rows.find((r) => r.post_id === postId)).toBeUndefined(); + }); + + it('shows the expected numbers after a manual refresh', async () => { + const { error } = await managerClient.rpc('refresh_post_engagement_daily'); + if (error) throw error; + + const rows = await listPostEngagement({ client: managerClient }); + const row = rows.find((r) => r.post_id === postId); + + expect(row).toBeDefined(); + expect(row).toMatchObject({ + title: `${RUN_MARKER} post`, + unique_readers: 2, + unique_clicks: 1, + total_reactions: 1, + }); + expect(row!.click_rate).toBeCloseTo(0.5); + expect(row!.avg_rating).toBeCloseTo(4); + // Señales opcionales (ADR-0006): media de (10, 20) y de (0.8, 0.4). + expect(row!.avg_seconds).toBeCloseTo(15); + expect(row!.avg_scroll).toBeCloseTo(0.6); + }); + + it('returns nothing to staff: the view is guarded in Postgres', async () => { + const rows = await listPostEngagement({ client: staffClient }); + + expect(rows).toEqual([]); + }); + + it('does not let staff refresh the dashboard', async () => { + const { error } = await staffClient.rpc('refresh_post_engagement_daily'); + + expect(error).not.toBeNull(); + }); +}); diff --git a/lib/database.types.ts b/lib/database.types.ts index d966295..d6111bb 100644 --- a/lib/database.types.ts +++ b/lib/database.types.ts @@ -484,6 +484,10 @@ export type Database = { } } Functions: { + refresh_post_engagement_daily: { + Args: never + Returns: undefined + } apply_engagement_events: { Args: { p_events: Json; p_user_id: string } Returns: { diff --git a/supabase/migrations/20260711000002_refresh_post_engagement_daily_rpc.sql b/supabase/migrations/20260711000002_refresh_post_engagement_daily_rpc.sql new file mode 100644 index 0000000..c1eecf8 --- /dev/null +++ b/supabase/migrations/20260711000002_refresh_post_engagement_daily_rpc.sql @@ -0,0 +1,39 @@ +-- Migration: RPC public.refresh_post_engagement_daily — refresco manual del dashboard +-- Part of Epic N04 / Feature F-N04-03 (#175) +-- Depende de: 20260711000001 (MV private.post_engagement_daily + job horario) +-- +-- El dashboard lee datos materializados con hasta 1h de lag. Esta RPC permite forzar +-- el refresco (admin/manager) sin esperar al job de pg_cron, y es lo que ejercita el +-- test e2e del DoD ("validar que la pantalla muestra los números tras refresco manual"). +-- +-- La MV vive en el schema `private` (no expuesto por PostgREST), así que el único +-- camino desde el cliente es esta función: SECURITY DEFINER para poder refrescarla, +-- con guard is_manager() dentro (jerarquía inclusiva admin > manager). + +create or replace function public.refresh_post_engagement_daily() +returns void +language plpgsql +security definer +set search_path = '' +as $$ +begin + if not public.is_manager() then + raise exception 'solo admin/manager pueden refrescar el dashboard de engagement' + using errcode = '42501'; + end if; + + -- Refresco NO concurrente a propósito: PostgREST ejecuta cada request dentro de una + -- transacción y REFRESH ... CONCURRENTLY no puede correr en un bloque transaccional. + -- El refresco periódico (job horario de pg_cron) sí usa CONCURRENTLY, porque corre + -- fuera de transacción y no debe bloquear las lecturas del dashboard. + refresh materialized view private.post_engagement_daily; +end; +$$; + +comment on function public.refresh_post_engagement_daily() is + 'Refresco manual del dashboard de engagement (F-N04-03). Solo admin/manager. ' + 'El refresco periódico lo hace el job horario de pg_cron con CONCURRENTLY.'; + +revoke execute on function public.refresh_post_engagement_daily() from public; +revoke execute on function public.refresh_post_engagement_daily() from anon; +grant execute on function public.refresh_post_engagement_daily() to authenticated; diff --git a/supabase/tests/rls/mv_post_engagement_daily.sql b/supabase/tests/rls/mv_post_engagement_daily.sql index 9d3089c..0209101 100644 --- a/supabase/tests/rls/mv_post_engagement_daily.sql +++ b/supabase/tests/rls/mv_post_engagement_daily.sql @@ -17,7 +17,7 @@ -- staff: aaaaaaaa-0000-0000-0000-000000000003 begin; -select plan(10); +select plan(13); create or replace function pg_temp.set_session(uid uuid) returns void language plpgsql as $$ @@ -145,6 +145,17 @@ select results_eq( 'el post agrega en 2 filas: una por día con actividad' ); +-- ── Refresco manual (RPC public.refresh_post_engagement_daily) ────────────── +select has_function( + 'public', 'refresh_post_engagement_daily', array[]::text[], + 'existe la RPC de refresco manual del dashboard' +); + +select lives_ok( + $$ select public.refresh_post_engagement_daily() $$, + 'manager puede refrescar el dashboard manualmente' +); + -- ── RBAC ──────────────────────────────────────────────────────────────────── select pg_temp.set_session('aaaaaaaa-0000-0000-0000-000000000003'::uuid); @@ -154,6 +165,13 @@ select results_eq( 'staff no ve datos del dashboard (vista vacía)' ); +select throws_ok( + $$ select public.refresh_post_engagement_daily() $$, + '42501', + null, + 'staff no puede refrescar el dashboard' +); + reset role; select * from finish();