From c503b78badd18f4c8b4188580b3c5b74b6a246bf Mon Sep 17 00:00:00 2001 From: Eduardo Date: Tue, 15 Oct 2024 16:54:49 +0200 Subject: [PATCH 1/4] Adding conflict resolver for delete comment --- src/libs/Network/SequentialQueue.ts | 11 +- src/libs/actions/PersistedRequests.ts | 17 +- src/libs/actions/Report.ts | 61 +++- src/types/onyx/Request.ts | 22 +- tests/actions/ReportTest.ts | 471 ++++++++++++++++++++++++++ tests/unit/PersistedRequests.ts | 4 +- 6 files changed, 577 insertions(+), 9 deletions(-) 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..10003b8b4b5 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); @@ -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 95bd2aa0b83..b5e1825a449 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1414,6 +1414,16 @@ function handleReportChanged(report: OnyxEntry) { } } } +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, +]); /** Deletes a comment from the report, basically sets it as empty string */ function deleteReportComment(reportID: string, reportAction: ReportAction) { @@ -1538,7 +1548,56 @@ 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) => { + const indices: number[] = []; + let addCommentFound = false; + + persistedRequests.forEach((request, index) => { + if (!commentsToBeDeleted.has(request.command) || request.data?.reportActionID !== reportActionID) { + return; + } + if (addNewMessage.has(request.command)) { + addCommentFound = true; + } + indices.push(index); + }); + + if (indices.length === 0) { + return { + conflictAction: { + type: 'push', + }, + }; + } + + if (addCommentFound) { + const rollbackData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`, + value: { + [reportActionID]: null, + }, + }, + ]; + Onyx.update(rollbackData); + } + + return { + conflictAction: { + type: 'delete', + indices, + pushNewRequest: !addCommentFound, + }, + }; + }, + }, + ); // 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/types/onyx/Request.ts b/src/types/onyx/Request.ts index 238e3a8c6a8..08510087094 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; }; /** diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 0ffb0ee9bc0..dc54c3730ad 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -1,8 +1,11 @@ /* 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 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 CONST from '@src/CONST'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import * as PersistedRequests from '@src/libs/actions/PersistedRequests'; @@ -757,4 +760,472 @@ describe('actions/Report', () => { expect(reportActionReaction?.[EMOJI.name].users[TEST_USER_ACCOUNT_ID]).toBeUndefined(); }); }); + + 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'); + + // wait for Onyx.connect execute the callback and start processing the queue + await Promise.resolve(); + + 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 dleting 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 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 Promise.resolve(); + + 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 dleting 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); + }, 2000); + + 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 Promise.resolve(); + + 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 dleting 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); + }); }); 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); }); From ecb7aaa4ee279520e671b7b497078fd1ca356b95 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Wed, 16 Oct 2024 17:33:08 +0200 Subject: [PATCH 2/4] Adding case for OpenReport when creating a thread --- src/libs/actions/RequestConflictUtils.ts | 15 ++++- tests/actions/ReportTest.ts | 80 ++++++++++++++++++++---- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts index f8aefbe73d8..02210523721 100644 --- a/src/libs/actions/RequestConflictUtils.ts +++ b/src/libs/actions/RequestConflictUtils.ts @@ -1,5 +1,6 @@ import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {WriteCommand} from '@libs/API/types'; import {WRITE_COMMANDS} from '@libs/API/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type OnyxRequest from '@src/types/onyx/Request'; @@ -43,14 +44,25 @@ function resolveDuplicationConflictAction(persistedRequests: OnyxRequest[], comm function resolveCommentDeletionConflicts(persistedRequests: OnyxRequest[], reportActionID: string, originalReportID: string): ConflictActionData { const indices: 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]; + indices.splice(indexToRemove, 1); + 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)) { addCommentFound = true; + commentCouldBeThread[reportActionID] = index; } indices.push(index); }); @@ -64,6 +76,7 @@ function resolveCommentDeletionConflicts(persistedRequests: OnyxRequest[], repor } if (addCommentFound) { + // The new message performs some changes in Onyx, so we need to rollback those changes. const rollbackData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index dc54c3730ad..bdb1b0c5e59 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -778,8 +778,7 @@ describe('actions/Report', () => { const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); Report.editReportComment(REPORT_ID, reportAction, 'Testing an edited comment'); - // wait for Onyx.connect execute the callback and start processing the queue - await Promise.resolve(); + await waitForBatchedUpdates(); await new Promise((resolve) => { const connection = Onyx.connect({ @@ -795,12 +794,13 @@ describe('actions/Report', () => { }); }); - // Checking the Report Action exists before dleting it + // 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(); @@ -859,9 +859,7 @@ describe('actions/Report', () => { key: ONYXKEYS.PERSISTED_REQUESTS, callback: (persistedRequests) => { Onyx.disconnect(connection); - expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.UPDATE_COMMENT); - resolve(); }, }); @@ -909,9 +907,7 @@ describe('actions/Report', () => { key: ONYXKEYS.PERSISTED_REQUESTS, callback: (persistedRequests) => { Onyx.disconnect(connection); - expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.UPDATE_COMMENT); - resolve(); }, }); @@ -950,21 +946,20 @@ describe('actions/Report', () => { 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 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 dleting it + // Checking the Report Action exists before deleting it await new Promise((resolve) => { const connection = Onyx.connect({ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, @@ -1018,21 +1013,20 @@ describe('actions/Report', () => { 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 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 dleting it + // Checking the Report Action exists before deleting it await new Promise((resolve) => { const connection = Onyx.connect({ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, @@ -1228,4 +1222,64 @@ describe('actions/Report', () => { 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); + }); }); From c93b9b4a64f2e983f8a3d492cd9d99ab1c6bc270 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Wed, 23 Oct 2024 19:41:36 +0200 Subject: [PATCH 3/4] Add new field to know if a request was rollbacked --- src/libs/actions/PersistedRequests.ts | 2 +- src/libs/actions/RequestConflictUtils.ts | 2 +- src/types/onyx/Request.ts | 5 ++ tests/actions/ReportTest.ts | 64 ++++++++++++++++++++++-- 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/libs/actions/PersistedRequests.ts b/src/libs/actions/PersistedRequests.ts index 10003b8b4b5..ebeaec6881a 100644 --- a/src/libs/actions/PersistedRequests.ts +++ b/src/libs/actions/PersistedRequests.ts @@ -130,7 +130,7 @@ function rollbackOngoingRequest() { } // Prepend ongoingRequest to persistedRequests - persistedRequests.unshift(ongoingRequest); + persistedRequests.unshift({...ongoingRequest, isRollbacked: true}); // Clear the ongoingRequest ongoingRequest = null; diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts index baad51eda0c..e806995fb6c 100644 --- a/src/libs/actions/RequestConflictUtils.ts +++ b/src/libs/actions/RequestConflictUtils.ts @@ -61,7 +61,7 @@ function resolveCommentDeletionConflicts(persistedRequests: OnyxRequest[], repor // 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)) { + if (addNewMessage.has(request.command) && !request.isRollbacked) { addCommentFound = true; commentCouldBeThread[reportActionID] = index; } diff --git a/src/types/onyx/Request.ts b/src/types/onyx/Request.ts index 08510087094..879164eafaf 100644 --- a/src/types/onyx/Request.ts +++ b/src/types/onyx/Request.ts @@ -135,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 b896cd553f4..bcbeeff9f62 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -2,10 +2,12 @@ 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'; @@ -38,7 +40,7 @@ jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({ didScreenTransitionEnd: true, }), })); - +const originalXHR = HttpUtils.xhr; OnyxUpdateManager(); describe('actions/Report', () => { beforeAll(() => { @@ -49,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(); @@ -977,6 +984,57 @@ describe('actions/Report', () => { 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(); @@ -1042,7 +1100,7 @@ describe('actions/Report', () => { // Checking no requests were or will be made TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_ATTACHMENT, 0); TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 0); - }, 2000); + }); it('should send not DeleteComment request and remove AddTextAndAttachment accordingly', async () => { global.fetch = TestHelper.getGlobalFetchMock(); From abd5651d2d7372476d98666cce9e302d42fb7038 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Fri, 25 Oct 2024 15:23:18 +0200 Subject: [PATCH 4/4] Fixed wrong indexing --- src/libs/actions/RequestConflictUtils.ts | 14 ++++++++------ tests/unit/RequestConflictUtilsTest.ts | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts index 9ddb80b5bf6..3c812642ff7 100644 --- a/src/libs/actions/RequestConflictUtils.ts +++ b/src/libs/actions/RequestConflictUtils.ts @@ -50,14 +50,16 @@ function resolveDuplicationConflictAction(persistedRequests: OnyxRequest[], requ } function resolveCommentDeletionConflicts(persistedRequests: OnyxRequest[], reportActionID: string, originalReportID: string): ConflictActionData { - const indices: number[] = []; + 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]; - indices.splice(indexToRemove, 1); + commentIndicesToDelete.splice(indexToRemove, 1); + // The new message performs some changes in Onyx, we want to keep those changes. + addCommentFound = false; return; } @@ -69,12 +71,12 @@ function resolveCommentDeletionConflicts(persistedRequests: OnyxRequest[], repor // doesn't know about it yet. if (addNewMessage.has(request.command) && !request.isRollbacked) { addCommentFound = true; - commentCouldBeThread[reportActionID] = index; + commentCouldBeThread[reportActionID] = commentIndicesToDelete.length; } - indices.push(index); + commentIndicesToDelete.push(index); }); - if (indices.length === 0) { + if (commentIndicesToDelete.length === 0) { return { conflictAction: { type: 'push', @@ -99,7 +101,7 @@ function resolveCommentDeletionConflicts(persistedRequests: OnyxRequest[], repor return { conflictAction: { type: 'delete', - indices, + indices: commentIndicesToDelete, pushNewRequest: !addCommentFound, }, }; diff --git a/tests/unit/RequestConflictUtilsTest.ts b/tests/unit/RequestConflictUtilsTest.ts index d566f5e64cf..93928b877f0 100644 --- a/tests/unit/RequestConflictUtilsTest.ts +++ b/tests/unit/RequestConflictUtilsTest.ts @@ -79,4 +79,18 @@ describe('RequestConflictUtils', () => { 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'}}); + }); });