Skip to content

Commit 554872c

Browse files
add negative path test coverage (#490)
Co-authored-by: macbook <lawaltoheeb36@gmail.com>
1 parent f021db1 commit 554872c

3 files changed

Lines changed: 212 additions & 18 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import React from 'react';
2+
import { render, fireEvent, waitFor } from '@testing-library/react-native';
3+
4+
jest.mock('expo-router');
5+
jest.mock('../src/services/stellar', () => ({
6+
server: {
7+
fetchBaseFee: jest.fn(async () => 100),
8+
},
9+
sendXlmTransaction: jest.fn(),
10+
}));
11+
jest.mock('../src/store/walletStore');
12+
jest.mock('../src/store/appStore', () => ({
13+
useAppStore: jest.fn((selector) => {
14+
const state = { contacts: [] };
15+
return selector ? selector(state) : state;
16+
}),
17+
}));
18+
jest.mock('../src/hooks/useTheme', () => ({
19+
useTheme: () => ({
20+
colors: {
21+
background: '#000000',
22+
surface: '#111111',
23+
textPrimary: '#ffffff',
24+
textSecondary: '#bbbbbb',
25+
textMuted: '#999999',
26+
primary: '#00E5FF',
27+
success: '#00C853',
28+
warning: '#FFB300',
29+
error: '#FF5252',
30+
border: '#333333',
31+
},
32+
}),
33+
}));
34+
jest.mock('lucide-react-native', () => ({
35+
ArrowRight: () => null,
36+
Smartphone: () => null,
37+
AlertTriangle: () => null,
38+
CheckCircle: () => null,
39+
XCircle: () => null,
40+
}));
41+
jest.mock('@/components', () => {
42+
const React = require('react');
43+
const { Text, TouchableOpacity, View } = require('react-native');
44+
45+
return {
46+
Button: ({ title, onPress }: any) => (
47+
<TouchableOpacity onPress={onPress}>
48+
<Text>{title}</Text>
49+
</TouchableOpacity>
50+
),
51+
LoadingState: ({ accessibilityLabel }: any) => <Text>{accessibilityLabel}</Text>,
52+
ReviewConfirm: ({ items, confirmLabel, onConfirm, cancelLabel, onCancel }: any) => (
53+
<View>
54+
{items.map((item: any) => (
55+
<Text key={item.label}>{item.value}</Text>
56+
))}
57+
{confirmLabel ? (
58+
<TouchableOpacity onPress={onConfirm}>
59+
<Text>{confirmLabel}</Text>
60+
</TouchableOpacity>
61+
) : null}
62+
{cancelLabel ? (
63+
<TouchableOpacity onPress={onCancel}>
64+
<Text>{cancelLabel}</Text>
65+
</TouchableOpacity>
66+
) : null}
67+
</View>
68+
),
69+
ReviewItem: () => null,
70+
ScreenHeader: ({ title, subtitle }: any) => (
71+
<View>
72+
<Text>{title}</Text>
73+
{subtitle ? <Text>{subtitle}</Text> : null}
74+
</View>
75+
),
76+
StatusBadge: ({ text }: any) => <Text>{text}</Text>,
77+
};
78+
});
79+
80+
import { useRouter, useLocalSearchParams } from 'expo-router';
81+
import { sendXlmTransaction } from '../src/services/stellar';
82+
import { useWalletStore } from '../src/store/walletStore';
83+
import { useSignerStore } from '../src/store/signerStore';
84+
import ReviewTransactionScreen from '../app/review-transaction';
85+
import { UNCONFIRMED_SUBMISSION_MESSAGE } from '../src/utils/paymentErrors';
86+
87+
const mockUseRouter = useRouter as jest.MockedFunction<typeof useRouter>;
88+
const mockUseLocalSearchParams = useLocalSearchParams as jest.MockedFunction<typeof useLocalSearchParams>;
89+
const mockUseWalletStore = useWalletStore as jest.MockedFunction<typeof useWalletStore>;
90+
const mockSendXlmTransaction = sendXlmTransaction as jest.MockedFunction<typeof sendXlmTransaction>;
91+
92+
const mockBack = jest.fn();
93+
const mockReplace = jest.fn();
94+
95+
describe('ReviewTransactionScreen negative paths', () => {
96+
beforeEach(() => {
97+
jest.clearAllMocks();
98+
useSignerStore.getState().reset();
99+
mockUseRouter.mockReturnValue({
100+
back: mockBack,
101+
replace: mockReplace,
102+
push: jest.fn(),
103+
} as any);
104+
mockUseLocalSearchParams.mockReturnValue({
105+
destination: 'GDEST123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCD',
106+
amount: '10',
107+
memo: 'invoice-42',
108+
} as any);
109+
mockUseWalletStore.mockReturnValue({
110+
publicKey: 'GSOURCE123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABC',
111+
getSecretKey: jest.fn(async () => 'SSECRET123'),
112+
refreshWalletData: jest.fn(),
113+
addPendingTransaction: jest.fn(),
114+
} as any);
115+
});
116+
117+
it('shows a safe unconfirmed-submission message when the network request fails', async () => {
118+
mockSendXlmTransaction.mockRejectedValueOnce(new Error('fetch failed'));
119+
120+
const { getByText } = render(<ReviewTransactionScreen />);
121+
fireEvent.press(getByText('Sign & Send'));
122+
123+
await waitFor(() => {
124+
expect(getByText('Transaction Failed')).toBeTruthy();
125+
expect(getByText(UNCONFIRMED_SUBMISSION_MESSAGE)).toBeTruthy();
126+
expect(getByText('Go Back')).toBeTruthy();
127+
});
128+
});
129+
130+
it('surfaces a clear insufficient-balance failure from submission', async () => {
131+
mockSendXlmTransaction.mockRejectedValueOnce(new Error('op_underfunded'));
132+
133+
const { getByText } = render(<ReviewTransactionScreen />);
134+
fireEvent.press(getByText('Sign & Send'));
135+
136+
await waitFor(() => {
137+
expect(getByText('Transaction Failed')).toBeTruthy();
138+
expect(getByText(UNCONFIRMED_SUBMISSION_MESSAGE)).toBeTruthy();
139+
});
140+
});
141+
142+
it('shows the cancelled state clearly when signing is aborted before submission', () => {
143+
useSignerStore.getState().cancelSigning();
144+
145+
const { getByText } = render(<ReviewTransactionScreen />);
146+
147+
expect(getByText('Cancelled')).toBeTruthy();
148+
expect(getByText('Signing was cancelled. No transaction was submitted.')).toBeTruthy();
149+
expect(getByText('Go Back')).toBeTruthy();
150+
});
151+
});

__tests__/send.test.tsx

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
* AC1 – Invalid address error is tested (empty destination blocks submit)
66
* AC2 – Invalid amount error is tested (zero / negative amount blocks submit)
77
* AC3 – Submit is blocked when the form is invalid
8-
* AC4 – Valid form calls sendXlmTransaction
9-
* AC5 – Failure in sendXlmTransaction displays an error alert
8+
* AC4 – Valid form routes into sign confirmation
9+
* AC5 – Validation includes missing balance / reserve protection states
1010
* AC6 – Scan option exists on the destination field
1111
* AC7 – Camera permission is handled (denied state shown, grant flow works)
1212
* AC8 – A valid scanned address fills the destination field and closes the scanner
@@ -72,11 +72,9 @@ jest.mock('expo-camera', () => ({
7272

7373
// ─── Typed mock imports ──────────────────────────────────────────────────
7474

75-
import { sendXlmTransaction } from '../src/services/stellar';
7675
import { useWalletStore } from '../src/store/walletStore';
7776
import { useRouter } from 'expo-router';
7877

79-
const mockSendXlmTransaction = sendXlmTransaction as jest.MockedFunction<typeof sendXlmTransaction>;
8078
const mockUseWalletStore = useWalletStore as jest.MockedFunction<typeof useWalletStore>;
8179
const mockUseRouter = useRouter as jest.MockedFunction<typeof useRouter>;
8280

@@ -120,7 +118,6 @@ beforeEach(() => {
120118
alertSpy.mockImplementation(() => undefined);
121119
mockUseRouter.mockReturnValue({ back: mockBack, push: mockPush, replace: mockReplace } as any);
122120
setupWalletStore();
123-
mockSendXlmTransaction.mockResolvedValue({ hash: 'abc123' } as any);
124121
mockPermissionGranted = true;
125122
mockPermissionCanAskAgain = true;
126123
});
@@ -175,6 +172,19 @@ describe('AC2 – invalid amount error', () => {
175172

176173
expect(getByText("You don't have enough XLM for this payment.")).toBeTruthy();
177174
});
175+
176+
it('shows a reserve-protection error when the payment would leave too little XLM behind', async () => {
177+
setupWalletStore({ balance: '5.0000000' });
178+
const { getByPlaceholderText, getByText } = render(<SendScreen />);
179+
180+
fireEvent.changeText(getByPlaceholderText('G...'), VALID_DESTINATION);
181+
fireEvent.changeText(getByPlaceholderText('0.00'), '4.5');
182+
fireEvent.press(getByText('Send Payment'));
183+
184+
expect(
185+
getByText('You need to keep at least 1 XLM in your wallet, so this amount is too high.'),
186+
).toBeTruthy();
187+
});
178188
});
179189

180190
// ────────────────────────────────────────────────────────────────────────
@@ -200,16 +210,28 @@ describe('AC3 – submit is blocked when the form is invalid', () => {
200210
fireEvent.press(getByText('Send Payment'));
201211

202212
expect(getByText('Amount must be more than 0.')).toBeTruthy();
203-
expect(mockSendXlmTransaction).not.toHaveBeenCalled();
213+
expect(mockPush).not.toHaveBeenCalled();
214+
});
215+
216+
it('does not continue when the recipient is the current wallet', async () => {
217+
setupWalletStore({ publicKey: VALID_DESTINATION });
218+
const { getByPlaceholderText, getByText } = render(<SendScreen />);
219+
220+
fireEvent.changeText(getByPlaceholderText('G...'), VALID_DESTINATION);
221+
fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT);
222+
fireEvent.press(getByText('Send Payment'));
223+
224+
expect(getByText("You can't send a payment to your own wallet.")).toBeTruthy();
225+
expect(mockPush).not.toHaveBeenCalled();
204226
});
205227
});
206228

207229
// ────────────────────────────────────────────────────────────────────────
208-
// AC4 – Valid form calls sendXlmTransaction
230+
// AC4 – Valid form routes into sign confirmation
209231
// ────────────────────────────────────────────────────────────────────────
210232

211-
describe('AC4 – valid form calls router.push to review-transaction', () => {
212-
it('navigates to review-transaction with correct arguments on a valid submission', async () => {
233+
describe('AC4 – valid form routes into sign confirmation', () => {
234+
it('navigates to sign-confirmation with correct arguments on a valid submission', async () => {
213235
const { getByPlaceholderText, getByText } = render(<SendScreen />);
214236

215237
fireEvent.changeText(getByPlaceholderText('G...'), VALID_DESTINATION);
@@ -218,17 +240,21 @@ describe('AC4 – valid form calls router.push to review-transaction', () => {
218240

219241
await waitFor(() => {
220242
expect(mockPush).toHaveBeenCalledWith({
221-
pathname: '/review-transaction',
243+
pathname: '/sign-confirmation',
222244
params: {
245+
source: 'GPUBLIC123',
223246
destination: VALID_DESTINATION,
224247
amount: VALID_AMOUNT,
248+
assetCode: 'XLM',
225249
memo: '',
250+
fee: '100',
251+
network: 'Testnet',
226252
},
227253
});
228254
});
229255
});
230256

231-
it('passes memo text to review-transaction params when provided', async () => {
257+
it('passes memo text to sign-confirmation params when provided', async () => {
232258
const { getByPlaceholderText, getByText } = render(<SendScreen />);
233259

234260
fireEvent.changeText(getByPlaceholderText('G...'), VALID_DESTINATION);
@@ -238,11 +264,15 @@ describe('AC4 – valid form calls router.push to review-transaction', () => {
238264

239265
await waitFor(() => {
240266
expect(mockPush).toHaveBeenCalledWith({
241-
pathname: '/review-transaction',
267+
pathname: '/sign-confirmation',
242268
params: {
269+
source: 'GPUBLIC123',
243270
destination: VALID_DESTINATION,
244271
amount: VALID_AMOUNT,
272+
assetCode: 'XLM',
245273
memo: 'invoice-42',
274+
fee: '100',
275+
network: 'Testnet',
246276
},
247277
});
248278
});
@@ -322,7 +352,7 @@ describe('AC7 – camera permission handling', () => {
322352
// ────────────────────────────────────────────────────────────────────────
323353

324354
describe('AC8 – valid scan fills destination field', () => {
325-
it('closes the scanner and calls sendXlmTransaction with the scanned address on submit', async () => {
355+
it('closes the scanner and routes into sign confirmation with the scanned address on submit', async () => {
326356
const { getByLabelText, getByPlaceholderText, getByText, queryByText } = render(<SendScreen />);
327357

328358
// We drive this through the same handler the QrScanner would call: onScan.
@@ -339,11 +369,15 @@ describe('AC8 – valid scan fills destination field', () => {
339369

340370
await waitFor(() => {
341371
expect(mockPush).toHaveBeenCalledWith({
342-
pathname: '/review-transaction',
372+
pathname: '/sign-confirmation',
343373
params: {
374+
source: 'GPUBLIC123',
344375
destination: SCANNED_ADDRESS,
345376
amount: VALID_AMOUNT,
377+
assetCode: 'XLM',
346378
memo: '',
379+
fee: '100',
380+
network: 'Testnet',
347381
},
348382
});
349383
});

docs/user-flows.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,21 @@ New keypairs do not exist on the Stellar ledger until they receive their first T
101101
3. Tapping **Send Payment** validates locally before navigating:
102102
- destination and amount are required;
103103
- amount must be greater than zero; and
104-
- amount must not exceed the displayed balance.
105-
4. If validation passes, navigate to `/review-transaction` with the payment details.
104+
- amount must not exceed the displayed balance or violate the XLM reserve floor.
105+
4. Validation failures stay inline on the form so the user can correct the destination, amount, or memo safely.
106+
5. If validation passes, navigate to `/sign-confirmation` with the payment details.
107+
108+
### Sign Confirmation
109+
110+
**Entry:** Send → **Send Payment** (after validation) → `/sign-confirmation`.
111+
112+
1. The Sign Confirmation screen shows the final source, destination, amount, memo, fee, and network details before any signing happens.
113+
2. Tapping **Cancel** keeps signing separate from editing and lets the user abort before any transaction is signed or submitted.
114+
3. Tapping **Sign Transaction** continues to `/review-transaction`.
106115

107116
### Transaction Review
108117

109-
**Entry:** Send **Send Payment** (after validation)`/review-transaction`.
118+
**Entry:** Sign Confirmation **Sign Transaction**`/review-transaction`.
110119

111120
1. The Review screen displays the full transaction details: source, destination (with contact label if known), amount, memo, and network.
112121
2. A signer info card shows which signer will be used (currently "This Device") and its security model.
@@ -115,7 +124,7 @@ New keypairs do not exist on the Stellar ledger until they receive their first T
115124
- Phase transitions: `review``handoff``signing``submitting``completed`
116125
- A loading indicator shows the current phase.
117126
5. On success, a success card appears with the transaction hash, then the user is navigated to the payment success screen.
118-
6. On failure, a red error card appears with the error message and a **Dismiss** button that returns to the Send screen.
127+
6. On failure, a red error card appears with safe, actionable copy and a **Go Back** path so the user can leave the failed state without risking a duplicate submission.
119128
7. At any point before submission, the user can tap **Cancel** to abort and return to Send.
120129

121130
**Expected states**

0 commit comments

Comments
 (0)