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" /> +