Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 19 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
158 changes: 158 additions & 0 deletions __tests__/integration/engagementDashboard.test.ts
Original file line number Diff line number Diff line change
@@ -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<Database>(LOCAL_URL, LOCAL_ANON_KEY);
const staffClient = createClient<Database>(LOCAL_URL, LOCAL_ANON_KEY);
const serviceClient = createClient<Database>(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<string, unknown>) => {
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();
});
});
4 changes: 4 additions & 0 deletions lib/database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
20 changes: 19 additions & 1 deletion supabase/tests/rls/mv_post_engagement_daily.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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 $$
Expand Down Expand Up @@ -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);

Expand All @@ -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();
Expand Down
Loading