From 7e4b8b4dd9814e708e561e7eab43498dafa2e2c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Fri, 11 Mar 2022 18:00:09 +0100 Subject: [PATCH] [Gekidou] [MM-41524] Add tests to draft Upload Manager (#5990) * Add tests to draft Upload Manager * Address feedback --- app/init/draft_upload_manager/index.test.ts | 439 ++++++++++++++++++ .../index.ts} | 12 +- test/test_helper.js | 1 + 3 files changed, 449 insertions(+), 3 deletions(-) create mode 100644 app/init/draft_upload_manager/index.test.ts rename app/init/{draft_upload_manager.ts => draft_upload_manager/index.ts} (94%) diff --git a/app/init/draft_upload_manager/index.test.ts b/app/init/draft_upload_manager/index.test.ts new file mode 100644 index 0000000000..eaca299aec --- /dev/null +++ b/app/init/draft_upload_manager/index.test.ts @@ -0,0 +1,439 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ClientResponse, ProgressPromise} from '@mattermost/react-native-network-client'; +import {AppState, AppStateStatus} from 'react-native'; + +import {addFilesToDraft} from '@actions/local/draft'; +import {PROGRESS_TIME_TO_STORE} from '@constants/files'; +import DatabaseManager from '@database/manager'; +import ServerDataOperator from '@database/operator/server_data_operator'; +import {queryDraft} from '@queries/servers/drafts'; +import TestHelper from '@test/test_helper'; + +import {exportedForTesting} from '.'; + +const {DraftUploadManager} = exportedForTesting; + +const url = 'baseHandler.test.com'; +const mockClient = TestHelper.createClient(); + +jest.mock('@init/network_manager', () => { + const original = jest.requireActual('@init/network_manager'); + return { + ...original, + getClient: (serverUrl: string) => { + if (serverUrl === url) { + return mockClient; + } + + throw new Error('client not found'); + }, + }; +}); + +const now = new Date('2020-01-01').getTime(); +const timeNotStore = now + (PROGRESS_TIME_TO_STORE - 1); +const timeStore = now + PROGRESS_TIME_TO_STORE + 1; + +const mockUpload = () => { + const returnValue: { + resolvePromise: ((value: ClientResponse | PromiseLike) => void) | null; + rejectPromise: ((reason?: any) => void) | null; + progressFunc: ((fractionCompleted: number, bytesRead?: number | null | undefined) => void) | null; + } = { + resolvePromise: null, + rejectPromise: null, + progressFunc: null, + }; + (mockClient.apiClient.upload as jest.Mock).mockImplementationOnce(() => { + const promise = (new Promise((resolve, reject) => { + returnValue.resolvePromise = resolve; + returnValue.rejectPromise = reject; + }) as ProgressPromise); + promise.progress = (f) => { + returnValue.progressFunc = f; + return promise; + }; + promise.cancel = jest.fn(); + return promise; + }); + + return returnValue; +}; + +describe('draft upload manager', () => { + let operator: ServerDataOperator; + const channelId = 'cid'; + const rootId = 'rid'; + + beforeEach(async () => { + await DatabaseManager.init([url]); + operator = DatabaseManager.serverDatabases[url].operator; + AppState.currentState = 'active'; + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(url); + }); + + it('File is uploaded and stored', async () => { + const manager = new DraftUploadManager(); + const uploadMocks = mockUpload(); + + const fileClientId = 'clientId'; + const fileServerId = 'serverId'; + await addFilesToDraft(url, channelId, rootId, [{clientId: fileClientId} as FileInfo]); + + manager.prepareUpload(url, {clientId: fileClientId} as FileInfo, channelId, rootId, 0); + expect(manager.isUploading(fileClientId)).toBe(true); + + expect(uploadMocks.resolvePromise).not.toBeNull(); + uploadMocks.resolvePromise!({ok: true, code: 201, data: {file_infos: [{clientId: fileClientId, id: fileServerId}]}}); + + // Wait for other promises (on complete write) to finish + await new Promise(process.nextTick); + + const draft = await queryDraft(operator.database, channelId, rootId); + expect(draft?.files.length).toBe(1); + expect(draft?.files[0].id).toBe(fileServerId); + + expect(manager.isUploading(fileClientId)).toBe(false); + }); + + it('Progress is not stored on progress, but stored on fail', async () => { + const manager = new DraftUploadManager(); + const uploadMocks = mockUpload(); + + const fileClientId = 'clientId'; + await addFilesToDraft(url, channelId, rootId, [{clientId: fileClientId} as FileInfo]); + + manager.prepareUpload(url, {clientId: fileClientId} as FileInfo, channelId, rootId, 0); + expect(manager.isUploading(fileClientId)).toBe(true); + + // Wait for other promises to finish + await new Promise(process.nextTick); + + const bytesRead = 200; + uploadMocks.progressFunc!(0.1, bytesRead); + + // Wait for other promises to finish + await new Promise(process.nextTick); + + // There has been progress, but we are not storing in to the database since the app is still active. + let draft = await queryDraft(operator.database, channelId, rootId); + expect(draft?.files.length).toBe(1); + expect(draft?.files[0].bytesRead).toBeUndefined(); + + uploadMocks.rejectPromise!('error'); + + // Wait for other promises to finish + await new Promise(process.nextTick); + + // After a failure, we store the progress on the database, so we can resume from the point before failure. + draft = await queryDraft(operator.database, channelId, rootId); + expect(draft?.files.length).toBe(1); + expect(draft?.files[0].bytesRead).toBe(bytesRead); + expect(draft?.files[0].failed).toBe(true); + + expect(manager.isUploading(fileClientId)).toBe(false); + }); + + it('Progress is stored on AppState change to background for all files, and then only after certain time', async () => { + const channelIds = ['cid1', 'cid2', 'cid3']; + const rootIds = ['rid1', '', 'rid3']; + const clientIds = ['client1', 'client2', 'client3']; + const fileUrls = ['url1', 'url2', 'url3']; + const bytesReads = [200, 300, 400]; + const bytesReadsNotStore = [200, 300, 400]; + const bytesReadsStore = [400, 1000, 450]; + const bytesReadBeforeActive = [500, 1200, 650]; + const appStateSpy = jest.spyOn(AppState, 'addEventListener'); + let eventListener: (state: AppStateStatus) => void; + appStateSpy.mockImplementationOnce((name, f) => { + eventListener = f; + return {} as any; + }); + const spyNow = jest.spyOn(Date, 'now'); + spyNow.mockImplementation(() => now); + AppState.currentState = 'active'; + const manager = new DraftUploadManager(); + + const progressFunc: {[fileUrl: string] : ((fractionCompleted: number, bytesRead?: number | null | undefined) => void)} = {}; + const cancel = jest.fn(); + + let promise: ProgressPromise; + (mockClient.apiClient.upload as jest.Mock).mockImplementation((endpoint, fileUrl) => { + promise = (new Promise(() => { + // Do nothing + }) as ProgressPromise); + promise.progress = (f) => { + progressFunc[fileUrl] = f; + return promise; + }; + promise.cancel = cancel; + return promise; + }); + + for (let i = 0; i < 3; i++) { + const file = {clientId: clientIds[i], localPath: fileUrls[i]} as FileInfo; + // eslint-disable-next-line no-await-in-loop + await addFilesToDraft(url, channelIds[i], rootIds[i], [file]); + manager.prepareUpload(url, file, channelIds[i], rootIds[i], 0); + } + + (mockClient.apiClient.upload as jest.Mock).mockRestore(); + + for (let i = 0; i < 3; i++) { + progressFunc[fileUrls[i]](0.1, bytesReads[i]); + } + + AppState.currentState = 'background'; + eventListener!('background'); + + // Wait for other promises to finish + await new Promise(process.nextTick); + + for (let i = 0; i < 3; i++) { + // eslint-disable-next-line no-await-in-loop + const draft = await queryDraft(operator.database, channelIds[i], rootIds[i]); + expect(draft?.files.length).toBe(1); + expect(draft?.files[0].bytesRead).toBe(bytesReads[i]); + } + + // Add progress inside the time window where it should not store in background + spyNow.mockImplementation(() => timeNotStore); + + for (let i = 0; i < 3; i++) { + progressFunc[fileUrls[i]](0.1, bytesReadsNotStore[i]); + } + + // Wait for other promises to finish + await new Promise(process.nextTick); + + for (let i = 0; i < 3; i++) { + // eslint-disable-next-line no-await-in-loop + const draft = await queryDraft(operator.database, channelIds[i], rootIds[i]); + expect(draft?.files.length).toBe(1); + expect(draft?.files[0].bytesRead).toBe(bytesReads[i]); + } + + // Add progress inside the time window where it should store in background + spyNow.mockImplementation(() => timeStore); + + for (let i = 0; i < 3; i++) { + progressFunc[fileUrls[i]](0.1, bytesReadsStore[i]); + + // Wait for other promises to finish (if not, watermelondb complains) + // eslint-disable-next-line no-await-in-loop + await new Promise(process.nextTick); + } + + for (let i = 0; i < 3; i++) { + // eslint-disable-next-line no-await-in-loop + const draft = await queryDraft(operator.database, channelIds[i], rootIds[i]); + expect(draft?.files.length).toBe(1); + expect(draft?.files[0].bytesRead).toBe(bytesReadsStore[i]); + } + + for (let i = 0; i < 3; i++) { + progressFunc[fileUrls[i]](0.1, bytesReadBeforeActive[i]); + } + + AppState.currentState = 'active'; + eventListener!('active'); + + for (let i = 0; i < 3; i++) { + // eslint-disable-next-line no-await-in-loop + const draft = await queryDraft(operator.database, channelIds[i], rootIds[i]); + expect(draft?.files.length).toBe(1); + expect(draft?.files[0].bytesRead).toBe(bytesReadsStore[i]); + } + + spyNow.mockClear(); + }); + + it('Error on complete: Received wrong response code', async () => { + const manager = new DraftUploadManager(); + const uploadMocks = mockUpload(); + + const fileClientId = 'clientId'; + const fileServerId = 'serverId'; + await addFilesToDraft(url, channelId, rootId, [{clientId: fileClientId} as FileInfo]); + + manager.prepareUpload(url, {clientId: fileClientId} as FileInfo, channelId, rootId, 0); + expect(manager.isUploading(fileClientId)).toBe(true); + + expect(uploadMocks.resolvePromise).not.toBeNull(); + uploadMocks.resolvePromise!({ok: true, code: 500, data: {file_infos: [{clientId: fileClientId, id: fileServerId}]}}); + + // Wait for other promises (on complete write) to finish + await new Promise(process.nextTick); + + const draft = await queryDraft(operator.database, channelId, rootId); + expect(draft?.files.length).toBe(1); + expect(draft?.files[0].id).toBeUndefined(); + expect(draft?.files[0].failed).toBe(true); + + expect(manager.isUploading(fileClientId)).toBe(false); + }); + + it('Error on complete: Received no data', async () => { + const manager = new DraftUploadManager(); + const uploadMocks = mockUpload(); + + const clientId = 'clientId'; + await addFilesToDraft(url, channelId, rootId, [{clientId} as FileInfo]); + + manager.prepareUpload(url, {clientId} as FileInfo, channelId, rootId, 0); + expect(manager.isUploading(clientId)).toBe(true); + + expect(uploadMocks.resolvePromise).not.toBeNull(); + uploadMocks.resolvePromise!({ok: true, code: 201}); + + // Wait for other promises (on complete write) to finish + await new Promise(process.nextTick); + + const draft = await queryDraft(operator.database, channelId, rootId); + expect(draft?.files.length).toBe(1); + expect(draft?.files[0].id).toBeUndefined(); + expect(draft?.files[0].failed).toBe(true); + + expect(manager.isUploading(clientId)).toBe(false); + }); + + it('Error on complete: Received no file info', async () => { + const manager = new DraftUploadManager(); + const uploadMocks = mockUpload(); + + const clientId = 'clientId'; + await addFilesToDraft(url, channelId, rootId, [{clientId} as FileInfo]); + + manager.prepareUpload(url, {clientId} as FileInfo, channelId, rootId, 0); + expect(manager.isUploading(clientId)).toBe(true); + + expect(uploadMocks.resolvePromise).not.toBeNull(); + uploadMocks.resolvePromise!({ok: true, code: 201, data: {}}); + + // Wait for other promises (on complete write) to finish + await new Promise(process.nextTick); + + const draft = await queryDraft(operator.database, channelId, rootId); + expect(draft?.files.length).toBe(1); + expect(draft?.files[0].id).toBeUndefined(); + expect(draft?.files[0].failed).toBe(true); + + expect(manager.isUploading(clientId)).toBe(false); + }); + + it('Progress handler', async () => { + const manager = new DraftUploadManager(); + const uploadMocks = mockUpload(); + + const clientId = 'clientId'; + await addFilesToDraft(url, channelId, rootId, [{clientId} as FileInfo]); + + const nullProgressHandler = jest.fn(); + let cancelProgressHandler = manager.registerProgressHandler(clientId, nullProgressHandler); + expect(cancelProgressHandler).toBeNull(); + + manager.prepareUpload(url, {clientId} as FileInfo, channelId, rootId, 0); + expect(manager.isUploading(clientId)).toBe(true); + + const progressHandler = jest.fn(); + cancelProgressHandler = manager.registerProgressHandler(clientId, progressHandler); + expect(cancelProgressHandler).not.toBeNull(); + + let bytesRead = 200; + uploadMocks.progressFunc!(0.1, bytesRead); + + // Wait for other promises (on complete write) to finish + await new Promise(process.nextTick); + + expect(progressHandler).toHaveBeenCalledWith(0.1, bytesRead); + + cancelProgressHandler!(); + bytesRead = 400; + uploadMocks.progressFunc!(0.1, bytesRead); + + // Make sure calling several times the cancel does not create any problem. + cancelProgressHandler!(); + + // Wait for other promises (on complete write) to finish + await new Promise(process.nextTick); + + expect(manager.isUploading(clientId)).toBe(true); + expect(progressHandler).toHaveBeenCalledTimes(1); + expect(nullProgressHandler).not.toHaveBeenCalled(); + }); + + it('Error handler: normal error', async () => { + const manager = new DraftUploadManager(); + const uploadMocks = mockUpload(); + + const clientId = 'clientId'; + await addFilesToDraft(url, channelId, rootId, [{clientId} as FileInfo]); + + const nullErrorHandler = jest.fn(); + let cancelErrorHandler = manager.registerProgressHandler(clientId, nullErrorHandler); + expect(cancelErrorHandler).toBeNull(); + + manager.prepareUpload(url, {clientId} as FileInfo, channelId, rootId, 0); + expect(manager.isUploading(clientId)).toBe(true); + + const errorHandler = jest.fn(); + cancelErrorHandler = manager.registerErrorHandler(clientId, errorHandler); + expect(cancelErrorHandler).not.toBeNull(); + + uploadMocks.rejectPromise!({message: 'error'}); + + // Wait for other promises (on complete write) to finish + await new Promise(process.nextTick); + + expect(errorHandler).toHaveBeenCalledWith('error'); + + // Make sure cancelling after error does not create any problem. + cancelErrorHandler!(); + + // Wait for other promises (on complete write) to finish + await new Promise(process.nextTick); + + expect(manager.isUploading(clientId)).toBe(false); + expect(errorHandler).toHaveBeenCalledTimes(1); + expect(nullErrorHandler).not.toHaveBeenCalled(); + }); + + it('Error handler: complete error', async () => { + const manager = new DraftUploadManager(); + const uploadMocks = mockUpload(); + + const clientId = 'clientId'; + await addFilesToDraft(url, channelId, rootId, [{clientId} as FileInfo]); + + const nullErrorHandler = jest.fn(); + let cancelErrorHandler = manager.registerProgressHandler(clientId, nullErrorHandler); + expect(cancelErrorHandler).toBeNull(); + + manager.prepareUpload(url, {clientId} as FileInfo, channelId, rootId, 0); + expect(manager.isUploading(clientId)).toBe(true); + + const errorHandler = jest.fn(); + cancelErrorHandler = manager.registerErrorHandler(clientId, errorHandler); + expect(cancelErrorHandler).not.toBeNull(); + + uploadMocks.resolvePromise!({ok: true, code: 500}); + + // Wait for other promises (on complete write) to finish + await new Promise(process.nextTick); + + // Make sure cancelling after error does not create any problem. + cancelErrorHandler!(); + + // Wait for other promises (on complete write) to finish + await new Promise(process.nextTick); + + expect(manager.isUploading(clientId)).toBe(false); + expect(errorHandler).toHaveBeenCalledTimes(1); + expect(nullErrorHandler).not.toHaveBeenCalled(); + }); +}); diff --git a/app/init/draft_upload_manager.ts b/app/init/draft_upload_manager/index.ts similarity index 94% rename from app/init/draft_upload_manager.ts rename to app/init/draft_upload_manager/index.ts index 320e9d1eb4..a31cf945ee 100644 --- a/app/init/draft_upload_manager.ts +++ b/app/init/draft_upload_manager/index.ts @@ -168,17 +168,23 @@ class DraftUploadManager { private onAppStateChange = async (appState: AppStateStatus) => { if (appState !== 'active' && this.previousAppState === 'active') { - this.storeProgress(); + await this.storeProgress(); } this.previousAppState = appState; }; - private storeProgress = () => { + private storeProgress = async () => { for (const h of Object.values(this.handlers)) { - updateDraftFile(h.serverUrl, h.channelId, h.rootId, h.fileInfo); + // eslint-disable-next-line no-await-in-loop + await updateDraftFile(h.serverUrl, h.channelId, h.rootId, h.fileInfo); + h.lastTimeStored = Date.now(); } }; } export default new DraftUploadManager(); + +export const exportedForTesting = { + DraftUploadManager, +}; diff --git a/test/test_helper.js b/test/test_helper.js index 5bd929f0c3..806a2487c0 100644 --- a/test/test_helper.js +++ b/test/test_helper.js @@ -116,6 +116,7 @@ class TestHelper { patch: jest.fn(), post: jest.fn(), put: jest.fn(), + upload: jest.fn(), }; return new Client(mockApiClient, mockApiClient.baseUrl);