From 38a31673278591dbad850bc58fca92c6aeb44f2d Mon Sep 17 00:00:00 2001 From: "Abdelrahman Khattab (via MelvinBot)" Date: Tue, 17 Mar 2026 18:09:02 +0000 Subject: [PATCH 1/9] Prevent Pay elsewhere fallback while bank account data is loading When bankAccountList hasn't loaded from Onyx, the isPayer check for ACH-configured policies incorrectly fails for admin users who access the bank account via sharees, causing onlyShowPayElsewhere to become true. This shows only 'Pay elsewhere' instead of the ACH option, letting users accidentally mark reports as paid without actual reimbursement. Add loading guards for bankAccountList in MoneyReportHeader, MoneyRequestReportPreviewContent, and useSearchBulkActions to prevent the payment button from being actionable while bank account data is still loading. Co-authored-by: Abdelrahman Khattab --- src/components/MoneyReportHeader.tsx | 8 +++++--- .../MoneyRequestReportPreviewContent.tsx | 8 +++++--- src/hooks/useSearchBulkActions.ts | 9 +++++++-- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index f5478ed1ca6a3..72c1f746972a7 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -159,6 +159,7 @@ import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type IconAsset from '@src/types/utils/IconAsset'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import ActivityIndicator from './ActivityIndicator'; import AnimatedSubmitButton from './AnimatedSubmitButton'; import BrokenConnectionDescription from './BrokenConnectionDescription'; @@ -251,7 +252,7 @@ function MoneyReportHeader({ const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); const [reportPDFFilename] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_EXPENSIFY_REPORT_PDF_FILENAME}${moneyRequestReport?.reportID}`) ?? null; const [session] = useOnyx(ONYXKEYS.SESSION); - const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [bankAccountList, bankAccountListResult] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); @@ -427,6 +428,7 @@ function MoneyReportHeader({ const [paymentType, setPaymentType] = useState(); const [requestType, setRequestType] = useState(); const [selectedVBBAToPayFromHoldMenu, setSelectedVBBAToPayFromHoldMenu] = useState(undefined); + const isLoadingBankAccountList = isLoadingOnyxValue(bankAccountListResult); const canAllowSettlement = hasUpdatedTotal(moneyRequestReport, policy); const policyType = policy?.type; const connectedIntegration = getValidConnectedIntegration(policy); @@ -1407,8 +1409,8 @@ function MoneyReportHeader({ shouldHidePaymentOptions={!shouldShowPayButton} shouldShowApproveButton={shouldShowApproveButton} shouldDisableApproveButton={shouldDisableApproveButton} - isDisabled={isOffline && !canAllowSettlement} - isLoading={!isOffline && !canAllowSettlement} + isDisabled={(isOffline && !canAllowSettlement) || isLoadingBankAccountList} + isLoading={(!isOffline && !canAllowSettlement) || isLoadingBankAccountList} /> ), [CONST.REPORT.PRIMARY_ACTIONS.EXPORT_TO_ACCOUNTING]: ( diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index a1536c8270504..805260d19567e 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -91,6 +91,7 @@ import ROUTES from '@src/ROUTES'; import {validTransactionDraftIDsSelector} from '@src/selectors/TransactionDraft'; import type {ReportAttributesDerivedValue, Transaction} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import AccessMoneyRequestReportPreviewPlaceHolder from './AccessMoneyRequestReportPreviewPlaceHolder'; import EmptyMoneyRequestReportPreview from './EmptyMoneyRequestReportPreview'; import type {MoneyRequestReportPreviewContentProps} from './types'; @@ -197,7 +198,7 @@ function MoneyRequestReportPreviewContent({ const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const {isBetaEnabled} = usePermissions(); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); - const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [bankAccountList, bankAccountListResult] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const [betas] = useOnyx(ONYXKEYS.BETAS); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); @@ -240,6 +241,7 @@ function MoneyRequestReportPreviewContent({ const isInvoiceRoom = isInvoiceRoomReportUtils(chatReport); const isTripRoom = isTripRoomReportUtils(chatReport); + const isLoadingBankAccountList = isLoadingOnyxValue(bankAccountListResult); const canAllowSettlement = hasUpdatedTotal(iouReport, policy); const numberOfRequests = transactions?.length ?? 0; const transactionsWithReceipts = getTransactionsWithReceipts(iouReportID); @@ -807,8 +809,8 @@ function MoneyRequestReportPreviewContent({ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, }} - isDisabled={isOffline && !canAllowSettlement} - isLoading={!isOffline && !canAllowSettlement} + isDisabled={(isOffline && !canAllowSettlement) || isLoadingBankAccountList} + isLoading={(!isOffline && !canAllowSettlement) || isLoadingBankAccountList} sentryLabel={CONST.SENTRY_LABEL.REPORT_PREVIEW.PAY_BUTTON} /> ), diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index ee551fe4ad2d6..83c788e52a219 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -56,6 +56,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {BillingGraceEndPeriod, Report, SearchResults, Transaction, TransactionViolations} from '@src/types/onyx'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import useAllTransactions from './useAllTransactions'; import useBulkPayOptions from './useBulkPayOptions'; import useConfirmModal from './useConfirmModal'; @@ -95,7 +96,7 @@ function useSearchBulkActions({queryJSON, deleteTransactionsOnSearch}: UseSearch const selfDMReport = useSelfDMReport(); const [lastPaymentMethods] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); - const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [bankAccountList, bankAccountListResult] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [integrationsExportTemplates] = useOnyx(ONYXKEYS.NVP_INTEGRATION_SERVER_EXPORT_TEMPLATES); const [csvExportLayouts] = useOnyx(ONYXKEYS.NVP_CSV_EXPORT_LAYOUTS); @@ -182,7 +183,11 @@ function useSearchBulkActions({queryJSON, deleteTransactionsOnSearch}: UseSearch const selectedBulkCurrency = selectedReports.at(0)?.currency ?? Object.values(selectedTransactions).at(0)?.currency; const totalFormattedAmount = getTotalFormattedAmount(selectedReports, selectedTransactions, selectedBulkCurrency); + const isLoadingBankAccountList = isLoadingOnyxValue(bankAccountListResult); const onlyShowPayElsewhere = useMemo(() => { + if (isLoadingBankAccountList) { + return false; + } const firstPolicyID = selectedPolicyIDs.at(0); const selectedPolicy = firstPolicyID ? currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.POLICY}${firstPolicyID}`] : undefined; return (selectedTransactionReportIDs ?? selectedReportIDs).some((reportID) => { @@ -197,7 +202,7 @@ function useSearchBulkActions({queryJSON, deleteTransactionsOnSearch}: UseSearch canIOUBePaid(report, chatReport, selectedPolicy, bankAccountList, undefined, true, undefined, invoiceReceiverPolicy) ); }); - }, [currentSearchResults?.data, selectedPolicyIDs, selectedReportIDs, selectedTransactionReportIDs, bankAccountList]); + }, [currentSearchResults?.data, selectedPolicyIDs, selectedReportIDs, selectedTransactionReportIDs, bankAccountList, isLoadingBankAccountList]); const {bulkPayButtonOptions, businessBankAccountOptions, shouldShowBusinessBankAccountOptions} = useBulkPayOptions({ selectedPolicyID: selectedPolicyIDs.at(0), From 0e336c99277f636bf10a14329638cfed591f9e51 Mon Sep 17 00:00:00 2001 From: "Abdelrahman Khattab (via MelvinBot)" Date: Tue, 17 Mar 2026 20:34:58 +0000 Subject: [PATCH 2/9] Add comprehensive tests for useSearchBulkActions hook Co-authored-by: abzokhattab <61255839+abzokhattab@users.noreply.github.com> Co-authored-by: Abdelrahman Khattab --- tests/unit/hooks/useSearchBulkActions.test.ts | 1056 +++++++++++++++++ 1 file changed, 1056 insertions(+) create mode 100644 tests/unit/hooks/useSearchBulkActions.test.ts diff --git a/tests/unit/hooks/useSearchBulkActions.test.ts b/tests/unit/hooks/useSearchBulkActions.test.ts new file mode 100644 index 0000000000000..794b028183149 --- /dev/null +++ b/tests/unit/hooks/useSearchBulkActions.test.ts @@ -0,0 +1,1056 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {renderHook} from '@testing-library/react-native'; +import Onyx from 'react-native-onyx'; +import type {SelectedReports, SelectedTransactionInfo, SelectedTransactions} from '@components/Search/types'; +import useSearchBulkActions from '@hooks/useSearchBulkActions'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +// --- Mutable mock state --- +let mockIsOffline = false; +let mockSelectedTransactions: SelectedTransactions = {}; +let mockSelectedReports: SelectedReports[] = []; +let mockAreAllMatchingItemsSelected = false; +let mockCurrentSearchResults: {data?: Record; search?: Record} | undefined; +let mockCurrentSearchKey: string | undefined; +const mockClearSelectedTransactions = jest.fn(); +const mockSelectAllMatchingItems = jest.fn(); + +let mockIsDelegateAccessRestricted = false; +const mockShowDelegateNoAccessModal = jest.fn(); + +let mockCanIOUBePaidResult = false; +let mockCanIOUBePaidElsewhereResult = false; + +let mockShouldShowDeleteOption = false; + +let mockGetPayOptionResult = {shouldEnableBulkPayOption: false, isFirstTimePayment: false}; + +let mockBulkPayButtonOptions: Array<{text: string}> | undefined; +let mockBusinessBankAccountOptions: Array<{text: string}> | undefined; +let mockShouldShowBusinessBankAccountOptions = false; + +let mockIsLoadingBankAccountListValue = false; + +// --- Module mocks --- +jest.mock('@rnmapbox/maps', () => ({ + __esModule: true, + default: {}, + MarkerView: {}, + setAccessToken: jest.fn(), +})); + +jest.mock('@react-navigation/native', () => ({ + useIsFocused: jest.fn(() => true), + createNavigationContainerRef: () => ({}), +})); + +const MockIcon = 'MockIcon'; +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: () => ({ + Export: MockIcon, + Table: MockIcon, + DocumentMerge: MockIcon, + Send: MockIcon, + Trashcan: MockIcon, + ThumbsUp: MockIcon, + ThumbsDown: MockIcon, + ArrowRight: MockIcon, + ArrowCollapse: MockIcon, + Stopwatch: MockIcon, + Exclamation: MockIcon, + MoneyBag: MockIcon, + ArrowSplit: MockIcon, + QBOSquare: MockIcon, + XeroSquare: MockIcon, + NetSuiteSquare: MockIcon, + IntacctSquare: MockIcon, + QBDSquare: MockIcon, + CertiniaSquare: MockIcon, + Pencil: MockIcon, + }), +})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({ + translate: jest.fn((key: string) => key), + localeCompare: jest.fn((a: string, b: string) => a.localeCompare(b)), + formatPhoneNumber: jest.fn((phone: string) => phone), + toLocaleDigit: jest.fn((digit: string) => digit), + }), +})); + +jest.mock('@hooks/useThemeStyles', () => ({ + __esModule: true, + default: () => ({ + integrationIcon: {}, + colorMuted: {}, + fontWeightNormal: {}, + textWrap: {}, + }), +})); + +jest.mock('@hooks/useTheme', () => ({ + __esModule: true, + default: () => ({ + icon: '#000', + }), +})); + +jest.mock('@hooks/useNetwork', () => ({ + __esModule: true, + default: () => ({ + get isOffline() { + return mockIsOffline; + }, + }), +})); + +jest.mock('@components/DelegateNoAccessModalProvider', () => ({ + useDelegateNoAccessState: () => ({ + get isDelegateAccessRestricted() { + return mockIsDelegateAccessRestricted; + }, + }), + useDelegateNoAccessActions: () => ({ + showDelegateNoAccessModal: mockShowDelegateNoAccessModal, + }), +})); + +jest.mock('@components/Search/SearchContext', () => ({ + useSearchStateContext: () => ({ + get selectedTransactions() { + return mockSelectedTransactions; + }, + get selectedReports() { + return mockSelectedReports; + }, + get areAllMatchingItemsSelected() { + return mockAreAllMatchingItemsSelected; + }, + get currentSearchResults() { + return mockCurrentSearchResults; + }, + get currentSearchKey() { + return mockCurrentSearchKey; + }, + }), + useSearchActionsContext: () => ({ + clearSelectedTransactions: mockClearSelectedTransactions, + selectAllMatchingItems: mockSelectAllMatchingItems, + }), +})); + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ + __esModule: true, + default: jest.fn(() => ({ + accountID: 1, + login: 'test@example.com', + email: 'test@example.com', + })), +})); + +jest.mock('@hooks/useAllTransactions', () => ({ + __esModule: true, + default: () => ({}), +})); + +jest.mock('@hooks/useSelfDMReport', () => ({ + __esModule: true, + default: () => undefined, +})); + +jest.mock('@hooks/useBulkPayOptions', () => ({ + __esModule: true, + default: () => ({ + get bulkPayButtonOptions() { + return mockBulkPayButtonOptions; + }, + get businessBankAccountOptions() { + return mockBusinessBankAccountOptions; + }, + get shouldShowBusinessBankAccountOptions() { + return mockShouldShowBusinessBankAccountOptions; + }, + }), +})); + +jest.mock('@hooks/useConfirmModal', () => ({ + __esModule: true, + default: () => ({ + showConfirmModal: jest.fn(() => Promise.resolve({action: 'CONFIRM'})), + }), +})); + +jest.mock('@hooks/usePermissions', () => ({ + __esModule: true, + default: () => ({ + isBetaEnabled: jest.fn(() => false), + }), +})); + +jest.mock('@hooks/usePersonalPolicy', () => ({ + __esModule: true, + default: () => undefined, +})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + getActiveRoute: jest.fn(() => '/test'), +})); + +jest.mock('@libs/actions/Search', () => ({ + approveMoneyRequestOnSearch: jest.fn(), + bulkDeleteReports: jest.fn(), + exportSearchItemsToCSV: jest.fn(), + exportToIntegrationOnSearch: jest.fn(), + getExportTemplates: jest.fn(() => []), + getLastPolicyBankAccountID: jest.fn(), + getLastPolicyPaymentMethod: jest.fn(), + getPayMoneyOnSearchInvoiceParams: jest.fn(() => ({})), + getPayOption: jest.fn(() => mockGetPayOptionResult), + getReportType: jest.fn(), + getTotalFormattedAmount: jest.fn(() => '$0.00'), + isCurrencySupportWalletBulkPay: jest.fn(() => false), + payMoneyRequestOnSearch: jest.fn(), + queueExportSearchItemsToCSV: jest.fn(), + queueExportSearchWithTemplate: jest.fn(), + submitMoneyRequestOnSearch: jest.fn(), + unholdMoneyRequestOnSearch: jest.fn(), +})); + +jest.mock('@libs/actions/Report', () => ({ + deleteAppReport: jest.fn(), + markAsManuallyExported: jest.fn(), + moveIOUReportToPolicy: jest.fn(), + moveIOUReportToPolicyAndInviteSubmitter: jest.fn(), +})); + +jest.mock('@libs/actions/MergeTransaction', () => ({ + setupMergeTransactionDataAndNavigate: jest.fn(), +})); + +jest.mock('@libs/actions/SplitExpenses', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('@libs/actions/User', () => ({ + setNameValuePair: jest.fn(), +})); + +jest.mock('@libs/MergeTransactionUtils', () => ({ + getTransactionsAndReportsFromSearch: jest.fn(() => ({transactions: [], reports: [], policies: []})), +})); + +jest.mock('@libs/PolicyUtils', () => ({ + getConnectedIntegration: jest.fn(() => undefined), + hasDynamicExternalWorkflow: jest.fn(() => false), +})); + +jest.mock('@libs/ReportSecondaryActionUtils', () => ({ + getSecondaryExportReportActions: jest.fn(() => []), + isMergeActionForSelectedTransactions: jest.fn(() => false), +})); + +jest.mock('@libs/ReportUtils', () => ({ + getIntegrationIcon: jest.fn(), + getReportOrDraftReport: jest.fn(), + isBusinessInvoiceRoom: jest.fn(() => false), + isCurrentUserSubmitter: jest.fn(() => false), + isExpenseReport: jest.fn(() => false), + isInvoiceReport: jest.fn(() => false), + isIOUReport: jest.fn(() => false), +})); + +jest.mock('@libs/SearchUIUtils', () => ({ + navigateToSearchRHP: jest.fn(), + shouldShowDeleteOption: jest.fn(() => mockShouldShowDeleteOption), +})); + +jest.mock('@libs/SubscriptionUtils', () => ({ + shouldRestrictUserBillableActions: jest.fn(() => false), +})); + +jest.mock('@libs/TransactionUtils', () => ({ + hasTransactionBeenRejected: jest.fn(() => false), +})); + +jest.mock('@userActions/IOU', () => ({ + canIOUBePaid: jest.fn((_report: unknown, _chatReport: unknown, _policy: unknown, _bankAccountList: unknown, _undefined1: unknown, payElsewhere: boolean) => { + if (payElsewhere) { + return mockCanIOUBePaidElsewhereResult; + } + return mockCanIOUBePaidResult; + }), + dismissRejectUseExplanation: jest.fn(), +})); + +jest.mock('@userActions/Link', () => ({ + openOldDotLink: jest.fn(), +})); + +jest.mock('@components/Modal/Global/ModalContext', () => ({ + ModalActions: {CONFIRM: 'CONFIRM', CANCEL: 'CANCEL'}, +})); + +jest.mock('@src/types/utils/isLoadingOnyxValue', () => ({ + __esModule: true, + default: jest.fn(() => mockIsLoadingBankAccountListValue), +})); + +jest.mock('@styles/variables', () => ({ + iconSizeLarge: 20, +})); + +// --- Helpers --- +function createTransactionInfo(overrides: Partial = {}): SelectedTransactionInfo { + return { + isSelected: true, + canReject: false, + canHold: false, + canSplit: false, + hasBeenSplit: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + reportID: 'report1', + policyID: 'policy1', + amount: 100, + currency: 'USD', + isFromOneTransactionReport: true, + ...overrides, + }; +} + +function createBaseQueryJSON() { + return { + type: CONST.SEARCH.DATA_TYPES.EXPENSE as string, + status: CONST.SEARCH.STATUS.EXPENSE.ALL as string, + hash: 12345, + inputQuery: 'type:expense status:all', + recentSearchHash: 12345, + similarSearchHash: 12345, + flatFilters: [], + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE as string, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC as string, + view: CONST.SEARCH.VIEW.LIST as string, + filters: {} as never, + }; +} + +// --- Tests --- +describe('useSearchBulkActions', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + mockIsOffline = false; + mockSelectedTransactions = {}; + mockSelectedReports = []; + mockAreAllMatchingItemsSelected = false; + mockCurrentSearchResults = undefined; + mockCurrentSearchKey = undefined; + mockIsDelegateAccessRestricted = false; + mockCanIOUBePaidResult = false; + mockCanIOUBePaidElsewhereResult = false; + mockShouldShowDeleteOption = false; + mockGetPayOptionResult = {shouldEnableBulkPayOption: false, isFirstTimePayment: false}; + mockBulkPayButtonOptions = undefined; + mockBusinessBankAccountOptions = undefined; + mockShouldShowBusinessBankAccountOptions = false; + mockIsLoadingBankAccountListValue = false; + }); + + describe('initial state', () => { + it('should return correct initial modal states', () => { + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + expect(result.current.isOfflineModalVisible).toBe(false); + expect(result.current.isDownloadErrorModalVisible).toBe(false); + expect(result.current.isHoldEducationalModalVisible).toBe(false); + expect(result.current.rejectModalAction).toBeNull(); + expect(result.current.emptyReportsCount).toBe(0); + }); + + it('should return empty header options when queryJSON is undefined', () => { + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: undefined, + }), + ); + + expect(result.current.headerButtonsOptions).toEqual([]); + }); + + it('should return empty header options when no transactions are selected', () => { + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + // With no transactions selected, selectedTransactionsKeys is empty, + // so the early return triggers + expect(result.current.headerButtonsOptions).toEqual([]); + }); + }); + + describe('derived values', () => { + it('should derive unique policy IDs from selected transactions', () => { + mockSelectedTransactions = { + trans1: createTransactionInfo({policyID: 'policy1'}), + trans2: createTransactionInfo({policyID: 'policy1'}), + trans3: createTransactionInfo({policyID: 'policy2'}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + expect(result.current.selectedPolicyIDs).toHaveLength(2); + expect(result.current.selectedPolicyIDs).toContain('policy1'); + expect(result.current.selectedPolicyIDs).toContain('policy2'); + }); + + it('should derive unique report IDs from selected transactions', () => { + mockSelectedTransactions = { + trans1: createTransactionInfo({reportID: 'report1'}), + trans2: createTransactionInfo({reportID: 'report1'}), + trans3: createTransactionInfo({reportID: 'report2'}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + expect(result.current.selectedTransactionReportIDs).toHaveLength(2); + expect(result.current.selectedTransactionReportIDs).toContain('report1'); + expect(result.current.selectedTransactionReportIDs).toContain('report2'); + }); + + it('should derive report IDs from selected reports', () => { + mockSelectedReports = [ + {reportID: 'report1', policyID: 'policy1', action: CONST.SEARCH.ACTION_TYPES.VIEW, allActions: [], total: 100, chatReportID: undefined, currency: 'USD'}, + {reportID: 'report2', policyID: 'policy1', action: CONST.SEARCH.ACTION_TYPES.VIEW, allActions: [], total: 200, chatReportID: undefined, currency: 'USD'}, + ]; + mockSelectedTransactions = { + trans1: createTransactionInfo({reportID: 'report1'}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + expect(result.current.selectedReportIDs).toEqual(['report1', 'report2']); + }); + }); + + describe('onlyShowPayElsewhere loading guard', () => { + it('should not show only-pay-elsewhere when bank account list is loading', () => { + mockIsLoadingBankAccountListValue = true; + + // Set up a scenario where onlyShowPayElsewhere WOULD be true if not loading: + // canIOUBePaid(normal) = false, canIOUBePaid(elsewhere) = true + mockCanIOUBePaidResult = false; + mockCanIOUBePaidElsewhereResult = true; + + mockSelectedTransactions = { + trans1: createTransactionInfo({ + action: CONST.SEARCH.ACTION_TYPES.PAY, + reportID: 'report1', + policyID: 'policy1', + }), + }; + + mockCurrentSearchResults = { + data: { + [`${ONYXKEYS.COLLECTION.REPORT}report1`]: {reportID: 'report1', chatReportID: 'chat1'}, + [`${ONYXKEYS.COLLECTION.REPORT}chat1`]: {reportID: 'chat1'}, + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: {id: 'policy1'}, + }, + search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE, status: CONST.SEARCH.STATUS.EXPENSE.ALL}, + }; + + // Enable pay option to verify the behavior + mockGetPayOptionResult = {shouldEnableBulkPayOption: true, isFirstTimePayment: true}; + mockBulkPayButtonOptions = [{text: 'Pay elsewhere'}]; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + // The pay option should still appear (onlyShowPayElsewhere should be false + // because bank account list is loading, which prevents the fallback-only behavior) + const payOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.PAY); + expect(payOption).toBeDefined(); + }); + + it('should evaluate onlyShowPayElsewhere normally when bank account list is loaded', () => { + mockIsLoadingBankAccountListValue = false; + + mockCanIOUBePaidResult = false; + mockCanIOUBePaidElsewhereResult = true; + + mockSelectedTransactions = { + trans1: createTransactionInfo({ + action: CONST.SEARCH.ACTION_TYPES.PAY, + reportID: 'report1', + policyID: 'policy1', + }), + }; + + mockCurrentSearchResults = { + data: { + [`${ONYXKEYS.COLLECTION.REPORT}report1`]: {reportID: 'report1', chatReportID: 'chat1'}, + [`${ONYXKEYS.COLLECTION.REPORT}chat1`]: {reportID: 'chat1'}, + [`${ONYXKEYS.COLLECTION.POLICY}policy1`]: {id: 'policy1'}, + }, + search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE, status: CONST.SEARCH.STATUS.EXPENSE.ALL}, + }; + + mockGetPayOptionResult = {shouldEnableBulkPayOption: true, isFirstTimePayment: true}; + mockBulkPayButtonOptions = [{text: 'Pay elsewhere'}]; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + // onlyShowPayElsewhere should be true when loaded, because canIOUBePaid(normal)=false + // and canIOUBePaid(elsewhere)=true. The pay option should still appear. + const payOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.PAY); + expect(payOption).toBeDefined(); + }); + }); + + describe('headerButtonsOptions - export', () => { + it('should show only export option when all matching items are selected', () => { + mockAreAllMatchingItemsSelected = true; + mockSelectedTransactions = { + trans1: createTransactionInfo(), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + expect(result.current.headerButtonsOptions).toHaveLength(1); + expect(result.current.headerButtonsOptions.at(0)?.value).toBe(CONST.SEARCH.BULK_ACTION_TYPES.EXPORT); + }); + + it('should always include export option when transactions are selected', () => { + mockSelectedTransactions = { + trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.VIEW}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const exportOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.EXPORT); + expect(exportOption).toBeDefined(); + expect(exportOption?.text).toBe('common.export'); + }); + }); + + describe('headerButtonsOptions - approve', () => { + it('should show approve option when all selected transactions have approve action', () => { + // Transactions without reportID bypass the areSelectedTransactionsIncludedInReports check + mockSelectedTransactions = { + trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.APPROVE, reportID: undefined}), + trans2: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.APPROVE, reportID: undefined}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const approveOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.APPROVE); + expect(approveOption).toBeDefined(); + expect(approveOption?.text).toBe('search.bulkActions.approve'); + }); + + it('should not show approve option when some transactions do not have approve action', () => { + mockSelectedTransactions = { + trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.APPROVE}), + trans2: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.VIEW}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const approveOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.APPROVE); + expect(approveOption).toBeUndefined(); + }); + + it('should not show approve option when any transaction is on hold', () => { + mockSelectedTransactions = { + trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.APPROVE, isHeld: true}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const approveOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.APPROVE); + expect(approveOption).toBeUndefined(); + }); + + it('should not show approve option when offline', () => { + mockIsOffline = true; + mockSelectedTransactions = { + trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.APPROVE}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const approveOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.APPROVE); + expect(approveOption).toBeUndefined(); + }); + + it('should show approve option when selected reports all have approve action', () => { + mockSelectedReports = [ + { + reportID: 'report1', + policyID: 'policy1', + action: CONST.SEARCH.ACTION_TYPES.APPROVE, + allActions: [CONST.SEARCH.ACTION_TYPES.APPROVE], + total: 100, + chatReportID: undefined, + currency: 'USD', + }, + ]; + // Transaction also needs to be part of the selected report for areSelectedTransactionsIncludedInReports + mockSelectedTransactions = { + trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.APPROVE, reportID: 'report1'}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const approveOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.APPROVE); + expect(approveOption).toBeDefined(); + }); + }); + + describe('headerButtonsOptions - pay', () => { + it('should show pay option when shouldEnableBulkPayOption is true and not on hold', () => { + mockGetPayOptionResult = {shouldEnableBulkPayOption: true, isFirstTimePayment: false}; + mockSelectedTransactions = { + trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.PAY}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const payOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.PAY); + expect(payOption).toBeDefined(); + expect(payOption?.text).toBe('search.bulkActions.pay'); + }); + + it('should not show pay option when offline', () => { + mockIsOffline = true; + mockGetPayOptionResult = {shouldEnableBulkPayOption: true, isFirstTimePayment: false}; + mockSelectedTransactions = { + trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.PAY}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const payOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.PAY); + expect(payOption).toBeUndefined(); + }); + + it('should not show pay option when any transaction is on hold', () => { + mockGetPayOptionResult = {shouldEnableBulkPayOption: true, isFirstTimePayment: false}; + mockSelectedTransactions = { + trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.PAY, isHeld: true}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const payOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.PAY); + expect(payOption).toBeUndefined(); + }); + }); + + describe('headerButtonsOptions - hold and unhold', () => { + it('should show hold option when all transactions can be held', () => { + mockSelectedTransactions = { + trans1: createTransactionInfo({canHold: true}), + trans2: createTransactionInfo({canHold: true}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const holdOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.HOLD); + expect(holdOption).toBeDefined(); + expect(holdOption?.text).toBe('search.bulkActions.hold'); + }); + + it('should not show hold option when some transactions cannot be held', () => { + mockSelectedTransactions = { + trans1: createTransactionInfo({canHold: true}), + trans2: createTransactionInfo({canHold: false}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const holdOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.HOLD); + expect(holdOption).toBeUndefined(); + }); + + it('should not show hold option when offline', () => { + mockIsOffline = true; + mockSelectedTransactions = { + trans1: createTransactionInfo({canHold: true}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const holdOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.HOLD); + expect(holdOption).toBeUndefined(); + }); + + it('should show unhold option when all transactions can be unholded', () => { + mockSelectedTransactions = { + trans1: createTransactionInfo({canUnhold: true}), + trans2: createTransactionInfo({canUnhold: true}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const unholdOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.UNHOLD); + expect(unholdOption).toBeDefined(); + expect(unholdOption?.text).toBe('search.bulkActions.unhold'); + }); + + it('should not show unhold option when some transactions cannot be unholded', () => { + mockSelectedTransactions = { + trans1: createTransactionInfo({canUnhold: true}), + trans2: createTransactionInfo({canUnhold: false}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const unholdOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.UNHOLD); + expect(unholdOption).toBeUndefined(); + }); + }); + + describe('headerButtonsOptions - submit', () => { + it('should show submit option when all selected transactions have submit action', () => { + // Transactions without reportID bypass the areSelectedTransactionsIncludedInReports check + mockSelectedTransactions = { + trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.SUBMIT, reportID: undefined}), + trans2: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.SUBMIT, reportID: undefined}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const submitOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.SUBMIT); + expect(submitOption).toBeDefined(); + expect(submitOption?.text).toBe('common.submit'); + }); + + it('should not show submit option when offline', () => { + mockIsOffline = true; + mockSelectedTransactions = { + trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.SUBMIT}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const submitOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.SUBMIT); + expect(submitOption).toBeUndefined(); + }); + }); + + describe('headerButtonsOptions - delete', () => { + it('should show delete option when shouldShowDeleteOption returns true', () => { + mockShouldShowDeleteOption = true; + mockSelectedTransactions = { + trans1: createTransactionInfo(), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const deleteOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.DELETE); + expect(deleteOption).toBeDefined(); + expect(deleteOption?.text).toBe('search.bulkActions.delete'); + }); + + it('should not show delete option when shouldShowDeleteOption returns false', () => { + mockShouldShowDeleteOption = false; + mockSelectedTransactions = { + trans1: createTransactionInfo(), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const deleteOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.DELETE); + expect(deleteOption).toBeUndefined(); + }); + }); + + describe('headerButtonsOptions - no options available', () => { + it('should show no-options-available message when no actions are available', () => { + mockSelectedTransactions = { + trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.VIEW}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + // With VIEW action and no other conditions met, only export should be shown + // (export is always present), so no-options-available should NOT be shown + const exportOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.EXPORT); + expect(exportOption).toBeDefined(); + }); + }); + + describe('headerButtonsOptions - reject', () => { + it('should show reject option when all transactions can be rejected and not on expense_report type', () => { + mockSelectedTransactions = { + trans1: createTransactionInfo({canReject: true}), + trans2: createTransactionInfo({canReject: true}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const rejectOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.REJECT); + expect(rejectOption).toBeDefined(); + expect(rejectOption?.text).toBe('search.bulkActions.reject'); + }); + + it('should not show reject option when query type is expense_report', () => { + mockSelectedTransactions = { + trans1: createTransactionInfo({canReject: true}), + }; + + const queryJSON = createBaseQueryJSON(); + queryJSON.type = CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON, + }), + ); + + const rejectOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.REJECT); + expect(rejectOption).toBeUndefined(); + }); + + it('should not show reject option when some transactions cannot be rejected', () => { + mockSelectedTransactions = { + trans1: createTransactionInfo({canReject: true}), + trans2: createTransactionInfo({canReject: false}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const rejectOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.REJECT); + expect(rejectOption).toBeUndefined(); + }); + + it('should not show reject option when offline', () => { + mockIsOffline = true; + mockSelectedTransactions = { + trans1: createTransactionInfo({canReject: true}), + }; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const rejectOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.REJECT); + expect(rejectOption).toBeUndefined(); + }); + }); + + describe('modal handlers', () => { + it('handleOfflineModalClose should be a function', () => { + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + expect(typeof result.current.handleOfflineModalClose).toBe('function'); + }); + + it('handleDownloadErrorModalClose should be a function', () => { + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + expect(typeof result.current.handleDownloadErrorModalClose).toBe('function'); + }); + + it('dismissModalAndUpdateUseHold should be a function', () => { + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + expect(typeof result.current.dismissModalAndUpdateUseHold).toBe('function'); + }); + + it('dismissRejectModalBasedOnAction should be a function', () => { + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + expect(typeof result.current.dismissRejectModalBasedOnAction).toBe('function'); + }); + + it('confirmPayment should be a function', () => { + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + expect(typeof result.current.confirmPayment).toBe('function'); + }); + }); + + describe('option ordering', () => { + it('should include export option in every non-all-selected scenario', () => { + // Transactions without reportID bypass the areSelectedTransactionsIncludedInReports check + mockSelectedTransactions = { + trans1: createTransactionInfo({ + action: CONST.SEARCH.ACTION_TYPES.APPROVE, + canHold: true, + reportID: undefined, + }), + }; + mockShouldShowDeleteOption = true; + + const {result} = renderHook(() => + useSearchBulkActions({ + queryJSON: createBaseQueryJSON(), + }), + ); + + const exportOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.EXPORT); + expect(exportOption).toBeDefined(); + + // Approve should also be present + const approveOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.APPROVE); + expect(approveOption).toBeDefined(); + + // Hold should be present + const holdOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.HOLD); + expect(holdOption).toBeDefined(); + + // Delete should be present + const deleteOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.DELETE); + expect(deleteOption).toBeDefined(); + }); + }); +}); From 821829c179848e7bcaa0bc7a60bb12b997da0405 Mon Sep 17 00:00:00 2001 From: "Abdelrahman Khattab (via MelvinBot)" Date: Tue, 17 Mar 2026 20:37:16 +0000 Subject: [PATCH 3/9] Fix: correct spelling of 'unholded' to 'unheld' in test descriptions Co-authored-by: Abdelrahman Khattab --- tests/unit/hooks/useSearchBulkActions.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/hooks/useSearchBulkActions.test.ts b/tests/unit/hooks/useSearchBulkActions.test.ts index 794b028183149..0440f6233566f 100644 --- a/tests/unit/hooks/useSearchBulkActions.test.ts +++ b/tests/unit/hooks/useSearchBulkActions.test.ts @@ -774,7 +774,7 @@ describe('useSearchBulkActions', () => { expect(holdOption).toBeUndefined(); }); - it('should show unhold option when all transactions can be unholded', () => { + it('should show unhold option when all transactions can be unheld', () => { mockSelectedTransactions = { trans1: createTransactionInfo({canUnhold: true}), trans2: createTransactionInfo({canUnhold: true}), @@ -791,7 +791,7 @@ describe('useSearchBulkActions', () => { expect(unholdOption?.text).toBe('search.bulkActions.unhold'); }); - it('should not show unhold option when some transactions cannot be unholded', () => { + it('should not show unhold option when some transactions cannot be unheld', () => { mockSelectedTransactions = { trans1: createTransactionInfo({canUnhold: true}), trans2: createTransactionInfo({canUnhold: false}), From 01fa22d20c0d056ffcab4610721b9f2d94785fd1 Mon Sep 17 00:00:00 2001 From: "Abdelrahman Khattab (via MelvinBot)" Date: Tue, 17 Mar 2026 20:44:25 +0000 Subject: [PATCH 4/9] Trim test suite to essential cases Removed redundant tests: modal handler typeof checks, derived value computations, offline/on-hold/mixed-action variants, option ordering. Kept 13 focused tests: initial state, loading guard (PR change), and one positive case per action type. Co-authored-by: Abdelrahman Khattab --- tests/unit/hooks/useSearchBulkActions.test.ts | 450 +----------------- 1 file changed, 6 insertions(+), 444 deletions(-) diff --git a/tests/unit/hooks/useSearchBulkActions.test.ts b/tests/unit/hooks/useSearchBulkActions.test.ts index 0440f6233566f..181546ff74fe4 100644 --- a/tests/unit/hooks/useSearchBulkActions.test.ts +++ b/tests/unit/hooks/useSearchBulkActions.test.ts @@ -370,20 +370,6 @@ describe('useSearchBulkActions', () => { }); describe('initial state', () => { - it('should return correct initial modal states', () => { - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - expect(result.current.isOfflineModalVisible).toBe(false); - expect(result.current.isDownloadErrorModalVisible).toBe(false); - expect(result.current.isHoldEducationalModalVisible).toBe(false); - expect(result.current.rejectModalAction).toBeNull(); - expect(result.current.emptyReportsCount).toBe(0); - }); - it('should return empty header options when queryJSON is undefined', () => { const {result} = renderHook(() => useSearchBulkActions({ @@ -401,68 +387,10 @@ describe('useSearchBulkActions', () => { }), ); - // With no transactions selected, selectedTransactionsKeys is empty, - // so the early return triggers expect(result.current.headerButtonsOptions).toEqual([]); }); }); - describe('derived values', () => { - it('should derive unique policy IDs from selected transactions', () => { - mockSelectedTransactions = { - trans1: createTransactionInfo({policyID: 'policy1'}), - trans2: createTransactionInfo({policyID: 'policy1'}), - trans3: createTransactionInfo({policyID: 'policy2'}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - expect(result.current.selectedPolicyIDs).toHaveLength(2); - expect(result.current.selectedPolicyIDs).toContain('policy1'); - expect(result.current.selectedPolicyIDs).toContain('policy2'); - }); - - it('should derive unique report IDs from selected transactions', () => { - mockSelectedTransactions = { - trans1: createTransactionInfo({reportID: 'report1'}), - trans2: createTransactionInfo({reportID: 'report1'}), - trans3: createTransactionInfo({reportID: 'report2'}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - expect(result.current.selectedTransactionReportIDs).toHaveLength(2); - expect(result.current.selectedTransactionReportIDs).toContain('report1'); - expect(result.current.selectedTransactionReportIDs).toContain('report2'); - }); - - it('should derive report IDs from selected reports', () => { - mockSelectedReports = [ - {reportID: 'report1', policyID: 'policy1', action: CONST.SEARCH.ACTION_TYPES.VIEW, allActions: [], total: 100, chatReportID: undefined, currency: 'USD'}, - {reportID: 'report2', policyID: 'policy1', action: CONST.SEARCH.ACTION_TYPES.VIEW, allActions: [], total: 200, chatReportID: undefined, currency: 'USD'}, - ]; - mockSelectedTransactions = { - trans1: createTransactionInfo({reportID: 'report1'}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - expect(result.current.selectedReportIDs).toEqual(['report1', 'report2']); - }); - }); - describe('onlyShowPayElsewhere loading guard', () => { it('should not show only-pay-elsewhere when bank account list is loading', () => { mockIsLoadingBankAccountListValue = true; @@ -544,7 +472,7 @@ describe('useSearchBulkActions', () => { }); }); - describe('headerButtonsOptions - export', () => { + describe('headerButtonsOptions', () => { it('should show only export option when all matching items are selected', () => { mockAreAllMatchingItemsSelected = true; mockSelectedTransactions = { @@ -574,13 +502,9 @@ describe('useSearchBulkActions', () => { const exportOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.EXPORT); expect(exportOption).toBeDefined(); - expect(exportOption?.text).toBe('common.export'); }); - }); - describe('headerButtonsOptions - approve', () => { it('should show approve option when all selected transactions have approve action', () => { - // Transactions without reportID bypass the areSelectedTransactionsIncludedInReports check mockSelectedTransactions = { trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.APPROVE, reportID: undefined}), trans2: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.APPROVE, reportID: undefined}), @@ -594,86 +518,9 @@ describe('useSearchBulkActions', () => { const approveOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.APPROVE); expect(approveOption).toBeDefined(); - expect(approveOption?.text).toBe('search.bulkActions.approve'); - }); - - it('should not show approve option when some transactions do not have approve action', () => { - mockSelectedTransactions = { - trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.APPROVE}), - trans2: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.VIEW}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const approveOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.APPROVE); - expect(approveOption).toBeUndefined(); - }); - - it('should not show approve option when any transaction is on hold', () => { - mockSelectedTransactions = { - trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.APPROVE, isHeld: true}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const approveOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.APPROVE); - expect(approveOption).toBeUndefined(); - }); - - it('should not show approve option when offline', () => { - mockIsOffline = true; - mockSelectedTransactions = { - trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.APPROVE}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const approveOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.APPROVE); - expect(approveOption).toBeUndefined(); }); - it('should show approve option when selected reports all have approve action', () => { - mockSelectedReports = [ - { - reportID: 'report1', - policyID: 'policy1', - action: CONST.SEARCH.ACTION_TYPES.APPROVE, - allActions: [CONST.SEARCH.ACTION_TYPES.APPROVE], - total: 100, - chatReportID: undefined, - currency: 'USD', - }, - ]; - // Transaction also needs to be part of the selected report for areSelectedTransactionsIncludedInReports - mockSelectedTransactions = { - trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.APPROVE, reportID: 'report1'}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const approveOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.APPROVE); - expect(approveOption).toBeDefined(); - }); - }); - - describe('headerButtonsOptions - pay', () => { - it('should show pay option when shouldEnableBulkPayOption is true and not on hold', () => { + it('should show pay option when shouldEnableBulkPayOption is true', () => { mockGetPayOptionResult = {shouldEnableBulkPayOption: true, isFirstTimePayment: false}; mockSelectedTransactions = { trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.PAY}), @@ -687,44 +534,8 @@ describe('useSearchBulkActions', () => { const payOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.PAY); expect(payOption).toBeDefined(); - expect(payOption?.text).toBe('search.bulkActions.pay'); - }); - - it('should not show pay option when offline', () => { - mockIsOffline = true; - mockGetPayOptionResult = {shouldEnableBulkPayOption: true, isFirstTimePayment: false}; - mockSelectedTransactions = { - trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.PAY}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const payOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.PAY); - expect(payOption).toBeUndefined(); }); - it('should not show pay option when any transaction is on hold', () => { - mockGetPayOptionResult = {shouldEnableBulkPayOption: true, isFirstTimePayment: false}; - mockSelectedTransactions = { - trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.PAY, isHeld: true}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const payOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.PAY); - expect(payOption).toBeUndefined(); - }); - }); - - describe('headerButtonsOptions - hold and unhold', () => { it('should show hold option when all transactions can be held', () => { mockSelectedTransactions = { trans1: createTransactionInfo({canHold: true}), @@ -739,39 +550,6 @@ describe('useSearchBulkActions', () => { const holdOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.HOLD); expect(holdOption).toBeDefined(); - expect(holdOption?.text).toBe('search.bulkActions.hold'); - }); - - it('should not show hold option when some transactions cannot be held', () => { - mockSelectedTransactions = { - trans1: createTransactionInfo({canHold: true}), - trans2: createTransactionInfo({canHold: false}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const holdOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.HOLD); - expect(holdOption).toBeUndefined(); - }); - - it('should not show hold option when offline', () => { - mockIsOffline = true; - mockSelectedTransactions = { - trans1: createTransactionInfo({canHold: true}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const holdOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.HOLD); - expect(holdOption).toBeUndefined(); }); it('should show unhold option when all transactions can be unheld', () => { @@ -788,32 +566,11 @@ describe('useSearchBulkActions', () => { const unholdOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.UNHOLD); expect(unholdOption).toBeDefined(); - expect(unholdOption?.text).toBe('search.bulkActions.unhold'); - }); - - it('should not show unhold option when some transactions cannot be unheld', () => { - mockSelectedTransactions = { - trans1: createTransactionInfo({canUnhold: true}), - trans2: createTransactionInfo({canUnhold: false}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const unholdOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.UNHOLD); - expect(unholdOption).toBeUndefined(); }); - }); - describe('headerButtonsOptions - submit', () => { it('should show submit option when all selected transactions have submit action', () => { - // Transactions without reportID bypass the areSelectedTransactionsIncludedInReports check mockSelectedTransactions = { trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.SUBMIT, reportID: undefined}), - trans2: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.SUBMIT, reportID: undefined}), }; const {result} = renderHook(() => @@ -824,82 +581,9 @@ describe('useSearchBulkActions', () => { const submitOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.SUBMIT); expect(submitOption).toBeDefined(); - expect(submitOption?.text).toBe('common.submit'); - }); - - it('should not show submit option when offline', () => { - mockIsOffline = true; - mockSelectedTransactions = { - trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.SUBMIT}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const submitOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.SUBMIT); - expect(submitOption).toBeUndefined(); - }); - }); - - describe('headerButtonsOptions - delete', () => { - it('should show delete option when shouldShowDeleteOption returns true', () => { - mockShouldShowDeleteOption = true; - mockSelectedTransactions = { - trans1: createTransactionInfo(), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const deleteOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.DELETE); - expect(deleteOption).toBeDefined(); - expect(deleteOption?.text).toBe('search.bulkActions.delete'); - }); - - it('should not show delete option when shouldShowDeleteOption returns false', () => { - mockShouldShowDeleteOption = false; - mockSelectedTransactions = { - trans1: createTransactionInfo(), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const deleteOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.DELETE); - expect(deleteOption).toBeUndefined(); - }); - }); - - describe('headerButtonsOptions - no options available', () => { - it('should show no-options-available message when no actions are available', () => { - mockSelectedTransactions = { - trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.VIEW}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - // With VIEW action and no other conditions met, only export should be shown - // (export is always present), so no-options-available should NOT be shown - const exportOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.EXPORT); - expect(exportOption).toBeDefined(); }); - }); - describe('headerButtonsOptions - reject', () => { - it('should show reject option when all transactions can be rejected and not on expense_report type', () => { + it('should show reject option when all transactions can be rejected', () => { mockSelectedTransactions = { trans1: createTransactionInfo({canReject: true}), trans2: createTransactionInfo({canReject: true}), @@ -913,123 +597,13 @@ describe('useSearchBulkActions', () => { const rejectOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.REJECT); expect(rejectOption).toBeDefined(); - expect(rejectOption?.text).toBe('search.bulkActions.reject'); - }); - - it('should not show reject option when query type is expense_report', () => { - mockSelectedTransactions = { - trans1: createTransactionInfo({canReject: true}), - }; - - const queryJSON = createBaseQueryJSON(); - queryJSON.type = CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON, - }), - ); - - const rejectOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.REJECT); - expect(rejectOption).toBeUndefined(); }); - it('should not show reject option when some transactions cannot be rejected', () => { - mockSelectedTransactions = { - trans1: createTransactionInfo({canReject: true}), - trans2: createTransactionInfo({canReject: false}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const rejectOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.REJECT); - expect(rejectOption).toBeUndefined(); - }); - - it('should not show reject option when offline', () => { - mockIsOffline = true; - mockSelectedTransactions = { - trans1: createTransactionInfo({canReject: true}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const rejectOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.REJECT); - expect(rejectOption).toBeUndefined(); - }); - }); - - describe('modal handlers', () => { - it('handleOfflineModalClose should be a function', () => { - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - expect(typeof result.current.handleOfflineModalClose).toBe('function'); - }); - - it('handleDownloadErrorModalClose should be a function', () => { - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - expect(typeof result.current.handleDownloadErrorModalClose).toBe('function'); - }); - - it('dismissModalAndUpdateUseHold should be a function', () => { - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - expect(typeof result.current.dismissModalAndUpdateUseHold).toBe('function'); - }); - - it('dismissRejectModalBasedOnAction should be a function', () => { - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - expect(typeof result.current.dismissRejectModalBasedOnAction).toBe('function'); - }); - - it('confirmPayment should be a function', () => { - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - expect(typeof result.current.confirmPayment).toBe('function'); - }); - }); - - describe('option ordering', () => { - it('should include export option in every non-all-selected scenario', () => { - // Transactions without reportID bypass the areSelectedTransactionsIncludedInReports check + it('should show delete option when shouldShowDeleteOption returns true', () => { + mockShouldShowDeleteOption = true; mockSelectedTransactions = { - trans1: createTransactionInfo({ - action: CONST.SEARCH.ACTION_TYPES.APPROVE, - canHold: true, - reportID: undefined, - }), + trans1: createTransactionInfo(), }; - mockShouldShowDeleteOption = true; const {result} = renderHook(() => useSearchBulkActions({ @@ -1037,18 +611,6 @@ describe('useSearchBulkActions', () => { }), ); - const exportOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.EXPORT); - expect(exportOption).toBeDefined(); - - // Approve should also be present - const approveOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.APPROVE); - expect(approveOption).toBeDefined(); - - // Hold should be present - const holdOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.HOLD); - expect(holdOption).toBeDefined(); - - // Delete should be present const deleteOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.DELETE); expect(deleteOption).toBeDefined(); }); From 6ab65325fa1fa3e195a3440269eda929421477ba Mon Sep 17 00:00:00 2001 From: "Abdelrahman Khattab (via MelvinBot)" Date: Tue, 17 Mar 2026 20:44:48 +0000 Subject: [PATCH 5/9] Remove unnecessary 'as string' casts in test mock to fix typecheck The createBaseQueryJSON helper was casting CONST values to string, widening the types and making them incompatible with SearchQueryJSON. Co-authored-by: Abdelrahman Khattab --- tests/unit/hooks/useSearchBulkActions.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/unit/hooks/useSearchBulkActions.test.ts b/tests/unit/hooks/useSearchBulkActions.test.ts index 0440f6233566f..0e422f8115b5c 100644 --- a/tests/unit/hooks/useSearchBulkActions.test.ts +++ b/tests/unit/hooks/useSearchBulkActions.test.ts @@ -327,16 +327,16 @@ function createTransactionInfo(overrides: Partial = {}) function createBaseQueryJSON() { return { - type: CONST.SEARCH.DATA_TYPES.EXPENSE as string, - status: CONST.SEARCH.STATUS.EXPENSE.ALL as string, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.ALL, hash: 12345, inputQuery: 'type:expense status:all', recentSearchHash: 12345, similarSearchHash: 12345, flatFilters: [], - sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE as string, - sortOrder: CONST.SEARCH.SORT_ORDER.DESC as string, - view: CONST.SEARCH.VIEW.LIST as string, + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: CONST.SEARCH.VIEW.LIST, filters: {} as never, }; } From 6e2ac0b761fb60e38d16e8d02256c5ad8c277348 Mon Sep 17 00:00:00 2001 From: "Abdelrahman Khattab (via MelvinBot)" Date: Tue, 17 Mar 2026 23:51:23 +0000 Subject: [PATCH 6/9] Fix: use CONST.SEARCH.VIEW.TABLE instead of non-existent LIST property in test Co-authored-by: Abdelrahman Khattab --- tests/unit/hooks/useSearchBulkActions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/hooks/useSearchBulkActions.test.ts b/tests/unit/hooks/useSearchBulkActions.test.ts index f453d08bb6649..6b105694b268b 100644 --- a/tests/unit/hooks/useSearchBulkActions.test.ts +++ b/tests/unit/hooks/useSearchBulkActions.test.ts @@ -336,7 +336,7 @@ function createBaseQueryJSON() { flatFilters: [], sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, sortOrder: CONST.SEARCH.SORT_ORDER.DESC, - view: CONST.SEARCH.VIEW.LIST, + view: CONST.SEARCH.VIEW.TABLE, filters: {} as never, }; } From ad91b739502a480996fb761521c9c84fbbe9db51 Mon Sep 17 00:00:00 2001 From: "Abdelrahman Khattab (via MelvinBot)" Date: Wed, 18 Mar 2026 00:01:42 +0000 Subject: [PATCH 7/9] Trim test suite to 4 focused tests per reviewer request Remove 9 headerButtonsOptions tests that only verified mocked conditional branching. Keep 2 initial state tests and 2 loading guard tests that directly validate the PR change. Co-authored-by: Abdelrahman Khattab --- tests/unit/hooks/useSearchBulkActions.test.ts | 148 +----------------- 1 file changed, 1 insertion(+), 147 deletions(-) diff --git a/tests/unit/hooks/useSearchBulkActions.test.ts b/tests/unit/hooks/useSearchBulkActions.test.ts index 6b105694b268b..f5344204e4f4b 100644 --- a/tests/unit/hooks/useSearchBulkActions.test.ts +++ b/tests/unit/hooks/useSearchBulkActions.test.ts @@ -22,8 +22,6 @@ const mockShowDelegateNoAccessModal = jest.fn(); let mockCanIOUBePaidResult = false; let mockCanIOUBePaidElsewhereResult = false; -let mockShouldShowDeleteOption = false; - let mockGetPayOptionResult = {shouldEnableBulkPayOption: false, isFirstTimePayment: false}; let mockBulkPayButtonOptions: Array<{text: string}> | undefined; @@ -266,7 +264,7 @@ jest.mock('@libs/ReportUtils', () => ({ jest.mock('@libs/SearchUIUtils', () => ({ navigateToSearchRHP: jest.fn(), - shouldShowDeleteOption: jest.fn(() => mockShouldShowDeleteOption), + shouldShowDeleteOption: jest.fn(() => false), })); jest.mock('@libs/SubscriptionUtils', () => ({ @@ -361,7 +359,6 @@ describe('useSearchBulkActions', () => { mockIsDelegateAccessRestricted = false; mockCanIOUBePaidResult = false; mockCanIOUBePaidElsewhereResult = false; - mockShouldShowDeleteOption = false; mockGetPayOptionResult = {shouldEnableBulkPayOption: false, isFirstTimePayment: false}; mockBulkPayButtonOptions = undefined; mockBusinessBankAccountOptions = undefined; @@ -472,147 +469,4 @@ describe('useSearchBulkActions', () => { }); }); - describe('headerButtonsOptions', () => { - it('should show only export option when all matching items are selected', () => { - mockAreAllMatchingItemsSelected = true; - mockSelectedTransactions = { - trans1: createTransactionInfo(), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - expect(result.current.headerButtonsOptions).toHaveLength(1); - expect(result.current.headerButtonsOptions.at(0)?.value).toBe(CONST.SEARCH.BULK_ACTION_TYPES.EXPORT); - }); - - it('should always include export option when transactions are selected', () => { - mockSelectedTransactions = { - trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.VIEW}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const exportOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.EXPORT); - expect(exportOption).toBeDefined(); - }); - - it('should show approve option when all selected transactions have approve action', () => { - mockSelectedTransactions = { - trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.APPROVE, reportID: undefined}), - trans2: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.APPROVE, reportID: undefined}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const approveOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.APPROVE); - expect(approveOption).toBeDefined(); - }); - - it('should show pay option when shouldEnableBulkPayOption is true', () => { - mockGetPayOptionResult = {shouldEnableBulkPayOption: true, isFirstTimePayment: false}; - mockSelectedTransactions = { - trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.PAY}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const payOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.PAY); - expect(payOption).toBeDefined(); - }); - - it('should show hold option when all transactions can be held', () => { - mockSelectedTransactions = { - trans1: createTransactionInfo({canHold: true}), - trans2: createTransactionInfo({canHold: true}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const holdOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.HOLD); - expect(holdOption).toBeDefined(); - }); - - it('should show unhold option when all transactions can be unheld', () => { - mockSelectedTransactions = { - trans1: createTransactionInfo({canUnhold: true}), - trans2: createTransactionInfo({canUnhold: true}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const unholdOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.UNHOLD); - expect(unholdOption).toBeDefined(); - }); - - it('should show submit option when all selected transactions have submit action', () => { - mockSelectedTransactions = { - trans1: createTransactionInfo({action: CONST.SEARCH.ACTION_TYPES.SUBMIT, reportID: undefined}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const submitOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.SUBMIT); - expect(submitOption).toBeDefined(); - }); - - it('should show reject option when all transactions can be rejected', () => { - mockSelectedTransactions = { - trans1: createTransactionInfo({canReject: true}), - trans2: createTransactionInfo({canReject: true}), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const rejectOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.REJECT); - expect(rejectOption).toBeDefined(); - }); - - it('should show delete option when shouldShowDeleteOption returns true', () => { - mockShouldShowDeleteOption = true; - mockSelectedTransactions = { - trans1: createTransactionInfo(), - }; - - const {result} = renderHook(() => - useSearchBulkActions({ - queryJSON: createBaseQueryJSON(), - }), - ); - - const deleteOption = result.current.headerButtonsOptions.find((opt) => opt.value === CONST.SEARCH.BULK_ACTION_TYPES.DELETE); - expect(deleteOption).toBeDefined(); - }); - }); }); From 487ef1ef9bab416f53e90d829181e635adbf99e2 Mon Sep 17 00:00:00 2001 From: "Abdelrahman Khattab (via MelvinBot)" Date: Wed, 18 Mar 2026 00:04:05 +0000 Subject: [PATCH 8/9] Fix: Prettier formatting in useSearchBulkActions test Co-authored-by: Abdelrahman Khattab --- tests/unit/hooks/useSearchBulkActions.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/hooks/useSearchBulkActions.test.ts b/tests/unit/hooks/useSearchBulkActions.test.ts index f5344204e4f4b..88f2bcc2120da 100644 --- a/tests/unit/hooks/useSearchBulkActions.test.ts +++ b/tests/unit/hooks/useSearchBulkActions.test.ts @@ -468,5 +468,4 @@ describe('useSearchBulkActions', () => { expect(payOption).toBeDefined(); }); }); - }); From 975135741a77b896650f6611ff9e70d8fb0453bf Mon Sep 17 00:00:00 2001 From: "Abdelrahman Khattab (via MelvinBot)" Date: Wed, 18 Mar 2026 10:30:20 +0000 Subject: [PATCH 9/9] Add justification comment for eslint-disable directive Co-authored-by: Abdelrahman Khattab --- tests/unit/hooks/useSearchBulkActions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/hooks/useSearchBulkActions.test.ts b/tests/unit/hooks/useSearchBulkActions.test.ts index 88f2bcc2120da..9da207beebb57 100644 --- a/tests/unit/hooks/useSearchBulkActions.test.ts +++ b/tests/unit/hooks/useSearchBulkActions.test.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/naming-convention -- Mock data uses Onyx collection key patterns (e.g., `report_123`) that don't conform to standard naming conventions */ import {renderHook} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; import type {SelectedReports, SelectedTransactionInfo, SelectedTransactions} from '@components/Search/types';