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/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..e44fee4 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'); + 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..26e6077 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) { 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__/lib/engagement.test.ts b/__tests__/lib/engagement.test.ts new file mode 100644 index 0000000..7ce1022 --- /dev/null +++ b/__tests__/lib/engagement.test.ts @@ -0,0 +1,34 @@ +import { trackLinkClick } from '@/lib/engagement'; +import { supabase } from '@/lib/supabase'; + +// ─── Mocks ────────────────────────────────────────────────────────────────── + +jest.mock('@/lib/supabase', () => ({ + supabase: { functions: { invoke: jest.fn() } }, +})); + +const mockInvoke = supabase.functions.invoke as jest.MockedFunction< + typeof supabase.functions.invoke +>; + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +beforeEach(() => jest.clearAllMocks()); + +describe('trackLinkClick', () => { + it('invokes track-engagement with post_id and link_clicked', async () => { + mockInvoke.mockResolvedValueOnce({ data: { data: {} }, error: null }); + + await trackLinkClick('post-1'); + + expect(mockInvoke).toHaveBeenCalledWith('track-engagement', { + body: { post_id: 'post-1', link_clicked: true }, + }); + }); + + it('throws when the edge function returns an error', async () => { + mockInvoke.mockResolvedValueOnce({ data: null, error: new Error('boom') }); + + await expect(trackLinkClick('post-1')).rejects.toThrow('boom'); + }); +}); 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/app/(app)/(tabs)/tablon/[id].tsx b/app/(app)/(tabs)/tablon/[id].tsx index fc17932..4300d7e 100644 --- a/app/(app)/(tabs)/tablon/[id].tsx +++ b/app/(app)/(tabs)/tablon/[id].tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { Linking, ScrollView, View } from 'react-native'; import { Image } from 'expo-image'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; @@ -5,8 +6,15 @@ import Markdown from 'react-native-markdown-display'; import { usePostDetail } from '@/hooks/usePostDetail'; import { usePostReactions } from '@/hooks/usePostReactions'; -import { Button, Text } from '@/components/ui'; +import { usePostRating } from '@/hooks/usePostRating'; +import { useComments } from '@/hooks/useComments'; +import { useSession } from '@/hooks/useSession'; +import { usePostEngagement } from '@/hooks/usePostEngagement'; +import { createEngagementSink, trackLinkClick } from '@/lib/engagement'; +import { Button, StarRating, Text } from '@/components/ui'; import { ReactionPicker } from '@/components/reactions'; +import { CommentComposer, CommentsList } from '@/components/comments'; +import type { CommentAuthor } from '@/lib/comments'; function getCoverUrl(url: string): string { return ( @@ -15,6 +23,12 @@ function getCoverUrl(url: string): string { ); } +// Host only, without www. — regex-based so it doesn't rely on RN's partial URL impl. +function getDomain(url: string): string { + const match = url.match(/^https?:\/\/([^/]+)/i); + return match ? match[1]!.replace(/^www\./, '') : url; +} + function formatRelativeTime(isoDate: string): string { const diff = Date.now() - new Date(isoDate).getTime(); const minutes = Math.floor(diff / 60_000); @@ -42,9 +56,41 @@ export default function PostDetailScreen() { const router = useRouter(); const { post, loading, error } = usePostDetail(id); const { myReaction, counts, loading: reactionsLoading, toggle } = usePostReactions(id ?? ''); - - // Punto de inserción para el futuro tracker de engagement (EPIC-N04, ADR-001): - // aquí se registrará el evento `view` una vez cargado `post`. No implementado en este issue. + const { + myRating, + average: ratingAverage, + count: ratingCount, + loading: ratingLoading, + rate, + } = usePostRating(id ?? ''); + const { session, profile } = useSession(); + + const currentUser = useMemo(() => { + if (!session) return null; + return { + user_id: session.userId, + full_name: [profile?.name, profile?.surname].filter(Boolean).join(' ') || null, + avatar_url: profile?.avatar_url ?? null, + }; + }, [session, profile?.name, profile?.surname, profile?.avatar_url]); + + const { + comments, + total: commentsTotal, + loading: commentsLoading, + loadingMore: commentsLoadingMore, + hasMore: commentsHasMore, + loadMore: loadMoreComments, + add: addComment, + remove: removeComment, + } = useComments(id ?? '', currentUser); + + // Engagement (EPIC-N04, ADR-001): un sink por apertura de pantalla que encola los + // heartbeats hacia la Edge Function track-engagement. El evento `init` (fila `viewed`) + // se emite al montar; `onScroll` alimenta el scroll monótono al ScrollView del post. + // El sink se auto-reinicia en cada `init`, así que no depende de `id`. + const onEngagementEvent = useMemo(() => createEngagementSink(), []); + const { onScroll } = usePostEngagement(id ?? '', { onEvent: onEngagementEvent }); return ( @@ -60,7 +106,12 @@ export default function PostDetailScreen() { ) : null} {!loading && post ? ( - + {post.cover_image_url ? ( -