Barazo default frontend barazo.forum
at main 300 lines 12 kB view raw
1/** 2 * Tests for TopicView component. 3 */ 4 5import { describe, it, expect, vi } from 'vitest' 6import { render, screen } from '@testing-library/react' 7import userEvent from '@testing-library/user-event' 8import { axe } from 'vitest-axe' 9import { TopicView } from './topic-view' 10import { mockTopics, mockUsers, mockAuthorDeletedTopic, mockModDeletedTopic } from '@/mocks/data' 11import { createMockOnboardingContext } from '@/test/mock-onboarding' 12 13vi.mock('@/hooks/use-toast', () => ({ 14 useToast: () => ({ toast: vi.fn() }), 15})) 16 17vi.mock('@/context/onboarding-context', () => ({ 18 useOnboardingContext: () => createMockOnboardingContext(), 19})) 20 21vi.mock('@/hooks/use-auth', () => ({ 22 useAuth: () => ({ 23 user: { did: 'did:plc:user-test-001', handle: 'test.bsky.social' }, 24 isAuthenticated: true, 25 isLoading: false, 26 getAccessToken: () => 'mock-access-token', 27 authFetch: vi.fn(), 28 login: vi.fn(), 29 logout: vi.fn(), 30 setSessionFromCallback: vi.fn(), 31 crossPostScopesGranted: false, 32 requestCrossPostAuth: vi.fn(), 33 }), 34})) 35 36vi.mock('@/lib/api/client', () => ({ 37 getReactions: vi.fn().mockResolvedValue({ reactions: [], cursor: null }), 38 createReaction: vi.fn().mockResolvedValue({ uri: 'at://test', cid: 'bafyrei-test' }), 39 deleteReaction: vi.fn().mockResolvedValue(undefined), 40})) 41 42const topic = mockTopics[0]! 43const baseTopic = topic 44const editedTopic = { 45 ...baseTopic, 46 indexedAt: new Date(new Date(baseTopic.publishedAt).getTime() + 60_000).toISOString(), 47} 48 49const mockReactions = [ 50 { type: 'like', count: 5, reacted: false }, 51 { type: 'celebrate', count: 2, reacted: true }, 52] 53 54describe('TopicView', () => { 55 it('renders topic title as h2', () => { 56 render(<TopicView topic={topic} />) 57 const heading = screen.getByRole('heading', { level: 2, name: topic.title }) 58 expect(heading).toBeInTheDocument() 59 }) 60 61 it('renders topic content via markdown', () => { 62 render(<TopicView topic={topic} />) 63 expect(screen.getByText(topic.content)).toBeInTheDocument() 64 }) 65 66 it('renders author display name with link', () => { 67 render(<TopicView topic={topic} />) 68 expect(screen.getByText('Jay')).toBeInTheDocument() 69 const authorLink = screen.getByRole('link', { name: /Jay/ }) 70 expect(authorLink).toHaveAttribute('href', `/profile/${mockUsers[0]!.handle}`) 71 }) 72 73 it('falls back to DID when author profile is missing', () => { 74 const topicWithoutAuthor = { ...topic, author: undefined } 75 render(<TopicView topic={topicWithoutAuthor} />) 76 expect(screen.getByText(mockUsers[0]!.did)).toBeInTheDocument() 77 }) 78 79 it('renders category link', () => { 80 render(<TopicView topic={topic} />) 81 const link = screen.getByRole('link', { name: topic.category }) 82 expect(link).toHaveAttribute('href', `/c/${topic.category}`) 83 }) 84 85 it('renders tags', () => { 86 render(<TopicView topic={topic} />) 87 for (const tag of topic.tags ?? []) { 88 expect(screen.getByText(`#${tag}`)).toBeInTheDocument() 89 } 90 }) 91 92 it('renders reply count as static text when onReply is not provided', () => { 93 render(<TopicView topic={topic} />) 94 expect(screen.getByLabelText(`${topic.replyCount} replies`)).toBeInTheDocument() 95 expect(screen.getByLabelText(`${topic.replyCount} replies`).tagName).toBe('SPAN') 96 }) 97 98 it('renders reply count as clickable button when onReply is provided', () => { 99 render(<TopicView topic={topic} onReply={vi.fn()} />) 100 const replyButton = screen.getByRole('button', { name: /reply to this topic/i }) 101 expect(replyButton).toBeInTheDocument() 102 }) 103 104 it('calls onReply when reply button is clicked', async () => { 105 const user = userEvent.setup() 106 const onReply = vi.fn() 107 render(<TopicView topic={topic} onReply={onReply} />) 108 await user.click(screen.getByRole('button', { name: /reply to this topic/i })) 109 expect(onReply).toHaveBeenCalledTimes(1) 110 }) 111 112 it('renders reaction count', () => { 113 render(<TopicView topic={topic} />) 114 expect(screen.getByLabelText(`${topic.reactionCount} reactions`)).toBeInTheDocument() 115 }) 116 117 it('uses article element with aria-labelledby', () => { 118 const { container } = render(<TopicView topic={topic} />) 119 const article = container.querySelector('article') 120 expect(article).toBeInTheDocument() 121 expect(article).toHaveAttribute('aria-labelledby') 122 }) 123 124 it('includes anchor link for post', () => { 125 const { container } = render(<TopicView topic={topic} />) 126 const article = container.querySelector('article') 127 expect(article).toHaveAttribute('id', 'post-1') 128 }) 129 130 it('passes axe accessibility check', async () => { 131 const { container } = render(<TopicView topic={topic} />) 132 const results = await axe(container) 133 expect(results).toHaveNoViolations() 134 }) 135 136 it('renders reaction bar when reactions are provided', () => { 137 render(<TopicView topic={topic} reactions={mockReactions} onReactionToggle={vi.fn()} />) 138 expect(screen.getByRole('group', { name: 'Reactions' })).toBeInTheDocument() 139 }) 140 141 it('does not render reactions when not provided', () => { 142 render(<TopicView topic={topic} />) 143 expect(screen.queryByRole('group', { name: 'Reactions' })).not.toBeInTheDocument() 144 }) 145 146 it('renders moderation controls for moderators', () => { 147 render(<TopicView topic={topic} isModerator={true} onModerationAction={vi.fn()} />) 148 expect(screen.getByRole('group', { name: /moderation/i })).toBeInTheDocument() 149 }) 150 151 it('renders report button when canReport is true', () => { 152 render(<TopicView topic={topic} canReport={true} onReport={vi.fn()} />) 153 expect(screen.getByRole('button', { name: /report/i })).toBeInTheDocument() 154 }) 155 156 it('renders self-label indicator when selfLabels are provided', () => { 157 render(<TopicView topic={topic} selfLabels={['sexual']} />) 158 expect(screen.getByText(/content warning/i)).toBeInTheDocument() 159 }) 160 161 it('calls onReactionToggle when reaction is clicked', async () => { 162 const user = userEvent.setup() 163 const onToggle = vi.fn() 164 render(<TopicView topic={topic} reactions={mockReactions} onReactionToggle={onToggle} />) 165 await user.click(screen.getByRole('button', { name: /like/i })) 166 expect(onToggle).toHaveBeenCalledWith('like') 167 }) 168 169 describe('edit button', () => { 170 it('renders edit button when canEdit is true and onEdit is provided', () => { 171 render(<TopicView topic={topic} canEdit={true} onEdit={vi.fn()} />) 172 expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument() 173 }) 174 175 it('does not render edit button when canEdit is false', () => { 176 render(<TopicView topic={topic} canEdit={false} onEdit={vi.fn()} />) 177 expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument() 178 }) 179 180 it('does not render edit button when canEdit is undefined', () => { 181 render(<TopicView topic={topic} onEdit={vi.fn()} />) 182 expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument() 183 }) 184 185 it('does not render edit button when onEdit is not provided', () => { 186 render(<TopicView topic={topic} canEdit={true} />) 187 expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument() 188 }) 189 190 it('calls onEdit callback when edit button is clicked', async () => { 191 const user = userEvent.setup() 192 const onEdit = vi.fn() 193 render(<TopicView topic={topic} canEdit={true} onEdit={onEdit} />) 194 await user.click(screen.getByRole('button', { name: /edit/i })) 195 expect(onEdit).toHaveBeenCalledTimes(1) 196 }) 197 198 it('passes axe accessibility check with edit button visible', async () => { 199 const { container } = render(<TopicView topic={topic} canEdit={true} onEdit={vi.fn()} />) 200 const results = await axe(container) 201 expect(results).toHaveNoViolations() 202 }) 203 }) 204 205 describe('edited indicator', () => { 206 it('shows "(edited)" when indexedAt is more than 30s after publishedAt', () => { 207 render(<TopicView topic={editedTopic} />) 208 expect(screen.getByText('(edited)')).toBeInTheDocument() 209 }) 210 211 it('does not show "(edited)" when timestamps are close', () => { 212 render(<TopicView topic={topic} />) 213 expect(screen.queryByText('(edited)')).not.toBeInTheDocument() 214 }) 215 }) 216 217 describe('tombstone: author-deleted topics', () => { 218 it('shows author-deleted placeholder text', () => { 219 render(<TopicView topic={mockAuthorDeletedTopic} />) 220 expect(screen.getByText('This topic was removed by the author.')).toBeInTheDocument() 221 }) 222 223 it('does not render topic content for author-deleted topics', () => { 224 const { container } = render(<TopicView topic={mockAuthorDeletedTopic} />) 225 expect(container.querySelector('.prose')).not.toBeInTheDocument() 226 }) 227 228 it('does not render the API placeholder title for author-deleted topics', () => { 229 render(<TopicView topic={mockAuthorDeletedTopic} />) 230 expect(screen.queryByText('[Deleted by author]')).not.toBeInTheDocument() 231 }) 232 233 it('shows [deleted] instead of author identity for author-deleted topics', () => { 234 render(<TopicView topic={mockAuthorDeletedTopic} />) 235 expect(screen.getByText('[deleted]')).toBeInTheDocument() 236 }) 237 238 it('does not render category or tags for author-deleted topics', () => { 239 render(<TopicView topic={mockAuthorDeletedTopic} />) 240 expect(screen.queryByText(mockAuthorDeletedTopic.category)).not.toBeInTheDocument() 241 }) 242 243 it('does not render reactions footer for author-deleted topics', () => { 244 render( 245 <TopicView 246 topic={mockAuthorDeletedTopic} 247 reactions={[{ type: 'like', count: 3, reacted: true }]} 248 onReactionToggle={vi.fn()} 249 /> 250 ) 251 expect(screen.queryByRole('group', { name: 'Reactions' })).not.toBeInTheDocument() 252 }) 253 254 it('does not render report button for author-deleted topics', () => { 255 render(<TopicView topic={mockAuthorDeletedTopic} canReport={true} onReport={vi.fn()} />) 256 expect(screen.queryByRole('button', { name: /report/i })).not.toBeInTheDocument() 257 }) 258 259 it('uses muted styling for author-deleted topics', () => { 260 const { container } = render(<TopicView topic={mockAuthorDeletedTopic} />) 261 const article = container.querySelector('article') 262 expect(article?.className).toContain('bg-muted/50') 263 }) 264 265 it('passes axe accessibility check for author-deleted topics', async () => { 266 const { container } = render(<TopicView topic={mockAuthorDeletedTopic} />) 267 const results = await axe(container) 268 expect(results).toHaveNoViolations() 269 }) 270 }) 271 272 describe('tombstone: moderator-deleted topics', () => { 273 it('shows moderator-deleted placeholder text', () => { 274 render(<TopicView topic={mockModDeletedTopic} />) 275 expect(screen.getByText('This topic was removed by a moderator.')).toBeInTheDocument() 276 }) 277 278 it('does not render the API placeholder title for mod-deleted topics', () => { 279 render(<TopicView topic={mockModDeletedTopic} />) 280 expect(screen.queryByText('[Removed by moderator]')).not.toBeInTheDocument() 281 }) 282 283 it('shows [deleted] instead of author identity for mod-deleted topics', () => { 284 render(<TopicView topic={mockModDeletedTopic} />) 285 expect(screen.getByText('[deleted]')).toBeInTheDocument() 286 }) 287 288 it('uses muted styling for mod-deleted topics', () => { 289 const { container } = render(<TopicView topic={mockModDeletedTopic} />) 290 const article = container.querySelector('article') 291 expect(article?.className).toContain('bg-muted/50') 292 }) 293 294 it('passes axe accessibility check for mod-deleted topics', async () => { 295 const { container } = render(<TopicView topic={mockModDeletedTopic} />) 296 const results = await axe(container) 297 expect(results).toHaveNoViolations() 298 }) 299 }) 300})