From 7989905ae7c3dee2e0c5bf9354f5da8e351b266d Mon Sep 17 00:00:00 2001 From: Pedro Gava Date: Wed, 8 Jul 2026 20:06:25 +0200 Subject: [PATCH 01/16] =?UTF-8?q?feat(db):=20align=20post=5Fcomments=20sch?= =?UTF-8?q?ema=20with=20N03=20spec=20=E2=80=94=20body=20CHECK=20+=20pagina?= =?UTF-8?q?tion=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...708000000_alter_post_comments_n03_schema.sql | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 supabase/migrations/20260708000000_alter_post_comments_n03_schema.sql diff --git a/supabase/migrations/20260708000000_alter_post_comments_n03_schema.sql b/supabase/migrations/20260708000000_alter_post_comments_n03_schema.sql new file mode 100644 index 0000000..8a1b46b --- /dev/null +++ b/supabase/migrations/20260708000000_alter_post_comments_n03_schema.sql @@ -0,0 +1,17 @@ +-- Migration: N03-02-01 — Align post_comments scaffold with N03 spec (issue #164) +-- Precondition: 20260618100000 (tabla post_comments + trigger updated_at existen) +-- Note: FK author_id se mantiene en auth.users(id) — las RLS policies (author_id = +-- auth.uid()) siguen siendo válidas; el cambio a profiles(id) queda para N03-02-03. + +-- 1. CHECK de longitud de body (1..2000) +alter table public.post_comments + add constraint post_comments_body_length + check (char_length(body) between 1 and 2000); + +-- 2. Índice de paginación cursor-based (post_id, created_at desc) +-- reemplaza al índice simple (post_id) del scaffold. +drop index if exists public.post_comments_post_id_idx; +create index post_comments_post_id_created_at_idx + on public.post_comments (post_id, created_at desc); + +-- Índice (author_id), trigger updated_at, RLS y grants ya existen (20260618100000). From 73ac3313c746de0f553e2a2fe4b0010b47e4d195 Mon Sep 17 00:00:00 2001 From: Pedro Gava Date: Wed, 8 Jul 2026 20:18:11 +0200 Subject: [PATCH 02/16] fix(db): rename post_comments migration to avoid 20260708000000 version collision --- ...hema.sql => 20260708200000_alter_post_comments_n03_schema.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename supabase/migrations/{20260708000000_alter_post_comments_n03_schema.sql => 20260708200000_alter_post_comments_n03_schema.sql} (100%) diff --git a/supabase/migrations/20260708000000_alter_post_comments_n03_schema.sql b/supabase/migrations/20260708200000_alter_post_comments_n03_schema.sql similarity index 100% rename from supabase/migrations/20260708000000_alter_post_comments_n03_schema.sql rename to supabase/migrations/20260708200000_alter_post_comments_n03_schema.sql From 6f1a7a2adf169285c4c7557bb53241e92b9d5189 Mon Sep 17 00:00:00 2001 From: Pedro Gava Date: Wed, 8 Jul 2026 20:42:09 +0200 Subject: [PATCH 03/16] =?UTF-8?q?feat(comments):=20mobile=20UI=20to=20crea?= =?UTF-8?q?te/list=20post=20comments=20(paginated)=20=E2=80=94=20#165?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __tests__/components/CommentComposer.test.tsx | 46 +++++ .../components/PostDetailScreen.test.tsx | 23 +++ __tests__/hooks/useComments.test.ts | 167 +++++++++++++++++ app/(app)/(tabs)/tablon/[id].tsx | 42 ++++- components/comments/CommentComposer.tsx | 51 +++++ components/comments/CommentsList.tsx | 143 ++++++++++++++ components/comments/index.ts | 2 + hooks/useComments.ts | 176 ++++++++++++++++++ lib/comments.ts | 117 ++++++++++++ 9 files changed, 766 insertions(+), 1 deletion(-) create mode 100644 __tests__/components/CommentComposer.test.tsx create mode 100644 __tests__/hooks/useComments.test.ts create mode 100644 components/comments/CommentComposer.tsx create mode 100644 components/comments/CommentsList.tsx create mode 100644 components/comments/index.ts create mode 100644 hooks/useComments.ts create mode 100644 lib/comments.ts 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/PostDetailScreen.test.tsx b/__tests__/components/PostDetailScreen.test.tsx index ea8d53a..c75ab1a 100644 --- a/__tests__/components/PostDetailScreen.test.tsx +++ b/__tests__/components/PostDetailScreen.test.tsx @@ -27,6 +27,29 @@ jest.mock('@/components/reactions', () => ({ ReactionPicker: () => null, })); +jest.mock('@/hooks/useComments', () => ({ + useComments: () => ({ + comments: [], + 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, +})); + jest.mock('expo-router', () => ({ useLocalSearchParams: () => ({ id: 'post-1' }), useRouter: () => ({ back: mockBack }), diff --git a/__tests__/hooks/useComments.test.ts b/__tests__/hooks/useComments.test.ts new file mode 100644 index 0000000..2eab494 --- /dev/null +++ b/__tests__/hooks/useComments.test.ts @@ -0,0 +1,167 @@ +import { act, renderHook, waitFor } from '@testing-library/react-native'; + +import { useComments } from '@/hooks/useComments'; +import { createComment, deleteComment, 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(), + MAX_COMMENT_LENGTH: 2000, +})); + +const mockList = listComments as jest.MockedFunction; +const mockCreate = createComment as jest.MockedFunction; +const mockDelete = deleteComment 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()); + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('useComments', () => { + it('carga los comentarios iniciales al montar', async () => { + mockList.mockResolvedValueOnce({ rows: [comment('1')], nextCursor: null }); + + 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.hasMore).toBe(false); + expect(mockList).toHaveBeenCalledWith({ postId: 'p1', pageSize: 20 }); + }); + + 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 }); + 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(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 }); + 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(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/app/(app)/(tabs)/tablon/[id].tsx b/app/(app)/(tabs)/tablon/[id].tsx index fc17932..24fb33e 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,12 @@ import Markdown from 'react-native-markdown-display'; import { usePostDetail } from '@/hooks/usePostDetail'; import { usePostReactions } from '@/hooks/usePostReactions'; +import { useComments } from '@/hooks/useComments'; +import { useSession } from '@/hooks/useSession'; import { Button, 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 ( @@ -42,6 +47,26 @@ export default function PostDetailScreen() { const router = useRouter(); const { post, loading, error } = usePostDetail(id); const { myReaction, counts, loading: reactionsLoading, toggle } = usePostReactions(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, + loading: commentsLoading, + loadingMore: commentsLoadingMore, + hasMore: commentsHasMore, + loadMore: loadMoreComments, + add: addComment, + remove: removeComment, + } = useComments(id ?? '', currentUser); // 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. @@ -60,7 +85,7 @@ export default function PostDetailScreen() { ) : null} {!loading && post ? ( - + {post.cover_image_url ? ( {post.body} ) : null} + + + Comentarios + + + ) : null} diff --git a/components/comments/CommentComposer.tsx b/components/comments/CommentComposer.tsx new file mode 100644 index 0000000..058ecb4 --- /dev/null +++ b/components/comments/CommentComposer.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; +import { Keyboard, TextInput, View } from 'react-native'; + +import { Button, Text } from '@/components/ui'; +import { MAX_COMMENT_LENGTH } from '@/lib/comments'; + +type Props = { + onSubmit: (body: string) => void; + submitting?: boolean; +}; + +export function CommentComposer({ onSubmit, submitting }: Props) { + const [body, setBody] = useState(''); + const trimmed = body.trim(); + const canSubmit = trimmed.length >= 1 && trimmed.length <= MAX_COMMENT_LENGTH && !submitting; + + function handleSubmit() { + if (!canSubmit) return; + onSubmit(trimmed); + setBody(''); + Keyboard.dismiss(); + } + + return ( + + + + + {body.length}/{MAX_COMMENT_LENGTH} + +