Barazo default frontend
barazo.forum
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})