Barazo default frontend barazo.forum
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(like-button): surface API errors via toast (#133)

* fix(like-button): surface API errors via toast instead of silent revert

The LikeButton's catch block silently reverted optimistic updates without
telling the user what went wrong. This makes it impossible to diagnose
why likes don't persist on staging. Add useToast to show the actual error
message (e.g., 403 onboarding required, 502 PDS write failure).

* fix(test): add useToast and api/client mocks for LikeButton integration

Tests that render components containing LikeButton now need useToast
mocked since the error toast was added. The page test also needs the
api/client mock with importOriginal to provide ApiError and other exports.

authored by

Guido X Jansen and committed by
GitHub
8474393f a25cbfb6

+61 -2
+11
src/app/t/[slug]/[rkey]/page.test.tsx
··· 7 7 import TopicPage from './page' 8 8 import { mockTopics, mockReplies, mockCategories } from '@/mocks/data' 9 9 10 + vi.mock('@/hooks/use-toast', () => ({ 11 + useToast: () => ({ toast: vi.fn() }), 12 + })) 13 + 14 + vi.mock('@/lib/api/client', async (importOriginal) => ({ 15 + ...(await importOriginal<typeof import('@/lib/api/client')>()), 16 + getReactions: vi.fn().mockResolvedValue({ reactions: [], cursor: null }), 17 + createReaction: vi.fn().mockResolvedValue({ uri: 'at://test', cid: 'bafyrei-test' }), 18 + deleteReaction: vi.fn().mockResolvedValue(undefined), 19 + })) 20 + 10 21 // Mock useAuth hook 11 22 vi.mock('@/hooks/use-auth', () => ({ 12 23 useAuth: () => ({
+31
src/components/like-button.test.tsx
··· 11 11 12 12 // --- Mocks --- 13 13 14 + const mockToast = vi.fn() 15 + 16 + vi.mock('@/hooks/use-toast', () => ({ 17 + useToast: () => ({ toast: mockToast }), 18 + })) 19 + 14 20 const mockGetAccessToken = vi.fn(() => 'mock-access-token') 15 21 const mockAuthFetch = vi.fn() 16 22 ··· 53 59 54 60 beforeEach(() => { 55 61 vi.clearAllMocks() 62 + mockToast.mockReset() 56 63 mockGetAccessToken.mockReturnValue('mock-access-token') 57 64 mockAuthFetch.mockReset() 58 65 vi.mocked(getReactions).mockResolvedValue({ reactions: [], cursor: null }) ··· 289 296 // After error: reverts to 5 290 297 await waitFor(() => { 291 298 expect(screen.getByText('5')).toBeInTheDocument() 299 + }) 300 + }) 301 + 302 + it('shows error toast on like failure', async () => { 303 + vi.mocked(createReaction).mockRejectedValueOnce( 304 + new Error('API 502: Failed to write to remote PDS') 305 + ) 306 + 307 + const user = userEvent.setup() 308 + render(<LikeButton {...defaultProps} initialCount={5} />) 309 + 310 + await waitFor(() => { 311 + expect(getReactions).toHaveBeenCalled() 312 + }) 313 + 314 + await user.click(screen.getByRole('button')) 315 + 316 + await waitFor(() => { 317 + expect(mockToast).toHaveBeenCalledWith( 318 + expect.objectContaining({ 319 + title: 'Error', 320 + variant: 'destructive', 321 + }) 322 + ) 292 323 }) 293 324 }) 294 325
+6 -2
src/components/like-button.tsx
··· 9 9 import { useState, useEffect, useCallback, useRef } from 'react' 10 10 import { Heart } from '@phosphor-icons/react' 11 11 import { useAuth } from '@/hooks/use-auth' 12 + import { useToast } from '@/hooks/use-toast' 12 13 import { getReactions, createReaction, deleteReaction } from '@/lib/api/client' 13 14 import { cn } from '@/lib/utils' 14 15 import { formatCompactNumber } from '@/lib/format' ··· 29 30 className, 30 31 }: LikeButtonProps) { 31 32 const { user, isAuthenticated, getAccessToken } = useAuth() 33 + const { toast } = useToast() 32 34 const [liked, setLiked] = useState(false) 33 35 const [count, setCount] = useState(initialCount) 34 36 const [pending, setPending] = useState(false) ··· 97 99 const result = await createReaction({ subjectUri, subjectCid, type: 'like' }, token) 98 100 reactionUriRef.current = result.uri 99 101 } 100 - } catch { 102 + } catch (err) { 101 103 // Revert optimistic update 102 104 setLiked(wasLiked) 103 105 setCount(previousCount) 104 106 reactionUriRef.current = previousUri 107 + const message = err instanceof Error ? err.message : 'Failed to update reaction' 108 + toast({ title: 'Error', description: message, variant: 'destructive' }) 105 109 } finally { 106 110 setPending(false) 107 111 } 108 - }, [liked, count, pending, subjectUri, subjectCid, getAccessToken]) 112 + }, [liked, count, pending, subjectUri, subjectCid, getAccessToken, toast]) 109 113 110 114 const iconSize = size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4' 111 115
+4
src/components/reply-card.test.tsx
··· 9 9 import { ReplyCard } from './reply-card' 10 10 import { mockReplies, mockAuthorDeletedReply, mockModDeletedReply } from '@/mocks/data' 11 11 12 + vi.mock('@/hooks/use-toast', () => ({ 13 + useToast: () => ({ toast: vi.fn() }), 14 + })) 15 + 12 16 vi.mock('@/hooks/use-auth', () => ({ 13 17 useAuth: () => ({ 14 18 user: { did: 'did:plc:user-test-001', handle: 'test.bsky.social' },
+5
src/components/reply-thread.test.tsx
··· 8 8 import { ReplyThread } from './reply-thread' 9 9 import { mockReplies } from '@/mocks/data' 10 10 11 + vi.mock('@/hooks/use-toast', () => ({ 12 + useToast: () => ({ toast: vi.fn(), dismiss: vi.fn() }), 13 + })) 14 + 11 15 vi.mock('@/hooks/use-auth', () => ({ 12 16 useAuth: () => ({ 13 17 user: { did: 'did:plc:user-test-001', handle: 'test.bsky.social' }, ··· 27 31 getReactions: vi.fn().mockResolvedValue({ reactions: [], cursor: null }), 28 32 createReaction: vi.fn().mockResolvedValue({ uri: 'at://test', cid: 'bafyrei-test' }), 29 33 deleteReaction: vi.fn().mockResolvedValue(undefined), 34 + updateReply: vi.fn(), 30 35 })) 31 36 32 37 describe('ReplyThread', () => {
+4
src/components/topic-view.test.tsx
··· 9 9 import { TopicView } from './topic-view' 10 10 import { mockTopics, mockUsers, mockAuthorDeletedTopic, mockModDeletedTopic } from '@/mocks/data' 11 11 12 + vi.mock('@/hooks/use-toast', () => ({ 13 + useToast: () => ({ toast: vi.fn() }), 14 + })) 15 + 12 16 vi.mock('@/hooks/use-auth', () => ({ 13 17 useAuth: () => ({ 14 18 user: { did: 'did:plc:user-test-001', handle: 'test.bsky.social' },