From 6e6fcef894e4dbf51df3c5e510a4ccdf5a3e9752 Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:56:17 +0000 Subject: [PATCH] Remove unsafe assertions from reimbursement and expense review pages Seatbelt-Operation: sign-aac2b053-20261024-dc7dbd59 --- .../ListItem/UserInfoAndActionButtonRow.tsx | 19 +- .../ReimbursementAccountPage.tsx | 8 +- .../LegalNameUBO.tsx | 5 +- .../subSteps/ConfirmationBusiness.tsx | 15 +- .../Search/SearchMoneyRequestReportPage.tsx | 22 +- .../DuplicateTransactionItem.tsx | 7 +- src/types/form/ReimbursementAccountForm.ts | 7 +- tests/ui/ConfirmationBusinessTest.tsx | 166 +++++++++++ tests/ui/DuplicateTransactionItemTest.tsx | 273 ++++++++++++++++++ tests/ui/LegalNameUBOTest.tsx | 145 ++++++++++ .../ReimbursementAccountPageCurrencyTest.tsx | 154 ++++++++++ tests/ui/SearchMoneyRequestReportPageTest.tsx | 222 +++++++++++--- 12 files changed, 960 insertions(+), 83 deletions(-) create mode 100644 tests/ui/ConfirmationBusinessTest.tsx create mode 100644 tests/ui/DuplicateTransactionItemTest.tsx create mode 100644 tests/ui/LegalNameUBOTest.tsx create mode 100644 tests/ui/ReimbursementAccountPageCurrencyTest.tsx diff --git a/src/components/Search/SearchList/ListItem/UserInfoAndActionButtonRow.tsx b/src/components/Search/SearchList/ListItem/UserInfoAndActionButtonRow.tsx index c60c19fff292..7b9729199086 100644 --- a/src/components/Search/SearchList/ListItem/UserInfoAndActionButtonRow.tsx +++ b/src/components/Search/SearchList/ListItem/UserInfoAndActionButtonRow.tsx @@ -9,6 +9,7 @@ import {COPYABLE_TEXT_DATA_SET} from '@libs/SelectionScraper'; import {isDeletedTransaction} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; +import type {Report} from '@src/types/onyx'; import type {StyleProp, ViewStyle} from 'react-native'; @@ -19,27 +20,21 @@ import type {StyleProp, ViewStyle} from 'react-native'; import React from 'react'; import {View} from 'react-native'; -import type {ExpenseReportListItemType, TransactionListItemType, TransactionReportGroupListItemType} from './types'; +import type {ExpenseReportListItemType} from './types'; import UserInfoCellsWithArrow from './UserInfoCellsWithArrow'; -function UserInfoAndActionButtonRow({ - item, - shouldShowUserInfo, - containerStyles, - stateNum, - statusNum, - isSelected, - shouldAllowStatusTextSelection = false, -}: { - item: TransactionReportGroupListItemType | TransactionListItemType | ExpenseReportListItemType; +type UserInfoAndActionButtonRowProps = { + item: Pick & Partial>; shouldShowUserInfo: boolean; containerStyles?: StyleProp; stateNum: ExpenseReportListItemType['stateNum']; statusNum: ExpenseReportListItemType['statusNum']; isSelected?: boolean; shouldAllowStatusTextSelection?: boolean; -}) { +}; + +function UserInfoAndActionButtonRow({item, shouldShowUserInfo, containerStyles, stateNum, statusNum, isSelected, shouldAllowStatusTextSelection = false}: UserInfoAndActionButtonRowProps) { const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx index b8260c2973a9..91650908cac2 100644 --- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx +++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx @@ -65,7 +65,6 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {ComponentRef} from 'react'; -import type {TupleToUnion} from 'type-fest'; import {useIsFocused} from '@react-navigation/native'; import {Str} from 'expensify-common'; @@ -81,7 +80,6 @@ import getStepToOpenFromRouteParams from './USD/utils/getStepToOpenFromRoutePara import VerifiedBankAccountFlowEntryPoint from './VerifiedBankAccountFlowEntryPoint'; type ReimbursementAccountPageProps = WithPolicyOnyxProps & PlatformStackScreenProps; -type CurrencyType = TupleToUnion; const OFFLINE_ACCESSIBLE_STEPS = [ CONST.BANK_ACCOUNT.STEP.COUNTRY, @@ -111,7 +109,7 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen const subStepParam = route.params?.subStep; const backTo = route.params?.backTo; const isChangingBankAccount = !!route.params?.isChangingBankAccount; - const isComingFromExpensifyCard = (backTo as string)?.includes(CONST.EXPENSIFY_CARD.ROUTE as string); + const isComingFromExpensifyCard = backTo?.includes(CONST.EXPENSIFY_CARD.ROUTE); const styles = useThemeStyles(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); @@ -151,9 +149,7 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen } const isNonUSDWorkspace = !!policyCurrency && policyCurrency !== CONST.CURRENCY.USD; const hasUnsupportedCurrency = - isComingFromExpensifyCard && isNonUSDWorkspace - ? !isCurrencySupportedForECards(policyCurrency) - : policyCurrency && !isCurrencySupportedForGlobalReimbursement(policyCurrency as CurrencyType); + isComingFromExpensifyCard && isNonUSDWorkspace ? !isCurrencySupportedForECards(policyCurrency) : policyCurrency && !isCurrencySupportedForGlobalReimbursement(policyCurrency); const nonUSDCountryDraftValue = reimbursementAccountDraft?.country ?? ''; let workspaceRoute = ''; diff --git a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/BeneficialOwnerDetailsFormSubSteps/LegalNameUBO.tsx b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/BeneficialOwnerDetailsFormSubSteps/LegalNameUBO.tsx index 6b8efe10d93b..b9ab19ae1719 100644 --- a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/BeneficialOwnerDetailsFormSubSteps/LegalNameUBO.tsx +++ b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/BeneficialOwnerDetailsFormSubSteps/LegalNameUBO.tsx @@ -1,4 +1,3 @@ -import type {FormOnyxValues} from '@components/Form/types'; import FullNameStep from '@components/SubStepForms/FullNameStep'; import useLocalize from '@hooks/useLocalize'; @@ -22,8 +21,8 @@ function LegalNameUBO({onNext, onMove, isEditing, beneficialOwnerBeingModifiedID const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); - const firstNameInputID = `${BENEFICIAL_OWNER_PREFIX}_${beneficialOwnerBeingModifiedID}_${FIRST_NAME}` as keyof FormOnyxValues; - const lastNameInputID = `${BENEFICIAL_OWNER_PREFIX}_${beneficialOwnerBeingModifiedID}_${LAST_NAME}` as keyof FormOnyxValues; + const firstNameInputID = `${BENEFICIAL_OWNER_PREFIX}_${beneficialOwnerBeingModifiedID}_${FIRST_NAME}` as const; + const lastNameInputID = `${BENEFICIAL_OWNER_PREFIX}_${beneficialOwnerBeingModifiedID}_${LAST_NAME}` as const; const stepFields = [firstNameInputID, lastNameInputID]; const defaultValues = { firstName: SafeString(reimbursementAccountDraft?.[firstNameInputID]), diff --git a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/ConfirmationBusiness.tsx b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/ConfirmationBusiness.tsx index 3ae983c5b0f3..940a31a1cacb 100644 --- a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/ConfirmationBusiness.tsx +++ b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/ConfirmationBusiness.tsx @@ -17,16 +17,11 @@ import {getFieldRequiredErrors} from '@libs/ValidationUtils'; import getSubStepValues from '@pages/ReimbursementAccount/utils/getSubStepValues'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; -import type {CONST as COMMON_CONST} from 'expensify-common'; - import React, {useCallback, useMemo} from 'react'; -type States = keyof typeof COMMON_CONST.STATES; - const BUSINESS_INFO_STEP_KEYS = INPUT_IDS.BUSINESS_INFO_STEP; const BUSINESS_INFO_STEP_INDEXES = CONST.REIMBURSEMENT_ACCOUNT.SUBSTEP_INDEX.BUSINESS_INFO; @@ -62,6 +57,8 @@ function ConfirmationBusiness({onNext, onMove}: SubPageProps) { ); const values = useMemo(() => getSubStepValues(BUSINESS_INFO_STEP_KEYS, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]); + const incorporationType = values[BUSINESS_INFO_STEP_KEYS.INCORPORATION_TYPE]; + const incorporationState = values[BUSINESS_INFO_STEP_KEYS.INCORPORATION_STATE]; const defaultCheckboxState = reimbursementAccountDraft?.[BUSINESS_INFO_STEP_KEYS.HAS_NO_CONNECTION_TO_CANNABIS] ?? false; @@ -108,11 +105,7 @@ function ConfirmationBusiness({onNext, onMove}: SubPageProps) { onPress={() => { onMove(BUSINESS_INFO_STEP_INDEXES.COMPANY_TYPE); }} - value={ - values[BUSINESS_INFO_STEP_KEYS.INCORPORATION_TYPE] - ? translate(`businessInfoStep.incorporationType.${values[BUSINESS_INFO_STEP_KEYS.INCORPORATION_TYPE]}` as TranslationPaths) - : undefined - } + value={incorporationType ? translate(`businessInfoStep.incorporationType.${incorporationType}`) : undefined} /> { onMove(BUSINESS_INFO_STEP_INDEXES.INCORPORATION_STATE); }} - value={values[BUSINESS_INFO_STEP_KEYS.INCORPORATION_STATE] ? translate(`allStates.${values[BUSINESS_INFO_STEP_KEYS.INCORPORATION_STATE] as States}.stateName`) : undefined} + value={incorporationState ? translate(`allStates.${incorporationState}.stateName`) : undefined} /> ; - const transactionKey = Object.keys(snapshotData).find((key) => { - if (!key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) { - return false; - } - const candidate = snapshotData[key]; - return typeof candidate === 'object' && candidate !== null && 'reportID' in candidate && candidate.reportID === reportIDFromRoute; - }); + const snapshotData = snapshot.data; + const transactionKey = Object.keys(snapshotData) + .filter(isTransactionEntry) + .find((key) => { + const candidate = snapshotData[key]; + return typeof candidate === 'object' && candidate !== null && 'reportID' in candidate && candidate.reportID === reportIDFromRoute; + }); if (!transactionKey) { return {snapshotTransaction: undefined, snapshotViolations: undefined}; } - const transaction = snapshotData[transactionKey] as Transaction; + const transaction = snapshotData[transactionKey]; - const violationKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`; - const violations = snapshotData[violationKey] as TransactionViolations | undefined; + const violations = snapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`]; return {snapshotTransaction: transaction, snapshotViolations: violations}; }, [snapshot?.data, allReportTransactions, reportIDFromRoute]); diff --git a/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx b/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx index 67956a8a6bc6..4ea06f8c4954 100644 --- a/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx +++ b/src/pages/TransactionDuplicate/DuplicateTransactionItem.tsx @@ -2,7 +2,6 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {PressableWithFeedback} from '@components/Pressable'; import RadioButton from '@components/RadioButton'; -import type {ExpenseReportListItemType, TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; import UserInfoAndActionButtonRow from '@components/Search/SearchList/ListItem/UserInfoAndActionButtonRow'; import TransactionItemRow from '@components/TransactionItemRow'; @@ -55,12 +54,12 @@ function DuplicateTransactionItem({transaction, isLastItem, isSelected, shouldSh }); const ownerPersonalDetails = report?.ownerAccountID ? personalDetails?.[report.ownerAccountID] : undefined; const reportStatusItem = ownerPersonalDetails - ? ({ + ? { ...report, from: ownerPersonalDetails, to: ownerPersonalDetails, formattedFrom: ownerPersonalDetails.displayName ?? '', - } as ExpenseReportListItemType) + } : undefined; const handlePreviewPress = () => { @@ -120,7 +119,7 @@ function DuplicateTransactionItem({transaction, isLastItem, isSelected, shouldSh /> )} (); + +const INCORPORATION_CASES = { + LLC: {incorporationType: 'LLC', incorporationState: 'CA'}, + CORPORATION: {incorporationType: 'CORPORATION', incorporationState: 'NY'}, + PARTNERSHIP: {incorporationType: 'PARTNERSHIP', incorporationState: 'TX'}, + COOPERATIVE: {incorporationType: 'COOPERATIVE', incorporationState: 'WA'}, + SOLE_PROPRIETORSHIP: {incorporationType: 'SOLE_PROPRIETORSHIP', incorporationState: 'CA'}, + OTHER: {incorporationType: 'OTHER', incorporationState: 'NY'}, +} satisfies Record>; + +async function renderBusinessConfirmation(draft: Partial = {}, shouldSeedAccount = true) { + await act(async () => { + if (shouldSeedAccount) { + await Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: buildAchData({incorporationType: 'CORPORATION', incorporationState: 'NY'})}); + } + await Onyx.set(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT, draft); + }); + const onNext = jest.fn, Parameters>(); + const onMove = jest.fn, Parameters>(); + render( + + + + + + {() => ( + + )} + + + + + , + ); + await waitForBatchedUpdatesWithAct(); + return {onNext, onMove}; +} + +describe('ConfirmationBusiness incorporation summary and confirmation', () => { + beforeAll(() => Onyx.init({keys: ONYXKEYS})); + + beforeEach(async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + }); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + jest.clearAllMocks(); + }); + + it.each(Object.values(INCORPORATION_CASES))('translates draft $incorporationType and $incorporationState using catalogue keys', async (incorporation) => { + // Given draft catalogue keys override a different saved incorporation summary + // When the real projection and translated summary rows render + await renderBusinessConfirmation(incorporation); + + // Then every picker type key and representative state keys have their production labels + expect(screen.getByText(translateLocal(`businessInfoStep.incorporationType.${incorporation.incorporationType}`))).toBeOnTheScreen(); + expect(screen.getByText(translateLocal(`allStates.${incorporation.incorporationState}.stateName`))).toBeOnTheScreen(); + }); + + it('falls back to ACH incorporation when the draft omits the fields', async () => { + // Given the saved company is a New York corporation and has no draft override + // When the real getSubStepValues projection reads the account + const {onMove} = await renderBusinessConfirmation(); + + // Then the saved labels remain visible and their edit destinations retain their indexes + expect(screen.getByText(translateLocal('businessInfoStep.incorporationType.CORPORATION'))).toBeOnTheScreen(); + expect(screen.getByText(translateLocal('allStates.NY.stateName'))).toBeOnTheScreen(); + const companyTypeButton = await screen.findByRole(CONST.ROLE.BUTTON, { + name: `${translateLocal('businessInfoStep.companyType')}, ${translateLocal('businessInfoStep.incorporationType.CORPORATION')}`, + }); + fireEvent.press(companyTypeButton, {nativeEvent: {}}); + expect(onMove).toHaveBeenCalledWith(CONST.REIMBURSEMENT_ACCOUNT.SUBSTEP_INDEX.BUSINESS_INFO.COMPANY_TYPE); + expect(onMove).toHaveBeenCalledTimes(1); + const incorporationStateButton = await screen.findByRole(CONST.ROLE.BUTTON, { + name: `${translateLocal('businessInfoStep.incorporationState')}, ${translateLocal('allStates.NY.stateName')}`, + }); + fireEvent.press(incorporationStateButton, {nativeEvent: {}}); + expect(onMove).toHaveBeenCalledWith(CONST.REIMBURSEMENT_ACCOUNT.SUBSTEP_INDEX.BUSINESS_INFO.INCORPORATION_STATE); + expect(onMove).toHaveBeenCalledTimes(2); + }); + + it.each([true, false])('keeps empty incorporation rows with a saved account=%s', async (shouldSeedAccount) => { + // Given explicit empty draft fields must override saved values, and absent data also stays blank + const draft = shouldSeedAccount ? ({incorporationType: '', incorporationState: ''} as const) : {}; + + // When the actual summary receives empty or missing incorporation data + await renderBusinessConfirmation(draft, shouldSeedAccount); + + // Then the field names render without borrowing the saved company's translated values + expect(screen.getByText(translateLocal('businessInfoStep.companyType'))).toBeOnTheScreen(); + expect(screen.getByText(translateLocal('businessInfoStep.incorporationState'))).toBeOnTheScreen(); + expect(screen.queryByText(translateLocal('businessInfoStep.incorporationType.CORPORATION'))).not.toBeOnTheScreen(); + expect(screen.queryByText(translateLocal('allStates.NY.stateName'))).not.toBeOnTheScreen(); + }); + + it('blocks unchecked confirmation through the real form validation', async () => { + // Given false is a saved checkbox value but does not confirm the restricted-business requirement + const {onNext} = await renderBusinessConfirmation({hasNoConnectionToCannabis: false}); + + // When the user submits without confirming + fireEvent.press(screen.getByRole(CONST.ROLE.BUTTON, {name: translateLocal('common.confirm')})); + + // Then the production validation explains the restriction and does not advance + expect(await screen.findByText(translateLocal('bankAccount.error.restrictedBusiness'))).toBeOnTheScreen(); + expect(onNext).not.toHaveBeenCalled(); + }); + + it('saves a checked confirmation and advances through the real form', async () => { + // Given the user has not confirmed the requirement yet + const {onNext} = await renderBusinessConfirmation({hasNoConnectionToCannabis: false}); + + // When the actual checkbox changes and the form is submitted + fireEvent.press(screen.getByRole(CONST.ROLE.CHECKBOX)); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByRole(CONST.ROLE.BUTTON, {name: translateLocal('common.confirm')})); + await waitFor(() => expect(onNext).toHaveBeenCalledTimes(1)); + + // Then submission and the draft carry true rather than bypassing checkbox validation + expect(onNext).toHaveBeenCalledWith(expect.objectContaining({hasNoConnectionToCannabis: true})); + await getOnyxData({ + key: ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT, + callback: (draft) => expect(draft?.hasNoConnectionToCannabis).toBe(true), + }); + }); +}); diff --git a/tests/ui/DuplicateTransactionItemTest.tsx b/tests/ui/DuplicateTransactionItemTest.tsx new file mode 100644 index 000000000000..3966d8fa1d37 --- /dev/null +++ b/tests/ui/DuplicateTransactionItemTest.tsx @@ -0,0 +1,273 @@ +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {CurrencyListContextProvider} from '@components/CurrencyListContextProvider'; +import HTMLEngineProvider from '@components/HTMLEngineProvider'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import UserInfoAndActionButtonRow from '@components/Search/SearchList/ListItem/UserInfoAndActionButtonRow'; + +import DuplicateTransactionItem from '@pages/TransactionDuplicate/DuplicateTransactionItem'; + +import {createTransactionThreadReport} from '@userActions/Report'; +import type * as ReportActions from '@userActions/Report'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetails, Report, ReportAction, Transaction} from '@src/types/onyx'; + +import {PortalProvider} from '@gorhom/portal'; +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import currencyList from '../unit/currencyList.json'; +import createMock from '../utils/createMock'; +import {translateLocal} from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@libs/Navigation/Navigation'); +jest.mock('@hooks/useAnimatedHighlightStyle'); +jest.mock('@userActions/Report', () => ({ + ...jest.requireActual('@userActions/Report'), + createTransactionThreadReport: jest.fn, Parameters>(), +})); + +const OWNER = {accountID: 41, displayName: 'Duplicate owner', login: 'owner@example.com'} satisfies PersonalDetails; +const REPORT = { + reportID: '710', + ownerAccountID: OWNER.accountID, + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + currency: CONST.CURRENCY.USD, +} satisfies Report; +const TRANSACTION = { + transactionID: '510', + reportID: REPORT.reportID, + amount: -1234, + currency: CONST.CURRENCY.USD, + created: '2026-10-01', + merchant: 'Duplicate merchant', + comment: {comment: 'Duplicate preview'}, +} satisfies Transaction; +const IOU_ACTION = { + reportActionID: '810', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: OWNER.accountID, + created: '2026-10-01 12:00:00', + originalMessage: {IOUTransactionID: TRANSACTION.transactionID, type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, amount: TRANSACTION.amount, currency: TRANSACTION.currency}, + message: [{type: 'COMMENT', text: 'Expense created', html: 'Expense created'}], +} satisfies ReportAction; +const CHILD_REPORT_ID = '910'; +const CREATED_REPORT_ID = '911'; + +type DuplicateItemProps = Parameters[0]; +type DuplicateRenderOptions = Partial> & { + report?: Report | null; + action?: ReportAction | null; + owner?: PersonalDetails | null; +}; + +async function renderDuplicate(options: DuplicateRenderOptions = {}) { + const transaction = 'transaction' in options ? options.transaction : TRANSACTION; + const report = 'report' in options ? options.report : REPORT; + const action = 'action' in options ? options.action : IOU_ACTION; + const owner = 'owner' in options ? options.owner : OWNER; + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + await Onyx.set(ONYXKEYS.CURRENCY_LIST, currencyList); + if (report) { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + if (action) { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, {[action.reportActionID]: action}); + } + } + if (transaction) { + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); + } + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, owner ? {[owner.accountID]: owner} : {}); + await waitForBatchedUpdatesWithAct(); + }); + const onSelectTransaction = jest.fn, Parameters>(); + const onPreviewPressed = jest.fn, Parameters>(); + const view = render( + + + + + , + ); + await waitForBatchedUpdatesWithAct(); + return {...view, onSelectTransaction, onPreviewPressed}; +} + +describe('DuplicateTransactionItem ordinary transaction view', () => { + beforeAll(() => Onyx.init({keys: ONYXKEYS})); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + jest.clearAllMocks(); + jest.mocked(createTransactionThreadReport).mockReset(); + }); + + it('renders the real owner, status and transaction row and selects its transactionID', async () => { + // Given an ordinary transaction has no grouped search or formatted search fields + const {onSelectTransaction, onPreviewPressed} = await renderDuplicate(); + + // When the user selects its actual radio control + fireEvent.press(screen.getByRole(CONST.ROLE.RADIO)); + + // Then the real row and status retain the owner, money and selection identity + expect(screen.getByText(OWNER.displayName)).toBeOnTheScreen(); + expect(screen.getByText(translateLocal('common.outstanding'))).toBeOnTheScreen(); + expect(screen.getByText(TRANSACTION.merchant)).toBeOnTheScreen(); + expect(screen.getByTestId('transaction-item-row')).toHaveTextContent(/12\.34/); + expect(onSelectTransaction).toHaveBeenCalledTimes(1); + expect(onSelectTransaction).toHaveBeenCalledWith(TRANSACTION.transactionID); + expect(onPreviewPressed).not.toHaveBeenCalled(); + }); + + it('omits the selection control when selection is disabled', async () => { + // Given preview-only rows still need their actual transaction content + // When selection is disabled at the production item boundary + await renderDuplicate({shouldShowSelection: false}); + + // Then only the radio control is absent + expect(screen.queryByRole(CONST.ROLE.RADIO)).not.toBeOnTheScreen(); + expect(screen.getByText(TRANSACTION.merchant)).toBeOnTheScreen(); + }); + + it('previews an existing child thread without creating another one', async () => { + // Given the matching IOU action already identifies its child thread + const {onPreviewPressed} = await renderDuplicate({action: {...IOU_ACTION, childReportID: CHILD_REPORT_ID}}); + + // When the actual duplicate row is pressed + fireEvent.press(screen.getByRole(CONST.ROLE.BUTTON, {name: TRANSACTION.comment.comment})); + + // Then the child destination wins over the parent report and thread creation + expect(onPreviewPressed).toHaveBeenCalledTimes(1); + expect(onPreviewPressed).toHaveBeenCalledWith(CHILD_REPORT_ID, TRANSACTION.transactionID); + expect(createTransactionThreadReport).not.toHaveBeenCalled(); + }); + + it('previews the reportID returned by thread creation', async () => { + // Given the lower action returns an intentional partial optimistic report with a new identity + jest.mocked(createTransactionThreadReport).mockReturnValue(createMock>>({reportID: CREATED_REPORT_ID})); + const {onPreviewPressed} = await renderDuplicate(); + + // When no existing child is available and the row is pressed + fireEvent.press(screen.getByRole(CONST.ROLE.BUTTON, {name: TRANSACTION.comment.comment})); + + // Then the item passes its real source data and previews only the created thread + await waitFor(() => expect(createTransactionThreadReport).toHaveBeenCalledTimes(1)); + expect(createTransactionThreadReport).toHaveBeenCalledWith( + expect.objectContaining({ + transaction: TRANSACTION, + iouReport: REPORT, + iouReportAction: IOU_ACTION, + personalDetails: {[OWNER.accountID]: OWNER}, + }), + ); + expect(onPreviewPressed).toHaveBeenCalledTimes(1); + expect(onPreviewPressed).toHaveBeenCalledWith(CREATED_REPORT_ID, TRANSACTION.transactionID); + }); + + it.each([undefined, createMock>>({})])('does not preview when thread creation has no reportID (%s)', async (created) => { + // Given the action can fail or return an incomplete optimistic result + jest.mocked(createTransactionThreadReport).mockReturnValue(created); + const {onPreviewPressed} = await renderDuplicate(); + + // When the user asks to preview without an existing child + fireEvent.press(screen.getByRole(CONST.ROLE.BUTTON, {name: TRANSACTION.comment.comment})); + + // Then an absent destination never becomes a parent-report preview + expect(createTransactionThreadReport).toHaveBeenCalledTimes(1); + expect(onPreviewPressed).not.toHaveBeenCalled(); + }); + + it('renders nothing for null transaction', async () => { + // Given malformed runtime input can supply null outside the declared OnyxEntry domain + // When the real item receives that value at its typed transaction boundary + const {onPreviewPressed, onSelectTransaction} = await renderDuplicate({ + // @ts-expect-error Runtime compatibility includes null although OnyxEntry declares only undefined absence. + transaction: null, + }); + + // Then the existing guard hides content and controls without triggering callbacks + expect(screen.queryByTestId('transaction-item-row')).not.toBeOnTheScreen(); + expect(screen.queryByRole(CONST.ROLE.RADIO)).not.toBeOnTheScreen(); + expect(onPreviewPressed).not.toHaveBeenCalled(); + expect(onSelectTransaction).not.toHaveBeenCalled(); + }); + + it.each([ + {name: 'missing transaction', options: {transaction: undefined}}, + {name: 'missing report', options: {report: null}}, + {name: 'missing action', options: {action: null}}, + ] satisfies Array<{name: string; options: DuplicateRenderOptions}>)('renders nothing for $name', async ({options}) => { + // Given the item cannot identify a complete source transaction and matching report action + // When the real item evaluates its existing guards + const {onPreviewPressed, onSelectTransaction} = await renderDuplicate(options); + + // Then neither row content nor interactive controls are exposed + expect(screen.queryByTestId('transaction-item-row')).not.toBeOnTheScreen(); + expect(screen.queryByRole(CONST.ROLE.RADIO)).not.toBeOnTheScreen(); + expect(onPreviewPressed).not.toHaveBeenCalled(); + expect(onSelectTransaction).not.toHaveBeenCalled(); + }); + + it('omits only the status view when the report owner is missing', async () => { + // Given the report and action exist but personal details have not arrived + // When the real item renders without its owner + const view = await renderDuplicate({owner: null}); + + // Then transaction rendering survives without manufacturing owner data + expect(screen.getByText(TRANSACTION.merchant)).toBeOnTheScreen(); + expect(view.UNSAFE_queryByType(UserInfoAndActionButtonRow)).toBeNull(); + expect(screen.queryByText(translateLocal('common.outstanding'))).not.toBeOnTheScreen(); + }); + + it('keeps an empty owner display name without dropping the status row', async () => { + // Given PersonalDetails permits an account with no displayName field + // When the real status row receives that owner + const view = await renderDuplicate({owner: {accountID: OWNER.accountID}}); + + // Then the existing fallback is empty and the actual status still renders + const statusRow = view.UNSAFE_getByType(UserInfoAndActionButtonRow); + expect(statusRow.props).toMatchObject({ + item: { + ...REPORT, + from: {accountID: OWNER.accountID}, + to: {accountID: OWNER.accountID}, + formattedFrom: '', + } satisfies Parameters[0]['item'], + }); + expect(screen.getByText(translateLocal('common.outstanding'))).toBeOnTheScreen(); + expect(screen.queryByText('undefined')).not.toBeOnTheScreen(); + }); + + it.each([ + {reportID: CONST.REPORT.TRASH_REPORT_ID, expectedStatus: 'iou.deleted'}, + {reportID: CONST.REPORT.UNREPORTED_REPORT_ID, expectedStatus: 'common.unreported'}, + ] as const)('preserves status for report $reportID', async ({reportID, expectedStatus}) => { + // Given deletion is determined by reportID and unreported status by absent state and status numbers + const report = {reportID, ownerAccountID: OWNER.accountID, type: CONST.REPORT.TYPE.EXPENSE} satisfies Report; + + // When the actual status component consumes the duplicate report view + await renderDuplicate({report, transaction: {...TRANSACTION, reportID}}); + + // Then retaining reportID in the consumed view is necessary for the correct label + expect(screen.getByText(translateLocal(expectedStatus))).toBeOnTheScreen(); + expect(screen.getByText(TRANSACTION.merchant)).toBeOnTheScreen(); + }); +}); diff --git a/tests/ui/LegalNameUBOTest.tsx b/tests/ui/LegalNameUBOTest.tsx new file mode 100644 index 000000000000..a3fc73f9cb25 --- /dev/null +++ b/tests/ui/LegalNameUBOTest.tsx @@ -0,0 +1,145 @@ +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import type {SubPageProps} from '@hooks/useSubPage/types'; + +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; +import type {ReimbursementAccountNavigatorParamList} from '@libs/Navigation/types'; + +import LegalNameUBO from '@pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/BeneficialOwnerDetailsFormSubSteps/LegalNameUBO'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {ReimbursementAccountForm} from '@src/types/form'; + +import {PortalProvider} from '@gorhom/portal'; +import {NavigationContainer} from '@react-navigation/native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import {getOnyxData, translateLocal} from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@libs/Navigation/Navigation'); + +const Stack = createPlatformStackNavigator(); + +const OWNER_A = 'owner_with_underscores'; +const OWNER_B = 'other'; +const OWNER_FIELDS = CONST.BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA; +const OWNER_A_FIRST_NAME = `${OWNER_FIELDS.PREFIX}_${OWNER_A}_${OWNER_FIELDS.FIRST_NAME}` as const; +const OWNER_A_LAST_NAME = `${OWNER_FIELDS.PREFIX}_${OWNER_A}_${OWNER_FIELDS.LAST_NAME}` as const; +const OWNER_B_FIRST_NAME = `${OWNER_FIELDS.PREFIX}_${OWNER_B}_${OWNER_FIELDS.FIRST_NAME}` as const; +const OWNER_B_LAST_NAME = `${OWNER_FIELDS.PREFIX}_${OWNER_B}_${OWNER_FIELDS.LAST_NAME}` as const; +const OWNER_B_NAMES = { + [OWNER_B_FIRST_NAME]: 'Grace', + [OWNER_B_LAST_NAME]: 'Hopper', +} satisfies Partial; +const OWNER_A_NAMES = { + [OWNER_A_FIRST_NAME]: 'Ada', + [OWNER_A_LAST_NAME]: 'Lovelace', +} satisfies Partial; +const UPDATED_OWNER_A_NAMES = { + [OWNER_A_FIRST_NAME]: 'Katherine', + [OWNER_A_LAST_NAME]: 'Johnson', +} satisfies Partial; + +function renderOwnerName(ownerID: string, isEditing: boolean, onNext: SubPageProps['onNext']) { + return render( + + + + + + {() => ( + , Parameters>()} + /> + )} + + + + + , + ); +} + +describe('LegalNameUBO owner fields and draft submission', () => { + beforeAll(() => Onyx.init({keys: ONYXKEYS})); + + beforeEach(async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + }); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + jest.clearAllMocks(); + }); + + it.each([ + {isEditing: false, shouldSeedOwnerA: true}, + {isEditing: true, shouldSeedOwnerA: true}, + {isEditing: false, shouldSeedOwnerA: false}, + {isEditing: true, shouldSeedOwnerA: false}, + ])('saves only owner A with isEditing=$isEditing and saved defaults=$shouldSeedOwnerA', async ({isEditing, shouldSeedOwnerA}) => { + // Given a second owner must survive edits to an owner whose ID contains delimiters + const initialDraft = {...OWNER_B_NAMES, ...(shouldSeedOwnerA ? OWNER_A_NAMES : {})}; + await act(async () => { + await Onyx.set(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT, initialDraft); + }); + const onNext = jest.fn, Parameters>(); + renderOwnerName(OWNER_A, isEditing, onNext); + await waitForBatchedUpdatesWithAct(); + const firstName = screen.getByLabelText(translateLocal('personalInfoStep.legalFirstName')); + const lastName = screen.getByLabelText(translateLocal('personalInfoStep.legalLastName')); + expect(firstName.props.value).toBe(shouldSeedOwnerA ? 'Ada' : ''); + expect(lastName.props.value).toBe(shouldSeedOwnerA ? 'Lovelace' : ''); + + // When real inputs and the real submit hook handle the selected owner's new legal name + fireEvent.changeText(firstName, 'Katherine'); + fireEvent.changeText(lastName, 'Johnson'); + await waitForBatchedUpdatesWithAct(); + await getOnyxData({ + key: ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT, + callback: (draft) => { + expect(draft).toEqual(isEditing ? initialDraft : {...OWNER_B_NAMES, ...UPDATED_OWNER_A_NAMES}); + }, + }); + fireEvent.press(screen.getByRole(CONST.ROLE.BUTTON, {name: translateLocal(isEditing ? 'common.confirm' : 'common.next')})); + await waitFor(() => expect(onNext).toHaveBeenCalledTimes(1)); + + // Then confirmation projects exactly first and last name while preserving the other owner in Onyx + expect(onNext).toHaveBeenCalledWith(...(isEditing ? [UPDATED_OWNER_A_NAMES] : [])); + await getOnyxData({ + key: ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT, + callback: (draft) => expect(draft).toEqual({...OWNER_B_NAMES, ...UPDATED_OWNER_A_NAMES}), + }); + }); + + it('uses the other owner ID to read its own saved defaults', async () => { + // Given two owners have different saved names in the same form draft + await act(async () => { + await Onyx.set(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT, {...OWNER_A_NAMES, ...OWNER_B_NAMES}); + }); + + // When the second owner is selected through the production component + renderOwnerName(OWNER_B, true, jest.fn, Parameters>()); + await waitForBatchedUpdatesWithAct(); + + // Then its defaults cannot be borrowed from the first owner + expect(screen.getByLabelText(translateLocal('personalInfoStep.legalFirstName')).props.value).toBe('Grace'); + expect(screen.getByLabelText(translateLocal('personalInfoStep.legalLastName')).props.value).toBe('Hopper'); + }); +}); diff --git a/tests/ui/ReimbursementAccountPageCurrencyTest.tsx b/tests/ui/ReimbursementAccountPageCurrencyTest.tsx new file mode 100644 index 000000000000..12a8f04ada15 --- /dev/null +++ b/tests/ui/ReimbursementAccountPageCurrencyTest.tsx @@ -0,0 +1,154 @@ +import {act, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import HTMLEngineProvider from '@components/HTMLEngineProvider'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {ReimbursementAccountNavigatorParamList} from '@libs/Navigation/types'; + +import ReimbursementAccountPage from '@pages/ReimbursementAccount/ReimbursementAccountPage'; +import VerifiedBankAccountFlowEntryPoint from '@pages/ReimbursementAccount/VerifiedBankAccountFlowEntryPoint'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import type {Policy} from '@src/types/onyx'; + +import type * as ReactNavigation from '@react-navigation/native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import type * as ReimbursementAccountTestUtils from '../utils/ReimbursementAccountTestUtils'; + +import createMock from '../utils/createMock'; +import {buildAchData, POLICY_ID} from '../utils/ReimbursementAccountTestUtils'; +import {setupGlobalFetchMock} from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: () => true, + usePreventRemove: jest.fn(), +})); +jest.mock('@src/hooks/useResponsiveLayout'); +jest.mock('@hooks/useRootNavigationState', () => ({__esModule: true, default: () => undefined})); +jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({__esModule: true, default: () => ({didScreenTransitionEnd: true})})); +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: jest.requireActual('../utils/ReimbursementAccountTestUtils').createNavigationMock(), +})); +// The page owns currency branching. This terminal boundary records the chosen setup branch. +jest.mock('@pages/ReimbursementAccount/VerifiedBankAccountFlowEntryPoint', () => jest.fn(() => null)); +jest.mock('@components/ReimbursementAccountLoadingIndicator', () => jest.fn(() => null)); + +setupGlobalFetchMock(); + +type PageProps = PlatformStackScreenProps; +const navigation = createMock({}); +const CARD_ROUTE = ROUTES.WORKSPACE_EXPENSIFY_CARD.getRoute(POLICY_ID); +const WORKSPACE_ROUTE = ROUTES.WORKSPACE_WORKFLOWS.getRoute(POLICY_ID); +const CURRENCY_ERROR = /Oops! It appears that your workspace currency/; + +async function renderCurrencyPage(outputCurrency: string, backTo: PageProps['route']['params']['backTo']) { + const policy = { + id: POLICY_ID, + name: 'Currency workspace', + outputCurrency, + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.CORPORATE, + owner: 'admin@example.com', + } satisfies Policy; + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + await Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, { + achData: buildAchData({state: CONST.BANK_ACCOUNT.STATE.SETUP, currentStep: CONST.BANK_ACCOUNT.STEP.COUNTRY, currency: CONST.CURRENCY.CAD}), + isLoading: false, + }); + await Onyx.set(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT, {currency: CONST.CURRENCY.AUD}); + await Onyx.set(ONYXKEYS.IS_LOADING_APP, false); + await Onyx.set(ONYXKEYS.HAS_LOADED_APP, true); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); + await waitForBatchedUpdatesWithAct(); + }); + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); +} + +describe('ReimbursementAccountPage currency routing', () => { + beforeAll(() => Onyx.init({keys: ONYXKEYS})); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + jest.clearAllMocks(); + }); + + it.each([undefined, WORKSPACE_ROUTE, CARD_ROUTE])('keeps USD on the global reimbursement branch for backTo %s', async (backTo) => { + // Given USD is supported globally even though the card helper accepts only GBP and EUR + // When the real page opens through each entry route + await renderCurrencyPage(CONST.CURRENCY.USD, backTo); + + // Then the setup child receives the original optional card result and route + expect(VerifiedBankAccountFlowEntryPoint).toHaveBeenCalled(); + const props = jest.mocked(VerifiedBankAccountFlowEntryPoint).mock.calls.at(-1)?.[0]; + expect(props).toBeDefined(); + expect(props?.backTo).toBe(backTo); + expect(props?.isComingFromExpensifyCard).toBe(backTo === undefined ? undefined : backTo === CARD_ROUTE); + expect(screen.queryByText(CURRENCY_ERROR)).not.toBeOnTheScreen(); + }); + + it.each([CONST.CURRENCY.GBP, CONST.CURRENCY.EUR])('allows %s for an Expensify Card route', async (currency) => { + // Given these are the two supported non-USD card currencies + // When the real card helper participates in the page branch + await renderCurrencyPage(currency, CARD_ROUTE); + + // Then the page reaches setup with card context intact + expect(VerifiedBankAccountFlowEntryPoint).toHaveBeenCalledWith(expect.objectContaining({isComingFromExpensifyCard: true}), undefined); + expect(screen.queryByText(CURRENCY_ERROR)).not.toBeOnTheScreen(); + }); + + it.each([CONST.CURRENCY.AUD, CONST.CURRENCY.CAD])('restricts globally supported %s for cards', async (currency) => { + // Given a currency supported by global reimbursement and rejected by cards + // When the card route is evaluated + await renderCurrencyPage(currency, CARD_ROUTE); + + // Then the real error is visible and setup has not been selected + expect(VerifiedBankAccountFlowEntryPoint).not.toHaveBeenCalled(); + expect(screen.getByText(CURRENCY_ERROR)).toBeOnTheScreen(); + }); + + it.each([CONST.CURRENCY.AUD, CONST.CURRENCY.CAD, CONST.CURRENCY.GBP, CONST.CURRENCY.EUR].flatMap((currency) => [undefined, WORKSPACE_ROUTE].map((backTo) => ({currency, backTo}))))( + 'allows $currency with non-card backTo $backTo', + async ({currency, backTo}) => { + // Given the same currency should use the broader global support contract + // When the page opens from workspace workflows + await renderCurrencyPage(currency, backTo); + + // Then it reaches setup rather than applying the card restriction + expect(VerifiedBankAccountFlowEntryPoint).toHaveBeenCalledWith(expect.objectContaining({isComingFromExpensifyCard: backTo === undefined ? undefined : false}), undefined); + }, + ); + + it.each([undefined, WORKSPACE_ROUTE, CARD_ROUTE])('restricts an unsupported currency for backTo %s', async (backTo) => { + // Given policy currency takes precedence over the supported ACH and draft currencies + // When an unsupported non-USD currency reaches the real support helpers + await renderCurrencyPage('JPY', backTo); + + // Then unsupported inputs remain visible to the error branch + expect(VerifiedBankAccountFlowEntryPoint).not.toHaveBeenCalled(); + expect(screen.getByText(CURRENCY_ERROR)).toBeOnTheScreen(); + }); +}); diff --git a/tests/ui/SearchMoneyRequestReportPageTest.tsx b/tests/ui/SearchMoneyRequestReportPageTest.tsx index 8042db632b0d..d97e48ed6d36 100644 --- a/tests/ui/SearchMoneyRequestReportPageTest.tsx +++ b/tests/ui/SearchMoneyRequestReportPageTest.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ import {render} from '@testing-library/react-native'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; @@ -32,6 +31,7 @@ import type * as ReactNavigation from '@react-navigation/native'; import React from 'react'; import Onyx from 'react-native-onyx'; +import createMock from '../utils/createMock'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; const REPORT_ID = '9990001'; @@ -87,22 +87,22 @@ jest.mock('@pages/inbox/ReactionListWrapper', () => jest.fn(() => null)); jest.mock('@pages/inbox/report/ReportActionEditMessageContext', () => ({ReportActionEditMessageContextProvider: jest.fn(() => null)})); jest.mock('@gorhom/portal', () => ({PortalHost: jest.fn(() => null)})); -const mockUseNetwork = useNetwork as jest.MockedFunction; -const mockUseOnyx = useOnyx as jest.MockedFunction; -const mockUseCurrentUserPersonalDetails = useCurrentUserPersonalDetails as jest.MockedFunction; -const mockUsePersonalDetails = usePersonalDetails as jest.MockedFunction; -const mockUseDismissOnMoneyRequestReportRemoval = useDismissOnMoneyRequestReportRemoval as jest.MockedFunction; -const mockUseSubmitToDestinationVisible = useSubmitToDestinationVisible as jest.MockedFunction; -const mockUseReportIsArchived = useReportIsArchived as jest.MockedFunction; -const mockUseIsReportReadyToDisplay = useIsReportReadyToDisplay as jest.MockedFunction; -const mockUseParentReportAction = useParentReportAction as jest.MockedFunction; -const mockUseDerivedReportNameByReportID = useDerivedReportNameByReportID as jest.MockedFunction; -const mockUseResponsiveLayout = useResponsiveLayout as jest.MockedFunction; -const mockUseClearReportActionDraftsOnReportChange = useClearReportActionDraftsOnReportChange as jest.MockedFunction; -const mockUseTransactionsAndViolationsForReport = useTransactionsAndViolationsForReport as jest.MockedFunction; -const mockUseTransactionThreadReportID = useTransactionThreadReportID as jest.MockedFunction; -const mockUseSearchResultsContext = useSearchResultsContext as jest.MockedFunction; -const mockCreateTransactionThreadReport = createTransactionThreadReport as jest.MockedFunction; +const mockUseNetwork = jest.mocked(useNetwork); +const mockUseOnyx = jest.mocked(useOnyx); +const mockUseCurrentUserPersonalDetails = jest.mocked(useCurrentUserPersonalDetails); +const mockUsePersonalDetails = jest.mocked(usePersonalDetails); +const mockUseDismissOnMoneyRequestReportRemoval = jest.mocked(useDismissOnMoneyRequestReportRemoval); +const mockUseSubmitToDestinationVisible = jest.mocked(useSubmitToDestinationVisible); +const mockUseReportIsArchived = jest.mocked(useReportIsArchived); +const mockUseIsReportReadyToDisplay = jest.mocked(useIsReportReadyToDisplay); +const mockUseParentReportAction = jest.mocked(useParentReportAction); +const mockUseDerivedReportNameByReportID = jest.mocked(useDerivedReportNameByReportID); +const mockUseResponsiveLayout = jest.mocked(useResponsiveLayout); +const mockUseClearReportActionDraftsOnReportChange = jest.mocked(useClearReportActionDraftsOnReportChange); +const mockUseTransactionsAndViolationsForReport = jest.mocked(useTransactionsAndViolationsForReport); +const mockUseTransactionThreadReportID = jest.mocked(useTransactionThreadReportID); +const mockUseSearchResultsContext = jest.mocked(useSearchResultsContext); +const mockCreateTransactionThreadReport = jest.mocked(createTransactionThreadReport); const mockReport: OnyxTypes.Report = { reportID: REPORT_ID, @@ -124,7 +124,7 @@ const mockReportLoadingState: OnyxTypes.ReportLoadingState = { /** Builds a minimal reportAction fixture. The self-heal effect only reads reportActionID/actionName/originalMessage. */ function buildReportAction(overrides: Partial = {}): OnyxTypes.ReportAction { - return { + return createMock({ reportActionID: 'defaultActionID', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, created: '2024-01-01 00:00:00', @@ -136,7 +136,7 @@ function buildReportAction(overrides: Partial = {}): Ony pendingAction: null, errors: {}, ...overrides, - } as OnyxTypes.ReportAction; + }); } function buildIOUAction(reportActionID: string, transactionID: string): OnyxTypes.ReportAction { @@ -158,14 +158,15 @@ function buildTransaction(transactionID: string, reportID: string, overrides: Pa created: '2024-01-01', merchant: 'Test Merchant', ...overrides, - } as OnyxTypes.Transaction; + } satisfies OnyxTypes.Transaction; } type RenderOptions = { allReportTransactions?: Record; reportActions?: OnyxTypes.ReportAction[]; transactionThreadReportID?: string; - snapshotData?: Record; + snapshotData?: OnyxTypes.SearchResults['data']; + allReportViolations?: ReturnType['violations']; report?: OnyxTypes.Report | undefined; }; @@ -178,28 +179,30 @@ async function seedReportActions(reportID: string, actions: OnyxTypes.ReportActi } // Only the fields the page reads from `route` (and no `navigation`, which it never touches) are relevant to these tests. -const pageProps = { +const pageProps = createMock>({ route: { params: {reportID: REPORT_ID, backTo: undefined}, name: SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, key: 'route-key', }, -} as unknown as React.ComponentProps; +}); function renderPage(options: RenderOptions) { - const {allReportTransactions = {}, reportActions = [], transactionThreadReportID = undefined, snapshotData} = options; + const {allReportTransactions = {}, reportActions = [], transactionThreadReportID = undefined, snapshotData, allReportViolations = {}} = options; // `'report' in options` distinguishes "not passed" (default to mockReport) from an explicit // `{report: undefined}` (the report-not-loaded-yet case) — a destructuring default would swallow the latter. const report = 'report' in options ? options.report : mockReport; - mockUseTransactionsAndViolationsForReport.mockReturnValue({transactions: allReportTransactions, violations: {}, isLoaded: true}); + mockUseTransactionsAndViolationsForReport.mockReturnValue({transactions: allReportTransactions, violations: allReportViolations, isLoaded: true}); mockUseTransactionThreadReportID.mockReturnValue({ transactionThreadReportID, effectiveTransactionThreadReportID: transactionThreadReportID, reportActions, }); - mockUseSearchResultsContext.mockReturnValue({ - currentSearchResults: snapshotData ? ({data: snapshotData, search: {}} as never) : undefined, - } as ReturnType); + mockUseSearchResultsContext.mockReturnValue( + createMock>({ + currentSearchResults: snapshotData ? createMock({data: snapshotData, search: {}}) : undefined, + }), + ); mockUseOnyx.mockImplementation((key: string) => { if (key === `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`) { return [mockReportLoadingState, {status: 'loaded'}]; @@ -228,7 +231,7 @@ describe('SearchMoneyRequestReportPage (legacy transaction self-heal)', () => { jest.clearAllMocks(); mockUseNetwork.mockReturnValue({isOffline: false}); - mockUseCurrentUserPersonalDetails.mockReturnValue({accountID: 1, login: 'test@example.com', email: 'test@example.com'} as ReturnType); + mockUseCurrentUserPersonalDetails.mockReturnValue({accountID: 1, login: 'test@example.com', email: 'test@example.com'}); mockUsePersonalDetails.mockReturnValue({}); mockUseDismissOnMoneyRequestReportRemoval.mockReturnValue(undefined); mockUseSubmitToDestinationVisible.mockReturnValue(jest.fn()); @@ -259,54 +262,67 @@ describe('SearchMoneyRequestReportPage (legacy transaction self-heal)', () => { }); it('self-heals when the report has a single non-IOU action (e.g. just CREATED)', async () => { + // Given real report actions identify the current report's legacy transaction const createdAction = buildReportAction({reportActionID: 'a1', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED}); await seedReportActions(REPORT_ID, [createdAction]); const transaction = buildTransaction('txn1', REPORT_ID); + // When the real page effect evaluates the available report and transaction data renderPage({allReportTransactions: {[transaction.transactionID]: transaction}, reportActions: [createdAction]}); await waitForBatchedUpdatesWithAct(); + // Then thread creation respects the report and transaction identity guards expect(mockCreateTransactionThreadReport).toHaveBeenCalledWith(expect.objectContaining({transaction: expect.objectContaining({transactionID: 'txn1'})})); }); it('self-heals when the report has multiple non-IOU actions (CREATED + SUBMITTED), none of them IOU', async () => { + // Given real report actions identify the current report's legacy transaction const createdAction = buildReportAction({reportActionID: 'a1', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED}); const submittedAction = buildReportAction({reportActionID: 'a2', actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED}); await seedReportActions(REPORT_ID, [createdAction, submittedAction]); const transaction = buildTransaction('txn2', REPORT_ID); + // When the real page effect evaluates the available report and transaction data renderPage({allReportTransactions: {[transaction.transactionID]: transaction}, reportActions: [createdAction, submittedAction]}); await waitForBatchedUpdatesWithAct(); + // Then thread creation respects the report and transaction identity guards expect(mockCreateTransactionThreadReport).toHaveBeenCalledWith(expect.objectContaining({transaction: expect.objectContaining({transactionID: 'txn2'})})); }); it('does not self-heal when the reportActions are stale data from a different report', async () => { + // Given these actions exist only in another report's Onyx bucket const staleAction1 = buildReportAction({reportActionID: 'stale1', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED}); const staleAction2 = buildReportAction({reportActionID: 'stale2', actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED}); // These reportActionIDs only exist under the OTHER report's Onyx bucket, not under REPORT_ID's. await seedReportActions(OTHER_REPORT_ID, [staleAction1, staleAction2]); const transaction = buildTransaction('txn3', REPORT_ID); + // When the real page effect evaluates the available report and transaction data renderPage({allReportTransactions: {[transaction.transactionID]: transaction}, reportActions: [staleAction1, staleAction2]}); await waitForBatchedUpdatesWithAct(); + // Then thread creation respects the report and transaction identity guards expect(mockCreateTransactionThreadReport).not.toHaveBeenCalled(); }); it('does not self-heal (and does not double-create) when a real IOU action for the transaction already exists', async () => { + // Given real report actions identify the current report's legacy transaction const createdAction = buildReportAction({reportActionID: 'a1', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED}); const iouAction = buildIOUAction('iou1', 'txn4'); await seedReportActions(REPORT_ID, [createdAction, iouAction]); const transaction = buildTransaction('txn4', REPORT_ID); + // When the real page effect evaluates the available report and transaction data renderPage({allReportTransactions: {[transaction.transactionID]: transaction}, reportActions: [createdAction, iouAction]}); await waitForBatchedUpdatesWithAct(); + // Then thread creation respects the report and transaction identity guards expect(mockCreateTransactionThreadReport).not.toHaveBeenCalled(); }); it('picks the transaction matching the current report from the search snapshot, even when it is not the first key', async () => { + // Given real report actions identify the current report's legacy transaction const createdAction = buildReportAction({reportActionID: 'a1', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED}); await seedReportActions(REPORT_ID, [createdAction]); @@ -315,24 +331,27 @@ describe('SearchMoneyRequestReportPage (legacy transaction self-heal)', () => { // Insertion order matters here: the wrong-report transaction key comes first in the snapshot blob, // so picking "the first transactions_ key" (the pre-fix behavior) would resolve to the wrong transaction. - const snapshotData: Record = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${wrongReportTransaction.transactionID}`]: wrongReportTransaction, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${correctTransaction.transactionID}`]: correctTransaction, - }; + const snapshotData: OnyxTypes.SearchResults['data'] = {}; + snapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${wrongReportTransaction.transactionID}`] = wrongReportTransaction; + snapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${correctTransaction.transactionID}`] = correctTransaction; // No transactions in the main collection, forcing the component to fall back to the snapshot. + // When the real page effect evaluates the available report and transaction data renderPage({allReportTransactions: {}, reportActions: [createdAction], snapshotData}); await waitForBatchedUpdatesWithAct(); + // Then thread creation respects the report and transaction identity guards expect(mockCreateTransactionThreadReport).toHaveBeenCalledWith(expect.objectContaining({transaction: expect.objectContaining({transactionID: 'correctTxn'})})); }); it('does not self-heal while the report has not loaded yet, and retries once it does (does not get stuck)', async () => { + // Given real report actions identify the current report's legacy transaction const createdAction = buildReportAction({reportActionID: 'a1', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED}); const submittedAction = buildReportAction({reportActionID: 'a2', actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED}); await seedReportActions(REPORT_ID, [createdAction, submittedAction]); const transaction = buildTransaction('txn5', REPORT_ID); + // When the page opens before its report has loaded const {rerender} = renderPage({ allReportTransactions: {[transaction.transactionID]: transaction}, reportActions: [createdAction, submittedAction], @@ -340,6 +359,7 @@ describe('SearchMoneyRequestReportPage (legacy transaction self-heal)', () => { }); await waitForBatchedUpdatesWithAct(); + // Then thread creation respects the report and transaction identity guards expect(mockCreateTransactionThreadReport).not.toHaveBeenCalled(); // The report finishes loading on a later render. If the effect had already marked itself as @@ -362,10 +382,12 @@ describe('SearchMoneyRequestReportPage (legacy transaction self-heal)', () => { rerender(); await waitForBatchedUpdatesWithAct(); + // Then thread creation respects the report and transaction identity guards expect(mockCreateTransactionThreadReport).toHaveBeenCalledWith(expect.objectContaining({transaction: expect.objectContaining({transactionID: 'txn5'})})); }); it('does not self-heal when the loaded report belongs to a different reportID than the route (stale report from a previous navigation)', async () => { + // Given real report actions identify the current report's legacy transaction const createdAction = buildReportAction({reportActionID: 'a1', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED}); const submittedAction = buildReportAction({reportActionID: 'a2', actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED}); await seedReportActions(REPORT_ID, [createdAction, submittedAction]); @@ -374,6 +396,7 @@ describe('SearchMoneyRequestReportPage (legacy transaction self-heal)', () => { // reportActions and transaction have already moved on to REPORT_ID. const staleReport: OnyxTypes.Report = {...mockReport, reportID: OTHER_REPORT_ID}; + // When the real page effect evaluates the available report and transaction data renderPage({ allReportTransactions: {[transaction.transactionID]: transaction}, reportActions: [createdAction, submittedAction], @@ -381,6 +404,139 @@ describe('SearchMoneyRequestReportPage (legacy transaction self-heal)', () => { }); await waitForBatchedUpdatesWithAct(); + // Then thread creation respects the report and transaction identity guards + expect(mockCreateTransactionThreadReport).not.toHaveBeenCalled(); + }); + + it('keeps the first matching snapshot object and selects only its own violations', async () => { + // Given unrelated keys and a wrong-report transaction precede two matching transactions + const createdAction = buildReportAction({reportActionID: 'ordered-created'}); + await seedReportActions(REPORT_ID, [createdAction]); + const wrong = buildTransaction('wrong', OTHER_REPORT_ID); + const first = buildTransaction('first', REPORT_ID); + const second = buildTransaction('second', REPORT_ID); + const firstViolations = [{name: CONST.VIOLATIONS.MISSING_CATEGORY, type: CONST.VIOLATION_TYPES.VIOLATION}] satisfies OnyxTypes.TransactionViolations; + const secondViolations = [{name: CONST.VIOLATIONS.MISSING_TAG, type: CONST.VIOLATION_TYPES.VIOLATION}] satisfies OnyxTypes.TransactionViolations; + const snapshotData: OnyxTypes.SearchResults['data'] = {}; + snapshotData[`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`] = mockReport; + snapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${wrong.transactionID}`] = wrong; + snapshotData[`${ONYXKEYS.COLLECTION.POLICY}workspace`] = { + id: 'workspace', + name: 'Snapshot workspace', + outputCurrency: CONST.CURRENCY.USD, + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.CORPORATE, + owner: 'owner@example.com', + }; + snapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${second.transactionID}`] = secondViolations; + snapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${first.transactionID}`] = first; + snapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${second.transactionID}`] = second; + snapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${first.transactionID}`] = firstViolations; + + // When the real page falls back to its typed snapshot + renderPage({reportActions: [createdAction], snapshotData}); + await waitForBatchedUpdatesWithAct(); + + // Then insertion order and the selected transactionID determine both exact argument identities + expect(mockCreateTransactionThreadReport).toHaveBeenCalledTimes(1); + const call = mockCreateTransactionThreadReport.mock.calls.at(0); + expect(call).toBeDefined(); + expect(call?.[0].transaction).toBe(first); + expect(call?.[0].transactionViolations).toBe(firstViolations); + }); + + it('leaves violations absent when only another snapshot transaction has them', async () => { + // Given the selected transaction has no violation key of its own + const createdAction = buildReportAction({reportActionID: 'missing-violations'}); + await seedReportActions(REPORT_ID, [createdAction]); + const transaction = buildTransaction('withoutViolations', REPORT_ID); + const snapshotData: OnyxTypes.SearchResults['data'] = {}; + snapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = transaction; + snapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}other`] = [{name: CONST.VIOLATIONS.MISSING_CATEGORY, type: CONST.VIOLATION_TYPES.VIOLATION}]; + + // When the page selects that transaction + renderPage({reportActions: [createdAction], snapshotData}); + await waitForBatchedUpdatesWithAct(); + + // Then an unrelated array cannot substitute for absence + expect(mockCreateTransactionThreadReport).toHaveBeenCalledTimes(1); + const call = mockCreateTransactionThreadReport.mock.calls.at(0); + expect(call).toBeDefined(); + expect(call?.[0].transaction).toBe(transaction); + expect(call?.[0].transactionViolations).toBeUndefined(); + }); + + const unrelatedSnapshotData: OnyxTypes.SearchResults['data'] = {}; + unrelatedSnapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}wrong`] = buildTransaction('wrong', OTHER_REPORT_ID); + const snapshotScenarios: Array<{name: string; snapshotData: OnyxTypes.SearchResults['data'] | undefined}> = [ + {name: 'absent snapshot', snapshotData: undefined}, + {name: 'empty snapshot', snapshotData: {}}, + {name: 'no matching transaction', snapshotData: unrelatedSnapshotData}, + ]; + + it.each(snapshotScenarios)('does not create a thread for $name', async ({snapshotData}) => { + // Given report actions are available but no matching transaction can be selected + const createdAction = buildReportAction({reportActionID: 'no-match'}); + await seedReportActions(REPORT_ID, [createdAction]); + + // When the production fallback inspects absent, empty, or unrelated data + renderPage({reportActions: [createdAction], snapshotData}); + await waitForBatchedUpdatesWithAct(); + + // Then no thread is created from missing or wrong-report input expect(mockCreateTransactionThreadReport).not.toHaveBeenCalled(); }); + + it('prefers live transaction and violation identities over competing snapshot data', async () => { + // Given live data and snapshot data disagree for the current report + const createdAction = buildReportAction({reportActionID: 'live-created'}); + await seedReportActions(REPORT_ID, [createdAction]); + const live = buildTransaction('live', REPORT_ID); + const snapshot = buildTransaction('snapshot', REPORT_ID); + const liveViolations = [{name: CONST.VIOLATIONS.MISSING_CATEGORY, type: CONST.VIOLATION_TYPES.VIOLATION}] satisfies OnyxTypes.TransactionViolations; + const snapshotViolations = [{name: CONST.VIOLATIONS.MISSING_TAG, type: CONST.VIOLATION_TYPES.VIOLATION}] satisfies OnyxTypes.TransactionViolations; + + const snapshotData: OnyxTypes.SearchResults['data'] = {}; + snapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${snapshot.transactionID}`] = snapshot; + snapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${snapshot.transactionID}`] = snapshotViolations; + + // When the page sees a live collection member, with violations at its existing ID lookup + renderPage({ + allReportTransactions: {[live.transactionID]: live}, + allReportViolations: {[live.transactionID]: liveViolations}, + reportActions: [createdAction], + snapshotData, + }); + await waitForBatchedUpdatesWithAct(); + + // Then snapshot fallback cannot replace either live object + expect(mockCreateTransactionThreadReport).toHaveBeenCalledTimes(1); + const call = mockCreateTransactionThreadReport.mock.calls.at(0); + expect(call).toBeDefined(); + expect(call?.[0].transaction).toBe(live); + expect(call?.[0].transactionViolations).toBe(liveViolations); + }); + + it('prefers live violations for a transaction selected from the snapshot', async () => { + // Given the current hook lookup supplies violations while the transaction exists only in the snapshot + const createdAction = buildReportAction({reportActionID: 'live-violations'}); + await seedReportActions(REPORT_ID, [createdAction]); + const transaction = buildTransaction('snapshotWithLiveViolations', REPORT_ID); + const liveViolations = [{name: CONST.VIOLATIONS.MISSING_CATEGORY, type: CONST.VIOLATION_TYPES.VIOLATION}] satisfies OnyxTypes.TransactionViolations; + const snapshotViolations = [{name: CONST.VIOLATIONS.MISSING_TAG, type: CONST.VIOLATION_TYPES.VIOLATION}] satisfies OnyxTypes.TransactionViolations; + const snapshotData: OnyxTypes.SearchResults['data'] = {}; + snapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = transaction; + snapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`] = snapshotViolations; + + // When the real page finds both competing violation arrays using its unchanged key convention + renderPage({reportActions: [createdAction], snapshotData, allReportViolations: {[transaction.transactionID]: liveViolations}}); + await waitForBatchedUpdatesWithAct(); + + // Then live violations win independently of the transaction fallback + expect(mockCreateTransactionThreadReport).toHaveBeenCalledTimes(1); + const call = mockCreateTransactionThreadReport.mock.calls.at(0); + expect(call).toBeDefined(); + expect(call?.[0].transaction).toBe(transaction); + expect(call?.[0].transactionViolations).toBe(liveViolations); + }); });