From cc33a7ccf7e371e16b2efba2d959ef06ac346dc4 Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:21:59 +0530 Subject: [PATCH 1/3] fix: publish validated cleanup result --- tests/actions/ReportTest.ts | 296 ++++++++++++++++++++---------------- 1 file changed, 169 insertions(+), 127 deletions(-) diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 1a6370ff933d..821ce3d880fd 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -16,7 +16,7 @@ import HttpUtils from '@libs/HttpUtils'; import Navigation from '@libs/Navigation/Navigation'; import {buildNextStepNew} from '@libs/NextStepUtils'; import {getAccountIDsByLogins} from '@libs/PersonalDetailsUtils'; -import {getOriginalMessage, isDeletedAction} from '@libs/ReportActionsUtils'; +import {getOriginalMessage, isActionOfType, isDeletedAction} from '@libs/ReportActionsUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import {toggleEmojiReaction} from '@userActions/EmojiReactions'; @@ -36,7 +36,6 @@ import type * as SearchQueryUtilsType from '@src/libs/SearchQueryUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; -import type {Message} from '@src/types/onyx/ReportAction'; import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; @@ -198,7 +197,43 @@ const TEST_INTRO_SELECTED: OnyxTypes.IntroSelected = { isInviteOnboardingComplete: false, }; -const getMockFetch = (fetch: typeof global.fetch) => fetch as MockFetch; +const getTransactionProperty = (value: unknown, property: 'pendingAction' | 'convertedAmount'): unknown => { + if (typeof value !== 'object' || value === null) { + return undefined; + } + if (property === 'pendingAction' && 'pendingAction' in value) { + return value.pendingAction; + } + if (property === 'convertedAmount' && 'convertedAmount' in value) { + return value.convertedAmount; + } + return undefined; +}; + +type GuidedSetupItem = { + type: string; + task?: string; + completedTaskReportActionID?: string; +}; + +const parseGuidedSetupData = (value: string): GuidedSetupItem[] => { + const parsed: unknown = JSON.parse(value); + if ( + !Array.isArray(parsed) || + !parsed.every( + (item: unknown): item is GuidedSetupItem => + typeof item === 'object' && + item !== null && + 'type' in item && + typeof item.type === 'string' && + (!('task' in item) || typeof item.task === 'string') && + (!('completedTaskReportActionID' in item) || typeof item.completedTaskReportActionID === 'string'), + ) + ) { + throw new Error('Expected guided setup data to contain only valid items'); + } + return parsed; +}; type APIWriteSpy = jest.SpiedFunction; @@ -225,7 +260,7 @@ describe('actions/Report', () => { // Onyx.clear() promise is resolved in batch which happens after the current microtasks cycle setImmediate(jest.runOnlyPendingTimers); } - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); apiWriteSpy = jest.spyOn(API, 'write'); // Clear the queue before each test to avoid test pollution @@ -241,7 +276,7 @@ describe('actions/Report', () => { }); it('should store a new report action in Onyx when onyxApiUpdate event is handled via Pusher', () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const TEST_USER_ACCOUNT_ID = 1; const TEST_USER_LOGIN = 'test@test.com'; @@ -511,7 +546,7 @@ describe('actions/Report', () => { return TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN) .then(() => TestHelper.setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID)) .then(() => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); // WHEN we add enough logs to send a packet for (let i = 0; i <= LOGGER_MAX_LOG_LINES; i++) { @@ -538,14 +573,13 @@ describe('actions/Report', () => { }) .then(() => { // THEN only ONE call to AddComment will happen - const URL_ARGUMENT_INDEX = 0; - const addCommentCalls = (global.fetch as jest.Mock).mock.calls.filter((callArguments: string[]) => callArguments.at(URL_ARGUMENT_INDEX)?.includes('AddComment')); + const addCommentCalls = jest.mocked(global.fetch).mock.calls.filter(([input]) => typeof input === 'string' && input.includes('AddComment')); expect(addCommentCalls.length).toBe(1); }); }); it('should be updated correctly when new comments are added, deleted or marked as unread', () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const REPORT_ID = '1'; let report: OnyxEntry; let reportActionCreatedDate: string; @@ -750,7 +784,7 @@ describe('actions/Report', () => { reportActionCreatedDate = DateUtils.getDBTime(); - const optimisticReportActionsValue = optimisticReportActions.value as Record; + const optimisticReportActionsValue = optimisticReportActions.value; if (optimisticReportActionsValue?.[400]) { optimisticReportActionsValue[400].created = reportActionCreatedDate; @@ -819,7 +853,7 @@ describe('actions/Report', () => { * already in the comment and the user deleted it on purpose. */ - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const TEST_USER_LOGIN = 'test@expensify.com'; @@ -952,7 +986,7 @@ describe('actions/Report', () => { }); it('should properly toggle reactions on a message', () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const TEST_USER_ACCOUNT_ID = 1; const TEST_USER_LOGIN = 'test@test.com'; @@ -1093,7 +1127,7 @@ describe('actions/Report', () => { }); it("shouldn't add the same reaction twice when changing preferred skin color and reaction doesn't support skin colors", () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const TEST_USER_ACCOUNT_ID = 1; const TEST_USER_LOGIN = 'test@test.com'; @@ -1174,7 +1208,7 @@ describe('actions/Report', () => { }); it('should send only one OpenReport, replacing any extra ones with same reportIDs', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const REPORT_ID = '1'; @@ -1253,7 +1287,7 @@ describe('actions/Report', () => { }); it('openReport legacy preview fallback stores action under correct Onyx key and preserves existing actions', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const TEST_USER_ACCOUNT_ID = 1; const TEST_USER_LOGIN = 'test@user.com'; @@ -1341,7 +1375,7 @@ describe('actions/Report', () => { }); it('should replace duplicate OpenReport commands with the same reportID', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const REPORT_ID = '1'; @@ -1377,7 +1411,7 @@ describe('actions/Report', () => { }); it('should remove AddComment and UpdateComment without sending any request when DeleteComment is set', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const TEST_USER_ACCOUNT_ID = 1; const REPORT_ID = '1'; @@ -1400,7 +1434,7 @@ describe('actions/Report', () => { // Need the reportActionID to delete the comments const newComment = PersistedRequests.getAll().at(0); - const reportActionID = newComment?.data?.reportActionID as string | undefined; + const reportActionID = typeof newComment?.data?.reportActionID === 'string' ? newComment.data.reportActionID : undefined; const newReportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); const originalReport = { reportID: REPORT_ID, @@ -1466,7 +1500,7 @@ describe('actions/Report', () => { }); it('should remove AddComment and UpdateComment without sending any request when DeleteComment is set with currentUserEmail', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const TEST_USER_ACCOUNT_ID = 1; const REPORT_ID = '1'; const REPORT: OnyxTypes.Report = createRandomReport(1, undefined); @@ -1484,7 +1518,8 @@ describe('actions/Report', () => { conciergeReportID: undefined, }); - const reportActionID = PersistedRequests.getAll().at(0)?.data?.reportActionID as string | undefined; + const reportActionIDCandidate = PersistedRequests.getAll().at(0)?.data?.reportActionID; + const reportActionID = typeof reportActionIDCandidate === 'string' ? reportActionIDCandidate : undefined; const newReportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); const originalReport = {reportID: REPORT_ID}; const {result: ancestors, rerender} = renderHook(() => useAncestors(originalReport)); @@ -1510,7 +1545,7 @@ describe('actions/Report', () => { }); it('should send DeleteComment request and remove UpdateComment accordingly', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const TEST_USER_ACCOUNT_ID = 1; const REPORT_ID = '1'; @@ -1536,7 +1571,7 @@ describe('actions/Report', () => { // Need the reportActionID to delete the comments — read before the queue processes the request const newComment = PersistedRequests.getAll().at(1); - const reportActionID = newComment?.data?.reportActionID as string | undefined; + const reportActionID = typeof newComment?.data?.reportActionID === 'string' ? newComment.data.reportActionID : undefined; const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); // Let the queue process the online ADD_COMMENT request before going offline. @@ -1610,7 +1645,7 @@ describe('actions/Report', () => { expect(PersistedRequests.getAll().length).toBe(1); expect(PersistedRequests.getAll().at(0)?.isRollback).toBeTruthy(); const newComment = PersistedRequests.getAll().at(1); - const reportActionID = newComment?.data?.reportActionID as string | undefined; + const reportActionID = typeof newComment?.data?.reportActionID === 'string' ? newComment.data.reportActionID : undefined; const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); await waitForBatchedUpdates(); @@ -1631,7 +1666,7 @@ describe('actions/Report', () => { jest.runOnlyPendingTimers(); await waitForBatchedUpdates(); - const httpCalls = (HttpUtils.xhr as jest.Mock).mock.calls; + const httpCalls = jest.mocked(HttpUtils.xhr).mock.calls; const addCommentCalls = httpCalls.filter(([command]) => command === 'AddComment'); const deleteCommentCalls = httpCalls.filter(([command]) => command === 'DeleteComment'); @@ -1643,7 +1678,7 @@ describe('actions/Report', () => { }); it('should send not DeleteComment request and remove AddAttachment accordingly', async () => { - global.fetch = TestHelper.getGlobalFetchMock({ + global.fetch = TestHelper.createGlobalFetchMock({ headers: new Headers({ 'Content-Type': 'image/jpeg', }), @@ -1671,7 +1706,7 @@ describe('actions/Report', () => { // Need the reportActionID to delete the comments const newComment = PersistedRequests.getAll().at(0); - const reportActionID = newComment?.data?.reportActionID as string | undefined; + const reportActionID = typeof newComment?.data?.reportActionID === 'string' ? newComment.data.reportActionID : undefined; const newReportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); // wait for Onyx.connect execute the callback and start processing the queue @@ -1726,7 +1761,7 @@ describe('actions/Report', () => { }); it('should send not DeleteComment request and remove AddTextAndAttachment accordingly', async () => { - global.fetch = TestHelper.getGlobalFetchMock({ + global.fetch = TestHelper.createGlobalFetchMock({ headers: new Headers({ 'Content-Type': 'image/jpeg', }), @@ -1755,7 +1790,7 @@ describe('actions/Report', () => { // Need the reportActionID to delete the comments const newComment = PersistedRequests.getAll().at(0); - const reportActionID = newComment?.data?.reportActionID as string | undefined; + const reportActionID = typeof newComment?.data?.reportActionID === 'string' ? newComment.data.reportActionID : undefined; const newReportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); // wait for Onyx.connect execute the callback and start processing the queue @@ -1810,17 +1845,17 @@ describe('actions/Report', () => { }); it('should post text + attachment as first action then attachment only for remaining attachments when adding multiple attachments with a comment', async () => { - global.fetch = TestHelper.getGlobalFetchMock({ + global.fetch = TestHelper.createGlobalFetchMock({ headers: new Headers({ 'Content-Type': 'image/jpeg', }), }); - const playSoundMock = playSound as jest.MockedFunction; + const playSoundMock = jest.mocked(playSound); setHasRadio(false); await waitForBatchedUpdates(); await waitForBatchedUpdates(); - const relevantPromise = new Promise((resolve) => { + const relevantPromise = new Promise((resolve) => { const conn = Onyx.connect({ key: ONYXKEYS.PERSISTED_REQUESTS, callback: (persisted) => { @@ -1852,7 +1887,7 @@ describe('actions/Report', () => { delegateAccountID: undefined, conciergeReportID: undefined, }); - const relevant = (await relevantPromise) as OnyxTypes.AnyRequest[]; + const relevant = await relevantPromise; expect(playSoundMock).toHaveBeenCalledTimes(1); expect(playSoundMock).toHaveBeenCalledWith(SOUNDS.DONE); @@ -1861,17 +1896,17 @@ describe('actions/Report', () => { }); it('should create attachment only actions when adding multiple attachments without a comment', async () => { - global.fetch = TestHelper.getGlobalFetchMock({ + global.fetch = TestHelper.createGlobalFetchMock({ headers: new Headers({ 'Content-Type': 'image/jpeg', }), }); - const playSoundMock = playSound as jest.MockedFunction; + const playSoundMock = jest.mocked(playSound); setHasRadio(false); await waitForBatchedUpdates(); await waitForBatchedUpdates(); - const relevantPromise = new Promise((resolve) => { + const relevantPromise = new Promise((resolve) => { const conn = Onyx.connect({ key: ONYXKEYS.PERSISTED_REQUESTS, callback: (persisted) => { @@ -1901,7 +1936,7 @@ describe('actions/Report', () => { delegateAccountID: undefined, conciergeReportID: undefined, }); - const relevant = (await relevantPromise) as OnyxTypes.AnyRequest[]; + const relevant = await relevantPromise; expect(playSoundMock).toHaveBeenCalledTimes(1); expect(playSoundMock).toHaveBeenCalledWith(SOUNDS.DONE); @@ -1911,17 +1946,17 @@ describe('actions/Report', () => { }); it('should create attachment only action & not play sound when adding attachment without a comment & shouldPlaySound not passed', async () => { - global.fetch = TestHelper.getGlobalFetchMock({ + global.fetch = TestHelper.createGlobalFetchMock({ headers: new Headers({ 'Content-Type': 'image/jpeg', }), }); - const playSoundMock = playSound as jest.MockedFunction; + const playSoundMock = jest.mocked(playSound); setHasRadio(false); await waitForBatchedUpdates(); await waitForBatchedUpdates(); - const relevantPromise = new Promise((resolve) => { + const relevantPromise = new Promise((resolve) => { const conn = Onyx.connect({ key: ONYXKEYS.PERSISTED_REQUESTS, callback: (persisted) => { @@ -1947,14 +1982,14 @@ describe('actions/Report', () => { delegateAccountID: undefined, conciergeReportID: undefined, }); - const relevant = (await relevantPromise) as OnyxTypes.AnyRequest[]; + const relevant = await relevantPromise; expect(playSoundMock).toHaveBeenCalledTimes(0); expect(relevant.at(0)?.command).toBe(WRITE_COMMANDS.ADD_ATTACHMENT); }); it('should optimistically mark an unresolved ACTIONABLE_MENTION_WHISPER as deleted when its parent comment is deleted', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const REPORT_ID = '1'; const COMMENT_ACTION_ID = '1000'; @@ -2012,7 +2047,7 @@ describe('actions/Report', () => { }); it('should only delete the whisper linked to the deleted comment, not other whispers in the same report', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const REPORT_ID = '1'; // Two comments, each triggering a whisper at parentID + 1 @@ -2099,7 +2134,7 @@ describe('actions/Report', () => { }); it('should not send DeleteComment request and remove any Reactions accordingly', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); // eslint-disable-next-line @typescript-eslint/no-unsafe-return jest.doMock('@libs/EmojiUtils', () => ({ ...jest.requireActual('@libs/EmojiUtils'), @@ -2128,7 +2163,7 @@ describe('actions/Report', () => { // Need the reportActionID to delete the comments const newComment = PersistedRequests.getAll().at(0); - const reportActionID = newComment?.data?.reportActionID as string | undefined; + const reportActionID = typeof newComment?.data?.reportActionID === 'string' ? newComment.data.reportActionID : undefined; const newReportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); await waitForBatchedUpdates(); @@ -2212,7 +2247,7 @@ describe('actions/Report', () => { }); it('should send DeleteComment request and remove any Reactions accordingly', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); // eslint-disable-next-line @typescript-eslint/no-unsafe-return jest.doMock('@libs/EmojiUtils', () => ({ ...jest.requireActual('@libs/EmojiUtils'), @@ -2237,7 +2272,7 @@ describe('actions/Report', () => { // Need the reportActionID to delete the comments — read before the queue processes the request const newComment = PersistedRequests.getAll().at(0); - const reportActionID = newComment?.data?.reportActionID as string | undefined; + const reportActionID = typeof newComment?.data?.reportActionID === 'string' ? newComment.data.reportActionID : undefined; const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); // Let the queue process the online ADD_COMMENT request before going offline @@ -2302,7 +2337,7 @@ describe('actions/Report', () => { }); it('should create and delete thread processing all the requests', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const TEST_USER_ACCOUNT_ID = 1; const REPORT_ID = '1'; @@ -2326,7 +2361,7 @@ describe('actions/Report', () => { }); const newComment = PersistedRequests.getAll().at(0); - const reportActionID = newComment?.data?.reportActionID as string | undefined; + const reportActionID = typeof newComment?.data?.reportActionID === 'string' ? newComment.data.reportActionID : undefined; const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); Report.openReport({ @@ -2368,7 +2403,7 @@ describe('actions/Report', () => { }); it('should update AddComment text with the UpdateComment text, sending just an AddComment request', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const TEST_USER_ACCOUNT_ID = 1; const REPORT_ID = '1'; @@ -2390,7 +2425,7 @@ describe('actions/Report', () => { }); // Need the reportActionID to delete the comments const newComment = PersistedRequests.getAll().at(0); - const reportActionID = newComment?.data?.reportActionID as string | undefined; + const reportActionID = typeof newComment?.data?.reportActionID === 'string' ? newComment.data.reportActionID : undefined; const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); const originalReport = { reportID: REPORT_ID, @@ -2415,7 +2450,7 @@ describe('actions/Report', () => { }); it('it should only send the last sequential UpdateComment request to BE', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const reportID = '123'; setHasRadio(false); @@ -2450,7 +2485,7 @@ describe('actions/Report', () => { }); it('should convert short mentions to full format when editing comments', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const TEST_USER_LOGIN = 'alice@expensify.com'; const TEST_USER_ACCOUNT_ID = 1; @@ -2488,7 +2523,7 @@ describe('actions/Report', () => { // Get the reportActionID to edit and delete the comment const newComment = PersistedRequests.getAll().at(0); - const reportActionID = newComment?.data?.reportActionID as string | undefined; + const reportActionID = typeof newComment?.data?.reportActionID === 'string' ? newComment.data.reportActionID : undefined; const newReportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); const originalReport = { reportID: REPORT_ID, @@ -2525,7 +2560,7 @@ describe('actions/Report', () => { }); it('it should only send the last sequential UpdateComment request to BE with currentUserLogin', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); setHasRadio(false); await waitForBatchedUpdates(); @@ -2660,7 +2695,8 @@ describe('actions/Report', () => { it('should create new report and "create report" quick action, when createNewReport gets called', async () => { const accountID = 1234; const policyID = '5678'; - const mockFetchData = getMockFetch(fetch); + const mockFetchData = TestHelper.createGlobalFetchMock(); + global.fetch = mockFetchData; // Given a policy with harvesting is disabled const policy = { ...createRandomPolicy(Number(policyID)), @@ -2684,7 +2720,10 @@ describe('actions/Report', () => { callback: (reportActions) => { Onyx.disconnect(connection); const action = Object.values(reportActions ?? {}).at(0); - resolve(action as OnyxTypes.ReportAction); + if (!isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW)) { + throw new Error('Expected the optimistic report preview action'); + } + resolve(action); }, }); }); @@ -2736,7 +2775,8 @@ describe('actions/Report', () => { it('should set hasOnceLoadedReportActions for parent report metadata when creating a new report', async () => { const accountID = 1234; const policyID = '5678'; - const mockFetchData = getMockFetch(fetch); + const mockFetchData = TestHelper.createGlobalFetchMock(); + global.fetch = mockFetchData; const policy = { ...createRandomPolicy(Number(policyID)), isPolicyExpenseChatEnabled: true, @@ -2886,7 +2926,7 @@ describe('actions/Report', () => { describe('completeOnboarding', () => { const TEST_USER_LOGIN = 'test@gmail.com'; const TEST_USER_ACCOUNT_ID = 1; - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); it('should not write any optimistic actions to admins report for MANAGE_TEAM (server posts via inboxAdminsBespoke)', async () => { await Onyx.set(ONYXKEYS.SESSION, {email: TEST_USER_LOGIN, accountID: TEST_USER_ACCOUNT_ID}); @@ -2925,7 +2965,7 @@ describe('actions/Report', () => { it('should forward selectedInterestedFeatures to the CompleteGuidedSetup API call as a JSON-encoded array', async () => { await Onyx.set(ONYXKEYS.SESSION, {email: TEST_USER_LOGIN, accountID: TEST_USER_ACCOUNT_ID}); - (global.fetch as jest.Mock).mockClear(); + jest.mocked(global.fetch).mockClear(); await waitForBatchedUpdates(); const engagementChoice = CONST.INTRO_CHOICES.MANAGE_TEAM; @@ -2950,7 +2990,10 @@ describe('actions/Report', () => { expect(calls.length).toBeGreaterThan(0); const body = calls.at(-1)?.[1]?.body; expect(body).toBeInstanceOf(FormData); - const formEntries = Object.fromEntries(body as FormData); + if (!(body instanceof FormData)) { + throw new Error('Expected CompleteGuidedSetup request body to be FormData'); + } + const formEntries = Object.fromEntries(body); expect(formEntries.selectedInterestedFeatures).toBe(JSON.stringify(selectedInterestedFeatures)); }); @@ -3117,7 +3160,7 @@ describe('actions/Report', () => { describe('updateDescription', () => { const currentUserAccountID = 1; it('should not call UpdateRoomDescription API if the description is not changed', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const report: OnyxTypes.Report = { ...createRandomReport(1, undefined), description: '

test

', @@ -3134,7 +3177,8 @@ describe('actions/Report', () => { ...createRandomReport(1, undefined), description: '

test

', }; - const mockFetch = getMockFetch(fetch); + const mockFetch = TestHelper.createGlobalFetchMock(); + global.fetch = mockFetch; await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); @@ -3837,7 +3881,7 @@ describe('actions/Report', () => { const ownerAccountID = 999; const ownerEmail = 'submitter@test.com'; const adminEmail = 'admin@test.com'; - const mockFetch = getMockFetch(TestHelper.getGlobalFetchMock()); + const mockFetch = TestHelper.createGlobalFetchMock(); const expenseReport: OnyxTypes.Report = { ...createRandomReport(1, undefined), @@ -4108,7 +4152,7 @@ describe('actions/Report', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${targetPolicy.id}`, targetPolicy); await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[ownerAccountID]: {login: ownerEmail, accountID: ownerAccountID}}); - const mockFetch = getMockFetch(TestHelper.getGlobalFetchMock()); + const mockFetch = TestHelper.createGlobalFetchMock(); global.fetch = mockFetch; mockFetch.pause?.(); @@ -4288,11 +4332,9 @@ describe('actions/Report', () => { const result = Report.convertIOUReportToExpenseReport(iouReport, policyWithEmptyFieldList, policyID, 'expenseChat123', undefined, []); // Then the report name should be set to the default formula result ("New Report") - const reportUpdate = result.optimisticData.find((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`) as - | OnyxUpdate - | undefined; - const reportValue = reportUpdate?.value as Partial | undefined; - expect(reportValue?.reportName).toBe(CONST.REPORT.DEFAULT_EXPENSE_REPORT_NAME); + const reportUpdate = result.optimisticData.find((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`); + const reportName = reportUpdate?.value && typeof reportUpdate.value === 'object' && 'reportName' in reportUpdate.value ? reportUpdate.value.reportName : undefined; + expect(reportName).toBe(CONST.REPORT.DEFAULT_EXPENSE_REPORT_NAME); }); it('should set reportName to default formula when policy field list is empty for different report', () => { @@ -4322,11 +4364,9 @@ describe('actions/Report', () => { const result = Report.convertIOUReportToExpenseReport(iouReport, policyWithEmptyFieldList, policyID, 'expenseChat124', undefined, []); // Then the report name should be set to the default formula result ("New Report") - const reportUpdate = result.optimisticData.find((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`) as - | OnyxUpdate - | undefined; - const reportValue = reportUpdate?.value as Partial | undefined; - expect(reportValue?.reportName).toBe(CONST.REPORT.DEFAULT_EXPENSE_REPORT_NAME); + const reportUpdate = result.optimisticData.find((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`); + const reportName = reportUpdate?.value && typeof reportUpdate.value === 'object' && 'reportName' in reportUpdate.value ? reportUpdate.value.reportName : undefined; + expect(reportName).toBe(CONST.REPORT.DEFAULT_EXPENSE_REPORT_NAME); }); }); }); @@ -4372,7 +4412,7 @@ describe('actions/Report', () => { it('correctly implements RedBrickRoad error handling for MoveIOUReportToPolicyAndInviteSubmitter when the request fails to add a new user to workspace', async () => { const ownerAccountID = 999; const ownerEmail = 'submitter@test.com'; - const mockFetch = getMockFetch(TestHelper.getGlobalFetchMock()); + const mockFetch = TestHelper.createGlobalFetchMock(); const iouReport: OnyxTypes.Report = { ...createRandomReport(1, undefined), @@ -4546,8 +4586,7 @@ describe('actions/Report', () => { ...createRandomReport(1, undefined), type: CONST.REPORT.TYPE.IOU, }; - const result = Report.moveIOUReportToPolicyAndInviteSubmitter(iouReport, undefined as unknown as OnyxTypes.Policy, {}, undefined, TEST_USER_ACCOUNT_ID, '', false); - expect(result).toBeUndefined(); + expect(Reflect.apply(Report.moveIOUReportToPolicyAndInviteSubmitter, undefined, [iouReport, undefined, {}, undefined, TEST_USER_ACCOUNT_ID, '', false])).toBeUndefined(); }); it('should return undefined when iouReport is missing', () => { @@ -4640,16 +4679,16 @@ describe('actions/Report', () => { const transactionFailureData = failureData.find((data) => data.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); // Should have pendingAction set to UPDATE - expect((transactionOptimisticData?.value as OnyxTypes.Transaction)?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + expect(getTransactionProperty(transactionOptimisticData?.value, 'pendingAction')).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); // Should have convertedAmount cleared - expect((transactionOptimisticData?.value as OnyxTypes.Transaction)?.convertedAmount).toBeNull(); + expect(getTransactionProperty(transactionOptimisticData?.value, 'convertedAmount')).toBeNull(); // Success data should clear pendingAction - expect((transactionSuccessData?.value as OnyxTypes.Transaction)?.pendingAction).toBeNull(); + expect(getTransactionProperty(transactionSuccessData?.value, 'pendingAction')).toBeNull(); // Failure data should restore original values - expect((transactionFailureData?.value as OnyxTypes.Transaction)?.pendingAction).toBe(transaction.pendingAction ?? null); - expect((transactionFailureData?.value as OnyxTypes.Transaction)?.convertedAmount).toBe(transaction.convertedAmount); + expect(getTransactionProperty(transactionFailureData?.value, 'pendingAction')).toBe(transaction.pendingAction ?? null); + expect(getTransactionProperty(transactionFailureData?.value, 'convertedAmount')).toBe(transaction.convertedAmount); }); it('should NOT clear convertedAmount when source and destination currencies are the same', async () => { @@ -4811,8 +4850,8 @@ describe('actions/Report', () => { // Should find optimistic data for the non-matching transaction (AUD doesn't match USD destination) const nonMatchingOptimisticData = optimisticData.find((data) => data.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${nonMatchingTransactionID}`); expect(nonMatchingOptimisticData).toBeDefined(); - expect((nonMatchingOptimisticData?.value as OnyxTypes.Transaction)?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); - expect((nonMatchingOptimisticData?.value as OnyxTypes.Transaction)?.convertedAmount).toBeNull(); + expect(getTransactionProperty(nonMatchingOptimisticData?.value, 'pendingAction')).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + expect(getTransactionProperty(nonMatchingOptimisticData?.value, 'convertedAmount')).toBeNull(); }); it('should mark old report preview action as deleted when changing report policy', () => { @@ -4910,7 +4949,7 @@ describe('actions/Report', () => { describe('openReport with introSelected', () => { it('should call OpenReport API with introSelected parameter', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const REPORT_ID = '1'; @@ -4924,7 +4963,7 @@ describe('actions/Report', () => { }); it('should handle openReport with TEST_INTRO_SELECTED', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const REPORT_ID = '2'; @@ -4935,7 +4974,7 @@ describe('actions/Report', () => { }); it('should handle openReport when introSelected is undefined', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const REPORT_ID = '3'; @@ -5595,13 +5634,10 @@ describe('actions/Report', () => { TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1); const openReportCall = TestHelper.getFetchMockCalls(WRITE_COMMANDS.OPEN_REPORT).at(0); - const body = (openReportCall?.at(1) as RequestInit)?.body; + const requestOptions = openReportCall?.at(1); + const body = requestOptions && typeof requestOptions === 'object' && 'body' in requestOptions ? requestOptions.body : undefined; const guidedSetupDataParam = body instanceof FormData ? body.get('guidedSetupData') : null; - const guidedSetupData = JSON.parse(typeof guidedSetupDataParam === 'string' ? guidedSetupDataParam : '[]') as Array<{ - type: string; - task?: string; - completedTaskReportActionID?: string; - }>; + const guidedSetupData = parseGuidedSetupData(typeof guidedSetupDataParam === 'string' ? guidedSetupDataParam : '[]'); const viewTourTask = guidedSetupData.find((item) => item.type === 'task' && item.task === CONST.ONBOARDING_TASK_TYPE.VIEW_TOUR); expect(viewTourTask).toBeDefined(); if (expectation === 'defined') { @@ -5893,13 +5929,10 @@ describe('actions/Report', () => { TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1); const openReportCall = TestHelper.getFetchMockCalls(WRITE_COMMANDS.OPEN_REPORT).at(0); - const body = (openReportCall?.at(1) as RequestInit)?.body; + const requestOptions = openReportCall?.at(1); + const body = requestOptions && typeof requestOptions === 'object' && 'body' in requestOptions ? requestOptions.body : undefined; const guidedSetupDataParam = body instanceof FormData ? body.get('guidedSetupData') : null; - const guidedSetupData = JSON.parse(typeof guidedSetupDataParam === 'string' ? guidedSetupDataParam : '[]') as Array<{ - type: string; - task?: string; - completedTaskReportActionID?: string; - }>; + const guidedSetupData = parseGuidedSetupData(typeof guidedSetupDataParam === 'string' ? guidedSetupDataParam : '[]'); const viewTourTask = guidedSetupData.find((item) => item.type === 'task' && item.task === CONST.ONBOARDING_TASK_TYPE.VIEW_TOUR); expect(viewTourTask).toBeDefined(); if (expectation === 'defined') { @@ -6251,8 +6284,9 @@ describe('actions/Report', () => { expect(result).not.toBeNull(); expect(result?.reportActionID).toBe('123'); - expect((result?.message as Message[]).at(0)?.html).toContain(''); - expect((result?.message as Message[]).at(0)?.html).not.toMatch(//); + const firstMessage = Array.isArray(result?.message) ? result.message.at(0) : undefined; + expect(firstMessage?.html).toContain(''); + expect(firstMessage?.html).not.toMatch(//); }); it('should handle followup-list with attributes before adding selected', () => { @@ -6270,7 +6304,8 @@ describe('actions/Report', () => { const result = Report.buildOptimisticResolvedFollowups(reportAction); expect(result).not.toBeNull(); - expect((result?.message as Message[]).at(0)?.html).toContain(''); + const firstMessage = Array.isArray(result?.message) ? result.message.at(0) : undefined; + expect(firstMessage?.html).toContain(''); }); }); @@ -6308,7 +6343,8 @@ describe('actions/Report', () => { // The report action should remain unchanged (no followup-list to resolve) const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const); - expect((reportActions?.[REPORT_ACTION_ID]?.message as Message[])?.at(0)?.html).toBe(htmlMessage); + const message = reportActions?.[REPORT_ACTION_ID]?.message; + expect(Array.isArray(message) ? message.at(0)?.html : undefined).toBe(htmlMessage); }); it('should optimistically resolve followups and post comment when unresolved followups exist', async () => { @@ -6346,7 +6382,8 @@ describe('actions/Report', () => { // Verify the followup-list was marked as selected const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const); - const updatedHtml = (reportActions?.[REPORT_ACTION_ID]?.message as Message[])?.at(0)?.html; + const message = reportActions?.[REPORT_ACTION_ID]?.message; + const updatedHtml = Array.isArray(message) ? message.at(0)?.html : undefined; expect(updatedHtml).toContain(''); // Verify addComment was called (which triggers ADD_COMMENT API call) @@ -6388,7 +6425,8 @@ describe('actions/Report', () => { // Verify the followup-list was marked as selected const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const); - const updatedHtml = (reportActions?.[REPORT_ACTION_ID]?.message as Message[])?.at(0)?.html; + const message = reportActions?.[REPORT_ACTION_ID]?.message; + const updatedHtml = Array.isArray(message) ? message.at(0)?.html : undefined; expect(updatedHtml).toContain(''); // Verify addComment was called (which triggers ADD_COMMENT API call) @@ -6501,7 +6539,7 @@ describe('actions/Report', () => { const telemetryCall = logInfoSpy.mock.calls.find((args) => { const params = args.at(2); - return !!params && typeof params === 'object' && !Array.isArray(params) && (params as Record).event === 'followup_clicked'; + return !!params && typeof params === 'object' && !Array.isArray(params) && 'event' in params && params.event === 'followup_clicked'; }); expect(telemetryCall).toBeDefined(); @@ -6511,7 +6549,7 @@ describe('actions/Report', () => { describe('resolveConciergeCategoryOptions', () => { it('posts the selected category back to Concierge as a comment (routes through resolveConciergeOptions → addComment)', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const REPORT_ID = 'concierge-opts-1'; const report = createMock({reportID: REPORT_ID, type: CONST.REPORT.TYPE.CHAT}); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); @@ -7989,6 +8027,8 @@ describe('actions/Report', () => { const STALE_REPORT_ID = '456'; // Matches ReportUtils jest mock: mockGenerateReportID returns '9876' for optimistic fallback DM reportID. const MOCK_FALLBACK_DM_REPORT_ID = '9876'; + const mockFetch = TestHelper.createGlobalFetchMock(); + global.fetch = mockFetch; await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN); await TestHelper.setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID); @@ -8009,10 +8049,11 @@ describe('actions/Report', () => { Report.navigateToAndOpenReportWithAccountIDs([PARTICIPANT_ACCOUNT_ID], TEST_USER_ACCOUNT_ID, testIntroSelected, false, undefined, undefined, {}, true); await waitForBatchedUpdates(); - const openReportCalls = getMockFetch(global.fetch).mock.calls.filter((c) => c[0] === `https://www.expensify.com.dev/api/${WRITE_COMMANDS.OPEN_REPORT}?`); + const openReportCalls = mockFetch.mock.calls.filter((c) => c[0] === `https://www.expensify.com.dev/api/${WRITE_COMMANDS.OPEN_REPORT}?`); expect(openReportCalls.length).toBeGreaterThanOrEqual(1); const openReportParamsList = openReportCalls.map((call) => { - const body = (call.at(1) as RequestInit)?.body; + const requestOptions = call.at(1); + const body = requestOptions && typeof requestOptions === 'object' && 'body' in requestOptions ? requestOptions.body : undefined; return body instanceof FormData ? Object.fromEntries(body) : {}; }); expect(openReportParamsList.some((p) => p.reportID === MOCK_FALLBACK_DM_REPORT_ID)).toBe(true); @@ -8030,8 +8071,9 @@ describe('actions/Report', () => { await waitForBatchedUpdates(); TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 1); - const addCommentCalls = getMockFetch(global.fetch).mock.calls.filter((c) => c[0] === `https://www.expensify.com.dev/api/${WRITE_COMMANDS.ADD_COMMENT}?`); - const addCommentBody = (addCommentCalls.at(-1)?.at(1) as RequestInit)?.body; + const addCommentCalls = mockFetch.mock.calls.filter((c) => c[0] === `https://www.expensify.com.dev/api/${WRITE_COMMANDS.ADD_COMMENT}?`); + const requestOptions = addCommentCalls.at(-1)?.at(1); + const addCommentBody = requestOptions && typeof requestOptions === 'object' && 'body' in requestOptions ? requestOptions.body : undefined; const addCommentParams = addCommentBody instanceof FormData ? Object.fromEntries(addCommentBody) : {}; expect(addCommentParams.reportID).toBe(MOCK_FALLBACK_DM_REPORT_ID); }); @@ -8151,7 +8193,7 @@ describe('actions/Report', () => { const result = Report.getGuidedSetupDataForOpenReport(introSelected, isSelfTourViewed); expect(result).toBeDefined(); - const guidedSetupData = JSON.parse(result?.guidedSetupData ?? '[]') as Array<{type: string; task?: string; completedTaskReportActionID?: string}>; + const guidedSetupData = parseGuidedSetupData(result?.guidedSetupData ?? '[]'); const viewTourTask = guidedSetupData.find((item) => item.type === 'task' && item.task === CONST.ONBOARDING_TASK_TYPE.VIEW_TOUR); expect(viewTourTask).toBeDefined(); @@ -8205,7 +8247,7 @@ describe('actions/Report', () => { const TEST_USER_LOGIN = 'test@test.com'; beforeEach(async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN); await waitForBatchedUpdates(); }); @@ -8398,7 +8440,7 @@ describe('actions/Report', () => { describe('resolveActionableMentionWhisper', () => { it('should optimistically add invited users to report.participants when resolution is INVITE', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const REPORT_ID = '1'; const WHISPER_ACTION_ID = '1001'; @@ -8467,7 +8509,7 @@ describe('actions/Report', () => { }); it('should NOT update participants when resolution is NOTHING', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const REPORT_ID = '2'; const WHISPER_ACTION_ID = '2001'; @@ -8523,7 +8565,7 @@ describe('actions/Report', () => { }); it('should preserve existing participant settings when invitee is already in participants', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const REPORT_ID = '4'; const WHISPER_ACTION_ID = '4001'; @@ -8590,7 +8632,7 @@ describe('actions/Report', () => { }); it('should also update parent report participants when parentReport is provided (oneTransactionThread)', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const TRANSACTION_THREAD_ID = '10'; const PARENT_REPORT_ID = '11'; @@ -8668,7 +8710,7 @@ describe('actions/Report', () => { }); it('should fall back to ancestor report via parentReportID when parentReport matches current report', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const TRANSACTION_THREAD_ID = '20'; const ANCESTOR_IOU_REPORT_ID = '21'; @@ -8747,7 +8789,7 @@ describe('actions/Report', () => { }); it('should remove optimistically added participants on failure rollback', async () => { - const mockFetch = getMockFetch(TestHelper.getGlobalFetchMock()); + const mockFetch = TestHelper.createGlobalFetchMock(); global.fetch = mockFetch; const REPORT_ID = '3'; @@ -8818,7 +8860,7 @@ describe('actions/Report', () => { }); it('should forward reportID and inviteeEmails to the API when resolution is INVITE', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const REPORT_ID = '900'; const WHISPER_ACTION_ID = '9001'; @@ -8856,7 +8898,7 @@ describe('actions/Report', () => { }); it('should NOT forward reportID and inviteeEmails when resolution is NOTHING', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const REPORT_ID = '901'; const WHISPER_ACTION_ID = '9011'; @@ -8888,7 +8930,7 @@ describe('actions/Report', () => { }); it('should NOT forward reportID and inviteeEmails when the whisper has no invitee emails', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const REPORT_ID = '902'; const WHISPER_ACTION_ID = '9021'; @@ -8920,7 +8962,7 @@ describe('actions/Report', () => { }); it('should only forward emails for invitees not already in the room (stale whisper)', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const REPORT_ID = '903'; const WHISPER_ACTION_ID = '9031'; @@ -8965,7 +9007,7 @@ describe('actions/Report', () => { }); it('should still forward a brand-new-user email that has no matching accountID (shorter inviteeAccountIDs)', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const REPORT_ID = '904'; const WHISPER_ACTION_ID = '9041'; @@ -9011,7 +9053,7 @@ describe('actions/Report', () => { }); it('should optimistically add only the new invitee to participants for a stale whisper (offline path)', async () => { - global.fetch = TestHelper.getGlobalFetchMock(); + global.fetch = TestHelper.createGlobalFetchMock(); const REPORT_ID = '905'; const WHISPER_ACTION_ID = '9051'; @@ -9280,8 +9322,8 @@ describe('actions/Report', () => { }); await waitForBatchedUpdates(); - global.fetch = TestHelper.getGlobalFetchMock(); - mockFetch = getMockFetch(global.fetch); + mockFetch = TestHelper.createGlobalFetchMock(); + global.fetch = mockFetch; // Clear the queue before each test to avoid test pollution SequentialQueue.resetQueue(); }); From 4f608862909544db5f2785bd1100c5b3578f1bfb Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:22:40 +0530 Subject: [PATCH 2/3] fix: address ReportTest review feedback --- tests/actions/ReportTest.ts | 70 +++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 38 deletions(-) diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 821ce3d880fd..ed135cafc746 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -7,6 +7,7 @@ import type handleWalletStatementNavigationDefault from '@components/WalletState import useAncestors from '@hooks/useAncestors'; +import type {GuidedSetupData} from '@libs/actions/Report'; import markAllMessagesAsRead from '@libs/actions/Report/MarkAllMessageAsRead'; import {CONCIERGE_RESPONSE_DELAY_MS, resolveSuggestedFollowup} from '@libs/actions/Report/SuggestedFollowup'; import {getOnboardingMessages} from '@libs/actions/Welcome/OnboardingFlow'; @@ -197,42 +198,31 @@ const TEST_INTRO_SELECTED: OnyxTypes.IntroSelected = { isInviteOnboardingComplete: false, }; -const getTransactionProperty = (value: unknown, property: 'pendingAction' | 'convertedAmount'): unknown => { - if (typeof value !== 'object' || value === null) { - return undefined; +const isTransactionMergeUpdate = (value: unknown): value is Extract, {onyxMethod: typeof Onyx.METHOD.MERGE}> => { + if (typeof value !== 'object' || value === null || !('onyxMethod' in value) || !('key' in value) || !('value' in value)) { + return false; } - if (property === 'pendingAction' && 'pendingAction' in value) { - return value.pendingAction; - } - if (property === 'convertedAmount' && 'convertedAmount' in value) { - return value.convertedAmount; - } - return undefined; -}; -type GuidedSetupItem = { - type: string; - task?: string; - completedTaskReportActionID?: string; + return ( + value.onyxMethod === Onyx.METHOD.MERGE && + typeof value.key === 'string' && + value.key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION) && + (value.value === null || typeof value.value === 'object') + ); }; -const parseGuidedSetupData = (value: string): GuidedSetupItem[] => { - const parsed: unknown = JSON.parse(value); - if ( - !Array.isArray(parsed) || - !parsed.every( - (item: unknown): item is GuidedSetupItem => - typeof item === 'object' && - item !== null && - 'type' in item && - typeof item.type === 'string' && - (!('task' in item) || typeof item.task === 'string') && - (!('completedTaskReportActionID' in item) || typeof item.completedTaskReportActionID === 'string'), - ) - ) { - throw new Error('Expected guided setup data to contain only valid items'); +const isGuidedSetupData = (value: unknown): value is GuidedSetupData => + Array.isArray(value) && value.every((item: unknown): item is GuidedSetupData[number] => typeof item === 'object' && item !== null && 'type' in item); + +const parseGuidedSetupData = (value: string): GuidedSetupData => { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return []; } - return parsed; + + return isGuidedSetupData(parsed) ? parsed : []; }; type APIWriteSpy = jest.SpiedFunction; @@ -4677,18 +4667,21 @@ describe('actions/Report', () => { const transactionOptimisticData = optimisticData.find((data) => data.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); const transactionSuccessData = successData.find((data) => data.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); const transactionFailureData = failureData.find((data) => data.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + const optimisticTransaction = isTransactionMergeUpdate(transactionOptimisticData) ? transactionOptimisticData : undefined; + const successTransaction = isTransactionMergeUpdate(transactionSuccessData) ? transactionSuccessData : undefined; + const failureTransaction = isTransactionMergeUpdate(transactionFailureData) ? transactionFailureData : undefined; // Should have pendingAction set to UPDATE - expect(getTransactionProperty(transactionOptimisticData?.value, 'pendingAction')).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + expect(optimisticTransaction?.value?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); // Should have convertedAmount cleared - expect(getTransactionProperty(transactionOptimisticData?.value, 'convertedAmount')).toBeNull(); + expect(optimisticTransaction?.value?.convertedAmount).toBeNull(); // Success data should clear pendingAction - expect(getTransactionProperty(transactionSuccessData?.value, 'pendingAction')).toBeNull(); + expect(successTransaction?.value?.pendingAction).toBeNull(); // Failure data should restore original values - expect(getTransactionProperty(transactionFailureData?.value, 'pendingAction')).toBe(transaction.pendingAction ?? null); - expect(getTransactionProperty(transactionFailureData?.value, 'convertedAmount')).toBe(transaction.convertedAmount); + expect(failureTransaction?.value?.pendingAction).toBe(transaction.pendingAction ?? null); + expect(failureTransaction?.value?.convertedAmount).toBe(transaction.convertedAmount); }); it('should NOT clear convertedAmount when source and destination currencies are the same', async () => { @@ -4850,8 +4843,9 @@ describe('actions/Report', () => { // Should find optimistic data for the non-matching transaction (AUD doesn't match USD destination) const nonMatchingOptimisticData = optimisticData.find((data) => data.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${nonMatchingTransactionID}`); expect(nonMatchingOptimisticData).toBeDefined(); - expect(getTransactionProperty(nonMatchingOptimisticData?.value, 'pendingAction')).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); - expect(getTransactionProperty(nonMatchingOptimisticData?.value, 'convertedAmount')).toBeNull(); + const nonMatchingTransactionMerge = isTransactionMergeUpdate(nonMatchingOptimisticData) ? nonMatchingOptimisticData : undefined; + expect(nonMatchingTransactionMerge?.value?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + expect(nonMatchingTransactionMerge?.value?.convertedAmount).toBeNull(); }); it('should mark old report preview action as deleted when changing report policy', () => { From 504cf9e013432cb2928df9f2c0096a2fc6e3eee7 Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:40:22 +0530 Subject: [PATCH 3/3] fix: narrow guided setup task assertions --- tests/actions/ReportTest.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index ed135cafc746..b1fafe34d0df 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -7,7 +7,7 @@ import type handleWalletStatementNavigationDefault from '@components/WalletState import useAncestors from '@hooks/useAncestors'; -import type {GuidedSetupData} from '@libs/actions/Report'; +import type {GuidedSetupData, TaskForParameters} from '@libs/actions/Report'; import markAllMessagesAsRead from '@libs/actions/Report/MarkAllMessageAsRead'; import {CONCIERGE_RESPONSE_DELAY_MS, resolveSuggestedFollowup} from '@libs/actions/Report/SuggestedFollowup'; import {getOnboardingMessages} from '@libs/actions/Welcome/OnboardingFlow'; @@ -5632,7 +5632,9 @@ describe('actions/Report', () => { const body = requestOptions && typeof requestOptions === 'object' && 'body' in requestOptions ? requestOptions.body : undefined; const guidedSetupDataParam = body instanceof FormData ? body.get('guidedSetupData') : null; const guidedSetupData = parseGuidedSetupData(typeof guidedSetupDataParam === 'string' ? guidedSetupDataParam : '[]'); - const viewTourTask = guidedSetupData.find((item) => item.type === 'task' && item.task === CONST.ONBOARDING_TASK_TYPE.VIEW_TOUR); + const viewTourTask = guidedSetupData.find( + (item): item is Extract => item.type === 'task' && item.task === CONST.ONBOARDING_TASK_TYPE.VIEW_TOUR, + ); expect(viewTourTask).toBeDefined(); if (expectation === 'defined') { expect(viewTourTask?.completedTaskReportActionID).toBeDefined(); @@ -5927,7 +5929,9 @@ describe('actions/Report', () => { const body = requestOptions && typeof requestOptions === 'object' && 'body' in requestOptions ? requestOptions.body : undefined; const guidedSetupDataParam = body instanceof FormData ? body.get('guidedSetupData') : null; const guidedSetupData = parseGuidedSetupData(typeof guidedSetupDataParam === 'string' ? guidedSetupDataParam : '[]'); - const viewTourTask = guidedSetupData.find((item) => item.type === 'task' && item.task === CONST.ONBOARDING_TASK_TYPE.VIEW_TOUR); + const viewTourTask = guidedSetupData.find( + (item): item is Extract => item.type === 'task' && item.task === CONST.ONBOARDING_TASK_TYPE.VIEW_TOUR, + ); expect(viewTourTask).toBeDefined(); if (expectation === 'defined') { expect(viewTourTask?.completedTaskReportActionID).toBeDefined(); @@ -8188,7 +8192,9 @@ describe('actions/Report', () => { expect(result).toBeDefined(); const guidedSetupData = parseGuidedSetupData(result?.guidedSetupData ?? '[]'); - const viewTourTask = guidedSetupData.find((item) => item.type === 'task' && item.task === CONST.ONBOARDING_TASK_TYPE.VIEW_TOUR); + const viewTourTask = guidedSetupData.find( + (item): item is Extract => item.type === 'task' && item.task === CONST.ONBOARDING_TASK_TYPE.VIEW_TOUR, + ); expect(viewTourTask).toBeDefined(); if (isSelfTourViewed) {