= {}): TransactionMonthGroupListItemType => ({
+ year,
+ month,
+ formattedMonth: options.formattedMonth ?? `January ${year}`,
+ count: options.count ?? 5,
+ currency: options.currency ?? 'USD',
+ total: options.total ?? 250,
+ groupedBy: CONST.SEARCH.GROUP_BY.MONTH,
+ transactions: [],
+ transactionsQueryJSON: undefined,
+ keyForList: `month-${year}-${month}`,
+ sortKey: options.sortKey ?? year * 100 + month,
+ ...options,
+});
+
+// Helper function to wrap component with context
+const renderMonthListItemHeader = (
+ monthItem: TransactionMonthGroupListItemType,
+ props: Partial<{
+ onCheckboxPress: jest.Mock;
+ isDisabled: boolean;
+ canSelectMultiple: boolean;
+ isSelectAllChecked: boolean;
+ isIndeterminate: boolean;
+ onDownArrowClick: jest.Mock;
+ isExpanded: boolean;
+ columns: SearchColumnType[];
+ }> = {},
+) => {
+ return render(
+
+
+
+
+ ,
+ );
+};
+
+describe('MonthListItemHeader', () => {
+ beforeAll(() =>
+ Onyx.init({
+ keys: ONYXKEYS,
+ evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS],
+ }),
+ );
+
+ beforeEach(() => {
+ // Default to small screen (mobile) layout
+ mockedUseResponsiveLayout.mockReturnValue({
+ isLargeScreenWidth: false,
+ shouldUseNarrowLayout: true,
+ isSmallScreenWidth: true,
+ isMediumScreenWidth: false,
+ isExtraSmallScreenWidth: false,
+ isExtraSmallScreenHeight: false,
+ isExtraLargeScreenWidth: false,
+ isSmallScreen: true,
+ isInNarrowPaneModal: false,
+ onboardingIsMediumOrLargerScreenWidth: false,
+ });
+ });
+
+ afterEach(async () => {
+ await act(async () => {
+ await Onyx.clear();
+ });
+ jest.clearAllMocks();
+ });
+
+ describe('Month name display', () => {
+ it('should display the formatted month name', async () => {
+ const monthItem = createMonthListItem(2026, 1, {formattedMonth: 'January 2026'});
+ renderMonthListItemHeader(monthItem);
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.getByText('January 2026')).toBeOnTheScreen();
+ });
+
+ it('should display different months correctly', async () => {
+ const monthItem = createMonthListItem(2025, 12, {formattedMonth: 'December 2025'});
+ renderMonthListItemHeader(monthItem);
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.getByText('December 2025')).toBeOnTheScreen();
+ });
+
+ it('should display month with different year', async () => {
+ const monthItem = createMonthListItem(2024, 6, {formattedMonth: 'June 2024'});
+ renderMonthListItemHeader(monthItem);
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.getByText('June 2024')).toBeOnTheScreen();
+ });
+ });
+
+ describe('Checkbox functionality', () => {
+ it('should render checkbox when canSelectMultiple is true', async () => {
+ const monthItem = createMonthListItem(2026, 1);
+ renderMonthListItemHeader(monthItem, {canSelectMultiple: true});
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.getByRole('checkbox')).toBeOnTheScreen();
+ });
+
+ it('should not render checkbox when canSelectMultiple is false', async () => {
+ const monthItem = createMonthListItem(2026, 1);
+ renderMonthListItemHeader(monthItem, {canSelectMultiple: false});
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.queryByRole('checkbox')).not.toBeOnTheScreen();
+ });
+
+ it('should call onCheckboxPress when checkbox is pressed', async () => {
+ const onCheckboxPress = jest.fn();
+ const monthItem = createMonthListItem(2026, 1);
+ renderMonthListItemHeader(monthItem, {canSelectMultiple: true, onCheckboxPress});
+ await waitForBatchedUpdatesWithAct();
+
+ const checkbox = screen.getByRole('checkbox');
+ fireEvent.press(checkbox);
+
+ expect(onCheckboxPress).toHaveBeenCalledWith(monthItem);
+ });
+
+ it('should show checkbox as checked when isSelectAllChecked is true', async () => {
+ const monthItem = createMonthListItem(2026, 1);
+ renderMonthListItemHeader(monthItem, {canSelectMultiple: true, isSelectAllChecked: true});
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.getByRole('checkbox')).toBeChecked();
+ });
+ });
+
+ describe('Total and count display', () => {
+ it('should display the total amount', async () => {
+ const monthItem = createMonthListItem(2026, 1, {total: 50000, currency: 'USD'});
+ renderMonthListItemHeader(monthItem);
+ await waitForBatchedUpdatesWithAct();
+
+ // TotalCell formats the amount, so we check for the formatted version
+ // $500.00 is 50000 cents
+ expect(screen.getByText('$500.00')).toBeOnTheScreen();
+ });
+
+ it('should display the total amount with different currencies', async () => {
+ const monthItem = createMonthListItem(2026, 1, {total: 10000, currency: 'EUR'});
+ renderMonthListItemHeader(monthItem);
+ await waitForBatchedUpdatesWithAct();
+
+ // Should display EUR formatted amount
+ expect(screen.getByTestId('TotalCell')).toBeOnTheScreen();
+ });
+ });
+
+ describe('Disabled state', () => {
+ it('should render checkbox with disabled styling when isDisabled is true', async () => {
+ const onCheckboxPress = jest.fn();
+ const monthItem = createMonthListItem(2026, 1);
+ renderMonthListItemHeader(monthItem, {canSelectMultiple: true, isDisabled: true, onCheckboxPress});
+ await waitForBatchedUpdatesWithAct();
+
+ const checkbox = screen.getByRole('checkbox');
+ // The checkbox should still be rendered
+ expect(checkbox).toBeOnTheScreen();
+ });
+
+ it('should render checkbox with disabled styling when isDisabledCheckbox is true on month item', async () => {
+ const onCheckboxPress = jest.fn();
+ const monthItem = createMonthListItem(2026, 1, {isDisabledCheckbox: true});
+ renderMonthListItemHeader(monthItem, {canSelectMultiple: true, onCheckboxPress});
+ await waitForBatchedUpdatesWithAct();
+
+ const checkbox = screen.getByRole('checkbox');
+ // The checkbox should still be rendered
+ expect(checkbox).toBeOnTheScreen();
+ });
+ });
+
+ describe('Large screen layout', () => {
+ beforeEach(() => {
+ mockedUseResponsiveLayout.mockReturnValue({
+ isLargeScreenWidth: true,
+ shouldUseNarrowLayout: false,
+ isSmallScreenWidth: false,
+ isMediumScreenWidth: false,
+ isExtraSmallScreenWidth: false,
+ isExtraSmallScreenHeight: false,
+ isExtraLargeScreenWidth: true,
+ isSmallScreen: false,
+ isInNarrowPaneModal: false,
+ onboardingIsMediumOrLargerScreenWidth: true,
+ });
+ });
+
+ it('should render column components on large screen', async () => {
+ const monthItem = createMonthListItem(2026, 1, {count: 5, total: 25000});
+ renderMonthListItemHeader(monthItem, {
+ columns: [CONST.SEARCH.TABLE_COLUMNS.GROUP_MONTH, CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES, CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL],
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ // Should display month name, expense count, and total
+ expect(screen.getByText('January 2026')).toBeOnTheScreen();
+ expect(screen.getByText('5')).toBeOnTheScreen();
+ expect(screen.getByText('$250.00')).toBeOnTheScreen();
+ });
+
+ it('should render checkbox on large screen when canSelectMultiple is true', async () => {
+ const monthItem = createMonthListItem(2026, 1);
+ renderMonthListItemHeader(monthItem, {canSelectMultiple: true});
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.getByRole('checkbox')).toBeOnTheScreen();
+ });
+ });
+
+ describe('Expand/Collapse functionality', () => {
+ it('should render expand/collapse button when onDownArrowClick is provided', async () => {
+ const onDownArrowClick = jest.fn();
+ const monthItem = createMonthListItem(2026, 1);
+ renderMonthListItemHeader(monthItem, {onDownArrowClick, isExpanded: false});
+ await waitForBatchedUpdatesWithAct();
+
+ // The expand/collapse button should be rendered with "Expand" label when not expanded
+ const expandButton = screen.getByLabelText('Expand');
+ expect(expandButton).toBeOnTheScreen();
+ });
+
+ it('should call onDownArrowClick when expand/collapse button is pressed', async () => {
+ const onDownArrowClick = jest.fn();
+ const monthItem = createMonthListItem(2026, 1);
+ renderMonthListItemHeader(monthItem, {onDownArrowClick, isExpanded: false});
+ await waitForBatchedUpdatesWithAct();
+
+ const expandButton = screen.getByLabelText('Expand');
+ fireEvent.press(expandButton);
+
+ expect(onDownArrowClick).toHaveBeenCalled();
+ });
+
+ it('should show "Collapse" label when isExpanded is true', async () => {
+ const onDownArrowClick = jest.fn();
+ const monthItem = createMonthListItem(2026, 1);
+ renderMonthListItemHeader(monthItem, {onDownArrowClick, isExpanded: true});
+ await waitForBatchedUpdatesWithAct();
+
+ const collapseButton = screen.getByLabelText('Collapse');
+ expect(collapseButton).toBeOnTheScreen();
+ });
+
+ it('should not render expand/collapse button when onDownArrowClick is not provided', async () => {
+ const monthItem = createMonthListItem(2026, 1);
+ renderMonthListItemHeader(monthItem);
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.queryByLabelText('Expand')).not.toBeOnTheScreen();
+ expect(screen.queryByLabelText('Collapse')).not.toBeOnTheScreen();
+ });
+ });
+});
diff --git a/tests/ui/NavigationTabBarAvatarTest.tsx b/tests/ui/NavigationTabBarAvatarTest.tsx
index 534045444281..3a1ddf5a5529 100644
--- a/tests/ui/NavigationTabBarAvatarTest.tsx
+++ b/tests/ui/NavigationTabBarAvatarTest.tsx
@@ -1,7 +1,7 @@
import {cleanup, fireEvent, render, screen} from '@testing-library/react-native';
import React from 'react';
import OnyxListItemProvider from '@components/OnyxListItemProvider';
-import NavigationTabBarAvatar from '@pages/home/sidebar/NavigationTabBarAvatar';
+import NavigationTabBarAvatar from '@pages/inbox/sidebar/NavigationTabBarAvatar';
import colors from '@styles/theme/colors';
import CONST from '@src/CONST';
diff --git a/tests/ui/PureReportActionItemTest.tsx b/tests/ui/PureReportActionItemTest.tsx
index 6174c013fe8f..589612424e27 100644
--- a/tests/ui/PureReportActionItemTest.tsx
+++ b/tests/ui/PureReportActionItemTest.tsx
@@ -11,7 +11,7 @@ import OptionsListContextProvider from '@components/OptionListContextProvider';
import ScreenWrapper from '@components/ScreenWrapper';
import Parser from '@libs/Parser';
import {getIOUActionForReportID} from '@libs/ReportActionsUtils';
-import PureReportActionItem from '@pages/home/report/PureReportActionItem';
+import PureReportActionItem from '@pages/inbox/report/PureReportActionItem';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import * as ReportActionUtils from '@src/libs/ReportActionsUtils';
@@ -356,4 +356,139 @@ describe('PureReportActionItem', () => {
expect(screen.queryByText(translateLocal('iou.queuedToSubmitViaDEW'))).not.toBeOnTheScreen();
});
});
+
+ describe('Followup list buttons', () => {
+ it('should display followup buttons when message contains unresolved followup-list', async () => {
+ const followupQuestion1 = 'How do I set up QuickBooks?';
+ const followupQuestion2 = 'What is the Expensify Card cashback?';
+
+ const action = {
+ reportActionID: '12345',
+ actorAccountID: CONST.ACCOUNT_ID.CONCIERGE,
+ created: '2025-07-12 09:03:17.653',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
+ automatic: false,
+ shouldShow: true,
+ avatar: '',
+ person: [{type: 'TEXT', style: 'strong', text: 'Concierge'}],
+ message: [
+ {
+ type: CONST.REPORT.MESSAGE.TYPE.COMMENT,
+ html: `Here is some helpful information.
+
+ ${followupQuestion1}
+ ${followupQuestion2}
+`,
+ text: 'Here is some helpful information.',
+ },
+ ],
+ originalMessage: {},
+ } as ReportAction;
+
+ const report = {
+ reportID: 'testReport',
+ type: CONST.REPORT.TYPE.CHAT,
+ };
+
+ render(
+
+
+
+
+
+
+
+
+ ,
+ );
+ await waitForBatchedUpdatesWithAct();
+
+ // Verify followup buttons are displayed
+ expect(screen.getByText(followupQuestion1)).toBeOnTheScreen();
+ expect(screen.getByText(followupQuestion2)).toBeOnTheScreen();
+ });
+
+ it('should not display followup buttons when followup-list is resolved (has selected attribute)', async () => {
+ const followupQuestion = 'How do I set up QuickBooks?';
+
+ const action = {
+ reportActionID: '12345',
+ actorAccountID: CONST.ACCOUNT_ID.CONCIERGE,
+ created: '2025-07-12 09:03:17.653',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
+ automatic: false,
+ shouldShow: true,
+ avatar: '',
+ person: [{type: 'TEXT', style: 'strong', text: 'Concierge'}],
+ message: [
+ {
+ type: CONST.REPORT.MESSAGE.TYPE.COMMENT,
+ html: `Here is some helpful information.
+
+ ${followupQuestion}
+`,
+ text: 'Here is some helpful information.',
+ },
+ ],
+ originalMessage: {},
+ } as ReportAction;
+
+ const report = {
+ reportID: 'testReport',
+ type: CONST.REPORT.TYPE.CHAT,
+ };
+
+ render(
+
+
+
+
+
+
+
+
+ ,
+ );
+ await waitForBatchedUpdatesWithAct();
+
+ // Verify followup buttons are NOT displayed (resolved state)
+ expect(screen.queryByText(followupQuestion)).not.toBeOnTheScreen();
+ });
+ });
});
diff --git a/tests/ui/ReportActionComposeTest.tsx b/tests/ui/ReportActionComposeTest.tsx
index ec840b47c019..a9eecd42f649 100644
--- a/tests/ui/ReportActionComposeTest.tsx
+++ b/tests/ui/ReportActionComposeTest.tsx
@@ -6,8 +6,8 @@ import ComposeProviders from '@components/ComposeProviders';
import {LocaleContextProvider} from '@components/LocaleContextProvider';
import OnyxListItemProvider from '@components/OnyxListItemProvider';
import {forceClearInput} from '@libs/ComponentUtils';
-import type {ReportActionComposeProps} from '@pages/home/report/ReportActionCompose/ReportActionCompose';
-import ReportActionCompose, {onSubmitAction} from '@pages/home/report/ReportActionCompose/ReportActionCompose';
+import type {ReportActionComposeProps} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose';
+import ReportActionCompose, {onSubmitAction} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import * as LHNTestUtils from '../utils/LHNTestUtils';
diff --git a/tests/ui/ReportActionItemMessageEditTest.tsx b/tests/ui/ReportActionItemMessageEditTest.tsx
index 868b555d35f7..95a9c42d2ded 100644
--- a/tests/ui/ReportActionItemMessageEditTest.tsx
+++ b/tests/ui/ReportActionItemMessageEditTest.tsx
@@ -6,8 +6,8 @@ import ComposeProviders from '@components/ComposeProviders';
import {LocaleContextProvider} from '@components/LocaleContextProvider';
import OnyxListItemProvider from '@components/OnyxListItemProvider';
import {editReportComment} from '@libs/actions/Report';
-import ReportActionItemMessageEdit from '@pages/home/report/ReportActionItemMessageEdit';
-import type {ReportActionItemMessageEditProps} from '@pages/home/report/ReportActionItemMessageEdit';
+import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit';
+import type {ReportActionItemMessageEditProps} from '@pages/inbox/report/ReportActionItemMessageEdit';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import * as LHNTestUtils from '../utils/LHNTestUtils';
diff --git a/tests/ui/ReportActionsViewTest.tsx b/tests/ui/ReportActionsViewTest.tsx
index 739c019b27cc..86fa52332b2c 100644
--- a/tests/ui/ReportActionsViewTest.tsx
+++ b/tests/ui/ReportActionsViewTest.tsx
@@ -7,7 +7,7 @@ import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport';
-import ReportActionsView from '@pages/home/report/ReportActionsView';
+import ReportActionsView from '@pages/inbox/report/ReportActionsView';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type * as OnyxTypes from '@src/types/onyx';
@@ -46,7 +46,7 @@ jest.mock('@hooks/useLoadReportActions', () =>
);
jest.mock('@hooks/usePrevious', () => jest.fn());
-jest.mock('@pages/home/report/ReportActionsList', () =>
+jest.mock('@pages/inbox/report/ReportActionsList', () =>
jest.fn(({sortedReportActions}: {sortedReportActions: OnyxTypes.ReportAction[]}) => {
if (sortedReportActions && sortedReportActions.length > 0) {
return null; // Simulate normal content
@@ -54,7 +54,7 @@ jest.mock('@pages/home/report/ReportActionsList', () =>
return null;
}),
);
-jest.mock('@pages/home/report/UserTypingEventListener', () => jest.fn(() => null));
+jest.mock('@pages/inbox/report/UserTypingEventListener', () => jest.fn(() => null));
jest.mock('@libs/actions/Report', () => ({
updateLoadingInitialReportAction: jest.fn(),
diff --git a/tests/ui/components/HeaderViewTest.tsx b/tests/ui/components/HeaderViewTest.tsx
index c7e2b70f9f1c..368b7f8c1e07 100644
--- a/tests/ui/components/HeaderViewTest.tsx
+++ b/tests/ui/components/HeaderViewTest.tsx
@@ -8,7 +8,7 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import type Navigation from '@libs/Navigation/Navigation';
import {buildOptimisticCreatedReportForUnapprovedAction} from '@libs/ReportUtils';
-import HeaderView from '@pages/home/HeaderView';
+import HeaderView from '@pages/inbox/HeaderView';
import {joinRoom} from '@userActions/Report';
// eslint-disable-next-line no-restricted-syntax
import type * as ReportType from '@userActions/Report';
diff --git a/tests/ui/components/LHNOptionsListTest.tsx b/tests/ui/components/LHNOptionsListTest.tsx
index b1804e2a2f49..76b147044f4b 100644
--- a/tests/ui/components/LHNOptionsListTest.tsx
+++ b/tests/ui/components/LHNOptionsListTest.tsx
@@ -8,14 +8,14 @@ import LHNOptionsList from '@components/LHNOptionsList/LHNOptionsList';
import type {LHNOptionsListProps} from '@components/LHNOptionsList/types';
import {LocaleContextProvider} from '@components/LocaleContextProvider';
import OnyxListItemProvider from '@components/OnyxListItemProvider';
-import {showContextMenu} from '@pages/home/report/ContextMenu/ReportActionContextMenu';
+import {showContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy, Report, ReportAction} from '@src/types/onyx';
import {getFakeReport} from '../../utils/LHNTestUtils';
// Mock the context menu
-jest.mock('@pages/home/report/ContextMenu/ReportActionContextMenu', () => ({
+jest.mock('@pages/inbox/report/ContextMenu/ReportActionContextMenu', () => ({
showContextMenu: jest.fn(),
}));
diff --git a/tests/unit/FollowupUtilsTest.ts b/tests/unit/FollowupUtilsTest.ts
new file mode 100644
index 000000000000..0124097b454e
--- /dev/null
+++ b/tests/unit/FollowupUtilsTest.ts
@@ -0,0 +1,160 @@
+import CONST from '../../src/CONST';
+import {containsActionableFollowUps, parseFollowupsFromHtml} from '../../src/libs/ReportActionsFollowupUtils';
+import {stripFollowupListFromHtml} from '../../src/libs/ReportActionsUtils';
+import type {ReportAction} from '../../src/types/onyx';
+
+describe('FollowupUtils', () => {
+ describe('parseFollowupsFromHtml', () => {
+ it('should return null when no followup-list exists', () => {
+ const html = 'Hello world
';
+ expect(parseFollowupsFromHtml(html)).toBeNull();
+ });
+
+ it('should return null for empty string', () => {
+ expect(parseFollowupsFromHtml('')).toBeNull();
+ });
+
+ it('should return empty array when followup-list has selected attribute', () => {
+ const html = `Some message
+
+ How do I set up QuickBooks?
+`;
+ expect(parseFollowupsFromHtml(html)).toEqual([]);
+ });
+
+ it('should return empty array when followup-list has selected attribute with other attributes', () => {
+ const html = `
+ Question 1
+`;
+ expect(parseFollowupsFromHtml(html)).toEqual([]);
+ });
+
+ it('should parse single followup from unresolved list', () => {
+ const html = `Hello
+
+ How do I set up QuickBooks?
+`;
+ expect(parseFollowupsFromHtml(html)).toEqual([{text: 'How do I set up QuickBooks?'}]);
+ });
+
+ it('should parse multiple followups from unresolved list', () => {
+ const html = `
+ How do I set up QuickBooks?
+ What is the Expensify Card cashback?
+`;
+ expect(parseFollowupsFromHtml(html)).toEqual([{text: 'How do I set up QuickBooks?'}, {text: 'What is the Expensify Card cashback?'}]);
+ });
+
+ it('should handle followup-list with whitespace attributes', () => {
+ const html = `
+ Question
+`;
+ expect(parseFollowupsFromHtml(html)).toEqual([{text: 'Question'}]);
+ });
+
+ it('should return empty array for followup-list with selected but no followups', () => {
+ const html = '';
+ expect(parseFollowupsFromHtml(html)).toEqual([]);
+ });
+
+ it('should return empty array for unresolved followup-list with no followups', () => {
+ const html = '';
+ expect(parseFollowupsFromHtml(html)).toEqual([]);
+ });
+ });
+
+ describe('stripFollowupListFromHtml', () => {
+ it('should return original string when no followup-list exists', () => {
+ const html = 'Hello world
';
+ expect(stripFollowupListFromHtml(html)).toBe('Hello world
');
+ });
+
+ it('should return undefined for empty input', () => {
+ expect(stripFollowupListFromHtml('')).not.toBeDefined();
+ });
+
+ it('should strip followup-list and trim result', () => {
+ const html = `Some message
+
+ How do I set up QuickBooks?
+`;
+ expect(stripFollowupListFromHtml(html)).toBe('Some message
');
+ });
+
+ it('should strip resolved followup-list with selected attribute', () => {
+ const html = `Answer to your question
+
+ Old question
+`;
+ expect(stripFollowupListFromHtml(html)).toBe('Answer to your question
');
+ });
+
+ it('should handle content before and after followup-list', () => {
+ const html = `Before
+
+ Question
+
+After
`;
+ expect(stripFollowupListFromHtml(html)).toBe(`Before
+
+After
`);
+ });
+ });
+
+ describe('containsActionableFollowUps', () => {
+ it('should return false for null/undefined reportAction', () => {
+ expect(containsActionableFollowUps(null)).toBe(false);
+ expect(containsActionableFollowUps(undefined)).toBe(false);
+ });
+
+ it('should return false for non-ADD_COMMENT action types', () => {
+ const action = {
+ reportActionID: '123',
+ actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
+ message: [{html: 'Question', text: '', type: 'COMMENT'}],
+ } as ReportAction;
+
+ expect(containsActionableFollowUps(action)).toBe(false);
+ });
+
+ it('should return false for ADD_COMMENT without message html', () => {
+ const action = {
+ reportActionID: '123',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
+ message: [{text: 'Just text', type: 'COMMENT'}],
+ } as ReportAction;
+
+ expect(containsActionableFollowUps(action)).toBe(false);
+ });
+
+ it('should return false for ADD_COMMENT without followup-list', () => {
+ const action = {
+ reportActionID: '123',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
+ message: [{html: 'Regular message
', text: 'Regular message', type: 'COMMENT'}],
+ } as ReportAction;
+
+ expect(containsActionableFollowUps(action)).toBe(false);
+ });
+
+ it('should return false for ADD_COMMENT with resolved followup-list (selected attribute)', () => {
+ const action = {
+ reportActionID: '123',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
+ message: [{html: 'Message
Question', text: 'Message', type: 'COMMENT'}],
+ } as ReportAction;
+
+ expect(containsActionableFollowUps(action)).toBe(false);
+ });
+
+ it('should return true for ADD_COMMENT with unresolved followup-list', () => {
+ const action = {
+ reportActionID: '123',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
+ message: [{html: 'Message
Question', text: 'Message', type: 'COMMENT'}],
+ } as ReportAction;
+
+ expect(containsActionableFollowUps(action)).toBe(true);
+ });
+ });
+});
diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts
index 9f65bcb3e61c..cd8ac0fb7ae6 100644
--- a/tests/unit/MergeTransactionUtilsTest.ts
+++ b/tests/unit/MergeTransactionUtilsTest.ts
@@ -423,7 +423,6 @@ describe('MergeTransactionUtils', () => {
it('should keep split expense when merging split and cash expenses', () => {
const targetTransaction = {
...createRandomTransaction(1),
- reportID: CONST.REPORT.SPLIT_REPORT_ID,
amount: 1000,
currency: CONST.CURRENCY.USD,
comment: {
diff --git a/tests/unit/ModifiedExpenseMessageTest.ts b/tests/unit/ModifiedExpenseMessageTest.ts
index 2ea0f4a43620..962e2cbe2592 100644
--- a/tests/unit/ModifiedExpenseMessageTest.ts
+++ b/tests/unit/ModifiedExpenseMessageTest.ts
@@ -1,3 +1,4 @@
+import {getEnvironmentURL} from '@libs/Environment/Environment';
import {getForReportAction, getMovedFromOrToReportMessage, getMovedReportID} from '@libs/ModifiedExpenseMessage';
// eslint-disable-next-line no-restricted-syntax -- this is required to allow mocking
import * as PolicyUtils from '@libs/PolicyUtils';
@@ -798,6 +799,120 @@ describe('ModifiedExpenseMessage', () => {
});
});
+ describe('when policy rules modify an expense', () => {
+ let environmentURL: string;
+ beforeAll(async () => {
+ environmentURL = await getEnvironmentURL();
+ });
+
+ it('returns the correct text message with multiple overrides', () => {
+ const reportAction = {
+ ...createRandomReportAction(1),
+ actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE,
+ originalMessage: {
+ policyID: '1234',
+ policyRulesModifiedFields: {
+ category: 'Travel',
+ merchant: "McDonald's",
+ billable: true,
+ reimbursable: true,
+ },
+ } as OriginalMessageModifiedExpense,
+ };
+
+ const result = getForReportAction({reportAction, policyID: report.policyID});
+
+ const expectedResult = `set the category to "Travel", merchant to "McDonald's", marked the expense as "billable", and marked the expense as "reimbursable" via workspace rules`;
+
+ expect(result).toEqual(expectedResult);
+ });
+
+ it('returns the correct text message with tax rate overrides', () => {
+ const reportAction = {
+ ...createRandomReportAction(1),
+ actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE,
+ originalMessage: {
+ policyID: '1234',
+ policyRulesModifiedFields: {
+ tax: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ field_id_TAX: {
+ externalID: '',
+ name: 'New Tax Rate',
+ value: '10',
+ },
+ },
+ },
+ } as OriginalMessageModifiedExpense,
+ };
+
+ const result = getForReportAction({reportAction, policyID: report.policyID});
+
+ const expectedResult = `set the tax rate to "New Tax Rate" via workspace rules`;
+
+ expect(result).toEqual(expectedResult);
+ });
+
+ it('returns the correct text message with two overrides', () => {
+ const reportAction = {
+ ...createRandomReportAction(1),
+ actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE,
+ originalMessage: {
+ policyID: '1234',
+ policyRulesModifiedFields: {
+ category: 'Travel',
+ merchant: "McDonald's",
+ },
+ } as OriginalMessageModifiedExpense,
+ };
+
+ const result = getForReportAction({reportAction, policyID: report.policyID});
+
+ const expectedResult = `set the category to "Travel" and merchant to "McDonald's" via workspace rules`;
+
+ expect(result).toEqual(expectedResult);
+ });
+
+ it('returns the correct text message with a single override', () => {
+ const reportAction = {
+ ...createRandomReportAction(1),
+ actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE,
+ originalMessage: {
+ policyID: '1234',
+ policyRulesModifiedFields: {
+ billable: true,
+ },
+ } as OriginalMessageModifiedExpense,
+ };
+
+ const result = getForReportAction({reportAction, policyID: report.policyID});
+
+ const expectedResult = `marked the expense as "billable" via workspace rules`;
+
+ expect(result).toEqual(expectedResult);
+ });
+
+ it('returns the correct text message with boolean overrides', () => {
+ const reportAction = {
+ ...createRandomReportAction(1),
+ actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE,
+ originalMessage: {
+ policyID: '1234',
+ policyRulesModifiedFields: {
+ reimbursable: true,
+ billable: true,
+ },
+ } as OriginalMessageModifiedExpense,
+ };
+
+ const result = getForReportAction({reportAction, policyID: report.policyID});
+
+ const expectedResult = `marked the expense as "reimbursable" and marked the expense as "billable" via workspace rules`;
+
+ expect(result).toEqual(expectedResult);
+ });
+ });
+
describe('when the category is changed without source (backward compatibility)', () => {
const reportAction = {
...createRandomReportAction(1),
diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts
index 46bd6e0a8773..193191d0c30b 100644
--- a/tests/unit/Search/SearchUIUtilsTest.ts
+++ b/tests/unit/Search/SearchUIUtilsTest.ts
@@ -12,7 +12,9 @@ import type {
TransactionGroupListItemType,
TransactionListItemType,
TransactionMemberGroupListItemType,
+ TransactionMonthGroupListItemType,
TransactionReportGroupListItemType,
+ TransactionTagGroupListItemType,
TransactionWithdrawalIDGroupListItemType,
} from '@components/SelectionListWithSections/types';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
@@ -52,6 +54,8 @@ jest.mock('@userActions/Search', () => ({
const adminAccountID = 18439984;
const adminEmail = 'admin@policy.com';
+const receiverAccountID = 18439985;
+const receiverEmail = 'receiver@policy.com';
const emptyPersonalDetails = {
accountID: 0,
@@ -1721,6 +1725,119 @@ const transactionCategoryGroupListItemsSorted: TransactionCategoryGroupListItemT
},
];
+const tagName1 = 'Project A';
+const tagName2 = 'Project B';
+
+const searchResultsGroupByTag: OnyxTypes.SearchResults = {
+ data: {
+ personalDetailsList: {},
+ [`${CONST.SEARCH.GROUP_PREFIX}${tagName1}` as const]: {
+ tag: tagName1,
+ count: 5,
+ currency: 'USD',
+ total: 250,
+ },
+ [`${CONST.SEARCH.GROUP_PREFIX}${tagName2}` as const]: {
+ tag: tagName2,
+ count: 3,
+ currency: 'USD',
+ total: 75,
+ },
+ },
+ search: {
+ count: 8,
+ currency: 'USD',
+ hasMoreResults: false,
+ hasResults: true,
+ offset: 0,
+ status: CONST.SEARCH.STATUS.EXPENSE.ALL,
+ total: 325,
+ isLoading: false,
+ type: 'expense',
+ },
+};
+
+const transactionTagGroupListItems: TransactionTagGroupListItemType[] = [
+ {
+ tag: tagName1,
+ count: 5,
+ currency: 'USD',
+ total: 250,
+ groupedBy: CONST.SEARCH.GROUP_BY.TAG,
+ formattedTag: tagName1,
+ transactions: [],
+ transactionsQueryJSON: undefined,
+ },
+ {
+ tag: tagName2,
+ count: 3,
+ currency: 'USD',
+ total: 75,
+ groupedBy: CONST.SEARCH.GROUP_BY.TAG,
+ formattedTag: tagName2,
+ transactions: [],
+ transactionsQueryJSON: undefined,
+ },
+];
+
+const searchResultsGroupByMonth: OnyxTypes.SearchResults = {
+ data: {
+ personalDetailsList: {},
+ [`${CONST.SEARCH.GROUP_PREFIX}2026_1` as const]: {
+ year: 2026,
+ month: 1,
+ count: 5,
+ currency: 'USD',
+ total: 250,
+ },
+ [`${CONST.SEARCH.GROUP_PREFIX}2025_12` as const]: {
+ year: 2025,
+ month: 12,
+ count: 3,
+ currency: 'USD',
+ total: 75,
+ },
+ },
+ search: {
+ count: 8,
+ currency: 'USD',
+ hasMoreResults: false,
+ hasResults: true,
+ offset: 0,
+ status: CONST.SEARCH.STATUS.EXPENSE.ALL,
+ total: 325,
+ isLoading: false,
+ type: 'expense',
+ },
+};
+
+const transactionMonthGroupListItems: TransactionMonthGroupListItemType[] = [
+ {
+ year: 2026,
+ month: 1,
+ count: 5,
+ currency: 'USD',
+ total: 250,
+ groupedBy: CONST.SEARCH.GROUP_BY.MONTH,
+ formattedMonth: 'January 2026',
+ sortKey: 202601,
+ transactions: [],
+ transactionsQueryJSON: undefined,
+ },
+ {
+ year: 2025,
+ month: 12,
+ count: 3,
+ currency: 'USD',
+ total: 75,
+ groupedBy: CONST.SEARCH.GROUP_BY.MONTH,
+ formattedMonth: 'December 2025',
+ sortKey: 202512,
+ transactions: [],
+ transactionsQueryJSON: undefined,
+ },
+];
+
describe('SearchUIUtils', () => {
beforeAll(async () => {
Onyx.init({
@@ -2377,6 +2494,90 @@ describe('SearchUIUtils', () => {
expect(SearchUIUtils.isTransactionCategoryGroupListItemType(categoryItem)).toBe(true);
});
+ it('should return getMonthSections result when type is EXPENSE and groupBy is month', () => {
+ expect(
+ SearchUIUtils.getSections({
+ type: CONST.SEARCH.DATA_TYPES.EXPENSE,
+ data: searchResultsGroupByMonth.data,
+ currentAccountID: 2074551,
+ currentUserEmail: '',
+ translate: translateLocal,
+ formatPhoneNumber,
+ bankAccountList: {},
+ groupBy: CONST.SEARCH.GROUP_BY.MONTH,
+ })[0],
+ ).toStrictEqual(transactionMonthGroupListItems);
+ });
+
+ it('should format month names correctly', () => {
+ const dataWithDifferentMonths: OnyxTypes.SearchResults['data'] = {
+ personalDetailsList: {},
+ [`${CONST.SEARCH.GROUP_PREFIX}2026_1` as const]: {
+ year: 2026,
+ month: 1,
+ count: 2,
+ currency: 'USD',
+ total: 50,
+ },
+ [`${CONST.SEARCH.GROUP_PREFIX}2026_6` as const]: {
+ year: 2026,
+ month: 6,
+ count: 1,
+ currency: 'USD',
+ total: 25,
+ },
+ };
+
+ const [result] = SearchUIUtils.getSections({
+ type: CONST.SEARCH.DATA_TYPES.EXPENSE,
+ data: dataWithDifferentMonths,
+ currentAccountID: 2074551,
+ currentUserEmail: '',
+ translate: translateLocal,
+ formatPhoneNumber,
+ bankAccountList: {},
+ groupBy: CONST.SEARCH.GROUP_BY.MONTH,
+ }) as [TransactionMonthGroupListItemType[], number];
+
+ expect(result).toHaveLength(2);
+ expect(result.some((item) => item.formattedMonth === 'January 2026')).toBe(true);
+ expect(result.some((item) => item.formattedMonth === 'June 2026')).toBe(true);
+ });
+
+ it('should calculate sortKey correctly for month groups', () => {
+ const [result] = SearchUIUtils.getSections({
+ type: CONST.SEARCH.DATA_TYPES.EXPENSE,
+ data: searchResultsGroupByMonth.data,
+ currentAccountID: 2074551,
+ currentUserEmail: '',
+ translate: translateLocal,
+ formatPhoneNumber,
+ bankAccountList: {},
+ groupBy: CONST.SEARCH.GROUP_BY.MONTH,
+ }) as [TransactionMonthGroupListItemType[], number];
+
+ expect(result).toHaveLength(2);
+ expect(result.some((item) => item.sortKey === 202601)).toBe(true);
+ expect(result.some((item) => item.sortKey === 202512)).toBe(true);
+ });
+
+ it('should return isTransactionMonthGroupListItemType true for month group items', () => {
+ const monthItem: TransactionMonthGroupListItemType = {
+ year: 2026,
+ month: 1,
+ count: 5,
+ currency: 'USD',
+ total: 250,
+ groupedBy: CONST.SEARCH.GROUP_BY.MONTH,
+ formattedMonth: 'January 2026',
+ sortKey: 202601,
+ transactions: [],
+ transactionsQueryJSON: undefined,
+ };
+
+ expect(SearchUIUtils.isTransactionMonthGroupListItemType(monthItem)).toBe(true);
+ });
+
it('should return isTransactionCategoryGroupListItemType false for non-category group items', () => {
const memberItem: TransactionMemberGroupListItemType = {
accountID: 123,
@@ -2577,6 +2778,147 @@ describe('SearchUIUtils', () => {
const quotesItem = result.find((item) => item.category === '"Special" Category');
expect(quotesItem?.formattedCategory).toBe('"Special" Category');
});
+
+ it('should return getTagSections result when type is EXPENSE and groupBy is tag', () => {
+ expect(
+ SearchUIUtils.getSections({
+ type: CONST.SEARCH.DATA_TYPES.EXPENSE,
+ data: searchResultsGroupByTag.data,
+ currentAccountID: 2074551,
+ currentUserEmail: '',
+ translate: translateLocal,
+ formatPhoneNumber,
+ bankAccountList: {},
+ groupBy: CONST.SEARCH.GROUP_BY.TAG,
+ })[0],
+ ).toStrictEqual(transactionTagGroupListItems);
+ });
+
+ it('should handle empty tag values correctly', () => {
+ const dataWithEmptyTag: OnyxTypes.SearchResults['data'] = {
+ personalDetailsList: {},
+ [`${CONST.SEARCH.GROUP_PREFIX}empty` as const]: {
+ tag: '',
+ count: 2,
+ currency: 'USD',
+ total: 50,
+ },
+ [`${CONST.SEARCH.GROUP_PREFIX}none` as const]: {
+ tag: CONST.SEARCH.TAG_EMPTY_VALUE,
+ count: 1,
+ currency: 'USD',
+ total: 25,
+ },
+ };
+
+ const [result] = SearchUIUtils.getSections({
+ type: CONST.SEARCH.DATA_TYPES.EXPENSE,
+ data: dataWithEmptyTag,
+ currentAccountID: 2074551,
+ currentUserEmail: '',
+ translate: translateLocal,
+ formatPhoneNumber,
+ bankAccountList: {},
+ groupBy: CONST.SEARCH.GROUP_BY.TAG,
+ }) as [TransactionTagGroupListItemType[], number];
+
+ expect(result).toHaveLength(2);
+ expect(result.some((item) => item.tag === '')).toBe(true);
+ expect(result.some((item) => item.tag === CONST.SEARCH.TAG_EMPTY_VALUE)).toBe(true);
+ });
+
+ it('should handle "(untagged)" value from backend', () => {
+ const dataWithUntagged: OnyxTypes.SearchResults['data'] = {
+ personalDetailsList: {},
+ [`${CONST.SEARCH.GROUP_PREFIX}untagged` as const]: {
+ tag: '(untagged)',
+ count: 3,
+ currency: 'USD',
+ total: 100,
+ },
+ };
+
+ const [result] = SearchUIUtils.getSections({
+ type: CONST.SEARCH.DATA_TYPES.EXPENSE,
+ data: dataWithUntagged,
+ currentAccountID: 2074551,
+ currentUserEmail: '',
+ translate: translateLocal,
+ formatPhoneNumber,
+ bankAccountList: {},
+ groupBy: CONST.SEARCH.GROUP_BY.TAG,
+ }) as [TransactionTagGroupListItemType[], number];
+
+ expect(result).toHaveLength(1);
+ expect(result.at(0)?.tag).toBe('(untagged)');
+ });
+
+ it('should return isTransactionTagGroupListItemType true for tag group items', () => {
+ const tagItem: TransactionTagGroupListItemType = {
+ tag: 'Project A',
+ count: 5,
+ currency: 'USD',
+ total: 250,
+ groupedBy: CONST.SEARCH.GROUP_BY.TAG,
+ formattedTag: 'Project A',
+ transactions: [],
+ transactionsQueryJSON: undefined,
+ };
+
+ expect(SearchUIUtils.isTransactionTagGroupListItemType(tagItem)).toBe(true);
+ });
+
+ it('should return isTransactionTagGroupListItemType false for non-tag group items', () => {
+ const categoryItem: TransactionCategoryGroupListItemType = {
+ category: 'Travel',
+ count: 5,
+ currency: 'USD',
+ total: 250,
+ groupedBy: CONST.SEARCH.GROUP_BY.CATEGORY,
+ formattedCategory: 'Travel',
+ transactions: [],
+ transactionsQueryJSON: undefined,
+ };
+
+ expect(SearchUIUtils.isTransactionTagGroupListItemType(categoryItem)).toBe(false);
+ });
+
+ it('should generate transactionsQueryJSON with valid hash for tag sections', () => {
+ const [result] = SearchUIUtils.getSections({
+ type: CONST.SEARCH.DATA_TYPES.EXPENSE,
+ data: searchResultsGroupByTag.data,
+ currentAccountID: 2074551,
+ currentUserEmail: '',
+ translate: translateLocal,
+ formatPhoneNumber,
+ bankAccountList: {},
+ groupBy: CONST.SEARCH.GROUP_BY.TAG,
+ queryJSON: {
+ type: CONST.SEARCH.DATA_TYPES.EXPENSE,
+ status: '',
+ sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE,
+ sortOrder: CONST.SEARCH.SORT_ORDER.DESC,
+ view: CONST.SEARCH.VIEW.TABLE,
+ hash: 12345,
+ flatFilters: [],
+ inputQuery: 'type:expense groupBy:tag',
+ recentSearchHash: 12345,
+ similarSearchHash: 12345,
+ filters: {
+ operator: CONST.SEARCH.SYNTAX_OPERATORS.AND,
+ left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TYPE,
+ right: CONST.SEARCH.DATA_TYPES.EXPENSE,
+ },
+ },
+ }) as [TransactionTagGroupListItemType[], number];
+
+ // Each tag section should have a transactionsQueryJSON with a hash
+ for (const item of result) {
+ expect(item.transactionsQueryJSON).toBeDefined();
+ expect(item.transactionsQueryJSON?.hash).toBeDefined();
+ expect(typeof item.transactionsQueryJSON?.hash).toBe('number');
+ }
+ });
});
describe('Test getSortedSections', () => {
@@ -2755,6 +3097,133 @@ describe('SearchUIUtils', () => {
expect(result.at(0)?.count).toBe(5);
expect(result.at(1)?.count).toBe(3);
});
+
+ it('should return getSortedTagData result when type is EXPENSE and groupBy is tag', () => {
+ expect(
+ SearchUIUtils.getSortedSections(
+ CONST.SEARCH.DATA_TYPES.EXPENSE,
+ '',
+ transactionTagGroupListItems,
+ localeCompare,
+ translateLocal,
+ CONST.SEARCH.TABLE_COLUMNS.DATE,
+ CONST.SEARCH.SORT_ORDER.ASC,
+ CONST.SEARCH.GROUP_BY.TAG,
+ ),
+ ).toStrictEqual(transactionTagGroupListItems);
+ });
+
+ it('should sort tag data by tag name in ascending order', () => {
+ const result = SearchUIUtils.getSortedSections(
+ CONST.SEARCH.DATA_TYPES.EXPENSE,
+ '',
+ transactionTagGroupListItems,
+ localeCompare,
+ translateLocal,
+ CONST.SEARCH.TABLE_COLUMNS.GROUP_TAG,
+ CONST.SEARCH.SORT_ORDER.ASC,
+ CONST.SEARCH.GROUP_BY.TAG,
+ ) as TransactionTagGroupListItemType[];
+
+ // "Project A" should come before "Project B" in ascending alphabetical order
+ expect(result.at(0)?.tag).toBe(tagName1);
+ expect(result.at(1)?.tag).toBe(tagName2);
+ });
+
+ it('should sort tag data by tag name in descending order', () => {
+ const result = SearchUIUtils.getSortedSections(
+ CONST.SEARCH.DATA_TYPES.EXPENSE,
+ '',
+ transactionTagGroupListItems,
+ localeCompare,
+ translateLocal,
+ CONST.SEARCH.TABLE_COLUMNS.GROUP_TAG,
+ CONST.SEARCH.SORT_ORDER.DESC,
+ CONST.SEARCH.GROUP_BY.TAG,
+ ) as TransactionTagGroupListItemType[];
+
+ // "Project B" should come before "Project A" in descending alphabetical order
+ expect(result.at(0)?.tag).toBe(tagName2);
+ expect(result.at(1)?.tag).toBe(tagName1);
+ });
+
+ it('should sort tag data by total amount', () => {
+ const result = SearchUIUtils.getSortedSections(
+ CONST.SEARCH.DATA_TYPES.EXPENSE,
+ '',
+ transactionTagGroupListItems,
+ localeCompare,
+ translateLocal,
+ CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL,
+ CONST.SEARCH.SORT_ORDER.DESC,
+ CONST.SEARCH.GROUP_BY.TAG,
+ ) as TransactionTagGroupListItemType[];
+
+ // Project A (250) should come before Project B (75) when sorted by total descending
+ expect(result.at(0)?.total).toBe(250);
+ expect(result.at(1)?.total).toBe(75);
+ });
+
+ it('should sort "No tag" alphabetically with other tags (not at the top)', () => {
+ // Create raw search results data WITHOUT formattedTag -
+ // this is what comes from the backend. getSections will call getTagSections
+ // which populates formattedTag with the translated "No tag" text for empty tags.
+ const dataWithEmptyTag: OnyxTypes.SearchResults['data'] = {
+ personalDetailsList: {},
+ [`${CONST.SEARCH.GROUP_PREFIX}123456` as const]: {
+ tag: 'Zulu',
+ count: 2,
+ currency: 'USD',
+ total: 100,
+ },
+ [`${CONST.SEARCH.GROUP_PREFIX}789012` as const]: {
+ // Empty tag - should become "No tag" in formattedTag
+ tag: '',
+ count: 1,
+ currency: 'USD',
+ total: 50,
+ },
+ [`${CONST.SEARCH.GROUP_PREFIX}345678` as const]: {
+ tag: 'Alpha',
+ count: 3,
+ currency: 'USD',
+ total: 150,
+ },
+ };
+
+ // First, call getSections to process raw data through getTagSections.
+ // This is where formattedTag gets populated
+ const [sections] = SearchUIUtils.getSections({
+ type: CONST.SEARCH.DATA_TYPES.EXPENSE,
+ data: dataWithEmptyTag,
+ currentAccountID: 2074551,
+ currentUserEmail: '',
+ translate: translateLocal,
+ formatPhoneNumber,
+ bankAccountList: {},
+ groupBy: CONST.SEARCH.GROUP_BY.TAG,
+ }) as [TransactionTagGroupListItemType[], number];
+
+ // Then sort the sections
+ const result = SearchUIUtils.getSortedSections(
+ CONST.SEARCH.DATA_TYPES.EXPENSE,
+ '',
+ sections,
+ localeCompare,
+ translateLocal,
+ CONST.SEARCH.TABLE_COLUMNS.GROUP_TAG,
+ CONST.SEARCH.SORT_ORDER.ASC,
+ CONST.SEARCH.GROUP_BY.TAG,
+ ) as TransactionTagGroupListItemType[];
+
+ const emptyTagDisplayText = translateLocal('search.noTag');
+
+ // In ascending alphabetical order: Alpha < No tag < Zulu
+ // "No tag" should NOT be at the top (that was the bug with empty string sorting)
+ expect(result.at(0)?.formattedTag).toBe('Alpha');
+ expect(result.at(1)?.formattedTag).toBe(emptyTagDisplayText);
+ expect(result.at(2)?.formattedTag).toBe('Zulu');
+ });
});
describe('Test createTypeMenuItems', () => {
@@ -3795,4 +4264,216 @@ describe('SearchUIUtils', () => {
expect(transactionThread).toBeTruthy();
});
});
+
+ describe('getToFieldValueForTransaction', () => {
+ const mockTransaction: OnyxTypes.Transaction = {
+ transactionID: '1',
+ amount: 1000,
+ currency: 'USD',
+ reportID,
+ accountID: adminAccountID,
+ created: '2024-12-21 13:05:20',
+ merchant: 'Test Merchant',
+ } as OnyxTypes.Transaction;
+
+ const mockPersonalDetails: OnyxTypes.PersonalDetailsList = {
+ [adminAccountID]: {
+ accountID: adminAccountID,
+ displayName: 'Admin User',
+ login: adminEmail,
+ avatar: 'https://example.com/avatar.png',
+ },
+ [receiverAccountID]: {
+ accountID: receiverAccountID,
+ displayName: 'Receiver User',
+ login: receiverEmail,
+ avatar: 'https://example.com/avatar2.png',
+ },
+ };
+
+ test('Should return emptyPersonalDetails when report is undefined', () => {
+ const result = SearchUIUtils.getToFieldValueForTransaction(mockTransaction, undefined, mockPersonalDetails, undefined);
+ expect(result).toEqual(emptyPersonalDetails);
+ });
+
+ test('Should return emptyPersonalDetails when report is an open expense report', () => {
+ const openExpenseReport: OnyxTypes.Report = {
+ ...report1,
+ stateNum: CONST.REPORT.STATE_NUM.OPEN,
+ statusNum: CONST.REPORT.STATUS_NUM.OPEN,
+ } as OnyxTypes.Report;
+
+ const result = SearchUIUtils.getToFieldValueForTransaction(mockTransaction, openExpenseReport, mockPersonalDetails, undefined);
+ expect(result).toEqual(emptyPersonalDetails);
+ });
+
+ test('Should return ownerAccountID personal details when reportAction is PAY type and report has ownerAccountID', () => {
+ const payReportAction: OnyxTypes.ReportAction = {
+ ...reportAction1,
+ originalMessage: {
+ type: CONST.IOU.REPORT_ACTION_TYPE.PAY,
+ IOUTransactionID: mockTransaction.transactionID,
+ IOUReportID: report1.reportID,
+ },
+ } as OnyxTypes.ReportAction;
+
+ const nonOpenReport: OnyxTypes.Report = {
+ ...report1,
+ stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
+ statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
+ ownerAccountID: adminAccountID,
+ } as OnyxTypes.Report;
+
+ const result = SearchUIUtils.getToFieldValueForTransaction(mockTransaction, nonOpenReport, mockPersonalDetails, payReportAction);
+ expect(result).toEqual(mockPersonalDetails[adminAccountID]);
+ });
+
+ test('Should return managerID personal details when reportAction is not a money request action', () => {
+ const nonMoneyRequestAction: OnyxTypes.ReportAction = {
+ ...reportAction1,
+ actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
+ originalMessage: undefined,
+ } as OnyxTypes.ReportAction;
+
+ const nonOpenReport: OnyxTypes.Report = {
+ ...report1,
+ stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
+ statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
+ managerID: receiverAccountID,
+ } as OnyxTypes.Report;
+
+ const result = SearchUIUtils.getToFieldValueForTransaction(mockTransaction, nonOpenReport, mockPersonalDetails, nonMoneyRequestAction);
+ expect(result).toEqual(mockPersonalDetails[receiverAccountID]);
+ });
+
+ test('Should return getIOUPayerAndReceiver result for IOU report with managerID', () => {
+ const iouReport: OnyxTypes.Report = {
+ ...report3,
+ managerID: receiverAccountID,
+ ownerAccountID: adminAccountID,
+ type: CONST.REPORT.TYPE.IOU,
+ } as OnyxTypes.Report;
+
+ const transactionWithNegativeAmount: OnyxTypes.Transaction = {
+ ...mockTransaction,
+ amount: -1000,
+ modifiedAmount: 1000,
+ } as OnyxTypes.Transaction;
+
+ const result = SearchUIUtils.getToFieldValueForTransaction(transactionWithNegativeAmount, iouReport, mockPersonalDetails, undefined);
+ expect(result).toEqual(mockPersonalDetails[receiverAccountID]);
+ });
+
+ test('Should return getIOUPayerAndReceiver result for IOU report with positive amount', () => {
+ const iouReport: OnyxTypes.Report = {
+ ...report3,
+ managerID: receiverAccountID,
+ ownerAccountID: adminAccountID,
+ type: CONST.REPORT.TYPE.IOU,
+ } as OnyxTypes.Report;
+
+ const transactionWithPositiveAmount: OnyxTypes.Transaction = {
+ ...mockTransaction,
+ amount: 1000,
+ } as OnyxTypes.Transaction;
+
+ const result = SearchUIUtils.getToFieldValueForTransaction(transactionWithPositiveAmount, iouReport, mockPersonalDetails, undefined);
+ expect(result).toEqual(mockPersonalDetails[receiverAccountID]);
+ });
+
+ test('Should use modifiedAmount when available for IOU report', () => {
+ const iouReport: OnyxTypes.Report = {
+ ...report3,
+ managerID: receiverAccountID,
+ ownerAccountID: adminAccountID,
+ type: CONST.REPORT.TYPE.IOU,
+ } as OnyxTypes.Report;
+
+ const transactionWithModifiedAmount: OnyxTypes.Transaction = {
+ ...mockTransaction,
+ amount: 1000,
+ modifiedAmount: -2000,
+ } as OnyxTypes.Transaction;
+
+ const result = SearchUIUtils.getToFieldValueForTransaction(transactionWithModifiedAmount, iouReport, mockPersonalDetails, undefined);
+ expect(result).toEqual(mockPersonalDetails[adminAccountID]);
+ });
+
+ test('Should return managerID personal details for non-IOU report with managerID', () => {
+ const nonIOUReport: OnyxTypes.Report = {
+ ...report1,
+ stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
+ statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
+ managerID: receiverAccountID,
+ type: CONST.REPORT.TYPE.EXPENSE,
+ } as OnyxTypes.Report;
+
+ const result = SearchUIUtils.getToFieldValueForTransaction(mockTransaction, nonIOUReport, mockPersonalDetails, undefined);
+ expect(result).toEqual(mockPersonalDetails[receiverAccountID]);
+ });
+
+ test('Should return emptyPersonalDetails when managerID personal details are not found', () => {
+ const nonIOUReport: OnyxTypes.Report = {
+ ...report1,
+ stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
+ statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
+ managerID: 999999,
+ type: CONST.REPORT.TYPE.EXPENSE,
+ } as OnyxTypes.Report;
+
+ const result = SearchUIUtils.getToFieldValueForTransaction(mockTransaction, nonIOUReport, mockPersonalDetails, undefined);
+ expect(result).toEqual(emptyPersonalDetails);
+ });
+
+ test('Should return emptyPersonalDetails when report has no managerID', () => {
+ const reportWithoutManager: OnyxTypes.Report = {
+ ...report1,
+ stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
+ statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
+ managerID: undefined,
+ type: CONST.REPORT.TYPE.EXPENSE,
+ } as OnyxTypes.Report;
+
+ const result = SearchUIUtils.getToFieldValueForTransaction(mockTransaction, reportWithoutManager, mockPersonalDetails, undefined);
+ expect(result).toEqual(emptyPersonalDetails);
+ });
+
+ test('Should return emptyPersonalDetails when getIOUPayerAndReceiver returns undefined for IOU report', () => {
+ const iouReport: OnyxTypes.Report = {
+ ...report3,
+ managerID: receiverAccountID,
+ ownerAccountID: adminAccountID,
+ type: CONST.REPORT.TYPE.IOU,
+ } as OnyxTypes.Report;
+
+ const emptyPersonalDetailsList: OnyxTypes.PersonalDetailsList = {};
+
+ const result = SearchUIUtils.getToFieldValueForTransaction(mockTransaction, iouReport, emptyPersonalDetailsList, undefined);
+ expect(result).toEqual(emptyPersonalDetails);
+ });
+
+ test('Should handle IOU report with DEFAULT_NUMBER_ID for managerID', () => {
+ const iouReport: OnyxTypes.Report = {
+ ...report3,
+ managerID: CONST.DEFAULT_NUMBER_ID,
+ ownerAccountID: adminAccountID,
+ type: CONST.REPORT.TYPE.IOU,
+ } as OnyxTypes.Report;
+
+ const result = SearchUIUtils.getToFieldValueForTransaction(mockTransaction, iouReport, mockPersonalDetails, undefined);
+ expect(result).toBeDefined();
+ });
+
+ test('Should handle IOU report with DEFAULT_NUMBER_ID for ownerAccountID', () => {
+ const iouReport: OnyxTypes.Report = {
+ ...report3,
+ managerID: receiverAccountID,
+ ownerAccountID: CONST.DEFAULT_NUMBER_ID,
+ type: CONST.REPORT.TYPE.IOU,
+ } as OnyxTypes.Report;
+
+ const result = SearchUIUtils.getToFieldValueForTransaction(mockTransaction, iouReport, mockPersonalDetails, undefined);
+ expect(result).toBeDefined();
+ });
+ });
});
diff --git a/tests/unit/TelemetrySynchronizerTest.ts b/tests/unit/TelemetrySynchronizerTest.ts
new file mode 100644
index 000000000000..22e0744eb8ae
--- /dev/null
+++ b/tests/unit/TelemetrySynchronizerTest.ts
@@ -0,0 +1,455 @@
+import * as Sentry from '@sentry/react-native';
+import Onyx from 'react-native-onyx';
+import {getActivePolicies} from '@libs/PolicyUtils';
+import '@libs/telemetry/TelemetrySynchronizer';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Policy, Session, TryNewDot} from '@src/types/onyx';
+import createRandomPolicy from '../utils/collections/policies';
+import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct';
+
+jest.mock('@sentry/react-native', () => ({
+ setTag: jest.fn(),
+ setContext: jest.fn(),
+}));
+
+jest.mock('@libs/PolicyUtils', () => ({
+ getActivePolicies: jest.fn(),
+}));
+
+jest.mock('@libs/telemetry/sendMemoryContext', () => ({
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ __esModule: true,
+ default: jest.fn(),
+}));
+
+Onyx.init({keys: ONYXKEYS});
+
+describe('TelemetrySynchronizer', () => {
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ await Onyx.clear();
+ await waitForBatchedUpdatesWithAct();
+ });
+
+ afterEach(async () => {
+ await Onyx.clear();
+ await waitForBatchedUpdatesWithAct();
+ });
+
+ describe('sendPoliciesContext', () => {
+ const mockSession: Session = {
+ email: 'test@example.com',
+ accountID: 1,
+ };
+
+ const mockActivePolicyID = '123';
+
+ const mockPolicies: Record = {
+ [`${ONYXKEYS.COLLECTION.POLICY}123`]: createRandomPolicy(123),
+ [`${ONYXKEYS.COLLECTION.POLICY}456`]: createRandomPolicy(456),
+ };
+
+ const mockActivePolicies = [mockPolicies[`${ONYXKEYS.COLLECTION.POLICY}123`], mockPolicies[`${ONYXKEYS.COLLECTION.POLICY}456`]];
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (getActivePolicies as jest.Mock).mockReturnValue(mockActivePolicies);
+ });
+
+ it('should call Sentry.setTag and Sentry.setContext when all required data is available', async () => {
+ await Onyx.multiSet({
+ [ONYXKEYS.SESSION]: mockSession,
+ [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: mockActivePolicyID,
+ [ONYXKEYS.COLLECTION.POLICY]: mockPolicies,
+ });
+
+ await waitForBatchedUpdatesWithAct();
+
+ expect(Sentry.setTag).toHaveBeenCalledWith(CONST.TELEMETRY.TAG_ACTIVE_POLICY, mockActivePolicyID);
+ expect(Sentry.setContext).toHaveBeenCalledWith(CONST.TELEMETRY.CONTEXT_POLICIES, {
+ activePolicyID: mockActivePolicyID,
+ activePolicies: expect.arrayContaining(['123', '456']),
+ });
+ expect(getActivePolicies).toHaveBeenCalled();
+ });
+
+ it('should not call Sentry methods when policies are missing', async () => {
+ await Onyx.multiSet({
+ [ONYXKEYS.SESSION]: mockSession,
+ [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: mockActivePolicyID,
+ [ONYXKEYS.COLLECTION.POLICY]: null,
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ jest.clearAllMocks();
+
+ expect(Sentry.setTag).toHaveBeenCalledTimes(0);
+ expect(Sentry.setContext).toHaveBeenCalledTimes(0);
+ });
+
+ it('should not call Sentry methods when session.email is missing', async () => {
+ const sessionWithoutEmail: Session = {
+ accountID: 1,
+ } as Session;
+
+ await Onyx.multiSet({
+ [ONYXKEYS.SESSION]: sessionWithoutEmail,
+ [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: mockActivePolicyID,
+ [ONYXKEYS.COLLECTION.POLICY]: mockPolicies,
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ jest.clearAllMocks();
+
+ expect(Sentry.setTag).toHaveBeenCalledTimes(0);
+ expect(Sentry.setContext).toHaveBeenCalledTimes(0);
+ });
+
+ it('should not call Sentry methods when activePolicyID is missing', async () => {
+ await Onyx.multiSet({
+ [ONYXKEYS.SESSION]: mockSession,
+ [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: null,
+ [ONYXKEYS.COLLECTION.POLICY]: mockPolicies,
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ jest.clearAllMocks();
+
+ expect(Sentry.setTag).toHaveBeenCalledTimes(0);
+ expect(Sentry.setContext).toHaveBeenCalledTimes(0);
+ });
+
+ it('should correctly map active policies using getActivePolicies', async () => {
+ const customActivePolicies = [createRandomPolicy(999)];
+ (getActivePolicies as jest.Mock).mockReturnValue(customActivePolicies);
+
+ await Onyx.multiSet({
+ [ONYXKEYS.SESSION]: mockSession,
+ [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: '999',
+ [ONYXKEYS.COLLECTION.POLICY]: {
+ [`${ONYXKEYS.COLLECTION.POLICY}999`]: createRandomPolicy(999),
+ },
+ });
+
+ await waitForBatchedUpdatesWithAct();
+
+ expect(getActivePolicies).toHaveBeenCalled();
+ expect(Sentry.setContext).toHaveBeenCalledWith(
+ CONST.TELEMETRY.CONTEXT_POLICIES,
+ expect.objectContaining({
+ activePolicies: ['999'],
+ }),
+ );
+ });
+
+ it('should include both activePolicyID and activePolicies array in context', async () => {
+ await Onyx.multiSet({
+ [ONYXKEYS.SESSION]: mockSession,
+ [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: mockActivePolicyID,
+ [ONYXKEYS.COLLECTION.POLICY]: mockPolicies,
+ });
+
+ await waitForBatchedUpdatesWithAct();
+
+ expect(Sentry.setContext).toHaveBeenCalledWith(CONST.TELEMETRY.CONTEXT_POLICIES, {
+ activePolicyID: mockActivePolicyID,
+ activePolicies: expect.arrayContaining([expect.any(String)]),
+ });
+ });
+ });
+
+ describe('sendTryNewDotCohortTag', () => {
+ it('should call Sentry.setTag when cohort exists', async () => {
+ const mockTryNewDot: TryNewDot = {
+ nudgeMigration: {
+ timestamp: new Date(),
+ cohort: 'cohort_A',
+ },
+ };
+
+ await Onyx.set(ONYXKEYS.NVP_TRY_NEW_DOT, mockTryNewDot);
+ await waitForBatchedUpdatesWithAct();
+
+ expect(Sentry.setTag).toHaveBeenCalledWith(CONST.TELEMETRY.TAG_NUDGE_MIGRATION_COHORT, 'cohort_A');
+ });
+
+ it('should not call Sentry.setTag when cohort is missing', async () => {
+ const mockTryNewDot: TryNewDot = {
+ nudgeMigration: {
+ timestamp: new Date(),
+ },
+ };
+
+ await Onyx.set(ONYXKEYS.NVP_TRY_NEW_DOT, mockTryNewDot);
+ await waitForBatchedUpdatesWithAct();
+
+ expect(Sentry.setTag).not.toHaveBeenCalled();
+ });
+
+ it('should not call Sentry.setTag when tryNewDot is null', async () => {
+ await Onyx.set(ONYXKEYS.NVP_TRY_NEW_DOT, null);
+ await waitForBatchedUpdatesWithAct();
+
+ expect(Sentry.setTag).not.toHaveBeenCalled();
+ });
+
+ it('should not call Sentry.setTag when nudgeMigration is missing', async () => {
+ const mockTryNewDot: TryNewDot = {};
+
+ await Onyx.set(ONYXKEYS.NVP_TRY_NEW_DOT, mockTryNewDot);
+ await waitForBatchedUpdatesWithAct();
+
+ expect(Sentry.setTag).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Onyx callbacks', () => {
+ describe('NVP_ACTIVE_POLICY_ID callback', () => {
+ it('should call sendPoliciesContext when value is set', async () => {
+ const mockSession: Session = {
+ email: 'test@example.com',
+ accountID: 1,
+ };
+ const mockPolicies: Record = {
+ [`${ONYXKEYS.COLLECTION.POLICY}123`]: createRandomPolicy(123),
+ };
+ (getActivePolicies as jest.Mock).mockReturnValue([mockPolicies[`${ONYXKEYS.COLLECTION.POLICY}123`]]);
+
+ await Onyx.multiSet({
+ [ONYXKEYS.SESSION]: mockSession,
+ [ONYXKEYS.COLLECTION.POLICY]: mockPolicies,
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ jest.clearAllMocks();
+
+ await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, 'policy123');
+ await waitForBatchedUpdatesWithAct();
+
+ expect(Sentry.setTag).toHaveBeenCalledWith(CONST.TELEMETRY.TAG_ACTIVE_POLICY, 'policy123');
+ expect(Sentry.setContext).toHaveBeenCalled();
+ });
+
+ it('should not call sendPoliciesContext when value is null', async () => {
+ const mockSession: Session = {
+ email: 'test@example.com',
+ accountID: 1,
+ };
+ const mockPolicies: Record = {
+ [`${ONYXKEYS.COLLECTION.POLICY}123`]: createRandomPolicy(123),
+ };
+ (getActivePolicies as jest.Mock).mockReturnValue([mockPolicies[`${ONYXKEYS.COLLECTION.POLICY}123`]]);
+
+ await Onyx.multiSet({
+ [ONYXKEYS.SESSION]: mockSession,
+ [ONYXKEYS.COLLECTION.POLICY]: mockPolicies,
+ [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: 'policy123',
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ jest.clearAllMocks();
+
+ await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, null);
+ await waitForBatchedUpdatesWithAct();
+
+ expect(Sentry.setTag).not.toHaveBeenCalled();
+ expect(Sentry.setContext).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('SESSION callback', () => {
+ it('should call sendPoliciesContext when session with email is set', async () => {
+ const mockSession: Session = {
+ email: 'test@example.com',
+ accountID: 1,
+ };
+ const mockPolicies: Record = {
+ [`${ONYXKEYS.COLLECTION.POLICY}123`]: createRandomPolicy(123),
+ };
+ (getActivePolicies as jest.Mock).mockReturnValue([mockPolicies[`${ONYXKEYS.COLLECTION.POLICY}123`]]);
+
+ await Onyx.multiSet({
+ [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: 'policy123',
+ [ONYXKEYS.COLLECTION.POLICY]: mockPolicies,
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ jest.clearAllMocks();
+
+ await Onyx.set(ONYXKEYS.SESSION, mockSession);
+ await waitForBatchedUpdatesWithAct();
+
+ expect(Sentry.setTag).toHaveBeenCalledWith(CONST.TELEMETRY.TAG_ACTIVE_POLICY, 'policy123');
+ expect(Sentry.setContext).toHaveBeenCalled();
+ });
+
+ it('should not call sendPoliciesContext when session.email is missing', async () => {
+ const sessionWithoutEmail: Session = {
+ accountID: 1,
+ } as Session;
+ const mockPolicies: Record = {
+ [`${ONYXKEYS.COLLECTION.POLICY}123`]: createRandomPolicy(123),
+ };
+
+ await Onyx.multiSet({
+ [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: 'policy123',
+ [ONYXKEYS.COLLECTION.POLICY]: mockPolicies,
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ jest.clearAllMocks();
+
+ await Onyx.set(ONYXKEYS.SESSION, sessionWithoutEmail);
+ await waitForBatchedUpdatesWithAct();
+
+ expect(Sentry.setTag).not.toHaveBeenCalled();
+ expect(Sentry.setContext).not.toHaveBeenCalled();
+ });
+
+ it('should not call sendPoliciesContext when session is null', async () => {
+ const mockPolicies: Record = {
+ [`${ONYXKEYS.COLLECTION.POLICY}123`]: createRandomPolicy(123),
+ };
+
+ await Onyx.multiSet({
+ [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: 'policy123',
+ [ONYXKEYS.COLLECTION.POLICY]: mockPolicies,
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ jest.clearAllMocks();
+
+ await Onyx.set(ONYXKEYS.SESSION, null);
+ await waitForBatchedUpdatesWithAct();
+
+ expect(Sentry.setTag).not.toHaveBeenCalled();
+ expect(Sentry.setContext).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('COLLECTION.POLICY callback', () => {
+ it('should call sendPoliciesContext when policies collection is set', async () => {
+ const mockSession: Session = {
+ email: 'test@example.com',
+ accountID: 1,
+ };
+ const mockPolicies: Record = {
+ [`${ONYXKEYS.COLLECTION.POLICY}123`]: createRandomPolicy(123),
+ };
+ (getActivePolicies as jest.Mock).mockReturnValue([mockPolicies[`${ONYXKEYS.COLLECTION.POLICY}123`]]);
+
+ await Onyx.multiSet({
+ [ONYXKEYS.SESSION]: mockSession,
+ [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: 'policy123',
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ jest.clearAllMocks();
+
+ await Onyx.set(ONYXKEYS.COLLECTION.POLICY, mockPolicies);
+ await waitForBatchedUpdatesWithAct();
+
+ expect(Sentry.setTag).toHaveBeenCalledWith(CONST.TELEMETRY.TAG_ACTIVE_POLICY, 'policy123');
+ expect(Sentry.setContext).toHaveBeenCalled();
+ });
+
+ it('should not call sendPoliciesContext when policies is null', async () => {
+ const mockSession: Session = {
+ email: 'test@example.com',
+ accountID: 1,
+ };
+
+ await Onyx.multiSet({
+ [ONYXKEYS.SESSION]: mockSession,
+ [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: 'policy123',
+ });
+ await waitForBatchedUpdatesWithAct();
+
+ jest.clearAllMocks();
+
+ await Onyx.set(ONYXKEYS.COLLECTION.POLICY, null);
+ await waitForBatchedUpdatesWithAct();
+
+ expect(Sentry.setTag).not.toHaveBeenCalled();
+ expect(Sentry.setContext).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('NVP_TRY_NEW_DOT callback', () => {
+ it('should call sendTryNewDotCohortTag when value is set', async () => {
+ const mockTryNewDot: TryNewDot = {
+ nudgeMigration: {
+ timestamp: new Date(),
+ cohort: 'cohort_B',
+ },
+ };
+
+ await Onyx.set(ONYXKEYS.NVP_TRY_NEW_DOT, mockTryNewDot);
+ await waitForBatchedUpdatesWithAct();
+
+ expect(Sentry.setTag).toHaveBeenCalledWith(CONST.TELEMETRY.TAG_NUDGE_MIGRATION_COHORT, 'cohort_B');
+ });
+ });
+ });
+
+ describe('Integration tests', () => {
+ it('should call sendPoliciesContext with correct data when all required Onyx keys are set', async () => {
+ const mockSession: Session = {
+ email: 'test@example.com',
+ accountID: 1,
+ };
+ const mockActivePolicyID = '789';
+ const mockPolicies: Record = {
+ [`${ONYXKEYS.COLLECTION.POLICY}789`]: createRandomPolicy(789),
+ [`${ONYXKEYS.COLLECTION.POLICY}101`]: createRandomPolicy(101),
+ };
+ const mockActivePolicies = [mockPolicies[`${ONYXKEYS.COLLECTION.POLICY}789`]];
+ (getActivePolicies as jest.Mock).mockReturnValue(mockActivePolicies);
+
+ await Onyx.set(ONYXKEYS.SESSION, mockSession);
+ await waitForBatchedUpdatesWithAct();
+
+ await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, mockActivePolicyID);
+ await waitForBatchedUpdatesWithAct();
+
+ await Onyx.set(ONYXKEYS.COLLECTION.POLICY, mockPolicies);
+ await waitForBatchedUpdatesWithAct();
+
+ expect(Sentry.setTag).toHaveBeenCalledWith(CONST.TELEMETRY.TAG_ACTIVE_POLICY, mockActivePolicyID);
+ expect(Sentry.setContext).toHaveBeenCalledWith(CONST.TELEMETRY.CONTEXT_POLICIES, {
+ activePolicyID: mockActivePolicyID,
+ activePolicies: ['789'],
+ });
+ expect(getActivePolicies).toHaveBeenCalled();
+ });
+
+ it('should verify Sentry methods are called with correct CONST values', async () => {
+ const mockSession: Session = {
+ email: 'test@example.com',
+ accountID: 1,
+ };
+ const mockPolicies: Record = {
+ [`${ONYXKEYS.COLLECTION.POLICY}123`]: createRandomPolicy(123),
+ };
+ (getActivePolicies as jest.Mock).mockReturnValue([mockPolicies[`${ONYXKEYS.COLLECTION.POLICY}123`]]);
+
+ await Onyx.multiSet({
+ [ONYXKEYS.SESSION]: mockSession,
+ [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: '123',
+ [ONYXKEYS.COLLECTION.POLICY]: mockPolicies,
+ });
+
+ await waitForBatchedUpdatesWithAct();
+
+ expect(Sentry.setTag).toHaveBeenCalledWith(CONST.TELEMETRY.TAG_ACTIVE_POLICY, '123');
+ expect(Sentry.setContext).toHaveBeenCalledWith(
+ CONST.TELEMETRY.CONTEXT_POLICIES,
+ expect.objectContaining({
+ activePolicyID: '123',
+ activePolicies: expect.any(Array) as unknown as string[],
+ }),
+ );
+ });
+ });
+});
diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts
index 20fb3951d585..f629ed1fdb90 100644
--- a/tests/unit/TransactionUtilsTest.ts
+++ b/tests/unit/TransactionUtilsTest.ts
@@ -1713,71 +1713,4 @@ describe('TransactionUtils', () => {
expect(TransactionUtils.getConvertedAmount(transaction, true, false, false, true)).toBe(-100);
});
});
-
- describe('isExpenseSplit', () => {
- it('should return false when transaction is assigned to a real report', () => {
- const transaction = generateTransaction({
- reportID: '12345',
- comment: {
- source: CONST.IOU.TYPE.SPLIT,
- originalTransactionID: 'original123',
- },
- });
- expect(TransactionUtils.isExpenseSplit(transaction)).toBe(false);
- });
-
- it('should return true when transaction is in split report and has split comment', () => {
- const transaction = generateTransaction({
- reportID: CONST.REPORT.SPLIT_REPORT_ID,
- comment: {
- source: CONST.IOU.TYPE.SPLIT,
- originalTransactionID: 'original123',
- },
- });
- expect(TransactionUtils.isExpenseSplit(transaction)).toBe(true);
- });
-
- it('should return true when transaction is unreported and has split comment', () => {
- const transaction = generateTransaction({
- reportID: CONST.REPORT.UNREPORTED_REPORT_ID,
- comment: {
- source: CONST.IOU.TYPE.SPLIT,
- originalTransactionID: 'original123',
- },
- });
- expect(TransactionUtils.isExpenseSplit(transaction)).toBe(true);
- });
-
- it('should return true when transaction has no reportID and has split comment', () => {
- const transaction = generateTransaction({
- reportID: undefined,
- comment: {
- source: CONST.IOU.TYPE.SPLIT,
- originalTransactionID: 'original123',
- },
- });
- expect(TransactionUtils.isExpenseSplit(transaction)).toBe(true);
- });
-
- it('should return false when transaction comment source is not split', () => {
- const transaction = generateTransaction({
- reportID: CONST.REPORT.SPLIT_REPORT_ID,
- comment: {
- source: CONST.IOU.TYPE.REQUEST,
- originalTransactionID: 'original123',
- },
- });
- expect(TransactionUtils.isExpenseSplit(transaction)).toBe(false);
- });
-
- it('should return false when transaction comment is missing originalTransactionID', () => {
- const transaction = generateTransaction({
- reportID: CONST.REPORT.SPLIT_REPORT_ID,
- comment: {
- source: CONST.IOU.TYPE.SPLIT,
- },
- });
- expect(TransactionUtils.isExpenseSplit(transaction)).toBe(false);
- });
- });
});
diff --git a/tests/unit/hooks/useReportWasDeleted.test.ts b/tests/unit/hooks/useReportWasDeleted.test.ts
index e13477b55894..cfa2246649fc 100644
--- a/tests/unit/hooks/useReportWasDeleted.test.ts
+++ b/tests/unit/hooks/useReportWasDeleted.test.ts
@@ -1,5 +1,5 @@
import {renderHook} from '@testing-library/react-native';
-import useReportWasDeleted from '@src/pages/home/hooks/useReportWasDeleted';
+import useReportWasDeleted from '@pages/inbox/hooks/useReportWasDeleted';
import type {Report} from '@src/types/onyx';
describe('useReportWasDeleted', () => {
diff --git a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts
index d696bbf9825c..68ce4f0609ae 100644
--- a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts
+++ b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts
@@ -1,7 +1,7 @@
import {act, renderHook} from '@testing-library/react-native';
import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native';
import type Navigation from '@libs/Navigation/Navigation';
-import useReportUnreadMessageScrollTracking from '@pages/home/report/useReportUnreadMessageScrollTracking';
+import useReportUnreadMessageScrollTracking from '@pages/inbox/report/useReportUnreadMessageScrollTracking';
import {readNewestAction} from '@userActions/Report';
import CONST from '@src/CONST';
diff --git a/tests/utils/LHNTestUtils.tsx b/tests/utils/LHNTestUtils.tsx
index 32699860caad..fa1dcb47e1b1 100644
--- a/tests/utils/LHNTestUtils.tsx
+++ b/tests/utils/LHNTestUtils.tsx
@@ -11,8 +11,8 @@ import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID';
import {SidebarOrderedReportsContextProvider} from '@hooks/useSidebarOrderedReports';
import DateUtils from '@libs/DateUtils';
import {buildParticipantsFromAccountIDs} from '@libs/ReportUtils';
-import ReportActionItemSingle from '@pages/home/report/ReportActionItemSingle';
-import SidebarLinksData from '@pages/home/sidebar/SidebarLinksData';
+import ReportActionItemSingle from '@pages/inbox/report/ReportActionItemSingle';
+import SidebarLinksData from '@pages/inbox/sidebar/SidebarLinksData';
import CONST from '@src/CONST';
import type {PersonalDetailsList, Policy, Report, ReportAction, TransactionViolation, ViolationName} from '@src/types/onyx';
import type ReportActionName from '@src/types/onyx/ReportActionName';
diff --git a/tests/utils/TestNavigationContainer.tsx b/tests/utils/TestNavigationContainer.tsx
index 007b8791dbd5..254598882038 100644
--- a/tests/utils/TestNavigationContainer.tsx
+++ b/tests/utils/TestNavigationContainer.tsx
@@ -66,12 +66,12 @@ function TestWorkspaceSplitNavigator() {
function TestReportsSplitNavigator() {
return (