/** * Tests for ReplyCard component. */ 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 { ReplyCard } from './reply-card' import { mockReplies, mockAuthorDeletedReply, mockModDeletedReply } from '@/mocks/data' import { useAuth } from '@/hooks/use-auth' import { updateReply, deleteReply } from '@/lib/api/client' import type { Reply } from '@/lib/api/types' import { createMockOnboardingContext } from '@/test/mock-onboarding' // Mock useAuth vi.mock('@/hooks/use-auth', () => ({ useAuth: vi.fn(() => ({ user: null, isAuthenticated: false, isLoading: false, crossPostScopesGranted: false, getAccessToken: () => null, login: vi.fn(), logout: vi.fn(), setSessionFromCallback: vi.fn(), requestCrossPostAuth: vi.fn(), authFetch: vi.fn(), })), })) // Mock useToast const mockToast = vi.fn() vi.mock('@/hooks/use-toast', () => ({ useToast: () => ({ toast: mockToast, dismiss: vi.fn() }), })) // Mock onboarding context vi.mock('@/context/onboarding-context', () => ({ useOnboardingContext: () => createMockOnboardingContext(), })) // Mock API client vi.mock('@/lib/api/client', () => ({ updateReply: vi.fn(), deleteReply: vi.fn(), getReactions: vi.fn().mockResolvedValue({ reactions: [], cursor: null }), createReaction: vi.fn().mockResolvedValue({ uri: 'at://test', cid: 'bafyrei-test' }), deleteReaction: vi.fn().mockResolvedValue(undefined), })) const reply = mockReplies[0]! const mockReactions = [{ type: 'like', count: 3, reacted: true }] beforeEach(() => { vi.clearAllMocks() mockToast.mockReset() }) describe('ReplyCard', () => { it('renders reply content', () => { render() expect(screen.getByText(reply.content)).toBeInTheDocument() }) it('renders author display name', () => { render() const expectedName = reply.author?.displayName ?? reply.author?.handle ?? reply.authorDid expect(screen.getByText(expectedName)).toBeInTheDocument() }) it('renders as article with aria-labelledby', () => { const { container } = render() const article = container.querySelector('article') expect(article).toBeInTheDocument() expect(article).toHaveAttribute('aria-labelledby') }) it('renders anchor id for post number', () => { const { container } = render() const article = container.querySelector('article') expect(article).toHaveAttribute('id', 'post-2') }) it('renders post number link', () => { render() const link = screen.getByRole('link', { name: 'Link to post #2' }) expect(link).toHaveAttribute('href', '#post-2') }) it('renders reaction count', () => { render() expect(screen.getByText(`${reply.reactionCount}`)).toBeInTheDocument() }) it('passes axe accessibility check', async () => { const { container } = render() const results = await axe(container) expect(results).toHaveNoViolations() }) it('renders reaction bar when reactions are provided', () => { render( ) expect(screen.getByRole('group', { name: 'Reactions' })).toBeInTheDocument() }) it('renders report button when canReport is true', () => { render() expect(screen.getByRole('button', { name: /report/i })).toBeInTheDocument() }) it('renders self-label indicator when selfLabels are provided', () => { render() expect(screen.getByText(/content warning/i)).toBeInTheDocument() }) it('calls onReactionToggle when reaction is clicked', async () => { const user = userEvent.setup() const onToggle = vi.fn() render( ) await user.click(screen.getByRole('button', { name: /like/i })) expect(onToggle).toHaveBeenCalledWith('like') }) describe('reply button', () => { it('calls onReply with correct metadata when Reply button is clicked', async () => { const user = userEvent.setup() const onReply = vi.fn() render() await user.click(screen.getByRole('button', { name: /reply to/i })) expect(onReply).toHaveBeenCalledWith({ uri: reply.uri, cid: reply.cid, authorHandle: reply.author?.handle ?? reply.authorDid, snippet: reply.content.slice(0, 100), }) }) it('does not show Reply button on deleted replies', () => { render() expect(screen.queryByRole('button', { name: /reply to/i })).not.toBeInTheDocument() }) it('does not show Reply button when onReply is not provided', () => { render() expect(screen.queryByRole('button', { name: /reply to/i })).not.toBeInTheDocument() }) }) describe('tombstone: author-deleted replies', () => { it('shows author-deleted placeholder text', () => { render() expect(screen.getByText('This post was removed by the author.')).toBeInTheDocument() }) it('does not render MarkdownContent for author-deleted replies', () => { const { container } = render() expect(container.querySelector('.prose')).not.toBeInTheDocument() }) it('shows [deleted] instead of author name for author-deleted replies', () => { render() expect(screen.getByText('[deleted]')).toBeInTheDocument() }) it('does not render author avatar for author-deleted replies', () => { render() expect(screen.queryByRole('img')).not.toBeInTheDocument() }) it('preserves post number anchor for author-deleted replies', () => { const { container } = render() const article = container.querySelector('article') expect(article).toHaveAttribute('id', 'post-4') }) it('does not render reactions footer for author-deleted replies', () => { render() expect(screen.queryByLabelText(/reactions/i)).not.toBeInTheDocument() }) it('does not render report button for author-deleted replies', () => { render( ) expect(screen.queryByRole('button', { name: /report/i })).not.toBeInTheDocument() }) it('passes axe accessibility check for author-deleted replies', async () => { const { container } = render() const results = await axe(container) expect(results).toHaveNoViolations() }) }) describe('tombstone: moderator-deleted replies', () => { it('shows moderator-deleted placeholder text', () => { render() expect(screen.getByText('This post was removed by a moderator.')).toBeInTheDocument() }) it('does not render original reply content for mod-deleted replies', () => { render() expect(screen.queryByText('[Removed by moderator]')).not.toBeInTheDocument() }) it('shows [deleted] instead of author name for mod-deleted replies', () => { render() expect(screen.getByText('[deleted]')).toBeInTheDocument() }) it('preserves post number anchor for mod-deleted replies', () => { const { container } = render() const article = container.querySelector('article') expect(article).toHaveAttribute('id', 'post-5') }) it('passes axe accessibility check for mod-deleted replies', async () => { const { container } = render() const results = await axe(container) expect(results).toHaveNoViolations() }) }) describe('edit mode', () => { beforeEach(() => { vi.mocked(useAuth).mockReturnValue({ user: { did: reply.authorDid, handle: reply.author?.handle ?? '', displayName: 'Alex', avatarUrl: null, role: 'user', }, isAuthenticated: true, isLoading: false, crossPostScopesGranted: false, getAccessToken: () => 'mock-token', login: vi.fn(), logout: vi.fn(), setSessionFromCallback: vi.fn(), requestCrossPostAuth: vi.fn(), authFetch: vi.fn(), } as ReturnType) }) it('renders Edit button when canEdit is true', () => { render() expect( screen.getByRole('button', { name: `Edit reply by ${reply.author?.handle ?? reply.authorDid}`, }) ).toBeInTheDocument() }) it('does not render Edit button when canEdit is false', () => { render() expect(screen.queryByRole('button', { name: /edit reply by/i })).not.toBeInTheDocument() }) it('does not render Edit button on deleted replies', () => { render() expect(screen.queryByRole('button', { name: /edit reply by/i })).not.toBeInTheDocument() }) it('shows MarkdownEditor with reply content when Edit is clicked', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: /edit reply by/i })) expect(screen.getByRole('toolbar')).toBeInTheDocument() expect(screen.getByRole('textbox')).toHaveValue(reply.content) }) it('returns to read mode when Cancel is clicked', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: /edit reply by/i })) expect(screen.getByRole('toolbar')).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Cancel' })) expect(screen.queryByRole('toolbar')).not.toBeInTheDocument() expect(screen.getByText(reply.content)).toBeInTheDocument() }) it('calls updateReply with correct args on save', async () => { const user = userEvent.setup() vi.mocked(updateReply).mockResolvedValueOnce({} as Reply) render() await user.click(screen.getByRole('button', { name: /edit reply by/i })) const textarea = screen.getByRole('textbox') await user.clear(textarea) await user.type(textarea, 'new content') await user.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => { expect(updateReply).toHaveBeenCalledWith( reply.uri, { content: 'new content' }, 'mock-token' ) }) // Editor should close after save await waitFor(() => { expect(screen.queryByRole('toolbar')).not.toBeInTheDocument() }) }) it('disables Save button when content is empty', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: /edit reply by/i })) const textarea = screen.getByRole('textbox') await user.clear(textarea) expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() }) it('shows "Saving..." while submitting', async () => { const user = userEvent.setup() let resolvePromise: (value: Reply) => void vi.mocked(updateReply).mockReturnValueOnce( new Promise((resolve) => { resolvePromise = resolve }) ) render() await user.click(screen.getByRole('button', { name: /edit reply by/i })) await user.click(screen.getByRole('button', { name: 'Save' })) expect(screen.getByRole('button', { name: 'Saving...' })).toBeInTheDocument() // Resolve and wait for state to settle resolvePromise!({} as Reply) await waitFor(() => { expect(screen.queryByRole('toolbar')).not.toBeInTheDocument() }) }) it('shows error toast on save failure', async () => { const user = userEvent.setup() vi.mocked(updateReply).mockRejectedValueOnce(new Error('Network error')) render() await user.click(screen.getByRole('button', { name: /edit reply by/i })) await user.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => { expect(mockToast).toHaveBeenCalledWith({ title: 'Error', description: 'Network error', variant: 'destructive', }) }) // Editor should remain open expect(screen.getByRole('toolbar')).toBeInTheDocument() }) it('shows success toast on save', async () => { const user = userEvent.setup() vi.mocked(updateReply).mockResolvedValueOnce({} as Reply) render() await user.click(screen.getByRole('button', { name: /edit reply by/i })) await user.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => { expect(mockToast).toHaveBeenCalledWith({ title: 'Reply updated' }) }) }) it('shows "(edited)" indicator when indexedAt > createdAt + 30s', () => { const editedReply: Reply = { ...reply, createdAt: '2026-02-14T12:00:00.000Z', indexedAt: '2026-02-14T12:01:00.000Z', } render() expect(screen.getByText('(edited)')).toBeInTheDocument() }) it('does not show "(edited)" indicator when timestamps are close', () => { // Default mock reply has same createdAt and indexedAt render() expect(screen.queryByText('(edited)')).not.toBeInTheDocument() }) it('passes axe accessibility check in edit mode', async () => { const user = userEvent.setup() const { container } = render() await user.click(screen.getByRole('button', { name: /edit reply by/i })) const results = await axe(container) expect(results).toHaveNoViolations() }) }) describe('delete mode', () => { beforeEach(() => { vi.mocked(useAuth).mockReturnValue({ user: { did: reply.authorDid, handle: reply.author?.handle ?? '', displayName: 'Alex', avatarUrl: null, role: 'user', }, isAuthenticated: true, isLoading: false, crossPostScopesGranted: false, getAccessToken: () => 'mock-token', login: vi.fn(), logout: vi.fn(), setSessionFromCallback: vi.fn(), requestCrossPostAuth: vi.fn(), authFetch: vi.fn(), } as ReturnType) }) it('renders Delete button when canDelete is true', () => { render() expect( screen.getByRole('button', { name: `Delete reply by ${reply.author?.handle ?? reply.authorDid}`, }) ).toBeInTheDocument() }) it('does not render Delete button when canDelete is false', () => { render() expect(screen.queryByRole('button', { name: /delete reply by/i })).not.toBeInTheDocument() }) it('does not render Delete button on deleted replies', () => { render() expect(screen.queryByRole('button', { name: /delete reply by/i })).not.toBeInTheDocument() }) it('shows confirmation dialog when Delete is clicked', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: /delete reply by/i })) expect(screen.getByRole('alertdialog')).toBeInTheDocument() expect(screen.getByText('Delete reply?')).toBeInTheDocument() expect( screen.getByText('This will permanently remove your reply. This cannot be undone.') ).toBeInTheDocument() }) it('closes confirmation dialog when Cancel is clicked', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: /delete reply by/i })) expect(screen.getByRole('alertdialog')).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Cancel' })) expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() }) it('calls deleteReply with correct args on confirm', async () => { const user = userEvent.setup() vi.mocked(deleteReply).mockResolvedValueOnce(undefined) render() await user.click(screen.getByRole('button', { name: /delete reply by/i })) await user.click(screen.getByRole('button', { name: 'Delete' })) await waitFor(() => { expect(deleteReply).toHaveBeenCalledWith(reply.uri, 'mock-token') }) }) it('calls onDelete callback after successful deletion', async () => { const user = userEvent.setup() const onDelete = vi.fn() vi.mocked(deleteReply).mockResolvedValueOnce(undefined) render() await user.click(screen.getByRole('button', { name: /delete reply by/i })) await user.click(screen.getByRole('button', { name: 'Delete' })) await waitFor(() => { expect(onDelete).toHaveBeenCalled() }) }) it('shows success toast after deletion', async () => { const user = userEvent.setup() vi.mocked(deleteReply).mockResolvedValueOnce(undefined) render() await user.click(screen.getByRole('button', { name: /delete reply by/i })) await user.click(screen.getByRole('button', { name: 'Delete' })) await waitFor(() => { expect(mockToast).toHaveBeenCalledWith({ title: 'Reply deleted' }) }) }) it('shows error toast on delete failure', async () => { const user = userEvent.setup() vi.mocked(deleteReply).mockRejectedValueOnce(new Error('Server error')) render() await user.click(screen.getByRole('button', { name: /delete reply by/i })) await user.click(screen.getByRole('button', { name: 'Delete' })) await waitFor(() => { expect(mockToast).toHaveBeenCalledWith({ title: 'Error', description: 'Server error', variant: 'destructive', }) }) }) it('does not call onDelete on failure', async () => { const user = userEvent.setup() const onDelete = vi.fn() vi.mocked(deleteReply).mockRejectedValueOnce(new Error('Server error')) render() await user.click(screen.getByRole('button', { name: /delete reply by/i })) await user.click(screen.getByRole('button', { name: 'Delete' })) await waitFor(() => { expect(mockToast).toHaveBeenCalled() }) expect(onDelete).not.toHaveBeenCalled() }) it('passes axe accessibility check with delete confirmation dialog open', async () => { const user = userEvent.setup() const { container } = render() await user.click(screen.getByRole('button', { name: /delete reply by/i })) const results = await axe(container) expect(results).toHaveNoViolations() }) }) })