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