Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Recreate a receipt for native #54358

Merged
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
acdd2e2
Add file handling utilities and enhance form data processing draft
rezkiy37 Dec 19, 2024
50c4f59
remove dev prop
rezkiy37 Dec 19, 2024
0c2b632
Merge branch 'main' of https://github.com/rezkiy37/Expensify into fix…
rezkiy37 Dec 19, 2024
2446bb8
Merge branch 'main' of https://github.com/rezkiy37/Expensify into fix…
rezkiy37 Dec 20, 2024
18d637f
Merge branch 'main' of https://github.com/rezkiy37/Expensify into fix…
rezkiy37 Dec 23, 2024
04cff7b
Refactor file reading logic to import readFileAsync dynamically and r…
rezkiy37 Dec 23, 2024
8dd63c2
remove import
rezkiy37 Dec 23, 2024
a9dbba9
Add initiatedOffline parameter to HTTP request handling for offline s…
rezkiy37 Dec 24, 2024
02ae8cd
Add initiatedOffline property to persistedRequest in SequentialQueueTest
rezkiy37 Dec 24, 2024
754949c
Add initiatedOffline property to persistedRequest in SequentialQueueTest
rezkiy37 Dec 24, 2024
0888f6b
Merge branch 'main' of https://github.com/rezkiy37/Expensify into fix…
rezkiy37 Dec 27, 2024
6f7f4a4
clean processFormData
rezkiy37 Dec 27, 2024
c1a5306
integrate prepareRequestPayload
rezkiy37 Dec 27, 2024
e12d29c
integrate prepareRequestPayload
rezkiy37 Dec 27, 2024
3a389bf
clean
rezkiy37 Dec 27, 2024
b899c11
lazy load readFileAsync in prepareRequestPayload
rezkiy37 Dec 28, 2024
38b1224
Merge branch 'main' of https://github.com/rezkiy37/Expensify into fix…
rezkiy37 Dec 28, 2024
713c51f
Merge branch 'main' of https://github.com/rezkiy37/Expensify into fix…
rezkiy37 Jan 7, 2025
8841ac4
Merge branch 'main' of https://github.com/rezkiy37/Expensify into fix…
rezkiy37 Jan 10, 2025
87db8e0
Merge branch 'main' of https://github.com/rezkiy37/Expensify into fix…
rezkiy37 Jan 13, 2025
9d86ba4
use correct value to validate
rezkiy37 Jan 13, 2025
4e79434
Merge branch 'main' of https://github.com/rezkiy37/Expensify into fix…
rezkiy37 Jan 14, 2025
e9f0e86
Merge branch 'main' of https://github.com/rezkiy37/Expensify into fix…
rezkiy37 Jan 15, 2025
bd16813
Merge branch 'main' of https://github.com/rezkiy37/Expensify into fix…
rezkiy37 Jan 15, 2025
83f28a3
fix no-restricted-syntax in SequentialQueueTest
rezkiy37 Jan 15, 2025
41bbf51
Merge branch 'main' of https://github.com/rezkiy37/Expensify into fix…
rezkiy37 Jan 20, 2025
ff738b5
Merge branch 'main' of https://github.com/rezkiy37/Expensify into fix…
rezkiy37 Jan 24, 2025
c21920e
Enhance documentation for initiatedOffline field in RequestData type
rezkiy37 Jan 24, 2025
28efbe4
Add initiatedOffline flag to requests in API and SequentialQueue
rezkiy37 Jan 24, 2025
faa122a
Add documentation for prepareRequestPayload function in native platforms
rezkiy37 Jan 24, 2025
e710d42
Refactor file reading logic in prepareRequestPayload to directly impo…
rezkiy37 Jan 24, 2025
cad3e6f
Merge branch 'main' of https://github.com/rezkiy37/Expensify into fix…
rezkiy37 Jan 27, 2025
0ce0a09
Mock fileDownload utility in tests to isolate file reading functionality
rezkiy37 Jan 27, 2025
bc698ea
Refactor imports to use named exports for consistency and clarity
rezkiy37 Jan 27, 2025
d2963fc
Merge branch 'main' of https://github.com/rezkiy37/Expensify into fix…
rezkiy37 Jan 28, 2025
23ac663
Fix expectations for API command calls in Report actions test
rezkiy37 Jan 28, 2025
f6304bb
Merge branch 'main' of https://github.com/rezkiy37/Expensify into fix…
rezkiy37 Jan 29, 2025
c4b9318
Revert "Fix expectations for API command calls in Report actions test"
rezkiy37 Jan 29, 2025
65d23a5
Revert "Mock fileDownload utility in tests to isolate file reading fu…
rezkiy37 Jan 29, 2025
57f62c8
Mock prepareRequestPayload for tests globally
rezkiy37 Jan 29, 2025
4efd175
Refactor prepareRequestPayload mock to handle dynamic data appending
rezkiy37 Jan 29, 2025
ff8d096
Revert "Refactor imports to use named exports for consistency and cla…
rezkiy37 Jan 29, 2025
6d19d5f
Refactor ValidationUtilsTest to use translateLocal for localization
rezkiy37 Jan 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/libs/API/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Onyx from 'react-native-onyx';
import type {SetRequired} from 'type-fest';
import Log from '@libs/Log';
import {HandleUnusedOptimisticID, Logging, Pagination, Reauthentication, RecheckConnection, SaveResponseInOnyx} from '@libs/Middleware';
import {isOffline} from '@libs/Network/NetworkStore';
import {push as pushToSequentialQueue, waitForIdle as waitForSequentialQueueIdle} from '@libs/Network/SequentialQueue';
import {getPusherSocketID} from '@libs/Pusher/pusher';
import {processWithMiddleware, use} from '@libs/Request';
Expand Down Expand Up @@ -76,6 +77,7 @@ function prepareRequest<TCommand extends ApiCommand>(
const request: SetRequired<OnyxRequest, 'data'> = {
command,
data,
initiatedOffline: isOffline(),
...onyxDataWithoutOptimisticData,
...conflictResolver,
};
Expand Down
54 changes: 7 additions & 47 deletions src/libs/HttpUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,7 @@ import {alertUser} from './actions/UpdateRequired';
import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from './API/types';
import {getCommandURL} from './ApiUtils';
import HttpsError from './Errors/HttpsError';
import getPlatform from './getPlatform';

const platform = getPlatform();
const isNativePlatform = platform === CONST.PLATFORM.ANDROID || platform === CONST.PLATFORM.IOS;
import prepareRequestPayload from './prepareRequestPayload';

let shouldFailAllRequests = false;
let shouldForceOffline = false;
Expand Down Expand Up @@ -161,50 +158,13 @@ function processHTTPRequest(url: string, method: RequestType = 'get', body: Form
* @param type HTTP request type (get/post)
* @param shouldUseSecure should we use the secure server
*/
function xhr(command: string, data: Record<string, unknown>, type: RequestType = CONST.NETWORK.METHOD.POST, shouldUseSecure = false): Promise<Response> {
const formData = new FormData();
Object.keys(data).forEach((key) => {
const value = data[key];
if (value === undefined) {
return;
}
validateFormDataParameter(command, key, value);
formData.append(key, value as string | Blob);
});

const url = getCommandURL({shouldUseSecure, command});

const abortSignalController = data.canCancel ? abortControllerMap.get(command as AbortCommand) ?? abortControllerMap.get(ABORT_COMMANDS.All) : undefined;
return processHTTPRequest(url, type, formData, abortSignalController?.signal);
}

/**
* Ensures no value of type `object` other than null, Blob, its subclasses, or {uri: string} (native platforms only) is passed to XMLHttpRequest.
* Otherwise, it will be incorrectly serialized as `[object Object]` and cause an error on Android.
* See https://github.com/Expensify/App/issues/45086
*/
function validateFormDataParameter(command: string, key: string, value: unknown) {
// eslint-disable-next-line @typescript-eslint/no-shadow
const isValid = (value: unknown, isTopLevel: boolean): boolean => {
if (value === null || typeof value !== 'object') {
return true;
}
if (Array.isArray(value)) {
return value.every((element) => isValid(element, false));
}
if (isTopLevel) {
// Native platforms only require the value to include the `uri` property.
// Optionally, it can also have a `name` and `type` props.
// On other platforms, the value must be an instance of `Blob`.
return isNativePlatform ? 'uri' in value && !!value.uri : value instanceof Blob;
}
return false;
};
function xhr(command: string, data: Record<string, unknown>, type: RequestType = CONST.NETWORK.METHOD.POST, shouldUseSecure = false, initiatedOffline = false): Promise<Response> {
return prepareRequestPayload(command, data, initiatedOffline).then((formData) => {
const url = getCommandURL({shouldUseSecure, command});
const abortSignalController = data.canCancel ? abortControllerMap.get(command as AbortCommand) ?? abortControllerMap.get(ABORT_COMMANDS.All) : undefined;

if (!isValid(value, true)) {
// eslint-disable-next-line no-console
console.warn(`An unsupported value was passed to command '${command}' (parameter: '${key}'). Only Blob and primitive types are allowed.`);
}
return processHTTPRequest(url, type, formData, abortSignalController?.signal);
});
}

function cancelPendingRequests(command: AbortCommand = ABORT_COMMANDS.All) {
Expand Down
2 changes: 1 addition & 1 deletion src/libs/Request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ function makeXHR(request: Request): Promise<Response | void> {
});
}

return HttpUtils.xhr(request.command, finalParameters, request.type, request.shouldUseSecure);
return HttpUtils.xhr(request.command, finalParameters, request.type, request.shouldUseSecure, request.initiatedOffline);
});
}

Expand Down
44 changes: 44 additions & 0 deletions src/libs/prepareRequestPayload/index.native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {readFileAsync} from '@libs/fileDownload/FileUtils';
import validateFormDataParameter from '@libs/validateFormDataParameter';
import type PrepareRequestPayload from './types';

/**
* Prepares the request payload (body) for a given command and data.
* This function is specifically designed for native platforms (IOS and Android) to handle the regeneration of blob files. It ensures that files, such as receipts, are properly read and appended to the FormData object before the request is sent.
*/
const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOffline) => {
const formData = new FormData();
let promiseChain = Promise.resolve();

Object.keys(data).forEach((key) => {
promiseChain = promiseChain.then(() => {
const value = data[key];

if (value === undefined) {
return Promise.resolve();
}

if (key === 'receipt' && initiatedOffline) {
const {uri: path = '', source} = value as File;

return readFileAsync(source, path, () => {}).then((file) => {
if (!file) {
return;
}

validateFormDataParameter(command, key, file);
formData.append(key, file);
});
}

validateFormDataParameter(command, key, value);
formData.append(key, value as string | Blob);

return Promise.resolve();
});
});

return promiseChain.then(() => formData);
};

export default prepareRequestPayload;
24 changes: 24 additions & 0 deletions src/libs/prepareRequestPayload/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import validateFormDataParameter from '@libs/validateFormDataParameter';
import type PrepareRequestPayload from './types';

/**
* Prepares the request payload (body) for a given command and data.
*/
const prepareRequestPayload: PrepareRequestPayload = (command, data) => {
const formData = new FormData();

Object.keys(data).forEach((key) => {
const value = data[key];

if (value === undefined) {
return;
}

validateFormDataParameter(command, key, value);
formData.append(key, value as string | Blob);
});

return Promise.resolve(formData);
};

export default prepareRequestPayload;
3 changes: 3 additions & 0 deletions src/libs/prepareRequestPayload/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
type PrepareRequestPayload = (command: string, data: Record<string, unknown>, initiatedOffline: boolean) => Promise<FormData>;

export default PrepareRequestPayload;
36 changes: 36 additions & 0 deletions src/libs/validateFormDataParameter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import CONST from '@src/CONST';
import getPlatform from './getPlatform';

const platform = getPlatform();
const isNativePlatform = platform === CONST.PLATFORM.ANDROID || platform === CONST.PLATFORM.IOS;

/**
* Ensures no value of type `object` other than null, Blob, its subclasses, or {uri: string} (native platforms only) is passed to XMLHttpRequest.
* Otherwise, it will be incorrectly serialized as `[object Object]` and cause an error on Android.
* See https://github.com/Expensify/App/issues/45086
*/
function validateFormDataParameter(command: string, key: string, value: unknown) {
// eslint-disable-next-line @typescript-eslint/no-shadow
const isValid = (value: unknown, isTopLevel: boolean): boolean => {
if (value === null || typeof value !== 'object') {
return true;
}
if (Array.isArray(value)) {
return value.every((element) => isValid(element, false));
}
if (isTopLevel) {
// Native platforms only require the value to include the `uri` property.
// Optionally, it can also have a `name` and `type` props.
// On other platforms, the value must be an instance of `Blob`.
return isNativePlatform ? 'uri' in value && !!value.uri : value instanceof Blob;
}
return false;
};

if (!isValid(value, true)) {
// eslint-disable-next-line no-console
console.warn(`An unsupported value was passed to command '${command}' (parameter: '${key}'). Only Blob and primitive types are allowed.`);
}
}

export default validateFormDataParameter;
10 changes: 10 additions & 0 deletions src/types/onyx/Request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ type RequestData = {

/** Whether the app should skip the web proxy to connect to API endpoints */
shouldSkipWebProxy?: boolean;

/**
* Whether the request is initiated offline.
*
* This field is used to indicate if the app initiates the request while offline.
* It is particularly useful for scenarios such as receipts recreating, where
* the app needs to regenerate a blob once the user gets back online.
* More info https://github.com/Expensify/App/issues/51761
*/
initiatedOffline?: boolean;
};

/**
Expand Down
30 changes: 20 additions & 10 deletions tests/actions/OnyxUpdateManagerTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,21 @@ import type {OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import OnyxUtils from 'react-native-onyx/dist/OnyxUtils';
import type {AppActionsMock} from '@libs/actions/__mocks__/App';

/* eslint-disable-next-line no-restricted-syntax */
import * as AppImport from '@libs/actions/App';
import applyOnyxUpdatesReliably from '@libs/actions/applyOnyxUpdatesReliably';
import * as OnyxUpdateManagerExports from '@libs/actions/OnyxUpdateManager';
import {queryPromise, resetDeferralLogicVariables} from '@libs/actions/OnyxUpdateManager';
import type {DeferredUpdatesDictionary} from '@libs/actions/OnyxUpdateManager/types';

/* eslint-disable-next-line no-restricted-syntax */
import * as OnyxUpdateManagerUtilsImport from '@libs/actions/OnyxUpdateManager/utils';
import type {OnyxUpdateManagerUtilsMock} from '@libs/actions/OnyxUpdateManager/utils/__mocks__';
import type {ApplyUpdatesMock} from '@libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates';

/* eslint-disable-next-line no-restricted-syntax */
import * as ApplyUpdatesImport from '@libs/actions/OnyxUpdateManager/utils/applyUpdates';
import * as SequentialQueue from '@libs/Network/SequentialQueue';
import {isPaused, isRunning} from '@libs/Network/SequentialQueue';
import CONST from '@src/CONST';
import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager';
import ONYXKEYS from '@src/ONYXKEYS';
Expand All @@ -34,6 +40,10 @@ jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({
}),
}));

jest.mock('@libs/fileDownload/FileUtils', () => ({
readFileAsync: jest.fn(),
}));

const App = AppImport as AppActionsMock;
const ApplyUpdates = ApplyUpdatesImport as ApplyUpdatesMock;
const OnyxUpdateManagerUtils = OnyxUpdateManagerUtilsImport as OnyxUpdateManagerUtilsMock;
Expand Down Expand Up @@ -138,7 +148,7 @@ describe('actions/OnyxUpdateManager', () => {

OnyxUpdateManagerUtils.mockValues.beforeValidateAndApplyDeferredUpdates = undefined;
App.mockValues.missingOnyxUpdatesToBeApplied = undefined;
OnyxUpdateManagerExports.resetDeferralLogicVariables();
resetDeferralLogicVariables();
});

it('should trigger Onyx update gap handling', async () => {
Expand All @@ -158,7 +168,7 @@ describe('actions/OnyxUpdateManager', () => {
applyOnyxUpdatesReliably(mockUpdate4);
applyOnyxUpdatesReliably(mockUpdate3);

return OnyxUpdateManagerExports.queryPromise.then(() => {
return queryPromise.then(() => {
const expectedResult: Record<string, Partial<OnyxTypes.ReportAction>> = {
report2: {
...exampleReportAction,
Expand Down Expand Up @@ -204,7 +214,7 @@ describe('actions/OnyxUpdateManager', () => {
};

return firstGetMissingOnyxUpdatesCallFinished
.then(() => OnyxUpdateManagerExports.queryPromise)
.then(() => queryPromise)
.then(() => {
const expectedResult: Record<string, Partial<OnyxTypes.ReportAction>> = {
report2: {
Expand Down Expand Up @@ -248,15 +258,15 @@ describe('actions/OnyxUpdateManager', () => {
const assertAfterFirstGetMissingOnyxUpdates = () => {
// While the fetching of missing udpates and the validation and application of the deferred updaes is running,
// the SequentialQueue should be paused.
expect(SequentialQueue.isPaused()).toBeTruthy();
expect(isPaused()).toBeTruthy();
expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1);
expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(1, 1, 2);
};

const assertAfterSecondGetMissingOnyxUpdates = () => {
// The SequentialQueue should still be paused.
expect(SequentialQueue.isPaused()).toBeTruthy();
expect(SequentialQueue.isRunning()).toBeFalsy();
expect(isPaused()).toBeTruthy();
expect(isRunning()).toBeFalsy();
expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(2);
expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(2, 3, 4);
};
Expand All @@ -277,10 +287,10 @@ describe('actions/OnyxUpdateManager', () => {
return Promise.resolve();
};

return OnyxUpdateManagerExports.queryPromise.then(() => {
return queryPromise.then(() => {
// Once the OnyxUpdateManager has finished filling the gaps, the SequentialQueue should be unpaused again.
// It must not necessarily be running, because it might not have been flushed yet.
expect(SequentialQueue.isPaused()).toBeFalsy();
expect(isPaused()).toBeFalsy();
expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(2);
});
});
Expand Down
4 changes: 4 additions & 0 deletions tests/actions/PolicyCategoryTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import * as TestHelper from '../utils/TestHelper';
import type {MockFetch} from '../utils/TestHelper';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';

jest.mock('@libs/fileDownload/FileUtils', () => ({
readFileAsync: jest.fn(),
}));

OnyxUpdateManager();
describe('actions/PolicyCategory', () => {
beforeAll(() => {
Expand Down
4 changes: 4 additions & 0 deletions tests/actions/PolicyMemberTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import * as TestHelper from '../utils/TestHelper';
import type {MockFetch} from '../utils/TestHelper';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';

jest.mock('@libs/fileDownload/FileUtils', () => ({
readFileAsync: jest.fn(),
}));

OnyxUpdateManager();
describe('actions/PolicyMember', () => {
beforeAll(() => {
Expand Down
4 changes: 4 additions & 0 deletions tests/actions/PolicyProfileTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import * as TestHelper from '../utils/TestHelper';
import type {MockFetch} from '../utils/TestHelper';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';

jest.mock('@libs/fileDownload/FileUtils', () => ({
readFileAsync: jest.fn(),
}));

OnyxUpdateManager();
describe('actions/PolicyProfile', () => {
beforeAll(() => {
Expand Down
Loading
Loading