diff --git a/apps/extension-wallet/src/router/__tests__/router.test.tsx b/apps/extension-wallet/src/router/__tests__/router.test.tsx index 2b7a5aed..6be9aee6 100644 --- a/apps/extension-wallet/src/router/__tests__/router.test.tsx +++ b/apps/extension-wallet/src/router/__tests__/router.test.tsx @@ -250,6 +250,8 @@ describe('extension transaction history filters', () => { /> ); - expect(screen.getByText('No transactions match this filter.')).toBeInTheDocument(); + expect(screen.getByText('No received transactions')).toBeInTheDocument(); + expect(screen.getByText('Incoming payments will appear here.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Reset filter' })).toBeInTheDocument(); }); }); diff --git a/apps/extension-wallet/src/screens/Settings/SecuritySettings.tsx b/apps/extension-wallet/src/screens/Settings/SecuritySettings.tsx index 8a00494c..82b8ca59 100644 --- a/apps/extension-wallet/src/screens/Settings/SecuritySettings.tsx +++ b/apps/extension-wallet/src/screens/Settings/SecuritySettings.tsx @@ -4,6 +4,7 @@ import { Button, Input } from '@ancore/ui-kit'; import { VaultExportError, revealVaultSecret, + verifyVaultPassword, type VaultExportKind, } from '../../security/vault-export'; import { useTransferPolicy } from '../../hooks/useTransferPolicy'; @@ -19,6 +20,14 @@ type SecurityView = | 'transfer-limits' | 'active-sessions'; +type SensitiveToggle = { + label: string; + description: string; + currentValue: boolean; + nextValue: boolean; + onConfirm: (value: boolean) => void; +} | null; + interface SecuritySettingsProps { autoLockTimeout: number; onAutoLockChange: (minutes: number) => void; @@ -521,6 +530,7 @@ export function SecuritySettings({ onBack, }: SecuritySettingsProps) { const [view, setView] = React.useState('menu'); + const [sensitiveToggle, setSensitiveToggle] = React.useState(null); const titles: Record = { menu: 'Security', @@ -537,6 +547,16 @@ export function SecuritySettings({ else setView('menu'); } + function handleSensitiveToggleRequest(nextValue: boolean) { + setSensitiveToggle({ + label: 'Require password for exports', + description: 'Re-enter your password to change this sensitive setting.', + currentValue: requirePasswordForSensitiveActions, + nextValue, + onConfirm: onRequirePasswordForSensitiveActionsChange, + }); + } + return (
@@ -546,7 +566,7 @@ export function SecuritySettings({ autoLockTimeout={autoLockTimeout} onNavigate={setView} requirePasswordForSensitiveActions={requirePasswordForSensitiveActions} - onRequirePasswordForSensitiveActionsChange={onRequirePasswordForSensitiveActionsChange} + onRequirePasswordForSensitiveActionsChange={handleSensitiveToggleRequest} /> )} {view === 'change-password' && setView('menu')} />} @@ -579,6 +599,100 @@ export function SecuritySettings({ /> )} {view === 'active-sessions' && setView('menu')} />} + {sensitiveToggle && ( + setSensitiveToggle(null)} + onConfirm={(value) => { + sensitiveToggle.onConfirm(value); + setSensitiveToggle(null); + }} + /> + )} +
+ ); +} + +function SensitiveSettingConfirmDialog({ + label, + description, + nextValue, + onCancel, + onConfirm, +}: { + label: string; + description: string; + nextValue: boolean; + onCancel: () => void; + onConfirm: (value: boolean) => void; +}) { + const [password, setPassword] = React.useState(''); + const [error, setError] = React.useState(''); + const [isSubmitting, setIsSubmitting] = React.useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + + if (!password) { + setError('Enter your password.'); + return; + } + + setIsSubmitting(true); + try { + const verified = await verifyVaultPassword(password); + if (!verified) { + setError('Incorrect password.'); + return; + } + + onConfirm(nextValue); + setPassword(''); + } catch { + setError('Unable to verify password.'); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+
+
+ +
+

{label}

+

{description}

+
+
+
+ + ) => + setPassword(event.target.value) + } + /> +
+ {error &&

{error}

} +
+ + +
+
+
); } diff --git a/apps/extension-wallet/src/screens/Settings/__tests__/settings.test.tsx b/apps/extension-wallet/src/screens/Settings/__tests__/settings.test.tsx index a54a57f8..121b67a1 100644 --- a/apps/extension-wallet/src/screens/Settings/__tests__/settings.test.tsx +++ b/apps/extension-wallet/src/screens/Settings/__tests__/settings.test.tsx @@ -15,7 +15,11 @@ import { SettingsScreen } from '../SettingsScreen'; import { NetworkSettings } from '../NetworkSettings'; import { SecuritySettings } from '../SecuritySettings'; import { AboutScreen } from '../AboutScreen'; -import { revealVaultSecret, VaultExportError } from '../../../security/vault-export'; +import { + revealVaultSecret, + verifyVaultPassword, + VaultExportError, +} from '../../../security/vault-export'; import { SettingsGroup, SettingItem } from '../../../components/SettingsGroup'; vi.mock('../../../security/vault-export', () => ({ @@ -28,6 +32,7 @@ vi.mock('../../../security/vault-export', () => ({ revealVaultSecret: vi.fn(async ({ kind }: { kind: 'privateKey' | 'mnemonic' }) => kind === 'privateKey' ? 'STESTPRIVATEKEY' : 'word '.repeat(12).trim() ), + verifyVaultPassword: vi.fn(async () => true), })); function renderSettingsScreen() { @@ -191,6 +196,7 @@ describe('SecuritySettings', () => { vi.mocked(revealVaultSecret).mockImplementation(async ({ kind }) => kind === 'privateKey' ? 'STESTPRIVATEKEY' : 'word '.repeat(12).trim() ); + vi.mocked(verifyVaultPassword).mockResolvedValue(true); }); it('renders security menu items', () => { @@ -251,9 +257,39 @@ describe('SecuritySettings', () => { /> ); await userEvent.click(screen.getByText('Require password for exports')); + expect( + screen.getByText(/re-enter your password to change this sensitive setting/i) + ).toBeInTheDocument(); + + await userEvent.type(screen.getByPlaceholderText(/enter password to continue/i), 'mypassword'); + await userEvent.click(screen.getByRole('button', { name: /confirm/i })); + + expect(verifyVaultPassword).toHaveBeenCalledWith('mypassword'); expect(onRequirePasswordForSensitiveActionsChange).toHaveBeenCalledWith(false); }); + it('shows error when sensitive toggle password is wrong', async () => { + vi.mocked(verifyVaultPassword).mockResolvedValueOnce(false); + + const onRequirePasswordForSensitiveActionsChange = vi.fn(); + render( + + ); + + await userEvent.click(screen.getByText('Require password for exports')); + await userEvent.type( + screen.getByPlaceholderText(/enter password to continue/i), + 'wrong-password' + ); + await userEvent.click(screen.getByRole('button', { name: /confirm/i })); + + expect(screen.getByText('Incorrect password.')).toBeInTheDocument(); + expect(onRequirePasswordForSensitiveActionsChange).not.toHaveBeenCalled(); + }); + it('shows export mnemonic warning', async () => { render(); await userEvent.click(screen.getByText('Export Recovery Phrase')); diff --git a/apps/extension-wallet/src/security/__tests__/vault-export.test.ts b/apps/extension-wallet/src/security/__tests__/vault-export.test.ts index b3e620aa..f2d8b2ec 100644 --- a/apps/extension-wallet/src/security/__tests__/vault-export.test.ts +++ b/apps/extension-wallet/src/security/__tests__/vault-export.test.ts @@ -1,5 +1,5 @@ import { webcrypto } from 'node:crypto'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { encryptSecretKey } from '@ancore/crypto'; import { SecureStorageManager, type StorageAdapter } from '@ancore/core-sdk'; @@ -11,6 +11,21 @@ import { } from '../vault-export'; import { getSharedStorageManager } from '../storage-manager'; +vi.mock('@ancore/core-sdk', async () => { + const actual = await vi.importActual('@ancore/core-sdk'); + let adapter: StorageAdapter | null = null; + + return { + ...actual, + createStorageAdapter: () => { + if (!adapter) { + adapter = new MockStorageAdapter(); + } + return adapter; + }, + }; +}); + if (!globalThis.crypto?.subtle) { Object.defineProperty(globalThis, 'crypto', { value: webcrypto, @@ -53,12 +68,18 @@ async function seedVaultAccount( privateKey: string; mnemonic?: string; encryptedMnemonic?: Awaited>; - } + }, + options: { + lockAfterSave?: boolean; + } = {} ): Promise { + const { lockAfterSave = true } = options; const manager = new SecureStorageManager(storage); await manager.unlock(PASSWORD); await manager.saveAccount(account); - manager.lock(); + if (lockAfterSave) { + manager.lock(); + } return manager; } @@ -81,8 +102,6 @@ describe('vault-export', () => { it('reveals a private key after password verification', async () => { const manager = await seedVaultAccount(storage, { privateKey: PRIVATE_KEY }); - await manager.unlock(PASSWORD); - await expect( revealVaultSecret({ kind: 'privateKey', @@ -94,12 +113,14 @@ describe('vault-export', () => { }); it('reveals a stored mnemonic when secure storage is already unlocked', async () => { - const manager = await seedVaultAccount(storage, { - privateKey: PRIVATE_KEY, - mnemonic: MNEMONIC, - }); - - await manager.unlock(PASSWORD); + const manager = await seedVaultAccount( + storage, + { + privateKey: PRIVATE_KEY, + mnemonic: MNEMONIC, + }, + { lockAfterSave: false } + ); await expect( revealVaultSecret({ diff --git a/apps/web-dashboard/src/hooks/__tests__/useAccountOverview.test.ts b/apps/web-dashboard/src/hooks/__tests__/useAccountOverview.test.ts index c92dac16..935e1588 100644 --- a/apps/web-dashboard/src/hooks/__tests__/useAccountOverview.test.ts +++ b/apps/web-dashboard/src/hooks/__tests__/useAccountOverview.test.ts @@ -1,6 +1,10 @@ -import { renderHook, waitFor } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { useAccountOverview } from '../useAccountOverview'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + AccountNotFoundError, + HorizonUnavailableError, + useAccountOverview, +} from '../useAccountOverview'; describe('useAccountOverview', () => { const mockPublicKey = 'GBVFLP5J7XLTQBJX5QZW6LNRUZPGZRG7GMKQMVYOMKCFQG4N7VJ7RCV'; @@ -15,25 +19,16 @@ describe('useAccountOverview', () => { it('fetches account data successfully', async () => { const mockAccountData = { - id: 'GBVFLP5J7XLTQBJX5QZW6LNRUZPGZRG7GMKQMVYOMKCFQG4N7VJ7RCV', - account_id: 'GBVFLP5J7XLTQBJX5QZW6LNRUZPGZRG7GMKQMVYOMKCFQG4N7VJ7RCV', - sequence: '123456789', - subentry_count: 0, - last_modified_ledger: 50000, - last_modified_time: '2024-01-01T00:00:00Z', - balances: [ - { - balance: '100.0000000', - asset_type: 'native', - }, - ], + balance: 100, + nonce: 123456789, + status: 'active', }; (global.fetch as any).mockResolvedValue( new Response(JSON.stringify(mockAccountData), { status: 200, headers: { 'Content-Type': 'application/json' }, - }), + }) ); const { result } = renderHook(() => useAccountOverview(mockPublicKey)); @@ -59,12 +54,7 @@ describe('useAccountOverview', () => { }); it('handles Horizon API error', async () => { - (global.fetch as any).mockResolvedValue( - new Response('Account not found', { - status: 404, - headers: { 'Content-Type': 'application/json' }, - }), - ); + (global.fetch as any).mockResolvedValue(new Response(null, { status: 404 })); const { result } = renderHook(() => useAccountOverview(mockPublicKey)); @@ -72,39 +62,100 @@ describe('useAccountOverview', () => { expect(result.current.data).toBeNull(); expect(result.current.error).not.toBeNull(); - expect(result.current.error?.message).toContain('Account not found'); + expect(result.current.error).toBeInstanceOf(AccountNotFoundError); }); it('allows refetch', async () => { - const mockAccountData = { - id: mockPublicKey, - account_id: mockPublicKey, - sequence: '100', - subentry_count: 0, - last_modified_ledger: 50000, - last_modified_time: '2024-01-01T00:00:00Z', - balances: [ - { - balance: '50.0000000', - asset_type: 'native', - }, - ], - }; - - (global.fetch as any).mockResolvedValue( - new Response(JSON.stringify(mockAccountData), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); + const fetchMock = global.fetch as ReturnType; + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + balance: 50, + nonce: 100, + status: 'active', + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + balance: 50, + nonce: 100, + status: 'active', + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); const { result } = renderHook(() => useAccountOverview(mockPublicKey)); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.data?.balance).toBe(50); - await result.current.refetch(); + await act(async () => { + await result.current.refetch(); + }); + await waitFor(() => expect(result.current.data?.balance).toBe(50)); expect(result.current.data?.balance).toBe(50); }); + + it('maps 404 responses to account-not-found errors', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 404 })); + + 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(new Response(null, { status: 500 })); + + 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(new Response(null, { status: 500 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + balance: 9, + nonce: 7, + status: 'inactive', + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + + 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 817ad001..be0e785c 100644 --- a/apps/web-dashboard/src/hooks/useAccountOverview.ts +++ b/apps/web-dashboard/src/hooks/useAccountOverview.ts @@ -1,5 +1,4 @@ import { useState, useEffect, useCallback } from 'react'; -import { fetchAccountBalance, fetchAccountData } from '../lib/horizon'; export type AccountStatus = 'active' | 'inactive' | 'locked'; @@ -9,13 +8,49 @@ 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; } +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). * Uses Horizon API to fetch real Stellar account data. @@ -23,11 +58,12 @@ 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) { setData(null); + setError(null); setIsLoading(false); return; } @@ -36,16 +72,23 @@ export function useAccountOverview(publicKey: string): UseAccountOverviewReturn setError(null); try { - const accountData = await fetchAccountData(publicKey); - const balance = await fetchAccountBalance(publicKey); - - setData({ - balance, - nonce: Number(accountData.sequence), - status: 'active', - }); + const response = await fetch( + `/api/account-overview?publicKey=${encodeURIComponent(publicKey)}` + ); + + if (!response.ok) { + throw classifyAccountOverviewError(response.status); + } + + const accountOverview = (await response.json()) as AccountOverview; + + setData(accountOverview); } catch (err) { - setError(err instanceof Error ? err : new Error('Failed to fetch account data')); + setError( + err instanceof AccountOverviewError + ? err + : new AccountOverviewError('Failed to fetch account data', 'FETCH_FAILED') + ); setData(null); } 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)); + }); +}); 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) =>