diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts index 35c7b2bf779..a7cb948a124 100644 --- a/src/libs/Network/SequentialQueue.ts +++ b/src/libs/Network/SequentialQueue.ts @@ -96,7 +96,7 @@ function process(): Promise { pause(); } - PersistedRequests.remove(requestToProcess); + PersistedRequests.endRequestAndRemoveFromQueue(requestToProcess); RequestThrottle.clear(); return process(); }) @@ -104,7 +104,7 @@ function process(): Promise { // On sign out we cancel any in flight requests from the user. Since that user is no longer signed in their requests should not be retried. // Duplicate records don't need to be retried as they just mean the record already exists on the server if (error.name === CONST.ERROR.REQUEST_CANCELLED || error.message === CONST.ERROR.DUPLICATE_RECORD) { - PersistedRequests.remove(requestToProcess); + PersistedRequests.endRequestAndRemoveFromQueue(requestToProcess); RequestThrottle.clear(); return process(); } @@ -113,7 +113,7 @@ function process(): Promise { .then(process) .catch(() => { Onyx.update(requestToProcess.failureData ?? []); - PersistedRequests.remove(requestToProcess); + PersistedRequests.endRequestAndRemoveFromQueue(requestToProcess); RequestThrottle.clear(); return process(); }); @@ -220,6 +220,11 @@ function push(newRequest: OnyxRequest) { PersistedRequests.save(newRequest); } else if (conflictAction.type === 'replace') { PersistedRequests.update(conflictAction.index, newRequest); + } else if (conflictAction.type === 'delete') { + PersistedRequests.deleteRequestsByIndices(conflictAction.indices); + if (conflictAction.pushNewRequest) { + PersistedRequests.save(newRequest); + } } else { Log.info(`[SequentialQueue] No action performed to command ${newRequest.command} and it will be ignored.`); } diff --git a/src/libs/actions/PersistedRequests.ts b/src/libs/actions/PersistedRequests.ts index fc14e8c2303..ebeaec6881a 100644 --- a/src/libs/actions/PersistedRequests.ts +++ b/src/libs/actions/PersistedRequests.ts @@ -53,7 +53,7 @@ function save(requestToPersist: Request) { }); } -function remove(requestToRemove: Request) { +function endRequestAndRemoveFromQueue(requestToRemove: Request) { ongoingRequest = null; /** * We only remove the first matching request because the order of requests matters. @@ -76,6 +76,19 @@ function remove(requestToRemove: Request) { }); } +function deleteRequestsByIndices(indices: number[]) { + // Create a Set from the indices array for efficient lookup + const indicesSet = new Set(indices); + + // Create a new array excluding elements at the specified indices + persistedRequests = persistedRequests.filter((_, index) => !indicesSet.has(index)); + + // Update the persisted requests in storage or state as necessary + Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, persistedRequests).then(() => { + Log.info(`Multiple (${indices.length}) requests removed from the queue. Queue length is ${persistedRequests.length}`); + }); +} + function update(oldRequestIndex: number, newRequest: Request) { const requests = [...persistedRequests]; requests.splice(oldRequestIndex, 1, newRequest); @@ -117,7 +130,7 @@ function rollbackOngoingRequest() { } // Prepend ongoingRequest to persistedRequests - persistedRequests.unshift(ongoingRequest); + persistedRequests.unshift({...ongoingRequest, isRollbacked: true}); // Clear the ongoingRequest ongoingRequest = null; @@ -131,4 +144,4 @@ function getOngoingRequest(): Request | null { return ongoingRequest; } -export {clear, save, getAll, remove, update, getLength, getOngoingRequest, processNextRequest, updateOngoingRequest, rollbackOngoingRequest}; +export {clear, save, getAll, endRequestAndRemoveFromQueue, update, getLength, getOngoingRequest, processNextRequest, updateOngoingRequest, rollbackOngoingRequest, deleteRequestsByIndices}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 4af2357fc57..df511d5da56 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -111,7 +111,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import * as CachedPDFPaths from './CachedPDFPaths'; import * as Modal from './Modal'; import navigateFromNotification from './navigateFromNotification'; -import {createUpdateCommentMatcher, resolveDuplicationConflictAction} from './RequestConflictUtils'; +import {createUpdateCommentMatcher, resolveCommentDeletionConflicts, resolveDuplicationConflictAction} from './RequestConflictUtils'; import * as Session from './Session'; import * as Welcome from './Welcome'; import * as OnboardingFlow from './Welcome/OnboardingFlow'; @@ -1535,7 +1535,14 @@ function deleteReportComment(reportID: string, reportAction: ReportAction) { CachedPDFPaths.clearByKey(reportActionID); - API.write(WRITE_COMMANDS.DELETE_COMMENT, parameters, {optimisticData, successData, failureData}); + API.write( + WRITE_COMMANDS.DELETE_COMMENT, + parameters, + {optimisticData, successData, failureData}, + { + checkAndFixConflictingRequest: (persistedRequests) => resolveCommentDeletionConflicts(persistedRequests, reportActionID, originalReportID), + }, + ); // if we are linking to the report action, and we are deleting it, and it's not a deleted parent action, // we should navigate to its report in order to not show not found page diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts index 36552a6fb5e..3c812642ff7 100644 --- a/src/libs/actions/RequestConflictUtils.ts +++ b/src/libs/actions/RequestConflictUtils.ts @@ -1,15 +1,29 @@ +import type {OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; import {WRITE_COMMANDS} from '@libs/API/types'; +import ONYXKEYS from '@src/ONYXKEYS'; import type OnyxRequest from '@src/types/onyx/Request'; import type {ConflictActionData} from '@src/types/onyx/Request'; +type RequestMatcher = (request: OnyxRequest) => boolean; + +const addNewMessage = new Set([WRITE_COMMANDS.ADD_COMMENT, WRITE_COMMANDS.ADD_ATTACHMENT, WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT]); + +const commentsToBeDeleted = new Set([ + WRITE_COMMANDS.ADD_COMMENT, + WRITE_COMMANDS.ADD_ATTACHMENT, + WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT, + WRITE_COMMANDS.UPDATE_COMMENT, + WRITE_COMMANDS.ADD_EMOJI_REACTION, + WRITE_COMMANDS.REMOVE_EMOJI_REACTION, +]); + function createUpdateCommentMatcher(reportActionID: string) { return function (request: OnyxRequest) { return request.command === WRITE_COMMANDS.UPDATE_COMMENT && request.data?.reportActionID === reportActionID; }; } -type RequestMatcher = (request: OnyxRequest) => boolean; - /** * Determines the appropriate action for handling duplication conflicts in persisted requests. * @@ -35,4 +49,62 @@ function resolveDuplicationConflictAction(persistedRequests: OnyxRequest[], requ }; } -export {resolveDuplicationConflictAction, createUpdateCommentMatcher}; +function resolveCommentDeletionConflicts(persistedRequests: OnyxRequest[], reportActionID: string, originalReportID: string): ConflictActionData { + const commentIndicesToDelete: number[] = []; + const commentCouldBeThread: Record = {}; + let addCommentFound = false; + persistedRequests.forEach((request, index) => { + // If the request will open a Thread, we should not delete the comment and we should send all the requests + if (request.command === WRITE_COMMANDS.OPEN_REPORT && request.data?.parentReportActionID === reportActionID && reportActionID in commentCouldBeThread) { + const indexToRemove = commentCouldBeThread[reportActionID]; + commentIndicesToDelete.splice(indexToRemove, 1); + // The new message performs some changes in Onyx, we want to keep those changes. + addCommentFound = false; + return; + } + + if (!commentsToBeDeleted.has(request.command) || request.data?.reportActionID !== reportActionID) { + return; + } + + // If we find a new message, we probably want to remove it and not perform any request given that the server + // doesn't know about it yet. + if (addNewMessage.has(request.command) && !request.isRollbacked) { + addCommentFound = true; + commentCouldBeThread[reportActionID] = commentIndicesToDelete.length; + } + commentIndicesToDelete.push(index); + }); + + if (commentIndicesToDelete.length === 0) { + return { + conflictAction: { + type: 'push', + }, + }; + } + + if (addCommentFound) { + // The new message performs some changes in Onyx, so we need to rollback those changes. + const rollbackData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`, + value: { + [reportActionID]: null, + }, + }, + ]; + Onyx.update(rollbackData); + } + + return { + conflictAction: { + type: 'delete', + indices: commentIndicesToDelete, + pushNewRequest: !addCommentFound, + }, + }; +} + +export {resolveDuplicationConflictAction, resolveCommentDeletionConflicts, createUpdateCommentMatcher}; diff --git a/src/types/onyx/Request.ts b/src/types/onyx/Request.ts index 238e3a8c6a8..879164eafaf 100644 --- a/src/types/onyx/Request.ts +++ b/src/types/onyx/Request.ts @@ -70,6 +70,26 @@ type ConflictRequestReplace = { index: number; }; +/** + * Model of a conflict request that needs to be deleted from the request queue. + */ +type ConflictRequestDelete = { + /** + * The action to take in case of a conflict. + */ + type: 'delete'; + + /** + * The indices of the requests in the queue that are to be deleted. + */ + indices: number[]; + + /** + * A flag to mark if the new request should be pushed into the queue after deleting the conflicting requests. + */ + pushNewRequest: boolean; +}; + /** * Model of a conflict request that has to be enqueued at the end of request queue. */ @@ -97,7 +117,7 @@ type ConflictActionData = { /** * The action to take in case of a conflict. */ - conflictAction: ConflictRequestReplace | ConflictRequestPush | ConflictRequestNoAction; + conflictAction: ConflictRequestReplace | ConflictRequestDelete | ConflictRequestPush | ConflictRequestNoAction; }; /** @@ -115,6 +135,11 @@ type RequestConflictResolver = { * the ongoing request, it will be removed from the persisted request queue. */ persistWhenOngoing?: boolean; + + /** + * A boolean flag to mark a request as rollbacked, if set to true it means the request failed and was added back into the queue. + */ + isRollbacked?: boolean; }; /** Model of requests sent to the API */ diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index c4327158b56..b79575f1a6d 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -1,9 +1,13 @@ /* eslint-disable @typescript-eslint/naming-convention */ import {afterEach, beforeAll, beforeEach, describe, expect, it} from '@jest/globals'; +import {addSeconds, format, subMinutes} from 'date-fns'; import {toZonedTime} from 'date-fns-tz'; +import type {Mock} from 'jest-mock'; import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import {WRITE_COMMANDS} from '@libs/API/types'; +import * as EmojiUtils from '@libs/EmojiUtils'; +import HttpUtils from '@libs/HttpUtils'; import CONST from '@src/CONST'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import * as PersistedRequests from '@src/libs/actions/PersistedRequests'; @@ -36,7 +40,7 @@ jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({ didScreenTransitionEnd: true, }), })); - +const originalXHR = HttpUtils.xhr; OnyxUpdateManager(); describe('actions/Report', () => { beforeAll(() => { @@ -47,16 +51,21 @@ describe('actions/Report', () => { }); beforeEach(() => { + HttpUtils.xhr = originalXHR; const promise = Onyx.clear().then(jest.useRealTimers); if (getIsUsingFakeTimers()) { // flushing pending timers // Onyx.clear() promise is resolved in batch which happends after the current microtasks cycle setImmediate(jest.runOnlyPendingTimers); } + return promise; }); - afterEach(PusherHelper.teardown); + afterEach(() => { + jest.clearAllMocks(); + PusherHelper.teardown(); + }); it('should store a new report action in Onyx when onyxApiUpdate event is handled via Pusher', () => { global.fetch = TestHelper.getGlobalFetchMock(); @@ -759,7 +768,7 @@ describe('actions/Report', () => { }); }); - it.only('should send only one OpenReport, replacing any extra ones with same reportIDs', async () => { + it('should send only one OpenReport, replacing any extra ones with same reportIDs', async () => { global.fetch = TestHelper.getGlobalFetchMock(); const REPORT_ID = '1'; @@ -782,7 +791,7 @@ describe('actions/Report', () => { TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1); }); - it.only('should replace duplicate OpenReport commands with the same reportID', async () => { + it('should replace duplicate OpenReport commands with the same reportID', async () => { global.fetch = TestHelper.getGlobalFetchMock(); const REPORT_ID = '1'; @@ -809,6 +818,579 @@ describe('actions/Report', () => { TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 4); }); + it('should remove AddComment and UpdateComment without sending any request when DeleteComment is set', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + + Report.addComment(REPORT_ID, 'Testing a comment'); + // Need the reportActionID to delete the comments + const newComment = PersistedRequests.getAll().at(0); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + Report.editReportComment(REPORT_ID, reportAction, 'Testing an edited comment'); + + await waitForBatchedUpdates(); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + Onyx.disconnect(connection); + + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_COMMENT); + expect(persistedRequests?.at(1)?.command).toBe(WRITE_COMMANDS.UPDATE_COMMENT); + + resolve(); + }, + }); + }); + + // Checking the Report Action exists before deleting it + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + + expect(reportActions?.[reportActionID]).not.toBeNull(); + expect(reportActions?.[reportActionID].reportActionID).toBe(reportActionID); + resolve(); + }, + }); + }); + + Report.deleteReportComment(REPORT_ID, reportAction); + + await waitForBatchedUpdates(); + expect(PersistedRequests.getAll().length).toBe(0); + + // Checking the Report Action doesn't exist after deleting it + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).toBeUndefined(); + }, + }); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.UPDATE_COMMENT, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 0); + }); + + it('should send DeleteComment request and remove UpdateComment accordingly', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + + Report.addComment(REPORT_ID, 'Testing a comment'); + + // Need the reportActionID to delete the comments + const newComment = PersistedRequests.getAll().at(1); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + + // wait for Onyx.connect execute the callback and start processing the queue + await Promise.resolve(); + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + + Report.editReportComment(REPORT_ID, reportAction, 'Testing an edited comment'); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + Onyx.disconnect(connection); + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.UPDATE_COMMENT); + resolve(); + }, + }); + }); + + Report.deleteReportComment(REPORT_ID, reportAction); + + await waitForBatchedUpdates(); + expect(PersistedRequests.getAll().length).toBe(1); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 1); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.UPDATE_COMMENT, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 1); + }); + + it('should send DeleteComment request and remove UpdateComment accordingly', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + + Report.addComment(REPORT_ID, 'Testing a comment'); + + // Need the reportActionID to delete the comments + const newComment = PersistedRequests.getAll().at(1); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + + // wait for Onyx.connect execute the callback and start processing the queue + await Promise.resolve(); + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + + Report.editReportComment(REPORT_ID, reportAction, 'Testing an edited comment'); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + Onyx.disconnect(connection); + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.UPDATE_COMMENT); + resolve(); + }, + }); + }); + + Report.deleteReportComment(REPORT_ID, reportAction); + + await waitForBatchedUpdates(); + expect(PersistedRequests.getAll().length).toBe(1); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 1); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.UPDATE_COMMENT, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 1); + }); + + it('should send DeleteComment request after AddComment is rollbacked', async () => { + global.fetch = jest.fn().mockRejectedValue(new TypeError(CONST.ERROR.FAILED_TO_FETCH)); + + const mockedXhr = jest.fn(); + mockedXhr + .mockImplementationOnce(originalXHR) + .mockImplementationOnce(() => + Promise.resolve({ + jsonCode: CONST.JSON_CODE.EXP_ERROR, + }), + ) + .mockImplementation(() => + Promise.resolve({ + jsonCode: CONST.JSON_CODE.SUCCESS, + }), + ); + + HttpUtils.xhr = mockedXhr; + await waitForBatchedUpdates(); + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + Report.addComment(REPORT_ID, 'Testing a comment'); + await waitForNetworkPromises(); + + const newComment = PersistedRequests.getAll().at(1); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + + await waitForBatchedUpdates(); + + expect(PersistedRequests.getAll().length).toBe(1); + expect(PersistedRequests.getAll().at(0)?.isRollbacked).toBeTruthy(); + Report.deleteReportComment(REPORT_ID, reportAction); + + jest.runOnlyPendingTimers(); + await waitForBatchedUpdates(); + + const httpCalls = (HttpUtils.xhr as Mock).mock.calls; + + const addCommentCalls = httpCalls.filter(([command]) => command === 'AddComment'); + const deleteCommentCalls = httpCalls.filter(([command]) => command === 'DeleteComment'); + + if (httpCalls.length === 3) { + expect(addCommentCalls).toHaveLength(2); + expect(deleteCommentCalls).toHaveLength(1); + } + }); + + it('should send not DeleteComment request and remove AddAttachment accordingly', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + + const file = new File([''], 'test.txt', {type: 'text/plain'}); + Report.addAttachment(REPORT_ID, file); + + // Need the reportActionID to delete the comments + const newComment = PersistedRequests.getAll().at(0); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + + // wait for Onyx.connect execute the callback and start processing the queue + await waitForBatchedUpdates(); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + Onyx.disconnect(connection); + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_ATTACHMENT); + resolve(); + }, + }); + }); + + // Checking the Report Action exists before deleting it + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).not.toBeNull(); + expect(reportActions?.[reportActionID].reportActionID).toBe(reportActionID); + resolve(); + }, + }); + }); + + Report.deleteReportComment(REPORT_ID, reportAction); + + await waitForBatchedUpdates(); + expect(PersistedRequests.getAll().length).toBe(0); + + // Checking the Report Action doesn't exist after deleting it + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).toBeUndefined(); + }, + }); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_ATTACHMENT, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 0); + }); + + it('should send not DeleteComment request and remove AddTextAndAttachment accordingly', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + const file = new File([''], 'test.txt', {type: 'text/plain'}); + + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + + Report.addAttachment(REPORT_ID, file, 'Attachment with comment'); + + // Need the reportActionID to delete the comments + const newComment = PersistedRequests.getAll().at(0); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + + // wait for Onyx.connect execute the callback and start processing the queue + await waitForBatchedUpdates(); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + Onyx.disconnect(connection); + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT); + resolve(); + }, + }); + }); + + // Checking the Report Action exists before deleting it + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).not.toBeNull(); + expect(reportActions?.[reportActionID].reportActionID).toBe(reportActionID); + resolve(); + }, + }); + }); + + Report.deleteReportComment(REPORT_ID, reportAction); + + await waitForBatchedUpdates(); + expect(PersistedRequests.getAll().length).toBe(0); + + // Checking the Report Action doesn't exist after deleting it + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).toBeUndefined(); + }, + }); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 0); + }); + + it('should not send DeleteComment request and remove any Reactions accordingly', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + jest.spyOn(EmojiUtils, 'hasAccountIDEmojiReacted').mockImplementation(() => true); + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + await Promise.resolve(); + + Report.addComment(REPORT_ID, 'reactions with comment'); + // Need the reportActionID to delete the comments + const newComment = PersistedRequests.getAll().at(0); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + + await waitForBatchedUpdates(); + + Report.toggleEmojiReaction(REPORT_ID, reportAction, {name: 'smile', code: '😄'}, {}); + Report.toggleEmojiReaction( + REPORT_ID, + reportAction, + {name: 'smile', code: '😄'}, + { + smile: { + createdAt: '2024-10-14 14:58:12', + oldestTimestamp: '2024-10-14 14:58:12', + users: { + [`${TEST_USER_ACCOUNT_ID}`]: { + id: `${TEST_USER_ACCOUNT_ID}`, + oldestTimestamp: '2024-10-14 14:58:12', + skinTones: { + '-1': '2024-10-14 14:58:12', + }, + }, + }, + }, + }, + ); + + await waitForBatchedUpdates(); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + Onyx.disconnect(connection); + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_COMMENT); + expect(persistedRequests?.at(1)?.command).toBe(WRITE_COMMANDS.ADD_EMOJI_REACTION); + expect(persistedRequests?.at(2)?.command).toBe(WRITE_COMMANDS.REMOVE_EMOJI_REACTION); + resolve(); + }, + }); + }); + + // Checking the Report Action exists before deleting it + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).not.toBeNull(); + expect(reportActions?.[reportActionID].reportActionID).toBe(reportActionID); + resolve(); + }, + }); + }); + + Report.deleteReportComment(REPORT_ID, reportAction); + + await waitForBatchedUpdates(); + expect(PersistedRequests.getAll().length).toBe(0); + + // Checking the Report Action doesn't exist after deleting it + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).toBeUndefined(); + }, + }); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_EMOJI_REACTION, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.REMOVE_EMOJI_REACTION, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 0); + }); + + it('should send DeleteComment request and remove any Reactions accordingly', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + jest.spyOn(EmojiUtils, 'hasAccountIDEmojiReacted').mockImplementation(() => true); + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + Report.addComment(REPORT_ID, 'Attachment with comment'); + + // Need the reportActionID to delete the comments + const newComment = PersistedRequests.getAll().at(0); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + + // wait for Onyx.connect execute the callback and start processing the queue + await Promise.resolve(); + + Report.toggleEmojiReaction(REPORT_ID, reportAction, {name: 'smile', code: '😄'}, {}); + Report.toggleEmojiReaction( + REPORT_ID, + reportAction, + {name: 'smile', code: '😄'}, + { + smile: { + createdAt: '2024-10-14 14:58:12', + oldestTimestamp: '2024-10-14 14:58:12', + users: { + [`${TEST_USER_ACCOUNT_ID}`]: { + id: `${TEST_USER_ACCOUNT_ID}`, + oldestTimestamp: '2024-10-14 14:58:12', + skinTones: { + '-1': '2024-10-14 14:58:12', + }, + }, + }, + }, + }, + ); + + await waitForBatchedUpdates(); + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + Onyx.disconnect(connection); + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_EMOJI_REACTION); + expect(persistedRequests?.at(1)?.command).toBe(WRITE_COMMANDS.REMOVE_EMOJI_REACTION); + resolve(); + }, + }); + }); + + Report.deleteReportComment(REPORT_ID, reportAction); + + await waitForBatchedUpdates(); + expect(PersistedRequests.getAll().length).toBe(1); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 1); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_EMOJI_REACTION, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.REMOVE_EMOJI_REACTION, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 1); + }); + + it('should create and delete thread processing all the requests', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + await waitForBatchedUpdates(); + + Report.addComment(REPORT_ID, 'Testing a comment'); + + const newComment = PersistedRequests.getAll().at(0); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + + Report.openReport( + REPORT_ID, + undefined, + ['test@user.com'], + { + isOptimisticReport: true, + parentReportID: REPORT_ID, + parentReportActionID: reportActionID, + reportID: '2', + }, + reportActionID, + ); + + Report.deleteReportComment(REPORT_ID, reportAction); + + expect(PersistedRequests.getAll().length).toBe(3); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + if (persistedRequests?.length !== 3) { + return; + } + Onyx.disconnect(connection); + + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_COMMENT); + expect(persistedRequests?.at(1)?.command).toBe(WRITE_COMMANDS.OPEN_REPORT); + expect(persistedRequests?.at(2)?.command).toBe(WRITE_COMMANDS.DELETE_COMMENT); + resolve(); + }, + }); + }); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 1); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 1); + }); + it('it should only send the last sequential UpdateComment request to BE', async () => { global.fetch = TestHelper.getGlobalFetchMock(); const reportID = '123'; diff --git a/tests/unit/PersistedRequests.ts b/tests/unit/PersistedRequests.ts index 7d3a7288ed9..c488b36013a 100644 --- a/tests/unit/PersistedRequests.ts +++ b/tests/unit/PersistedRequests.ts @@ -36,7 +36,7 @@ describe('PersistedRequests', () => { }); it('remove a request from the PersistedRequests array', () => { - PersistedRequests.remove(request); + PersistedRequests.endRequestAndRemoveFromQueue(request); expect(PersistedRequests.getAll().length).toBe(0); }); @@ -84,7 +84,7 @@ describe('PersistedRequests', () => { it('when removing a request should update the persistedRequests queue and clear the ongoing request', () => { PersistedRequests.processNextRequest(); expect(PersistedRequests.getOngoingRequest()).toEqual(request); - PersistedRequests.remove(request); + PersistedRequests.endRequestAndRemoveFromQueue(request); expect(PersistedRequests.getOngoingRequest()).toBeNull(); expect(PersistedRequests.getAll().length).toBe(0); }); diff --git a/tests/unit/RequestConflictUtilsTest.ts b/tests/unit/RequestConflictUtilsTest.ts index 98ffe50e62b..93928b877f0 100644 --- a/tests/unit/RequestConflictUtilsTest.ts +++ b/tests/unit/RequestConflictUtilsTest.ts @@ -1,4 +1,5 @@ -import {resolveDuplicationConflictAction} from '@libs/actions/RequestConflictUtils'; +import Onyx from 'react-native-onyx'; +import {resolveCommentDeletionConflicts, resolveDuplicationConflictAction} from '@libs/actions/RequestConflictUtils'; import type {WriteCommand} from '@libs/API/types'; describe('RequestConflictUtils', () => { @@ -32,4 +33,64 @@ describe('RequestConflictUtils', () => { const result = resolveDuplicationConflictAction(persistedRequests, (request) => request.command === 'OpenReport' && request.data?.reportID === reportID); expect(result).toEqual({conflictAction: {type: 'replace', index: 2}}); }); + + it('resolveCommentDeletionConflicts should return push when no special comments are found', () => { + const persistedRequests = [{command: 'OpenReport'}, {command: 'AddComment', data: {reportActionID: 2}}, {command: 'CloseAccount'}]; + const reportActionID = '1'; + const originalReportID = '1'; + const result = resolveCommentDeletionConflicts(persistedRequests, reportActionID, originalReportID); + expect(result).toEqual({conflictAction: {type: 'push'}}); + }); + + it('resolveCommentDeletionConflicts should return delete when special comments are found', () => { + const persistedRequests = [{command: 'AddComment', data: {reportActionID: '2'}}, {command: 'CloseAccount'}, {command: 'OpenReport'}]; + const reportActionID = '2'; + const originalReportID = '1'; + const result = resolveCommentDeletionConflicts(persistedRequests, reportActionID, originalReportID); + expect(result).toEqual({conflictAction: {type: 'delete', indices: [0], pushNewRequest: false}}); + }); + + it.each([['AddComment'], ['AddAttachment'], ['AddTextAndAttachment']])( + 'resolveCommentDeletionConflicts should return delete when special comments are found and %s is true', + (commandName) => { + const updateSpy = jest.spyOn(Onyx, 'update'); + const persistedRequests = [ + {command: commandName, data: {reportActionID: '2'}}, + {command: 'UpdateComment', data: {reportActionID: '2'}}, + {command: 'CloseAccount'}, + {command: 'OpenReport'}, + ]; + const reportActionID = '2'; + const originalReportID = '1'; + const result = resolveCommentDeletionConflicts(persistedRequests, reportActionID, originalReportID); + expect(result).toEqual({conflictAction: {type: 'delete', indices: [0, 1], pushNewRequest: false}}); + expect(updateSpy).toHaveBeenCalledTimes(1); + updateSpy.mockClear(); + }, + ); + + it.each([['UpdateComment'], ['AddEmojiReaction'], ['RemoveEmojiReaction']])( + 'resolveCommentDeletionConflicts should return delete when special comments are found and %s is false', + (commandName) => { + const persistedRequests = [{command: commandName, data: {reportActionID: '2'}}, {command: 'CloseAccount'}, {command: 'OpenReport'}]; + const reportActionID = '2'; + const originalReportID = '1'; + const result = resolveCommentDeletionConflicts(persistedRequests, reportActionID, originalReportID); + expect(result).toEqual({conflictAction: {type: 'delete', indices: [0], pushNewRequest: true}}); + }, + ); + + it('resolveCommentDeletionConflicts should return push when an OpenReport as thread is found', () => { + const reportActionID = '2'; + const persistedRequests = [ + {command: 'CloseAccount'}, + {command: 'AddComment', data: {reportActionID}}, + {command: 'OpenReport', data: {parentReportActionID: reportActionID}}, + {command: 'AddComment', data: {reportActionID: '3'}}, + {command: 'OpenReport'}, + ]; + const originalReportID = '1'; + const result = resolveCommentDeletionConflicts(persistedRequests, reportActionID, originalReportID); + expect(result).toEqual({conflictAction: {type: 'push'}}); + }); });