From 7844373e49573745ca3ea092cb996734ad0c1aae Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:26:41 +0530 Subject: [PATCH] Rebuild cleanup on current main --- tests/actions/SessionTest.ts | 83 +++-- .../ui/NetSuiteCustomListSelectorPageTest.tsx | 32 +- tests/unit/DebugUtilsTest.ts | 57 ++-- tests/unit/ExportOnyxStateTest.ts | 284 ++++++++++++------ tests/unit/IOUUtilsTest.ts | 45 +-- tests/unit/RequestConflictUtilsTest.ts | 27 +- tests/unit/TransactionTest.ts | 129 +++++--- .../unit/hooks/useGettingStartedItems.test.ts | 42 +-- .../unit/hooks/useIsBlockedToAddFeed.test.ts | 120 ++++---- .../GettingStartedSectionTest.tsx | 49 +-- 10 files changed, 529 insertions(+), 339 deletions(-) diff --git a/tests/actions/SessionTest.ts b/tests/actions/SessionTest.ts index f3b0649a48b5..ec480efa5385 100644 --- a/tests/actions/SessionTest.ts +++ b/tests/actions/SessionTest.ts @@ -14,20 +14,21 @@ import getPlatform from '@libs/getPlatform'; import HttpUtils from '@libs/HttpUtils'; import {setHasRadio} from '@libs/NetworkState'; import PushNotification from '@libs/Notification/PushNotification'; +import {isRecord} from '@libs/ObjectUtils'; import reauthenticate from '@libs/Reauthentication'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; -import * as SessionUtil from '@src/libs/actions/Session'; // This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection import '@libs/Notification/PushNotification/subscribeToPushNotifications'; +import * as SessionUtil from '@src/libs/actions/Session'; import {KEYS_TO_PRESERVE_SUPPORTAL, signOutAndRedirectToSignIn} from '@src/libs/actions/Session'; import * as API from '@src/libs/API'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Credentials, Session} from '@src/types/onyx'; +import type {Account, Credentials, Session} from '@src/types/onyx'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import {openAuthSessionAsync} from 'expo-web-browser'; import {clearTokenRefresh, removeAllFromAutoprefetch} from 'react-native-nitro-fetch'; @@ -61,6 +62,23 @@ jest.mock('@libs/getPlatform', () => jest.fn()); const mockedGetPlatform = jest.mocked(getPlatform); +type AccountMergeUpdate = Extract, {onyxMethod: typeof Onyx.METHOD.MERGE}>; +type AccountMergeValue = Pick, 'isLoading' | 'errors' | 'twoFactorAuthSecretKey'>; +type AccountMergeObjectUpdate = Omit & {value: AccountMergeValue}; + +function isAccountMergeUpdate(value: unknown): value is AccountMergeObjectUpdate { + if (!isRecord(value) || value.key !== ONYXKEYS.ACCOUNT || value.onyxMethod !== Onyx.METHOD.MERGE || !isRecord(value.value)) { + return false; + } + + const {isLoading, errors, twoFactorAuthSecretKey} = value.value; + return ( + (isLoading === undefined || isLoading === null || typeof isLoading === 'boolean') && + (errors === undefined || errors === null || (isRecord(errors) && Object.values(errors).every((error) => error === null || typeof error === 'string'))) && + (twoFactorAuthSecretKey === undefined || twoFactorAuthSecretKey === null || typeof twoFactorAuthSecretKey === 'string') + ); +} + Onyx.init({ keys: ONYXKEYS, }); @@ -167,8 +185,9 @@ describe('Session', () => { test('reauthenticate proceeds even when a legacy session.isAuthenticatingWithShortLivedToken=true is persisted (recovers stuck users)', async () => { // Given a session in Onyx that still carries the legacy stuck flag from before the RAM-only migration. - // The Session type no longer declares the field, so cast to write the legacy shape. - await Onyx.merge(ONYXKEYS.SESSION, {isAuthenticatingWithShortLivedToken: true} as unknown as Session); + // The Session type no longer declares the field, so write the legacy shape to exercise persisted-data compatibility. + // @ts-expect-error -- legacy persisted sessions can contain this field even though current Session does not. + await Onyx.merge(ONYXKEYS.SESSION, {isAuthenticatingWithShortLivedToken: true}); await waitForBatchedUpdates(); const redirectToSignInSpy = jest.spyOn(SignInRedirect, 'default').mockImplementation(() => Promise.resolve()); @@ -281,7 +300,7 @@ describe('Session', () => { // to Re-Authenticate with the stored credentials. Our next call will be to Authenticate // so we will mock that response with a new authToken and then verify that Onyx has our // data. - (HttpUtils.xhr as jest.MockedFunction) + jest.mocked(HttpUtils.xhr) // This will make the call to OpenApp below return with an expired session code .mockImplementationOnce(() => @@ -431,7 +450,7 @@ describe('Session', () => { setHasRadio(false); await waitForBatchedUpdates(); - (HttpUtils.xhr as jest.MockedFunction) + jest.mocked(HttpUtils.xhr) // This will make the call to OpenApp below return with an expired session code .mockImplementationOnce(() => Promise.resolve({ @@ -491,7 +510,7 @@ describe('Session', () => { setHasRadio(false); await waitForBatchedUpdates(); - (HttpUtils.xhr as jest.MockedFunction) + jest.mocked(HttpUtils.xhr) // This will make the call to OpenApp below return with an expired session code .mockImplementationOnce(() => Promise.resolve({ @@ -516,7 +535,7 @@ describe('Session', () => { setHasRadio(false); await waitForBatchedUpdates(); - (HttpUtils.xhr as jest.MockedFunction) + jest.mocked(HttpUtils.xhr) // This will make the call to OpenApp below return with an expired session code .mockImplementationOnce(() => Promise.resolve({ @@ -539,8 +558,8 @@ describe('Session', () => { jest.spyOn(SessionUtil, 'isSupportAuthToken').mockReturnValue(true); jest.spyOn(SessionUtil, 'hasStashedSession').mockReturnValue(true); jest.spyOn(SessionUtil, 'signOut').mockResolvedValue(undefined); - jest.spyOn(Onyx, 'clear').mockResolvedValue(undefined); - jest.spyOn(Onyx, 'multiSet').mockResolvedValue(undefined); + const onyxClearSpy = jest.spyOn(Onyx, 'clear').mockResolvedValue(undefined); + const onyxMultiSetSpy = jest.spyOn(Onyx, 'multiSet').mockResolvedValue(undefined); const testStashedCredentials = {login: 'stashed@expensify.com', autoGeneratedLogin: 'stashedAutoLogin', autoGeneratedPassword: 'stashedAutoPassword'}; const testStashedSession = {authToken: 'stashedAuthToken', email: 'stashed@expensify.com', accountID: 123, creationDate: new Date().getTime()}; @@ -551,9 +570,6 @@ describe('Session', () => { await waitForBatchedUpdates(); - const onyxClearSpy = Onyx.clear as jest.Mock; - const onyxMultiSetSpy = Onyx.multiSet as jest.Mock; - signOutAndRedirectToSignIn(false, false, true, true); await waitForBatchedUpdates(); @@ -575,8 +591,8 @@ describe('Session', () => { jest.spyOn(SessionUtil, 'isSupportAuthToken').mockReturnValue(false); jest.spyOn(SessionUtil, 'hasStashedSession').mockReturnValue(true); jest.spyOn(SessionUtil, 'signOut').mockResolvedValue(undefined); - jest.spyOn(Onyx, 'clear').mockResolvedValue(undefined); - jest.spyOn(Onyx, 'multiSet').mockResolvedValue(undefined); + const onyxClearSpy = jest.spyOn(Onyx, 'clear').mockResolvedValue(undefined); + const onyxMultiSetSpy = jest.spyOn(Onyx, 'multiSet').mockResolvedValue(undefined); const testStashedCredentials = {login: 'delegate@expensify.com', autoGeneratedLogin: 'delegateAutoLogin', autoGeneratedPassword: 'delegateAutoPassword'}; const testStashedSession = {authToken: 'delegateAuthToken', email: 'delegate@expensify.com', accountID: 456, creationDate: new Date().getTime()}; @@ -586,8 +602,6 @@ describe('Session', () => { await waitForBatchedUpdates(); - const onyxClearSpy = Onyx.clear as jest.Mock; - const onyxMultiSetSpy = Onyx.multiSet as jest.Mock; const redirectToSignInSpy = jest.spyOn(SignInRedirect, 'default').mockImplementation(() => Promise.resolve()); signOutAndRedirectToSignIn(true, false, true, true); @@ -742,9 +756,12 @@ describe('Session', () => { SessionUtil.replaceTwoFactorDevice('verify_old', '123456'); - const onyxData = writeSpy.mock.calls.at(0)?.at(2) as {optimisticData: Array<{key: string; value: unknown}>}; - const accountOptimistic = onyxData.optimisticData.find((d) => d.key === ONYXKEYS.ACCOUNT); - expect(accountOptimistic?.value).toStrictEqual({isLoading: true, errors: null}); + const [, , onyxData] = TestHelper.getRequiredWriteCall(writeSpy.mock.calls, 0); + const accountOptimistic = TestHelper.getRequiredOnyxUpdate(onyxData, 'optimisticData', ONYXKEYS.ACCOUNT, Onyx.METHOD.MERGE, true); + if (!isAccountMergeUpdate(accountOptimistic)) { + throw new Error('Expected a typed account optimistic update'); + } + expect(accountOptimistic.value).toStrictEqual({isLoading: true, errors: null}); writeSpy.mockRestore(); }); @@ -754,9 +771,12 @@ describe('Session', () => { SessionUtil.replaceTwoFactorDevice('verify_old', '123456'); - const onyxData = writeSpy.mock.calls.at(0)?.at(2) as {successData: Array<{key: string; value: Record}>}; - const accountSuccess = onyxData.successData.find((d) => d.key === ONYXKEYS.ACCOUNT); - expect(accountSuccess?.value).not.toHaveProperty('twoFactorAuthSecretKey'); + const [, , onyxData] = TestHelper.getRequiredWriteCall(writeSpy.mock.calls, 0); + const accountSuccess = TestHelper.getRequiredOnyxUpdate(onyxData, 'successData', ONYXKEYS.ACCOUNT, Onyx.METHOD.MERGE, true); + if (!isAccountMergeUpdate(accountSuccess)) { + throw new Error('Expected a typed account success update'); + } + expect(accountSuccess.value).not.toHaveProperty('twoFactorAuthSecretKey'); writeSpy.mockRestore(); }); @@ -766,9 +786,12 @@ describe('Session', () => { SessionUtil.replaceTwoFactorDevice('verify_new', '654321'); - const onyxData = writeSpy.mock.calls.at(0)?.at(2) as {successData: Array<{key: string; value: Record}>}; - const accountSuccess = onyxData.successData.find((d) => d.key === ONYXKEYS.ACCOUNT); - expect(accountSuccess?.value.twoFactorAuthSecretKey).toBeNull(); + const [, , onyxData] = TestHelper.getRequiredWriteCall(writeSpy.mock.calls, 0); + const accountSuccess = TestHelper.getRequiredOnyxUpdate(onyxData, 'successData', ONYXKEYS.ACCOUNT, Onyx.METHOD.MERGE, true); + if (!isAccountMergeUpdate(accountSuccess)) { + throw new Error('Expected a typed account success update'); + } + expect(accountSuccess.value.twoFactorAuthSecretKey).toBeNull(); writeSpy.mockRestore(); }); @@ -779,12 +802,10 @@ describe('Session', () => { await Onyx.merge(ONYXKEYS.ACCOUNT, {twoFactorAuthSecretKey: 'SOMESECRETKEY123'}); await waitForBatchedUpdates(); - let account: Record | null | undefined; + let account: OnyxEntry; Onyx.connect({ key: ONYXKEYS.ACCOUNT, - callback: (val) => { - account = val as Record | null | undefined; - }, + callback: (val) => (account = val), }); await waitForBatchedUpdates(); expect(account?.twoFactorAuthSecretKey).toBe('SOMESECRETKEY123'); diff --git a/tests/ui/NetSuiteCustomListSelectorPageTest.tsx b/tests/ui/NetSuiteCustomListSelectorPageTest.tsx index 1764ed6c4844..41ecafe990f5 100644 --- a/tests/ui/NetSuiteCustomListSelectorPageTest.tsx +++ b/tests/ui/NetSuiteCustomListSelectorPageTest.tsx @@ -17,8 +17,12 @@ import type * as ReactNavigation from '@react-navigation/native'; import React from 'react'; +import createMock from '../utils/createMock'; + const mockUseState = React.useState; +type NetSuiteCustomListSelectorPageProps = Parameters[0]; + const mockCustomLists = [ {id: '123', name: 'Department'}, {id: '456', name: 'Project'}, @@ -75,7 +79,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ })); describe('NetSuiteCustomListSelectorPage', () => { - const mockedSelectionList = jest.mocked(SelectionList); + const mockedSelectionList = jest.mocked(SelectionList); const mockedSetDraftValues = jest.mocked(setDraftValues); const mockedNavigationGoBack = jest.mocked(Navigation.goBack); @@ -91,8 +95,8 @@ describe('NetSuiteCustomListSelectorPage', () => { render( ({params: {policyID: 'P1'}})} + navigation={createMock({})} />, ); @@ -107,13 +111,16 @@ describe('NetSuiteCustomListSelectorPage', () => { it('writes both listName and internalID to the form draft on row select then returns to the custom list name sub-page', () => { render( ({params: {policyID: 'P1'}})} + navigation={createMock({})} />, ); const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; - const selectedRow = selectionListProps?.data.find((item) => (item as CustomListSelectorType).value === 'Department') as CustomListSelectorType; + const selectedRow = selectionListProps?.data.find((item) => item.value === 'Department'); + if (!selectedRow) { + throw new Error('Expected the Department row to be rendered'); + } selectionListProps?.onSelectRow?.(selectedRow); expect(mockedSetDraftValues).toHaveBeenCalledWith(ONYXKEYS.FORMS.NETSUITE_CUSTOM_LIST_ADD_FORM, { @@ -129,13 +136,16 @@ describe('NetSuiteCustomListSelectorPage', () => { it('returns to the name sub-page in edit mode on row select when the selector was opened while editing from the confirm step', () => { render( ({params: {policyID: 'P1', action: 'edit'}})} + navigation={createMock({})} />, ); const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; - const selectedRow = selectionListProps?.data.find((item) => (item as CustomListSelectorType).value === 'Department') as CustomListSelectorType; + const selectedRow = selectionListProps?.data.find((item) => item.value === 'Department'); + if (!selectedRow) { + throw new Error('Expected the Department row to be rendered'); + } selectionListProps?.onSelectRow?.(selectedRow); expect(mockedNavigationGoBack).toHaveBeenCalledTimes(1); @@ -147,8 +157,8 @@ describe('NetSuiteCustomListSelectorPage', () => { it('renders an empty option set with a no-results header message when search filters everything out', () => { render( ({params: {policyID: 'P1'}})} + navigation={createMock({})} />, ); diff --git a/tests/unit/DebugUtilsTest.ts b/tests/unit/DebugUtilsTest.ts index 0e64fd3f0622..dccd654aa4ce 100644 --- a/tests/unit/DebugUtilsTest.ts +++ b/tests/unit/DebugUtilsTest.ts @@ -5,12 +5,12 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import DateUtils from '@libs/DateUtils'; import type {ObjectType} from '@libs/DebugUtils'; import DebugUtils from '@libs/DebugUtils'; +import {getObjectKeys} from '@libs/ObjectUtils'; import {getAllReportErrors} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report, ReportAction, ReportActions, Transaction} from '@src/types/onyx'; -import type {JoinWorkspaceResolution} from '@src/types/onyx/OriginalMessage'; import type {ReportCollectionDataSet} from '@src/types/onyx/Report'; import type {ReportActionsCollectionDataSet} from '@src/types/onyx/ReportAction'; @@ -18,8 +18,6 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import type ReportActionName from '../../src/types/onyx/ReportActionName'; - import {chatReportR14932} from '../../__mocks__/reportData/reports'; import createRandomReportAction from '../utils/collections/reportActions'; import {createRandomReport} from '../utils/collections/reports'; @@ -517,7 +515,7 @@ describe('DebugUtils', () => { }); describe('validateReportDraftProperty', () => { - describe.each(Object.keys(MOCK_REPORT) as Array)('%s', (key) => { + describe.each(getObjectKeys(MOCK_REPORT))('%s', (key) => { describe('is undefined', () => { it(`${DebugUtils.REPORT_REQUIRED_PROPERTIES.includes(key) ? 'throws SyntaxError' : 'does not throw SyntaxError'}`, () => { if (DebugUtils.REPORT_REQUIRED_PROPERTIES.includes(key)) { @@ -566,7 +564,7 @@ describe('DebugUtils', () => { }); describe('validateReportActionDraftProperty', () => { - describe.each(Object.keys(MOCK_REPORT_ACTION) as Array)('%s', (key) => { + describe.each(getObjectKeys(MOCK_REPORT_ACTION))('%s', (key) => { it(`${DebugUtils.REPORT_ACTION_REQUIRED_PROPERTIES.includes(key) ? "throws SyntaxError when 'undefined'" : 'does not throw SyntaxError when "undefined"'}`, () => { if (DebugUtils.REPORT_ACTION_REQUIRED_PROPERTIES.includes(key)) { expect(() => { @@ -609,7 +607,7 @@ describe('DebugUtils', () => { }); describe('validateTransactionDraftProperty', () => { - describe.each(Object.keys(MOCK_TRANSACTION) as Array)('%s', (key) => { + describe.each(getObjectKeys(MOCK_TRANSACTION))('%s', (key) => { it(`${DebugUtils.TRANSACTION_REQUIRED_PROPERTIES.includes(key) ? "throws SyntaxError when 'undefined'" : 'does not throw SyntaxError when "undefined"'}`, () => { if (DebugUtils.TRANSACTION_REQUIRED_PROPERTIES.includes(key)) { expect(() => { @@ -659,9 +657,9 @@ describe('DebugUtils', () => { }); it('throws SyntaxError when property is not a valid number', () => { - const reportAction: ReportAction = { + const reportAction = { ...MOCK_REPORT_ACTION, - accountID: '2' as unknown as number, + accountID: '2', }; const draftReportAction = DebugUtils.onyxDataToString(reportAction); expect(() => { @@ -677,9 +675,9 @@ describe('DebugUtils', () => { }); it('throws SyntaxError when property is not a valid date', () => { - const reportAction: ReportAction = { + const reportAction = { ...MOCK_REPORT_ACTION, - created: 2 as unknown as string, + created: 2, }; const draftReportAction = DebugUtils.onyxDataToString(reportAction); expect(() => { @@ -695,9 +693,9 @@ describe('DebugUtils', () => { }); it('throws SyntaxError when property is not a valid boolean', () => { - const reportAction: ReportAction = { + const reportAction = { ...MOCK_REPORT_ACTION, - isLoading: 2 as unknown as boolean, + isLoading: 2, }; const draftReportAction = DebugUtils.onyxDataToString(reportAction); expect(() => { @@ -713,9 +711,9 @@ describe('DebugUtils', () => { }); it('throws SyntaxError when property is missing', () => { - const reportAction: ReportAction = { + const reportAction = { ...MOCK_REPORT_ACTION, - actionName: undefined as unknown as ReportActionName, + actionName: undefined, }; const draftReportAction = DebugUtils.onyxDataToString(reportAction); expect(() => { @@ -1038,8 +1036,9 @@ describe('DebugUtils', () => { reportActionID: '0', actionName: CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_JOIN_REQUEST, created: '2024-08-08 19:70:44.171', + // @ts-expect-error -- persisted join requests can contain an empty choice. message: { - choice: '' as JoinWorkspaceResolution, + choice: '', policyID: '0', }, }), @@ -1125,8 +1124,9 @@ describe('DebugUtils', () => { reportActionID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_JOIN_REQUEST, created: '2024-08-08 19:70:44.171', + // @ts-expect-error -- persisted join requests can contain an empty choice. message: { - choice: '' as JoinWorkspaceResolution, + choice: '', policyID: '0', }, }), @@ -1206,8 +1206,11 @@ describe('DebugUtils', () => { email: RORY_EMAIL, }, }); - // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style - const {reportAction} = DebugUtils.getReasonAndReportActionForGBRInLHNRow(MOCK_REPORTS[`${ONYXKEYS.COLLECTION.REPORT}1`] as Report, RORY_EMAIL, 12345) ?? {}; + const report = MOCK_REPORTS[`${ONYXKEYS.COLLECTION.REPORT}1`]; + if (!report) { + throw new Error('Expected the report fixture to be present'); + } + const {reportAction} = DebugUtils.getReasonAndReportActionForGBRInLHNRow(report, RORY_EMAIL, 12345) ?? {}; expect(reportAction).toMatchObject(MOCK_REPORT_ACTIONS['1']); }); it('returns undefined report action when report has no GBR', () => { @@ -1303,19 +1306,11 @@ describe('DebugUtils', () => { modifiedCreated: '', }), }; - const {reportAction} = - DebugUtils.getReasonAndReportActionForRBRInLHNRow( - // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style - MOCK_REPORTS[`${ONYXKEYS.COLLECTION.REPORT}1`] as Report, - chatReportR14932, - undefined, - transactionForTest, - undefined, - false, - {}, - false, - 12345, - ) ?? {}; + const report = MOCK_REPORTS[`${ONYXKEYS.COLLECTION.REPORT}1`]; + if (!report) { + throw new Error('Expected the report fixture to be present'); + } + const {reportAction} = DebugUtils.getReasonAndReportActionForRBRInLHNRow(report, chatReportR14932, undefined, transactionForTest, undefined, false, {}, false, 12345) ?? {}; expect(reportAction).toBe(undefined); }); describe('There is a report action with smart scan errors', () => { diff --git a/tests/unit/ExportOnyxStateTest.ts b/tests/unit/ExportOnyxStateTest.ts index 99830b06e305..578a3fc58ba3 100644 --- a/tests/unit/ExportOnyxStateTest.ts +++ b/tests/unit/ExportOnyxStateTest.ts @@ -1,13 +1,7 @@ import {emailRegex, keysToMask, maskOnyxState, ONYX_KEY_EXPORT_RULES, onyxKeysToMaskFragileData, onyxKeysToRemove, safeOnyxKeys} from '@libs/ExportOnyxState/common'; +import {isRecord} from '@libs/ObjectUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Session} from '@src/types/onyx'; - -type ExampleOnyxState = { - session: Session; - preservedUserSession?: Session; - [key: string]: unknown; -}; describe('maskOnyxState', () => { const mockSession = { @@ -23,27 +17,46 @@ describe('maskOnyxState', () => { it('should only export whitelisted fields from session', () => { // preservedUserSession holds a full Session (tokens included) and must be masked exactly like session const input = {session: mockSession, [ONYXKEYS.PRESERVED_USER_SESSION]: mockSession}; - const result = maskOnyxState(input) as ExampleOnyxState; - - // Whitelisted fields should be preserved - expect(result.session.email).toBe('user@example.com'); - expect(result.session.accountID).toBe(12345); - expect(result.session.loading).toBe(false); - expect(result.session.creationDate).toBe('2024-01-01'); - - // Non-whitelisted fields should be intelligently redacted - expect(result.session.authToken).not.toBe('sensitive-auth-token'); - expect(result.session.authToken).toHaveLength('sensitive-auth-token'.length); - expect(result.session.encryptedAuthToken).not.toBe('sensitive-encrypted-token'); - expect(result.session.encryptedAuthToken).toHaveLength('sensitive-encrypted-token'.length); - - // preservedUserSession must get the same treatment - tokens masked, whitelisted fields kept - expect(result.preservedUserSession?.email).toBe('user@example.com'); - expect(result.preservedUserSession?.accountID).toBe(12345); - expect(result.preservedUserSession?.authToken).not.toBe('sensitive-auth-token'); - expect(result.preservedUserSession?.authToken).toHaveLength('sensitive-auth-token'.length); - expect(result.preservedUserSession?.encryptedAuthToken).not.toBe('sensitive-encrypted-token'); - expect(result.preservedUserSession?.encryptedAuthToken).toHaveLength('sensitive-encrypted-token'.length); + const result = maskOnyxState(input); + const session = result.session; + const preservedUserSession = result.preservedUserSession; + + if (!isRecord(session) || !isRecord(preservedUserSession)) { + throw new Error('Expected session records in masked Onyx state'); + } + + expect(result).toMatchObject({ + session: { + email: 'user@example.com', + accountID: 12345, + loading: false, + creationDate: '2024-01-01', + }, + preservedUserSession: { + email: 'user@example.com', + accountID: 12345, + }, + }); + expect(typeof session.authToken).toBe('string'); + expect(typeof session.encryptedAuthToken).toBe('string'); + expect(typeof preservedUserSession.authToken).toBe('string'); + expect(typeof preservedUserSession.encryptedAuthToken).toBe('string'); + if ( + typeof session.authToken !== 'string' || + typeof session.encryptedAuthToken !== 'string' || + typeof preservedUserSession.authToken !== 'string' || + typeof preservedUserSession.encryptedAuthToken !== 'string' + ) { + throw new Error('Expected masked session tokens'); + } + expect(session.authToken).toHaveLength('sensitive-auth-token'.length); + expect(session.encryptedAuthToken).toHaveLength('sensitive-encrypted-token'.length); + expect(preservedUserSession.authToken).toHaveLength('sensitive-auth-token'.length); + expect(preservedUserSession.encryptedAuthToken).toHaveLength('sensitive-encrypted-token'.length); + expect(session.authToken).not.toBe('sensitive-auth-token'); + expect(session.encryptedAuthToken).not.toBe('sensitive-encrypted-token'); + expect(preservedUserSession.authToken).not.toBe('sensitive-auth-token'); + expect(preservedUserSession.encryptedAuthToken).not.toBe('sensitive-encrypted-token'); }); it('should mask fields in maskList while preserving structure', () => { @@ -56,19 +69,27 @@ describe('maskOnyxState', () => { }; const input = {[ONYXKEYS.ACCOUNT]: mockAccount}; - const result = maskOnyxState(input) as {account: Record}; - - // Whitelisted fields should be preserved - expect(result.account.validated).toBe(true); - expect(result.account.isFromPublicDomain).toBe(false); - expect(result.account.isUsingExpensifyCard).toBe(true); - - // Masked fields should be masked but preserved - expect(result.account.primaryLogin).not.toBe('user@example.com'); - expect(result.account.primaryLogin).toHaveLength('user@example.com'.length); - - // Non-whitelisted, non-masked fields should be redacted - expect(result.account.requiresTwoFactorAuth).toBe('***'); + const result = maskOnyxState(input); + const account = result.account; + + if (!isRecord(account)) { + throw new Error('Expected an account record in masked Onyx state'); + } + + expect(result).toMatchObject({ + account: { + validated: true, + isFromPublicDomain: false, + isUsingExpensifyCard: true, + requiresTwoFactorAuth: '***', + }, + }); + expect(typeof account.primaryLogin).toBe('string'); + if (typeof account.primaryLogin !== 'string') { + throw new Error('Expected a masked primary login'); + } + expect(account.primaryLogin).toHaveLength('user@example.com'.length); + expect(account.primaryLogin).not.toBe('user@example.com'); }); it('should redact fields not in allowList or maskList', () => { @@ -79,17 +100,28 @@ describe('maskOnyxState', () => { anotherField: 'also-redacted', }, }; - const result = maskOnyxState(input) as {session: Record}; + const result = maskOnyxState(input); + const session = result.session; - // Whitelisted fields should be preserved - expect(result.session.email).toBe('user@example.com'); - expect(result.session.accountID).toBe(12345); + if (!isRecord(session)) { + throw new Error('Expected a session record in masked Onyx state'); + } - // Non-whitelisted fields should be intelligently redacted - expect(result.session.customField).not.toBe('should-be-redacted'); - expect(result.session.customField).toHaveLength('should-be-redacted'.length); - expect(result.session.anotherField).not.toBe('also-redacted'); - expect(result.session.anotherField).toHaveLength('also-redacted'.length); + expect(result).toMatchObject({ + session: { + email: 'user@example.com', + accountID: 12345, + }, + }); + expect(typeof session.customField).toBe('string'); + expect(typeof session.anotherField).toBe('string'); + if (typeof session.customField !== 'string' || typeof session.anotherField !== 'string') { + throw new Error('Expected masked custom session fields'); + } + expect(session.customField).toHaveLength('should-be-redacted'.length); + expect(session.anotherField).toHaveLength('also-redacted'.length); + expect(session.customField).not.toBe('should-be-redacted'); + expect(session.anotherField).not.toBe('also-redacted'); }); it('should handle collection keys correctly', () => { @@ -108,25 +140,35 @@ describe('maskOnyxState', () => { const input = { [`${ONYXKEYS.COLLECTION.REPORT}123`]: mockReport, }; - const result = maskOnyxState(input) as Record; - - const processedReport = result[`${ONYXKEYS.COLLECTION.REPORT}123`]; - - // Whitelisted fields should be preserved - expect(processedReport.reportID).toBe('123'); - expect(processedReport.type).toBe('expense'); - expect(processedReport.chatType).toBe('policyExpenseChat'); - expect(processedReport.stateNum).toBe(1); - expect(processedReport.statusNum).toBe(0); - - // Masked fields should be masked - expect(processedReport.reportName).not.toBe('Test Report'); - expect(processedReport.reportName).toHaveLength('Test Report'.length); - expect(processedReport.description).not.toBe('Test Description'); - - // Non-whitelisted, non-masked fields should be intelligently redacted - expect(processedReport.customField).not.toBe('should-be-redacted'); - expect(processedReport.customField).toHaveLength('should-be-redacted'.length); + const result = maskOnyxState(input); + const reportKey = `${ONYXKEYS.COLLECTION.REPORT}123`; + const report = result[reportKey]; + + if (!isRecord(report)) { + throw new Error('Expected a report record in masked Onyx state'); + } + + expect(result).toMatchObject({ + [reportKey]: { + reportID: '123', + type: 'expense', + chatType: 'policyExpenseChat', + stateNum: 1, + statusNum: 0, + }, + }); + expect(typeof report.reportName).toBe('string'); + expect(typeof report.description).toBe('string'); + expect(typeof report.customField).toBe('string'); + if (typeof report.reportName !== 'string' || typeof report.description !== 'string' || typeof report.customField !== 'string') { + throw new Error('Expected masked report fields'); + } + expect(report.reportName).toHaveLength('Test Report'.length); + expect(report.description).toHaveLength('Test Description'.length); + expect(report.customField).toHaveLength('should-be-redacted'.length); + expect(report.reportName).not.toBe('Test Report'); + expect(report.description).not.toBe('Test Description'); + expect(report.customField).not.toBe('should-be-redacted'); }); it('should remove sensitive keys from export', () => { @@ -214,37 +256,71 @@ describe('maskOnyxState', () => { it('should mask session details by default', () => { const input = {session: mockSession}; - const result = maskOnyxState(input) as ExampleOnyxState; + const result = maskOnyxState(input); + const session = result.session; - expect(result.session.authToken).not.toBe('sensitive-auth-token'); - expect(result.session.authToken).toHaveLength('sensitive-auth-token'.length); - expect(result.session.encryptedAuthToken).not.toBe('sensitive-encrypted-token'); - expect(result.session.encryptedAuthToken).toHaveLength('sensitive-encrypted-token'.length); + if (!isRecord(session)) { + throw new Error('Expected a session record in masked Onyx state'); + } + + expect(typeof session.authToken).toBe('string'); + expect(typeof session.encryptedAuthToken).toBe('string'); + if (typeof session.authToken !== 'string' || typeof session.encryptedAuthToken !== 'string') { + throw new Error('Expected masked session tokens'); + } + expect(session.authToken).toHaveLength('sensitive-auth-token'.length); + expect(session.encryptedAuthToken).toHaveLength('sensitive-encrypted-token'.length); + expect(session.authToken).not.toBe('sensitive-auth-token'); + expect(session.encryptedAuthToken).not.toBe('sensitive-encrypted-token'); }); it('should not mask fragile data when isMaskingFragileDataEnabled is false', () => { const input = { session: mockSession, }; - const result = maskOnyxState(input) as ExampleOnyxState; + const result = maskOnyxState(input); + const session = result.session; + + if (!isRecord(session)) { + throw new Error('Expected a session record in masked Onyx state'); + } - expect(result.session.authToken).not.toBe('sensitive-auth-token'); - expect(result.session.authToken).toHaveLength('sensitive-auth-token'.length); - expect(result.session.encryptedAuthToken).not.toBe('sensitive-encrypted-token'); - expect(result.session.encryptedAuthToken).toHaveLength('sensitive-encrypted-token'.length); - expect(result.session.email).toBe('user@example.com'); + expect(result).toMatchObject({ + session: { + email: 'user@example.com', + }, + }); + expect(typeof session.authToken).toBe('string'); + expect(typeof session.encryptedAuthToken).toBe('string'); + if (typeof session.authToken !== 'string' || typeof session.encryptedAuthToken !== 'string') { + throw new Error('Expected masked session tokens'); + } + expect(session.authToken).toHaveLength('sensitive-auth-token'.length); + expect(session.encryptedAuthToken).toHaveLength('sensitive-encrypted-token'.length); + expect(session.authToken).not.toBe('sensitive-auth-token'); + expect(session.encryptedAuthToken).not.toBe('sensitive-encrypted-token'); }); it('should mask fragile data when isMaskingFragileDataEnabled is true', () => { const input = { session: mockSession, }; - const result = maskOnyxState(input, true) as ExampleOnyxState; + const result = maskOnyxState(input, true); + const session = result.session; - expect(result.session.authToken).not.toBe('sensitive-auth-token'); - expect(result.session.authToken).toHaveLength('sensitive-auth-token'.length); - expect(result.session.encryptedAuthToken).not.toBe('sensitive-encrypted-token'); - expect(result.session.encryptedAuthToken).toHaveLength('sensitive-encrypted-token'.length); + if (!isRecord(session)) { + throw new Error('Expected a session record in masked Onyx state'); + } + + expect(typeof session.authToken).toBe('string'); + expect(typeof session.encryptedAuthToken).toBe('string'); + if (typeof session.authToken !== 'string' || typeof session.encryptedAuthToken !== 'string') { + throw new Error('Expected masked session tokens'); + } + expect(session.authToken).toHaveLength('sensitive-auth-token'.length); + expect(session.encryptedAuthToken).toHaveLength('sensitive-encrypted-token'.length); + expect(session.authToken).not.toBe('sensitive-auth-token'); + expect(session.encryptedAuthToken).not.toBe('sensitive-encrypted-token'); }); it('should mask emails as a string value in property with a random email', () => { @@ -252,8 +328,11 @@ describe('maskOnyxState', () => { session: mockSession, }; - const result = maskOnyxState(input) as ExampleOnyxState; + const result = maskOnyxState(input); + if (!isRecord(result.session) || typeof result.session.email !== 'string') { + throw new Error('Expected a masked session email'); + } expect(result.session.email).toMatch(emailRegex); }); @@ -263,10 +342,19 @@ describe('maskOnyxState', () => { emails: ['user@example.com', 'user2@example.com'], }; - const result = maskOnyxState(input, true) as Record; + const result = maskOnyxState(input, true); - expect(result.emails.at(0)).toMatch(emailRegex); - expect(result.emails.at(1)).toMatch(emailRegex); + expect(Array.isArray(result.emails)).toBe(true); + if (!Array.isArray(result.emails)) { + return; + } + expect(result.emails).toHaveLength(2); + for (const email of result.emails) { + expect(typeof email).toBe('string'); + if (typeof email === 'string') { + expect(email).toMatch(emailRegex); + } + } }); it('should mask emails in keys of objects', () => { @@ -276,7 +364,7 @@ describe('maskOnyxState', () => { session: mockSession, }; - const result = maskOnyxState(input, true) as Record; + const result = maskOnyxState(input, true); expect(Object.keys(result).at(0)).toMatch(emailRegex); }); @@ -287,8 +375,11 @@ describe('maskOnyxState', () => { emailString: 'user@example.com is a test string', }; - const result = maskOnyxState(input, true) as Record; - expect(result.emailString).not.toContain('user@example.com'); + const result = maskOnyxState(input, true); + expect(typeof result.emailString).toBe('string'); + if (typeof result.emailString === 'string') { + expect(result.emailString).not.toContain('user@example.com'); + } }); it('should mask keys that are in the fixed list', () => { @@ -298,10 +389,13 @@ describe('maskOnyxState', () => { lastMessageHtml: 'hey', }; - const result = maskOnyxState(input, true) as ExampleOnyxState; + const result = maskOnyxState(input, true); - expect(result.edits).toEqual(['***', '***']); - expect(result.lastMessageHtml).not.toEqual(input.lastMessageHtml); + expect(result).toMatchObject({edits: ['***', '***']}); + expect(typeof result.lastMessageHtml).toBe('string'); + if (typeof result.lastMessageHtml === 'string') { + expect(result.lastMessageHtml).not.toBe(input.lastMessageHtml); + } }); }); diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts index 3c89362a8e85..52568bacf533 100644 --- a/tests/unit/IOUUtilsTest.ts +++ b/tests/unit/IOUUtilsTest.ts @@ -15,7 +15,7 @@ import * as TransactionUtils from '@src/libs/TransactionUtils'; import {hasAnyTransactionWithoutRTERViolation} from '@src/libs/TransactionUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Policy, Report, ReportMetadata, Transaction, TransactionViolations} from '@src/types/onyx'; +import type {Policy, Report, ReportAction, ReportMetadata, Transaction, TransactionViolations} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; @@ -24,6 +24,7 @@ import Onyx from 'react-native-onyx'; import createRandomPolicy from '../utils/collections/policies'; import {createRandomReport} from '../utils/collections/reports'; import createRandomTransaction from '../utils/collections/transaction'; +import createMock from '../utils/createMock'; import initCurrencyListContext from '../utils/initCurrencyListContext'; const testDate = DateUtils.getDBTime(); @@ -729,31 +730,31 @@ describe('canApproveIOU', () => { it('should return true for DEW policy report without pending approval', async () => { // Given a submitted expense report on a DEW policy without any pending approval action - const report = { + const report = createMock({ reportID: REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE, ownerAccountID: currentUserAccountID, stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, managerID: currentUserAccountID, - } as unknown as Report; + }); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); - const policy = { + const policy = createMock({ type: CONST.POLICY.TYPE.TEAM, approver: CURRENT_USER_EMAIL, approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, - } as unknown as Policy; + }); const reportMetadata: ReportMetadata = {}; - const transaction = { + const transaction = createMock({ reportID: `${REPORT_ID}`, transactionID: '123', amount: 10, merchant: 'Merchant', created: '2025-01-01', - } as unknown as Transaction; + }); // When checking if approve action is available // Then it should return true because DEW approval is not in progress @@ -762,33 +763,33 @@ describe('canApproveIOU', () => { it('should return false for DEW policy report with pending approval', async () => { // Given a submitted expense report on a DEW policy with a pending approval action - const report = { + const report = createMock({ reportID: REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE, ownerAccountID: currentUserAccountID, stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, managerID: currentUserAccountID, - } as unknown as Report; + }); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); - const policy = { + const policy = createMock({ type: CONST.POLICY.TYPE.TEAM, approver: CURRENT_USER_EMAIL, approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, - } as unknown as Policy; + }); const reportMetadata: ReportMetadata = { pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE, }; - const transaction = { + const transaction = createMock({ reportID: `${REPORT_ID}`, transactionID: '123', amount: 10, merchant: 'Merchant', created: '2025-01-01', - } as unknown as Transaction; + }); // When checking if approve action is available while DEW approval is pending // Then it should return false because DEW is already processing an approval @@ -826,16 +827,16 @@ describe('canApproveIOU', () => { it('should return false for non-expense report', async () => { // Given a non-expense report - const report = { + const report = createMock({ reportID: REPORT_ID, type: CONST.REPORT.TYPE.CHAT, ownerAccountID: currentUserAccountID, - } as unknown as Report; + }); - const policy = { + const policy = createMock({ type: CONST.POLICY.TYPE.TEAM, approver: CURRENT_USER_EMAIL, - } as unknown as Policy; + }); const reportMetadata: ReportMetadata = {}; @@ -850,18 +851,18 @@ describe('getExistingTransactionID', () => { }); test('should return undefined when reportAction is not a money request action', () => { - const nonMoneyRequestAction = { + const nonMoneyRequestAction = createMock({ reportActionID: 'action1', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', message: [], - } as unknown as Parameters[0]; + }); expect(IOUUtils.getExistingTransactionID(nonMoneyRequestAction)).toBeUndefined(); }); test('should return IOUTransactionID from a valid money request action', () => { - const moneyRequestAction = { + const moneyRequestAction = createMock>({ reportActionID: 'action1', actionName: CONST.REPORT.ACTIONS.TYPE.IOU, created: '', @@ -870,7 +871,7 @@ describe('getExistingTransactionID', () => { IOUTransactionID: 'txn123', type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, }, - } as unknown as Parameters[0]; + }); expect(IOUUtils.getExistingTransactionID(moneyRequestAction)).toBe('txn123'); }); @@ -892,7 +893,7 @@ describe('getExistingTransactionID', () => { }); it('should generate optimistic ID when existing report has no reportID', () => { - const emptyReport = {} as Report; + const emptyReport = createMock({}); const result = IOUUtils.resolveOptimisticChatReportID([1, 2], emptyReport); expect(result.optimisticChatReportID).toBeDefined(); diff --git a/tests/unit/RequestConflictUtilsTest.ts b/tests/unit/RequestConflictUtilsTest.ts index d3c809fa826f..c10dc3af92b7 100644 --- a/tests/unit/RequestConflictUtilsTest.ts +++ b/tests/unit/RequestConflictUtilsTest.ts @@ -9,17 +9,17 @@ import { resolveReconnectDuplicationConflictAction, } from '@libs/actions/RequestConflictUtils'; import {WRITE_COMMANDS} from '@libs/API/types'; -import type {WriteCommand} from '@libs/API/types'; import type {AnyRequest} from '@src/types/onyx/Request'; +import type {OnyxKey} from 'react-native-onyx'; + import Onyx from 'react-native-onyx'; describe('RequestConflictUtils', () => { it.each([['OpenApp'], ['ReconnectApp']])('resolveDuplicationConflictAction when %s do not exist in the queue should push %i', (command) => { const persistedRequests = [{command: 'OpenReport'}, {command: 'AddComment'}, {command: 'CloseAccount'}]; - const commandToFind = command as WriteCommand; - const result = resolveDuplicationConflictAction(persistedRequests, (request) => request.command === commandToFind); + const result = resolveDuplicationConflictAction(persistedRequests, (request) => request.command === command); expect(result).toEqual({conflictAction: {type: 'push'}}); }); @@ -28,8 +28,7 @@ describe('RequestConflictUtils', () => { ['ReconnectApp', 2], ])('resolveDuplicationConflictAction when %s exist in the queue should replace at index %i', (command, index) => { const persistedRequests = [{command: 'OpenApp'}, {command: 'AddComment'}, {command: 'ReconnectApp'}]; - const commandToFind = command as WriteCommand; - const result = resolveDuplicationConflictAction(persistedRequests, (request) => request.command === commandToFind); + const result = resolveDuplicationConflictAction(persistedRequests, (request) => request.command === command); expect(result).toEqual({conflictAction: {type: 'replace', index}}); }); @@ -171,37 +170,37 @@ describe('RequestConflictUtils', () => { describe('resolveOpenReportDuplicationConflictAction', () => { it('returns push when no matching OpenReport for the reportID exists in the queue', () => { const persistedRequests = [{command: 'OpenApp'}, {command: WRITE_COMMANDS.OPEN_REPORT, data: {reportID: '2'}}]; - const result = resolveOpenReportDuplicationConflictAction(persistedRequests, {reportID: '1'} as never); + const result = resolveOpenReportDuplicationConflictAction(persistedRequests, {reportID: '1'}); expect(result).toEqual({conflictAction: {type: 'push'}}); }); it('returns noAction when the queued OpenReport carries guidedSetupData', () => { const persistedRequests = [{command: WRITE_COMMANDS.OPEN_REPORT, data: {reportID: '1', guidedSetupData: '[{}]'}}]; - const result = resolveOpenReportDuplicationConflictAction(persistedRequests, {reportID: '1'} as never); + const result = resolveOpenReportDuplicationConflictAction(persistedRequests, {reportID: '1'}); expect(result).toEqual({conflictAction: {type: 'noAction'}}); }); it('returns noAction when the queued request carries accountIDList but the new one has no participants', () => { const persistedRequests = [{command: WRITE_COMMANDS.OPEN_REPORT, data: {reportID: '1', accountIDList: '10,20'}}]; - const result = resolveOpenReportDuplicationConflictAction(persistedRequests, {reportID: '1'} as never); + const result = resolveOpenReportDuplicationConflictAction(persistedRequests, {reportID: '1'}); expect(result).toEqual({conflictAction: {type: 'noAction'}}); }); it('replaces when the new request also carries an accountIDList', () => { const persistedRequests = [{command: WRITE_COMMANDS.OPEN_REPORT, data: {reportID: '1', accountIDList: '10,20'}}]; - const result = resolveOpenReportDuplicationConflictAction(persistedRequests, {reportID: '1', accountIDList: '10,20'} as never); + const result = resolveOpenReportDuplicationConflictAction(persistedRequests, {reportID: '1', accountIDList: '10,20'}); expect(result).toEqual({conflictAction: {type: 'replace', index: 0}}); }); it('replaces when neither queued nor new request has participants', () => { const persistedRequests = [{command: 'OpenApp'}, {command: WRITE_COMMANDS.OPEN_REPORT, data: {reportID: '1'}}]; - const result = resolveOpenReportDuplicationConflictAction(persistedRequests, {reportID: '1'} as never); + const result = resolveOpenReportDuplicationConflictAction(persistedRequests, {reportID: '1'}); expect(result).toEqual({conflictAction: {type: 'replace', index: 1}}); }); it('replaces when the queued request has no participants but the new request does', () => { const persistedRequests = [{command: WRITE_COMMANDS.OPEN_REPORT, data: {reportID: '1'}}]; - const result = resolveOpenReportDuplicationConflictAction(persistedRequests, {reportID: '1', accountIDList: '10,20'} as never); + const result = resolveOpenReportDuplicationConflictAction(persistedRequests, {reportID: '1', accountIDList: '10,20'}); expect(result).toEqual({conflictAction: {type: 'replace', index: 0}}); }); }); @@ -209,13 +208,13 @@ describe('RequestConflictUtils', () => { describe('resolveDetachReceiptConflicts', () => { it('returns push when no replace-receipt requests match transactionID', () => { const persistedRequests = [{command: 'OpenReport'}, {command: WRITE_COMMANDS.REPLACE_RECEIPT, data: {transactionID: '2'}}, {command: 'CloseAccount'}]; - const result = resolveDetachReceiptConflicts(persistedRequests, {transactionID: '1'} as never); + const result = resolveDetachReceiptConflicts(persistedRequests, {transactionID: '1', reportActionID: '1'}); expect(result).toEqual({conflictAction: {type: 'push'}}); }); it('returns push when exactly one replace-receipt request matches transactionID', () => { const persistedRequests = [{command: WRITE_COMMANDS.REPLACE_RECEIPT, data: {transactionID: '1'}}]; - const result = resolveDetachReceiptConflicts(persistedRequests, {transactionID: '1'} as never); + const result = resolveDetachReceiptConflicts(persistedRequests, {transactionID: '1', reportActionID: '1'}); expect(result).toEqual({conflictAction: {type: 'push'}}); }); @@ -228,7 +227,7 @@ describe('RequestConflictUtils', () => { {command: WRITE_COMMANDS.REPLACE_RECEIPT, data: {transactionID: '1'}}, ]; - const result = resolveDetachReceiptConflicts(persistedRequests, {transactionID: '1'} as never); + const result = resolveDetachReceiptConflicts(persistedRequests, {transactionID: '1', reportActionID: '1'}); expect(result).toEqual({conflictAction: {type: 'delete', indices: [0, 2], pushNewRequest: true}}); }); }); diff --git a/tests/unit/TransactionTest.ts b/tests/unit/TransactionTest.ts index 142f65fd3f2a..8ca3544ea54f 100644 --- a/tests/unit/TransactionTest.ts +++ b/tests/unit/TransactionTest.ts @@ -3,10 +3,13 @@ import {act, renderHook, waitFor} from '@testing-library/react-native'; import useOnyx from '@hooks/useOnyx'; import {changeTransactionsReport as changeTransactionsReportAction, dismissDuplicateTransactionViolation, markAsCash, sanitizeWaypointsForAPI, saveWaypoint} from '@libs/actions/Transaction'; +import * as API from '@libs/API'; +import type {ChangeTransactionsReportParams} from '@libs/API/parameters'; import DateUtils from '@libs/DateUtils'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; -import type {buildOptimisticNextStep} from '@libs/NextStepUtils'; +import * as NextStepUtils from '@libs/NextStepUtils'; import {rand64} from '@libs/NumberUtils'; +import {isRecord} from '@libs/ObjectUtils'; import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; import CONST from '@src/CONST'; @@ -16,7 +19,7 @@ import type {Attendee} from '@src/types/onyx/IOU'; import type {ReportCollectionDataSet} from '@src/types/onyx/Report'; import type {OnyxData} from '@src/types/onyx/Request'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import Onyx from 'react-native-onyx'; @@ -29,6 +32,7 @@ import * as TransactionUtils from '../../src/libs/TransactionUtils'; import createRandomPolicy from '../utils/collections/policies'; import createRandomPolicyCategories from '../utils/collections/policyCategory'; import {createExpenseReport, createRandomReport} from '../utils/collections/reports'; +import createMock from '../utils/createMock'; import getOnyxValue from '../utils/getOnyxValue'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -51,6 +55,41 @@ function isCapturedOnyxData(value: unknown): value is CapturedOnyxData { return typeof value === 'object' && value !== null; } +function isChangeTransactionsReportParams(value: unknown): value is ChangeTransactionsReportParams { + return ( + isRecord(value) && + typeof value.reportID === 'string' && + typeof value.transactionList === 'string' && + typeof value.transactionIDToReportActionAndThreadData === 'string' && + (value.transactionIDToUpdatedCustomUnitRateID === undefined || typeof value.transactionIDToUpdatedCustomUnitRateID === 'string') + ); +} + +type ReportMergeUpdate = Extract, {onyxMethod: typeof Onyx.METHOD.MERGE}>; +type ReportStateMergeValue = Required, 'stateNum' | 'statusNum'>>; +type ReportStateMergeUpdate = Omit & {value: ReportStateMergeValue}; +type ReportStateNum = ValueOf; +type ReportStatusNum = ValueOf; + +function isReportStateNum(value: unknown): value is ReportStateNum { + return typeof value === 'number' && Object.values(CONST.REPORT.STATE_NUM).some((stateNum) => stateNum === value); +} + +function isReportStatusNum(value: unknown): value is ReportStatusNum { + return typeof value === 'number' && Object.values(CONST.REPORT.STATUS_NUM).some((statusNum) => statusNum === value); +} + +function isReportMergeUpdate(value: unknown, reportKey: ReportMergeUpdate['key']): value is ReportStateMergeUpdate { + return ( + isRecord(value) && + value.key === reportKey && + value.onyxMethod === Onyx.METHOD.MERGE && + isRecord(value.value) && + isReportStateNum(value.value.stateNum) && + isReportStatusNum(value.value.statusNum) + ); +} + // Wrapper mirroring the pre-refactor signature so existing test call sites compile unchanged. function changeTransactionsReport({allTransactions, transactionIDs, transactionViolations = {}, personalPolicyOutputCurrency, ...rest}: LegacyChangeTransactionsReportProps) { const transactions = transactionIDs.map((id) => allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`]).filter((transaction): transaction is Transaction => !!transaction); @@ -136,10 +175,14 @@ describe('Transaction', () => { }); }); - let mockFetch: TestHelper.MockFetch; + const mockFetch = TestHelper.setupGlobalFetchMock(); + const statefulFetchImplementation = mockFetch.getMockImplementation(); + if (!statefulFetchImplementation) { + throw new Error('Expected the stateful fetch mock implementation'); + } beforeEach(() => { - global.fetch = TestHelper.getGlobalFetchMock(); - mockFetch = global.fetch as TestHelper.MockFetch; + mockFetch.mockReset(); + mockFetch.mockImplementation(statefulFetchImplementation); return Onyx.clear().then(waitForBatchedUpdates); }); @@ -241,7 +284,7 @@ describe('Transaction', () => { }); it('correctly handles reportNextStep parameter when moving transactions between reports', async () => { - const mockAPIWrite = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); + const mockAPIWrite = jest.spyOn(API, 'write').mockResolvedValue(undefined); const transaction = generateTransaction({ reportID: FAKE_OLD_REPORT_ID, @@ -285,7 +328,7 @@ describe('Transaction', () => { expect(mockAPIWrite).toHaveBeenCalled(); const apiWriteCall = mockAPIWrite.mock.calls.at(0); - const failureData = (apiWriteCall?.[2] as {failureData?: Array<{key: string; value: unknown}>})?.failureData; + const failureData = apiWriteCall?.[2]?.failureData; const nextStepFailureData = failureData?.find((data) => data.key === `${ONYXKEYS.COLLECTION.NEXT_STEP}${FAKE_NEW_REPORT_ID}`); @@ -296,7 +339,8 @@ describe('Transaction', () => { }); it('correctly handles reportNextStep parameter when moving transactions to unreported report', async () => { - const mockAPIWrite = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); + // eslint-disable-next-line rulesdir/no-multiple-api-calls -- Spy on API.write to inspect the generated failure data. + const mockAPIWrite = jest.spyOn(API, 'write').mockResolvedValue(undefined); const transaction = generateTransaction({ reportID: FAKE_OLD_REPORT_ID, @@ -341,7 +385,7 @@ describe('Transaction', () => { expect(mockAPIWrite).toHaveBeenCalled(); const apiWriteCall = mockAPIWrite.mock.calls.at(0); - const failureData = (apiWriteCall?.[2] as {failureData?: Array<{key: string; value: unknown}>})?.failureData; + const failureData = apiWriteCall?.[2]?.failureData; const nextStepFailureData = failureData?.find((data) => data.key === `${ONYXKEYS.COLLECTION.NEXT_STEP}${CONST.REPORT.UNREPORTED_REPORT_ID}`); @@ -496,7 +540,8 @@ describe('Transaction', () => { }); it('correctly handles undefined reportNextStep parameter', async () => { - const mockAPIWrite = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); + // eslint-disable-next-line rulesdir/no-multiple-api-calls -- Spy on API.write to inspect the generated failure data. + const mockAPIWrite = jest.spyOn(API, 'write').mockResolvedValue(undefined); const transaction = generateTransaction({ reportID: FAKE_OLD_REPORT_ID, @@ -529,7 +574,7 @@ describe('Transaction', () => { expect(mockAPIWrite).toHaveBeenCalled(); const apiWriteCall = mockAPIWrite.mock.calls.at(0); - const failureData = (apiWriteCall?.[2] as {failureData?: Array<{key: string; value: unknown}>})?.failureData; + const failureData = apiWriteCall?.[2]?.failureData; const nextStepFailureData = failureData?.find((data) => data.key === `${ONYXKEYS.COLLECTION.NEXT_STEP}${FAKE_NEW_REPORT_ID}`); @@ -540,8 +585,9 @@ describe('Transaction', () => { }); it('updates the source submitted report next step and reopens it when it becomes empty', async () => { - const mockAPIWrite = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); - const buildOptimisticNextStepSpy = jest.spyOn(require('@libs/NextStepUtils'), 'buildOptimisticNextStep'); + // eslint-disable-next-line rulesdir/no-multiple-api-calls -- Spy on API.write to inspect the generated optimistic data. + const mockAPIWrite = jest.spyOn(API, 'write').mockResolvedValue(undefined); + const buildOptimisticNextStepSpy = jest.spyOn(NextStepUtils, 'buildOptimisticNextStep'); const transaction = generateTransaction({ reportID: FAKE_OLD_REPORT_ID, @@ -583,22 +629,22 @@ describe('Transaction', () => { await waitForBatchedUpdates(); try { - const buildOptimisticNextStepCalls = buildOptimisticNextStepSpy.mock.calls as Array<[Parameters[0]]>; - const sourceNextStepCall = buildOptimisticNextStepCalls.find(([params]) => params.report?.reportID === FAKE_OLD_REPORT_ID); + const sourceNextStepCall = buildOptimisticNextStepSpy.mock.calls.find(([params]) => params.report?.reportID === FAKE_OLD_REPORT_ID); expect(sourceNextStepCall).toBeDefined(); expect(sourceNextStepCall?.[0].predictedNextStatus).toBe(CONST.REPORT.STATUS_NUM.OPEN); - const apiWriteCall = mockAPIWrite.mock.calls.at(0); - const optimisticData = (apiWriteCall?.[2] as {optimisticData?: Array<{key: string; value: Partial}>})?.optimisticData; - const sourceNextStepUpdate = optimisticData?.find((data) => data.key === `${ONYXKEYS.COLLECTION.NEXT_STEP}${FAKE_OLD_REPORT_ID}`); - const sourceReportStateUpdate = optimisticData?.find( - (data) => data.key === `${ONYXKEYS.COLLECTION.REPORT}${FAKE_OLD_REPORT_ID}` && 'stateNum' in data.value && 'statusNum' in data.value, - ); + const [, , onyxData] = TestHelper.getRequiredWriteCall(mockAPIWrite.mock.calls, 0); + const sourceNextStepUpdate = TestHelper.getRequiredOnyxUpdate(onyxData, 'optimisticData', `${ONYXKEYS.COLLECTION.NEXT_STEP}${FAKE_OLD_REPORT_ID}`, Onyx.METHOD.MERGE); + const reportKey: ReportMergeUpdate['key'] = `${ONYXKEYS.COLLECTION.REPORT}${FAKE_OLD_REPORT_ID}`; + const sourceReportStateUpdate = TestHelper.getRequiredOnyxUpdates(onyxData, 'optimisticData').find((update) => isReportMergeUpdate(update, reportKey)); + if (!sourceReportStateUpdate) { + throw new Error('Expected a typed report state update'); + } expect(sourceNextStepUpdate).toBeDefined(); - expect(sourceReportStateUpdate?.value.stateNum).toBe(CONST.REPORT.STATE_NUM.OPEN); - expect(sourceReportStateUpdate?.value.statusNum).toBe(CONST.REPORT.STATUS_NUM.OPEN); + expect(sourceReportStateUpdate.value.stateNum).toBe(CONST.REPORT.STATE_NUM.OPEN); + expect(sourceReportStateUpdate.value.statusNum).toBe(CONST.REPORT.STATUS_NUM.OPEN); } finally { buildOptimisticNextStepSpy.mockRestore(); mockAPIWrite.mockRestore(); @@ -606,7 +652,8 @@ describe('Transaction', () => { }); it('correctly handles ASAP submit beta enabled when moving transactions', async () => { - const mockAPIWrite = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); + // eslint-disable-next-line rulesdir/no-multiple-api-calls -- Spy on API.write to inspect the generated parameters. + const mockAPIWrite = jest.spyOn(API, 'write').mockResolvedValue(undefined); const transaction = generateTransaction({ reportID: FAKE_OLD_REPORT_ID, @@ -637,10 +684,10 @@ describe('Transaction', () => { expect(mockAPIWrite).toHaveBeenCalled(); - const apiWriteCall = mockAPIWrite.mock.calls.at(0); - const parameters = apiWriteCall?.[1] as {reportID: string; transactionList: string; transactionIDToReportActionAndThreadData: string}; - - expect(parameters).toBeDefined(); + const [, parameters] = TestHelper.getRequiredWriteCall(mockAPIWrite.mock.calls, 0); + if (!isChangeTransactionsReportParams(parameters)) { + throw new Error('Expected changeTransactionsReport API.write parameters'); + } expect(parameters.reportID).toBe(FAKE_NEW_REPORT_ID); expect(parameters.transactionList).toBe(transaction.transactionID); @@ -648,7 +695,8 @@ describe('Transaction', () => { }); it('correctly handles different account IDs and emails when moving transactions', async () => { - const mockAPIWrite = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); + // eslint-disable-next-line rulesdir/no-multiple-api-calls -- Spy on API.write to inspect the generated parameters. + const mockAPIWrite = jest.spyOn(API, 'write').mockResolvedValue(undefined); const transaction = generateTransaction({ reportID: FAKE_OLD_REPORT_ID, @@ -682,10 +730,10 @@ describe('Transaction', () => { expect(mockAPIWrite).toHaveBeenCalled(); - const apiWriteCall = mockAPIWrite.mock.calls.at(0); - const parameters = apiWriteCall?.[1] as {reportID: string; transactionList: string; transactionIDToReportActionAndThreadData: string}; - - expect(parameters).toBeDefined(); + const [, parameters] = TestHelper.getRequiredWriteCall(mockAPIWrite.mock.calls, 0); + if (!isChangeTransactionsReportParams(parameters)) { + throw new Error('Expected changeTransactionsReport API.write parameters'); + } expect(parameters.reportID).toBe(FAKE_NEW_REPORT_ID); expect(parameters.transactionList).toBe(transaction.transactionID); @@ -1364,7 +1412,8 @@ describe('Transaction', () => { }); it('should not call API.write when the transaction is already on the target report', async () => { - const mockAPIWrite = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); + // eslint-disable-next-line rulesdir/no-multiple-api-calls -- Spy on API.write to inspect the generated updates. + const mockAPIWrite = jest.spyOn(API, 'write').mockResolvedValue(undefined); const transaction = generateTransaction({ reportID: FAKE_NEW_REPORT_ID, @@ -2174,8 +2223,7 @@ describe('Transaction', () => { // When sanitizing the waypoints // Test intentionally passes extra fields not in WaypointCollection to verify they are stripped - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument - const sanitizedWaypoints = sanitizeWaypointsForAPI(waypointsWithExtraFields as any); + const sanitizedWaypoints = sanitizeWaypointsForAPI(waypointsWithExtraFields); // Then only allowed fields should remain expect(sanitizedWaypoints.waypoint0).toEqual({ @@ -2203,8 +2251,7 @@ describe('Transaction', () => { // When sanitizing the waypoints // Test uses a partial waypoint object to verify sanitization handles missing fields - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument - const sanitizedWaypoints = sanitizeWaypointsForAPI(waypointsWithPartialFields as any); + const sanitizedWaypoints = sanitizeWaypointsForAPI(waypointsWithPartialFields); // Then only the address should be present expect(sanitizedWaypoints.waypoint0).toEqual({ @@ -2241,8 +2288,8 @@ describe('Transaction', () => { // When sanitizing the waypoints // Null entries can occur at runtime even though WaypointCollection type doesn't include null - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument - const sanitizedWaypoints = sanitizeWaypointsForAPI(waypointsWithNulls as any); + // @ts-expect-error -- rollback data can contain null waypoint entries even though the production collection type excludes null. + const sanitizedWaypoints = sanitizeWaypointsForAPI(waypointsWithNulls); // Then null entries should be dropped and valid entries sanitized expect(sanitizedWaypoints).toEqual({ @@ -2730,7 +2777,7 @@ describe('removeTransactionFromDuplicateTransactionViolation', () => { function makeTransactionCollection(...ids: string[]) { const collection: Record = {}; for (const id of ids) { - collection[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`] = {transactionID: id} as Transaction; + collection[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`] = createMock({transactionID: id}); } return collection; } diff --git a/tests/unit/hooks/useGettingStartedItems.test.ts b/tests/unit/hooks/useGettingStartedItems.test.ts index 34ae044f6326..95b259ae35c5 100644 --- a/tests/unit/hooks/useGettingStartedItems.test.ts +++ b/tests/unit/hooks/useGettingStartedItems.test.ts @@ -4,6 +4,7 @@ import {renderHook, waitFor} from '@testing-library/react-native'; import useGettingStartedItems from '@pages/home/GettingStartedSection/hooks/useGettingStartedItems'; import CONST from '@src/CONST'; +import type {OnboardingAccounting} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Policy, PolicyCategories} from '@src/types/onyx'; @@ -12,6 +13,7 @@ import type {PolicyEmployeeList} from '@src/types/onyx/PolicyEmployee'; import Onyx from 'react-native-onyx'; import createRandomPolicy from '../../utils/collections/policies'; +import createMock from '../../utils/createMock'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; jest.mock('@hooks/useLocalize', () => @@ -103,7 +105,7 @@ async function setupTrackPersonalScenario(overrides: {policy?: Partial; await waitForBatchedUpdates(); } -async function setupManageTeamScenario(overrides: {policy?: Partial; accounting?: string | null; firstDayTrial?: string; lastDayTrial?: string} = {}) { +async function setupManageTeamScenario(overrides: {policy?: Partial; accounting?: OnboardingAccounting; firstDayTrial?: string; lastDayTrial?: string} = {}) { // New workspaces enable Categories by default, so keep the categories step visible unless a test opts out. const policy = buildPolicy({areCategoriesEnabled: true, ...overrides.policy}); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); @@ -111,7 +113,7 @@ async function setupManageTeamScenario(overrides: {policy?: Partial; acc await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, POLICY_ID); if (overrides.accounting !== undefined) { - await Onyx.merge(ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION, overrides.accounting as never); + await Onyx.merge(ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION, overrides.accounting); } const now = new Date(); @@ -186,7 +188,7 @@ describe('useGettingStartedItems', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); await Onyx.merge(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, RECENT_TRIAL_START); await Onyx.merge(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL, FUTURE_TRIAL_END); - await Onyx.merge(ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION, CONST.POLICY.CONNECTIONS.NAME.QBO as never); + await Onyx.merge(ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION, CONST.POLICY.CONNECTIONS.NAME.QBO); await waitForBatchedUpdates(); const {result} = renderHook(() => useGettingStartedItems()); @@ -246,7 +248,7 @@ describe('useGettingStartedItems', () => { await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, POLICY_ID); const policy = buildPolicy(); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); - await Onyx.merge(ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION, CONST.POLICY.CONNECTIONS.NAME.QBO as never); + await Onyx.merge(ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION, CONST.POLICY.CONNECTIONS.NAME.QBO); await waitForBatchedUpdates(); const {result} = renderHook(() => useGettingStartedItems()); @@ -348,13 +350,13 @@ describe('useGettingStartedItems', () => { // Keep an incomplete card row so the section stays visible; it hides once every item is complete. areCompanyCardsEnabled: true, - connections: { + connections: createMock>({ [CONST.POLICY.CONNECTIONS.NAME.QBO]: { config: {}, data: {}, lastSync: {isConnected: true}, }, - } as Policy['connections'], + }), }, }); @@ -372,13 +374,13 @@ describe('useGettingStartedItems', () => { accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, policy: { areConnectionsEnabled: true, - connections: { + connections: createMock>({ [CONST.POLICY.CONNECTIONS.NAME.QBO]: { config: {}, data: {}, lastSync: {isConnected: false}, }, - } as Policy['connections'], + }), }, }); @@ -397,13 +399,13 @@ describe('useGettingStartedItems', () => { // Keep an incomplete card row so the section stays visible; it hides once every item is complete. areCompanyCardsEnabled: true, - connections: { + connections: createMock>({ [CONST.POLICY.CONNECTIONS.NAME.QBO]: { config: {}, data: {}, lastSync: {isConnected: false, successfulDate: '2024-01-01'}, }, - } as Policy['connections'], + }), }, }); @@ -433,13 +435,13 @@ describe('useGettingStartedItems', () => { // Keep an incomplete card row so the section stays visible; it hides once every item is complete. areCompanyCardsEnabled: true, - connections: { + connections: createMock>({ [CONST.POLICY.CONNECTIONS.NAME.QBO]: { config: {}, data: {}, lastSync: {isConnected: true}, }, - } as Policy['connections'], + }), }, }); @@ -471,13 +473,13 @@ describe('useGettingStartedItems', () => { // Keep an incomplete card row so the section stays visible; it hides once every item is complete. areCompanyCardsEnabled: true, - connections: { + connections: createMock>({ [CONST.POLICY.CONNECTIONS.NAME.QBO]: { config: {}, data: {}, lastSync: {isConnected: true}, }, - } as Policy['connections'], + }), }, }); @@ -915,7 +917,7 @@ describe('useGettingStartedItems', () => { policy: { areRulesEnabled: true, type: CONST.POLICY.TYPE.CORPORATE, - rules: { + rules: createMock>({ approvalRules: [ { applyWhen: [{condition: 'matches', field: 'amount', value: '1000'}], @@ -923,7 +925,7 @@ describe('useGettingStartedItems', () => { id: 'rule-1', }, ], - } as Policy['rules'], + }), }, }); @@ -1260,7 +1262,7 @@ describe('useGettingStartedItems', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); await Onyx.merge(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, RECENT_TRIAL_START); await Onyx.merge(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL, FUTURE_TRIAL_END); - await Onyx.merge(ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION, CONST.POLICY.CONNECTIONS.NAME.QBO as never); + await Onyx.merge(ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION, CONST.POLICY.CONNECTIONS.NAME.QBO); await waitForBatchedUpdates(); const {result} = renderHook(() => useGettingStartedItems()); @@ -1441,16 +1443,16 @@ describe('useGettingStartedItems', () => { await setupTrackWorkspaceScenario({ policy: { areConnectionsEnabled: true, - connections: { + connections: createMock>({ [CONST.POLICY.CONNECTIONS.NAME.QBO]: { config: {}, data: {}, lastSync: {isConnected: true}, }, - } as Policy['connections'], + }), }, }); - await Onyx.merge(ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION, CONST.POLICY.CONNECTIONS.NAME.QBO as never); + await Onyx.merge(ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION, CONST.POLICY.CONNECTIONS.NAME.QBO); await waitForBatchedUpdates(); const {result} = renderHook(() => useGettingStartedItems()); diff --git a/tests/unit/hooks/useIsBlockedToAddFeed.test.ts b/tests/unit/hooks/useIsBlockedToAddFeed.test.ts index 9cf4f4d4c589..2021590e28c9 100644 --- a/tests/unit/hooks/useIsBlockedToAddFeed.test.ts +++ b/tests/unit/hooks/useIsBlockedToAddFeed.test.ts @@ -3,12 +3,16 @@ import {renderHook} from '@testing-library/react-native'; import useCardFeeds from '@hooks/useCardFeeds'; import useIsBlockedToAddFeed from '@hooks/useIsBlockedToAddFeed'; +import {getCardFeedWithDomainID} from '@libs/CardUtils'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {CombinedCardFeed, CombinedCardFeeds} from '@src/types/onyx/CardFeeds'; import Onyx from 'react-native-onyx'; import createRandomPolicy from '../../utils/collections/policies'; +import createMock from '../../utils/createMock'; const mockPolicyID = '123456'; @@ -18,30 +22,37 @@ const delay = (ms: number) => }); const mockPolicy = {...createRandomPolicy(Number(mockPolicyID), CONST.POLICY.TYPE.TEAM, 'TestPolicy'), policyID: mockPolicyID, policyAccountID: Number(mockPolicyID)}; +type UseCardFeedsResult = ReturnType; + +const loadedResultMetadata: UseCardFeedsResult[1] = {status: 'loaded'}; +const loadingResultMetadata: UseCardFeedsResult[1] = {status: 'loading'}; +const emptyCardFeedStatuses: UseCardFeedsResult[3] = {}; +const mockWorkspaceAccountID: UseCardFeedsResult[4] = Number(mockPolicyID); -const mockCardFeeds = { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'plaid.ins_19': { +const mockCardFeeds: CombinedCardFeeds = { + [getCardFeedWithDomainID(CONST.COMPANY_CARD.FEED_BANK_NAME.MOCK_BANK, mockWorkspaceAccountID)]: createMock({ asrEnabled: false, country: 'US', - feed: 'plaid.ins_19', - domainID: 123456, + feed: CONST.COMPANY_CARD.FEED_BANK_NAME.MOCK_BANK, + domainID: mockWorkspaceAccountID, forceReimbursable: 'force_no', liabilityType: 'corporate', preferredPolicy: '135CA2196CD21C88', reportTitleFormat: '', - shouldApplyCashbackToBill: true, statementPeriodEndDay: 'LAST_DAY_OF_MONTH', - uploadLayoutSettings: [], + uploadLayoutSettings: {}, customFeedName: 'Regions Bank cards', accountList: ['Plaid Checking 0000', 'Plaid Credit Card 3333'], - }, + }), }; jest.mock('@hooks/useCardFeeds', () => ({ __esModule: true, default: jest.fn(), })); + +const mockedUseCardFeeds = jest.mocked(useCardFeeds); + describe('useIsBlockedToAddFeed', () => { beforeAll(() => { Onyx.init({ @@ -52,33 +63,37 @@ describe('useIsBlockedToAddFeed', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${mockPolicy?.policyID}`, mockPolicy); }); it('should return true if collect policy and feed already exists', () => { - (useCardFeeds as jest.Mock).mockReturnValue([mockCardFeeds, {status: 'loaded'}]); + mockedUseCardFeeds.mockReturnValue([mockCardFeeds, loadedResultMetadata, undefined, emptyCardFeedStatuses, mockWorkspaceAccountID]); const {result} = renderHook(() => useIsBlockedToAddFeed(mockPolicyID)); expect(result?.current.isBlockedToAddNewFeeds).toBe(true); }); it('should return isBlockedToAddNewFeeds as false if control policy', async () => { - (useCardFeeds as jest.Mock).mockReturnValue([mockCardFeeds, {status: 'loaded'}]); + mockedUseCardFeeds.mockReturnValue([mockCardFeeds, loadedResultMetadata, undefined, emptyCardFeedStatuses, mockWorkspaceAccountID]); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${mockPolicy?.policyID}`, {...mockPolicy, type: CONST.POLICY.TYPE.CORPORATE}); const {result} = renderHook(() => useIsBlockedToAddFeed(mockPolicyID)); expect(result?.current.isBlockedToAddNewFeeds).toBe(false); }); it('should return isBlockedToAddNewFeeds as false if collect policy and new feed added', async () => { - (useCardFeeds as jest.Mock).mockReturnValue([{}, {status: 'loaded'}]); + mockedUseCardFeeds.mockReturnValue([{}, loadedResultMetadata, undefined, emptyCardFeedStatuses, mockWorkspaceAccountID]); const {result, rerender} = renderHook(() => useIsBlockedToAddFeed(mockPolicyID)); expect(result.current.isBlockedToAddNewFeeds).toBe(false); // Set initial empty state and wait for new connection to be established await delay(2000); - (useCardFeeds as jest.Mock).mockReturnValue([ + mockedUseCardFeeds.mockReturnValue([ { - ins: { - feed: 'ins', + [getCardFeedWithDomainID(CONST.COMPANY_CARD.FEED_BANK_NAME.MOCK_BANK, mockWorkspaceAccountID)]: createMock({ + feed: CONST.COMPANY_CARD.FEED_BANK_NAME.MOCK_BANK, + domainID: mockWorkspaceAccountID, customFeedName: 'Regions Bank cards', accountList: ['Plaid Checking 0000', 'Plaid Credit Card 3333'], - }, + }), }, - {status: 'loaded'}, + loadedResultMetadata, + undefined, + emptyCardFeedStatuses, + mockWorkspaceAccountID, ]); // Wait to set state happened await delay(2000); @@ -88,79 +103,74 @@ describe('useIsBlockedToAddFeed', () => { }); it('should return isBlockedToAddNewFeeds as false if collect policy and no feed added', async () => { - (useCardFeeds as jest.Mock).mockReturnValue([{}, {status: 'loaded'}]); + mockedUseCardFeeds.mockReturnValue([{}, loadedResultMetadata, undefined, emptyCardFeedStatuses, mockWorkspaceAccountID]); const {result} = renderHook(() => useIsBlockedToAddFeed(mockPolicyID)); expect(result.current.isBlockedToAddNewFeeds).toBe(false); }); it('should return isBlockedToAddNewFeeds as false if collect policy and Expensify feed exists', async () => { - (useCardFeeds as jest.Mock).mockReturnValue([ + mockedUseCardFeeds.mockReturnValue([ { - [CONST.EXPENSIFY_CARD.BANK]: { + [getCardFeedWithDomainID(CONST.EXPENSIFY_CARD.BANK, mockWorkspaceAccountID)]: createMock({ feed: CONST.EXPENSIFY_CARD.BANK, - domainID: 123456, - centralTravelBilling: false, - expensifyCardMonthlySettlementDate: 0, - expensifyCardSettlementBankAccount: { - bankAccountID: 3288123, - maskedNumber: '111122XXXXXX1111', - ownerEmail: '1234@gmail.com', - state: 'OPEN', - }, - expensifyCardSettlementFrequency: 'daily', - expensifyCardUseContinuousReconciliation: true, - policyWithdrawalIDMap: [], + domainID: mockWorkspaceAccountID, preferredPolicy: mockPolicyID, - }, + }), }, - {status: 'loaded'}, + loadedResultMetadata, + undefined, + emptyCardFeedStatuses, + mockWorkspaceAccountID, ]); const {result} = renderHook(() => useIsBlockedToAddFeed(mockPolicyID)); expect(result.current.isBlockedToAddNewFeeds).toBe(false); }); it('should return isBlockedToAddNewFeeds as false if collect policy and only CSV feed exists', () => { - (useCardFeeds as jest.Mock).mockReturnValue([ + mockedUseCardFeeds.mockReturnValue([ { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'csv#123456': { - feed: 'csv#123456', - domainID: 123456, + [getCardFeedWithDomainID(CONST.COMPANY_CARD.FEED_BANK_NAME.CSV_CLASSIC, mockWorkspaceAccountID)]: createMock({ + feed: CONST.COMPANY_CARD.FEED_BANK_NAME.CSV_CLASSIC, + domainID: mockWorkspaceAccountID, customFeedName: 'CSV Upload', accountList: [], - }, + }), }, - {status: 'loaded'}, + loadedResultMetadata, + undefined, + emptyCardFeedStatuses, + mockWorkspaceAccountID, ]); const {result} = renderHook(() => useIsBlockedToAddFeed(mockPolicyID)); expect(result.current.isBlockedToAddNewFeeds).toBe(false); }); it('should return isBlockedToAddNewFeeds as true if collect policy has CSV feed and a real feed', () => { - (useCardFeeds as jest.Mock).mockReturnValue([ + mockedUseCardFeeds.mockReturnValue([ { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'csv#123456': { - feed: 'csv#123456', - domainID: 123456, + [getCardFeedWithDomainID(CONST.COMPANY_CARD.FEED_BANK_NAME.CSV_CLASSIC, mockWorkspaceAccountID)]: createMock({ + feed: CONST.COMPANY_CARD.FEED_BANK_NAME.CSV_CLASSIC, + domainID: mockWorkspaceAccountID, customFeedName: 'CSV Upload', accountList: [], - }, - // eslint-disable-next-line @typescript-eslint/naming-convention - 'plaid.ins_19': { - feed: 'plaid.ins_19', - domainID: 123456, + }), + [getCardFeedWithDomainID(CONST.COMPANY_CARD.FEED_BANK_NAME.MOCK_BANK, mockWorkspaceAccountID)]: createMock({ + feed: CONST.COMPANY_CARD.FEED_BANK_NAME.MOCK_BANK, + domainID: mockWorkspaceAccountID, customFeedName: 'Bank Feed', accountList: [], - }, + }), }, - {status: 'loaded'}, + loadedResultMetadata, + undefined, + emptyCardFeedStatuses, + mockWorkspaceAccountID, ]); const {result} = renderHook(() => useIsBlockedToAddFeed(mockPolicyID)); expect(result.current.isBlockedToAddNewFeeds).toBe(true); }); it('should return isBlockedToAddNewFeeds as false when data is still loading', () => { - (useCardFeeds as jest.Mock).mockReturnValue([mockCardFeeds, {status: 'loading'}, {isLoading: true}]); + mockedUseCardFeeds.mockReturnValue([mockCardFeeds, loadingResultMetadata, {isLoading: true, settings: {}}, emptyCardFeedStatuses, mockWorkspaceAccountID]); const {result} = renderHook(() => useIsBlockedToAddFeed(mockPolicyID)); // Should not block while loading, even if feeds exist expect(result.current.isBlockedToAddNewFeeds).toBe(false); @@ -169,11 +179,11 @@ describe('useIsBlockedToAddFeed', () => { }); it('should transition from not blocked (loading) to blocked (loaded) when data finishes loading', async () => { - (useCardFeeds as jest.Mock).mockReturnValue([mockCardFeeds, {status: 'loading'}, {isLoading: true}]); + mockedUseCardFeeds.mockReturnValue([mockCardFeeds, loadingResultMetadata, {isLoading: true, settings: {}}, emptyCardFeedStatuses, mockWorkspaceAccountID]); const {result, rerender} = renderHook(() => useIsBlockedToAddFeed(mockPolicyID)); expect(result.current.isBlockedToAddNewFeeds).toBe(false); - (useCardFeeds as jest.Mock).mockReturnValue([mockCardFeeds, {status: 'loaded'}, {isLoading: false}]); + mockedUseCardFeeds.mockReturnValue([mockCardFeeds, loadedResultMetadata, {isLoading: false, settings: {}}, emptyCardFeedStatuses, mockWorkspaceAccountID]); rerender({policyID: mockPolicyID}); expect(result.current.isBlockedToAddNewFeeds).toBe(true); }); diff --git a/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx b/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx index 73d4609009db..4c162387fe76 100644 --- a/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx +++ b/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx @@ -4,17 +4,19 @@ import Navigation from '@libs/Navigation/Navigation'; import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; import CONST from '@src/CONST'; +import type {OnboardingAccounting} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import GettingStartedSection from '@src/pages/home/GettingStartedSection'; import useGettingStartedItems from '@src/pages/home/GettingStartedSection/hooks/useGettingStartedItems'; import ROUTES from '@src/ROUTES'; -import type {PolicyCategories, Report} from '@src/types/onyx'; +import type {Policy, PolicyCategories, Report} from '@src/types/onyx'; import type {ValueOf} from 'type-fest'; import React from 'react'; import Onyx from 'react-native-onyx'; +import createMock from '../../../../utils/createMock'; import waitForBatchedUpdates from '../../../../utils/waitForBatchedUpdates'; const TEST_POLICY_ID = 'ABC123'; @@ -59,7 +61,7 @@ const gettingStartedItemsWrapper = ({children}: {children: React.ReactNode}) => * so the section is visible by default. */ async function setManageTeamUserState(overrides?: { - integration?: string | null; + integration?: OnboardingAccounting; areCompanyCardsEnabled?: boolean; areRulesEnabled?: boolean; areAccountingEnabled?: boolean; @@ -79,9 +81,9 @@ async function setManageTeamUserState(overrides?: { }); await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, TEST_POLICY_ID); await Onyx.set(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, trialStart); - await Onyx.set(ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION, (overrides?.integration ?? 'quickbooksOnline') as never); + await Onyx.set(ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION, overrides?.integration ?? 'quickbooksOnline'); - const policyData: Record = { + const policyData = createMock({ id: TEST_POLICY_ID, name: 'Test Workspace', type: overrides?.policyType ?? CONST.POLICY.TYPE.TEAM, @@ -89,7 +91,7 @@ async function setManageTeamUserState(overrides?: { areCompanyCardsEnabled: overrides?.areCompanyCardsEnabled ?? true, areConnectionsEnabled: overrides?.areAccountingEnabled, areCategoriesEnabled: overrides?.areCategoriesEnabled, - }; + }); if (overrides && 'areRulesEnabled' in overrides) { policyData.areRulesEnabled = overrides.areRulesEnabled; @@ -98,15 +100,15 @@ async function setManageTeamUserState(overrides?: { } if (overrides?.hasAccountingConnection) { - policyData.connections = { + policyData.connections = createMock>({ quickbooksOnline: { config: {}, data: {}, }, - }; + }); } - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${TEST_POLICY_ID}` as never, policyData as never); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${TEST_POLICY_ID}`, policyData); if (overrides?.policyCategories) { await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${TEST_POLICY_ID}`, overrides.policyCategories); @@ -159,15 +161,15 @@ describe('GettingStartedSection', () => { await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, TEST_POLICY_ID); await Onyx.set(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, RECENT_TRIAL_START); await Onyx.set( - `${ONYXKEYS.COLLECTION.POLICY}${TEST_POLICY_ID}` as never, - { + `${ONYXKEYS.COLLECTION.POLICY}${TEST_POLICY_ID}`, + createMock({ id: TEST_POLICY_ID, name: 'Test Workspace', type: CONST.POLICY.TYPE.TEAM, role: CONST.POLICY.ROLE.USER, areCompanyCardsEnabled: true, areRulesEnabled: true, - } as never, + }), ); await waitForBatchedUpdates(); @@ -184,15 +186,15 @@ describe('GettingStartedSection', () => { await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, TEST_POLICY_ID); await Onyx.set(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, RECENT_TRIAL_START); await Onyx.set( - `${ONYXKEYS.COLLECTION.POLICY}${TEST_POLICY_ID}` as never, - { + `${ONYXKEYS.COLLECTION.POLICY}${TEST_POLICY_ID}`, + createMock({ id: TEST_POLICY_ID, name: 'Test Workspace', type: CONST.POLICY.TYPE.TEAM, role: CONST.POLICY.ROLE.AUDITOR, areCompanyCardsEnabled: true, areRulesEnabled: true, - } as never, + }), ); await waitForBatchedUpdates(); @@ -212,19 +214,19 @@ describe('GettingStartedSection', () => { }); it('renders when manage-team intent is set via fallback ONBOARDING_PURPOSE_SELECTED', async () => { - await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.MANAGE_TEAM as never); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.MANAGE_TEAM); await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, TEST_POLICY_ID); await Onyx.set(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, RECENT_TRIAL_START); await Onyx.set( - `${ONYXKEYS.COLLECTION.POLICY}${TEST_POLICY_ID}` as never, - { + `${ONYXKEYS.COLLECTION.POLICY}${TEST_POLICY_ID}`, + createMock({ id: TEST_POLICY_ID, name: 'Test Workspace', type: CONST.POLICY.TYPE.TEAM, role: CONST.POLICY.ROLE.ADMIN, areCompanyCardsEnabled: true, areRulesEnabled: true, - } as never, + }), ); await waitForBatchedUpdates(); @@ -342,12 +344,21 @@ describe('GettingStartedSection', () => { await waitForBatchedUpdates(); const allRows = screen.getAllByText(/homePage\.gettingStartedSection\./); - const rowTexts = allRows.map((el) => (el.props as {children: string}).children); + const rowTexts = allRows.map((el) => { + if (typeof el.props.children !== 'string') { + throw new Error('Expected getting started row text'); + } + return el.props.children; + }); const createIdx = rowTexts.findIndex((t) => t.includes('createWorkspace')); const accountingIdx = rowTexts.findIndex((t) => t.includes('connectAccounting')); const cardsIdx = rowTexts.findIndex((t) => t.includes('linkCompanyCards')); const rulesIdx = rowTexts.findIndex((t) => t.includes('setupRules')); + expect(createIdx).toBeGreaterThanOrEqual(0); + expect(accountingIdx).toBeGreaterThanOrEqual(0); + expect(cardsIdx).toBeGreaterThanOrEqual(0); + expect(rulesIdx).toBeGreaterThanOrEqual(0); expect(createIdx).toBeLessThan(accountingIdx); expect(accountingIdx).toBeLessThan(cardsIdx); expect(cardsIdx).toBeLessThan(rulesIdx);