/** * Tests for LikeButton component. * Interactive heart button for liking/unliking topics and replies. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { axe } from 'vitest-axe' import { LikeButton } from './like-button' import { createMockOnboardingContext } from '@/test/mock-onboarding' import type { OnboardingContextValue } from '@/context/onboarding-context' // --- Mocks --- const mockToast = vi.fn() vi.mock('@/hooks/use-toast', () => ({ useToast: () => ({ toast: mockToast }), })) const mockGetAccessToken = vi.fn(() => 'mock-access-token') const mockAuthFetch = vi.fn() vi.mock('@/hooks/use-auth', () => ({ useAuth: () => ({ user: { did: 'did:plc:user-test-001', handle: 'test.bsky.social' }, isAuthenticated: mockGetAccessToken() !== null, isLoading: false, getAccessToken: mockGetAccessToken, authFetch: mockAuthFetch, login: vi.fn(), logout: vi.fn(), setSessionFromCallback: vi.fn(), crossPostScopesGranted: false, requestCrossPostAuth: vi.fn(), }), })) let mockOnboardingContext: OnboardingContextValue = createMockOnboardingContext() vi.mock('@/context/onboarding-context', () => ({ useOnboardingContext: () => mockOnboardingContext, })) vi.mock('@/lib/api/client', () => ({ getReactions: vi.fn().mockResolvedValue({ reactions: [], cursor: null }), createReaction: vi.fn().mockResolvedValue({ uri: 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/abc123', cid: 'bafyrei-abc123', rkey: 'abc123', type: 'like', subjectUri: 'at://did:plc:author/forum.barazo.topic.post/topic1', createdAt: '2026-02-14T12:00:00.000Z', }), deleteReaction: vi.fn().mockResolvedValue(undefined), })) // Import after mocks so we can spy on them const { getReactions, createReaction, deleteReaction } = await import('@/lib/api/client') const defaultProps = { subjectUri: 'at://did:plc:author/forum.barazo.topic.post/topic1', subjectCid: 'bafyrei-topic1', initialCount: 5, } beforeEach(() => { vi.clearAllMocks() mockToast.mockReset() mockGetAccessToken.mockReturnValue('mock-access-token') mockAuthFetch.mockReset() mockOnboardingContext = createMockOnboardingContext() vi.mocked(getReactions).mockResolvedValue({ reactions: [], cursor: null }) vi.mocked(createReaction).mockResolvedValue({ uri: 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/abc123', cid: 'bafyrei-abc123', rkey: 'abc123', type: 'like', subjectUri: defaultProps.subjectUri, createdAt: '2026-02-14T12:00:00.000Z', }) vi.mocked(deleteReaction).mockResolvedValue(undefined) }) describe('LikeButton', () => { describe('rendering', () => { it('renders a button with heart icon and count', () => { render() const button = screen.getByRole('button', { name: /5 reactions/i }) expect(button).toBeInTheDocument() }) it('displays the initial count', () => { render() expect(screen.getByText('12')).toBeInTheDocument() }) it('displays zero count', () => { render() expect(screen.getByText('0')).toBeInTheDocument() }) }) describe('fetching user reaction status', () => { it('checks if the current user has already liked on mount', async () => { render() await waitFor(() => { expect(getReactions).toHaveBeenCalledWith( defaultProps.subjectUri, { type: 'like' }, expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer mock-access-token', }), }) ) }) }) it('shows filled state when user has already liked', async () => { vi.mocked(getReactions).mockResolvedValueOnce({ reactions: [ { uri: 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/existing', rkey: 'existing', authorDid: 'did:plc:user-test-001', subjectUri: defaultProps.subjectUri, subjectCid: defaultProps.subjectCid, type: 'like', communityDid: 'did:plc:community', cid: 'bafyrei-existing', createdAt: '2026-02-14T12:00:00.000Z', }, ], cursor: null, }) render() await waitFor(() => { expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true') }) }) it('shows unfilled state when user has not liked', async () => { render() await waitFor(() => { expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'false') }) }) }) describe('liking', () => { it('calls createReaction when clicking an unliked button', async () => { const user = userEvent.setup() render() await waitFor(() => { expect(getReactions).toHaveBeenCalled() }) await user.click(screen.getByRole('button')) expect(createReaction).toHaveBeenCalledWith( { subjectUri: defaultProps.subjectUri, subjectCid: defaultProps.subjectCid, type: 'like', }, 'mock-access-token' ) }) it('optimistically increments count on like', async () => { const user = userEvent.setup() render() await waitFor(() => { expect(getReactions).toHaveBeenCalled() }) await user.click(screen.getByRole('button')) expect(screen.getByText('6')).toBeInTheDocument() }) it('optimistically sets pressed state on like', async () => { const user = userEvent.setup() render() await waitFor(() => { expect(getReactions).toHaveBeenCalled() }) await user.click(screen.getByRole('button')) expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true') }) }) describe('unliking', () => { it('calls deleteReaction when clicking a liked button', async () => { vi.mocked(getReactions).mockResolvedValueOnce({ reactions: [ { uri: 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/existing', rkey: 'existing', authorDid: 'did:plc:user-test-001', subjectUri: defaultProps.subjectUri, subjectCid: defaultProps.subjectCid, type: 'like', communityDid: 'did:plc:community', cid: 'bafyrei-existing', createdAt: '2026-02-14T12:00:00.000Z', }, ], cursor: null, }) const user = userEvent.setup() render() await waitFor(() => { expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true') }) await user.click(screen.getByRole('button')) expect(deleteReaction).toHaveBeenCalledWith( 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/existing', 'mock-access-token' ) }) it('optimistically decrements count on unlike', async () => { vi.mocked(getReactions).mockResolvedValueOnce({ reactions: [ { uri: 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/existing', rkey: 'existing', authorDid: 'did:plc:user-test-001', subjectUri: defaultProps.subjectUri, subjectCid: defaultProps.subjectCid, type: 'like', communityDid: 'did:plc:community', cid: 'bafyrei-existing', createdAt: '2026-02-14T12:00:00.000Z', }, ], cursor: null, }) const user = userEvent.setup() render() await waitFor(() => { expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true') }) await user.click(screen.getByRole('button')) expect(screen.getByText('4')).toBeInTheDocument() }) it('does not go below zero on unlike', async () => { vi.mocked(getReactions).mockResolvedValueOnce({ reactions: [ { uri: 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/existing', rkey: 'existing', authorDid: 'did:plc:user-test-001', subjectUri: defaultProps.subjectUri, subjectCid: defaultProps.subjectCid, type: 'like', communityDid: 'did:plc:community', cid: 'bafyrei-existing', createdAt: '2026-02-14T12:00:00.000Z', }, ], cursor: null, }) const user = userEvent.setup() render() await waitFor(() => { expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true') }) await user.click(screen.getByRole('button')) expect(screen.getByText('0')).toBeInTheDocument() }) }) describe('error handling', () => { it('reverts count on like failure', async () => { vi.mocked(createReaction).mockRejectedValueOnce(new Error('Network error')) const user = userEvent.setup() render() await waitFor(() => { expect(getReactions).toHaveBeenCalled() }) await user.click(screen.getByRole('button')) // After error: reverts to 5 await waitFor(() => { expect(screen.getByText('5')).toBeInTheDocument() }) }) it('shows error toast on like failure', async () => { vi.mocked(createReaction).mockRejectedValueOnce( new Error('API 502: Failed to write to remote PDS') ) const user = userEvent.setup() render() await waitFor(() => { expect(getReactions).toHaveBeenCalled() }) await user.click(screen.getByRole('button')) await waitFor(() => { expect(mockToast).toHaveBeenCalledWith( expect.objectContaining({ title: 'Error', variant: 'destructive', }) ) }) }) it('reverts pressed state on like failure', async () => { vi.mocked(createReaction).mockRejectedValueOnce(new Error('Network error')) const user = userEvent.setup() render() await waitFor(() => { expect(getReactions).toHaveBeenCalled() }) await user.click(screen.getByRole('button')) await waitFor(() => { expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'false') }) }) }) describe('unauthenticated state', () => { it('does not fetch reactions when not authenticated', () => { mockGetAccessToken.mockReturnValue(null as unknown as string) render() expect(getReactions).not.toHaveBeenCalled() }) it('disables the button when not authenticated', () => { mockGetAccessToken.mockReturnValue(null as unknown as string) render() expect(screen.getByRole('button')).toBeDisabled() }) }) describe('onboarding gate', () => { it('does not call createReaction when ensureOnboarded returns false', async () => { mockOnboardingContext = createMockOnboardingContext({ ensureOnboarded: vi.fn(() => false), }) const user = userEvent.setup() render() await waitFor(() => { expect(getReactions).toHaveBeenCalled() }) await user.click(screen.getByRole('button')) expect(createReaction).not.toHaveBeenCalled() expect(deleteReaction).not.toHaveBeenCalled() }) it('proceeds normally when ensureOnboarded returns true', async () => { mockOnboardingContext = createMockOnboardingContext({ ensureOnboarded: vi.fn(() => true), }) const user = userEvent.setup() render() await waitFor(() => { expect(getReactions).toHaveBeenCalled() }) await user.click(screen.getByRole('button')) expect(createReaction).toHaveBeenCalled() }) }) describe('size variants', () => { it('renders with default size', () => { render() expect(screen.getByRole('button')).toBeInTheDocument() }) it('accepts sm size prop', () => { render() expect(screen.getByRole('button')).toBeInTheDocument() }) }) describe('accessibility', () => { it('has aria-pressed reflecting liked state', async () => { render() await waitFor(() => { expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'false') }) }) it('has accessible label with count', () => { render() expect(screen.getByRole('button', { name: /5 reactions/i })).toBeInTheDocument() }) it('passes axe accessibility check', async () => { const { container } = render() const results = await axe(container) expect(results).toHaveNoViolations() }) }) })