diff --git a/package-lock.json b/package-lock.json index 739270b..a1407a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -387,7 +387,6 @@ }, "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -402,7 +401,6 @@ }, "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -416,7 +414,6 @@ }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -430,7 +427,6 @@ }, "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -445,7 +441,6 @@ }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -461,7 +456,6 @@ }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -593,7 +587,6 @@ }, "node_modules/@babel/plugin-proposal-private-property-in-object": { "version": "7.21.0-placeholder-for-preset-env.2", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -686,7 +679,6 @@ }, "node_modules/@babel/plugin-syntax-import-assertions": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -700,7 +692,6 @@ }, "node_modules/@babel/plugin-syntax-import-attributes": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -851,7 +842,6 @@ }, "node_modules/@babel/plugin-syntax-unicode-sets-regex": { "version": "7.18.6", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", @@ -879,7 +869,6 @@ }, "node_modules/@babel/plugin-transform-async-generator-functions": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -910,7 +899,6 @@ }, "node_modules/@babel/plugin-transform-block-scoped-functions": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -937,7 +925,6 @@ }, "node_modules/@babel/plugin-transform-class-properties": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", @@ -952,7 +939,6 @@ }, "node_modules/@babel/plugin-transform-class-static-block": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", @@ -1013,7 +999,6 @@ }, "node_modules/@babel/plugin-transform-dotall-regex": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", @@ -1028,7 +1013,6 @@ }, "node_modules/@babel/plugin-transform-duplicate-keys": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1042,7 +1026,6 @@ }, "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", @@ -1057,7 +1040,6 @@ }, "node_modules/@babel/plugin-transform-dynamic-import": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1071,7 +1053,6 @@ }, "node_modules/@babel/plugin-transform-explicit-resource-management": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -1086,7 +1067,6 @@ }, "node_modules/@babel/plugin-transform-exponentiation-operator": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1100,7 +1080,6 @@ }, "node_modules/@babel/plugin-transform-export-namespace-from": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1128,7 +1107,6 @@ }, "node_modules/@babel/plugin-transform-for-of": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -1158,7 +1136,6 @@ }, "node_modules/@babel/plugin-transform-json-strings": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1185,7 +1162,6 @@ }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1199,7 +1175,6 @@ }, "node_modules/@babel/plugin-transform-member-expression-literals": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1213,7 +1188,6 @@ }, "node_modules/@babel/plugin-transform-modules-amd": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.29.7", @@ -1242,7 +1216,6 @@ }, "node_modules/@babel/plugin-transform-modules-systemjs": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.29.7", @@ -1259,7 +1232,6 @@ }, "node_modules/@babel/plugin-transform-modules-umd": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.29.7", @@ -1288,7 +1260,6 @@ }, "node_modules/@babel/plugin-transform-new-target": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1302,7 +1273,6 @@ }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1316,7 +1286,6 @@ }, "node_modules/@babel/plugin-transform-numeric-separator": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1330,7 +1299,6 @@ }, "node_modules/@babel/plugin-transform-object-rest-spread": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", @@ -1348,7 +1316,6 @@ }, "node_modules/@babel/plugin-transform-object-super": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -1363,7 +1330,6 @@ }, "node_modules/@babel/plugin-transform-optional-catch-binding": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1377,7 +1343,6 @@ }, "node_modules/@babel/plugin-transform-optional-chaining": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -1434,7 +1399,6 @@ }, "node_modules/@babel/plugin-transform-property-literals": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1504,7 +1468,6 @@ }, "node_modules/@babel/plugin-transform-regenerator": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1518,7 +1481,6 @@ }, "node_modules/@babel/plugin-transform-regexp-modifiers": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", @@ -1533,7 +1495,6 @@ }, "node_modules/@babel/plugin-transform-reserved-words": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1616,7 +1577,6 @@ }, "node_modules/@babel/plugin-transform-template-literals": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1630,7 +1590,6 @@ }, "node_modules/@babel/plugin-transform-typeof-symbol": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1661,7 +1620,6 @@ }, "node_modules/@babel/plugin-transform-unicode-escapes": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1675,7 +1633,6 @@ }, "node_modules/@babel/plugin-transform-unicode-property-regex": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", @@ -1704,7 +1661,6 @@ }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", @@ -1719,7 +1675,6 @@ }, "node_modules/@babel/preset-env": { "version": "7.29.7", - "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.29.7", @@ -1818,7 +1773,6 @@ }, "node_modules/@babel/preset-modules": { "version": "0.1.6-no-external-plugins", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", @@ -3838,12 +3792,12 @@ }, "node_modules/@types/prop-types": { "version": "15.7.15", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.30", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -5484,7 +5438,6 @@ }, "node_modules/babel-plugin-polyfill-corejs3": { "version": "0.14.2", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8", @@ -6298,7 +6251,7 @@ }, "node_modules/csstype": { "version": "3.2.3", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/data-view-buffer": { @@ -7181,7 +7134,6 @@ }, "node_modules/esutils": { "version": "2.0.3", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" diff --git a/src/__tests__/WalletScreen.test.tsx b/src/__tests__/WalletScreen.test.tsx new file mode 100644 index 0000000..03db9fd --- /dev/null +++ b/src/__tests__/WalletScreen.test.tsx @@ -0,0 +1,183 @@ +/** + * WalletScreen.test.tsx + * + * Tests: + * 1. empty state when no wallet is connected + * 2. last-known balances render after a successful refresh + * 3. a failed refresh surfaces "Failed to refresh balance" and a stale + * indicator without replacing the last known balance with zero + * 4. Retry (button and pull-to-refresh) re-runs the balance fetch and + * clears the error when it succeeds + */ + +import './__mocks__/setup'; +import React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import { RefreshControl, Text, TouchableOpacity } from 'react-native'; +import WalletScreen from '../screens/WalletScreen'; +import { useWalletStore } from '../store/walletStore'; + +const mockNavigate = jest.fn(); +const mockRefreshBalance = jest.fn(); +const mockDisconnectWallet = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); + +jest.mock('../hooks/useStellarWallet', () => ({ + useStellarWallet: () => ({ + disconnectWallet: mockDisconnectWallet, + refreshBalance: mockRefreshBalance, + }), +})); + +jest.mock('../components/TransactionHistory', () => ({ + __esModule: true, + default: () => null, +})); + +jest.mock('../components/PublicKeyDisplay', () => ({ + __esModule: true, + default: () => null, +})); + +function textValues(tree: renderer.ReactTestRenderer): string[] { + return tree.root + .findAllByType(Text) + .flatMap(node => + (Array.isArray(node.props.children) + ? node.props.children + : [node.props.children] + ).filter((child: unknown): child is string => typeof child === 'string'), + ); +} + +function buttonWithText( + tree: renderer.ReactTestRenderer, + label: string, +): renderer.ReactTestInstance { + const button = tree.root + .findAllByType(TouchableOpacity) + .find(node => + node.findAllByType(Text).some(text => text.props.children === label), + ); + if (!button) { + throw new Error(`Could not find a button labelled "${label}"`); + } + return button; +} + +function resetWalletStore(connected: boolean) { + useWalletStore.setState({ + isConnected: connected, + status: connected ? 'connected' : 'disconnected', + connectError: null, + publicKey: connected ? 'GCWALLETTESTKEY' : null, + balance: connected ? '42.5' : null, + ecoBalance: connected ? '10' : null, + usdcBalance: connected ? '5' : null, + walletType: connected ? 'inapp' : null, + }); +} + +async function renderScreen() { + const tree = renderer.create(); + await act(async () => { + await Promise.resolve(); + }); + return tree; +} + +describe('WalletScreen', () => { + let tree: renderer.ReactTestRenderer | null = null; + + beforeEach(() => { + mockNavigate.mockClear(); + mockDisconnectWallet.mockClear(); + mockRefreshBalance.mockReset(); + mockRefreshBalance.mockResolvedValue(true); + resetWalletStore(true); + }); + + afterEach(async () => { + await act(async () => { + tree?.unmount(); + }); + tree = null; + }); + + it('shows the empty state when no wallet is connected', async () => { + resetWalletStore(false); + tree = await renderScreen(); + + const texts = textValues(tree); + expect(texts).toContain('No wallet connected'); + expect(mockRefreshBalance).not.toHaveBeenCalled(); + }); + + it('renders last-known balances after a successful refresh', async () => { + tree = await renderScreen(); + + expect(mockRefreshBalance).toHaveBeenCalledTimes(1); + const texts = textValues(tree); + expect(texts).toContain('Wallet'); + expect(texts).toContain('42.5'); + expect(texts).toContain('10'); + expect(texts).toContain('5'); + expect(texts).not.toContain('Failed to refresh balance'); + expect(texts).not.toContain('May be out of date'); + }); + + it('shows the error, stale indicator, and last known balance when refresh fails', async () => { + mockRefreshBalance.mockResolvedValue(false); + tree = await renderScreen(); + + const texts = textValues(tree); + expect(texts).toContain('Failed to refresh balance'); + expect(texts).toContain('May be out of date'); + expect(texts).toContain('42.5'); + expect(texts).not.toContain('No wallet connected'); + }); + + it('retries the balance refresh and clears the error when it succeeds', async () => { + mockRefreshBalance.mockResolvedValueOnce(false); + tree = await renderScreen(); + expect(textValues(tree)).toContain('Failed to refresh balance'); + + mockRefreshBalance.mockResolvedValueOnce(true); + await act(async () => { + buttonWithText(tree!, 'Retry').props.onPress(); + }); + + expect(mockRefreshBalance).toHaveBeenCalledTimes(2); + const texts = textValues(tree); + expect(texts).not.toContain('Failed to refresh balance'); + expect(texts).not.toContain('May be out of date'); + expect(texts).toContain('42.5'); + }); + + it('retries via pull-to-refresh', async () => { + mockRefreshBalance.mockResolvedValueOnce(false); + tree = await renderScreen(); + + mockRefreshBalance.mockResolvedValueOnce(true); + const control = tree.root.findByType(RefreshControl); + await act(async () => { + control.props.onRefresh(); + }); + + expect(mockRefreshBalance).toHaveBeenCalledTimes(2); + expect(textValues(tree)).not.toContain('Failed to refresh balance'); + }); + + it('treats a thrown refresh as a failed refresh', async () => { + mockRefreshBalance.mockRejectedValue(new Error('Horizon timeout')); + tree = await renderScreen(); + + const texts = textValues(tree); + expect(texts).toContain('Failed to refresh balance'); + expect(texts).toContain('May be out of date'); + expect(texts).toContain('42.5'); + }); +}); diff --git a/src/__tests__/useStellarWallet.test.tsx b/src/__tests__/useStellarWallet.test.tsx index 137cd3b..9b5085b 100644 --- a/src/__tests__/useStellarWallet.test.tsx +++ b/src/__tests__/useStellarWallet.test.tsx @@ -516,10 +516,12 @@ describe('useStellarWallet connect flow', () => { // A later network blip during refresh must not reject into the // fire-and-forget call sites nor wipe the verified balance. getBalance.mockRejectedValue(new Error('offline blip')); + let refreshOk: boolean | undefined; await act(async () => { - await hook.refreshBalance(); + refreshOk = await hook.refreshBalance(); }); + expect(refreshOk).toBe(false); expect(useWalletStore.getState().balance).toBe('123.45'); expect(useWalletStore.getState().isConnected).toBe(true); expect(hook.error).toBe('offline blip'); diff --git a/src/hooks/useStellarWallet.ts b/src/hooks/useStellarWallet.ts index 21e7ba5..5173fb4 100644 --- a/src/hooks/useStellarWallet.ts +++ b/src/hooks/useStellarWallet.ts @@ -298,16 +298,26 @@ export function useStellarWallet() { disconnect(); }, [publicKey, disconnect]); - const refreshBalance = useCallback(async () => { - if (publicKey) { - try { - const balance = await stellar.getBalance(publicKey); - setBalance(balance); - } catch (err) { - // Keep the last known balance on refresh failure instead of - // rejecting into fire-and-forget call sites. - setError(toErrorMessage(err, 'Could not refresh balance')); - } + /** + * Refresh the native XLM balance. + * + * Resolves `true` on success and `false` on network/Horizon failure so + * screens can surface an error without this function ever rejecting — + * several call sites fire-and-forget (`void refreshBalance()`). + * A failed refresh keeps the last known balance in the store. + */ + const refreshBalance = useCallback(async (): Promise => { + if (!publicKey) { + return true; + } + try { + const balance = await stellar.getBalance(publicKey); + setBalance(balance); + setError(null); + return true; + } catch (err) { + setError(toErrorMessage(err, 'Could not refresh balance')); + return false; } }, [publicKey, setBalance]); diff --git a/src/screens/WalletScreen.tsx b/src/screens/WalletScreen.tsx index 722eb1e..5bc851d 100644 --- a/src/screens/WalletScreen.tsx +++ b/src/screens/WalletScreen.tsx @@ -1,5 +1,11 @@ -import React, { useEffect, useState } from 'react'; -import { View, Text, TouchableOpacity, ScrollView } from 'react-native'; +import React, { useCallback, useEffect, useState } from 'react'; +import { + View, + Text, + TouchableOpacity, + ScrollView, + RefreshControl, +} from 'react-native'; import { useWalletStore } from '../store/walletStore'; import { useStellarWallet } from '../hooks/useStellarWallet'; import { colors, spacing } from '../utils/theme'; @@ -9,16 +15,60 @@ import TransactionHistory from '../components/TransactionHistory'; import PublicKeyDisplay from '../components/PublicKeyDisplay'; import { useTabNavigation } from '../navigation/useAppNavigation'; +const REFRESH_CONTROL_COLORS = [colors.primary]; + export default function WalletScreen() { const navigation = useTabNavigation(); const { balance, ecoBalance, usdcBalance, publicKey, isConnected } = useWalletStore(); const { disconnectWallet, refreshBalance } = useStellarWallet(); const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [refreshError, setRefreshError] = useState(null); + const [isBalanceStale, setIsBalanceStale] = useState(false); + + const loadBalance = useCallback( + async (mode: 'initial' | 'retry') => { + if (mode === 'retry') { + setRefreshing(true); + } else { + setLoading(true); + } + try { + const ok = await refreshBalance(); + if (ok === false) { + setRefreshError('Failed to refresh balance'); + setIsBalanceStale(true); + } else { + setRefreshError(null); + setIsBalanceStale(false); + } + } catch { + // refreshBalance is fail-safe and should not throw; still treat a + // rejection as a failed refresh so the UI never swallows it. + setRefreshError('Failed to refresh balance'); + setIsBalanceStale(true); + } finally { + setLoading(false); + setRefreshing(false); + } + }, + [refreshBalance], + ); useEffect(() => { - void refreshBalance().finally(() => setLoading(false)); - }, [refreshBalance]); + if (!isConnected) { + setLoading(false); + setRefreshError(null); + setIsBalanceStale(false); + return; + } + void loadBalance('initial'); + }, [isConnected, loadBalance]); + + const handleRetry = useCallback(() => { + void loadBalance('retry'); + }, [loadBalance]); if (!isConnected) { return ( @@ -62,11 +112,53 @@ export default function WalletScreen() { backgroundColor: colors.background, padding: spacing.lg, }} + refreshControl={ + + } > Wallet + {refreshError && ( + + + {refreshError} + + + + Retry + + + + )} + {balance ?? '0'}{' '} XLM + {isBalanceStale && ( + + May be out of date + + )}