Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
25 changes: 21 additions & 4 deletions src/components/WalletButton.jsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -24,8 +34,15 @@ export default function WalletButton() {
}

return (
<Button onClick={connect} disabled={connecting}>
{connecting ? 'Connecting...' : 'Connect Wallet'}
</Button>
<div className="wallet-button-wrapper">
<Button onClick={handleConnect} disabled={connecting}>
{connecting ? 'Connecting...' : 'Connect Wallet'}
</Button>
{connectionError && (
<Alert variant="error" style={{ marginTop: '0.5rem' }}>
{connectionError}
</Alert>
)}
</div>
)
}
17 changes: 16 additions & 1 deletion src/context/AppContext.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand All @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions src/hooks/useWallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
11 changes: 10 additions & 1 deletion src/services/wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
140 changes: 140 additions & 0 deletions test/components/WalletButton.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<AppProvider>{component}</AppProvider>)
}

describe('WalletButton', () => {
it('renders connect button when wallet is not connected', () => {
renderWithProvider(<WalletButton />)
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(<WalletButton />)
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(<WalletButton />)

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(<WalletButton />)

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(<WalletButton />)

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(<WalletButton />)

// 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(<WalletButton />)

// 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(<WalletButton />)
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()
})
})
})
82 changes: 80 additions & 2 deletions test/services/wallet.test.js
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -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()
})
})
Loading
Loading