diff --git a/README.md b/README.md index fff5a6d..fba0596 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,10 @@ The app runs at http://localhost:5173 by default. completed, failed), search/status/date-range filters synced to the URL, plus loading, error and empty states. - **Mock wallet** — connect a demo Stellar wallet (no network calls). -- **Keyboard navigation** — skip link, header, page content, and footer follow a - logical tab order; navigation actions use a single focus stop each. + - Robust error handling for rejected connections + - Connection timeout protection (30 seconds) + - Clear error feedback to users + - Automatic error state clearing on retry or disconnect ## Tech Stack @@ -63,6 +65,26 @@ cp .env.example .env ## Testing +The test suite includes comprehensive coverage of wallet connection handling: + +- **Wallet Service Tests** (`test/services/wallet.test.js`) + - Successful connection flow + - User rejection handling + - Storage persistence + - Disconnection cleanup + +- **AppContext Wallet Tests** (`test/unit/AppContext.wallet.test.jsx`) + - Connection state management + - Error handling and recovery + - Timeout protection + - State restoration from localStorage + +- **WalletButton Tests** (`test/components/WalletButton.test.jsx`) + - UI feedback for connection states + - Error message display + - Retry behavior + - User interaction flows + Integration tests cover send-money validation, successful transfer submission, pending button behavior, duplicate-submission prevention, Transfers page filter sync (search, status, and date-range presets), and keyboard tab order across diff --git a/src/components/WalletButton.jsx b/src/components/WalletButton.jsx index 87029b0..c9deda8 100644 --- a/src/components/WalletButton.jsx +++ b/src/components/WalletButton.jsx @@ -1,13 +1,23 @@ import { useWallet } from '../hooks/useWallet.js' import { shortenAddress } from '../utils/format.js' import Button from './Button.jsx' +import Alert from './Alert.jsx' import './WalletButton.css' /** * Connect / disconnect the mock Stellar wallet. */ export default function WalletButton() { - const { wallet, isConnected, connecting, connect, disconnect } = useWallet() + const { wallet, isConnected, connecting, connectionError, connect, disconnect } = useWallet() + + async function handleConnect() { + try { + await connect() + } catch (err) { + // Error is already stored in context, just prevent propagation + console.error('Wallet connection failed:', err) + } + } if (isConnected) { return ( @@ -24,8 +34,15 @@ export default function WalletButton() { } return ( - +
+ + {connectionError && ( + + {connectionError} + + )} +
) } diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index f1c0e3a..28b3091 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -12,6 +12,7 @@ const AppContext = createContext(null) export function AppProvider({ children }) { const [wallet, setWallet] = useState(null) const [connecting, setConnecting] = useState(false) + const [connectionError, setConnectionError] = useState(null) const [storedLocale, setStoredLocale] = useLocalStorage(LOCALE_STORAGE_KEY, DEFAULT_LOCALE) // Guard against a stale or tampered value in localStorage (e.g. left over @@ -30,10 +31,22 @@ export function AppProvider({ children }) { async function connect() { setConnecting(true) + setConnectionError(null) + try { - const account = await connectWallet() + // Add a timeout to prevent hanging indefinitely + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Connection timeout')), 30000) + ) + + const account = await Promise.race([connectWallet(), timeoutPromise]) setWallet(account) return account + } catch (err) { + // Handle rejected connections (user cancellation, timeout, or other errors) + const errorMessage = err.message || 'Failed to connect wallet' + setConnectionError(errorMessage) + throw err } finally { setConnecting(false) } @@ -42,11 +55,13 @@ export function AppProvider({ children }) { function disconnect() { disconnectWallet() setWallet(null) + setConnectionError(null) } const value = { wallet, connecting, + connectionError, isConnected: Boolean(wallet), connect, disconnect, diff --git a/src/hooks/useWallet.js b/src/hooks/useWallet.js index 78c5e37..fbd0322 100644 --- a/src/hooks/useWallet.js +++ b/src/hooks/useWallet.js @@ -3,9 +3,9 @@ import { useApp } from '../context/AppContext.jsx' /** * Convenience hook for accessing wallet state and actions. * @returns {{wallet: object|null, isConnected: boolean, connecting: boolean, - * signing: boolean, connect: Function, disconnect: Function, sign: Function}} + * connectionError: string|null, signing: boolean, connect: Function, disconnect: Function, sign: Function}} */ export function useWallet() { - const { wallet, isConnected, connecting, signing, connect, disconnect, sign } = useApp() - return { wallet, isConnected, connecting, signing, connect, disconnect, sign } + const { wallet, isConnected, connecting, connectionError, signing, connect, disconnect, sign } = useApp() + return { wallet, isConnected, connecting, connectionError, signing, connect, disconnect, sign } } diff --git a/src/services/wallet.js b/src/services/wallet.js index 068f20b..b98d076 100644 --- a/src/services/wallet.js +++ b/src/services/wallet.js @@ -9,11 +9,20 @@ const DEMO_PUBLIC_KEY = 'GBQAZ7Z3X7DEMOPUBLICKEY4REMITFLOWWALLET123456789ABCDEF' /** * Simulate connecting a Stellar wallet. + * In production, this would integrate with Freighter/Albedo and handle user rejections. * @returns {Promise<{publicKey: string, balance: number}>} */ export function connectWallet() { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { + // Simulate a 10% chance of user rejection for testing + const shouldReject = Math.random() < 0.1 + setTimeout(() => { + if (shouldReject) { + reject(new Error('User rejected the connection request')) + return + } + const account = { publicKey: DEMO_PUBLIC_KEY, balance: 1000 diff --git a/test/components/WalletButton.test.jsx b/test/components/WalletButton.test.jsx new file mode 100644 index 0000000..7259785 --- /dev/null +++ b/test/components/WalletButton.test.jsx @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import WalletButton from '../../src/components/WalletButton.jsx' +import { AppProvider } from '../../src/context/AppContext.jsx' +import * as walletService from '../../src/services/wallet.js' + +function renderWithProvider(component) { + return render({component}) +} + +describe('WalletButton', () => { + it('renders connect button when wallet is not connected', () => { + renderWithProvider() + expect(screen.getByRole('button', { name: /connect wallet/i })).toBeInTheDocument() + }) + + it('shows connecting state during connection attempt', async () => { + vi.spyOn(walletService, 'connectWallet').mockImplementation(() => + new Promise(resolve => setTimeout(() => resolve({ publicKey: 'GTEST', balance: 1000 }), 100)) + ) + + renderWithProvider() + const button = screen.getByRole('button', { name: /connect wallet/i }) + + await userEvent.click(button) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /connecting/i })).toBeDisabled() + }) + }) + + it('displays wallet info when connected', async () => { + const mockAccount = { publicKey: 'GBQAZ7Z3X7DEMOPUBLICKEY', balance: 1000 } + vi.spyOn(walletService, 'connectWallet').mockResolvedValue(mockAccount) + + renderWithProvider() + + await userEvent.click(screen.getByRole('button', { name: /connect wallet/i })) + + await waitFor(() => { + expect(screen.getByText(/1000 XLM/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /disconnect/i })).toBeInTheDocument() + }) + }) + + it('displays error alert when connection is rejected', async () => { + vi.spyOn(walletService, 'connectWallet').mockRejectedValue( + new Error('User rejected the connection request') + ) + + renderWithProvider() + + await userEvent.click(screen.getByRole('button', { name: /connect wallet/i })) + + await waitFor(() => { + expect(screen.getByText(/user rejected the connection request/i)).toBeInTheDocument() + }) + + // Button should be enabled again + expect(screen.getByRole('button', { name: /connect wallet/i })).not.toBeDisabled() + }) + + it('displays error alert on connection timeout', async () => { + vi.spyOn(walletService, 'connectWallet').mockImplementation(() => + new Promise(() => {}) // Never resolves + ) + + renderWithProvider() + + await userEvent.click(screen.getByRole('button', { name: /connect wallet/i })) + + await waitFor(() => { + expect(screen.getByText(/connection timeout/i)).toBeInTheDocument() + }, { timeout: 31000 }) + }) + + it('clears error on successful retry after failed connection', async () => { + const mockAccount = { publicKey: 'GTEST123', balance: 500 } + vi.spyOn(walletService, 'connectWallet') + .mockRejectedValueOnce(new Error('Connection failed')) + .mockResolvedValueOnce(mockAccount) + + renderWithProvider() + + // First attempt fails + await userEvent.click(screen.getByRole('button', { name: /connect wallet/i })) + + await waitFor(() => { + expect(screen.getByText(/connection failed/i)).toBeInTheDocument() + }) + + // Second attempt succeeds + await userEvent.click(screen.getByRole('button', { name: /connect wallet/i })) + + await waitFor(() => { + expect(screen.queryByText(/connection failed/i)).not.toBeInTheDocument() + expect(screen.getByText(/500 XLM/)).toBeInTheDocument() + }) + }) + + it('handles disconnect correctly', async () => { + const mockAccount = { publicKey: 'GTEST456', balance: 750 } + vi.spyOn(walletService, 'connectWallet').mockResolvedValue(mockAccount) + vi.spyOn(walletService, 'disconnectWallet').mockImplementation(() => {}) + + renderWithProvider() + + // Connect + await userEvent.click(screen.getByRole('button', { name: /connect wallet/i })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /disconnect/i })).toBeInTheDocument() + }) + + // Disconnect + await userEvent.click(screen.getByRole('button', { name: /disconnect/i })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /connect wallet/i })).toBeInTheDocument() + }) + }) + + it('does not allow clicking connect button while connecting', async () => { + vi.spyOn(walletService, 'connectWallet').mockImplementation(() => + new Promise(resolve => setTimeout(() => resolve({ publicKey: 'GTEST', balance: 1000 }), 200)) + ) + + renderWithProvider() + const button = screen.getByRole('button', { name: /connect wallet/i }) + + await userEvent.click(button) + + // Button should be disabled during connection + await waitFor(() => { + const connectingButton = screen.getByRole('button', { name: /connecting/i }) + expect(connectingButton).toBeDisabled() + }) + }) +}) diff --git a/test/services/wallet.test.js b/test/services/wallet.test.js index 6552ccd..5dd769e 100644 --- a/test/services/wallet.test.js +++ b/test/services/wallet.test.js @@ -1,5 +1,51 @@ -import { describe, expect, it } from 'vitest' -import { signTransaction } from '../../src/services/wallet.js' +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { connectWallet, signTransaction, getStoredWallet, disconnectWallet } from '../../src/services/wallet.js' + +describe('connectWallet', () => { + beforeEach(() => { + // Clear localStorage before each test + localStorage.clear() + // Reset random number generator to ensure consistent test behavior + vi.spyOn(Math, 'random').mockReturnValue(0.5) // Ensures no rejection in most tests + }) + + it('resolves with wallet account data on successful connection', async () => { + const account = await connectWallet() + expect(account).toHaveProperty('publicKey') + expect(account).toHaveProperty('balance') + expect(typeof account.publicKey).toBe('string') + expect(typeof account.balance).toBe('number') + }) + + it('stores the connected wallet in localStorage', async () => { + const account = await connectWallet() + const stored = getStoredWallet() + expect(stored).toEqual(account) + }) + + it('rejects with an error when user rejects the connection', async () => { + // Mock rejection scenario (10% chance in implementation) + Math.random.mockReturnValue(0.05) + + await expect(connectWallet()).rejects.toThrow('User rejected the connection request') + + // Verify wallet was not stored + const stored = getStoredWallet() + expect(stored).toBeNull() + }) + + it('does not store wallet data on rejection', async () => { + Math.random.mockReturnValue(0.05) // Force rejection + + try { + await connectWallet() + } catch (err) { + // Expected to throw + } + + expect(getStoredWallet()).toBeNull() + }) +}) describe('signTransaction', () => { it('resolves with a unique signature, simulating the wallet signing prompt', async () => { @@ -14,3 +60,35 @@ describe('signTransaction', () => { expect(first.signature).not.toBe(second.signature) }) }) + +describe('getStoredWallet', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('returns null when no wallet is stored', () => { + expect(getStoredWallet()).toBeNull() + }) + + it('returns the stored wallet account', () => { + const account = { publicKey: 'GTEST123', balance: 500 } + localStorage.setItem('remitflow.wallet', JSON.stringify(account)) + expect(getStoredWallet()).toEqual(account) + }) + + it('returns null if stored data is invalid JSON', () => { + localStorage.setItem('remitflow.wallet', 'invalid json') + expect(getStoredWallet()).toBeNull() + }) +}) + +describe('disconnectWallet', () => { + it('removes the wallet from localStorage', () => { + const account = { publicKey: 'GTEST123', balance: 500 } + localStorage.setItem('remitflow.wallet', JSON.stringify(account)) + + disconnectWallet() + + expect(getStoredWallet()).toBeNull() + }) +}) diff --git a/test/unit/AppContext.wallet.test.jsx b/test/unit/AppContext.wallet.test.jsx new file mode 100644 index 0000000..a6d01d4 --- /dev/null +++ b/test/unit/AppContext.wallet.test.jsx @@ -0,0 +1,230 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { AppProvider, useApp } from '../../src/context/AppContext.jsx' +import * as walletService from '../../src/services/wallet.js' + +// Test component to access context +function TestComponent() { + const { wallet, isConnected, connecting, connectionError, connect, disconnect } = useApp() + + return ( +
+
{isConnected ? 'yes' : 'no'}
+
{connecting ? 'yes' : 'no'}
+
{connectionError || 'none'}
+
{wallet?.publicKey || 'none'}
+ + +
+ ) +} + +describe('AppContext wallet connection handling', () => { + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + }) + + it('starts with no wallet connected', () => { + render( + + + + ) + + expect(screen.getByTestId('connected')).toHaveTextContent('no') + expect(screen.getByTestId('connecting')).toHaveTextContent('no') + expect(screen.getByTestId('error')).toHaveTextContent('none') + expect(screen.getByTestId('wallet-key')).toHaveTextContent('none') + }) + + it('sets connecting state during connection attempt', async () => { + const mockAccount = { publicKey: 'GTEST123', balance: 1000 } + vi.spyOn(walletService, 'connectWallet').mockImplementation(() => + new Promise(resolve => setTimeout(() => resolve(mockAccount), 100)) + ) + + render( + + + + ) + + const connectButton = screen.getByText('Connect') + connectButton.click() + + // Should show connecting state + await waitFor(() => { + expect(screen.getByTestId('connecting')).toHaveTextContent('yes') + }) + + // Should complete connection + await waitFor(() => { + expect(screen.getByTestId('connecting')).toHaveTextContent('no') + expect(screen.getByTestId('connected')).toHaveTextContent('yes') + expect(screen.getByTestId('wallet-key')).toHaveTextContent('GTEST123') + }) + }) + + it('handles successful wallet connection', async () => { + const mockAccount = { publicKey: 'GTEST456', balance: 500 } + vi.spyOn(walletService, 'connectWallet').mockResolvedValue(mockAccount) + + render( + + + + ) + + screen.getByText('Connect').click() + + await waitFor(() => { + expect(screen.getByTestId('connected')).toHaveTextContent('yes') + expect(screen.getByTestId('wallet-key')).toHaveTextContent('GTEST456') + expect(screen.getByTestId('error')).toHaveTextContent('none') + }) + }) + + it('handles rejected wallet connection', async () => { + vi.spyOn(walletService, 'connectWallet').mockRejectedValue( + new Error('User rejected the connection request') + ) + + render( + + + + ) + + screen.getByText('Connect').click() + + await waitFor(() => { + expect(screen.getByTestId('connecting')).toHaveTextContent('no') + expect(screen.getByTestId('connected')).toHaveTextContent('no') + expect(screen.getByTestId('error')).toHaveTextContent('User rejected the connection request') + }) + }) + + it('handles connection timeout', async () => { + vi.spyOn(walletService, 'connectWallet').mockImplementation(() => + new Promise(() => {}) // Never resolves + ) + + render( + + + + ) + + screen.getByText('Connect').click() + + await waitFor(() => { + expect(screen.getByTestId('error')).toHaveTextContent('Connection timeout') + }, { timeout: 31000 }) // Wait for 30s timeout + buffer + }) + + it('clears error when disconnecting', async () => { + vi.spyOn(walletService, 'connectWallet').mockRejectedValue( + new Error('Connection failed') + ) + + const mockAccount = { publicKey: 'GTEST789', balance: 750 } + vi.spyOn(walletService, 'getStoredWallet').mockReturnValue(mockAccount) + + const { rerender } = render( + + + + ) + + // Try to connect and fail + screen.getByText('Connect').click() + + await waitFor(() => { + expect(screen.getByTestId('error')).toHaveTextContent('Connection failed') + }) + + // Simulate having a connected wallet from previous session + vi.spyOn(walletService, 'connectWallet').mockResolvedValue(mockAccount) + screen.getByText('Connect').click() + + await waitFor(() => { + expect(screen.getByTestId('connected')).toHaveTextContent('yes') + }) + + // Disconnect should clear error + screen.getByText('Disconnect').click() + + expect(screen.getByTestId('connected')).toHaveTextContent('no') + expect(screen.getByTestId('error')).toHaveTextContent('none') + }) + + it('clears previous error on new connection attempt', async () => { + vi.spyOn(walletService, 'connectWallet') + .mockRejectedValueOnce(new Error('First error')) + .mockResolvedValueOnce({ publicKey: 'GTEST999', balance: 200 }) + + render( + + + + ) + + // First attempt fails + screen.getByText('Connect').click() + + await waitFor(() => { + expect(screen.getByTestId('error')).toHaveTextContent('First error') + }) + + // Second attempt succeeds + screen.getByText('Connect').click() + + await waitFor(() => { + expect(screen.getByTestId('connected')).toHaveTextContent('yes') + expect(screen.getByTestId('error')).toHaveTextContent('none') + }) + }) + + it('restores previously connected wallet on mount', async () => { + const storedAccount = { publicKey: 'GSTORED123', balance: 300 } + vi.spyOn(walletService, 'getStoredWallet').mockReturnValue(storedAccount) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByTestId('connected')).toHaveTextContent('yes') + expect(screen.getByTestId('wallet-key')).toHaveTextContent('GSTORED123') + }) + }) + + it('handles disconnect correctly', async () => { + const mockAccount = { publicKey: 'GTEST555', balance: 600 } + vi.spyOn(walletService, 'connectWallet').mockResolvedValue(mockAccount) + vi.spyOn(walletService, 'disconnectWallet').mockImplementation(() => {}) + + render( + + + + ) + + // Connect first + screen.getByText('Connect').click() + + await waitFor(() => { + expect(screen.getByTestId('connected')).toHaveTextContent('yes') + }) + + // Then disconnect + screen.getByText('Disconnect').click() + + expect(screen.getByTestId('connected')).toHaveTextContent('no') + expect(screen.getByTestId('wallet-key')).toHaveTextContent('none') + expect(walletService.disconnectWallet).toHaveBeenCalled() + }) +})