Skip to content

Commit d8115a3

Browse files
committed
add unit tests
1 parent 3f2050e commit d8115a3

3 files changed

Lines changed: 315 additions & 3 deletions

File tree

tests/actions/IOUTest/RejectMoneyRequestTest.ts

Lines changed: 133 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {markRejectViolationAsResolved, rejectExpenseReport, rejectMoneyRequest} from '@libs/actions/IOU/RejectMoneyRequest';
1+
import {dismissRejectExpenseError, markRejectViolationAsResolved, rejectExpenseReport, rejectMoneyRequest} from '@libs/actions/IOU/RejectMoneyRequest';
22
import initOnyxDerivedValues from '@libs/actions/OnyxDerived';
33
import {WRITE_COMMANDS} from '@libs/API/types';
44
import {getParsedComment} from '@libs/ReportUtils';
@@ -22,7 +22,7 @@ import createRandomPolicy from '../../utils/collections/policies';
2222
import {createRandomReport} from '../../utils/collections/reports';
2323
import createRandomTransaction from '../../utils/collections/transaction';
2424
import getOnyxValue from '../../utils/getOnyxValue';
25-
import {getCurrencyDecimalsLocal, getGlobalFetchMock, getOnyxData} from '../../utils/TestHelper';
25+
import {getCurrencyDecimalsLocal, getGlobalFetchMock, getOnyxData, translateLocal} from '../../utils/TestHelper';
2626
import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates';
2727

2828
jest.mock('@src/libs/Navigation/Navigation', () => ({
@@ -334,6 +334,137 @@ describe('actions/IOU/RejectMoneyRequest', () => {
334334
expect(firstRejectedTransaction?.reportID).toBe(sharedRejectedToReportID);
335335
expect(secondRejectedTransaction?.reportID).toBe(sharedRejectedToReportID);
336336
});
337+
338+
describe('when the expense has already moved off the report', () => {
339+
const MOVED_TO_REPORT_ID = '999';
340+
const ERROR_TIMESTAMP = '1770000000000000';
341+
342+
// The copy Web-Expensify returns for this case, reported under its own `reject` error field
343+
const SERVER_REJECT_ERROR = {[ERROR_TIMESTAMP]: 'The expense has already been moved or rejected.'};
344+
345+
/** Puts a second expense on the report so rejecting moves the expense off it instead of deleting the report. */
346+
async function addSecondExpenseToReport() {
347+
const secondTransaction = {
348+
...createRandomTransaction(2),
349+
reportID: iouReport?.reportID,
350+
amount,
351+
currency: CONST.CURRENCY.USD,
352+
merchant: 'Second Test Merchant',
353+
transactionID: '2',
354+
};
355+
await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${secondTransaction.transactionID}`, secondTransaction);
356+
await waitForBatchedUpdates();
357+
}
358+
359+
it('should not send a reject for an expense the server has already moved elsewhere', async () => {
360+
if (!transaction?.transactionID || !iouReport?.reportID) {
361+
throw new Error('Required transaction or report data is missing');
362+
}
363+
364+
// Given: The expense now sits on a different report, so the copy on this report is stale
365+
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, {reportID: MOVED_TO_REPORT_ID});
366+
await waitForBatchedUpdates();
367+
368+
// When: Rejecting it from the report it is no longer on
369+
const result = rejectMoneyRequest(
370+
transaction.transactionID,
371+
iouReport.reportID,
372+
comment,
373+
policy,
374+
TEST_USER_ACCOUNT_ID,
375+
TEST_USER_EMAIL,
376+
[CONST.BETAS.ALL],
377+
undefined,
378+
getCurrencyDecimalsLocal,
379+
);
380+
381+
await waitForBatchedUpdates();
382+
383+
// Then: There is nowhere to navigate back to, and nothing was applied optimistically —
384+
// the expense stays put on the report the server moved it to
385+
expect(result).toBeUndefined();
386+
const untouchedTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`);
387+
expect(untouchedTransaction?.reportID).toBe(MOVED_TO_REPORT_ID);
388+
});
389+
390+
it('should put the expense back on the report with an error when the reject fails', async () => {
391+
if (!transaction?.transactionID || !iouReport?.reportID) {
392+
throw new Error('Required transaction or report data is missing');
393+
}
394+
await addSecondExpenseToReport();
395+
396+
// When: The server rejects the request
397+
mockFetch?.fail?.();
398+
rejectMoneyRequest(
399+
transaction.transactionID,
400+
iouReport.reportID,
401+
comment,
402+
policy,
403+
TEST_USER_ACCOUNT_ID,
404+
TEST_USER_EMAIL,
405+
[CONST.BETAS.ALL],
406+
undefined,
407+
getCurrencyDecimalsLocal,
408+
);
409+
await waitForBatchedUpdates();
410+
411+
// Then: The expense returns to the report carrying a translated rejection error
412+
const rejectedTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`);
413+
expect(rejectedTransaction?.reportID).toBe(iouReport.reportID);
414+
expect(Object.values(rejectedTransaction?.errors ?? {})).toEqual([translateLocal('iou.rejectReport.couldNotRejectExpense')]);
415+
});
416+
417+
it('should clear an earlier rejection error when the expense is rejected again', async () => {
418+
if (!transaction?.transactionID || !iouReport?.reportID) {
419+
throw new Error('Required transaction or report data is missing');
420+
}
421+
await addSecondExpenseToReport();
422+
423+
// Given: The expense still shows the error left by an earlier failed reject
424+
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, {
425+
errors: {[ERROR_TIMESTAMP]: translateLocal('iou.rejectReport.couldNotRejectExpense')},
426+
errorFields: {reject: SERVER_REJECT_ERROR},
427+
});
428+
await waitForBatchedUpdates();
429+
430+
// When: Rejecting it again
431+
rejectMoneyRequest(
432+
transaction.transactionID,
433+
iouReport.reportID,
434+
comment,
435+
policy,
436+
TEST_USER_ACCOUNT_ID,
437+
TEST_USER_EMAIL,
438+
[CONST.BETAS.ALL],
439+
undefined,
440+
getCurrencyDecimalsLocal,
441+
);
442+
await waitForBatchedUpdates();
443+
444+
// Then: The stale error is gone, so the retry does not show the previous failure
445+
const rejectedTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`);
446+
expect(rejectedTransaction?.errors).toBeFalsy();
447+
expect(rejectedTransaction?.errorFields?.reject).toBeFalsy();
448+
});
449+
450+
it('should drop the stale local copy of the expense when its reject error is dismissed', async () => {
451+
if (!transaction?.transactionID) {
452+
throw new Error('Required transaction data is missing');
453+
}
454+
455+
// Given: An expense the server reported as already moved
456+
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, {errorFields: {reject: SERVER_REJECT_ERROR}});
457+
await waitForBatchedUpdates();
458+
459+
// When: The user dismisses the error
460+
dismissRejectExpenseError(transaction.transactionID);
461+
await waitForBatchedUpdates();
462+
463+
// Then: The expense is gone locally, so it stops showing on a report it is no longer on
464+
const dismissedTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`);
465+
expect(dismissedTransaction).toBeFalsy();
466+
});
467+
});
337468
});
338469

339470
describe('rejectExpenseReport', () => {

tests/actions/TransactionTest.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {format} from 'date-fns';
3030
import Onyx from 'react-native-onyx';
3131
import createRandomReportAction from 'tests/utils/collections/reportActions';
3232

33-
import {changeTransactionsReport as changeTransactionsReportAction} from '../../src/libs/actions/Transaction';
33+
import {changeTransactionsReport as changeTransactionsReportAction, clearError} from '../../src/libs/actions/Transaction';
3434
import currencyList from '../unit/currencyList.json';
3535
import createPersonalDetails from '../utils/collections/personalDetails';
3636
import createRandomPolicy from '../utils/collections/policies';
@@ -192,6 +192,34 @@ describe('actions/Transaction', () => {
192192
jest.clearAllMocks();
193193
});
194194

195+
describe('clearError', () => {
196+
it('should clear the reject error reported against an expense that had already moved', async () => {
197+
const transactionID = 'transaction-with-reject-error';
198+
const errorTimestamp = '1770000000000000';
199+
200+
// Given: An expense carrying a reject error from the server alongside an unrelated route error
201+
await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
202+
transactionID,
203+
errors: {[errorTimestamp]: 'Something went wrong'},
204+
errorFields: {
205+
reject: {[errorTimestamp]: 'The expense has already been moved or rejected.'},
206+
route: {[errorTimestamp]: 'Route error'},
207+
},
208+
});
209+
await waitForBatchedUpdates();
210+
211+
// When: The error is dismissed
212+
clearError(transactionID);
213+
await waitForBatchedUpdates();
214+
215+
// Then: The reject field is cleared along with the errors already covered
216+
const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`);
217+
expect(transaction?.errors).toBeFalsy();
218+
expect(transaction?.errorFields?.reject).toBeFalsy();
219+
expect(transaction?.errorFields?.route).toBeFalsy();
220+
});
221+
});
222+
195223
describe('changeTransactionsReport', () => {
196224
it('should set the correct optimistic onyx data for reporting a tracked expense', async () => {
197225
let personalDetailsList: OnyxEntry<PersonalDetailsList>;
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import {act, fireEvent, render, screen} from '@testing-library/react-native';
2+
3+
import ComposeProviders from '@components/ComposeProviders';
4+
import {LocaleContextProvider} from '@components/LocaleContextProvider';
5+
import MoneyRequestReportTransactionItem from '@components/MoneyRequestReportView/MoneyRequestReportTransactionItem';
6+
import OnyxListItemProvider from '@components/OnyxListItemProvider';
7+
import ScreenWrapper from '@components/ScreenWrapper';
8+
9+
import CONST from '@src/CONST';
10+
import IntlStore from '@src/languages/IntlStore';
11+
import ONYXKEYS from '@src/ONYXKEYS';
12+
import type {Report} from '@src/types/onyx';
13+
import type Transaction from '@src/types/onyx/Transaction';
14+
15+
import {PortalProvider} from '@gorhom/portal';
16+
import React from 'react';
17+
import Onyx from 'react-native-onyx';
18+
19+
import createRandomTransaction from '../utils/collections/transaction';
20+
import getOnyxValue from '../utils/getOnyxValue';
21+
import {translateLocal} from '../utils/TestHelper';
22+
import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct';
23+
24+
jest.mock('@react-navigation/native');
25+
26+
const REPORT_ID = '12345';
27+
const TRANSACTION_ID = '67890';
28+
const ERROR_TIMESTAMP = '1770000000000000';
29+
30+
// The exact copy Web-Expensify returns for this case (Web-Expensify/lib/ReportAPI.php). It is English-only,
31+
// so it must never reach the screen.
32+
const SERVER_REJECT_MESSAGE = 'The expense has already been moved or rejected.';
33+
34+
const report: Report = {
35+
reportID: REPORT_ID,
36+
type: CONST.REPORT.TYPE.EXPENSE,
37+
currency: CONST.CURRENCY.USD,
38+
reportName: 'Test report',
39+
};
40+
41+
function buildTransaction(overrides: Partial<Transaction> = {}): Transaction {
42+
return {
43+
...createRandomTransaction(0),
44+
transactionID: TRANSACTION_ID,
45+
reportID: REPORT_ID,
46+
amount: 1000,
47+
currency: CONST.CURRENCY.USD,
48+
merchant: 'Test Merchant',
49+
...overrides,
50+
};
51+
}
52+
53+
/** A transaction the server reported as already moved, which lands under its own `reject` error field. */
54+
function buildTransactionWithRejectError(): Transaction {
55+
return buildTransaction({errorFields: {reject: {[ERROR_TIMESTAMP]: SERVER_REJECT_MESSAGE}}});
56+
}
57+
58+
describe('MoneyRequestReportTransactionItem - reject errors', () => {
59+
beforeAll(() => {
60+
Onyx.init({keys: ONYXKEYS});
61+
IntlStore.load(CONST.LOCALES.EN);
62+
return waitForBatchedUpdatesWithAct();
63+
});
64+
65+
afterEach(async () => {
66+
await act(async () => {
67+
await Onyx.clear();
68+
});
69+
jest.clearAllMocks();
70+
await waitForBatchedUpdatesWithAct();
71+
});
72+
73+
async function renderTransactionItem(transaction: Transaction, handleOnPress = jest.fn()) {
74+
await act(async () => {
75+
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report);
76+
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction);
77+
});
78+
await waitForBatchedUpdatesWithAct();
79+
80+
render(
81+
<ComposeProviders components={[OnyxListItemProvider, LocaleContextProvider]}>
82+
<ScreenWrapper testID="test">
83+
<PortalProvider>
84+
<MoneyRequestReportTransactionItem
85+
transaction={transaction}
86+
violations={[]}
87+
report={report}
88+
policy={undefined}
89+
isSelectionModeEnabled={false}
90+
toggleTransaction={jest.fn()}
91+
handleOnPress={handleOnPress}
92+
handleLongPress={jest.fn()}
93+
isSelected={false}
94+
dateColumnSize={CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL}
95+
postedColumnSize={CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL}
96+
amountColumnSize={CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL}
97+
taxAmountColumnSize={CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL}
98+
columns={[CONST.SEARCH.TABLE_COLUMNS.MERCHANT, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]}
99+
shouldBeHighlighted={false}
100+
nonPersonalAndWorkspaceCards={{}}
101+
/>
102+
</PortalProvider>
103+
</ScreenWrapper>
104+
</ComposeProviders>,
105+
);
106+
await waitForBatchedUpdatesWithAct();
107+
108+
return {handleOnPress};
109+
}
110+
111+
it('should show the translated copy instead of the English message the server sent', async () => {
112+
// Given: An expense the server refused to reject because it had already moved
113+
await renderTransactionItem(buildTransactionWithRejectError());
114+
115+
// Then: The user sees the app's own copy, not the untranslatable server string
116+
expect(screen.getByText(translateLocal('iou.rejectReport.couldNotRejectExpense'))).toBeOnTheScreen();
117+
expect(screen.queryByText(SERVER_REJECT_MESSAGE)).not.toBeOnTheScreen();
118+
});
119+
120+
it('should not let the expense be opened while it carries a reject error', async () => {
121+
// Given: An expense showing a reject error, so the server no longer has it on this report
122+
await renderTransactionItem(buildTransactionWithRejectError());
123+
124+
// Then: The row cannot be opened, because the RHP would immediately dismiss itself
125+
expect(screen.getByLabelText('View details')).toBeDisabled();
126+
});
127+
128+
it('should still open an expense that has no reject error', async () => {
129+
// Given: An ordinary expense
130+
const {handleOnPress} = await renderTransactionItem(buildTransaction());
131+
132+
// When: The user presses the row
133+
expect(screen.getByLabelText('View details')).toBeEnabled();
134+
fireEvent.press(screen.getByLabelText('View details'));
135+
await waitForBatchedUpdatesWithAct();
136+
137+
// Then: The expense opens as usual
138+
expect(handleOnPress).toHaveBeenCalledWith(TRANSACTION_ID);
139+
});
140+
141+
it('should drop the stale expense when the reject error is dismissed', async () => {
142+
// Given: An expense left behind by a reject the server refused
143+
await renderTransactionItem(buildTransactionWithRejectError());
144+
145+
// When: The user dismisses the error
146+
fireEvent.press(screen.getByLabelText('Dismiss'));
147+
await waitForBatchedUpdatesWithAct();
148+
149+
// Then: The stale local copy is gone, so it stops showing on a report it is no longer on
150+
const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`);
151+
expect(transaction).toBeFalsy();
152+
});
153+
});

0 commit comments

Comments
 (0)