|
| 1 | +import {act, render} from '@testing-library/react-native'; |
| 2 | + |
| 3 | +import SearchSelectionFooter from '@components/Search/SearchSelectionFooter'; |
| 4 | +import type {SelectedTransactionInfo, SelectedTransactions} from '@components/Search/types'; |
| 5 | + |
| 6 | +import {getFooterConvertedAmounts} from '@libs/actions/Search'; |
| 7 | + |
| 8 | +import CONST from '@src/CONST'; |
| 9 | +import ONYXKEYS from '@src/ONYXKEYS'; |
| 10 | +import type {SearchResults} from '@src/types/onyx'; |
| 11 | + |
| 12 | +import Onyx from 'react-native-onyx'; |
| 13 | + |
| 14 | +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; |
| 15 | + |
| 16 | +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); |
| 17 | + |
| 18 | +jest.mock('@hooks/useSearchShouldCalculateTotals', () => jest.fn(() => true)); |
| 19 | + |
| 20 | +jest.mock('@libs/actions/Search', () => ({ |
| 21 | + getFooterConvertedAmounts: jest.fn(), |
| 22 | +})); |
| 23 | + |
| 24 | +const mockSearchQueryContext: {current: {currentSearchHash: number; currentSearchKey: undefined; currentSearchQueryJSON: {hash: number; type: string} | undefined}} = { |
| 25 | + current: {currentSearchHash: 1, currentSearchKey: undefined, currentSearchQueryJSON: {hash: 1, type: CONST.SEARCH.DATA_TYPES.EXPENSE}}, |
| 26 | +}; |
| 27 | +const mockSelectedTransactions: {current: SelectedTransactions} = {current: {}}; |
| 28 | +jest.mock('@components/Search/SearchContext', () => ({ |
| 29 | + useSearchQueryContext: () => mockSearchQueryContext.current, |
| 30 | + useSearchResultsContext: () => ({currentSearchResults: undefined}), |
| 31 | + useSearchSelectionContext: () => ({selectedTransactions: mockSelectedTransactions.current, areAllMatchingItemsSelected: false, selectedReports: []}), |
| 32 | +})); |
| 33 | + |
| 34 | +type CapturedFooterProps = {defaultCurrency?: string; currency?: string; onCurrencyChange?: (currency: string) => void}; |
| 35 | +const mockCapturedFooterProps: {current: CapturedFooterProps | undefined} = {current: undefined}; |
| 36 | +jest.mock('@components/Search/SearchPageFooter', () => ({ |
| 37 | + __esModule: true, |
| 38 | + default: (props: CapturedFooterProps) => { |
| 39 | + mockCapturedFooterProps.current = props; |
| 40 | + return null; |
| 41 | + }, |
| 42 | +})); |
| 43 | + |
| 44 | +// The currency of the selected expense — deliberately different from every other currency in this test so a leak |
| 45 | +// from any wrong fallback source is easy to spot. |
| 46 | +const SELECTED_EXPENSE_CURRENCY = 'JPY'; |
| 47 | + |
| 48 | +// The Preferences > Payment currency setting, stored as the personal policy's output currency. Deliberately not USD |
| 49 | +// so the test can tell the real fallback apart from the USD last resort. |
| 50 | +const PAYMENT_CURRENCY = CONST.CURRENCY.GBP; |
| 51 | + |
| 52 | +const ACCOUNT_ID = 1; |
| 53 | +const PERSONAL_POLICY_ID = 'personalPolicy1'; |
| 54 | +const WORKSPACE_POLICY_ID = 'workspacePolicy1'; |
| 55 | + |
| 56 | +function buildSearchResults(currency: string | undefined, count = 1): SearchResults { |
| 57 | + return { |
| 58 | + search: { |
| 59 | + count, |
| 60 | + currency, |
| 61 | + total: -100, |
| 62 | + offset: 0, |
| 63 | + isLoading: false, |
| 64 | + hash: 1, |
| 65 | + type: CONST.SEARCH.DATA_TYPES.EXPENSE, |
| 66 | + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, |
| 67 | + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, |
| 68 | + hasMoreResults: false, |
| 69 | + hasResults: true, |
| 70 | + }, |
| 71 | + data: {}, |
| 72 | + }; |
| 73 | +} |
| 74 | + |
| 75 | +function buildSelectedTransaction(currency: string, groupCurrency?: string, groupAmount?: number): SelectedTransactionInfo { |
| 76 | + return { |
| 77 | + isSelected: true, |
| 78 | + canReject: false, |
| 79 | + canHold: false, |
| 80 | + canSplit: false, |
| 81 | + hasBeenSplit: false, |
| 82 | + canChangeReport: false, |
| 83 | + isHeld: false, |
| 84 | + canUnhold: false, |
| 85 | + action: CONST.SEARCH.ACTION_TYPES.VIEW, |
| 86 | + policyID: undefined, |
| 87 | + amount: 100, |
| 88 | + currency, |
| 89 | + groupCurrency, |
| 90 | + groupAmount, |
| 91 | + isFromOneTransactionReport: false, |
| 92 | + }; |
| 93 | +} |
| 94 | + |
| 95 | +describe('SearchSelectionFooter', () => { |
| 96 | + beforeAll(() => { |
| 97 | + Onyx.init({keys: ONYXKEYS}); |
| 98 | + }); |
| 99 | + |
| 100 | + beforeEach(async () => { |
| 101 | + mockSearchQueryContext.current = {currentSearchHash: 1, currentSearchKey: undefined, currentSearchQueryJSON: {hash: 1, type: CONST.SEARCH.DATA_TYPES.EXPENSE}}; |
| 102 | + mockSelectedTransactions.current = {transaction1: buildSelectedTransaction(SELECTED_EXPENSE_CURRENCY)}; |
| 103 | + mockCapturedFooterProps.current = undefined; |
| 104 | + // Clear here rather than in afterEach: Onyx.clear() there re-renders the previous test's still-mounted |
| 105 | + // component (testing-library only unmounts it afterwards), and those renders can record mock calls. |
| 106 | + jest.clearAllMocks(); |
| 107 | + await Onyx.merge(ONYXKEYS.SESSION, {accountID: ACCOUNT_ID}); |
| 108 | + // Accounts without a workspace have their personal policy as the active policy. |
| 109 | + await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, PERSONAL_POLICY_ID); |
| 110 | + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${PERSONAL_POLICY_ID}`, {id: PERSONAL_POLICY_ID, outputCurrency: PAYMENT_CURRENCY}); |
| 111 | + await waitForBatchedUpdates(); |
| 112 | + }); |
| 113 | + |
| 114 | + afterEach(async () => { |
| 115 | + await Onyx.clear(); |
| 116 | + }); |
| 117 | + |
| 118 | + it("offers the user's live payment currency as the Reset target when there is no active workspace", async () => { |
| 119 | + // A fresh no-workspace account: the active policy is the personal policy, and the only selected expense |
| 120 | + // happens to be in a different currency (JPY) from the live payment currency (GBP). |
| 121 | + render(<SearchSelectionFooter searchResults={buildSearchResults(undefined)} />); |
| 122 | + await waitForBatchedUpdates(); |
| 123 | + |
| 124 | + // The footer's Reset/default currency follows the live payment currency (the personal policy's output |
| 125 | + // currency), not the selected expense's own (stale) currency. |
| 126 | + expect(mockCapturedFooterProps.current?.defaultCurrency).toBe(PAYMENT_CURRENCY); |
| 127 | + }); |
| 128 | + |
| 129 | + it("offers the active workspace's currency as the Reset target when one is set", async () => { |
| 130 | + // The server converts search figures to the active policy's currency, so with an active workspace the Reset |
| 131 | + // target is the workspace currency, not the personal payment currency. |
| 132 | + await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, WORKSPACE_POLICY_ID); |
| 133 | + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${WORKSPACE_POLICY_ID}`, {id: WORKSPACE_POLICY_ID, outputCurrency: CONST.CURRENCY.EUR}); |
| 134 | + await waitForBatchedUpdates(); |
| 135 | + |
| 136 | + render(<SearchSelectionFooter searchResults={buildSearchResults(CONST.CURRENCY.EUR)} />); |
| 137 | + await waitForBatchedUpdates(); |
| 138 | + |
| 139 | + expect(mockCapturedFooterProps.current?.defaultCurrency).toBe(CONST.CURRENCY.EUR); |
| 140 | + }); |
| 141 | + |
| 142 | + it('converts the figures when Reset selects a default the figures are not denominated in', async () => { |
| 143 | + // The payment currency changed after the snapshot loaded: the selected group's server-converted figure is |
| 144 | + // still denominated in the old payment currency (INR), while the live default is now GBP. |
| 145 | + mockSelectedTransactions.current = {[`${CONST.SEARCH.GROUP_PREFIX}category1`]: buildSelectedTransaction(SELECTED_EXPENSE_CURRENCY, 'INR', -100)}; |
| 146 | + |
| 147 | + // A partial selection (1 of 2), so the footer uses the client-side selected total. |
| 148 | + render(<SearchSelectionFooter searchResults={buildSearchResults(undefined, 2)} />); |
| 149 | + await waitForBatchedUpdates(); |
| 150 | + |
| 151 | + // No picker choice yet, so nothing converts. |
| 152 | + expect(getFooterConvertedAmounts).not.toHaveBeenCalled(); |
| 153 | + |
| 154 | + // Reset passes the default through onCurrencyChange as an explicit selection. |
| 155 | + await act(async () => { |
| 156 | + mockCapturedFooterProps.current?.onCurrencyChange?.(PAYMENT_CURRENCY); |
| 157 | + await waitForBatchedUpdates(); |
| 158 | + }); |
| 159 | + |
| 160 | + // The chosen default differs from the figures' denomination, so a conversion to it is requested. |
| 161 | + expect(getFooterConvertedAmounts).toHaveBeenCalledWith(expect.objectContaining({targetCurrency: PAYMENT_CURRENCY})); |
| 162 | + }); |
| 163 | + |
| 164 | + it('does not convert when Reset selects the currency the figures are already denominated in', async () => { |
| 165 | + mockSelectedTransactions.current = {[`${CONST.SEARCH.GROUP_PREFIX}category1`]: buildSelectedTransaction(SELECTED_EXPENSE_CURRENCY, PAYMENT_CURRENCY, -100)}; |
| 166 | + |
| 167 | + render(<SearchSelectionFooter searchResults={buildSearchResults(undefined, 2)} />); |
| 168 | + await waitForBatchedUpdates(); |
| 169 | + |
| 170 | + await act(async () => { |
| 171 | + mockCapturedFooterProps.current?.onCurrencyChange?.(PAYMENT_CURRENCY); |
| 172 | + await waitForBatchedUpdates(); |
| 173 | + }); |
| 174 | + |
| 175 | + // The figures are already in the chosen currency, so no request is made and the snapshot data is used as-is. |
| 176 | + expect(getFooterConvertedAmounts).not.toHaveBeenCalled(); |
| 177 | + expect(mockCapturedFooterProps.current?.currency).toBe(PAYMENT_CURRENCY); |
| 178 | + }); |
| 179 | +}); |
0 commit comments