/** * Tests for ReplyComposer component. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, waitFor, act } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { axe } from 'vitest-axe' import { ReplyComposer } from './reply-composer' import type { ReplyTarget } from './reply-composer' import { createMockOnboardingContext } from '@/test/mock-onboarding' import type { OnboardingContextValue } from '@/context/onboarding-context' const mockGetAccessToken = vi.fn<() => string | null>(() => 'mock-access-token') const mockToast = vi.fn() const mockCreateReply = vi.fn() vi.mock('@/hooks/use-auth', () => ({ useAuth: () => ({ user: { did: 'did:plc:user-test-001', handle: 'test.bsky.social', displayName: 'Test User', avatarUrl: null, }, isAuthenticated: true, isLoading: false, getAccessToken: mockGetAccessToken, login: vi.fn(), logout: vi.fn(), setSessionFromCallback: vi.fn(), authFetch: vi.fn(), crossPostScopesGranted: false, }), })) vi.mock('@/hooks/use-toast', () => ({ useToast: () => ({ toast: mockToast, dismiss: vi.fn(), }), })) vi.mock('@/lib/api/client', async (importOriginal) => { const actual = await importOriginal() return { ...actual, createReply: (...args: unknown[]) => mockCreateReply(...args), } }) let mockOnboardingContext: OnboardingContextValue = createMockOnboardingContext() vi.mock('@/context/onboarding-context', () => ({ useOnboardingContext: () => mockOnboardingContext, })) const defaultProps = { topicUri: 'at://did:plc:abc/forum.barazo.topic/123', topicCid: 'bafyreiabc123', communityDid: 'did:plc:community-001', onReplyCreated: vi.fn(), } const mockReplyTarget: ReplyTarget = { uri: 'at://did:plc:def/forum.barazo.reply/456', cid: 'bafyreidef456', authorHandle: 'alice.bsky.social', snippet: 'This is a snippet of the original reply content', } beforeEach(() => { vi.clearAllMocks() mockOnboardingContext = createMockOnboardingContext() mockCreateReply.mockResolvedValue({ uri: 'at://did:plc:user-test-001/forum.barazo.reply/789', cid: 'bafyrei789', content: 'Test reply', authorDid: 'did:plc:user-test-001', }) }) describe('ReplyComposer', () => { describe('collapsed state', () => { it('renders collapsed bar with "Write a reply..." text', () => { render() expect(screen.getByText('Write a reply...')).toBeInTheDocument() }) it('does not show textarea in collapsed state', () => { render() expect(screen.queryByRole('textbox')).not.toBeInTheDocument() }) it('passes axe accessibility check in collapsed state', async () => { const { container } = render() const results = await axe(container) expect(results).toHaveNoViolations() }) }) describe('expand/collapse', () => { it('expands when collapsed bar is clicked', async () => { const user = userEvent.setup() render() await user.click(screen.getByText('Write a reply...')) expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument() }) it('collapses when Cancel button is clicked', async () => { const user = userEvent.setup() render() await user.click(screen.getByText('Write a reply...')) expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Cancel' })) expect(screen.queryByRole('textbox')).not.toBeInTheDocument() expect(screen.getByText('Write a reply...')).toBeInTheDocument() }) }) describe('reply target banner', () => { it('shows reply target banner when replyTarget prop is provided', async () => { render() // Should auto-expand when reply target is set expect(screen.getByText('Replying to @alice.bsky.social')).toBeInTheDocument() expect(screen.getByText(mockReplyTarget.snippet)).toBeInTheDocument() }) it('auto-expands when replyTarget is set', () => { render() // Textarea should be visible because it auto-expanded expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument() }) it('calls onClearReplyTarget when dismiss button is clicked', async () => { const user = userEvent.setup() const onClear = vi.fn() render( ) await user.click(screen.getByRole('button', { name: 'Dismiss reply target' })) expect(onClear).toHaveBeenCalledTimes(1) }) it('does not show reply target banner when replyTarget is null', async () => { const user = userEvent.setup() render() await user.click(screen.getByText('Write a reply...')) expect(screen.queryByText(/replying to/i)).not.toBeInTheDocument() }) }) describe('locked topic', () => { it('shows locked notice when isLocked is true', () => { render() expect( screen.getByText('This topic is locked. New replies are not accepted.') ).toBeInTheDocument() }) it('does not show composer input when locked', () => { render() expect(screen.queryByText('Write a reply...')).not.toBeInTheDocument() expect(screen.queryByRole('textbox')).not.toBeInTheDocument() }) it('passes axe accessibility check when locked', async () => { const { container } = render() const results = await axe(container) expect(results).toHaveNoViolations() }) }) describe('submit behavior', () => { it('disables submit button when content is empty', async () => { const user = userEvent.setup() render() await user.click(screen.getByText('Write a reply...')) const submitBtn = screen.getByRole('button', { name: 'Reply' }) expect(submitBtn).toBeDisabled() }) it('disables submit button when content is only whitespace', async () => { const user = userEvent.setup() render() await user.click(screen.getByText('Write a reply...')) const textarea = screen.getByRole('textbox', { name: 'Reply' }) await user.type(textarea, ' ') const submitBtn = screen.getByRole('button', { name: 'Reply' }) expect(submitBtn).toBeDisabled() }) it('enables submit button when content has text', async () => { const user = userEvent.setup() render() await user.click(screen.getByText('Write a reply...')) const textarea = screen.getByRole('textbox', { name: 'Reply' }) await user.type(textarea, 'A valid reply') const submitBtn = screen.getByRole('button', { name: 'Reply' }) expect(submitBtn).toBeEnabled() }) it('calls createReply and onReplyCreated on successful submit', async () => { const user = userEvent.setup() const onReplyCreated = vi.fn() render() await user.click(screen.getByText('Write a reply...')) const textarea = screen.getByRole('textbox', { name: 'Reply' }) await user.type(textarea, 'My test reply') await user.click(screen.getByRole('button', { name: 'Reply' })) await waitFor(() => { expect(mockCreateReply).toHaveBeenCalledWith( defaultProps.topicUri, { content: 'My test reply', parentUri: undefined }, 'mock-access-token' ) }) await waitFor(() => { expect(onReplyCreated).toHaveBeenCalledTimes(1) }) await waitFor(() => { expect(mockToast).toHaveBeenCalledWith({ title: 'Reply posted' }) }) }) it('passes parentUri when reply target is set', async () => { const user = userEvent.setup() render( ) const textarea = screen.getByRole('textbox', { name: 'Reply' }) await user.type(textarea, 'Replying to a specific post') await user.click(screen.getByRole('button', { name: 'Reply' })) await waitFor(() => { expect(mockCreateReply).toHaveBeenCalledWith( defaultProps.topicUri, { content: 'Replying to a specific post', parentUri: mockReplyTarget.uri }, 'mock-access-token' ) }) }) it('clears content and collapses after successful submit', async () => { const user = userEvent.setup() render() await user.click(screen.getByText('Write a reply...')) const textarea = screen.getByRole('textbox', { name: 'Reply' }) await user.type(textarea, 'My test reply') await user.click(screen.getByRole('button', { name: 'Reply' })) await waitFor(() => { expect(screen.getByText('Write a reply...')).toBeInTheDocument() }) expect(screen.queryByRole('textbox')).not.toBeInTheDocument() }) it('shows error toast on failed submit', async () => { mockCreateReply.mockRejectedValueOnce(new Error('Network error')) const user = userEvent.setup() render() await user.click(screen.getByText('Write a reply...')) const textarea = screen.getByRole('textbox', { name: 'Reply' }) await user.type(textarea, 'My test reply') await user.click(screen.getByRole('button', { name: 'Reply' })) await waitFor(() => { expect(mockToast).toHaveBeenCalledWith({ title: 'Error', description: 'Network error', variant: 'destructive', }) }) }) it('shows generic error message for non-Error exceptions', async () => { mockCreateReply.mockRejectedValueOnce('unknown error') const user = userEvent.setup() render() await user.click(screen.getByText('Write a reply...')) const textarea = screen.getByRole('textbox', { name: 'Reply' }) await user.type(textarea, 'My test reply') await user.click(screen.getByRole('button', { name: 'Reply' })) await waitFor(() => { expect(mockToast).toHaveBeenCalledWith({ title: 'Error', description: 'Failed to post reply', variant: 'destructive', }) }) }) it('stays expanded after failed submit', async () => { mockCreateReply.mockRejectedValueOnce(new Error('Network error')) const user = userEvent.setup() render() await user.click(screen.getByText('Write a reply...')) const textarea = screen.getByRole('textbox', { name: 'Reply' }) await user.type(textarea, 'My test reply') await user.click(screen.getByRole('button', { name: 'Reply' })) await waitFor(() => { expect(mockToast).toHaveBeenCalled() }) // Should still be expanded with content intact expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument() }) }) describe('onboarding gate', () => { it('does not call createReply when ensureOnboarded returns false', async () => { mockOnboardingContext = createMockOnboardingContext({ ensureOnboarded: vi.fn(() => false), }) const user = userEvent.setup() render() await user.click(screen.getByText('Write a reply...')) const textarea = screen.getByRole('textbox', { name: 'Reply' }) await user.type(textarea, 'My test reply') await user.click(screen.getByRole('button', { name: 'Reply' })) expect(mockCreateReply).not.toHaveBeenCalled() }) it('preserves content when ensureOnboarded returns false', async () => { mockOnboardingContext = createMockOnboardingContext({ ensureOnboarded: vi.fn(() => false), }) const user = userEvent.setup() render() await user.click(screen.getByText('Write a reply...')) const textarea = screen.getByRole('textbox', { name: 'Reply' }) await user.type(textarea, 'My test reply') await user.click(screen.getByRole('button', { name: 'Reply' })) // Content should still be there expect(screen.getByRole('textbox', { name: 'Reply' })).toHaveValue('My test reply') }) it('proceeds normally when ensureOnboarded returns true', async () => { mockOnboardingContext = createMockOnboardingContext({ ensureOnboarded: vi.fn(() => true), }) const user = userEvent.setup() render() await user.click(screen.getByText('Write a reply...')) const textarea = screen.getByRole('textbox', { name: 'Reply' }) await user.type(textarea, 'My test reply') await user.click(screen.getByRole('button', { name: 'Reply' })) await waitFor(() => { expect(mockCreateReply).toHaveBeenCalled() }) }) }) describe('initialContent', () => { it('populates textarea with initialContent and auto-expands', () => { const initialText = '> quoted text\n\n' render() expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument() expect(screen.getByRole('textbox', { name: 'Reply' })).toHaveValue(initialText) }) }) describe('expanded state accessibility', () => { it('passes axe accessibility check in expanded state', async () => { const user = userEvent.setup() const { container } = render() await user.click(screen.getByText('Write a reply...')) const results = await axe(container) expect(results).toHaveNoViolations() }) it('passes axe accessibility check with reply target banner', async () => { const { container } = render( ) const results = await axe(container) expect(results).toHaveNoViolations() }) }) describe('keyboard shortcuts', () => { it('collapses when Escape is pressed while expanded', async () => { const user = userEvent.setup() render() // Expand first await user.click(screen.getByText('Write a reply...')) expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument() // Press Escape await user.keyboard('{Escape}') expect(screen.queryByRole('textbox')).not.toBeInTheDocument() expect(screen.getByText('Write a reply...')).toBeInTheDocument() }) it('does not collapse when Escape is pressed while already collapsed', async () => { const user = userEvent.setup() render() // Press Escape while collapsed - should remain collapsed (no crash) await user.keyboard('{Escape}') expect(screen.getByText('Write a reply...')).toBeInTheDocument() }) it('preserves draft content after Escape collapse', async () => { const user = userEvent.setup() render() // Expand, type content, collapse with Escape await user.click(screen.getByText('Write a reply...')) const textarea = screen.getByRole('textbox', { name: 'Reply' }) await user.type(textarea, 'My draft reply') await user.keyboard('{Escape}') // Re-expand and verify draft is preserved await user.click(screen.getByText('Write a reply...')) expect(screen.getByRole('textbox', { name: 'Reply' })).toHaveValue('My draft reply') }) }) describe('imperative handle', () => { it('expands composer when expand() is called via ref', async () => { const ref = { current: null } as React.RefObject< import('./reply-composer').ReplyComposerHandle | null > render() expect(screen.getByText('Write a reply...')).toBeInTheDocument() expect(screen.queryByRole('textbox')).not.toBeInTheDocument() // Call expand via ref wrapped in act act(() => { ref.current?.expand() }) await waitFor(() => { expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument() }) }) }) describe('className prop', () => { it('applies custom className in collapsed state', () => { const { container } = render() expect(container.firstChild).toHaveClass('custom-class') }) it('applies custom className when locked', () => { const { container } = render( ) expect(container.firstChild).toHaveClass('custom-class') }) }) })