Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7989905
feat(db): align post_comments schema with N03 spec — body CHECK + pag…
Piedra-1988 Jul 8, 2026
27c6d7f
Merge remote-tracking branch 'origin/develop' into task/n03-02-01-pos…
Piedra-1988 Jul 8, 2026
73ac331
fix(db): rename post_comments migration to avoid 20260708000000 versi…
Piedra-1988 Jul 8, 2026
e48751c
Merge pull request #214 from CodeCrafters-ES/task/n03-02-01-post-comm…
Piedra-1988 Jul 8, 2026
6f1a7a2
feat(comments): mobile UI to create/list post comments (paginated) — …
Piedra-1988 Jul 8, 2026
92798aa
Merge pull request #215 from CodeCrafters-ES/task/n03-02-02-mobile-co…
Piedra-1988 Jul 8, 2026
2f674f9
test(rls): add post_comments INSERT-negative case + align ADR-002 pol…
Piedra-1988 Jul 8, 2026
4b6294a
Merge pull request #216 from CodeCrafters-ES/task/n03-02-03-comments-rls
Piedra-1988 Jul 8, 2026
518d16c
feat(comments): post comment count aggregate + finalize RLS moderation
Piedra-1988 Jul 8, 2026
bf747fd
Merge pull request #217 from CodeCrafters-ES/feat/n03-02-comments
Piedra-1988 Jul 8, 2026
5383375
feat(ratings): add StarRating UI with optimistic idempotent upsert (#…
Azfe Jul 9, 2026
a77f028
test(ratings): cover usePostRating and StarRating (#169)
Azfe Jul 9, 2026
b413f9a
Merge branch 'develop' into task/n03-03-02-star-rating-ui
Azfe Jul 9, 2026
4ebc097
Merge pull request #218 from CodeCrafters-ES/task/n03-03-02-star-rati…
Azfe Jul 9, 2026
8e1a6fd
feat(ratings): formalize post_ratings RLS policy names to _own (#168)
Azfe Jul 9, 2026
32a4659
Merge pull request #219 from CodeCrafters-ES/task/n03-03-03-rls-post-…
Azfe Jul 9, 2026
dd5b237
feat(ratings): show post rating average in the feed (#159)
Azfe Jul 9, 2026
cad9229
test(ratings): cover feed rating average in PostCard and useFeed (#159)
Azfe Jul 9, 2026
e739a24
Merge pull request #220 from CodeCrafters-ES/feat/n03-03-ratings
Azfe Jul 9, 2026
6486a20
feat(engagement): track link_clicked before opening external link (#170)
Azfe Jul 9, 2026
0e4ea77
Merge pull request #221 from CodeCrafters-ES/task/n03-04-01-link-clic…
Azfe Jul 9, 2026
9c399b7
feat(engagement): add usePostEngagement hook (#176)
Piedra-1988 Jul 9, 2026
0dbc784
Merge pull request #222 from CodeCrafters-ES/task/n04-01-01-use-post-…
Piedra-1988 Jul 9, 2026
0fcb857
feat(engagement): heartbeats + offline queue (#177)
Piedra-1988 Jul 9, 2026
0f7bb67
Merge pull request #223 from CodeCrafters-ES/task/n04-01-02-heartbeat…
Piedra-1988 Jul 9, 2026
73d5e01
feat(engagement): wire post engagement tracking into UI (#173)
Piedra-1988 Jul 9, 2026
6185038
Merge pull request #224 from CodeCrafters-ES/feat/n04-01-engagement-c…
Piedra-1988 Jul 9, 2026
8eda107
feat(engagement): replace engagement_sessions with reading-session mo…
Piedra-1988 Jul 9, 2026
ddd67b4
fix(docs): satisfy markdownlint MD032 in ADR-0003
Piedra-1988 Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions __tests__/components/CommentComposer.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<CommentComposer onSubmit={onSubmit} />);

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(<CommentComposer onSubmit={onSubmit} />);

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(<CommentComposer onSubmit={onSubmit} />);

fireEvent.changeText(getByLabelText('Nuevo comentario'), ' ');
fireEvent.press(getByLabelText('Comentar'));

expect(onSubmit).not.toHaveBeenCalled();
});

it('muestra el contador de caracteres', () => {
const { getByText, getByLabelText } = render(<CommentComposer onSubmit={jest.fn()} />);

fireEvent.changeText(getByLabelText('Nuevo comentario'), 'abc');
expect(getByText('3/2000')).toBeTruthy();
});
});
17 changes: 17 additions & 0 deletions __tests__/components/PostCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ jest.mock('expo-image', () => {

jest.mock('lucide-react-native', () => ({
ExternalLink: () => null,
MessageCircle: () => null,
Star: () => null,
}));

jest.mock('@/hooks/usePostReactions', () => ({
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -91,6 +96,18 @@ describe('PostCard', () => {
expect(getByText('—')).toBeTruthy();
});

it('renders the rounded rating average when the post has ratings', () => {
const { getByText } = render(<PostCard post={POST} onPress={jest.fn()} />);
expect(getByText('4.3')).toBeTruthy(); // 4.25 -> toFixed(1)
});

it('does not render a rating when the post has no ratings', () => {
const { queryByText } = render(
<PostCard post={{ ...POST, rating_average: 0, rating_count: 0 }} onPress={jest.fn()} />,
);
expect(queryByText('0.0')).toBeNull();
});

it('calls onPress when tapped', () => {
const onPress = jest.fn();
const { getByRole } = render(<PostCard post={POST} onPress={onPress} />);
Expand Down
58 changes: 57 additions & 1 deletion __tests__/components/PostDetailScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down Expand Up @@ -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(<PostDetailScreen />);
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 });

Expand Down
69 changes: 69 additions & 0 deletions __tests__/components/StarRating.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<StarRating value={null} average={0} count={0} onRate={jest.fn()} />,
);
expect(getAllByRole('button')).toHaveLength(5);
});

it('marks the chosen star as selected', () => {
const { getByLabelText } = render(
<StarRating value={3} average={3} count={1} onRate={jest.fn()} />,
);
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(
<StarRating value={null} average={0} count={0} onRate={onRate} />,
);
fireEvent.press(getByLabelText('Valorar con 4 estrellas'));
expect(onRate).toHaveBeenCalledWith(4);
});

it('shows the average and a pluralised count', () => {
const { getByText } = render(
<StarRating value={2} average={3.75} count={8} onRate={jest.fn()} />,
);
expect(getByText('3.8 · 8 valoraciones')).toBeTruthy();
});

it('shows the singular label for a single rating', () => {
const { getByText } = render(
<StarRating value={5} average={5} count={1} onRate={jest.fn()} />,
);
expect(getByText('5.0 · 1 valoración')).toBeTruthy();
});

it('shows a placeholder when there are no ratings', () => {
const { getByText } = render(
<StarRating value={null} average={0} count={0} onRate={jest.fn()} />,
);
expect(getByText('Sé el primero en valorar')).toBeTruthy();
});

it('disables all buttons when disabled', () => {
const { getAllByRole } = render(
<StarRating value={null} average={0} count={0} onRate={jest.fn()} disabled />,
);
getAllByRole('button').forEach((btn) =>
expect(btn.props.accessibilityState.disabled).toBe(true),
);
});
});
18 changes: 18 additions & 0 deletions __tests__/components/__snapshots__/PostCard.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,24 @@ exports[`PostCard matches snapshot 1`] = `
30 jun
</Text>
</View>
<View
className="flex-row items-center gap-1"
>
<View
className="flex-row items-center gap-1 mr-2"
>
<Text
className="text-[15px] text-nun-dark font-normal text-xs text-nun-muted"
>
4.3
</Text>
</View>
<Text
className="text-[15px] text-nun-dark font-normal text-xs text-nun-muted"
>
4
</Text>
</View>
</View>
</View>
</View>
Expand Down
Loading
Loading