Barazo default frontend barazo.forum
at main 505 lines 18 kB view raw
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})