diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index 97b9ba885870..794c54d4a121 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -4,7 +4,6 @@ import {act, fireEvent, render, screen} from '@testing-library/react-native'; import {addSeconds, format, subMinutes} from 'date-fns'; import React from 'react'; import Onyx from 'react-native-onyx'; -import type {ApiCommand} from '@libs/API/types'; import * as Localize from '@libs/Localize'; import * as AppActions from '@userActions/App'; import * as User from '@userActions/User'; @@ -88,14 +87,17 @@ const USER_A_EMAIL = 'user_a@test.com'; const USER_B_ACCOUNT_ID = 2; const USER_B_EMAIL = 'user_b@test.com'; -function mockOpenReport(messageCount: number, includeCreatedAction: boolean) { +function buildReportComments(count: number, initialID: string, reverse = false) { + let currentID = parseInt(initialID, 10); const TEN_MINUTES_AGO = subMinutes(new Date(), 10); - const actions = Object.fromEntries( - Array.from({length: messageCount}).map((_, index) => { - const created = format(addSeconds(TEN_MINUTES_AGO, 10 * index), CONST.DATE.FNS_DB_FORMAT_STRING); + return Object.fromEntries( + Array.from({length: Math.min(count, currentID)}).map(() => { + const created = format(addSeconds(TEN_MINUTES_AGO, 10 * currentID), CONST.DATE.FNS_DB_FORMAT_STRING); + const id = currentID; + currentID += reverse ? 1 : -1; return [ - `${index + 1}`, - index === 0 && includeCreatedAction + `${id}`, + id === 1 ? { reportActionID: '1', actionName: 'CREATED' as const, @@ -107,23 +109,44 @@ function mockOpenReport(messageCount: number, includeCreatedAction: boolean) { }, ], } - : TestHelper.buildTestReportComment(created, USER_B_ACCOUNT_ID, `${index + 1}`), + : TestHelper.buildTestReportComment(created, USER_B_ACCOUNT_ID, `${id}`), ]; }), ); - fetchMock.mockAPICommand('OpenReport', [ +} + +function mockOpenReport(messageCount: number, initialID: string) { + fetchMock.mockAPICommand('OpenReport', () => [ { onyxMethod: 'merge', key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, - value: actions, + value: buildReportComments(messageCount, initialID), }, ]); } -function expectAPICommandToHaveBeenCalled(commandName: ApiCommand, expectedCalls: number) { - expect(fetchMock.mock.calls.filter((c) => c[0] === `https://www.expensify.com.dev/api/${commandName}?`)).toHaveLength(expectedCalls); +function mockGetOlderActions(messageCount: number) { + fetchMock.mockAPICommand('GetOlderActions', ({reportActionID}) => [ + { + onyxMethod: 'merge', + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + // The API also returns the action that was requested with the reportActionID. + value: buildReportComments(messageCount + 1, reportActionID), + }, + ]); } +// function mockGetNewerActions(messageCount: number) { +// fetchMock.mockAPICommand('GetNewerActions', ({reportActionID}) => [ +// { +// onyxMethod: 'merge', +// key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, +// // The API also returns the action that was requested with the reportActionID. +// value: buildReportComments(messageCount + 1, reportActionID, true), +// }, +// ]); +// } + /** * Sets up a test with a logged in user. Returns the test instance. */ @@ -182,15 +205,16 @@ describe('Pagination', () => { }); it('opens a chat and load initial messages', async () => { - mockOpenReport(5, true); + mockOpenReport(5, '5'); await signInAndGetApp(); await navigateToSidebarOption(0); expect(getReportActions()).toHaveLength(5); - expectAPICommandToHaveBeenCalled('OpenReport', 1); - expectAPICommandToHaveBeenCalled('GetOlderActions', 0); - expectAPICommandToHaveBeenCalled('GetNewerActions', 0); + TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1); + TestHelper.expectAPICommandToHaveBeenCalledWith('OpenReport', 0, {reportID: REPORT_ID, reportActionID: ''}); + TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); + TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); // Scrolling here should not trigger a new network request. scrollToOffset(LIST_CONTENT_SIZE.height); @@ -198,28 +222,31 @@ describe('Pagination', () => { scrollToOffset(0); await waitForBatchedUpdatesWithAct(); - expectAPICommandToHaveBeenCalled('OpenReport', 1); - expectAPICommandToHaveBeenCalled('GetOlderActions', 0); - expectAPICommandToHaveBeenCalled('GetNewerActions', 0); + TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1); + TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); + TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); }); it('opens a chat and load older messages', async () => { - mockOpenReport(5, false); + mockOpenReport(5, '8'); + mockGetOlderActions(5); await signInAndGetApp(); await navigateToSidebarOption(0); expect(getReportActions()).toHaveLength(5); - expectAPICommandToHaveBeenCalled('OpenReport', 1); - expectAPICommandToHaveBeenCalled('GetOlderActions', 0); - expectAPICommandToHaveBeenCalled('GetNewerActions', 0); + TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1); + TestHelper.expectAPICommandToHaveBeenCalledWith('OpenReport', 0, {reportID: REPORT_ID, reportActionID: ''}); + TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); + TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); // Scrolling here should trigger a new network request. scrollToOffset(LIST_CONTENT_SIZE.height); await waitForBatchedUpdatesWithAct(); - expectAPICommandToHaveBeenCalled('OpenReport', 1); - expectAPICommandToHaveBeenCalled('GetOlderActions', 1); - expectAPICommandToHaveBeenCalled('GetNewerActions', 0); + TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1); + TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 1); + TestHelper.expectAPICommandToHaveBeenCalledWith('GetOlderActions', 0, {reportID: REPORT_ID, reportActionID: '4'}); + TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); }); }); diff --git a/tests/utils/TestHelper.ts b/tests/utils/TestHelper.ts index f3aed289acff..81fe1e9173f4 100644 --- a/tests/utils/TestHelper.ts +++ b/tests/utils/TestHelper.ts @@ -2,6 +2,7 @@ import type * as NativeNavigation from '@react-navigation/native'; import {Str} from 'expensify-common'; import {Linking} from 'react-native'; import Onyx from 'react-native-onyx'; +import type {ApiCommand, ApiRequestCommandParameters} from '@libs/API/types'; import * as Pusher from '@libs/Pusher/pusher'; import PusherConnectionManager from '@libs/PusherConnectionManager'; import CONFIG from '@src/CONFIG'; @@ -14,18 +15,20 @@ import appSetup from '@src/setup'; import type {Response as OnyxResponse, PersonalDetails, Report} from '@src/types/onyx'; import waitForBatchedUpdates from './waitForBatchedUpdates'; +console.debug = () => {}; + type MockFetch = ReturnType & { pause: () => void; fail: () => void; succeed: () => void; resume: () => Promise; - mockAPICommand: (command: string, response: OnyxResponse['onyxData']) => void; + mockAPICommand: (command: TCommand, responseHandler: (params: ApiRequestCommandParameters[TCommand]) => OnyxResponse['onyxData']) => void; }; type QueueItem = { resolve: (value: Partial | PromiseLike>) => void; input: RequestInfo; - init?: RequestInit; + options?: RequestInit; }; type FormData = { @@ -186,11 +189,12 @@ function signOutTestUser() { */ function getGlobalFetchMock(): typeof fetch { let queue: QueueItem[] = []; - let responses = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let responses = new Map OnyxResponse['onyxData']>(); let isPaused = false; let shouldFail = false; - const getResponse = (input: RequestInfo): Partial => + const getResponse = (input: RequestInfo, options?: RequestInit): Partial => shouldFail ? { ok: true, @@ -202,20 +206,22 @@ function getGlobalFetchMock(): typeof fetch { const commandMatch = typeof input === 'string' ? input.match(/https:\/\/www.expensify.com.dev\/api\/(\w+)\?/) : null; const command = commandMatch ? commandMatch[1] : null; - if (command && responses.has(command)) { - return Promise.resolve({jsonCode: 200, onyxData: responses.get(command)}); + const responseHandler = command ? responses.get(command) : null; + if (responseHandler) { + const requestData = options?.body instanceof FormData ? Object.fromEntries(options.body) : {}; + return Promise.resolve({jsonCode: 200, onyxData: responseHandler(requestData)}); } return Promise.resolve({jsonCode: 200}); }, }; - const mockFetch = jest.fn().mockImplementation((input: RequestInfo) => { + const mockFetch = jest.fn().mockImplementation((input: RequestInfo, options?: RequestInit) => { if (!isPaused) { - return Promise.resolve(getResponse(input)); + return Promise.resolve(getResponse(input, options)); } return new Promise((resolve) => { - queue.push({resolve, input}); + queue.push({resolve, input, options}); }); }) as MockFetch; @@ -237,8 +243,8 @@ function getGlobalFetchMock(): typeof fetch { }; mockFetch.fail = () => (shouldFail = true); mockFetch.succeed = () => (shouldFail = false); - mockFetch.mockAPICommand = (command: string, response: OnyxResponse['onyxData']) => { - responses.set(command, response); + mockFetch.mockAPICommand = (command: TCommand, responseHandler: (params: ApiRequestCommandParameters[TCommand]) => OnyxResponse['onyxData']): void => { + responses.set(command, responseHandler); }; return mockFetch as typeof fetch; } @@ -256,6 +262,28 @@ function setupGlobalFetchMock(): MockFetch { return mockFetch as MockFetch; } +function getFetchMockCalls(commandName: ApiCommand) { + return (global.fetch as MockFetch).mock.calls.filter((c) => c[0] === `https://www.expensify.com.dev/api/${commandName}?`); +} + +/** + * Assertion helper to validate that a command has been called a specific number of times. + */ +function expectAPICommandToHaveBeenCalled(commandName: ApiCommand, expectedCalls: number) { + expect(getFetchMockCalls(commandName)).toHaveLength(expectedCalls); +} + +/** + * Assertion helper to validate that a command has been called with specific parameters. + */ +function expectAPICommandToHaveBeenCalledWith(commandName: TCommand, callIndex: number, expectedParams: ApiRequestCommandParameters[TCommand]) { + const call = getFetchMockCalls(commandName).at(callIndex); + expect(call).toBeTruthy(); + const body = call?.at(1)?.body; + const params = body instanceof FormData ? Object.fromEntries(body) : {}; + expect(params).toEqual(expect.objectContaining(expectedParams)); +} + function setPersonalDetails(login: string, accountID: number) { Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { [accountID]: buildPersonalDetails(login, accountID), @@ -292,6 +320,8 @@ export { getGlobalFetchMock, setupApp, setupGlobalFetchMock, + expectAPICommandToHaveBeenCalled, + expectAPICommandToHaveBeenCalledWith, setPersonalDetails, signInWithTestUser, signOutTestUser, diff --git a/tsconfig.json b/tsconfig.json index ea072fc4a354..16497c29b8cb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "compilerOptions": { "module": "commonjs", "types": ["react-native", "jest"], + "lib": ["DOM", "DOM.Iterable", "ESNext"], "isolatedModules": true, "strict": true, "allowSyntheticDefaultImports": true,