Barazo default frontend
barazo.forum
1/**
2 * Tests for LikeButton component.
3 * Interactive heart button for liking/unliking topics and replies.
4 */
5
6import { describe, it, expect, vi, beforeEach } from 'vitest'
7import { render, screen, waitFor } from '@testing-library/react'
8import userEvent from '@testing-library/user-event'
9import { axe } from 'vitest-axe'
10import { LikeButton } from './like-button'
11import { createMockOnboardingContext } from '@/test/mock-onboarding'
12import type { OnboardingContextValue } from '@/context/onboarding-context'
13
14// --- Mocks ---
15
16const mockToast = vi.fn()
17
18vi.mock('@/hooks/use-toast', () => ({
19 useToast: () => ({ toast: mockToast }),
20}))
21
22const mockGetAccessToken = vi.fn(() => 'mock-access-token')
23const mockAuthFetch = vi.fn()
24
25vi.mock('@/hooks/use-auth', () => ({
26 useAuth: () => ({
27 user: { did: 'did:plc:user-test-001', handle: 'test.bsky.social' },
28 isAuthenticated: mockGetAccessToken() !== null,
29 isLoading: false,
30 getAccessToken: mockGetAccessToken,
31 authFetch: mockAuthFetch,
32 login: vi.fn(),
33 logout: vi.fn(),
34 setSessionFromCallback: vi.fn(),
35 crossPostScopesGranted: false,
36 requestCrossPostAuth: vi.fn(),
37 }),
38}))
39
40let mockOnboardingContext: OnboardingContextValue = createMockOnboardingContext()
41
42vi.mock('@/context/onboarding-context', () => ({
43 useOnboardingContext: () => mockOnboardingContext,
44}))
45
46vi.mock('@/lib/api/client', () => ({
47 getReactions: vi.fn().mockResolvedValue({ reactions: [], cursor: null }),
48 createReaction: vi.fn().mockResolvedValue({
49 uri: 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/abc123',
50 cid: 'bafyrei-abc123',
51 rkey: 'abc123',
52 type: 'like',
53 subjectUri: 'at://did:plc:author/forum.barazo.topic.post/topic1',
54 createdAt: '2026-02-14T12:00:00.000Z',
55 }),
56 deleteReaction: vi.fn().mockResolvedValue(undefined),
57}))
58
59// Import after mocks so we can spy on them
60const { getReactions, createReaction, deleteReaction } = await import('@/lib/api/client')
61
62const defaultProps = {
63 subjectUri: 'at://did:plc:author/forum.barazo.topic.post/topic1',
64 subjectCid: 'bafyrei-topic1',
65 initialCount: 5,
66}
67
68beforeEach(() => {
69 vi.clearAllMocks()
70 mockToast.mockReset()
71 mockGetAccessToken.mockReturnValue('mock-access-token')
72 mockAuthFetch.mockReset()
73 mockOnboardingContext = createMockOnboardingContext()
74 vi.mocked(getReactions).mockResolvedValue({ reactions: [], cursor: null })
75 vi.mocked(createReaction).mockResolvedValue({
76 uri: 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/abc123',
77 cid: 'bafyrei-abc123',
78 rkey: 'abc123',
79 type: 'like',
80 subjectUri: defaultProps.subjectUri,
81 createdAt: '2026-02-14T12:00:00.000Z',
82 })
83 vi.mocked(deleteReaction).mockResolvedValue(undefined)
84})
85
86describe('LikeButton', () => {
87 describe('rendering', () => {
88 it('renders a button with heart icon and count', () => {
89 render(<LikeButton {...defaultProps} />)
90 const button = screen.getByRole('button', { name: /5 reactions/i })
91 expect(button).toBeInTheDocument()
92 })
93
94 it('displays the initial count', () => {
95 render(<LikeButton {...defaultProps} initialCount={12} />)
96 expect(screen.getByText('12')).toBeInTheDocument()
97 })
98
99 it('displays zero count', () => {
100 render(<LikeButton {...defaultProps} initialCount={0} />)
101 expect(screen.getByText('0')).toBeInTheDocument()
102 })
103 })
104
105 describe('fetching user reaction status', () => {
106 it('checks if the current user has already liked on mount', async () => {
107 render(<LikeButton {...defaultProps} />)
108 await waitFor(() => {
109 expect(getReactions).toHaveBeenCalledWith(
110 defaultProps.subjectUri,
111 { type: 'like' },
112 expect.objectContaining({
113 headers: expect.objectContaining({
114 Authorization: 'Bearer mock-access-token',
115 }),
116 })
117 )
118 })
119 })
120
121 it('shows filled state when user has already liked', async () => {
122 vi.mocked(getReactions).mockResolvedValueOnce({
123 reactions: [
124 {
125 uri: 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/existing',
126 rkey: 'existing',
127 authorDid: 'did:plc:user-test-001',
128 subjectUri: defaultProps.subjectUri,
129 subjectCid: defaultProps.subjectCid,
130 type: 'like',
131 communityDid: 'did:plc:community',
132 cid: 'bafyrei-existing',
133 createdAt: '2026-02-14T12:00:00.000Z',
134 },
135 ],
136 cursor: null,
137 })
138
139 render(<LikeButton {...defaultProps} />)
140 await waitFor(() => {
141 expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true')
142 })
143 })
144
145 it('shows unfilled state when user has not liked', async () => {
146 render(<LikeButton {...defaultProps} />)
147 await waitFor(() => {
148 expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'false')
149 })
150 })
151 })
152
153 describe('liking', () => {
154 it('calls createReaction when clicking an unliked button', async () => {
155 const user = userEvent.setup()
156 render(<LikeButton {...defaultProps} />)
157
158 await waitFor(() => {
159 expect(getReactions).toHaveBeenCalled()
160 })
161
162 await user.click(screen.getByRole('button'))
163
164 expect(createReaction).toHaveBeenCalledWith(
165 {
166 subjectUri: defaultProps.subjectUri,
167 subjectCid: defaultProps.subjectCid,
168 type: 'like',
169 },
170 'mock-access-token'
171 )
172 })
173
174 it('optimistically increments count on like', async () => {
175 const user = userEvent.setup()
176 render(<LikeButton {...defaultProps} initialCount={5} />)
177
178 await waitFor(() => {
179 expect(getReactions).toHaveBeenCalled()
180 })
181
182 await user.click(screen.getByRole('button'))
183 expect(screen.getByText('6')).toBeInTheDocument()
184 })
185
186 it('optimistically sets pressed state on like', async () => {
187 const user = userEvent.setup()
188 render(<LikeButton {...defaultProps} />)
189
190 await waitFor(() => {
191 expect(getReactions).toHaveBeenCalled()
192 })
193
194 await user.click(screen.getByRole('button'))
195 expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true')
196 })
197 })
198
199 describe('unliking', () => {
200 it('calls deleteReaction when clicking a liked button', async () => {
201 vi.mocked(getReactions).mockResolvedValueOnce({
202 reactions: [
203 {
204 uri: 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/existing',
205 rkey: 'existing',
206 authorDid: 'did:plc:user-test-001',
207 subjectUri: defaultProps.subjectUri,
208 subjectCid: defaultProps.subjectCid,
209 type: 'like',
210 communityDid: 'did:plc:community',
211 cid: 'bafyrei-existing',
212 createdAt: '2026-02-14T12:00:00.000Z',
213 },
214 ],
215 cursor: null,
216 })
217
218 const user = userEvent.setup()
219 render(<LikeButton {...defaultProps} initialCount={5} />)
220
221 await waitFor(() => {
222 expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true')
223 })
224
225 await user.click(screen.getByRole('button'))
226
227 expect(deleteReaction).toHaveBeenCalledWith(
228 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/existing',
229 'mock-access-token'
230 )
231 })
232
233 it('optimistically decrements count on unlike', async () => {
234 vi.mocked(getReactions).mockResolvedValueOnce({
235 reactions: [
236 {
237 uri: 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/existing',
238 rkey: 'existing',
239 authorDid: 'did:plc:user-test-001',
240 subjectUri: defaultProps.subjectUri,
241 subjectCid: defaultProps.subjectCid,
242 type: 'like',
243 communityDid: 'did:plc:community',
244 cid: 'bafyrei-existing',
245 createdAt: '2026-02-14T12:00:00.000Z',
246 },
247 ],
248 cursor: null,
249 })
250
251 const user = userEvent.setup()
252 render(<LikeButton {...defaultProps} initialCount={5} />)
253
254 await waitFor(() => {
255 expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true')
256 })
257
258 await user.click(screen.getByRole('button'))
259 expect(screen.getByText('4')).toBeInTheDocument()
260 })
261
262 it('does not go below zero on unlike', async () => {
263 vi.mocked(getReactions).mockResolvedValueOnce({
264 reactions: [
265 {
266 uri: 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/existing',
267 rkey: 'existing',
268 authorDid: 'did:plc:user-test-001',
269 subjectUri: defaultProps.subjectUri,
270 subjectCid: defaultProps.subjectCid,
271 type: 'like',
272 communityDid: 'did:plc:community',
273 cid: 'bafyrei-existing',
274 createdAt: '2026-02-14T12:00:00.000Z',
275 },
276 ],
277 cursor: null,
278 })
279
280 const user = userEvent.setup()
281 render(<LikeButton {...defaultProps} initialCount={0} />)
282
283 await waitFor(() => {
284 expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true')
285 })
286
287 await user.click(screen.getByRole('button'))
288 expect(screen.getByText('0')).toBeInTheDocument()
289 })
290 })
291
292 describe('error handling', () => {
293 it('reverts count on like failure', async () => {
294 vi.mocked(createReaction).mockRejectedValueOnce(new Error('Network error'))
295
296 const user = userEvent.setup()
297 render(<LikeButton {...defaultProps} initialCount={5} />)
298
299 await waitFor(() => {
300 expect(getReactions).toHaveBeenCalled()
301 })
302
303 await user.click(screen.getByRole('button'))
304
305 // After error: reverts to 5
306 await waitFor(() => {
307 expect(screen.getByText('5')).toBeInTheDocument()
308 })
309 })
310
311 it('shows error toast on like failure', async () => {
312 vi.mocked(createReaction).mockRejectedValueOnce(
313 new Error('API 502: Failed to write to remote PDS')
314 )
315
316 const user = userEvent.setup()
317 render(<LikeButton {...defaultProps} initialCount={5} />)
318
319 await waitFor(() => {
320 expect(getReactions).toHaveBeenCalled()
321 })
322
323 await user.click(screen.getByRole('button'))
324
325 await waitFor(() => {
326 expect(mockToast).toHaveBeenCalledWith(
327 expect.objectContaining({
328 title: 'Error',
329 variant: 'destructive',
330 })
331 )
332 })
333 })
334
335 it('reverts pressed state on like failure', async () => {
336 vi.mocked(createReaction).mockRejectedValueOnce(new Error('Network error'))
337
338 const user = userEvent.setup()
339 render(<LikeButton {...defaultProps} />)
340
341 await waitFor(() => {
342 expect(getReactions).toHaveBeenCalled()
343 })
344
345 await user.click(screen.getByRole('button'))
346
347 await waitFor(() => {
348 expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'false')
349 })
350 })
351 })
352
353 describe('unauthenticated state', () => {
354 it('does not fetch reactions when not authenticated', () => {
355 mockGetAccessToken.mockReturnValue(null as unknown as string)
356 render(<LikeButton {...defaultProps} />)
357 expect(getReactions).not.toHaveBeenCalled()
358 })
359
360 it('disables the button when not authenticated', () => {
361 mockGetAccessToken.mockReturnValue(null as unknown as string)
362 render(<LikeButton {...defaultProps} />)
363 expect(screen.getByRole('button')).toBeDisabled()
364 })
365 })
366
367 describe('onboarding gate', () => {
368 it('does not call createReaction when ensureOnboarded returns false', async () => {
369 mockOnboardingContext = createMockOnboardingContext({
370 ensureOnboarded: vi.fn(() => false),
371 })
372
373 const user = userEvent.setup()
374 render(<LikeButton {...defaultProps} />)
375
376 await waitFor(() => {
377 expect(getReactions).toHaveBeenCalled()
378 })
379
380 await user.click(screen.getByRole('button'))
381
382 expect(createReaction).not.toHaveBeenCalled()
383 expect(deleteReaction).not.toHaveBeenCalled()
384 })
385
386 it('proceeds normally when ensureOnboarded returns true', async () => {
387 mockOnboardingContext = createMockOnboardingContext({
388 ensureOnboarded: vi.fn(() => true),
389 })
390
391 const user = userEvent.setup()
392 render(<LikeButton {...defaultProps} />)
393
394 await waitFor(() => {
395 expect(getReactions).toHaveBeenCalled()
396 })
397
398 await user.click(screen.getByRole('button'))
399
400 expect(createReaction).toHaveBeenCalled()
401 })
402 })
403
404 describe('size variants', () => {
405 it('renders with default size', () => {
406 render(<LikeButton {...defaultProps} />)
407 expect(screen.getByRole('button')).toBeInTheDocument()
408 })
409
410 it('accepts sm size prop', () => {
411 render(<LikeButton {...defaultProps} size="sm" />)
412 expect(screen.getByRole('button')).toBeInTheDocument()
413 })
414 })
415
416 describe('accessibility', () => {
417 it('has aria-pressed reflecting liked state', async () => {
418 render(<LikeButton {...defaultProps} />)
419 await waitFor(() => {
420 expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'false')
421 })
422 })
423
424 it('has accessible label with count', () => {
425 render(<LikeButton {...defaultProps} initialCount={5} />)
426 expect(screen.getByRole('button', { name: /5 reactions/i })).toBeInTheDocument()
427 })
428
429 it('passes axe accessibility check', async () => {
430 const { container } = render(<LikeButton {...defaultProps} />)
431 const results = await axe(container)
432 expect(results).toHaveNoViolations()
433 })
434 })
435})