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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ EXPO_PUBLIC_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
# Deployed savings vault contract ID (C...). Leave empty to run the vault
# screen in mock mode no real funds are moved until this is set.
EXPO_PUBLIC_VAULT_CONTRACT_ID=

# Set to 'false' to disable the vault tab UI (e.g., when SDK is not ready)
EXPO_PUBLIC_VAULT_ENABLED=true

11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,14 @@ yarn-error.*
# generated native folders
/ios
/android

# spec-driven development documentation
PRD.md
Architecture.md
Tasks.md
task.md
Memory.md
Handoff.md



64 changes: 64 additions & 0 deletions __tests__/VaultUnavailableState.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import { VaultUnavailableState } from '../src/components/VaultUnavailableState';

jest.mock('lucide-react-native', () => ({
XCircle: () => null,
}));

describe('VaultUnavailableState', () => {
it('renders the title and primary message for no-wallet reason', () => {
const { getByText } = render(
<VaultUnavailableState reasons={['no-wallet']} />
);

expect(getByText('Vault Unavailable')).toBeTruthy();
expect(getByText('Create or import a wallet to use the Soroban Savings Vault.')).toBeTruthy();
});

it('renders detail boxes for multiple reasons', () => {
const { getByTestId, getByText } = render(
<VaultUnavailableState reasons={['feature-disabled', 'no-wallet']} />
);

expect(getByTestId('reason-detail-feature-disabled')).toBeTruthy();
expect(getByTestId('reason-detail-no-wallet')).toBeTruthy();
expect(getByText('EXPO_PUBLIC_VAULT_ENABLED is set to false.')).toBeTruthy();
});

it('calls onNavigateToSettings when Go to Settings button is pressed', () => {
const onNavigateToSettings = jest.fn();
const { getByText } = render(
<VaultUnavailableState
reasons={['no-wallet']}
onNavigateToSettings={onNavigateToSettings}
/>
);

const button = getByText('Go to Settings');
fireEvent.press(button);
expect(onNavigateToSettings).toHaveBeenCalledTimes(1);
});

it('calls onRetry when Try Again button is pressed for feature-disabled reason', () => {
const onRetry = jest.fn();
const { getByText } = render(
<VaultUnavailableState
reasons={['feature-disabled']}
onRetry={onRetry}
/>
);

const button = getByText('Try Again');
fireEvent.press(button);
expect(onRetry).toHaveBeenCalledTimes(1);
});

it('renders the docs reference link', () => {
const { getByText } = render(
<VaultUnavailableState reasons={['no-wallet']} />
);

expect(getByText('See docs/vault-ui-guidance.md for more information.')).toBeTruthy();
});
});
38 changes: 22 additions & 16 deletions __tests__/home.pullToRefresh.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ jest.mock('lucide-react-native', () => ({
ArrowDownLeft: () => null,
WifiOff: () => null,
RefreshCw: () => null,
ShieldAlert: () => null,
CheckSquare: () => null,
Square: () => null,
Info: () => null,
X: () => null,
}));

import { useWalletStore } from '../src/store/walletStore';
Expand Down Expand Up @@ -114,29 +119,29 @@ describe('AC-W1 – refreshWalletData on mount', () => {
describe('AC-W2, AC-W3, AC-W4 – RefreshControl props', () => {
it('attaches a RefreshControl to the ScrollView', () => {
setup();
const { UNSAFE_getByType } = render(<HomeScreen />);
const scrollView = UNSAFE_getByType(ScrollView);
const { UNSAFE_getAllByType } = render(<HomeScreen />);
const scrollView = UNSAFE_getAllByType(ScrollView)[0];
expect(scrollView.props.refreshControl).toBeDefined();
});

it('passes refreshing=false to RefreshControl when not loading', () => {
setup({ isLoading: false });
const { UNSAFE_getByType } = render(<HomeScreen />);
const scrollView = UNSAFE_getByType(ScrollView);
const { UNSAFE_getAllByType } = render(<HomeScreen />);
const scrollView = UNSAFE_getAllByType(ScrollView)[0];
expect(scrollView.props.refreshControl.props.refreshing).toBe(false);
});

it('passes refreshing=true to RefreshControl while loading', () => {
setup({ isLoading: true });
const { UNSAFE_getByType } = render(<HomeScreen />);
const scrollView = UNSAFE_getByType(ScrollView);
const { UNSAFE_getAllByType } = render(<HomeScreen />);
const scrollView = UNSAFE_getAllByType(ScrollView)[0];
expect(scrollView.props.refreshControl.props.refreshing).toBe(true);
});

it('calls refreshWalletData when onRefresh fires', () => {
const store = setup({ isLoading: false });
const { UNSAFE_getByType } = render(<HomeScreen />);
const scrollView = UNSAFE_getByType(ScrollView);
const { UNSAFE_getAllByType } = render(<HomeScreen />);
const scrollView = UNSAFE_getAllByType(ScrollView)[0];

// Simulate the pull gesture completing.
scrollView.props.refreshControl.props.onRefresh();
Expand All @@ -153,14 +158,14 @@ describe('AC-W2, AC-W3, AC-W4 – RefreshControl props', () => {
describe('AC-W5, AC-W6 – spinner while loading', () => {
it('marks RefreshControl as refreshing while isLoading is true', () => {
setup({ isLoading: true });
const { UNSAFE_getByType } = render(<HomeScreen />);
expect(UNSAFE_getByType(ScrollView).props.refreshControl.props.refreshing).toBe(true);
const { UNSAFE_getAllByType } = render(<HomeScreen />);
expect(UNSAFE_getAllByType(ScrollView)[0].props.refreshControl.props.refreshing).toBe(true);
});

it('clears refreshing once isLoading returns to false', () => {
setup({ isLoading: false });
const { UNSAFE_getByType } = render(<HomeScreen />);
expect(UNSAFE_getByType(ScrollView).props.refreshControl.props.refreshing).toBe(false);
const { UNSAFE_getAllByType } = render(<HomeScreen />);
expect(UNSAFE_getAllByType(ScrollView)[0].props.refreshControl.props.refreshing).toBe(false);
});
});

Expand All @@ -177,8 +182,8 @@ describe('AC-W7 – no duplicate requests while already refreshing', () => {
* a single additional call — and that the store guard is in place.
*/
const store = setup({ isLoading: true });
const { UNSAFE_getByType } = render(<HomeScreen />);
const rc = UNSAFE_getByType(ScrollView).props.refreshControl;
const { UNSAFE_getAllByType } = render(<HomeScreen />);
const rc = UNSAFE_getAllByType(ScrollView)[0].props.refreshControl;

rc.props.onRefresh();

Expand All @@ -203,11 +208,12 @@ describe('AC-W8 – error state', () => {

it('resets refreshing (isLoading=false) after a failed refresh', () => {
setup({ isLoading: false, error: 'Horizon down' });
const { UNSAFE_getByType } = render(<HomeScreen />);
expect(UNSAFE_getByType(ScrollView).props.refreshControl.props.refreshing).toBe(false);
const { UNSAFE_getAllByType } = render(<HomeScreen />);
expect(UNSAFE_getAllByType(ScrollView)[0].props.refreshControl.props.refreshing).toBe(false);
});
});


// ─────────────────────────────────────────────────────────────────────────────
// AC-W9 – Balance value is rendered
// ─────────────────────────────────────────────────────────────────────────────
Expand Down
5 changes: 3 additions & 2 deletions __tests__/paymentSuccess.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,10 @@ describe('graceful handling of missing or invalid data', () => {
amount: undefined,
destination: DESTINATION,
} as any);
const { getByText, queryByText } = render(<PaymentSuccessScreen />);
expect(getByText('—')).toBeTruthy();
const { getAllByText, queryByText } = render(<PaymentSuccessScreen />);
expect(getAllByText('—').length).toBeGreaterThan(0);
expect(queryByText('— XLM')).toBeNull();

});

it('handles missing date gracefully by showing fallback dash', () => {
Expand Down
126 changes: 24 additions & 102 deletions __tests__/send.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,129 +208,47 @@ describe('AC3 – submit is blocked when the form is invalid', () => {
// AC4 – Valid form calls sendXlmTransaction
// ────────────────────────────────────────────────────────────────────────

describe('AC4 – valid form calls sendXlmTransaction', () => {
it('calls sendXlmTransaction with correct arguments on a valid submission', async () => {
describe('AC4 – valid form calls router.push to review-transaction', () => {
it('navigates to review-transaction with correct arguments on a valid submission', async () => {
const { getByPlaceholderText, getByText } = render(<SendScreen />);

fireEvent.changeText(getByPlaceholderText('G...'), VALID_DESTINATION);
fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT);
fireEvent.press(getByText('Send Payment'));
await waitFor(() => getByText('Sign & Send'));
fireEvent.press(getByText('Sign & Send'));

await waitFor(() => {
expect(mockSendXlmTransaction).toHaveBeenCalledWith(
MOCK_SECRET, VALID_DESTINATION, VALID_AMOUNT, '',
);
expect(mockPush).toHaveBeenCalledWith({
pathname: '/review-transaction',
params: {
destination: VALID_DESTINATION,
amount: VALID_AMOUNT,
memo: '',
},
});
});
});

it('passes memo text to sendXlmTransaction when provided', async () => {
it('passes memo text to review-transaction params when provided', async () => {
const { getByPlaceholderText, getByText } = render(<SendScreen />);

fireEvent.changeText(getByPlaceholderText('G...'), VALID_DESTINATION);
fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT);
fireEvent.changeText(getByPlaceholderText('Payment reference'), 'invoice-42');
fireEvent.press(getByText('Send Payment'));
await waitFor(() => getByText('Sign & Send'));
fireEvent.press(getByText('Sign & Send'));

await waitFor(() => {
expect(mockSendXlmTransaction).toHaveBeenCalledWith(
MOCK_SECRET, VALID_DESTINATION, VALID_AMOUNT, 'invoice-42',
);
});
});

it('navigates to the payment success receipt with the tx hash, amount, and destination', async () => {
const { getByPlaceholderText, getByText } = render(<SendScreen />);

fireEvent.changeText(getByPlaceholderText('G...'), VALID_DESTINATION);
fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT);
fireEvent.press(getByText('Send Payment'));
await waitFor(() => getByText('Sign & Send'));
fireEvent.press(getByText('Sign & Send'));

await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith({
pathname: '/payment-success',
expect(mockPush).toHaveBeenCalledWith({
pathname: '/review-transaction',
params: {
hash: 'abc123',
amount: VALID_AMOUNT,
destination: VALID_DESTINATION,
date: expect.any(String),
amount: VALID_AMOUNT,
memo: 'invoice-42',
},
});
});
});

it('refreshes wallet data after a successful send', async () => {
const refreshWalletData = jest.fn();
setupWalletStore({ refreshWalletData });
const { getByPlaceholderText, getByText } = render(<SendScreen />);

fireEvent.changeText(getByPlaceholderText('G...'), VALID_DESTINATION);
fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT);
fireEvent.press(getByText('Send Payment'));
await waitFor(() => getByText('Sign & Send'));
fireEvent.press(getByText('Sign & Send'));

await waitFor(() => {
expect(refreshWalletData).toHaveBeenCalled();
});
});
});

// ────────────────────────────────────────────────────────────────────────
// AC5 – Failure displays an error
// ────────────────────────────────────────────────────────────────────────

describe('AC5 – failure displays error', () => {
it('shows recovery guidance when sendXlmTransaction throws a known error', async () => {
mockSendXlmTransaction.mockRejectedValueOnce(new Error('tx_bad_seq'));
const { getByPlaceholderText, getByText } = render(<SendScreen />);

fireEvent.changeText(getByPlaceholderText('G...'), VALID_DESTINATION);
fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT);
fireEvent.press(getByText('Send Payment'));
await waitFor(() => getByText('Sign & Send'));
fireEvent.press(getByText('Sign & Send'));

await waitFor(() => {
// User-friendly title from the recovery guidance, not raw error code
expect(getByText('Sequence Error')).toBeTruthy();
});
});

it('shows default guidance when the error has no message', async () => {
mockSendXlmTransaction.mockRejectedValueOnce({});
const { getByPlaceholderText, getByText } = render(<SendScreen />);

fireEvent.changeText(getByPlaceholderText('G...'), VALID_DESTINATION);
fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT);
fireEvent.press(getByText('Send Payment'));
await waitFor(() => getByText('Sign & Send'));
fireEvent.press(getByText('Sign & Send'));

await waitFor(() => {
expect(getByText('Transaction Failed')).toBeTruthy();
});
});

it('does NOT call router.back after a failed send', async () => {
mockSendXlmTransaction.mockRejectedValueOnce(new Error('network error'));
const { getByPlaceholderText, getByText } = render(<SendScreen />);

fireEvent.changeText(getByPlaceholderText('G...'), VALID_DESTINATION);
fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT);
fireEvent.press(getByText('Send Payment'));
await waitFor(() => getByText('Sign & Send'));
fireEvent.press(getByText('Sign & Send'));

await waitFor(() => expect(getByText('Network Error')).toBeTruthy());
expect(mockBack).not.toHaveBeenCalled();
});
});

// ────────────────────────────────────────────────────────────────────────
// AC6 – Scan option exists
Expand Down Expand Up @@ -418,17 +336,21 @@ describe('AC8 – valid scan fills destination field', () => {
fireEvent.changeText(getByPlaceholderText('G...'), SCANNED_ADDRESS);
fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT);
fireEvent.press(getByText('Send Payment'));
await waitFor(() => getByText('Sign & Send'));
fireEvent.press(getByText('Sign & Send'));

await waitFor(() => {
expect(mockSendXlmTransaction).toHaveBeenCalledWith(
MOCK_SECRET, SCANNED_ADDRESS, VALID_AMOUNT, '',
);
expect(mockPush).toHaveBeenCalledWith({
pathname: '/review-transaction',
params: {
destination: SCANNED_ADDRESS,
amount: VALID_AMOUNT,
memo: '',
},
});
});
});
});


// ────────────────────────────────────────────────────────────────────────
// AC9 – Invalid QR shows an error
// ────────────────────────────────────────────────────────────────────────
Expand Down
Loading