diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..b3b61a1
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,17 @@
+# Propietarios de código — Nun Ibiza PinBoard App
+#
+# Gana la ÚLTIMA regla que coincide, no la más específica.
+# Por eso el comodín va primero.
+#
+# Solo surte efecto de bloqueo si el ruleset tiene require_code_owner_review: true.
+
+* @Azfe
+
+# Zonas críticas: decisiones de arquitectura, esquema de datos y RLS.
+/docs/adr/ @Azfe
+/supabase/migrations/ @Azfe
+/supabase/functions/ @Azfe
+/supabase/tests/ @Azfe
+
+# La propia configuración de revisión no se cambia sin revisión.
+/.github/ @Azfe
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..c228385
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,27 @@
+## Qué cambia
+
+
+
+Closes #
+
+## Decisiones de arquitectura
+
+- [ ] Este PR **no** cambia ninguna decisión registrada en `docs/adr/`.
+- [ ] Este PR **sí** la cambia. ADR que lo respalda: `docs/adr/____`
+ (el ADR debe existir y estar aprobado **antes** de la implementación,
+ no escribirse después para justificarla)
+
+Si supersede a un ADR anterior, marca el antiguo como `Superseded por ADR-XXXX`
+y actualiza el índice en `docs/adr/README.md`.
+
+## Consumidores afectados
+
+
+
+## Comprobaciones
+
+- [ ] Migración aplicada en local y revisada.
+- [ ] Cada policy RLS nueva tiene al menos un test positivo y uno negativo en `supabase/tests/`.
+- [ ] `pnpm lint`, `pnpm typecheck` y `pnpm test` pasan.
+- [ ] Los tipos de Supabase están regenerados si cambió el esquema.
diff --git a/.github/rulesets/protect-main-and-develop.json b/.github/rulesets/protect-main-and-develop.json
new file mode 100644
index 0000000..d25dc89
--- /dev/null
+++ b/.github/rulesets/protect-main-and-develop.json
@@ -0,0 +1,48 @@
+{
+ "name": "Protección de main y develop",
+ "target": "branch",
+ "enforcement": "active",
+ "conditions": {
+ "ref_name": {
+ "include": ["refs/heads/main", "refs/heads/develop"],
+ "exclude": []
+ }
+ },
+ "bypass_actors": [
+ {
+ "actor_id": 5,
+ "actor_type": "RepositoryRole",
+ "bypass_mode": "pull_request"
+ }
+ ],
+ "rules": [
+ { "type": "deletion" },
+ { "type": "non_fast_forward" },
+ {
+ "type": "pull_request",
+ "parameters": {
+ "required_approving_review_count": 1,
+ "require_code_owner_review": true,
+ "require_last_push_approval": true,
+ "dismiss_stale_reviews_on_push": true,
+ "required_review_thread_resolution": true,
+ "allowed_merge_methods": ["squash", "merge", "rebase"]
+ }
+ },
+ {
+ "type": "required_status_checks",
+ "parameters": {
+ "strict_required_status_checks_policy": true,
+ "do_not_enforce_on_create": false,
+ "required_status_checks": [
+ { "context": "Lint", "integration_id": 15368 },
+ { "context": "Markdown Lint (ADRs)", "integration_id": 15368 },
+ { "context": "TypeScript", "integration_id": 15368 },
+ { "context": "Tests (Jest)", "integration_id": 15368 },
+ { "context": "RLS Tests (Supabase)", "integration_id": 15368 },
+ { "context": "Integration Tests (Supabase)", "integration_id": 15368 }
+ ]
+ }
+ }
+ ]
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 68c29c0..c5c6ec4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -104,6 +104,41 @@ jobs:
with:
version: 2.108.0
- run: supabase start
- - run: pnpm test -- --ci --testPathPattern="__tests__/integration/"
+ # `supabase start` no sirve las Edge Functions: hay que arrancarlas aparte
+ # para que el test de integración de track-engagement pueda alcanzarlas.
+ # 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 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 &
+
+ # `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 "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
- if: always()
run: supabase stop
diff --git a/__tests__/components/CommentComposer.test.tsx b/__tests__/components/CommentComposer.test.tsx
new file mode 100644
index 0000000..4459084
--- /dev/null
+++ b/__tests__/components/CommentComposer.test.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import { fireEvent, render } from '@testing-library/react-native';
+
+import { CommentComposer } from '@/components/comments';
+
+// CommentComposer importa MAX_COMMENT_LENGTH de @/lib/comments, que a su vez carga
+// @/lib/supabase (createClient con env de Expo, no disponible en jest).
+jest.mock('@/lib/supabase', () => ({ supabase: {} }));
+
+describe('CommentComposer', () => {
+ it('el botón está deshabilitado cuando el campo está vacío', () => {
+ const onSubmit = jest.fn();
+ const { getByLabelText } = render();
+
+ fireEvent.press(getByLabelText('Comentar'));
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('envía el comentario recortado y limpia el campo', () => {
+ const onSubmit = jest.fn();
+ const { getByLabelText } = render();
+
+ const input = getByLabelText('Nuevo comentario');
+ fireEvent.changeText(input, ' hola mundo ');
+ fireEvent.press(getByLabelText('Comentar'));
+
+ expect(onSubmit).toHaveBeenCalledWith('hola mundo');
+ });
+
+ it('no envía cuando solo hay espacios', () => {
+ const onSubmit = jest.fn();
+ const { getByLabelText } = render();
+
+ fireEvent.changeText(getByLabelText('Nuevo comentario'), ' ');
+ fireEvent.press(getByLabelText('Comentar'));
+
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('muestra el contador de caracteres', () => {
+ const { getByText, getByLabelText } = render();
+
+ fireEvent.changeText(getByLabelText('Nuevo comentario'), 'abc');
+ expect(getByText('3/2000')).toBeTruthy();
+ });
+});
diff --git a/__tests__/components/EngagementScreen.test.tsx b/__tests__/components/EngagementScreen.test.tsx
new file mode 100644
index 0000000..1ac7e41
--- /dev/null
+++ b/__tests__/components/EngagementScreen.test.tsx
@@ -0,0 +1,124 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react-native';
+
+import EngagementScreen from '@/app/(app)/(tabs)/admin/engagement/index';
+import type { PostEngagement } from '@/lib/supabase/queries/engagement';
+
+// ─── Mocks ──────────────────────────────────────────────────────────────────
+
+const mockUseSession = jest.fn();
+const mockUseMetrics = jest.fn();
+
+// El módulo de queries arrastra el singleton de Supabase, que lee
+// Constants.expoConfig.extra (no poblado bajo Jest).
+jest.mock('@/lib/supabase', () => ({ supabase: {} }));
+
+jest.mock('@/hooks/useSession', () => ({
+ useSession: () => mockUseSession(),
+}));
+
+jest.mock('@/hooks/usePostEngagementMetrics', () => ({
+ usePostEngagementMetrics: () => mockUseMetrics(),
+}));
+
+jest.mock('expo-router', () => ({
+ Redirect: () => null,
+ Stack: { Screen: () => null },
+}));
+
+jest.mock('react-native-safe-area-context', () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { View } = require('react-native');
+ return { SafeAreaView: View };
+});
+
+const ROWS: PostEngagement[] = [
+ {
+ post_id: 'p1',
+ title: 'Nueva carta de temporada',
+ unique_readers: 4,
+ unique_clicks: 3,
+ click_rate: 0.75,
+ avg_rating: 4.5,
+ total_reactions: 6,
+ engaged_users: 2,
+ avg_seconds: 12,
+ avg_scroll: 0.8,
+ },
+];
+
+function metrics(overrides: Partial> = {}) {
+ return {
+ rows: ROWS,
+ loading: false,
+ error: null,
+ refresh: jest.fn(),
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseSession.mockReturnValue({ session: { userId: 'u1', role: 'admin' } });
+ mockUseMetrics.mockReturnValue(metrics());
+});
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+describe('EngagementScreen', () => {
+ it('shows the metrics of each post to an admin', () => {
+ render();
+
+ expect(screen.getByText('Nueva carta de temporada')).toBeTruthy();
+ expect(screen.getByText('3')).toBeTruthy(); // unique_clicks
+ expect(screen.getByText('75%')).toBeTruthy(); // click_rate
+ expect(screen.getByText('4.5')).toBeTruthy(); // avg_rating
+ expect(screen.getByText('6')).toBeTruthy(); // total_reactions
+ // engaged (ADR-001): interactuaron pero no llegaron al enlace externo.
+ expect(screen.getByText(/2 interactuaron sin clicar/)).toBeTruthy();
+ });
+
+ it('shows the metrics to a manager too', () => {
+ mockUseSession.mockReturnValue({ session: { userId: 'u2', role: 'manager' } });
+
+ render();
+
+ expect(screen.getByText('Nueva carta de temporada')).toBeTruthy();
+ });
+
+ it('does not render the metrics for staff (redirects away)', () => {
+ mockUseSession.mockReturnValue({ session: { userId: 'u3', role: 'staff' } });
+
+ render();
+
+ expect(screen.queryByText('Nueva carta de temporada')).toBeNull();
+ });
+
+ it('renders an em dash when a post has no readers or ratings', () => {
+ mockUseMetrics.mockReturnValue(
+ metrics({
+ rows: [{ ...ROWS[0]!, unique_readers: 0, unique_clicks: 0, click_rate: null, avg_rating: null }],
+ }),
+ );
+
+ render();
+
+ expect(screen.getAllByText('—')).toHaveLength(2); // click_rate y avg_rating
+ });
+
+ it('shows the empty state when there is no activity', () => {
+ mockUseMetrics.mockReturnValue(metrics({ rows: [] }));
+
+ render();
+
+ expect(screen.getByText(/Todavía no hay actividad registrada/)).toBeTruthy();
+ });
+
+ it('shows the error state', () => {
+ mockUseMetrics.mockReturnValue(metrics({ rows: [], error: 'Fallo de red' }));
+
+ render();
+
+ expect(screen.getByText('Fallo de red')).toBeTruthy();
+ });
+});
diff --git a/__tests__/components/PostCard.test.tsx b/__tests__/components/PostCard.test.tsx
index 501327d..fab6876 100644
--- a/__tests__/components/PostCard.test.tsx
+++ b/__tests__/components/PostCard.test.tsx
@@ -18,6 +18,8 @@ jest.mock('expo-image', () => {
jest.mock('lucide-react-native', () => ({
ExternalLink: () => null,
+ MessageCircle: () => null,
+ Star: () => null,
}));
jest.mock('@/hooks/usePostReactions', () => ({
@@ -49,6 +51,9 @@ const POST: PostWithAuthor = {
updated_at: '2026-06-30T09:00:00Z',
deleted_at: null,
author: { name: 'Juan', surname: 'García' },
+ comments_count: 4,
+ rating_average: 4.25,
+ rating_count: 8,
};
// ─── Tests ───────────────────────────────────────────────────────────────────
@@ -91,6 +96,18 @@ describe('PostCard', () => {
expect(getByText('—')).toBeTruthy();
});
+ it('renders the rounded rating average when the post has ratings', () => {
+ const { getByText } = render();
+ expect(getByText('4.3')).toBeTruthy(); // 4.25 -> toFixed(1)
+ });
+
+ it('does not render a rating when the post has no ratings', () => {
+ const { queryByText } = render(
+ ,
+ );
+ expect(queryByText('0.0')).toBeNull();
+ });
+
it('calls onPress when tapped', () => {
const onPress = jest.fn();
const { getByRole } = render();
diff --git a/__tests__/components/PostDetailScreen.test.tsx b/__tests__/components/PostDetailScreen.test.tsx
index ea8d53a..31c4f28 100644
--- a/__tests__/components/PostDetailScreen.test.tsx
+++ b/__tests__/components/PostDetailScreen.test.tsx
@@ -23,10 +23,54 @@ jest.mock('@/hooks/usePostReactions', () => ({
}),
}));
+jest.mock('@/hooks/usePostRating', () => ({
+ usePostRating: () => ({
+ myRating: null,
+ average: 0,
+ count: 0,
+ loading: false,
+ rate: jest.fn(),
+ }),
+}));
+
jest.mock('@/components/reactions', () => ({
ReactionPicker: () => null,
}));
+jest.mock('@/hooks/useComments', () => ({
+ useComments: () => ({
+ comments: [],
+ total: 0,
+ loading: false,
+ loadingMore: false,
+ hasMore: false,
+ error: null,
+ loadMore: jest.fn(),
+ refresh: jest.fn(),
+ add: jest.fn(),
+ remove: jest.fn(),
+ }),
+}));
+
+jest.mock('@/hooks/useSession', () => ({
+ useSession: () => ({ session: null, profile: null }),
+}));
+
+jest.mock('@/components/comments', () => ({
+ CommentComposer: () => null,
+ CommentsList: () => null,
+}));
+
+const mockTrackLinkClick = jest.fn().mockResolvedValue(undefined);
+jest.mock('@/lib/engagement', () => ({
+ trackLinkClick: (...args: unknown[]) => mockTrackLinkClick(...args),
+ createEngagementSink: () => jest.fn(),
+}));
+
+jest.mock('@/hooks/usePostEngagement', () => ({
+ usePostEngagement: () => ({ onScroll: jest.fn(), sessionId: 'sess-test' }),
+}));
+
jest.mock('expo-router', () => ({
useLocalSearchParams: () => ({ id: 'post-1' }),
useRouter: () => ({ back: mockBack }),
@@ -111,7 +155,19 @@ describe('PostDetailScreen', () => {
expect(screen.queryByTestId('markdown-body')).toBeNull();
});
- it('opens external_url when "Leer noticia" is tapped', () => {
+ it('tracks the click and opens external_url when "Leer noticia" is tapped', () => {
+ const openURLSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined as never);
+ mockUsePostDetail.mockReturnValue({ post: POST, loading: false, error: null });
+
+ render();
+ fireEvent.press(screen.getByLabelText('Leer noticia →'));
+
+ expect(mockTrackLinkClick).toHaveBeenCalledWith('post-1', 'sess-test');
+ expect(openURLSpy).toHaveBeenCalledWith('https://example.com/noticia');
+ });
+
+ it('still opens external_url even if tracking rejects (fire-and-no-wait)', () => {
+ mockTrackLinkClick.mockRejectedValueOnce(new Error('network'));
const openURLSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined as never);
mockUsePostDetail.mockReturnValue({ post: POST, loading: false, error: null });
diff --git a/__tests__/components/StarRating.test.tsx b/__tests__/components/StarRating.test.tsx
new file mode 100644
index 0000000..938efba
--- /dev/null
+++ b/__tests__/components/StarRating.test.tsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react-native';
+
+import { StarRating } from '@/components/ui';
+
+// ─── Mocks ──────────────────────────────────────────────────────────────────
+
+jest.mock('lucide-react-native', () => ({
+ Star: () => null,
+}));
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+describe('StarRating', () => {
+ it('renders 5 star buttons', () => {
+ const { getAllByRole } = render(
+ ,
+ );
+ expect(getAllByRole('button')).toHaveLength(5);
+ });
+
+ it('marks the chosen star as selected', () => {
+ const { getByLabelText } = render(
+ ,
+ );
+ expect(getByLabelText('Valorar con 3 estrellas').props.accessibilityState.selected).toBe(true);
+ expect(getByLabelText('Valorar con 2 estrellas').props.accessibilityState.selected).toBe(false);
+ expect(getByLabelText('Valorar con 4 estrellas').props.accessibilityState.selected).toBe(false);
+ });
+
+ it('calls onRate with the pressed star number', () => {
+ const onRate = jest.fn();
+ const { getByLabelText } = render(
+ ,
+ );
+ fireEvent.press(getByLabelText('Valorar con 4 estrellas'));
+ expect(onRate).toHaveBeenCalledWith(4);
+ });
+
+ it('shows the average and a pluralised count', () => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText('3.8 · 8 valoraciones')).toBeTruthy();
+ });
+
+ it('shows the singular label for a single rating', () => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText('5.0 · 1 valoración')).toBeTruthy();
+ });
+
+ it('shows a placeholder when there are no ratings', () => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText('Sé el primero en valorar')).toBeTruthy();
+ });
+
+ it('disables all buttons when disabled', () => {
+ const { getAllByRole } = render(
+ ,
+ );
+ getAllByRole('button').forEach((btn) =>
+ expect(btn.props.accessibilityState.disabled).toBe(true),
+ );
+ });
+});
diff --git a/__tests__/components/__snapshots__/PostCard.test.tsx.snap b/__tests__/components/__snapshots__/PostCard.test.tsx.snap
index 6a89630..963c975 100644
--- a/__tests__/components/__snapshots__/PostCard.test.tsx.snap
+++ b/__tests__/components/__snapshots__/PostCard.test.tsx.snap
@@ -84,6 +84,24 @@ exports[`PostCard matches snapshot 1`] = `
30 jun
+
+
+
+ 4.3
+
+
+
+ 4
+
+
diff --git a/__tests__/hooks/useComments.test.ts b/__tests__/hooks/useComments.test.ts
new file mode 100644
index 0000000..d643930
--- /dev/null
+++ b/__tests__/hooks/useComments.test.ts
@@ -0,0 +1,179 @@
+import { act, renderHook, waitFor } from '@testing-library/react-native';
+
+import { useComments } from '@/hooks/useComments';
+import { createComment, deleteComment, getCommentsCount, listComments } from '@/lib/comments';
+import type { CommentAuthor, CommentWithAuthor } from '@/lib/comments';
+
+// ─── Mocks ──────────────────────────────────────────────────────────────────
+
+jest.mock('@/lib/supabase', () => ({
+ supabase: { from: jest.fn(), auth: { getUser: jest.fn() } },
+}));
+
+jest.mock('@/lib/comments', () => ({
+ listComments: jest.fn(),
+ createComment: jest.fn(),
+ deleteComment: jest.fn(),
+ getCommentsCount: jest.fn(),
+ MAX_COMMENT_LENGTH: 2000,
+}));
+
+const mockList = listComments as jest.MockedFunction;
+const mockCreate = createComment as jest.MockedFunction;
+const mockDelete = deleteComment as jest.MockedFunction;
+const mockCount = getCommentsCount as jest.MockedFunction;
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+const AUTHOR: CommentAuthor = { user_id: 'u1', full_name: 'Ana', avatar_url: null };
+
+function comment(id: string, authorId = 'u1'): CommentWithAuthor {
+ return {
+ id,
+ post_id: 'p1',
+ author_id: authorId,
+ body: `body-${id}`,
+ created_at: `2026-07-08T10:0${id}:00Z`,
+ updated_at: `2026-07-08T10:0${id}:00Z`,
+ author: { user_id: authorId, full_name: 'Ana', avatar_url: null },
+ };
+}
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ mockCount.mockResolvedValue(0);
+});
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+describe('useComments', () => {
+ it('carga los comentarios iniciales al montar', async () => {
+ mockList.mockResolvedValueOnce({ rows: [comment('1')], nextCursor: null });
+ mockCount.mockResolvedValueOnce(1);
+
+ const { result } = renderHook(() => useComments('p1', AUTHOR));
+
+ expect(result.current.loading).toBe(true);
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ expect(result.current.comments).toHaveLength(1);
+ expect(result.current.total).toBe(1);
+ expect(result.current.hasMore).toBe(false);
+ expect(mockList).toHaveBeenCalledWith({ postId: 'p1', pageSize: 20 });
+ expect(mockCount).toHaveBeenCalledWith('p1');
+ });
+
+ it('expone error cuando falla la carga', async () => {
+ mockList.mockRejectedValueOnce(new Error('network'));
+
+ const { result } = renderHook(() => useComments('p1', AUTHOR));
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.error).toBe('No se pudieron cargar los comentarios.');
+ });
+
+ it('add inserta de forma optimista y sustituye por la fila del servidor', async () => {
+ mockList.mockResolvedValueOnce({ rows: [], nextCursor: null });
+ mockCount.mockResolvedValueOnce(0);
+ mockCreate.mockResolvedValueOnce({
+ id: 'server-1',
+ post_id: 'p1',
+ author_id: 'u1',
+ body: 'hola',
+ created_at: '2026-07-08T11:00:00Z',
+ updated_at: '2026-07-08T11:00:00Z',
+ });
+
+ const { result } = renderHook(() => useComments('p1', AUTHOR));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => {
+ await result.current.add('hola');
+ });
+
+ expect(result.current.comments).toHaveLength(1);
+ expect(result.current.comments[0]!.id).toBe('server-1');
+ expect(result.current.comments[0]!.author).toEqual(AUTHOR);
+ expect(result.current.total).toBe(1);
+ expect(mockCreate).toHaveBeenCalledWith({ postId: 'p1', body: 'hola' });
+ });
+
+ it('add hace rollback si el servidor falla', async () => {
+ mockList.mockResolvedValueOnce({ rows: [], nextCursor: null });
+ mockCreate.mockRejectedValueOnce(new Error('network'));
+
+ const { result } = renderHook(() => useComments('p1', AUTHOR));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => {
+ await result.current.add('hola');
+ });
+
+ expect(result.current.comments).toHaveLength(0);
+ });
+
+ it('no envía comentarios vacíos', async () => {
+ mockList.mockResolvedValueOnce({ rows: [], nextCursor: null });
+
+ const { result } = renderHook(() => useComments('p1', AUTHOR));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => {
+ await result.current.add(' ');
+ });
+
+ expect(mockCreate).not.toHaveBeenCalled();
+ });
+
+ it('remove borra de forma optimista', async () => {
+ mockList.mockResolvedValueOnce({ rows: [comment('1'), comment('2')], nextCursor: null });
+ mockCount.mockResolvedValueOnce(2);
+ mockDelete.mockResolvedValueOnce(undefined);
+
+ const { result } = renderHook(() => useComments('p1', AUTHOR));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => {
+ await result.current.remove('1');
+ });
+
+ expect(result.current.comments.map((c) => c.id)).toEqual(['2']);
+ expect(result.current.total).toBe(1);
+ expect(mockDelete).toHaveBeenCalledWith('1');
+ });
+
+ it('remove restaura el comentario si el borrado falla', async () => {
+ mockList.mockResolvedValueOnce({ rows: [comment('1'), comment('2')], nextCursor: null });
+ mockDelete.mockRejectedValueOnce(new Error('network'));
+
+ const { result } = renderHook(() => useComments('p1', AUTHOR));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => {
+ await result.current.remove('1');
+ });
+
+ expect(result.current.comments.map((c) => c.id)).toEqual(['1', '2']);
+ });
+
+ it('loadMore pagina sin duplicados', async () => {
+ mockList.mockResolvedValueOnce({
+ rows: [comment('1')],
+ nextCursor: { created_at: '2026-07-08T10:01:00Z', id: '1' },
+ });
+
+ const { result } = renderHook(() => useComments('p1', AUTHOR));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.hasMore).toBe(true);
+
+ // La segunda página devuelve '1' (duplicado) y '2' (nuevo)
+ mockList.mockResolvedValueOnce({ rows: [comment('1'), comment('2')], nextCursor: null });
+
+ await act(async () => {
+ await result.current.loadMore();
+ });
+
+ expect(result.current.comments.map((c) => c.id)).toEqual(['1', '2']);
+ expect(result.current.hasMore).toBe(false);
+ });
+});
diff --git a/__tests__/hooks/useFeed.test.ts b/__tests__/hooks/useFeed.test.ts
index 67fba13..27ffe2d 100644
--- a/__tests__/hooks/useFeed.test.ts
+++ b/__tests__/hooks/useFeed.test.ts
@@ -31,6 +31,9 @@ const BASE_POST = {
updated_at: '2026-06-30T09:00:00Z',
deleted_at: null,
author: { name: 'Juan', surname: 'García' },
+ comments_count: 0,
+ rating_average: 0,
+ rating_count: 0,
};
function makePosts(count: number) {
@@ -53,14 +56,26 @@ describe('useFeed', () => {
expect(result.current.hasMore).toBe(false);
});
- it('sets error on fetch failure', async () => {
- mockList.mockRejectedValueOnce(new Error('network error'));
+ it('shows a friendly message on failure and logs the underlying error', async () => {
+ const logged = jest.spyOn(console, 'error').mockImplementation(() => {});
+ // Un PostgrestError es un objeto plano, no un Error: es el caso que antes se tragaba
+ // el mensaje y dejaba a la UI sin nada accionable.
+ const dbError = { message: 'column post_ratings_1.rating does not exist', code: '42703' };
+ mockList.mockRejectedValueOnce(dbError);
const { result } = renderHook(() => useFeed());
await waitFor(() => expect(result.current.loading).toBe(false));
- expect(result.current.error).toBe('network error');
+ // El usuario no ve jerga de Postgres…
+ expect(result.current.error).toBe('No se pudieron cargar las noticias.');
expect(result.current.posts).toHaveLength(0);
+ // …pero el error real queda registrado para poder diagnosticarlo.
+ expect(logged).toHaveBeenCalledWith(
+ '[useFeed] column post_ratings_1.rating does not exist',
+ dbError,
+ );
+
+ logged.mockRestore();
});
it('hasMore is true when a nextCursor is returned', async () => {
diff --git a/__tests__/hooks/usePostEngagement.test.ts b/__tests__/hooks/usePostEngagement.test.ts
new file mode 100644
index 0000000..8cfabf6
--- /dev/null
+++ b/__tests__/hooks/usePostEngagement.test.ts
@@ -0,0 +1,187 @@
+import { act, renderHook } from '@testing-library/react-native';
+import {
+ AppState,
+ type AppStateStatus,
+ type NativeScrollEvent,
+ type NativeSyntheticEvent,
+} from 'react-native';
+import { useIsFocused } from '@react-navigation/native';
+
+import { usePostEngagement, type EngagementEvent } from '@/hooks/usePostEngagement';
+
+// ─── Mocks ──────────────────────────────────────────────────────────────────
+
+jest.mock('@react-navigation/native', () => ({
+ useIsFocused: jest.fn(() => true),
+}));
+
+let mockUuidCounter = 0;
+jest.mock('expo-crypto', () => ({
+ randomUUID: jest.fn(() => `sess-${++mockUuidCounter}`),
+}));
+
+const mockUseIsFocused = useIsFocused as jest.MockedFunction;
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+// Captura el handler de AppState para simular transiciones active/background.
+function spyAppState() {
+ let handler: ((state: AppStateStatus) => void) | undefined;
+ const remove = jest.fn();
+ const addSpy = jest
+ .spyOn(AppState, 'addEventListener')
+ .mockImplementation((_event, cb) => {
+ handler = cb as (state: AppStateStatus) => void;
+ return { remove } as never;
+ });
+ return {
+ addSpy,
+ remove,
+ change: (state: AppStateStatus) => act(() => handler?.(state)),
+ };
+}
+
+function scrollEvent(pct: number): NativeSyntheticEvent {
+ const layoutHeight = 100;
+ const contentHeight = 200; // scrollable = 100
+ return {
+ nativeEvent: {
+ contentOffset: { x: 0, y: pct * (contentHeight - layoutHeight) },
+ contentSize: { width: 0, height: contentHeight },
+ layoutMeasurement: { width: 0, height: layoutHeight },
+ zoomScale: 1,
+ contentInset: { top: 0, left: 0, bottom: 0, right: 0 },
+ },
+ } as NativeSyntheticEvent;
+}
+
+const HEARTBEAT = 5000;
+
+function ticks(events: EngagementEvent[]) {
+ return events.filter((e): e is Extract => e.type === 'tick');
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+beforeEach(() => {
+ jest.useFakeTimers();
+ mockUuidCounter = 0;
+ mockUseIsFocused.mockReturnValue(true);
+ (AppState as unknown as { currentState: AppStateStatus }).currentState = 'active';
+});
+
+afterEach(() => {
+ jest.clearAllMocks();
+ jest.useRealTimers();
+});
+
+describe('usePostEngagement', () => {
+ it('genera un sessionId estable durante toda la vida del hook', () => {
+ spyAppState();
+ const { result, rerender } = renderHook(() => usePostEngagement('post-1'));
+
+ const first = result.current.sessionId;
+ expect(first).toBe('sess-1');
+
+ // Un re-render (p. ej. por cambio de foco) no debe cambiar el id.
+ mockUseIsFocused.mockReturnValue(false);
+ rerender({});
+ expect(result.current.sessionId).toBe(first);
+ });
+
+ it('emite un init al montar y un tick por heartbeat', () => {
+ spyAppState();
+ const onEvent = jest.fn();
+ renderHook(() => usePostEngagement('post-1', { wordCount: 120, onEvent, heartbeatMs: HEARTBEAT }));
+
+ expect(onEvent).toHaveBeenCalledTimes(1);
+ expect(onEvent).toHaveBeenLastCalledWith(
+ expect.objectContaining({ type: 'init', postId: 'post-1', wordCount: 120, sessionId: 'sess-1' }),
+ );
+
+ act(() => jest.advanceTimersByTime(HEARTBEAT * 2));
+ const events = onEvent.mock.calls.map((c) => c[0] as EngagementEvent);
+ expect(ticks(events)).toHaveLength(2);
+ });
+
+ it('acumula focusedSeconds mientras hay foco real', () => {
+ spyAppState();
+ const onEvent = jest.fn();
+ renderHook(() => usePostEngagement('post-1', { onEvent, heartbeatMs: HEARTBEAT }));
+
+ act(() => jest.advanceTimersByTime(HEARTBEAT * 2));
+ const events = ticks(onEvent.mock.calls.map((c) => c[0] as EngagementEvent));
+ expect(events[events.length - 1]!.focusedSeconds).toBe(10);
+ });
+
+ it('no incrementa focusedSeconds en background y lo retoma al volver a active', () => {
+ const app = spyAppState();
+ const onEvent = jest.fn();
+ renderHook(() => usePostEngagement('post-1', { onEvent, heartbeatMs: HEARTBEAT }));
+
+ act(() => jest.advanceTimersByTime(HEARTBEAT)); // +5s activo
+ app.change('background');
+ act(() => jest.advanceTimersByTime(HEARTBEAT * 2)); // congelado
+ app.change('active');
+ act(() => jest.advanceTimersByTime(HEARTBEAT)); // +5s activo
+
+ const events = ticks(onEvent.mock.calls.map((c) => c[0] as EngagementEvent));
+ // Sigue emitiendo ticks en background pero el contador no avanza allí.
+ expect(events.map((e) => e.focusedSeconds)).toEqual([5, 5, 5, 10]);
+ });
+
+ it('detiene la cuenta cuando useIsFocused es false aunque AppState siga active', () => {
+ spyAppState();
+ mockUseIsFocused.mockReturnValue(false);
+ const onEvent = jest.fn();
+ renderHook(() => usePostEngagement('post-1', { onEvent, heartbeatMs: HEARTBEAT }));
+
+ act(() => jest.advanceTimersByTime(HEARTBEAT * 3));
+ const events = ticks(onEvent.mock.calls.map((c) => c[0] as EngagementEvent));
+ expect(events.every((e) => e.focusedSeconds === 0)).toBe(true);
+ });
+
+ it('maxScrollPct solo aumenta y nunca decrece', () => {
+ spyAppState();
+ const onEvent = jest.fn();
+ const { result } = renderHook(() =>
+ usePostEngagement('post-1', { onEvent, heartbeatMs: HEARTBEAT }),
+ );
+
+ act(() => result.current.onScroll(scrollEvent(0.5)));
+ act(() => result.current.onScroll(scrollEvent(0.8)));
+ act(() => result.current.onScroll(scrollEvent(0.3))); // scroll hacia arriba
+
+ act(() => jest.advanceTimersByTime(HEARTBEAT));
+ const last = ticks(onEvent.mock.calls.map((c) => c[0] as EngagementEvent)).at(-1)!;
+ expect(last.maxScrollPct).toBeCloseTo(0.8);
+ });
+
+ it('clampa maxScrollPct al rango [0, 1]', () => {
+ spyAppState();
+ const onEvent = jest.fn();
+ const { result } = renderHook(() =>
+ usePostEngagement('post-1', { onEvent, heartbeatMs: HEARTBEAT }),
+ );
+
+ act(() => result.current.onScroll(scrollEvent(3))); // overscroll
+ act(() => jest.advanceTimersByTime(HEARTBEAT));
+ const last = ticks(onEvent.mock.calls.map((c) => c[0] as EngagementEvent)).at(-1)!;
+ expect(last.maxScrollPct).toBe(1);
+ });
+
+ it('limpia timers y listener tras unmount sin emitir más eventos', () => {
+ const app = spyAppState();
+ const onEvent = jest.fn();
+ const { unmount } = renderHook(() =>
+ usePostEngagement('post-1', { onEvent, heartbeatMs: HEARTBEAT }),
+ );
+
+ unmount();
+ expect(app.remove).toHaveBeenCalledTimes(1);
+
+ onEvent.mockClear();
+ act(() => jest.advanceTimersByTime(HEARTBEAT * 3));
+ expect(onEvent).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/hooks/usePostRating.test.ts b/__tests__/hooks/usePostRating.test.ts
new file mode 100644
index 0000000..d69008d
--- /dev/null
+++ b/__tests__/hooks/usePostRating.test.ts
@@ -0,0 +1,118 @@
+import { act, renderHook, waitFor } from '@testing-library/react-native';
+
+import { usePostRating } from '@/hooks/usePostRating';
+import { getRatingState, upsertRating } from '@/lib/ratings';
+
+// ─── Mocks ──────────────────────────────────────────────────────────────────
+
+jest.mock('@/lib/supabase', () => ({
+ supabase: { from: jest.fn(), auth: { getUser: jest.fn() } },
+}));
+
+jest.mock('@/lib/ratings', () => ({
+ getRatingState: jest.fn(),
+ upsertRating: jest.fn(),
+ EMPTY_RATING: { myRating: null, average: 0, count: 0 },
+}));
+
+const mockGetRatingState = getRatingState as jest.MockedFunction;
+const mockUpsertRating = upsertRating as jest.MockedFunction;
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+beforeEach(() => jest.clearAllMocks());
+
+describe('usePostRating', () => {
+ it('loads the initial rating state on mount', async () => {
+ mockGetRatingState.mockResolvedValueOnce({ myRating: 4, average: 4.2, count: 5 });
+
+ const { result } = renderHook(() => usePostRating('post-1'));
+
+ expect(result.current.loading).toBe(true);
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ expect(result.current.myRating).toBe(4);
+ expect(result.current.average).toBe(4.2);
+ expect(result.current.count).toBe(5);
+ expect(mockGetRatingState).toHaveBeenCalledWith('post-1');
+ });
+
+ it('falls back to empty state on fetch error', async () => {
+ mockGetRatingState.mockRejectedValueOnce(new Error('network'));
+
+ const { result } = renderHook(() => usePostRating('post-1'));
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.myRating).toBeNull();
+ expect(result.current.average).toBe(0);
+ expect(result.current.count).toBe(0);
+ });
+
+ it('adds a first rating optimistically and increments the count', async () => {
+ mockGetRatingState.mockResolvedValueOnce({ myRating: null, average: 4, count: 2 });
+ mockUpsertRating.mockResolvedValueOnce(undefined);
+
+ const { result } = renderHook(() => usePostRating('post-1'));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => {
+ result.current.rate(5);
+ });
+
+ // previous sum 4*2=8, +5 → 13 over 3 ratings
+ expect(result.current.myRating).toBe(5);
+ expect(result.current.count).toBe(3);
+ expect(result.current.average).toBeCloseTo(13 / 3);
+ expect(mockUpsertRating).toHaveBeenCalledWith('post-1', 5);
+ });
+
+ it('updates an existing rating in place without changing the count', async () => {
+ mockGetRatingState.mockResolvedValueOnce({ myRating: 2, average: 3, count: 4 });
+ mockUpsertRating.mockResolvedValueOnce(undefined);
+
+ const { result } = renderHook(() => usePostRating('post-1'));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => {
+ result.current.rate(4);
+ });
+
+ // previous sum 3*4=12, -2 +4 → 14 over 4 ratings
+ expect(result.current.myRating).toBe(4);
+ expect(result.current.count).toBe(4);
+ expect(result.current.average).toBeCloseTo(3.5);
+ expect(mockUpsertRating).toHaveBeenCalledWith('post-1', 4);
+ });
+
+ it('does not upsert when reselecting the same rating', async () => {
+ mockGetRatingState.mockResolvedValueOnce({ myRating: 3, average: 3, count: 1 });
+
+ const { result } = renderHook(() => usePostRating('post-1'));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => {
+ result.current.rate(3);
+ });
+
+ expect(mockUpsertRating).not.toHaveBeenCalled();
+ expect(result.current.myRating).toBe(3);
+ expect(result.current.count).toBe(1);
+ });
+
+ it('rolls back the optimistic update on error', async () => {
+ mockGetRatingState.mockResolvedValueOnce({ myRating: null, average: 2, count: 1 });
+ mockUpsertRating.mockRejectedValueOnce(new Error('network'));
+
+ const { result } = renderHook(() => usePostRating('post-1'));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => {
+ result.current.rate(5);
+ });
+
+ expect(result.current.myRating).toBeNull();
+ expect(result.current.average).toBe(2);
+ expect(result.current.count).toBe(1);
+ expect(mockUpsertRating).toHaveBeenCalledWith('post-1', 5);
+ });
+});
diff --git a/__tests__/hooks/useSession.test.ts b/__tests__/hooks/useSession.test.tsx
similarity index 73%
rename from __tests__/hooks/useSession.test.ts
rename to __tests__/hooks/useSession.test.tsx
index 398a438..f8ffc52 100644
--- a/__tests__/hooks/useSession.test.ts
+++ b/__tests__/hooks/useSession.test.tsx
@@ -1,6 +1,7 @@
-import { renderHook, act, waitFor } from '@testing-library/react-native';
+import { Text } from 'react-native';
+import { render, renderHook, act, waitFor } from '@testing-library/react-native';
-import { useSession } from '@/hooks/useSession';
+import { SessionProvider, useSession } from '@/hooks/useSession';
// ─── Mocks ──────────────────────────────────────────────────────────────────
@@ -65,6 +66,13 @@ function setupWithSession() {
});
}
+// ─── Wrapper ─────────────────────────────────────────────────────────────────
+
+// The session state lives in the provider, so the hook is always rendered inside it.
+function wrapper({ children }: { children: React.ReactNode }) {
+ return {children};
+}
+
// ─── Tests ───────────────────────────────────────────────────────────────────
beforeEach(() => {
@@ -75,7 +83,7 @@ beforeEach(() => {
describe('useSession', () => {
it('starts with loading status', async () => {
setupNoSession();
- const { result } = await renderHook(() => useSession());
+ const { result } = await renderHook(() => useSession(), { wrapper });
expect(result.current.status).toBe('loading');
expect(result.current.session).toBeNull();
expect(result.current.profile).toBeNull();
@@ -83,7 +91,7 @@ describe('useSession', () => {
it('returns unauthenticated when no session exists', async () => {
setupNoSession();
- const { result } = await renderHook(() => useSession());
+ const { result } = await renderHook(() => useSession(), { wrapper });
await waitFor(() => expect(result.current.status).toBe('unauthenticated'));
expect(result.current.session).toBeNull();
expect(result.current.profile).toBeNull();
@@ -91,7 +99,7 @@ describe('useSession', () => {
it('returns authenticated with session and profile when session exists', async () => {
setupWithSession();
- const { result } = await renderHook(() => useSession());
+ const { result } = await renderHook(() => useSession(), { wrapper });
await waitFor(() => expect(result.current.status).toBe('authenticated'));
expect(result.current.session).toEqual({ userId: 'user-1', role: 'staff' });
expect(result.current.profile).toEqual(STAFF_PROFILE);
@@ -105,7 +113,7 @@ describe('useSession', () => {
return { data: { subscription: { unsubscribe: mockUnsubscribe } } };
});
- const { result } = await renderHook(() => useSession());
+ const { result } = await renderHook(() => useSession(), { wrapper });
await waitFor(() => expect(result.current.status).toBe('authenticated'));
await act(async () => {
@@ -118,7 +126,7 @@ describe('useSession', () => {
it('unsubscribes from auth changes on unmount', async () => {
setupNoSession();
- const { unmount } = await renderHook(() => useSession());
+ const { unmount } = await renderHook(() => useSession(), { wrapper });
await waitFor(() => expect(mockGetSession).toHaveBeenCalled());
unmount();
expect(mockUnsubscribe).toHaveBeenCalledTimes(1);
@@ -128,7 +136,7 @@ describe('useSession', () => {
setupWithSession();
mockAuthSignOut.mockResolvedValueOnce(undefined);
- const { result } = await renderHook(() => useSession());
+ const { result } = await renderHook(() => useSession(), { wrapper });
await waitFor(() => expect(result.current.status).toBe('authenticated'));
await act(async () => {
@@ -142,7 +150,7 @@ describe('useSession', () => {
setupWithSession();
mockAuthSignOut.mockRejectedValueOnce(new Error('Network request failed'));
- const { result } = await renderHook(() => useSession());
+ const { result } = await renderHook(() => useSession(), { wrapper });
await waitFor(() => expect(result.current.status).toBe('authenticated'));
await act(async () => {
@@ -160,7 +168,7 @@ describe('useSession', () => {
});
mockRegisterPushToken.mockResolvedValueOnce('ExponentPushToken[abc]');
- const { result } = await renderHook(() => useSession());
+ const { result } = await renderHook(() => useSession(), { wrapper });
await waitFor(() => expect(result.current.status).toBe('authenticated'));
await act(async () => {
@@ -172,6 +180,35 @@ describe('useSession', () => {
expect(mockRegisterPushToken).toHaveBeenCalledWith('user-1');
});
+ // Regression: resolving the session per-consumer sent every new consumer back to
+ // 'loading', which made the role guard in (app)/_layout unmount its own
+ // and loop forever (blank screen for manager/staff landing on the admin tab).
+ it('does not reset to loading when another consumer mounts', async () => {
+ setupWithSession();
+
+ function StatusProbe({ label }: { label: string }) {
+ const { status } = useSession();
+ return {status};
+ }
+
+ const { rerender, getByTestId } = render(
+
+
+ ,
+ );
+ await waitFor(() => expect(getByTestId('first').props.children).toBe('authenticated'));
+
+ rerender(
+
+
+
+ ,
+ );
+
+ expect(getByTestId('late').props.children).toBe('authenticated');
+ expect(mockGetSession).toHaveBeenCalledTimes(1);
+ });
+
it('does not register push token on non-SIGNED_IN events', async () => {
setupWithSession();
let capturedCallback: ((event: string, session: unknown) => void) | null = null;
@@ -180,7 +217,7 @@ describe('useSession', () => {
return { data: { subscription: { unsubscribe: mockUnsubscribe } } };
});
- const { result } = await renderHook(() => useSession());
+ const { result } = await renderHook(() => useSession(), { wrapper });
await waitFor(() => expect(result.current.status).toBe('authenticated'));
await act(async () => {
diff --git a/__tests__/integration/engagementDashboard.test.ts b/__tests__/integration/engagementDashboard.test.ts
new file mode 100644
index 0000000..fa79cc2
--- /dev/null
+++ b/__tests__/integration/engagementDashboard.test.ts
@@ -0,0 +1,160 @@
+/**
+ * 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,
+ // engaged (ADR-001): el manager valoró sin clicar; el staff clicó, así que no cuenta.
+ engaged_users: 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/__tests__/integration/trackEngagement.test.ts b/__tests__/integration/trackEngagement.test.ts
new file mode 100644
index 0000000..01d4873
--- /dev/null
+++ b/__tests__/integration/trackEngagement.test.ts
@@ -0,0 +1,226 @@
+/**
+ * Integration test — requires local Supabase running with functions served:
+ * npx supabase start # CI: serves functions via the edge runtime
+ * # or, locally: npx supabase functions serve track-engagement
+ *
+ * Run with: npx jest --testPathPattern="integration/trackEngagement" --no-coverage
+ *
+ * Exercises the real track-engagement Edge Function end-to-end against the local
+ * stack: array (batch) contract, JWT auth, and the append-only / accumulation
+ * UPSERT (via the apply_engagement_events RPC). The pure write logic is also
+ * covered by supabase/tests/rls/rpc_track_engagement.sql (pgTAP).
+ */
+import { createClient } from '@supabase/supabase-js';
+
+import type { Database } from '@/lib/database.types';
+
+jest.mock('@/lib/supabase', () => ({ supabase: {} }));
+
+// El edge runtime hace cold start en la primera invocación real (resuelve los
+// imports remotos de esm.sh); con `policy = "per_worker"` puede superar los 5s
+// por defecto de Jest. Damos margen a todo el fichero (tests + hooks).
+jest.setTimeout(30000);
+
+const LOCAL_URL = 'http://127.0.0.1:54321';
+const LOCAL_ANON_KEY =
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRFA0NiK7ACcShDMkTBHHAN4vqu6S25ULXF-V70J4fM';
+
+const FN_URL = `${LOCAL_URL}/functions/v1/track-engagement`;
+
+const MANAGER = { email: 'manager@nun-ibiza.dev', password: 'password123' };
+const STAFF = { email: 'staff@nun-ibiza.dev', password: 'password123' };
+const RUN_MARKER = `track_engagement_it_${Date.now()}`;
+
+const managerClient = createClient(LOCAL_URL, LOCAL_ANON_KEY);
+const staffClient = createClient(LOCAL_URL, LOCAL_ANON_KEY);
+
+type FnResponse = { status: number; body: unknown };
+
+const MAX_ATTEMPTS = 3;
+const RETRY_DELAY_MS = 500;
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+// El edge runtime recicla workers (`policy = "per_worker"`), así que una petición
+// suelta puede fallar con 5xx mientras arranca uno nuevo. El cliente real reintenta
+// los 5xx (lib/engagement/queue.ts) y aquí se hace lo mismo: sin esto, un fallo
+// transitorio se colaba en silencio y afloraba después como datos incorrectos.
+// Los 4xx son definitivos y se devuelven tal cual (los tests de 400/401 dependen de ello).
+async function callFn(events: unknown, token: string | null): Promise {
+ let last: FnResponse = { status: 0, body: null };
+
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
+ try {
+ const res = await fetch(FN_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ apikey: LOCAL_ANON_KEY,
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ body: JSON.stringify(events),
+ });
+ last = { status: res.status, body: await res.json().catch(() => null) };
+ if (res.status < 500) return last;
+ } catch (e) {
+ last = { status: 0, body: String(e) }; // error de red
+ }
+ if (attempt < MAX_ATTEMPTS) await sleep(RETRY_DELAY_MS);
+ }
+
+ return last;
+}
+
+async function sessionRow(postId: string) {
+ const { data, error } = await staffClient
+ .from('engagement_sessions')
+ .select('status, link_clicked, focused_seconds, max_scroll_pct')
+ .eq('post_id', postId)
+ .maybeSingle();
+ if (error) throw error;
+ return data;
+}
+
+describe('track-engagement Edge Function (integration)', () => {
+ let staffToken: string;
+ const postIds: string[] = [];
+
+ async function createPost(): Promise {
+ const { data, error } = await managerClient
+ .from('posts')
+ .insert({
+ author_id: authorId,
+ title: `${RUN_MARKER} ${postIds.length}`,
+ external_url: 'https://example.com/track-engagement',
+ status: 'published',
+ published_at: new Date().toISOString(),
+ })
+ .select('id')
+ .single();
+ if (error) throw error;
+ postIds.push(data!.id);
+ return data!.id;
+ }
+
+ let authorId: string;
+
+ beforeAll(async () => {
+ const mgr = await managerClient.auth.signInWithPassword(MANAGER);
+ if (mgr.error) throw mgr.error;
+ const { data: profile, error: pErr } = await managerClient
+ .from('profiles')
+ .select('id')
+ .eq('email', MANAGER.email)
+ .single();
+ if (pErr) throw pErr;
+ authorId = profile.id;
+
+ const staff = await staffClient.auth.signInWithPassword(STAFF);
+ if (staff.error) throw staff.error;
+ staffToken = staff.data.session!.access_token;
+
+ // Warm up the edge function so the cold start is paid here (covered by the
+ // 30s hook timeout) and the individual tests run against a warm worker.
+ await callFn([{ session_id: crypto.randomUUID(), post_id: crypto.randomUUID() }], staffToken);
+ });
+
+ afterAll(async () => {
+ if (postIds.length > 0) {
+ await managerClient
+ .from('posts')
+ .update({ deleted_at: new Date().toISOString() })
+ .in('id', postIds);
+ }
+ await managerClient.auth.signOut();
+ await staffClient.auth.signOut();
+ });
+
+ it('processes a batch (array) of events and creates the session as viewed', async () => {
+ const postId = await createPost();
+ const { status } = await callFn(
+ [
+ {
+ session_id: crypto.randomUUID(),
+ post_id: postId,
+ focused_seconds_delta: 0,
+ max_scroll_pct: 0,
+ },
+ ],
+ staffToken,
+ );
+ expect(status).toBe(200);
+
+ const row = await sessionRow(postId);
+ expect(row).toMatchObject({ status: 'viewed', link_clicked: false });
+ });
+
+ it('also accepts the { events: [...] } envelope', async () => {
+ const postId = await createPost();
+ const { status } = await callFn(
+ { events: [{ session_id: crypto.randomUUID(), post_id: postId }] },
+ staffToken,
+ );
+ expect(status).toBe(200);
+ expect(await sessionRow(postId)).toMatchObject({ status: 'viewed' });
+ });
+
+ it('sets clicked on link_clicked and never reverts it (append-only)', async () => {
+ const postId = await createPost();
+ const sid = crypto.randomUUID();
+
+ const click = await callFn([{ session_id: sid, post_id: postId, link_clicked: true }], staffToken);
+ expect(click.status).toBe(200);
+ expect(await sessionRow(postId)).toMatchObject({ status: 'clicked', link_clicked: true });
+
+ // A later event without a click must not revert clicked → viewed.
+ const later = await callFn(
+ [{ session_id: sid, post_id: postId, link_clicked: false, focused_seconds_delta: 3 }],
+ staffToken,
+ );
+ expect(later.status).toBe(200);
+ expect(await sessionRow(postId)).toMatchObject({ status: 'clicked', link_clicked: true });
+ });
+
+ it('accumulates focused_seconds and takes the max of max_scroll_pct; one row per post', async () => {
+ const postId = await createPost();
+ const sid = crypto.randomUUID();
+
+ const first = await callFn(
+ [{ session_id: sid, post_id: postId, focused_seconds_delta: 5, max_scroll_pct: 0.3 }],
+ staffToken,
+ );
+ expect(first.status).toBe(200);
+
+ const second = await callFn(
+ [{ session_id: sid, post_id: postId, focused_seconds_delta: 7, max_scroll_pct: 0.1 }],
+ staffToken,
+ );
+ expect(second.status).toBe(200);
+
+ const row = await sessionRow(postId);
+ expect(row).toMatchObject({ focused_seconds: 12, max_scroll_pct: 0.3 });
+
+ const { count, error } = await staffClient
+ .from('engagement_sessions')
+ .select('id', { count: 'exact', head: true })
+ .eq('post_id', postId);
+ if (error) throw error;
+ expect(count).toBe(1);
+ });
+
+ it('rejects an unauthenticated request with 401', async () => {
+ const postId = await createPost();
+ const { status } = await callFn(
+ [{ session_id: crypto.randomUUID(), post_id: postId }],
+ null,
+ );
+ expect(status).toBe(401);
+ });
+
+ it('rejects an invalid payload with 400', async () => {
+ const { status } = await callFn([{ post_id: 'not-a-uuid' }], staffToken);
+ expect(status).toBe(400);
+ });
+});
diff --git a/__tests__/lib/engagement.test.ts b/__tests__/lib/engagement.test.ts
new file mode 100644
index 0000000..0fd2037
--- /dev/null
+++ b/__tests__/lib/engagement.test.ts
@@ -0,0 +1,40 @@
+import { trackLinkClick } from '@/lib/engagement';
+import { enqueue } from '@/lib/engagement/queue';
+import { supabase } from '@/lib/supabase';
+
+// ─── Mocks ──────────────────────────────────────────────────────────────────
+
+jest.mock('@/lib/engagement/queue', () => ({
+ enqueue: jest.fn().mockResolvedValue(undefined),
+}));
+
+jest.mock('@/lib/supabase', () => ({
+ supabase: { functions: { invoke: jest.fn() } },
+}));
+
+const mockEnqueue = enqueue as jest.MockedFunction;
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+beforeEach(() => jest.clearAllMocks());
+
+describe('trackLinkClick', () => {
+ it('enqueues a link_clicked event into the shared engagement queue', async () => {
+ await trackLinkClick('post-1', 'sess-1');
+
+ expect(mockEnqueue).toHaveBeenCalledWith({
+ session_id: 'sess-1',
+ post_id: 'post-1',
+ link_clicked: true,
+ focused_seconds_delta: 0,
+ max_scroll_pct: 0,
+ client_ts: expect.any(String),
+ });
+ });
+
+ it('does not call the edge function directly (goes through the offline queue)', async () => {
+ await trackLinkClick('post-1', 'sess-1');
+
+ expect(supabase.functions.invoke).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/lib/engagement/queue.test.ts b/__tests__/lib/engagement/queue.test.ts
new file mode 100644
index 0000000..70f8be3
--- /dev/null
+++ b/__tests__/lib/engagement/queue.test.ts
@@ -0,0 +1,205 @@
+import type { EngagementPayload } from '@/lib/engagement/queue';
+
+// ─── Mocks ──────────────────────────────────────────────────────────────────
+
+const mockStore = new Map();
+
+jest.mock('@react-native-async-storage/async-storage', () => ({
+ __esModule: true,
+ default: {
+ getItem: jest.fn((k: string) => Promise.resolve(mockStore.get(k) ?? null)),
+ setItem: jest.fn((k: string, v: string) => {
+ mockStore.set(k, v);
+ return Promise.resolve();
+ }),
+ removeItem: jest.fn((k: string) => {
+ mockStore.delete(k);
+ return Promise.resolve();
+ }),
+ },
+}));
+
+const mockGetSession = jest.fn();
+const mockRefreshSession = jest.fn();
+jest.mock('@/lib/supabase', () => ({
+ supabase: {
+ auth: {
+ getSession: (...a: unknown[]) => mockGetSession(...a),
+ refreshSession: (...a: unknown[]) => mockRefreshSession(...a),
+ },
+ },
+}));
+
+jest.mock('expo-constants', () => ({
+ __esModule: true,
+ default: {
+ expoConfig: {
+ extra: { supabaseUrl: 'https://test.supabase.co', supabaseAnonKey: 'anon-key' },
+ },
+ },
+}));
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+const QUEUE_KEY = '@engagement/queue';
+
+function payload(n: number): EngagementPayload {
+ return {
+ session_id: `sess-${n}`,
+ post_id: 'post-1',
+ focused_seconds_delta: 5,
+ max_scroll_pct: 0.5,
+ client_ts: new Date(n).toISOString(),
+ };
+}
+
+function seed(items: EngagementPayload[]): void {
+ mockStore.set(QUEUE_KEY, JSON.stringify(items));
+}
+
+function stored(): EngagementPayload[] {
+ const raw = mockStore.get(QUEUE_KEY);
+ return raw ? (JSON.parse(raw) as EngagementPayload[]) : [];
+}
+
+function resp(status: number): Response {
+ return { ok: status >= 200 && status < 300, status } as unknown as Response;
+}
+
+// Módulo re-importado en cada test para resetear su estado interno (backoff, flags).
+let queue: typeof import('@/lib/engagement/queue');
+
+beforeEach(() => {
+ jest.resetModules();
+ jest.useFakeTimers();
+ mockStore.clear();
+ jest.clearAllMocks();
+ mockGetSession.mockResolvedValue({ data: { session: { access_token: 'jwt' } } });
+ mockRefreshSession.mockResolvedValue({ data: { session: null }, error: null });
+ global.fetch = jest.fn();
+ // require (no import) para reimportar el módulo con estado interno fresco tras resetModules.
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ queue = require('@/lib/engagement/queue');
+});
+
+afterEach(() => {
+ jest.clearAllTimers();
+ jest.useRealTimers();
+});
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+describe('engagement queue', () => {
+ it('persiste eventos en AsyncStorage bajo @engagement/queue en orden FIFO', async () => {
+ (global.fetch as jest.Mock).mockRejectedValue(new Error('offline'));
+
+ await queue.enqueue(payload(1));
+ await queue.enqueue(payload(2));
+
+ const persisted = stored();
+ expect(persisted).toHaveLength(2);
+ expect(persisted.map((p) => p.session_id)).toEqual(['sess-1', 'sess-2']);
+ expect(await queue.size()).toBe(2);
+ });
+
+ it('descarta los más antiguos y loguea dropped_count al superar 500', async () => {
+ (global.fetch as jest.Mock).mockRejectedValue(new Error('offline'));
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ seed(Array.from({ length: 500 }, (_, i) => payload(i)));
+
+ await queue.enqueue(payload(500));
+
+ const persisted = stored();
+ expect(persisted).toHaveLength(500);
+ expect(persisted[0]!.session_id).toBe('sess-1'); // sess-0 descartado
+ expect(persisted[499]!.session_id).toBe('sess-500');
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('descartados 1'),
+ expect.objectContaining({ dropped_count: 1 }),
+ );
+ warn.mockRestore();
+ });
+
+ it('drena la cola en lotes de ≤50 con red activa', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue(resp(200));
+ seed(Array.from({ length: 120 }, (_, i) => payload(i)));
+
+ await queue.flush();
+
+ expect(global.fetch).toHaveBeenCalledTimes(3); // 50 + 50 + 20
+ const firstBody = JSON.parse((global.fetch as jest.Mock).mock.calls[0]![1].body);
+ expect(firstBody).toHaveLength(50);
+ expect(await queue.size()).toBe(0);
+ });
+
+ it('envía Authorization Bearer con el token de la sesión', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue(resp(200));
+ seed([payload(1)]);
+
+ await queue.flush();
+
+ const [url, init] = (global.fetch as jest.Mock).mock.calls[0]!;
+ expect(url).toBe('https://test.supabase.co/functions/v1/track-engagement');
+ expect(init.headers.Authorization).toBe('Bearer jwt');
+ expect(init.headers.apikey).toBe('anon-key');
+ });
+
+ it('mantiene el lote y programa reintento con backoff 1s→2s ante error de red', async () => {
+ (global.fetch as jest.Mock).mockRejectedValue(new Error('offline'));
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
+ seed([payload(1)]);
+
+ await queue.flush();
+ expect(await queue.size()).toBe(1); // no se pierde
+ expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), 1000);
+
+ // El timer de reintento vuelve a fallar → siguiente backoff 2s.
+ await jest.advanceTimersByTimeAsync(1000);
+ expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), 2000);
+ setTimeoutSpy.mockRestore();
+ });
+
+ it('ante 401 refresca la sesión y reintenta una vez', async () => {
+ (global.fetch as jest.Mock)
+ .mockResolvedValueOnce(resp(401))
+ .mockResolvedValueOnce(resp(200));
+ seed([payload(1)]);
+
+ await queue.flush();
+
+ expect(mockRefreshSession).toHaveBeenCalledTimes(1);
+ expect(global.fetch).toHaveBeenCalledTimes(2);
+ expect(await queue.size()).toBe(0);
+ });
+
+ it('descarta el lote ante un 4xx permanente (no-401)', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue(resp(400));
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ seed([payload(1)]);
+
+ await queue.flush();
+
+ expect(mockRefreshSession).not.toHaveBeenCalled();
+ expect(await queue.size()).toBe(0); // descartado, no reintentado
+ warn.mockRestore();
+ });
+
+ it('no lanza flushes concurrentes', async () => {
+ let resolveFetch: (r: Response) => void = () => {};
+ (global.fetch as jest.Mock).mockReturnValue(
+ new Promise((r) => {
+ resolveFetch = r;
+ }),
+ );
+ seed([payload(1), payload(2)]);
+
+ const first = queue.flush();
+ const second = queue.flush(); // debe ser no-op mientras el primero está en curso
+
+ resolveFetch(resp(200));
+ await Promise.all([first, second]);
+
+ // Un único ciclo de drenaje: 2 eventos → 1 request (batch).
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/__tests__/lib/engagement/sink.test.ts b/__tests__/lib/engagement/sink.test.ts
new file mode 100644
index 0000000..8205343
--- /dev/null
+++ b/__tests__/lib/engagement/sink.test.ts
@@ -0,0 +1,85 @@
+import type { EngagementEvent } from '@/hooks/usePostEngagement';
+import { createEngagementSink } from '@/lib/engagement/sink';
+import type { EngagementPayload } from '@/lib/engagement/queue';
+
+// Cortamos la cadena de import a la cola real (y a supabase): estos tests
+// inyectan su propio sink, así que enqueue nunca se usa.
+jest.mock('@/lib/engagement/queue', () => ({ enqueue: jest.fn() }));
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function init(overrides?: Partial>): EngagementEvent {
+ return {
+ type: 'init',
+ sessionId: 'sess-1',
+ postId: 'post-1',
+ wordCount: 100,
+ startedAt: '2026-07-09T10:00:00.000Z',
+ ...overrides,
+ };
+}
+
+function tick(focusedSeconds: number, maxScrollPct = 0, at = '2026-07-09T10:00:05.000Z'): EngagementEvent {
+ return { type: 'tick', sessionId: 'sess-1', postId: 'post-1', focusedSeconds, maxScrollPct, at };
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+describe('createEngagementSink', () => {
+ it('encola un payload con delta 0 al recibir init', () => {
+ const out = jest.fn();
+ const sink = createEngagementSink(out);
+
+ sink(init());
+
+ expect(out).toHaveBeenCalledWith({
+ session_id: 'sess-1',
+ post_id: 'post-1',
+ focused_seconds_delta: 0,
+ max_scroll_pct: 0,
+ client_ts: '2026-07-09T10:00:00.000Z',
+ });
+ });
+
+ it('calcula focused_seconds_delta a partir del acumulado del hook', () => {
+ const out = jest.fn();
+ const sink = createEngagementSink(out);
+
+ sink(init());
+ sink(tick(5)); // 5 - 0
+ sink(tick(10)); // 10 - 5
+ sink(tick(10)); // 10 - 10 = 0 (background)
+
+ const deltas = out.mock.calls.map((c) => c[0].focused_seconds_delta);
+ expect(deltas).toEqual([0, 5, 5, 0]);
+ });
+
+ it('propaga max_scroll_pct y client_ts del tick', () => {
+ const out = jest.fn();
+ const sink = createEngagementSink(out);
+
+ sink(init());
+ sink(tick(5, 0.72, '2026-07-09T10:00:05.500Z'));
+
+ expect(out).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ max_scroll_pct: 0.72,
+ client_ts: '2026-07-09T10:00:05.500Z',
+ focused_seconds_delta: 5,
+ }),
+ );
+ });
+
+ it('reinicia el acumulado en cada init (nueva sesión)', () => {
+ const out = jest.fn();
+ const sink = createEngagementSink(out);
+
+ sink(init());
+ sink(tick(15));
+ sink(init({ sessionId: 'sess-2' })); // reset
+ sink(tick(5));
+
+ const last = out.mock.calls.at(-1)![0];
+ expect(last.focused_seconds_delta).toBe(5); // no 5 - 15
+ });
+});
diff --git a/__tests__/lib/engagement/sync.test.ts b/__tests__/lib/engagement/sync.test.ts
new file mode 100644
index 0000000..a23d939
--- /dev/null
+++ b/__tests__/lib/engagement/sync.test.ts
@@ -0,0 +1,57 @@
+import type { NetInfoState } from '@react-native-community/netinfo';
+
+import { startEngagementSync } from '@/lib/engagement/sync';
+import { flush } from '@/lib/engagement/queue';
+
+// ─── Mocks ──────────────────────────────────────────────────────────────────
+
+let netInfoHandler: ((state: NetInfoState) => void) | undefined;
+const mockUnsubscribe = jest.fn();
+const mockAddEventListener = jest.fn((cb: (state: NetInfoState) => void) => {
+ netInfoHandler = cb;
+ return mockUnsubscribe;
+});
+
+jest.mock('@react-native-community/netinfo', () => ({
+ __esModule: true,
+ default: { addEventListener: (cb: (state: NetInfoState) => void) => mockAddEventListener(cb) },
+}));
+
+jest.mock('@/lib/engagement/queue', () => ({ flush: jest.fn() }));
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function state(isConnected: boolean, isInternetReachable: boolean | null): NetInfoState {
+ return { isConnected, isInternetReachable } as NetInfoState;
+}
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ netInfoHandler = undefined;
+});
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+describe('startEngagementSync', () => {
+ it('vacía la cola al recuperar conectividad real', () => {
+ startEngagementSync();
+ netInfoHandler!(state(true, true));
+
+ expect(flush).toHaveBeenCalledTimes(1);
+ });
+
+ it('no vacía si no hay conexión o internet no es alcanzable', () => {
+ startEngagementSync();
+
+ netInfoHandler!(state(false, false));
+ netInfoHandler!(state(true, false));
+ netInfoHandler!(state(true, null));
+
+ expect(flush).not.toHaveBeenCalled();
+ });
+
+ it('devuelve la función de desuscripción de NetInfo', () => {
+ const unsubscribe = startEngagementSync();
+ expect(unsubscribe).toBe(mockUnsubscribe);
+ });
+});
diff --git a/__tests__/lib/engagementQueries.test.ts b/__tests__/lib/engagementQueries.test.ts
new file mode 100644
index 0000000..aaa61e5
--- /dev/null
+++ b/__tests__/lib/engagementQueries.test.ts
@@ -0,0 +1,149 @@
+import type { SupabaseClient } from '@supabase/supabase-js';
+
+import { listPostEngagement } from '@/lib/supabase/queries/engagement';
+import type { Database } from '@/lib/database.types';
+
+jest.mock('@/lib/supabase', () => ({ supabase: {} }));
+
+type DailyRow = {
+ post_id: string;
+ unique_readers: number | null;
+ unique_clicks: number | null;
+ avg_rating: number | null;
+ total_ratings: number | null;
+ total_reactions: number | null;
+ engaged_users: number | null;
+ avg_seconds: number | null;
+ avg_scroll: number | null;
+};
+
+const gte = jest.fn();
+const inFilter = jest.fn();
+
+function fakeClient(daily: DailyRow[], posts: { id: string; title: string }[]) {
+ gte.mockResolvedValue({ data: daily, error: null });
+ inFilter.mockResolvedValue({ data: posts, error: null });
+
+ return {
+ from: (table: string) =>
+ table === 'post_engagement_daily'
+ ? { select: () => ({ gte }) }
+ : { select: () => ({ in: inFilter }) },
+ } as unknown as SupabaseClient;
+}
+
+beforeEach(() => jest.clearAllMocks());
+
+describe('listPostEngagement', () => {
+ it('aggregates a post across days with weighted averages', async () => {
+ const client = fakeClient(
+ [
+ {
+ post_id: 'p1',
+ unique_readers: 2,
+ unique_clicks: 1,
+ avg_rating: 4,
+ total_ratings: 2,
+ total_reactions: 1,
+ engaged_users: 1,
+ avg_seconds: 15,
+ avg_scroll: 0.6,
+ },
+ {
+ post_id: 'p1',
+ unique_readers: 3,
+ unique_clicks: 2,
+ avg_rating: 2,
+ total_ratings: 1,
+ total_reactions: 2,
+ engaged_users: 2,
+ avg_seconds: 5,
+ avg_scroll: 0.2,
+ },
+ ],
+ [{ id: 'p1', title: 'Noticia 1' }],
+ );
+
+ const [row] = await listPostEngagement({ client });
+
+ expect(row).toMatchObject({
+ post_id: 'p1',
+ title: 'Noticia 1',
+ unique_readers: 5, // 2 + 3
+ unique_clicks: 3, // 1 + 2
+ total_reactions: 3, // 1 + 2
+ // La vista imputa cada usuario engaged a su primer día, así que sumar no dobla.
+ engaged_users: 3, // 1 + 2
+ });
+ expect(row!.click_rate).toBeCloseTo(3 / 5);
+ // avg_rating pondera por nº de valoraciones: (4*2 + 2*1) / 3
+ expect(row!.avg_rating).toBeCloseTo(10 / 3);
+ // las señales opcionales ponderan por nº de sesiones: (15*2 + 5*3) / 5
+ expect(row!.avg_seconds).toBeCloseTo(9);
+ expect(row!.avg_scroll).toBeCloseTo((0.6 * 2 + 0.2 * 3) / 5);
+ });
+
+ it('leaves click_rate and avg_rating as null when there is nothing to divide by', async () => {
+ const client = fakeClient(
+ [
+ {
+ post_id: 'p1',
+ unique_readers: 0,
+ unique_clicks: 0,
+ avg_rating: null,
+ total_ratings: 0,
+ total_reactions: 2,
+ engaged_users: 0,
+ avg_seconds: null,
+ avg_scroll: null,
+ },
+ ],
+ [{ id: 'p1', title: 'Sin lectores' }],
+ );
+
+ const [row] = await listPostEngagement({ client });
+
+ expect(row).toMatchObject({
+ unique_readers: 0,
+ click_rate: null,
+ avg_rating: null,
+ avg_seconds: null,
+ total_reactions: 2,
+ });
+ });
+
+ it('sorts by unique_clicks descending', async () => {
+ const base = {
+ unique_readers: 10,
+ avg_rating: null,
+ total_ratings: 0,
+ total_reactions: 0,
+ engaged_users: 0,
+ avg_seconds: null,
+ avg_scroll: null,
+ };
+ const client = fakeClient(
+ [
+ { post_id: 'p1', unique_clicks: 1, ...base },
+ { post_id: 'p2', unique_clicks: 7, ...base },
+ { post_id: 'p3', unique_clicks: 3, ...base },
+ ],
+ [
+ { id: 'p1', title: 'Uno' },
+ { id: 'p2', title: 'Dos' },
+ { id: 'p3', title: 'Tres' },
+ ],
+ );
+
+ const rows = await listPostEngagement({ client });
+
+ expect(rows.map((r) => r.post_id)).toEqual(['p2', 'p3', 'p1']);
+ });
+
+ it('returns an empty list without querying posts when there is no activity', async () => {
+ const client = fakeClient([], []);
+
+ await expect(listPostEngagement({ client })).resolves.toEqual([]);
+ expect(inFilter).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/lib/errors.test.ts b/__tests__/lib/errors.test.ts
new file mode 100644
index 0000000..ac54459
--- /dev/null
+++ b/__tests__/lib/errors.test.ts
@@ -0,0 +1,55 @@
+import { errorMessage, reportError } from '@/lib/errors';
+
+// PostgrestError (supabase-js) es un objeto plano, no una subclase de Error: ese es
+// justo el caso que antes se tragaba el mensaje real y dejaba solo el texto genérico.
+const POSTGREST_ERROR = {
+ message: 'column post_ratings_1.rating does not exist',
+ code: '42703',
+ details: null,
+ hint: null,
+};
+
+describe('errorMessage', () => {
+ it('reads the message from a real Error', () => {
+ expect(errorMessage(new Error('boom'))).toBe('boom');
+ });
+
+ it('reads the message from a plain Supabase error object', () => {
+ expect(errorMessage(POSTGREST_ERROR)).toBe('column post_ratings_1.rating does not exist');
+ });
+
+ it('returns null when there is no usable message', () => {
+ expect(errorMessage(null)).toBeNull();
+ expect(errorMessage('boom')).toBeNull();
+ expect(errorMessage({ code: '42703' })).toBeNull();
+ expect(errorMessage(new Error(''))).toBeNull();
+ });
+});
+
+describe('reportError', () => {
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ afterEach(() => spy.mockClear());
+ afterAll(() => spy.mockRestore());
+
+ it('shows the user-facing message, never the database jargon', () => {
+ expect(reportError('useFeed', POSTGREST_ERROR, 'No se pudieron cargar las noticias.')).toBe(
+ 'No se pudieron cargar las noticias.',
+ );
+ });
+
+ it('logs the underlying error so the failure is diagnosable', () => {
+ reportError('useFeed', POSTGREST_ERROR, 'No se pudieron cargar las noticias.');
+
+ expect(spy).toHaveBeenCalledWith(
+ '[useFeed] column post_ratings_1.rating does not exist',
+ POSTGREST_ERROR,
+ );
+ });
+
+ it('still logs when the error carries no message', () => {
+ reportError('useFeed', {}, 'No se pudieron cargar las noticias.');
+
+ expect(spy).toHaveBeenCalledWith('[useFeed] error desconocido', {});
+ });
+});
diff --git a/app/(app)/(tabs)/admin/_layout.tsx b/app/(app)/(tabs)/admin/_layout.tsx
index 59c6e54..0600ac0 100644
--- a/app/(app)/(tabs)/admin/_layout.tsx
+++ b/app/(app)/(tabs)/admin/_layout.tsx
@@ -1,5 +1,34 @@
-import { Stack } from 'expo-router';
+import { useEffect } from 'react';
+import { Stack, useRouter, useSegments } from 'expo-router';
+
+import { useSession } from '@/hooks/useSession';
+
+// Staff can never enter the admin section; managers may reach post management and
+// the (read-only) user list, but not the admin panel index — invitations and role
+// changes stay admin-only.
+function canEnter(role: string | undefined, adminRoute: string | undefined): boolean {
+ if (role === 'admin') return true;
+ if (role === 'manager') return adminRoute === 'posts' || adminRoute === 'users';
+ return false;
+}
export default function AdminLayout() {
+ const { session } = useSession();
+ const router = useRouter();
+ const segments = useSegments() as string[];
+
+ // Route shape: ['(app)', '(tabs)', 'admin', , ...]
+ const allowed = canEnter(session?.role, segments[3]);
+ const blocked = Boolean(session) && !allowed;
+
+ // The tab navigator stays mounted while this fires, so the replace lands on the
+ // Tablón tab and unmounts this layout. Using here would re-run its
+ // router.replace on every render (its useFocusEffect callback is not memoized).
+ useEffect(() => {
+ if (blocked) router.replace('/(app)/(tabs)/tablon');
+ }, [blocked, router]);
+
+ if (blocked) return null;
+
return ;
}
diff --git a/app/(app)/(tabs)/admin/engagement/index.tsx b/app/(app)/(tabs)/admin/engagement/index.tsx
new file mode 100644
index 0000000..831ab14
--- /dev/null
+++ b/app/(app)/(tabs)/admin/engagement/index.tsx
@@ -0,0 +1,101 @@
+import { ActivityIndicator, FlatList, RefreshControl } from 'react-native';
+import { Redirect, Stack } from 'expo-router';
+import { SafeAreaView } from 'react-native-safe-area-context';
+
+import { Card, Text, View } from '@/components/ui';
+import { usePostEngagementMetrics } from '@/hooks/usePostEngagementMetrics';
+import { useSession } from '@/hooks/useSession';
+import { DEFAULT_DAYS, type PostEngagement } from '@/lib/supabase/queries/engagement';
+
+function formatPct(value: number | null): string {
+ if (value === null) return '—';
+ return `${Math.round(value * 100)}%`;
+}
+
+function formatRating(value: number | null): string {
+ if (value === null) return '—';
+ return value.toFixed(1);
+}
+
+function Metric({ label, value }: { label: string; value: string }) {
+ return (
+
+ {value}
+ {label}
+
+ );
+}
+
+function EngagementRow({ row }: { row: PostEngagement }) {
+ return (
+
+
+ {row.title}
+
+
+
+
+
+
+
+ {/* engaged (ADR-001): interactuaron pero no llegaron al enlace externo. */}
+
+ {row.unique_readers} {row.unique_readers === 1 ? 'lector único' : 'lectores únicos'} ·{' '}
+ {row.engaged_users} {row.engaged_users === 1 ? 'interactuó' : 'interactuaron'} sin clicar
+
+
+ );
+}
+
+export default function EngagementScreen() {
+ const { session } = useSession();
+ const { rows, loading, error, refresh } = usePostEngagementMetrics();
+
+ // Gate de UX: la seguridad real la impone Postgres (la vista lleva el guard
+ // is_manager(), así que a staff le devolvería 0 filas aunque entrase por URL).
+ if (session && session.role === 'staff') {
+ return ;
+ }
+
+ const isInitialLoading = loading && rows.length === 0;
+
+ return (
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {isInitialLoading ? (
+
+
+
+ ) : (
+ item.post_id}
+ renderItem={({ item }) => }
+ refreshControl={}
+ ListHeaderComponent={
+
+ Últimos {DEFAULT_DAYS} días · se actualiza cada hora
+
+ }
+ ListEmptyComponent={
+ error ? null : (
+
+
+ Todavía no hay actividad registrada en los últimos {DEFAULT_DAYS} días.
+
+
+ )
+ }
+ contentContainerClassName="pb-8"
+ />
+ )}
+
+ );
+}
diff --git a/app/(app)/(tabs)/admin/index.tsx b/app/(app)/(tabs)/admin/index.tsx
index 9f09c7f..e5f7cbf 100644
--- a/app/(app)/(tabs)/admin/index.tsx
+++ b/app/(app)/(tabs)/admin/index.tsx
@@ -83,6 +83,12 @@ export default function AdminPanel() {
onPress={() => router.push('/(app)/(tabs)/admin/users')}
className="w-full"
/>
+