Barazo default frontend
barazo.forum
1/**
2 * Tests for ReplyComposer component.
3 */
4
5import { describe, it, expect, vi, beforeEach } from 'vitest'
6import { render, screen, waitFor, act } from '@testing-library/react'
7import userEvent from '@testing-library/user-event'
8import { axe } from 'vitest-axe'
9import { ReplyComposer } from './reply-composer'
10import type { ReplyTarget } from './reply-composer'
11import { createMockOnboardingContext } from '@/test/mock-onboarding'
12import type { OnboardingContextValue } from '@/context/onboarding-context'
13
14const mockGetAccessToken = vi.fn<() => string | null>(() => 'mock-access-token')
15const mockToast = vi.fn()
16const mockCreateReply = vi.fn()
17
18vi.mock('@/hooks/use-auth', () => ({
19 useAuth: () => ({
20 user: {
21 did: 'did:plc:user-test-001',
22 handle: 'test.bsky.social',
23 displayName: 'Test User',
24 avatarUrl: null,
25 },
26 isAuthenticated: true,
27 isLoading: false,
28 getAccessToken: mockGetAccessToken,
29 login: vi.fn(),
30 logout: vi.fn(),
31 setSessionFromCallback: vi.fn(),
32 authFetch: vi.fn(),
33 crossPostScopesGranted: false,
34 }),
35}))
36
37vi.mock('@/hooks/use-toast', () => ({
38 useToast: () => ({
39 toast: mockToast,
40 dismiss: vi.fn(),
41 }),
42}))
43
44vi.mock('@/lib/api/client', async (importOriginal) => {
45 const actual = await importOriginal<typeof import('@/lib/api/client')>()
46 return {
47 ...actual,
48 createReply: (...args: unknown[]) => mockCreateReply(...args),
49 }
50})
51
52let mockOnboardingContext: OnboardingContextValue = createMockOnboardingContext()
53
54vi.mock('@/context/onboarding-context', () => ({
55 useOnboardingContext: () => mockOnboardingContext,
56}))
57
58const defaultProps = {
59 topicUri: 'at://did:plc:abc/forum.barazo.topic/123',
60 topicCid: 'bafyreiabc123',
61 communityDid: 'did:plc:community-001',
62 onReplyCreated: vi.fn(),
63}
64
65const mockReplyTarget: ReplyTarget = {
66 uri: 'at://did:plc:def/forum.barazo.reply/456',
67 cid: 'bafyreidef456',
68 authorHandle: 'alice.bsky.social',
69 snippet: 'This is a snippet of the original reply content',
70}
71
72beforeEach(() => {
73 vi.clearAllMocks()
74 mockOnboardingContext = createMockOnboardingContext()
75 mockCreateReply.mockResolvedValue({
76 uri: 'at://did:plc:user-test-001/forum.barazo.reply/789',
77 cid: 'bafyrei789',
78 content: 'Test reply',
79 authorDid: 'did:plc:user-test-001',
80 })
81})
82
83describe('ReplyComposer', () => {
84 describe('collapsed state', () => {
85 it('renders collapsed bar with "Write a reply..." text', () => {
86 render(<ReplyComposer {...defaultProps} />)
87 expect(screen.getByText('Write a reply...')).toBeInTheDocument()
88 })
89
90 it('does not show textarea in collapsed state', () => {
91 render(<ReplyComposer {...defaultProps} />)
92 expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
93 })
94
95 it('passes axe accessibility check in collapsed state', async () => {
96 const { container } = render(<ReplyComposer {...defaultProps} />)
97 const results = await axe(container)
98 expect(results).toHaveNoViolations()
99 })
100 })
101
102 describe('expand/collapse', () => {
103 it('expands when collapsed bar is clicked', async () => {
104 const user = userEvent.setup()
105 render(<ReplyComposer {...defaultProps} />)
106
107 await user.click(screen.getByText('Write a reply...'))
108
109 expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument()
110 })
111
112 it('collapses when Cancel button is clicked', async () => {
113 const user = userEvent.setup()
114 render(<ReplyComposer {...defaultProps} />)
115
116 await user.click(screen.getByText('Write a reply...'))
117 expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument()
118
119 await user.click(screen.getByRole('button', { name: 'Cancel' }))
120 expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
121 expect(screen.getByText('Write a reply...')).toBeInTheDocument()
122 })
123 })
124
125 describe('reply target banner', () => {
126 it('shows reply target banner when replyTarget prop is provided', async () => {
127 render(<ReplyComposer {...defaultProps} replyTarget={mockReplyTarget} />)
128
129 // Should auto-expand when reply target is set
130 expect(screen.getByText('Replying to @alice.bsky.social')).toBeInTheDocument()
131 expect(screen.getByText(mockReplyTarget.snippet)).toBeInTheDocument()
132 })
133
134 it('auto-expands when replyTarget is set', () => {
135 render(<ReplyComposer {...defaultProps} replyTarget={mockReplyTarget} />)
136
137 // Textarea should be visible because it auto-expanded
138 expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument()
139 })
140
141 it('calls onClearReplyTarget when dismiss button is clicked', async () => {
142 const user = userEvent.setup()
143 const onClear = vi.fn()
144 render(
145 <ReplyComposer
146 {...defaultProps}
147 replyTarget={mockReplyTarget}
148 onClearReplyTarget={onClear}
149 />
150 )
151
152 await user.click(screen.getByRole('button', { name: 'Dismiss reply target' }))
153 expect(onClear).toHaveBeenCalledTimes(1)
154 })
155
156 it('does not show reply target banner when replyTarget is null', async () => {
157 const user = userEvent.setup()
158 render(<ReplyComposer {...defaultProps} replyTarget={null} />)
159
160 await user.click(screen.getByText('Write a reply...'))
161 expect(screen.queryByText(/replying to/i)).not.toBeInTheDocument()
162 })
163 })
164
165 describe('locked topic', () => {
166 it('shows locked notice when isLocked is true', () => {
167 render(<ReplyComposer {...defaultProps} isLocked={true} />)
168 expect(
169 screen.getByText('This topic is locked. New replies are not accepted.')
170 ).toBeInTheDocument()
171 })
172
173 it('does not show composer input when locked', () => {
174 render(<ReplyComposer {...defaultProps} isLocked={true} />)
175 expect(screen.queryByText('Write a reply...')).not.toBeInTheDocument()
176 expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
177 })
178
179 it('passes axe accessibility check when locked', async () => {
180 const { container } = render(<ReplyComposer {...defaultProps} isLocked={true} />)
181 const results = await axe(container)
182 expect(results).toHaveNoViolations()
183 })
184 })
185
186 describe('submit behavior', () => {
187 it('disables submit button when content is empty', async () => {
188 const user = userEvent.setup()
189 render(<ReplyComposer {...defaultProps} />)
190
191 await user.click(screen.getByText('Write a reply...'))
192 const submitBtn = screen.getByRole('button', { name: 'Reply' })
193 expect(submitBtn).toBeDisabled()
194 })
195
196 it('disables submit button when content is only whitespace', async () => {
197 const user = userEvent.setup()
198 render(<ReplyComposer {...defaultProps} />)
199
200 await user.click(screen.getByText('Write a reply...'))
201 const textarea = screen.getByRole('textbox', { name: 'Reply' })
202 await user.type(textarea, ' ')
203
204 const submitBtn = screen.getByRole('button', { name: 'Reply' })
205 expect(submitBtn).toBeDisabled()
206 })
207
208 it('enables submit button when content has text', async () => {
209 const user = userEvent.setup()
210 render(<ReplyComposer {...defaultProps} />)
211
212 await user.click(screen.getByText('Write a reply...'))
213 const textarea = screen.getByRole('textbox', { name: 'Reply' })
214 await user.type(textarea, 'A valid reply')
215
216 const submitBtn = screen.getByRole('button', { name: 'Reply' })
217 expect(submitBtn).toBeEnabled()
218 })
219
220 it('calls createReply and onReplyCreated on successful submit', async () => {
221 const user = userEvent.setup()
222 const onReplyCreated = vi.fn()
223 render(<ReplyComposer {...defaultProps} onReplyCreated={onReplyCreated} />)
224
225 await user.click(screen.getByText('Write a reply...'))
226 const textarea = screen.getByRole('textbox', { name: 'Reply' })
227 await user.type(textarea, 'My test reply')
228 await user.click(screen.getByRole('button', { name: 'Reply' }))
229
230 await waitFor(() => {
231 expect(mockCreateReply).toHaveBeenCalledWith(
232 defaultProps.topicUri,
233 { content: 'My test reply', parentUri: undefined },
234 'mock-access-token'
235 )
236 })
237
238 await waitFor(() => {
239 expect(onReplyCreated).toHaveBeenCalledTimes(1)
240 })
241
242 await waitFor(() => {
243 expect(mockToast).toHaveBeenCalledWith({ title: 'Reply posted' })
244 })
245 })
246
247 it('passes parentUri when reply target is set', async () => {
248 const user = userEvent.setup()
249 render(
250 <ReplyComposer
251 {...defaultProps}
252 replyTarget={mockReplyTarget}
253 onClearReplyTarget={vi.fn()}
254 />
255 )
256
257 const textarea = screen.getByRole('textbox', { name: 'Reply' })
258 await user.type(textarea, 'Replying to a specific post')
259 await user.click(screen.getByRole('button', { name: 'Reply' }))
260
261 await waitFor(() => {
262 expect(mockCreateReply).toHaveBeenCalledWith(
263 defaultProps.topicUri,
264 { content: 'Replying to a specific post', parentUri: mockReplyTarget.uri },
265 'mock-access-token'
266 )
267 })
268 })
269
270 it('clears content and collapses after successful submit', async () => {
271 const user = userEvent.setup()
272 render(<ReplyComposer {...defaultProps} />)
273
274 await user.click(screen.getByText('Write a reply...'))
275 const textarea = screen.getByRole('textbox', { name: 'Reply' })
276 await user.type(textarea, 'My test reply')
277 await user.click(screen.getByRole('button', { name: 'Reply' }))
278
279 await waitFor(() => {
280 expect(screen.getByText('Write a reply...')).toBeInTheDocument()
281 })
282 expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
283 })
284
285 it('shows error toast on failed submit', async () => {
286 mockCreateReply.mockRejectedValueOnce(new Error('Network error'))
287
288 const user = userEvent.setup()
289 render(<ReplyComposer {...defaultProps} />)
290
291 await user.click(screen.getByText('Write a reply...'))
292 const textarea = screen.getByRole('textbox', { name: 'Reply' })
293 await user.type(textarea, 'My test reply')
294 await user.click(screen.getByRole('button', { name: 'Reply' }))
295
296 await waitFor(() => {
297 expect(mockToast).toHaveBeenCalledWith({
298 title: 'Error',
299 description: 'Network error',
300 variant: 'destructive',
301 })
302 })
303 })
304
305 it('shows generic error message for non-Error exceptions', async () => {
306 mockCreateReply.mockRejectedValueOnce('unknown error')
307
308 const user = userEvent.setup()
309 render(<ReplyComposer {...defaultProps} />)
310
311 await user.click(screen.getByText('Write a reply...'))
312 const textarea = screen.getByRole('textbox', { name: 'Reply' })
313 await user.type(textarea, 'My test reply')
314 await user.click(screen.getByRole('button', { name: 'Reply' }))
315
316 await waitFor(() => {
317 expect(mockToast).toHaveBeenCalledWith({
318 title: 'Error',
319 description: 'Failed to post reply',
320 variant: 'destructive',
321 })
322 })
323 })
324
325 it('stays expanded after failed submit', async () => {
326 mockCreateReply.mockRejectedValueOnce(new Error('Network error'))
327
328 const user = userEvent.setup()
329 render(<ReplyComposer {...defaultProps} />)
330
331 await user.click(screen.getByText('Write a reply...'))
332 const textarea = screen.getByRole('textbox', { name: 'Reply' })
333 await user.type(textarea, 'My test reply')
334 await user.click(screen.getByRole('button', { name: 'Reply' }))
335
336 await waitFor(() => {
337 expect(mockToast).toHaveBeenCalled()
338 })
339 // Should still be expanded with content intact
340 expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument()
341 })
342 })
343
344 describe('onboarding gate', () => {
345 it('does not call createReply when ensureOnboarded returns false', async () => {
346 mockOnboardingContext = createMockOnboardingContext({
347 ensureOnboarded: vi.fn(() => false),
348 })
349
350 const user = userEvent.setup()
351 render(<ReplyComposer {...defaultProps} />)
352
353 await user.click(screen.getByText('Write a reply...'))
354 const textarea = screen.getByRole('textbox', { name: 'Reply' })
355 await user.type(textarea, 'My test reply')
356 await user.click(screen.getByRole('button', { name: 'Reply' }))
357
358 expect(mockCreateReply).not.toHaveBeenCalled()
359 })
360
361 it('preserves content when ensureOnboarded returns false', async () => {
362 mockOnboardingContext = createMockOnboardingContext({
363 ensureOnboarded: vi.fn(() => false),
364 })
365
366 const user = userEvent.setup()
367 render(<ReplyComposer {...defaultProps} />)
368
369 await user.click(screen.getByText('Write a reply...'))
370 const textarea = screen.getByRole('textbox', { name: 'Reply' })
371 await user.type(textarea, 'My test reply')
372 await user.click(screen.getByRole('button', { name: 'Reply' }))
373
374 // Content should still be there
375 expect(screen.getByRole('textbox', { name: 'Reply' })).toHaveValue('My test reply')
376 })
377
378 it('proceeds normally when ensureOnboarded returns true', async () => {
379 mockOnboardingContext = createMockOnboardingContext({
380 ensureOnboarded: vi.fn(() => true),
381 })
382
383 const user = userEvent.setup()
384 render(<ReplyComposer {...defaultProps} />)
385
386 await user.click(screen.getByText('Write a reply...'))
387 const textarea = screen.getByRole('textbox', { name: 'Reply' })
388 await user.type(textarea, 'My test reply')
389 await user.click(screen.getByRole('button', { name: 'Reply' }))
390
391 await waitFor(() => {
392 expect(mockCreateReply).toHaveBeenCalled()
393 })
394 })
395 })
396
397 describe('initialContent', () => {
398 it('populates textarea with initialContent and auto-expands', () => {
399 const initialText = '> quoted text\n\n'
400 render(<ReplyComposer {...defaultProps} initialContent={initialText} />)
401 expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument()
402 expect(screen.getByRole('textbox', { name: 'Reply' })).toHaveValue(initialText)
403 })
404 })
405
406 describe('expanded state accessibility', () => {
407 it('passes axe accessibility check in expanded state', async () => {
408 const user = userEvent.setup()
409 const { container } = render(<ReplyComposer {...defaultProps} />)
410
411 await user.click(screen.getByText('Write a reply...'))
412
413 const results = await axe(container)
414 expect(results).toHaveNoViolations()
415 })
416
417 it('passes axe accessibility check with reply target banner', async () => {
418 const { container } = render(
419 <ReplyComposer
420 {...defaultProps}
421 replyTarget={mockReplyTarget}
422 onClearReplyTarget={vi.fn()}
423 />
424 )
425
426 const results = await axe(container)
427 expect(results).toHaveNoViolations()
428 })
429 })
430
431 describe('keyboard shortcuts', () => {
432 it('collapses when Escape is pressed while expanded', async () => {
433 const user = userEvent.setup()
434 render(<ReplyComposer {...defaultProps} />)
435
436 // Expand first
437 await user.click(screen.getByText('Write a reply...'))
438 expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument()
439
440 // Press Escape
441 await user.keyboard('{Escape}')
442 expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
443 expect(screen.getByText('Write a reply...')).toBeInTheDocument()
444 })
445
446 it('does not collapse when Escape is pressed while already collapsed', async () => {
447 const user = userEvent.setup()
448 render(<ReplyComposer {...defaultProps} />)
449
450 // Press Escape while collapsed - should remain collapsed (no crash)
451 await user.keyboard('{Escape}')
452 expect(screen.getByText('Write a reply...')).toBeInTheDocument()
453 })
454
455 it('preserves draft content after Escape collapse', async () => {
456 const user = userEvent.setup()
457 render(<ReplyComposer {...defaultProps} />)
458
459 // Expand, type content, collapse with Escape
460 await user.click(screen.getByText('Write a reply...'))
461 const textarea = screen.getByRole('textbox', { name: 'Reply' })
462 await user.type(textarea, 'My draft reply')
463 await user.keyboard('{Escape}')
464
465 // Re-expand and verify draft is preserved
466 await user.click(screen.getByText('Write a reply...'))
467 expect(screen.getByRole('textbox', { name: 'Reply' })).toHaveValue('My draft reply')
468 })
469 })
470
471 describe('imperative handle', () => {
472 it('expands composer when expand() is called via ref', async () => {
473 const ref = { current: null } as React.RefObject<
474 import('./reply-composer').ReplyComposerHandle | null
475 >
476 render(<ReplyComposer {...defaultProps} ref={ref} />)
477
478 expect(screen.getByText('Write a reply...')).toBeInTheDocument()
479 expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
480
481 // Call expand via ref wrapped in act
482 act(() => {
483 ref.current?.expand()
484 })
485
486 await waitFor(() => {
487 expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument()
488 })
489 })
490 })
491
492 describe('className prop', () => {
493 it('applies custom className in collapsed state', () => {
494 const { container } = render(<ReplyComposer {...defaultProps} className="custom-class" />)
495 expect(container.firstChild).toHaveClass('custom-class')
496 })
497
498 it('applies custom className when locked', () => {
499 const { container } = render(
500 <ReplyComposer {...defaultProps} isLocked={true} className="custom-class" />
501 )
502 expect(container.firstChild).toHaveClass('custom-class')
503 })
504 })
505})