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 26 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
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
5 changes: 3 additions & 2 deletions src/libs/Network/SequentialQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ function handleConflictActions(conflictAction: ConflictData, newRequest: OnyxReq

function push(newRequest: OnyxRequest) {
const {checkAndFixConflictingRequest} = newRequest;
const updatedRequest = {...newRequest, initiatedOffline: isOffline()};

if (checkAndFixConflictingRequest) {
const requests = getAllPersistedRequests();
Expand All @@ -259,8 +260,8 @@ function push(newRequest: OnyxRequest) {

// don't try to serialize a function.
// eslint-disable-next-line no-param-reassign
delete newRequest.checkAndFixConflictingRequest;
handleConflictActions(conflictAction, newRequest);
delete updatedRequest.checkAndFixConflictingRequest;
handleConflictActions(conflictAction, updatedRequest);
} else {
// Add request to Persisted Requests so that it can be retried if it fails
savePersistedRequest(newRequest);
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
41 changes: 41 additions & 0 deletions src/libs/prepareRequestPayload/index.native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import validateFormDataParameter from '@libs/validateFormDataParameter';
import type PrepareRequestPayload from './types';

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 import('@libs/fileDownload/FileUtils').then(({readFileAsync}) =>
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;
21 changes: 21 additions & 0 deletions src/libs/prepareRequestPayload/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import validateFormDataParameter from '@libs/validateFormDataParameter';
import type PrepareRequestPayload from './types';

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;
3 changes: 3 additions & 0 deletions src/types/onyx/Request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ type RequestData = {

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

/** Whether the request is initiated offline */
initiatedOffline?: boolean;
};

/**
Expand Down
36 changes: 18 additions & 18 deletions tests/unit/SequentialQueueTest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Onyx from 'react-native-onyx';
import {waitForActiveRequestsToBeEmpty} from '@libs/E2E/utils/NetworkInterceptor';
import * as PersistedRequests from '@userActions/PersistedRequests';
import {getAll, getLength, getOngoingRequest} from '@userActions/PersistedRequests';
import ONYXKEYS from '@src/ONYXKEYS';
import * as SequentialQueue from '../../src/libs/Network/SequentialQueue';
import type Request from '../../src/types/onyx/Request';
Expand All @@ -27,13 +27,13 @@ describe('SequentialQueue', () => {

it('should push one request and persist one', () => {
SequentialQueue.push(request);
expect(PersistedRequests.getLength()).toBe(1);
expect(getLength()).toBe(1);
});

it('should push two requests and persist two', () => {
SequentialQueue.push(request);
SequentialQueue.push(request);
expect(PersistedRequests.getLength()).toBe(2);
expect(getLength()).toBe(2);
});

it('should push two requests with conflict resolution and replace', () => {
Expand All @@ -54,10 +54,10 @@ describe('SequentialQueue', () => {
},
};
SequentialQueue.push(requestWithConflictResolution);
expect(PersistedRequests.getLength()).toBe(1);
expect(getLength()).toBe(1);
// We know there is only one request in the queue, so we can get the first one and verify
// that the persisted request is the second one.
const persistedRequest = PersistedRequests.getAll().at(0);
const persistedRequest = getAll().at(0);
expect(persistedRequest?.data?.accountID).toBe(56789);
});

Expand All @@ -71,7 +71,7 @@ describe('SequentialQueue', () => {
},
};
SequentialQueue.push(requestWithConflictResolution);
expect(PersistedRequests.getLength()).toBe(2);
expect(getLength()).toBe(2);
});

it('should push two requests with conflict resolution and noAction', () => {
Expand All @@ -84,7 +84,7 @@ describe('SequentialQueue', () => {
},
};
SequentialQueue.push(requestWithConflictResolution);
expect(PersistedRequests.getLength()).toBe(1);
expect(getLength()).toBe(1);
});

it('should add a new request even if a similar one is ongoing', async () => {
Expand All @@ -111,7 +111,7 @@ describe('SequentialQueue', () => {
};

SequentialQueue.push(requestWithConflictResolution);
expect(PersistedRequests.getLength()).toBe(2);
expect(getLength()).toBe(2);
});

it('should replace request request in queue while a similar one is ongoing', async () => {
Expand Down Expand Up @@ -148,7 +148,7 @@ describe('SequentialQueue', () => {
SequentialQueue.push(requestWithConflictResolution);
SequentialQueue.push(requestWithConflictResolution2);

expect(PersistedRequests.getLength()).toBe(2);
expect(getLength()).toBe(2);
});

it('should replace request request in queue while a similar one is ongoing and keep the same index', () => {
Expand All @@ -175,8 +175,8 @@ describe('SequentialQueue', () => {
SequentialQueue.push({command: 'AddComment'});
SequentialQueue.push({command: 'OpenReport'});

expect(PersistedRequests.getLength()).toBe(4);
const persistedRequests = PersistedRequests.getAll();
expect(getLength()).toBe(4);
const persistedRequests = getAll();
// We know ReconnectApp is at index 1 in the queue, so we can get it to verify
// that was replaced by the new request.
expect(persistedRequests.at(1)?.data?.accountID).toBe(56789);
Expand Down Expand Up @@ -219,7 +219,7 @@ describe('SequentialQueue', () => {

await Promise.resolve();
await waitForActiveRequestsToBeEmpty();
const persistedRequests = PersistedRequests.getAll();
const persistedRequests = getAll();

// We know ReconnectApp is at index 9 in the queue, so we can get it to verify
// that was replaced by the new request.
Expand All @@ -229,7 +229,7 @@ describe('SequentialQueue', () => {

// I need to test now when moving the request from the queue to the ongoing request the PERSISTED_REQUESTS is decreased and PERSISTED_ONGOING_REQUESTS has the new request
it('should move the request from the queue to the ongoing request and save it into Onyx', () => {
const persistedRequest = {...request, persistWhenOngoing: true};
const persistedRequest = {...request, persistWhenOngoing: true, initiatedOffline: false};
SequentialQueue.push(persistedRequest);

const connectionId = Onyx.connect({
Expand All @@ -241,20 +241,20 @@ describe('SequentialQueue', () => {

Onyx.disconnect(connectionId);
expect(ongoingRequest).toEqual(persistedRequest);
expect(ongoingRequest).toEqual(PersistedRequests.getOngoingRequest());
expect(PersistedRequests.getAll().length).toBe(0);
expect(ongoingRequest).toEqual(getOngoingRequest());
expect(getAll().length).toBe(0);
},
});
});

it('should get the ongoing request from onyx and start processing it', async () => {
const persistedRequest = {...request, persistWhenOngoing: true};
const persistedRequest = {...request, persistWhenOngoing: true, initiatedOffline: false};
Onyx.set(ONYXKEYS.PERSISTED_ONGOING_REQUESTS, persistedRequest);
SequentialQueue.push({command: 'OpenReport'});

await Promise.resolve();

expect(persistedRequest).toEqual(PersistedRequests.getOngoingRequest());
expect(PersistedRequests.getAll().length).toBe(1);
expect(persistedRequest).toEqual(getOngoingRequest());
expect(getAll().length).toBe(1);
});
});
Loading