From 52207c3d684a9466f6fff2fd319a23d0f1ef62c9 Mon Sep 17 00:00:00 2001 From: Aniekan Victory Date: Mon, 1 Jun 2026 10:35:33 +0100 Subject: [PATCH 01/10] [DASHBOARD] Add error banner for failed account fetch --- .../__tests__/useAccountOverview.test.ts | 82 +++++++++++- .../src/hooks/useAccountOverview.ts | 83 ++++++++++--- .../src/hooks/useWidgetErrorLogger.ts | 9 +- apps/web-dashboard/src/pages/Dashboard.tsx | 56 +++++++++ .../src/pages/__tests__/Dashboard.test.tsx | 117 ++++++++++++++++++ 5 files changed, 323 insertions(+), 24 deletions(-) create mode 100644 apps/web-dashboard/src/pages/__tests__/Dashboard.test.tsx diff --git a/apps/web-dashboard/src/hooks/__tests__/useAccountOverview.test.ts b/apps/web-dashboard/src/hooks/__tests__/useAccountOverview.test.ts index e2043acb..00e86f11 100644 --- a/apps/web-dashboard/src/hooks/__tests__/useAccountOverview.test.ts +++ b/apps/web-dashboard/src/hooks/__tests__/useAccountOverview.test.ts @@ -1,9 +1,26 @@ -import { renderHook, waitFor } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; -import { useAccountOverview } from '../useAccountOverview'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { + AccountNotFoundError, + HorizonUnavailableError, + useAccountOverview, +} from '../useAccountOverview'; describe('useAccountOverview', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + it('fetches account data successfully', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + balance: 1250.75, + nonce: 42, + status: 'active', + }), + } as Response); + const { result } = renderHook(() => useAccountOverview('GB...')); expect(result.current.isLoading).toBe(true); @@ -20,9 +37,62 @@ describe('useAccountOverview', () => { it('handles empty public key', () => { const { result } = renderHook(() => useAccountOverview('')); - // Depending on implementation, it might stay loading or set error - // In my current implementation it just returns initial state - expect(result.current.isLoading).toBe(true); + expect(result.current.isLoading).toBe(false); expect(result.current.data).toBeNull(); }); + + it('maps 404 responses to account-not-found errors', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 404, + } as Response); + + const { result } = renderHook(() => useAccountOverview('GB...')); + + await waitFor(() => expect(result.current.error).toBeInstanceOf(AccountNotFoundError)); + }); + + it('maps 500 responses to horizon-unavailable errors', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 500, + } as Response); + + const { result } = renderHook(() => useAccountOverview('GB...')); + + await waitFor(() => expect(result.current.error).toBeInstanceOf(HorizonUnavailableError)); + }); + + it('recovers successfully after retry', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce({ + ok: false, + status: 500, + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + balance: 9, + nonce: 7, + status: 'inactive', + }), + } as Response); + + const { result } = renderHook(() => useAccountOverview('GB...')); + + await waitFor(() => expect(result.current.error).toBeInstanceOf(HorizonUnavailableError)); + + await act(async () => { + await result.current.refetch(); + }); + + await waitFor(() => expect(result.current.error).toBeNull()); + expect(result.current.data).toEqual({ + balance: 9, + nonce: 7, + status: 'inactive', + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); }); diff --git a/apps/web-dashboard/src/hooks/useAccountOverview.ts b/apps/web-dashboard/src/hooks/useAccountOverview.ts index 7326d842..c955ae68 100644 --- a/apps/web-dashboard/src/hooks/useAccountOverview.ts +++ b/apps/web-dashboard/src/hooks/useAccountOverview.ts @@ -8,13 +8,58 @@ export interface AccountOverview { status: AccountStatus; } +export class AccountOverviewError extends Error { + code: 'ACCOUNT_NOT_FOUND' | 'HORIZON_UNAVAILABLE' | 'FETCH_FAILED'; + + constructor( + message: string, + code: 'ACCOUNT_NOT_FOUND' | 'HORIZON_UNAVAILABLE' | 'FETCH_FAILED', + ) { + super(message); + this.name = 'AccountOverviewError'; + this.code = code; + } +} + +export class AccountNotFoundError extends AccountOverviewError { + constructor() { + super('Account not found on network', 'ACCOUNT_NOT_FOUND'); + this.name = 'AccountNotFoundError'; + } +} + +export class HorizonUnavailableError extends AccountOverviewError { + constructor() { + super('Horizon is temporarily unavailable', 'HORIZON_UNAVAILABLE'); + this.name = 'HorizonUnavailableError'; + } +} + export interface UseAccountOverviewReturn { data: AccountOverview | null; isLoading: boolean; - error: Error | null; + error: AccountOverviewError | null; refetch: () => Promise; } +const MOCK_ACCOUNT_OVERVIEW: AccountOverview = { + balance: 1250.75, + nonce: 42, + status: 'active', +}; + +function classifyAccountOverviewError(status?: number): AccountOverviewError { + if (status === 404) { + return new AccountNotFoundError(); + } + + if (status && status >= 500) { + return new HorizonUnavailableError(); + } + + return new AccountOverviewError('Failed to fetch account data', 'FETCH_FAILED'); +} + /** * Hook to fetch account overview metrics (balance, nonce, status). * In production, this would use @ancore/core-sdk to query the Stellar network. @@ -22,31 +67,39 @@ export interface UseAccountOverviewReturn { export function useAccountOverview(publicKey: string): UseAccountOverviewReturn { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const fetchData = useCallback(async () => { - if (!publicKey) return; + if (!publicKey) { + setIsLoading(false); + setData(null); + return; + } setIsLoading(true); setError(null); try { - // Simulate network delay - await new Promise((resolve) => setTimeout(resolve, 600)); + const response = await fetch(`/api/account-overview?publicKey=${encodeURIComponent(publicKey)}`); - // Mock data - in a real app, this would be a multi-call or aggregate query - const mockData: AccountOverview = { - balance: 1250.75, - nonce: 42, - status: 'active', - }; + if (!response.ok) { + throw classifyAccountOverviewError(response.status); + } - // Simulate partial/missing data edge cases if needed for testing - // (Though the hook itself should probably return consistent types) + const payload = (await response.json()) as Partial; - setData(mockData); + setData({ + balance: payload.balance ?? MOCK_ACCOUNT_OVERVIEW.balance, + nonce: payload.nonce ?? MOCK_ACCOUNT_OVERVIEW.nonce, + status: payload.status ?? MOCK_ACCOUNT_OVERVIEW.status, + }); } catch (err) { - setError(err instanceof Error ? err : new Error('Failed to fetch account data')); + setData(null); + if (err instanceof AccountOverviewError) { + setError(err); + } else { + setError(new AccountOverviewError('Failed to fetch account data', 'FETCH_FAILED')); + } } finally { setIsLoading(false); } diff --git a/apps/web-dashboard/src/hooks/useWidgetErrorLogger.ts b/apps/web-dashboard/src/hooks/useWidgetErrorLogger.ts index fab958f9..5575c8d8 100644 --- a/apps/web-dashboard/src/hooks/useWidgetErrorLogger.ts +++ b/apps/web-dashboard/src/hooks/useWidgetErrorLogger.ts @@ -3,9 +3,12 @@ import type { ErrorInfo } from 'react'; export function useWidgetErrorLogger() { return useCallback((error: Error, info: ErrorInfo) => { - // Invoking our centralized logging mechanism. - // In a production app, this would route to an external service (e.g., Sentry, Datadog). - console.error('[Widget Error Logger] Caught isolated widget failure:', error); + const sanitizedError = { + name: error.name, + message: error.message, + }; + + console.error('[Widget Error Logger] Caught isolated widget failure:', sanitizedError); console.error('[Widget Error Logger] Component Stack:', info.componentStack); }, []); } diff --git a/apps/web-dashboard/src/pages/Dashboard.tsx b/apps/web-dashboard/src/pages/Dashboard.tsx index e378857b..a4cb50cc 100644 --- a/apps/web-dashboard/src/pages/Dashboard.tsx +++ b/apps/web-dashboard/src/pages/Dashboard.tsx @@ -5,11 +5,60 @@ import { AccountOverviewGrid } from '../widgets/AccountOverviewGrid'; import { useAccountData } from '../hooks/useAccountData'; import { useIndexerActivity } from '../hooks/useIndexerActivity'; import { DashboardPageSkeleton } from '../components/LoadingSkeletons'; +import { + AccountNotFoundError, + HorizonUnavailableError, + useAccountOverview, +} from '../hooks/useAccountOverview'; const DEFAULT_ADDRESS = 'GABC...XYZ'; +const AccountFetchAlert: React.FC<{ + error: Error; + onRetry: () => Promise; + retrying: boolean; +}> = ({ error, onRetry, retrying }) => { + let title = 'Unable to load account overview'; + let message = 'Try again in a moment.'; + + if (error instanceof AccountNotFoundError) { + title = 'Account not found'; + message = 'This account does not exist on the selected network.'; + } else if (error instanceof HorizonUnavailableError) { + title = 'Horizon is unavailable'; + message = 'The Stellar Horizon service is temporarily unavailable. Please retry shortly.'; + } + + return ( +
+
+
+

{title}

+

{message}

+
+ +
+
+ ); +}; + export const Dashboard: React.FC = () => { const { account, loading: accountLoading, error: accountError } = useAccountData(DEFAULT_ADDRESS); + const { + error: overviewError, + refetch: refetchOverview, + isLoading: overviewLoading, + } = useAccountOverview(DEFAULT_ADDRESS); const { items: transactions, loading: txLoading, @@ -28,6 +77,13 @@ export const Dashboard: React.FC = () => { return (

Dashboard

+ {overviewError && ( + + )} diff --git a/apps/web-dashboard/src/pages/__tests__/Dashboard.test.tsx b/apps/web-dashboard/src/pages/__tests__/Dashboard.test.tsx new file mode 100644 index 00000000..51930ed0 --- /dev/null +++ b/apps/web-dashboard/src/pages/__tests__/Dashboard.test.tsx @@ -0,0 +1,117 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Dashboard } from '../Dashboard'; +import { + AccountNotFoundError, + HorizonUnavailableError, +} from '../../hooks/useAccountOverview'; + +const mockUseAccountData = vi.fn(); +const mockUseIndexerActivity = vi.fn(); +const mockUseAccountOverview = vi.fn(); + +vi.mock('../../hooks/useAccountData', () => ({ + useAccountData: () => mockUseAccountData(), +})); + +vi.mock('../../hooks/useIndexerActivity', () => ({ + useIndexerActivity: () => mockUseIndexerActivity(), +})); + +vi.mock('../../hooks/useAccountOverview', async () => { + const actual = await vi.importActual( + '../../hooks/useAccountOverview', + ); + + return { + ...actual, + useAccountOverview: () => mockUseAccountOverview(), + }; +}); + +vi.mock('../../components/AccountSummary', () => ({ + AccountSummary: () =>
Account Summary
, +})); + +vi.mock('../../components/TransactionList', () => ({ + TransactionList: () =>
Transaction List
, +})); + +vi.mock('../../widgets/AccountOverviewGrid', () => ({ + AccountOverviewGrid: () =>
Overview Grid
, +})); + +vi.mock('../../components/LoadingSkeletons', () => ({ + DashboardPageSkeleton: () =>
Loading Dashboard
, +})); + +describe('Dashboard', () => { + beforeEach(() => { + mockUseAccountData.mockReturnValue({ + account: { + address: 'GABC...XYZ', + balance: 100, + status: 'active', + lastActivity: new Date('2026-04-24T10:00:00Z'), + }, + loading: false, + error: null, + }); + + mockUseIndexerActivity.mockReturnValue({ + items: [], + loading: false, + error: null, + loadMore: vi.fn(), + hasMore: false, + }); + + mockUseAccountOverview.mockReturnValue({ + data: null, + isLoading: false, + error: null, + refetch: vi.fn(), + }); + }); + + it('shows account-not-found copy for 404 failures', () => { + mockUseAccountOverview.mockReturnValue({ + data: null, + isLoading: false, + error: new AccountNotFoundError(), + refetch: vi.fn(), + }); + + render(); + + expect(screen.getByRole('alert')).toHaveTextContent('Account not found'); + expect(screen.getByRole('alert')).toHaveTextContent( + 'This account does not exist on the selected network.', + ); + }); + + it('shows horizon outage copy for 500 failures and retries', async () => { + const user = userEvent.setup(); + const refetch = vi.fn().mockResolvedValue(undefined); + + mockUseAccountOverview.mockReturnValue({ + data: null, + isLoading: false, + error: new HorizonUnavailableError(), + refetch, + }); + + render(); + + expect(screen.getByRole('alert')).toHaveTextContent('Horizon is unavailable'); + expect(screen.getByRole('alert')).toHaveTextContent( + 'The Stellar Horizon service is temporarily unavailable. Please retry shortly.', + ); + + await user.click(screen.getByRole('button', { name: 'Retry' })); + + await waitFor(() => expect(refetch).toHaveBeenCalledTimes(1)); + }); +}); From 97a8b1e87554a96ec648f6264a7417f3c1bda963 Mon Sep 17 00:00:00 2001 From: Aniekan Victory Date: Mon, 1 Jun 2026 11:35:05 +0100 Subject: [PATCH 02/10] test: fix dashboard widget test regressions --- .../widgets/__tests__/AccountWidgets.test.tsx | 32 +++++++++++++++---- .../__tests__/WidgetErrorBoundary.test.tsx | 5 ++- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/apps/web-dashboard/src/widgets/__tests__/AccountWidgets.test.tsx b/apps/web-dashboard/src/widgets/__tests__/AccountWidgets.test.tsx index 26fbf8d4..20c6c704 100644 --- a/apps/web-dashboard/src/widgets/__tests__/AccountWidgets.test.tsx +++ b/apps/web-dashboard/src/widgets/__tests__/AccountWidgets.test.tsx @@ -15,11 +15,29 @@ vi.mock('lucide-react', () => ({ // Mock @ancore/ui-kit vi.mock('@ancore/ui-kit', () => ({ - Card: ({ children, className }: any) =>
{children}
, - CardHeader: ({ children, className }: any) =>
{children}
, - CardTitle: ({ children, className }: any) =>
{children}
, - CardContent: ({ children, className }: any) =>
{children}
, - Skeleton: ({ className }: any) =>