From 8857234ea7b7a0f51a1978541b8aa51f2f789bad Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Mon, 2 Nov 2026 16:53:59 +0100 Subject: [PATCH] refactor(upload): remove the legacy upload thunks, folder managers and upload retry paths --- .../uploadFoldersWithTracking.ts | 26 - src/app/i18n/locales/de.json | 4 - src/app/i18n/locales/en.json | 4 - src/app/i18n/locales/es.json | 4 - src/app/i18n/locales/fr.json | 4 - src/app/i18n/locales/it.json | 4 - src/app/i18n/locales/pt.json | 4 - src/app/i18n/locales/ru.json | 4 - src/app/i18n/locales/tw.json | 4 - src/app/i18n/locales/zh.json | 4 - src/app/network/RetryManager.test.ts | 135 +-- src/app/network/RetryManager.ts | 39 +- src/app/network/UploadFolderManager.test.ts | 837 ------------------ src/app/network/UploadFolderManager.ts | 449 ---------- .../fileUtils/prepareFilesToUpload.test.ts | 377 -------- .../storage/fileUtils/prepareFilesToUpload.ts | 67 -- .../fileUtils/processDuplicateFiles.test.ts | 141 --- .../fileUtils/processDuplicateFiles.ts | 48 - .../folderUtils/getUniqueFolderName.test.ts | 2 - .../storage/hooks/useReduxActions.test.ts | 169 ---- .../slices/storage/hooks/useReduxActions.ts | 67 -- .../slices/storage/storage.thunks/index.ts | 7 - .../storage.thunks/uploadFolderThunk.test.ts | 255 ------ .../storage.thunks/uploadFolderThunk.ts | 292 ------ .../storage.thunks/uploadItemsThunk.test.ts | 473 ---------- .../storage.thunks/uploadItemsThunk.ts | 558 ------------ .../components/TaskLogger/TaskLogger.tsx | 9 +- .../TaskLoggerItem/TaskLoggerItem.test.tsx | 23 +- .../TaskLoggerItem/TaskLoggerItem.tsx | 36 +- .../TaskToRetry/TaskToRetry.test.tsx | 133 +-- .../useRetryManagerFailedItems.test.ts | 87 +- .../TaskToRetry/useRetryManagerFailedItems.ts | 66 +- src/app/tasks/hooks/useRetry.ts | 86 +- src/app/tasks/services/tasks.service/index.ts | 1 - src/app/tasks/types.ts | 14 - .../tasks/upload/uploadFilesWithTasks.test.ts | 337 ------- src/app/tasks/upload/uploadFilesWithTasks.ts | 237 ----- .../upload/uploadFoldersWithTasks.test.ts | 265 ------ .../tasks/upload/uploadFoldersWithTasks.ts | 123 --- src/app/upload/Upload.ts | 7 - 40 files changed, 80 insertions(+), 5322 deletions(-) delete mode 100644 src/app/drive/services/folder.service/uploadFoldersWithTracking.ts delete mode 100644 src/app/network/UploadFolderManager.test.ts delete mode 100644 src/app/network/UploadFolderManager.ts delete mode 100644 src/app/store/slices/storage/fileUtils/prepareFilesToUpload.test.ts delete mode 100644 src/app/store/slices/storage/fileUtils/prepareFilesToUpload.ts delete mode 100644 src/app/store/slices/storage/fileUtils/processDuplicateFiles.test.ts delete mode 100644 src/app/store/slices/storage/fileUtils/processDuplicateFiles.ts delete mode 100644 src/app/store/slices/storage/hooks/useReduxActions.test.ts delete mode 100644 src/app/store/slices/storage/hooks/useReduxActions.ts delete mode 100644 src/app/store/slices/storage/storage.thunks/uploadFolderThunk.test.ts delete mode 100644 src/app/store/slices/storage/storage.thunks/uploadFolderThunk.ts delete mode 100644 src/app/store/slices/storage/storage.thunks/uploadItemsThunk.test.ts delete mode 100644 src/app/store/slices/storage/storage.thunks/uploadItemsThunk.ts delete mode 100644 src/app/tasks/upload/uploadFilesWithTasks.test.ts delete mode 100644 src/app/tasks/upload/uploadFilesWithTasks.ts delete mode 100644 src/app/tasks/upload/uploadFoldersWithTasks.test.ts delete mode 100644 src/app/tasks/upload/uploadFoldersWithTasks.ts diff --git a/src/app/drive/services/folder.service/uploadFoldersWithTracking.ts b/src/app/drive/services/folder.service/uploadFoldersWithTracking.ts deleted file mode 100644 index 3a16176eb8..0000000000 --- a/src/app/drive/services/folder.service/uploadFoldersWithTracking.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { uiActions } from 'app/store/slices/ui'; -import { planThunks } from 'app/store/slices/plan'; -import { uploadFoldersWithTasks } from 'app/tasks/upload/uploadFoldersWithTasks'; - -type UploadFoldersWithTasksProps = Parameters[0]; -type UploadFoldersWithTrackingProps = Omit< - UploadFoldersWithTasksProps, - 'fileSizeExceededCallback' | 'onFolderUploadSucceeded' ->; - -export const uploadFoldersWithTracking = (props: UploadFoldersWithTrackingProps): Promise => { - const { dispatch, selectedWorkspace } = props; - const memberId = selectedWorkspace?.workspaceUser?.memberId; - - return uploadFoldersWithTasks({ - ...props, - fileSizeExceededCallback: (exceededFiles) => - dispatch(uiActions.setOpenFileSizeLimitReachedDialog({ open: true, info: { exceededFiles } })), - onFolderUploadSucceeded: () => { - setTimeout(() => { - dispatch(planThunks.fetchUsageThunk()); - if (memberId) dispatch(planThunks.fetchBusinessLimitUsageThunk()); - }, 1000); - }, - }); -}; diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index 504e57d6ad..5c11c53c2b 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -1115,9 +1115,6 @@ "fetchingFolderContent": "Fehler beim Abrufen des Ordnerinhalts", "togglingFavorite": "Beim Aktualisieren der Favoriten ist ein Fehler aufgetreten", "togglingSomeFavorites": "Einige Favoriten konnten nicht aktualisiert werden", - "uploadingFolder": "Fehler beim Hochladen des Ordners", - "uploadingFile": "Fehler beim Hochladen der Datei: {{reason}}", - "uploadingItems": "Einige Elemente konnten nicht hochgeladen werden", "downloadingFile": "Fehler beim Herunterladen der Datei: {{message}}", "downloadingFolder": "Fehler beim Herunterladen des Ordners: {{message}}", "movingItemInsideItself": "Du kannst einen Ordner nicht in sich selbst verschieben", @@ -1355,7 +1352,6 @@ "upload-failed": "Upload fehlgeschlagen" }, "generalErrorMessages": { - "retryUploadFailed": "Beim erneuten Hochladen ist etwas schiefgegangen. Versuche es erneut", "retryDownloadFailed": "Etwas ist schief gelaufen beim erneuten Versuch des Downloads. Versuche den Download erneut.", "openUploadedFileFailed": "Etwas ist schief gelaufen beim Versuch, die Datei zu öffnen", "openUploadedFolderFailed": "Etwas ist schief gelaufen beim Versuch, zum Ordner zu navigieren" diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index 58cbde8b64..48aab5faa4 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -1136,9 +1136,6 @@ "fetchingFolderContent": "Error fetching folder content", "togglingFavorite": "There was an error updating favorites", "togglingSomeFavorites": "Some favorites could not be updated", - "uploadingFolder": "Error uploading folder", - "uploadingFile": "Error uploading file: {{reason}}", - "uploadingItems": "Some of the elements could not be uploaded", "downloadingFile": "Error downloading file: {{message}}", "downloadingFolder": "Error downloading folder: {{message}}", "movingItemInsideItself": "You can't move a folder inside itself", @@ -1379,7 +1376,6 @@ "upload-failed": "Upload failed" }, "generalErrorMessages": { - "retryUploadFailed": "Something went wrong when retrying the upload. Try uploading again", "retryDownloadFailed": "Something went wrong while retrying the download. Please try downloading again.", "openUploadedFileFailed": "Something went wrong while trying to open the file", "openUploadedFolderFailed": "Something went wrong while trying to navigate to the folder" diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index cea13bbeaf..c2dae14a5a 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -1115,9 +1115,6 @@ "fetchingFolderContent": "Error al obtener la carpeta", "togglingFavorite": "Hubo un error al actualizar favoritos", "togglingSomeFavorites": "Algunos favoritos no se pudieron actualizar", - "uploadingFolder": "Error al subir la carpeta", - "uploadingFile": "Error al subir el archivo: {{reason}}", - "uploadingItems": "No se ha podido subir alguno de los elementos", "downloadingFile": "Error descargando archivo: {{message}}", "downloadingFolder": "Error descargando carpeta: {{message}}", "movingItemInsideItself": "No se puede mover una carpeta dentro de sí misma", @@ -1359,7 +1356,6 @@ "upload-failed": "Error al subir" }, "generalErrorMessages": { - "retryUploadFailed": "Algo salió mal al reintentar la subida. Intenta subirlo de nuevo", "retryDownloadFailed": "Algo salió mal al reintentar la descarga. Intenta la descarga de nuevo", "openUploadedFileFailed": "Algo salió mal al intentar abrir el archivo", "openUploadedFolderFailed": "Algo salió mal al intentar navegar hasta la carpeta" diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index 7304d1d97e..cb0802ed36 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -1115,9 +1115,6 @@ "fetchingFolderContent": "Erreur dans l'extraction du contenu d'un dossier", "togglingFavorite": "Une erreur s'est produite lors de la mise à jour des favoris", "togglingSomeFavorites": "Certains favoris n'ont pas pu être mis à jour", - "uploadingFolder": "Erreur lors du téléchargement d'un dossier", - "uploadingFile": "Erreur lors du téléchargement du fichier : {{reason}}", - "uploadingItems": "Certains éléments n'ont pas pu être téléchargés", "downloadingFile": "Erreur de téléchargement du fichier : {{message}}", "downloadingFolder": "Erreur de téléchargement du dossier : {{message}}", "movingItemInsideItself": "Vous ne pouvez pas déplacer un dossier à l'intérieur de lui-même", @@ -1358,7 +1355,6 @@ "upload-failed": "Échec du téléchargement" }, "generalErrorMessages": { - "retryUploadFailed": "Quelque chose s'est mal passé en réessayant de télécharger. Essayez de télécharger à nouveau", "retryDownloadFailed": "Quelque chose s'est mal passé lors de la nouvelle tentative de téléchargement. Veuillez réessayer le téléchargement.", "openUploadedFileFailed": "Quelque chose s'est mal passé lors de la tentative d'ouverture du fichier", "openUploadedFolderFailed": "Quelque chose s'est mal passé lors de la tentative d'accès au dossier" diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index 860d8f041b..742ebf8e4d 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -1136,9 +1136,6 @@ "fetchingFolderContent": "Errore nel recupero del contenuto della cartella", "togglingFavorite": "Si è verificato un errore durante l'aggiornamento dei preferiti", "togglingSomeFavorites": "Non è stato possibile aggiornare alcuni preferiti", - "uploadingFolder": "Errore nel caricamento della cartella", - "uploadingFile": "Errore nel caricamento del file: {{reason}}", - "uploadingItems": "Non è stato possibile caricare alcuni elementi", "downloadingFile": "Errore nel download del file: {{message}}", "downloadingFolder": "Errore nel download della cartella: {{message}}", "movingItemInsideItself": "Non è possibile spostare una cartella al suo interno", @@ -1379,7 +1376,6 @@ "upload-failed": "Caricamento fallito" }, "generalErrorMessages": { - "retryUploadFailed": "Qualcosa è andato storto nel riprovare a caricare. Prova a caricare di nuovo", "retryDownloadFailed": "Qualcosa è andato storto nel tentativo di ripetere il download. Riprova il download.", "openUploadedFileFailed": "Qualcosa è andato storto nel tentativo di aprire il file", "openUploadedFolderFailed": "Qualcosa è andato storto nel tentativo di navigare nella cartella" diff --git a/src/app/i18n/locales/pt.json b/src/app/i18n/locales/pt.json index 5311aa6bdd..d45706dc8c 100644 --- a/src/app/i18n/locales/pt.json +++ b/src/app/i18n/locales/pt.json @@ -1146,9 +1146,6 @@ "fetchingFolderContent": "Erro ao carregar o conteúdo da pasta", "togglingFavorite": "Ocorreu um erro ao atualizar os favoritos", "togglingSomeFavorites": "Não foi possível atualizar alguns favoritos", - "uploadingFolder": "Erro ao enviar a pasta", - "uploadingFile": "Erro ao enviar o arquivo: {{reason}}", - "uploadingItems": "Não foi possível enviar alguns dos elementos", "downloadingFile": "Erro ao baixar o arquivo: {{message}}", "downloadingFolder": "Erro ao baixar a pasta: {{message}}", "movingItemInsideItself": "Você não pode mover uma pasta para dentro dela mesma", @@ -1389,7 +1386,6 @@ "upload-failed": "Falha no envio" }, "generalErrorMessages": { - "retryUploadFailed": "Algo deu errado ao tentar enviar novamente. Tente fazer o envio de novo", "retryDownloadFailed": "Algo deu errado ao tentar baixar novamente. Tente fazer o download de novo.", "openUploadedFileFailed": "Algo deu errado ao tentar abrir o arquivo", "openUploadedFolderFailed": "Algo deu errado ao tentar navegar até a pasta" diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index 10a1710223..764d4372a7 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -1115,9 +1115,6 @@ "fetchingFolderContent": "Ошибка при получении содержимого папки", "togglingFavorite": "Произошла ошибка при обновлении избранного", "togglingSomeFavorites": "Не удалось обновить некоторые элементы избранного", - "uploadingFolder": "Ошибка при загрузке папки", - "uploadingFile": "Ошибка при загрузке файла: {{reason}}", - "uploadingItems": "Некоторые элементы не удалось загрузить", "downloadingFile": "Ошибка при загрузке файла: {{message}}", "downloadingFolder": "Ошибка при загрузке папки: {{message}}", "movingItemInsideItself": "Вы не можете переместить папку внутрь самой себя", @@ -1358,7 +1355,6 @@ "upload-failed": "Не удалось загрузить" }, "generalErrorMessages": { - "retryUploadFailed": "Что-то пошло не так при повторной попытке загрузки. Попробуйте загрузить снова", "retryDownloadFailed": "Что-то пошло не так при повторной попытке загрузки. Повторите попытку загрузки.", "openUploadedFileFailed": "Что-то пошло не так при попытке открыть файл", "openUploadedFolderFailed": "Что-то пошло не так при попытке перейти к папке" diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index 098ac91e22..15246cf944 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -1136,9 +1136,6 @@ "fetchingFolderContent": "獲取文件夾內容時出錯", "togglingFavorite": "更新我的最愛時發生錯誤", "togglingSomeFavorites": "部分我的最愛無法更新", - "uploadingFolder": "上傳文件夾時出錯", - "uploadingFile": "上傳文件時出錯:{{reason}}", - "uploadingItems": "部分元素無法上傳", "downloadingFile": "下載文件時出錯:{{message}}", "downloadingFolder": "下載文件夾時出錯:{{message}}", "movingItemInsideItself": "您不能將文件夾移動到其內部", @@ -1379,7 +1376,6 @@ "upload-failed": "上傳失敗" }, "generalErrorMessages": { - "retryUploadFailed": "重試上傳時出現問題。請重新上傳", "retryDownloadFailed": "重試下載時出現問題。請重新下載", "openUploadedFileFailed": "打開檔案時出現問題", "openUploadedFolderFailed": "打開資料夾時出現問題" diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index 8de621aa8c..b05c87daa6 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -1136,9 +1136,6 @@ "fetchingFolderContent": "获取文件夹内容时出错", "togglingFavorite": "更新收藏时出错", "togglingSomeFavorites": "部分收藏无法更新", - "uploadingFolder": "上传文件夹时出错", - "uploadingFile": "上传文件时出错: {{reason}}", - "uploadingItems": "部分元素无法上传", "downloadingFile": "下载文件时出错: {{message}}", "downloadingFolder": "下载文件夹时出错: {{message}}", "movingItemInsideItself": "您无法移动文件夹本身", @@ -1379,7 +1376,6 @@ "upload-failed": "上传失败" }, "generalErrorMessages": { - "retryUploadFailed": "重试上传时出了问题。请再次上传", "retryDownloadFailed": "重试下载时出现问题。请重试下载", "openUploadedFileFailed": "尝试打开文件时出现问题", "openUploadedFolderFailed": "尝试访问文件夹时出现问题" diff --git a/src/app/network/RetryManager.test.ts b/src/app/network/RetryManager.test.ts index 9e01649c1c..c04e0abbbe 100644 --- a/src/app/network/RetryManager.test.ts +++ b/src/app/network/RetryManager.test.ts @@ -2,20 +2,13 @@ import { describe, it, test, expect, vi, beforeEach } from 'vitest'; import RetryManager, { RetryableTask, RetryableTaskStatus, RetryableTaskType } from './RetryManager'; describe('RetryManager', () => { - const sampleTask: RetryableTask = { taskId: 'task1', type: RetryableTaskType.Upload, params: {} }; + const sampleTask: RetryableTask = { taskId: 'task1', type: RetryableTaskType.Download, params: {} }; const anotherTask: RetryableTask = { taskId: 'task2', type: RetryableTaskType.Download, params: {} }; beforeEach(() => { RetryManager.clearTasks(); }); - it('should add a task with failed status', () => { - RetryManager.addTask(sampleTask); - const tasks = RetryManager.getTasks(); - expect(tasks).toHaveLength(1); - expect(tasks[0]).toEqual({ ...sampleTask, status: RetryableTaskStatus.Failed }); - }); - it('should add multiple tasks with failed status', () => { RetryManager.addTasks([sampleTask, anotherTask]); const tasks = RetryManager.getTasks(); @@ -35,14 +28,14 @@ describe('RetryManager', () => { }); it('should change the status of a task', () => { - RetryManager.addTask(sampleTask); + RetryManager.addTasks([sampleTask]); RetryManager.changeStatus('task1', RetryableTaskStatus.Retrying); const tasks = RetryManager.getTasks(); expect(tasks[0].status).toBe(RetryableTaskStatus.Retrying); }); it('should not change status if taskId does not exist', () => { - RetryManager.addTask(sampleTask); + RetryManager.addTasks([sampleTask]); RetryManager.changeStatus('invalidTask', RetryableTaskStatus.Retrying); const tasks = RetryManager.getTasks(); expect(tasks[0].status).toBe(RetryableTaskStatus.Failed); @@ -73,63 +66,6 @@ describe('RetryManager', () => { expect(getDownloadStatuses()).toEqual([RetryableTaskStatus.Retrying, RetryableTaskStatus.Retrying]); }); - it('should remove a task by taskId', () => { - RetryManager.addTasks([sampleTask, anotherTask]); - RetryManager.removeTask('task1'); - const tasks = RetryManager.getTasks(); - expect(tasks).toHaveLength(1); - expect(tasks[0].taskId).toBe('task2'); - }); - - describe('when removing the entries of a related task', () => { - const buildTask = (taskId: string, relatedTaskId?: string): RetryableTask => ({ - taskId, - type: RetryableTaskType.Upload, - params: relatedTaskId ? { relatedTaskId } : {}, - }); - const remainingTaskIds = () => RetryManager.getTasks().map((task) => task.taskId); - - beforeEach(() => { - RetryManager.addTasks([ - buildTask('folder-file-a', 'folder'), - buildTask('folder-file-b', 'folder'), - buildTask('folder'), - buildTask('other-folder-file', 'other-folder'), - buildTask('standalone-file'), - ]); - }); - - test('when entries belong to the task, then they are removed and the other entries are kept', () => { - RetryManager.removeTasksOfRelatedTask('folder'); - - expect(remainingTaskIds()).toEqual(['other-folder-file', 'standalone-file']); - }); - - test('when an entry has no related task and its own id matches, then it is removed', () => { - RetryManager.removeTasksOfRelatedTask('standalone-file'); - - expect(remainingTaskIds()).toEqual(['folder-file-a', 'folder-file-b', 'folder', 'other-folder-file']); - }); - - test('when an entry is re-added without a related task, then it keeps the replaced entry related task', () => { - RetryManager.addTasks([buildTask('folder-file-a')]); - - RetryManager.removeTasksOfRelatedTask('folder'); - - expect(remainingTaskIds()).toEqual(['other-folder-file', 'standalone-file']); - }); - - test('when entries are removed, then listeners are notified', () => { - const listener = vi.fn(); - RetryManager.subscribe(listener); - - RetryManager.removeTasksOfRelatedTask('folder'); - - expect(listener).toHaveBeenCalledTimes(1); - RetryManager.unsubscribe(listener); - }); - }); - it('should clear all tasks', () => { RetryManager.addTasks([sampleTask, anotherTask]); RetryManager.clearTasks(); @@ -137,26 +73,16 @@ describe('RetryManager', () => { expect(tasks).toHaveLength(0); }); - it('should return true if a task is being retried', () => { - RetryManager.addTask(sampleTask); - expect(RetryManager.isRetryingTask('task1')).toBe(true); - }); - - it('should return false if a task is not being retried', () => { - RetryManager.addTask(sampleTask); - expect(RetryManager.isRetryingTask('nonExistentTask')).toBe(false); - }); - it('should notify listeners on state change', () => { const listener = vi.fn(); RetryManager.subscribe(listener); - RetryManager.addTask(sampleTask); + RetryManager.addTasks([sampleTask]); expect(listener).toHaveBeenCalledTimes(1); RetryManager.changeStatus('task1', RetryableTaskStatus.Retrying); expect(listener).toHaveBeenCalledTimes(2); - RetryManager.removeTask('task1'); + RetryManager.clearTasks(); expect(listener).toHaveBeenCalledTimes(3); }); @@ -164,7 +90,7 @@ describe('RetryManager', () => { const listener = vi.fn(); RetryManager.subscribe(listener); RetryManager.unsubscribe(listener); - RetryManager.addTask(sampleTask); + RetryManager.addTasks([sampleTask]); expect(listener).not.toHaveBeenCalled(); }); @@ -174,58 +100,13 @@ describe('RetryManager', () => { RetryManager.subscribe(listener1); RetryManager.subscribe(listener2); - RetryManager.addTask(sampleTask); + RetryManager.addTasks([sampleTask]); expect(listener1).toHaveBeenCalledTimes(1); expect(listener2).toHaveBeenCalledTimes(1); RetryManager.unsubscribe(listener1); - RetryManager.addTask(anotherTask); + RetryManager.addTasks([anotherTask]); expect(listener1).toHaveBeenCalledTimes(1); expect(listener2).toHaveBeenCalledTimes(2); }); - - it('should return all tasks if no type is specified', () => { - RetryManager.addTasks([ - { taskId: 'task1', type: RetryableTaskType.Upload, params: {} }, - { taskId: 'task2', type: RetryableTaskType.Download, params: {} }, - ]); - - const tasks = RetryManager.getTasks(); - expect(tasks).toHaveLength(2); - expect(tasks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ taskId: 'task1', type: RetryableTaskType.Upload }), - expect.objectContaining({ taskId: 'task2', type: RetryableTaskType.Download }), - ]), - ); - }); - - it('should return only upload tasks when type is upload', () => { - RetryManager.addTasks([ - { taskId: 'task1', type: RetryableTaskType.Upload, params: {} }, - { taskId: 'task2', type: RetryableTaskType.Download, params: {} }, - ]); - - const tasks = RetryManager.getTasks(RetryableTaskType.Upload); - expect(tasks).toHaveLength(1); - expect(tasks[0]).toEqual(expect.objectContaining({ taskId: 'task1', type: RetryableTaskType.Upload })); - }); - - it('should return only download tasks when type is download', () => { - RetryManager.addTasks([ - { taskId: 'task1', type: RetryableTaskType.Upload, params: {} }, - { taskId: 'task2', type: RetryableTaskType.Download, params: {} }, - ]); - - const tasks = RetryManager.getTasks(RetryableTaskType.Download); - expect(tasks).toHaveLength(1); - expect(tasks[0]).toEqual(expect.objectContaining({ taskId: 'task2', type: RetryableTaskType.Download })); - }); - - it('should return an empty array if no tasks match the specified type', () => { - RetryManager.addTask({ taskId: 'task1', type: RetryableTaskType.Upload, params: {} }); - - const tasks = RetryManager.getTasks(RetryableTaskType.Download); - expect(tasks).toHaveLength(0); - }); }); diff --git a/src/app/network/RetryManager.ts b/src/app/network/RetryManager.ts index 041be8f606..687fb5a67d 100644 --- a/src/app/network/RetryManager.ts +++ b/src/app/network/RetryManager.ts @@ -1,5 +1,4 @@ export enum RetryableTaskType { - Upload = 'upload', Download = 'download', } @@ -26,29 +25,15 @@ class RetryManager { private tasksToRetry: RetryableTask[] = []; private listeners: (() => void)[] = []; - addTask(task: RetryableTask) { - this.tasksToRetry.push({ ...task, status: RetryableTaskStatus.Failed }); - this.notify(); - } - /** * Adds tasks to the retry list, marking them as failed. * A task can only appear once per taskId: re-adding a failed task * replaces its entry instead of duplicating it in the retry list. - * A re-added task without a relatedTaskId keeps the one of the entry it replaces. */ addTasks(tasks: RetryableTask[]) { - const incomingTaskIds = new Set(tasks.map((task) => task.taskId).filter(Boolean)); - const tasksWithStatus = tasks.map((task) => { - const previousRelatedTaskId = this.getTasksById(task.taskId)[0]?.params?.relatedTaskId; - return { - ...task, - params: { ...task.params, relatedTaskId: task.params?.relatedTaskId ?? previousRelatedTaskId }, - status: RetryableTaskStatus.Failed, - }; - }); + const incomingTaskIds = new Set(tasks.map((task) => task.taskId)); this.tasksToRetry = this.tasksToRetry.filter((task) => !incomingTaskIds.has(task.taskId)); - this.tasksToRetry.push(...tasksWithStatus); + this.tasksToRetry.push(...tasks.map((task) => ({ ...task, status: RetryableTaskStatus.Failed }))); this.notify(); } @@ -59,41 +44,25 @@ class RetryManager { this.notify(); } - removeTask(taskId: string) { - this.tasksToRetry = this.tasksToRetry.filter((task) => task.taskId !== taskId); - this.notify(); - } - removeTaskByIdAndParams(taskId: string, where: ParamsFilter) { this.tasksToRetry = this.tasksToRetry.filter((task) => !matchesTask(task, taskId, where)); this.notify(); } - removeTasksOfRelatedTask(relatedTaskId: string) { - this.tasksToRetry = this.tasksToRetry.filter( - (task) => (task.params?.relatedTaskId ?? task.taskId) !== relatedTaskId, - ); - this.notify(); - } - clearTasks() { this.tasksToRetry = []; this.notify(); } - getTasks(type?: RetryableTaskType) { - return type ? this.tasksToRetry.filter((task) => task.type === type) : this.tasksToRetry; + getTasks() { + return this.tasksToRetry; } getTasksById(id: string) { return this.tasksToRetry.filter((task) => task.taskId === id); } - isRetryingTask(taskId: string): boolean { - return this.tasksToRetry.some((task) => task.taskId === taskId); - } - subscribe(listener: () => void) { this.listeners.push(listener); } diff --git a/src/app/network/UploadFolderManager.test.ts b/src/app/network/UploadFolderManager.test.ts deleted file mode 100644 index 46a0eca2dc..0000000000 --- a/src/app/network/UploadFolderManager.test.ts +++ /dev/null @@ -1,837 +0,0 @@ -import { DriveFolderData } from 'app/drive/types'; -import { IRoot } from 'app/store/slices/storage/types'; -import { createFolder } from 'app/store/slices/storage/folderUtils/createFolder'; -import { checkFolderDuplicated } from 'app/store/slices/storage/folderUtils/checkFolderDuplicated'; -import { getUniqueFolderName } from 'app/store/slices/storage/folderUtils/getUniqueFolderName'; -import { beforeEach, describe, expect, Mock, test, vi } from 'vitest'; -import { - TaskFolder, - UploadFolderManagerEvents, - UploadFoldersManager, - uploadFoldersWithManager, -} from './UploadFolderManager'; -import { FilesExceedsSizeLimitError } from 'app/drive/services/file.service/upload.errors'; -import { uploadItemsParallelThunk } from 'app/store/slices/storage/storage.thunks/uploadItemsThunk'; -import { deleteItemsThunk } from '../store/slices/storage/storage.thunks/deleteItemsThunk'; -import { uploadFilesWithTasks } from 'app/tasks/upload/uploadFilesWithTasks'; -import { PersistUploadRepository } from 'app/repositories/DatabaseUploadRepository'; -import uploadFile from 'app/drive/services/file.service/uploadFile'; -import newStorageService from 'app/drive/services/new-storage.service'; -import RetryManager from './RetryManager'; -import { ConnectionLostError } from './requests'; -import errorService from 'services/error.service'; -import { UploadFailureReason } from 'app/upload/types'; - -vi.mock('app/drive/services/file.service/uploadFile', () => ({ - default: vi.fn(async (file: { name: string }) => ({ uuid: `${file.name}-uuid`, name: file.name })), -})); - -vi.mock('app/drive/services/new-storage.service', () => ({ - default: { - deleteFolderByUuid: vi.fn(), - }, -})); - -vi.mock('app/store/slices/storage/folderUtils/createFolder', () => ({ - createFolder: vi.fn(), -})); - -vi.mock('app/store/slices/storage/folderUtils/checkFolderDuplicated', () => ({ - checkFolderDuplicated: vi.fn(), -})); - -vi.mock('../store/slices/storage/storage.thunks/deleteItemsThunk', () => ({ - deleteItemsThunk: vi.fn(), -})); - -vi.mock('../store/slices/storage/storage.thunks/uploadItemsThunk', () => ({ - uploadItemsParallelThunk: vi.fn(), -})); - -vi.mock('app/store/slices/storage/folderUtils/getUniqueFolderName', () => ({ - getUniqueFolderName: vi.fn(), -})); - -vi.mock('app/drive/services/folder.service', () => ({ default: { createFolderByUuid: vi.fn() } })); -vi.mock('services/workspace.service', () => ({ default: { createFolder: vi.fn() } })); -vi.mock('services/referral.service', () => ({ default: { trackFileUpload: vi.fn() } })); -vi.mock('app/network/networkInformation', () => ({ logNetworkInfoForUpload: vi.fn() })); -vi.mock('services/error.service', () => ({ - default: { - castError: vi.fn().mockImplementation((e) => e), - reportError: vi.fn(), - }, -})); - -describe('uploadFoldersWithManager', () => { - const mockDispatch = vi.fn(); - - const buildFolderData = (overrides: Partial = {}): DriveFolderData => - ({ - id: 0, - uuid: 'uuid', - name: 'Folder1', - bucket: 'bucket', - parentId: 0, - parent_id: 0, - parentUuid: 'parentUuid', - userId: 0, - user_id: 0, - icon: null, - iconId: null, - icon_id: null, - isFolder: true, - color: null, - encrypt_version: null, - plain_name: 'Folder1', - deleted: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - ...overrides, - }) as DriveFolderData; - - const dispatchToUploadManager = (uploadRepository: PersistUploadRepository) => { - (uploadItemsParallelThunk as unknown as Mock).mockImplementation((thunkArgs) => thunkArgs); - return vi.fn(({ files, parentFolderId, options, onFileUploadCallback }) => ({ - unwrap: () => - uploadFilesWithTasks({ - files: files.map((content: File, index: number) => ({ - taskId: `file-task-${index}`, - relatedTaskId: options.relatedTaskId, - filecontent: { content, name: content.name, size: content.size, type: 'txt', parentFolderId }, - userEmail: '', - parentFolderId, - })), - maxSpaceOccupiedCallback: vi.fn(), - uploadRepository, - abortController: options.abortController, - options, - onFileUploadCallback, - }), - })); - }; - - beforeEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - }); - - test('When a folder upload completes, then the folder is created and the upload is notified from start to success', async () => { - const mockFolder = buildFolderData({ name: 'MyFolder', plain_name: 'MyFolder' }); - const taskId = 'task-id'; - - const createFolderSpy = (createFolder as Mock).mockResolvedValueOnce(mockFolder); - (checkFolderDuplicated as Mock).mockResolvedValueOnce({ - duplicatedFoldersResponse: [], - foldersWithDuplicates: [], - foldersWithoutDuplicates: [mockFolder], - }); - - const events: UploadFolderManagerEvents = { - onFolderUploadStarted: vi.fn(), - onFolderUploadSuccess: vi.fn(), - onFolderUploadError: vi.fn(), - }; - - await uploadFoldersWithManager({ - payload: [ - { - currentFolderId: 'currentFolderId', - root: { - folderId: mockFolder.uuid, - childrenFiles: [], - childrenFolders: [], - name: mockFolder.name, - isFolder: true, - }, - options: { taskId }, - }, - ], - selectedWorkspace: null, - dispatch: mockDispatch, - maxUploadFileSize: 100, - events, - }); - - expect(createFolderSpy).toHaveBeenCalledOnce(); - expect(events.onFolderUploadStarted).toHaveBeenCalledWith( - taskId, - expect.objectContaining({ name: 'MyFolder' }), - expect.objectContaining({ - cancelUpload: expect.any(Function), - pauseUpload: expect.any(Function), - resumeUpload: expect.any(Function), - }), - ); - expect(events.onFolderUploadSuccess).toHaveBeenCalledOnce(); - expect(events.onFolderUploadSuccess).toHaveBeenCalledWith(taskId, { - folderName: 'MyFolder', - rootFolderUUID: mockFolder.uuid, - hasFailedFiles: false, - }); - expect(events.onFolderUploadError).not.toHaveBeenCalled(); - }); - - test('When a file inside the folder fails to upload, then the uploaded content is kept and the folder finishes with a partial failure', async () => { - const mockFolder = buildFolderData({ name: 'MyFolder', plain_name: 'MyFolder' }); - const taskId = 'task-id'; - const smallFile = new File([new ArrayBuffer(10)], 'small.txt'); - - (createFolder as Mock).mockResolvedValueOnce(mockFolder); - (checkFolderDuplicated as Mock).mockResolvedValueOnce({ - duplicatedFoldersResponse: [], - foldersWithDuplicates: [], - foldersWithoutDuplicates: [mockFolder], - }); - const dispatchWithFailingUnwrap = vi - .fn() - .mockReturnValue({ unwrap: () => Promise.reject(new Error('file failed')) }); - - const events: UploadFolderManagerEvents = { - onFolderUploadSuccess: vi.fn(), - onFolderUploadError: vi.fn(), - }; - - await uploadFoldersWithManager({ - payload: [ - { - currentFolderId: 'currentFolderId', - root: { - folderId: mockFolder.uuid, - childrenFiles: [smallFile], - childrenFolders: [], - name: mockFolder.name, - isFolder: true, - }, - options: { taskId }, - }, - ], - selectedWorkspace: null, - dispatch: dispatchWithFailingUnwrap, - maxUploadFileSize: 100, - events, - }); - - expect(events.onFolderUploadSuccess).toHaveBeenCalledWith(taskId, { - folderName: 'MyFolder', - rootFolderUUID: mockFolder.uuid, - hasFailedFiles: true, - }); - expect(events.onFolderUploadError).not.toHaveBeenCalled(); - expect(deleteItemsThunk).not.toHaveBeenCalled(); - }); - - test('When reading the upload state of its files fails, then the folder upload finishes with failed files instead of hanging', async () => { - const mockFolder = buildFolderData({ name: 'MyFolder', plain_name: 'MyFolder' }); - const taskId = 'task-id'; - const failingUploadRepository: PersistUploadRepository = { - setUploadState: vi.fn(async () => undefined), - getUploadState: vi.fn(() => Promise.reject(new Error('IDB failure'))), - removeUploadState: vi.fn(async () => undefined), - }; - - (createFolder as Mock).mockResolvedValueOnce(mockFolder); - (checkFolderDuplicated as Mock).mockResolvedValueOnce({ - duplicatedFoldersResponse: [], - foldersWithDuplicates: [], - foldersWithoutDuplicates: [mockFolder], - }); - const events: UploadFolderManagerEvents = { - onFolderUploadSuccess: vi.fn(), - onFolderUploadError: vi.fn(), - }; - - const folderUpload = uploadFoldersWithManager({ - payload: [ - { - currentFolderId: 'currentFolderId', - root: { - folderId: mockFolder.uuid, - childrenFiles: [new File(['a'], 'a.txt'), new File(['b'], 'b.txt')], - childrenFolders: [], - name: mockFolder.name, - isFolder: true, - }, - options: { taskId }, - }, - ], - selectedWorkspace: null, - dispatch: dispatchToUploadManager(failingUploadRepository), - maxUploadFileSize: 100, - events, - }); - const hangTimeout = new Promise((_, reject) => - setTimeout(() => reject(new Error('folder upload never settled')), 2000), - ); - - await expect(Promise.race([folderUpload, hangTimeout])).resolves.toBeUndefined(); - expect(events.onFolderUploadSuccess).toHaveBeenCalledWith(taskId, { - folderName: 'MyFolder', - rootFolderUUID: mockFolder.uuid, - hasFailedFiles: true, - }); - expect(events.onFolderUploadError).not.toHaveBeenCalled(); - }); - - test('When the folder itself cannot be created, then the failure is notified and no success is announced afterwards', async () => { - const taskId = 'task-id'; - - (createFolder as Mock).mockRejectedValue(new Error('folder creation failed')); - (checkFolderDuplicated as Mock).mockResolvedValueOnce({ - duplicatedFoldersResponse: [], - foldersWithDuplicates: [], - foldersWithoutDuplicates: [], - }); - - const events: UploadFolderManagerEvents = { - onFolderUploadSuccess: vi.fn(), - onFolderUploadError: vi.fn(), - }; - - await uploadFoldersWithManager({ - payload: [ - { - currentFolderId: 'currentFolderId', - root: { - folderId: 'folder-uuid', - childrenFiles: [], - childrenFolders: [], - name: 'MyFolder', - isFolder: true, - }, - options: { taskId }, - }, - ], - selectedWorkspace: null, - dispatch: mockDispatch, - maxUploadFileSize: 100, - events, - }); - - expect(events.onFolderUploadError).toHaveBeenCalledOnce(); - expect(events.onFolderUploadError).toHaveBeenCalledWith(taskId, UploadFailureReason.UploadFailed); - expect(events.onFolderUploadSuccess).not.toHaveBeenCalled(); - }); - - test('When a folder name already exists, then the folder is uploaded and announced with a new unique name', async () => { - const mockFolder = buildFolderData(); - const taskId = 'task-id'; - - (createFolder as Mock).mockResolvedValueOnce(mockFolder); - (checkFolderDuplicated as Mock).mockResolvedValueOnce({ - duplicatedFoldersResponse: [mockFolder] as DriveFolderData[], - foldersWithDuplicates: [mockFolder] as DriveFolderData[], - foldersWithoutDuplicates: [], - }); - const renameFolderSpy = (getUniqueFolderName as Mock).mockResolvedValueOnce('renamed-folder1'); - - const events: UploadFolderManagerEvents = { onFolderUploadStarted: vi.fn() }; - - await uploadFoldersWithManager({ - payload: [ - { - currentFolderId: 'currentFolderId', - root: { - folderId: mockFolder.uuid, - childrenFiles: [], - childrenFolders: [], - name: mockFolder.name, - isFolder: true, - }, - options: { taskId }, - }, - ], - selectedWorkspace: null, - dispatch: mockDispatch, - maxUploadFileSize: 100, - events, - }); - - expect(renameFolderSpy).toHaveBeenCalledOnce(); - expect(events.onFolderUploadStarted).toHaveBeenCalledWith( - taskId, - expect.objectContaining({ name: 'renamed-folder1' }), - expect.any(Object), - ); - }); - - describe('when the connection is lost', () => { - const taskId = 'task-id'; - const createdRootFolder = buildFolderData({ uuid: 'root-uuid', name: 'MyFolder', plain_name: 'MyFolder' }); - const uploadRepository: PersistUploadRepository = { - setUploadState: vi.fn(async () => undefined), - getUploadState: vi.fn(async () => undefined), - removeUploadState: vi.fn(async () => undefined), - }; - let events: UploadFolderManagerEvents; - - const loseConnection = () => { - globalThis.dispatchEvent(new Event('offline')); - throw new ConnectionLostError(); - }; - - const runFolderUpload = () => - uploadFoldersWithManager({ - payload: [ - { - currentFolderId: 'currentFolderId', - root: { - folderId: 'currentFolderId', - childrenFiles: [new File(['a'], 'a.txt'), new File(['b'], 'b.txt')], - childrenFolders: [], - name: 'MyFolder', - isFolder: true, - }, - options: { taskId }, - }, - ], - selectedWorkspace: null, - dispatch: dispatchToUploadManager(uploadRepository), - maxUploadFileSize: 100, - events, - }); - - beforeEach(() => { - RetryManager.clearTasks(); - events = { onFolderUploadSuccess: vi.fn(), onFolderUploadError: vi.fn() }; - (checkFolderDuplicated as Mock).mockResolvedValueOnce({ - duplicatedFoldersResponse: [], - foldersWithDuplicates: [], - foldersWithoutDuplicates: [], - }); - }); - - test('when it is lost after the folder was created, then the folder finishes with failed files that stay listed for retry into that folder', async () => { - (createFolder as Mock).mockResolvedValueOnce(createdRootFolder); - (uploadFile as Mock).mockImplementationOnce(loseConnection); - - await runFolderUpload(); - - expect(events.onFolderUploadSuccess).toHaveBeenCalledWith(taskId, { - folderName: 'MyFolder', - rootFolderUUID: createdRootFolder.uuid, - hasFailedFiles: true, - }); - expect(events.onFolderUploadError).not.toHaveBeenCalled(); - expect(RetryManager.getTasks()).toEqual([ - expect.objectContaining({ - params: expect.objectContaining({ relatedTaskId: taskId, parentFolderId: createdRootFolder.uuid }), - }), - ]); - }); - - test('when it is lost before the folder was created, then the folder upload fails as a connection loss', async () => { - (createFolder as Mock).mockImplementation(loseConnection); - - await runFolderUpload(); - - expect(events.onFolderUploadError).toHaveBeenLastCalledWith(taskId, UploadFailureReason.ConnectionLost); - expect(events.onFolderUploadSuccess).not.toHaveBeenCalled(); - }); - }); - - describe('when the upload is cancelled', () => { - const taskId = 'task-id'; - const createdRootFolder = buildFolderData({ uuid: 'root-uuid', name: 'MyFolder', plain_name: 'MyFolder' }); - const deleteRootFolderAction = Symbol('deleteItemsThunk'); - - const arrangeCancelledFolderUpload = (fileCount: number) => { - const files = Array.from({ length: fileCount }, (_, i) => new File([`content-${i}`], `file${i}.txt`)); - const uploadRepository: PersistUploadRepository = { - setUploadState: vi.fn(async () => undefined), - getUploadState: vi.fn(async () => undefined), - removeUploadState: vi.fn(async () => undefined), - }; - const uploadDispatch = dispatchToUploadManager(uploadRepository); - const dispatch = vi.fn((action) => - action === deleteRootFolderAction ? { unwrap: () => Promise.resolve() } : uploadDispatch(action), - ); - - (createFolder as Mock).mockResolvedValueOnce(createdRootFolder); - (checkFolderDuplicated as Mock).mockResolvedValueOnce({ - duplicatedFoldersResponse: [], - foldersWithDuplicates: [], - foldersWithoutDuplicates: [createdRootFolder], - }); - (deleteItemsThunk as unknown as Mock).mockReturnValue(deleteRootFolderAction); - - let cancelUpload = () => undefined; - const stopRelatedUploads = vi.fn(() => []); - const events: UploadFolderManagerEvents = { - onFolderUploadStarted: vi.fn((_taskId, _root, controls) => { - cancelUpload = controls.cancelUpload; - }), - onFolderUploadSuccess: vi.fn(), - onFolderUploadError: vi.fn(), - onFolderUploadCancelled: vi.fn(), - stopRelatedUploads, - }; - - let hasCancelled = false; - (uploadFile as Mock).mockImplementation(async (file: { name: string }) => { - if (!hasCancelled) { - hasCancelled = true; - cancelUpload(); - } - return { uuid: `${file.name}-uuid`, name: file.name }; - }); - - const run = () => - uploadFoldersWithManager({ - payload: [ - { - currentFolderId: 'currentFolderId', - root: { - folderId: 'currentFolderId', - childrenFiles: files, - childrenFolders: [], - name: 'MyFolder', - isFolder: true, - }, - options: { taskId }, - }, - ], - selectedWorkspace: null, - dispatch, - maxUploadFileSize: 100, - events, - }); - - return { events, stopRelatedUploads, run }; - }; - - beforeEach(() => { - RetryManager.clearTasks(); - }); - - test('when a folder upload is cancelled while its first file is uploading, then no other file starts and the task ends as cancelled', async () => { - const { events, stopRelatedUploads, run } = arrangeCancelledFolderUpload(10); - - await run(); - - expect(uploadFile).toHaveBeenCalledOnce(); - expect(stopRelatedUploads).toHaveBeenCalledWith(taskId); - expect(events.onFolderUploadSuccess).not.toHaveBeenCalled(); - expect(events.onFolderUploadError).not.toHaveBeenCalled(); - }); - - test('when a folder upload is cancelled, then it is announced as cancelled once its queue has drained', async () => { - const { events, run } = arrangeCancelledFolderUpload(2); - - await run(); - - expect(events.onFolderUploadCancelled).toHaveBeenCalledWith(taskId); - expect(events.onFolderUploadSuccess).not.toHaveBeenCalled(); - expect(events.onFolderUploadError).not.toHaveBeenCalled(); - }); - - test('when cancel is clicked, then the root folder and its uploaded content are deleted', async () => { - const { run } = arrangeCancelledFolderUpload(2); - - await run(); - - expect(deleteItemsThunk).toHaveBeenCalledWith([createdRootFolder]); - expect(newStorageService.deleteFolderByUuid).toHaveBeenCalledWith(createdRootFolder.uuid); - }); - - test('when the upload is cancelled while the root folder is still being created, then the folder is deleted once it is created', async () => { - let cancelUpload = () => undefined; - const dispatch = vi.fn((action) => - action === deleteRootFolderAction ? { unwrap: () => Promise.resolve() } : undefined, - ); - const events: UploadFolderManagerEvents = { - onFolderUploadStarted: vi.fn((_taskId, _root, controls) => { - cancelUpload = controls.cancelUpload; - }), - onFolderUploadSuccess: vi.fn(), - onFolderUploadError: vi.fn(), - stopRelatedUploads: vi.fn(() => []), - }; - - (checkFolderDuplicated as Mock).mockResolvedValueOnce({ - duplicatedFoldersResponse: [], - foldersWithDuplicates: [], - foldersWithoutDuplicates: [createdRootFolder], - }); - (createFolder as Mock).mockImplementation(async () => { - cancelUpload(); - return createdRootFolder; - }); - (deleteItemsThunk as unknown as Mock).mockReturnValue(deleteRootFolderAction); - - await uploadFoldersWithManager({ - payload: [ - { - currentFolderId: 'currentFolderId', - root: { - folderId: 'currentFolderId', - childrenFiles: [], - childrenFolders: [], - name: 'MyFolder', - isFolder: true, - }, - options: { taskId }, - }, - ], - selectedWorkspace: null, - dispatch, - maxUploadFileSize: 100, - events, - }); - - expect(deleteItemsThunk).toHaveBeenCalledWith([createdRootFolder]); - expect(newStorageService.deleteFolderByUuid).toHaveBeenCalledWith(createdRootFolder.uuid); - expect(events.onFolderUploadSuccess).not.toHaveBeenCalled(); - expect(events.onFolderUploadError).not.toHaveBeenCalled(); - }); - - test('when stopping the uploads on cancel fails, then the error is reported', async () => { - const { stopRelatedUploads, run } = arrangeCancelledFolderUpload(2); - const stopUploadError = new Error('stopRelatedUploads failed'); - stopRelatedUploads.mockImplementation(() => { - throw stopUploadError; - }); - - await run(); - - expect(errorService.reportError).toHaveBeenCalledWith(stopUploadError); - }); - }); - - describe('when the folders cannot be prepared', () => { - const taskIds = ['task-1', 'task-2']; - let events: UploadFolderManagerEvents; - - const runFolderUpload = () => - uploadFoldersWithManager({ - payload: taskIds.map((taskId) => ({ - currentFolderId: 'currentFolderId', - root: { - folderId: 'currentFolderId', - childrenFiles: [], - childrenFolders: [], - name: taskId, - isFolder: true, - }, - options: { taskId }, - })), - selectedWorkspace: null, - dispatch: mockDispatch, - maxUploadFileSize: 100, - events, - }); - - const expectEveryFolderFailedWith = (reason: string) => { - expect(events.onFolderUploadError).toHaveBeenCalledTimes(taskIds.length); - for (const taskId of taskIds) { - expect(events.onFolderUploadError).toHaveBeenCalledWith(taskId, reason); - } - expect(events.onFolderUploadStarted).not.toHaveBeenCalled(); - expect(events.onFolderUploadSuccess).not.toHaveBeenCalled(); - expect(createFolder).not.toHaveBeenCalled(); - }; - - beforeEach(() => { - events = { onFolderUploadStarted: vi.fn(), onFolderUploadSuccess: vi.fn(), onFolderUploadError: vi.fn() }; - }); - - test('when the browser is offline, then every folder fails as a connection loss and the error is reported', async () => { - const networkError = new Error('Network Error'); - vi.spyOn(navigator, 'onLine', 'get').mockReturnValueOnce(false); - (checkFolderDuplicated as Mock).mockRejectedValueOnce(networkError); - - await expect(runFolderUpload()).resolves.toBeUndefined(); - - expectEveryFolderFailedWith(UploadFailureReason.ConnectionLost); - expect(errorService.reportError).toHaveBeenCalledWith(networkError); - }); - - test('when it fails for another reason while online, then every folder fails as an upload failure', async () => { - (checkFolderDuplicated as Mock).mockRejectedValueOnce(new Error('server error')); - - await runFolderUpload(); - - expectEveryFolderFailedWith(UploadFailureReason.UploadFailed); - }); - }); - - test('When a folder has subfolders, then every level is uploaded', async () => { - const mockParentFolder = buildFolderData({ id: 1, uuid: 'uuid1' }); - const mockChildFolder = buildFolderData({ - id: 2, - uuid: 'uuid2', - name: 'Folder2', - parentId: 1, - parent_id: 1, - parentUuid: 'uuid1', - plain_name: 'Folder2', - }); - const taskId = 'task-id'; - - const createFolderSpy = (createFolder as Mock) - .mockResolvedValueOnce(mockParentFolder) - .mockResolvedValueOnce(mockChildFolder); - - (checkFolderDuplicated as Mock).mockResolvedValueOnce({ - duplicatedFoldersResponse: [] as DriveFolderData[], - foldersWithDuplicates: [] as DriveFolderData[], - foldersWithoutDuplicates: [mockParentFolder], - }); - - (getUniqueFolderName as Mock).mockResolvedValueOnce(''); - - await uploadFoldersWithManager({ - payload: [ - { - currentFolderId: 'currentFolderId', - root: { - folderId: mockParentFolder.parentUuid, - childrenFiles: [], - childrenFolders: [ - { - folderId: mockParentFolder.uuid, - childrenFiles: [], - childrenFolders: [], - name: mockChildFolder.name, - isFolder: true, - }, - ], - name: mockParentFolder.name, - isFolder: true, - }, - options: { - taskId, - }, - }, - ], - selectedWorkspace: null, - dispatch: mockDispatch, - maxUploadFileSize: 100, - }); - - expect(createFolderSpy).toHaveBeenCalledTimes(2); - }); - - describe('Handle File Uploads', () => { - const taskId = 'task-id'; - const mockCreatedFolder = buildFolderData({ uuid: 'folder-uuid' }); - - const buildManager = (maxUploadFileSize?: number) => { - const manager = new UploadFoldersManager({ - payload: [], - selectedWorkspace: null, - dispatch: mockDispatch, - maxUploadFileSize: maxUploadFileSize ?? 100, - }); - manager['tasksInfo'][taskId] = { isCancelled: false, progress: { itemsUploaded: 0, totalItems: 1 } }; - return manager; - }; - - test('When all files exceed the size limit and there are no subfolders, then an error indicating so is thrown', async () => { - const bigFile = new File([new ArrayBuffer(200)], 'big.mp4'); - const manager = buildManager(100); - const level: IRoot = { childrenFiles: [bigFile], childrenFolders: [], folderId: 'f', name: 'F', isFolder: true }; - - await expect( - manager['handleFileUploads'](level, mockCreatedFolder, taskId, new AbortController()), - ).rejects.toThrow(FilesExceedsSizeLimitError); - }); - - test('When a folder has files exceeding the size limit but also has subfolders, then the upload continues', async () => { - const bigFile = new File([new ArrayBuffer(200)], 'big.mp4'); - const dispatchWithUnwrap = vi.fn().mockReturnValue({ unwrap: () => Promise.resolve() }); - const manager = new UploadFoldersManager({ - payload: [], - selectedWorkspace: null, - dispatch: dispatchWithUnwrap, - maxUploadFileSize: 100, - }); - manager['tasksInfo'][taskId] = { isCancelled: false, progress: { itemsUploaded: 0, totalItems: 1 } }; - const level: IRoot = { - childrenFiles: [bigFile], - childrenFolders: [{ folderId: 'c', childrenFiles: [], childrenFolders: [], name: 'Child', isFolder: true }], - folderId: 'f', - name: 'F', - isFolder: true, - }; - - await manager['handleFileUploads'](level, mockCreatedFolder, taskId, new AbortController()); - - expect(uploadItemsParallelThunk).toHaveBeenCalledWith( - expect.objectContaining({ files: level.childrenFiles, parentFolderId: mockCreatedFolder.uuid }), - ); - }); - }); - - test('When the upload was already cancelled, then the folder is not uploaded', async () => { - const mockParentFolder = buildFolderData({ id: 1, uuid: 'uuid1' }); - const mockChildFolder = buildFolderData({ - id: 2, - uuid: 'uuid2', - name: 'Folder2', - parentId: 1, - parent_id: 1, - parentUuid: 'uuid1', - plain_name: 'Folder2', - }); - const taskId = 'task-id'; - - const manager = new UploadFoldersManager({ - payload: [], - selectedWorkspace: null, - dispatch: mockDispatch, - maxUploadFileSize: 100, - }); - const abortController = new AbortController(); - - const taskFolder: TaskFolder = { - currentFolderId: 'currentFolderId', - root: { - folderId: mockParentFolder.parentUuid, - childrenFiles: [], - childrenFolders: [ - { - folderId: mockParentFolder.uuid, - childrenFiles: [], - childrenFolders: [], - name: mockChildFolder.name, - isFolder: true, - }, - ], - name: mockParentFolder.name, - isFolder: true, - }, - taskId, - abortController, - }; - - const createFolderSpy = (createFolder as Mock) - .mockResolvedValueOnce(mockParentFolder) - .mockResolvedValueOnce(mockChildFolder); - - (checkFolderDuplicated as Mock).mockResolvedValueOnce({ - duplicatedFoldersResponse: [] as DriveFolderData[], - foldersWithDuplicates: [] as DriveFolderData[], - foldersWithoutDuplicates: [mockParentFolder], - }); - - const renameFolderSpy = (getUniqueFolderName as Mock).mockResolvedValueOnce(''); - - manager['tasksInfo'][taskId] = { - isCancelled: false, - progress: { - itemsUploaded: 0, - totalItems: 2, - }, - rootFolderItem: mockParentFolder, - }; - - abortController.abort(); - - const uploadPromise = manager['uploadFolderAsync'](taskFolder); - - await expect(uploadPromise).resolves.toBeUndefined(); - expect(createFolderSpy).not.toHaveBeenCalled(); - expect(renameFolderSpy).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/network/UploadFolderManager.ts b/src/app/network/UploadFolderManager.ts deleted file mode 100644 index c60e567ccb..0000000000 --- a/src/app/network/UploadFolderManager.ts +++ /dev/null @@ -1,449 +0,0 @@ -import { WorkspaceData } from '@internxt/sdk/dist/workspaces'; -import { AnyAction, ThunkDispatch } from '@reduxjs/toolkit'; -import { queue, QueueObject } from 'async'; -import errorService from 'services/error.service'; -import newStorageService from 'app/drive/services/new-storage.service'; -import { DriveFolderData, DriveItemData } from 'app/drive/types'; -import { RootState } from '../store'; -import { checkFolderDuplicated } from '../store/slices/storage/folderUtils/checkFolderDuplicated'; -import { createFolder } from '../store/slices/storage/folderUtils/createFolder'; -import { getUniqueFolderName } from '../store/slices/storage/folderUtils/getUniqueFolderName'; -import { deleteItemsThunk } from '../store/slices/storage/storage.thunks/deleteItemsThunk'; -import { uploadItemsParallelThunk } from '../store/slices/storage/storage.thunks/uploadItemsThunk'; -import { IRoot } from '../store/slices/storage/types'; -import { QueueUtilsService } from 'utils/queueUtils'; -import { wait } from 'utils/timeUtils'; -import { ConnectionLostError } from './requests'; -import { FilesExceedsSizeLimitError } from 'app/drive/services/file.service/upload.errors'; -import { filterFilesByMaxSize } from 'app/store/slices/storage/fileUtils/filterFilesByMaxSize'; -import { UploadFailureReason } from 'app/upload/types'; -import { MAX_CONCURRENT_UPLOADS } from './networkConstants'; - -export interface UploadFolderPayload { - root: IRoot; - currentFolderId: string; - options: { - taskId: string; - onSuccess?: () => void; - }; -} - -export interface FolderUploadControls { - cancelUpload: () => void; - pauseUpload: () => void; - resumeUpload: () => void; -} - -/** - * Upload progress notifications emitted by the manager. The manager knows nothing - * about how uploads are presented to the user; observers subscribe to these events. - * `onFolderUploadStarted` may return a cleanup function, invoked when that folder's - * upload finishes (successfully or not). - */ -export interface UploadFolderManagerEvents { - onFolderUploadStarted?: (taskId: string, root: IRoot, controls: FolderUploadControls) => (() => void) | void; - onFolderUploadProgress?: (taskId: string, progress: number, stopUpload: () => Promise) => void; - onFolderUploadSuccess?: ( - taskId: string, - info: { folderName: string; rootFolderUUID?: string; hasFailedFiles: boolean }, - ) => void; - onFolderUploadError?: (taskId: string, reason?: UploadFailureReason) => void; - onFolderUploadCancelled?: (taskId: string) => void; - stopRelatedUploads?: (taskId: string) => Promise[]; -} - -export interface TaskFolder { - root: IRoot; - currentFolderId: string; - options?: { - onSuccess?: () => void; - }; - taskId: string; - abortController: AbortController; -} - -interface TaskInfo { - rootFolderItem?: DriveFolderData; - isCancelled: boolean; - hasErrored?: boolean; - hasFailedFiles?: boolean; - progress: { - itemsUploaded: number; - totalItems: number; - }; -} - -interface UploadFoldersWithManagerProps { - payload: UploadFolderPayload[]; - selectedWorkspace: WorkspaceData | null; - dispatch: ThunkDispatch; - maxUploadFileSize: number; - fileSizeExceededCallback?: (exceededFiles: File[]) => void; - events?: UploadFolderManagerEvents; -} - -const prepareTaskFolders = async (foldersPayload: UploadFolderPayload[]): Promise => { - const taskFolders: TaskFolder[] = []; - - for (const folder of foldersPayload) { - const { root: originalRoot, currentFolderId, options } = folder; - const root = await handleFoldersRename(originalRoot, currentFolderId); - - taskFolders.push({ - root, - currentFolderId, - options, - taskId: options.taskId, - abortController: new AbortController(), - }); - } - return taskFolders; -}; - -const countItemsUnderRoot = (root: IRoot): number => { - let count = 1; - - const queueOfFolders: Array = [root]; - - while (queueOfFolders.length > 0) { - const folder = queueOfFolders.shift() as IRoot; - count += folder.childrenFiles.length; - - if (folder.childrenFolders) { - count += folder.childrenFolders.length; - queueOfFolders.push(...folder.childrenFolders); - } - } - - return count; -}; - -const handleFoldersRename = async (root: IRoot, currentFolderId: string) => { - const { duplicatedFoldersResponse } = await checkFolderDuplicated([root], currentFolderId); - - let finalFilename = root.name; - if (duplicatedFoldersResponse.length > 0) { - finalFilename = await getUniqueFolderName( - root.name, - duplicatedFoldersResponse as DriveFolderData[], - currentFolderId, - ); - } - - const folder: IRoot = { ...root, name: finalFilename }; - return folder; -}; - -export const uploadFoldersWithManager = (props: UploadFoldersWithManagerProps): Promise => - new UploadFoldersManager(props).run(); - -export class UploadFoldersManager { - private static readonly MAX_UPLOAD_ATTEMPTS = 2; - - private readonly payload: UploadFolderPayload[]; - private readonly selectedWorkspace: WorkspaceData | null; - private readonly dispatch: ThunkDispatch; - private readonly abortController?: AbortController; - private readonly maxUploadFileSize: number; - private readonly fileSizeExceededCallback?: (exceededFiles: File[]) => void; - private readonly events?: UploadFolderManagerEvents; - - private readonly tasksInfo: Record = {}; - - constructor(props: UploadFoldersWithManagerProps) { - this.payload = props.payload; - this.selectedWorkspace = props.selectedWorkspace; - this.dispatch = props.dispatch; - this.maxUploadFileSize = props.maxUploadFileSize; - this.fileSizeExceededCallback = props.fileSizeExceededCallback; - this.events = props.events; - } - - private readonly uploadFoldersQueue: QueueObject = queue( - (task, next: (err: Error | null, res?: DriveFolderData) => void) => { - if (this.abortController?.signal.aborted) return; - - QueueUtilsService.instance.adjustConcurrency(this.uploadFoldersQueue, MAX_CONCURRENT_UPLOADS); - - this.uploadFolderAsync(task) - .then((uploadedFolder: DriveFolderData | undefined) => { - next(null, uploadedFolder); - }) - .catch((e) => { - next(e); - }); - }, - MAX_CONCURRENT_UPLOADS, - ); - - private readonly createFolderWithRetry = async ( - level: IRoot, - currentFolderId: string, - taskId: string, - abortController: AbortController, - ): Promise => { - let createdFolder: DriveFolderData | undefined; - let uploadAttempts = 0; - - while (!createdFolder && uploadAttempts < UploadFoldersManager.MAX_UPLOAD_ATTEMPTS) { - uploadAttempts++; - try { - createdFolder = await createFolder( - { - parentFolderId: level.folderId as string, - folderName: level.name, - options: { relatedTaskId: taskId, showErrors: false }, - }, - currentFolderId, - this.selectedWorkspace, - { dispatch: this.dispatch }, - ); - } catch { - if (uploadAttempts >= UploadFoldersManager.MAX_UPLOAD_ATTEMPTS) { - this.stopUploadTask(taskId, abortController); - this.killQueueAndNotifyError(taskId); - return; - } - } - } - - return createdFolder; - }; - - private readonly updateTaskProgress = (taskId: string, abortController: AbortController): void => { - this.tasksInfo[taskId].progress.itemsUploaded += 1; - this.events?.onFolderUploadProgress?.( - taskId, - this.tasksInfo[taskId].progress.itemsUploaded / this.tasksInfo[taskId].progress.totalItems, - () => this.stopUploadTask(taskId, abortController), - ); - }; - - private readonly handleFileUploads = async ( - level: IRoot, - createdFolder: DriveFolderData, - taskId: string, - abortController: AbortController, - ): Promise => { - if (level.childrenFiles.length === 0) return; - if (abortController.signal.aborted) return; - - const hasNoChildFolders = level.childrenFolders.length === 0; - const { exceededFiles } = filterFilesByMaxSize({ - files: level.childrenFiles, - maxUploadFileSize: this.maxUploadFileSize, - }); - const allFilesExceedSizeLimit = - exceededFiles.length === level.childrenFiles.length && level.childrenFiles.length > 0; - - if (allFilesExceedSizeLimit && hasNoChildFolders) { - await this.stopUploadTask(taskId, abortController); - this.killQueueAndNotifyError(taskId); - - this.fileSizeExceededCallback?.(level.childrenFiles); - throw new FilesExceedsSizeLimitError(); - } - - await this.dispatch( - uploadItemsParallelThunk({ - files: level.childrenFiles, - parentFolderId: createdFolder.uuid, - options: { - relatedTaskId: taskId, - showNotifications: false, - showErrors: false, - abortController: abortController, - disableDuplicatedNamesCheck: true, - disableExistenceCheck: true, - isUploadedFromFolder: true, - notUploadHiddenFiles: true, - }, - onFileUploadCallback: () => this.updateTaskProgress(taskId, abortController), - }), - ) - .unwrap() - .catch(() => { - // A file failed to upload. Do NOT roll back: keep everything already uploaded and let - // the queue keep uploading the remaining files/folders. The partial failure is - // announced once the whole folder finishes, in run(). - this.tasksInfo[taskId].hasFailedFiles = true; - }); - }; - - private readonly queueChildFolders = (level: IRoot, createdFolder: DriveFolderData, taskFolder: TaskFolder): void => { - for (const child of level.childrenFolders) { - if (taskFolder.abortController.signal.aborted) return; - - this.uploadFoldersQueue.push({ - root: { ...child, folderId: createdFolder.uuid }, - currentFolderId: taskFolder.currentFolderId, - options: taskFolder.options, - abortController: taskFolder.abortController, - taskId: taskFolder.taskId, - }); - } - }; - - private readonly uploadFolderAsync = async (taskFolder: TaskFolder) => { - const { root: level, currentFolderId, taskId, abortController } = taskFolder; - - if (abortController.signal.aborted) return; - - const createdFolder = await this.createFolderWithRetry(level, currentFolderId, taskId, abortController); - if (!createdFolder) return; - - if (!this.tasksInfo[taskId].rootFolderItem) { - this.tasksInfo[taskId].rootFolderItem = createdFolder; - } - - if (abortController.signal.aborted) { - await this.stopUploadTask(taskId, abortController); - return; - } - - this.updateTaskProgress(taskId, abortController); - - const hasChildren = level.childrenFiles.length > 0 || level.childrenFolders.length > 0; - if (this.selectedWorkspace && hasChildren) { - // Workspace endpoints still validate the parent folder against the read replica - await wait(600); - } - - await this.handleFileUploads(level, createdFolder, taskId, abortController); - this.queueChildFolders(level, createdFolder, taskFolder); - - return createdFolder; - }; - - private readonly stopUploadTask = async (taskId: string, uploadFolderAbortController: AbortController) => { - uploadFolderAbortController.abort(); - const promises: Promise[] = []; - - // Cancels uploads spawned by this folder upload (e.g. its file uploads) - promises.push(...(this.events?.stopRelatedUploads?.(taskId) ?? [])); - // Deletes the root folder - const rootFolderItem = this.tasksInfo[taskId].rootFolderItem; - if (rootFolderItem) { - promises.push( - this.dispatch(deleteItemsThunk([rootFolderItem as DriveItemData])).unwrap(), - newStorageService.deleteFolderByUuid(rootFolderItem.uuid), - ); - } - await Promise.allSettled(promises); - }; - - private readonly killQueueAndNotifyError = (taskId: string) => { - this.tasksInfo[taskId].hasErrored = true; - this.uploadFoldersQueue.kill(); - this.events?.onFolderUploadError?.(taskId, UploadFailureReason.UploadFailed); - }; - - /** - * Announces how a folder upload ended once its queue has drained. A lost connection ends the - * folder as an error only when nothing of it was kept (its root folder was never created or was - * rolled back); otherwise it finishes with failed files, so they can be retried into the same folder. - * @throws {ConnectionLostError} when the connection was lost and nothing of the folder was kept - */ - private readonly announceFolderUploadEnd = (taskFolder: TaskFolder, isConnectionLost: boolean): void => { - const { root, options, taskId } = taskFolder; - const taskInfo = this.tasksInfo[taskId]; - const hasKeptUploadedContent = !!taskInfo.rootFolderItem && !taskInfo.hasErrored; - - if (isConnectionLost && !hasKeptUploadedContent) throw new ConnectionLostError(); - - if (taskInfo.isCancelled) { - this.events?.onFolderUploadCancelled?.(taskId); - return; - } - - // A fatal error (e.g. the folder could not be created) already announced a final state - // while the queue was draining: don't announce success on top. - if (taskInfo.hasErrored) return; - - this.events?.onFolderUploadSuccess?.(taskId, { - folderName: root.name, - rootFolderUUID: taskInfo.rootFolderItem?.uuid, - hasFailedFiles: !!taskInfo.hasFailedFiles || isConnectionLost, - }); - - options?.onSuccess?.(); - }; - - private readonly notifyPreparationError = (error: unknown): void => { - const reason = navigator.onLine ? UploadFailureReason.UploadFailed : UploadFailureReason.ConnectionLost; - - for (const { options } of this.payload) { - this.events?.onFolderUploadError?.(options.taskId, reason); - } - errorService.reportError(errorService.castError(error)); - }; - - public readonly run = async (): Promise => { - let taskFolders: TaskFolder[]; - try { - taskFolders = await prepareTaskFolders(this.payload); - } catch (error) { - this.notifyPreparationError(error); - return; - } - - const context = typeof window === 'undefined' ? self : window; - - let connectionLost = false; - - function connectionLostListener() { - connectionLost = true; - context.removeEventListener('offline', connectionLostListener); - } - context.addEventListener('offline', connectionLostListener); - - for (const taskFolder of taskFolders) { - const { root, currentFolderId, taskId } = taskFolder; - - this.tasksInfo[taskId] = { - isCancelled: false, - progress: { - itemsUploaded: 0, - totalItems: countItemsUnderRoot(root), - }, - }; - - const cleanupStartedEvent = this.events?.onFolderUploadStarted?.(taskId, root, { - cancelUpload: () => { - this.tasksInfo[taskId].isCancelled = true; - this.stopUploadTask(taskId, taskFolder.abortController).catch((error) => errorService.reportError(error)); - this.uploadFoldersQueue.kill(); - }, - pauseUpload: () => this.uploadFoldersQueue.pause(), - resumeUpload: () => this.uploadFoldersQueue.resume(), - }); - - try { - root.folderId = currentFolderId; - await this.uploadFoldersQueue.pushAsync(taskFolder); - - while (this.uploadFoldersQueue.running() > 0 || this.uploadFoldersQueue.length() > 0) { - await this.uploadFoldersQueue.drain(); - } - - this.announceFolderUploadEnd(taskFolder, connectionLost); - if (connectionLost) break; - } catch (err: unknown) { - const castedError = errorService.castError(err); - - if (connectionLost) { - this.events?.onFolderUploadError?.(taskId, UploadFailureReason.ConnectionLost); - errorService.reportError(castedError); - break; - } else if (!this.tasksInfo[taskId].isCancelled) { - this.events?.onFolderUploadError?.(taskId, UploadFailureReason.UploadFailed); - // Log the error or report it but don't re-throw it to allow the next folder to be processed - errorService.reportError(castedError); - continue; - } - } finally { - cleanupStartedEvent?.(); - } - } - }; -} diff --git a/src/app/store/slices/storage/fileUtils/prepareFilesToUpload.test.ts b/src/app/store/slices/storage/fileUtils/prepareFilesToUpload.test.ts deleted file mode 100644 index 525bb44fd2..0000000000 --- a/src/app/store/slices/storage/fileUtils/prepareFilesToUpload.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { DriveFileData } from 'app/drive/types'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { checkDuplicatedFiles } from './checkDuplicatedFiles'; -import { prepareFilesToUpload } from './prepareFilesToUpload'; -import { processDuplicateFiles } from './processDuplicateFiles'; - -const BATCH_SIZE = 200; - -vi.mock('./checkDuplicatedFiles', () => ({ - checkDuplicatedFiles: vi.fn(), -})); - -vi.mock('./processDuplicateFiles', () => ({ - processDuplicateFiles: vi.fn(), -})); - -describe('prepareFilesToUpload', () => { - const mockFiles = [ - new File(['content'], 'file1.txt', { type: 'text/plain' }), - new File([''], 'file2.txt', { type: 'text/plain' }), - ]; - const parentFolderId = 'folder123'; - const mockFileType = 'text/plain'; - - beforeEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - }); - - it('should process files without duplicates correctly', async () => { - const filesWithoutDuplicates = [mockFiles[0]]; - vi.mocked(checkDuplicatedFiles).mockResolvedValue({ - duplicatedFilesResponse: [], - filesWithoutDuplicates, - filesWithDuplicates: [], - }); - - vi.mocked(processDuplicateFiles).mockResolvedValue({ - newFilesToUpload: [ - { - name: 'file1.txt', - size: 7, - type: 'text/plain', - parentFolderId, - content: new File(['content'], 'file1.txt', { type: 'text/plain' }), - }, - ], - }); - - const result = await prepareFilesToUpload({ - files: mockFiles, - parentFolderId, - fileType: mockFileType, - }); - - expect(result.filesToUpload).toHaveLength(1); - expect(result.filesToUpload[0]).toEqual({ - name: 'file1.txt', - size: 7, - type: 'text/plain', - parentFolderId, - content: new File(['content'], 'file1.txt', { type: 'text/plain' }), - }); - - expect(checkDuplicatedFiles).toHaveBeenCalledTimes(1); - expect(processDuplicateFiles).toHaveBeenCalledTimes(2); - }); - - it('should handle duplicated files correctly', async () => { - const duplicatedFilesResponse: DriveFileData[] = [ - { - bucket: 'bucket123', - createdAt: new Date().toISOString(), - created_at: new Date().toISOString(), - deleted: false, - deletedAt: null, - encrypt_version: '1', - fileId: 'file1', - folderId: 123, - folder_id: 123, - folderUuid: 'folderUuid123', - id: 1, - name: 'file1.txt', - plain_name: 'file1.txt', - plainName: 'file1.txt', - size: 1024, - type: 'text/plain', - updatedAt: new Date().toISOString(), - status: 'active', - thumbnails: [], - currentThumbnail: null, - uuid: 'file-uuid-123', - }, - ]; - const filesWithDuplicates = [mockFiles[0]]; - - vi.mocked(checkDuplicatedFiles).mockResolvedValue({ - duplicatedFilesResponse, - filesWithoutDuplicates: [], - filesWithDuplicates, - }); - - vi.mocked(processDuplicateFiles).mockResolvedValue({ - newFilesToUpload: [ - { - name: 'file1.txt', - size: 7, - type: 'text/plain', - parentFolderId, - content: new File(['content'], 'file1.txt', { type: 'text/plain' }), - }, - ], - }); - - const result = await prepareFilesToUpload({ - files: mockFiles, - parentFolderId, - fileType: mockFileType, - }); - - expect(result.filesToUpload).toHaveLength(1); - expect(result.filesToUpload[0]).toEqual({ - name: 'file1.txt', - size: 7, - type: 'text/plain', - parentFolderId, - content: new File(['content'], 'file1.txt', { type: 'text/plain' }), - }); - - expect(checkDuplicatedFiles).toHaveBeenCalled(); - expect(processDuplicateFiles).toHaveBeenCalled(); - }); - - it('should process files in batches correctly', async () => { - const largeFileBatch = Array.from( - { length: 250 }, - (_, i) => new File(['content'], `file${i + 1}.txt`, { type: 'text/plain' }), - ); - - vi.mocked(checkDuplicatedFiles).mockResolvedValue({ - duplicatedFilesResponse: [], - filesWithoutDuplicates: largeFileBatch.slice(0, BATCH_SIZE), - filesWithDuplicates: [], - }); - - vi.mocked(processDuplicateFiles).mockResolvedValue({ - newFilesToUpload: largeFileBatch.slice(0, BATCH_SIZE).map((file) => ({ - name: file.name, - size: file.size, - type: file.type, - parentFolderId, - content: file, - })), - }); - - const result = await prepareFilesToUpload({ - files: largeFileBatch, - parentFolderId, - fileType: mockFileType, - }); - - expect(result.filesToUpload).toHaveLength(200); - - expect(checkDuplicatedFiles).toHaveBeenCalledTimes(2); - expect(processDuplicateFiles).toHaveBeenCalledTimes(4); - }); - - it('should process files in multiple batches correctly', async () => { - const TOTAL_FILES = 800; - const mockFiles = Array.from( - { length: TOTAL_FILES }, - (_, index) => new File([], `file${index}.txt`, { type: 'text/plain' }), - ); - - const mockProcessedFiles = mockFiles.map((file) => ({ - name: file.name, - size: file.size, - type: file.type, - content: file, - parentFolderId: 'parentFolderId', - })); - - vi.mocked(checkDuplicatedFiles).mockResolvedValue({ - duplicatedFilesResponse: [], - filesWithoutDuplicates: mockFiles.slice(0, BATCH_SIZE), - filesWithDuplicates: mockFiles.slice(BATCH_SIZE), - }); - - vi.mocked(processDuplicateFiles).mockResolvedValue({ - newFilesToUpload: mockProcessedFiles, - }); - - const { filesToUpload } = await prepareFilesToUpload({ - files: mockFiles, - parentFolderId: 'parentFolderId', - }); - - expect(checkDuplicatedFiles).toHaveBeenCalledTimes(4); - expect(processDuplicateFiles).toHaveBeenCalledTimes(8); - expect(filesToUpload).toHaveLength(TOTAL_FILES); - }); - - it('should handle fileType parameter', async () => { - const mockFiles = [ - new File(['content1'], 'file1.txt', { type: 'text/plain' }), - new File(['content2'], 'file2.txt', { type: 'text/plain' }), - ]; - const parentFolderId = '123'; - const fileType = 'image/jpeg'; - - const duplicatedFilesResponse = []; - const filesWithoutDuplicates = mockFiles; - const filesWithDuplicates = []; - - vi.mocked(checkDuplicatedFiles).mockResolvedValue({ - duplicatedFilesResponse, - filesWithoutDuplicates, - filesWithDuplicates, - }); - - const mockProcessDuplicateFiles = vi.mocked(processDuplicateFiles).mockResolvedValue({ - newFilesToUpload: [ - { - name: 'file1.txt', - size: 10, - type: 'text/plain', - content: mockFiles[0], - parentFolderId, - }, - { - name: 'file2.txt', - size: 20, - type: 'text/plain', - content: mockFiles[1], - parentFolderId, - }, - ], - }); - - await prepareFilesToUpload({ - files: mockFiles, - parentFolderId, - fileType, - }); - - expect(mockProcessDuplicateFiles).toHaveBeenCalledWith( - expect.objectContaining({ - fileType, - }), - ); - }); - - it('should filter hidden files when notUploadHiddenFiles is true', async () => { - const mockFilesWithHidden = [ - new File(['content'], 'file1.txt', { type: 'text/plain' }), - new File(['content'], '.hidden-file', { type: 'text/plain' }), - new File(['content'], '.DS_Store', { type: 'text/plain' }), - new File(['content'], 'normal-file.txt', { type: 'text/plain' }), - ]; - - const visibleFiles = [mockFilesWithHidden[0], mockFilesWithHidden[3]]; - - vi.mocked(checkDuplicatedFiles).mockResolvedValue({ - duplicatedFilesResponse: [], - filesWithoutDuplicates: visibleFiles, - filesWithDuplicates: [], - }); - - vi.mocked(processDuplicateFiles).mockResolvedValue({ - newFilesToUpload: visibleFiles.map((file) => ({ - name: file.name, - size: file.size, - type: file.type, - parentFolderId, - content: file, - })), - }); - - const result = await prepareFilesToUpload({ - files: mockFilesWithHidden, - parentFolderId, - notUploadHiddenFiles: true, - }); - - expect(result.filesToUpload).toHaveLength(2); - expect(result.filesToUpload[0].name).toBe('file1.txt'); - expect(result.filesToUpload[1].name).toBe('normal-file.txt'); - - expect(checkDuplicatedFiles).toHaveBeenCalledWith(visibleFiles, parentFolderId); - }); - - it('should not filter hidden files when notUploadHiddenFiles is false', async () => { - const mockFilesWithHidden = [ - new File(['content'], 'file1.txt', { type: 'text/plain' }), - new File(['content'], '.hidden-file', { type: 'text/plain' }), - new File(['content'], '.DS_Store', { type: 'text/plain' }), - ]; - - vi.mocked(checkDuplicatedFiles).mockResolvedValue({ - duplicatedFilesResponse: [], - filesWithoutDuplicates: mockFilesWithHidden, - filesWithDuplicates: [], - }); - - vi.mocked(processDuplicateFiles).mockResolvedValue({ - newFilesToUpload: mockFilesWithHidden.map((file) => ({ - name: file.name, - size: file.size, - type: file.type, - parentFolderId, - content: file, - })), - }); - - const result = await prepareFilesToUpload({ - files: mockFilesWithHidden, - parentFolderId, - notUploadHiddenFiles: false, - }); - - expect(result.filesToUpload).toHaveLength(3); - expect(result.filesToUpload.map((f) => f.name)).toEqual(['file1.txt', '.hidden-file', '.DS_Store']); - - expect(checkDuplicatedFiles).toHaveBeenCalledWith(mockFilesWithHidden, parentFolderId); - }); - - it('should not filter hidden files when notUploadHiddenFiles is undefined', async () => { - const mockFilesWithHidden = [ - new File(['content'], 'file1.txt', { type: 'text/plain' }), - new File(['content'], '.hidden-file', { type: 'text/plain' }), - ]; - - vi.mocked(checkDuplicatedFiles).mockResolvedValue({ - duplicatedFilesResponse: [], - filesWithoutDuplicates: mockFilesWithHidden, - filesWithDuplicates: [], - }); - - vi.mocked(processDuplicateFiles).mockResolvedValue({ - newFilesToUpload: mockFilesWithHidden.map((file) => ({ - name: file.name, - size: file.size, - type: file.type, - parentFolderId, - content: file, - })), - }); - - const result = await prepareFilesToUpload({ - files: mockFilesWithHidden, - parentFolderId, - }); - - expect(result.filesToUpload).toHaveLength(2); - expect(result.filesToUpload.map((f) => f.name)).toEqual(['file1.txt', '.hidden-file']); - - expect(checkDuplicatedFiles).toHaveBeenCalledWith(mockFilesWithHidden, parentFolderId); - }); - - it('should handle empty array when all files are hidden and notUploadHiddenFiles is true', async () => { - const mockHiddenFiles = [ - new File(['content'], '.hidden1', { type: 'text/plain' }), - new File(['content'], '.hidden2', { type: 'text/plain' }), - ]; - - const result = await prepareFilesToUpload({ - files: mockHiddenFiles, - parentFolderId, - notUploadHiddenFiles: true, - }); - - expect(result.filesToUpload).toHaveLength(0); - - expect(checkDuplicatedFiles).not.toHaveBeenCalled(); - expect(processDuplicateFiles).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/store/slices/storage/fileUtils/prepareFilesToUpload.ts b/src/app/store/slices/storage/fileUtils/prepareFilesToUpload.ts deleted file mode 100644 index 52bf87b3d1..0000000000 --- a/src/app/store/slices/storage/fileUtils/prepareFilesToUpload.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { DriveFileData } from '@internxt/sdk/dist/drive/storage/types'; -import { FileToUpload } from 'app/drive/services/file.service/types'; -import { checkDuplicatedFiles } from './checkDuplicatedFiles'; -import { processDuplicateFiles } from './processDuplicateFiles'; - -const BATCH_SIZE = 200; - -const isHiddenFile = (fileName: string): boolean => { - return fileName.startsWith('.'); -}; - -export const prepareFilesToUpload = async ({ - files, - parentFolderId, - disableDuplicatedNamesCheck = false, - fileType, - disableExistenceCheck = false, - notUploadHiddenFiles = false, -}: { - files: File[]; - parentFolderId: string; - disableDuplicatedNamesCheck?: boolean; - fileType?: string; - disableExistenceCheck?: boolean; - notUploadHiddenFiles?: boolean; -}): Promise<{ filesToUpload: FileToUpload[] }> => { - const filteredFiles = notUploadHiddenFiles ? files.filter((file) => !isHiddenFile(file.name)) : files; - - let filesToUpload: FileToUpload[] = []; - - const processFiles = async ( - filesBatch: File[], - disableDuplicatedNamesCheckOverride: boolean, - duplicatedFiles?: DriveFileData[], - ) => { - const { newFilesToUpload } = await processDuplicateFiles({ - files: filesBatch, - existingFilesToUpload: filesToUpload, - fileType, - parentFolderId, - disableDuplicatedNamesCheck: disableDuplicatedNamesCheckOverride, - duplicatedFiles, - }); - filesToUpload = newFilesToUpload; - }; - - const processFilesBatch = async (filesBatch: File[]) => { - if (disableExistenceCheck) { - await processFiles(filesBatch, true); - } else { - const { duplicatedFilesResponse, filesWithoutDuplicates, filesWithDuplicates } = await checkDuplicatedFiles( - filesBatch, - parentFolderId, - ); - - await processFiles(filesWithoutDuplicates as File[], true); - await processFiles(filesWithDuplicates as File[], disableDuplicatedNamesCheck, duplicatedFilesResponse); - } - }; - - for (let i = 0; i < filteredFiles.length; i += BATCH_SIZE) { - const batch = filteredFiles.slice(i, i + BATCH_SIZE); - await processFilesBatch(batch); - } - - return { filesToUpload }; -}; diff --git a/src/app/store/slices/storage/fileUtils/processDuplicateFiles.test.ts b/src/app/store/slices/storage/fileUtils/processDuplicateFiles.test.ts deleted file mode 100644 index d3c0d96bf2..0000000000 --- a/src/app/store/slices/storage/fileUtils/processDuplicateFiles.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { DriveFileData } from '@internxt/sdk/dist/drive/storage/types'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { processDuplicateFiles } from './processDuplicateFiles'; -import { getUniqueFilename } from './getUniqueFilename'; - -vi.mock('./getUniqueFilename', () => ({ - getUniqueFilename: vi.fn(), -})); - -const mockGetUniqueFilename = vi.mocked(getUniqueFilename); - -describe('Process duplicated files', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - test('When processing a single file without duplicates check, then it should add the file with original name', async () => { - const fileName = 'document'; - const fileType = 'pff'; - const file = new File(['content'], `${fileName}.${fileType}`, { type: 'application/pdf' }); - const parentFolderId = 'folder-123'; - - const result = await processDuplicateFiles({ - files: [file], - existingFilesToUpload: [], - parentFolderId, - disableDuplicatedNamesCheck: true, - }); - - expect(result.newFilesToUpload).toHaveLength(1); - expect(result.newFilesToUpload[0]).toStrictEqual({ - name: fileName, - size: file.size, - type: fileType, - parentFolderId, - content: file, - }); - - expect(mockGetUniqueFilename).not.toHaveBeenCalled(); - }); - - test('When processing a file with duplicate check enabled and duplicates exist, then it should rename the file', async () => { - const file = new File(['content'], 'report.txt', { type: 'text/plain' }); - const parentFolderId = 'folder-456'; - const duplicatedFiles = [{ plainName: 'report', type: 'txt' }] as DriveFileData[]; - - mockGetUniqueFilename.mockResolvedValue('report (1)'); - - const result = await processDuplicateFiles({ - files: [file], - existingFilesToUpload: [], - parentFolderId, - disableDuplicatedNamesCheck: false, - duplicatedFiles, - }); - - expect(result.newFilesToUpload).toHaveLength(1); - expect(result.newFilesToUpload[0].name).toBe('report (1)'); - expect(mockGetUniqueFilename).toHaveBeenCalledWith('report', 'txt', duplicatedFiles, parentFolderId); - }); - - test('When a file has no extension, then it should use the provided file type', async () => { - const file = new File(['content'], 'README', { type: 'text/plain' }); - const parentFolderId = 'folder-222'; - const fileType = 'txt'; - - const result = await processDuplicateFiles({ - files: [file], - existingFilesToUpload: [], - fileType, - parentFolderId, - disableDuplicatedNamesCheck: true, - }); - - expect(result.newFilesToUpload).toHaveLength(1); - expect(result.newFilesToUpload[0].name).toBe('README'); - expect(result.newFilesToUpload[0].type).toBe(fileType); - }); - - test('When there are no duplicated files and the check is disabled, then it should not get a unique filename', async () => { - const fileName = 'test'; - const fileType = 'js'; - const file = new File(['content'], `${fileName}.${fileType}`, { type: 'application/javascript' }); - const parentFolderId = 'folder-333'; - - const result = await processDuplicateFiles({ - files: [file], - existingFilesToUpload: [], - parentFolderId, - disableDuplicatedNamesCheck: false, - duplicatedFiles: undefined, - }); - - expect(result.newFilesToUpload).toHaveLength(1); - expect(result.newFilesToUpload[0].name).toBe(fileName); - expect(result.newFilesToUpload[0].type).toBe(fileType); - expect(mockGetUniqueFilename).not.toHaveBeenCalled(); - }); - - test('When processing files with duplicate check, then it should use an unique filename', async () => { - const file = new File(['content'], 'data.csv', { type: 'text/csv' }); - const uniqueName = 'data (3)'; - const parentFolderId = 'folder-555'; - const duplicatedFiles = [ - { plainName: 'data', type: 'csv' }, - { plainName: 'data (1)', type: 'csv' }, - { plainName: 'data (2)', type: 'csv' }, - ] as DriveFileData[]; - - mockGetUniqueFilename.mockResolvedValue(uniqueName); - - const result = await processDuplicateFiles({ - files: [file], - existingFilesToUpload: [], - parentFolderId, - disableDuplicatedNamesCheck: false, - duplicatedFiles, - }); - - expect(result.newFilesToUpload[0].name).toBe(uniqueName); - expect(result.newFilesToUpload[0].content.name).toBe(uniqueName); - }); - - test('When processing files with different extensions but same name, then each should be processed independently', async () => { - const fileTxt = new File(['text'], 'readme.txt', { type: 'text/plain' }); - const fileMd = new File(['markdown'], 'readme.md', { type: 'text/markdown' }); - const parentFolderId = 'folder-777'; - - const result = await processDuplicateFiles({ - files: [fileTxt, fileMd], - existingFilesToUpload: [], - parentFolderId, - disableDuplicatedNamesCheck: true, - }); - - expect(result.newFilesToUpload).toHaveLength(2); - const types = result.newFilesToUpload.map((f) => f.type); - expect(types).toContain('txt'); - expect(types).toContain('md'); - }); -}); diff --git a/src/app/store/slices/storage/fileUtils/processDuplicateFiles.ts b/src/app/store/slices/storage/fileUtils/processDuplicateFiles.ts deleted file mode 100644 index 5d80f6df10..0000000000 --- a/src/app/store/slices/storage/fileUtils/processDuplicateFiles.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { items as itemUtils } from '@internxt/lib'; -import { DriveFileData } from '@internxt/sdk/dist/drive/storage/types'; -import { renameFile } from '../../../../crypto/services/utils'; -import { FileToUpload } from 'app/drive/services/file.service/types'; -import { getUniqueFilename } from './getUniqueFilename'; - -interface ProcessDuplicateFilesParams { - files: File[]; - existingFilesToUpload: FileToUpload[]; - fileType?: string; - parentFolderId: string; - disableDuplicatedNamesCheck?: boolean; - duplicatedFiles?: DriveFileData[]; -} - -export const processDuplicateFiles = async ({ - files, - existingFilesToUpload, - fileType, - parentFolderId, - disableDuplicatedNamesCheck, - duplicatedFiles, -}: ProcessDuplicateFilesParams): Promise<{ newFilesToUpload: FileToUpload[] }> => { - const newFilesToUpload: FileToUpload[] = [...existingFilesToUpload]; - - const processFile = async (file: File): Promise => { - const { filename, extension } = itemUtils.getFilenameAndExt(file.name); - let finalFilename = filename; - - if (!disableDuplicatedNamesCheck && duplicatedFiles) { - finalFilename = await getUniqueFilename(filename, extension, duplicatedFiles, parentFolderId); - } - - const fileContent = renameFile(file, finalFilename); - - newFilesToUpload.push({ - name: finalFilename, - size: file.size, - type: extension ?? fileType, - content: fileContent, - parentFolderId, - }); - }; - - await Promise.all(files.map(processFile)); - - return { newFilesToUpload }; -}; diff --git a/src/app/store/slices/storage/folderUtils/getUniqueFolderName.test.ts b/src/app/store/slices/storage/folderUtils/getUniqueFolderName.test.ts index dc112ce0e1..30177a88f9 100644 --- a/src/app/store/slices/storage/folderUtils/getUniqueFolderName.test.ts +++ b/src/app/store/slices/storage/folderUtils/getUniqueFolderName.test.ts @@ -11,8 +11,6 @@ vi.mock('app/drive/services/new-storage.service', () => ({ }, })); -vi.mock('../storage.thunks/uploadFolderThunk'); - describe('getUniqueFolderName', () => { const parentFolderId = 'parent123'; diff --git a/src/app/store/slices/storage/hooks/useReduxActions.test.ts b/src/app/store/slices/storage/hooks/useReduxActions.test.ts deleted file mode 100644 index 7e28436eb8..0000000000 --- a/src/app/store/slices/storage/hooks/useReduxActions.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { describe, it, test, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; -import { useDispatch, useSelector } from 'react-redux'; -import { useReduxActions } from './useReduxActions'; -import { uploadFolderThunk } from '../storage.thunks/uploadFolderThunk'; -import { uploadItemsThunk, uploadSharedItemsThunk } from '../storage.thunks/uploadItemsThunk'; -import { UploadFolderData } from '../../../../tasks/types'; -import { renderHook } from '@testing-library/react'; -import { uploadFoldersWithTracking } from 'app/drive/services/folder.service/uploadFoldersWithTracking'; -import errorService from 'services/error.service'; - -vi.mock('react-redux', () => ({ - useDispatch: vi.fn(), - useSelector: vi.fn(), -})); - -vi.mock('app/drive/services/folder.service/uploadFoldersWithTracking', () => ({ - uploadFoldersWithTracking: vi.fn(), -})); - -vi.mock('services/error.service', () => ({ - default: { reportError: vi.fn() }, -})); - -vi.mock('../storage.thunks/uploadFolderThunk', () => ({ - uploadFolderThunk: vi.fn(), -})); - -vi.mock('../storage.thunks/uploadItemsThunk', () => ({ - uploadItemsThunk: vi.fn(), - uploadSharedItemsThunk: vi.fn(), -})); - -vi.mock('app/drive/services/folder.service', () => ({ - createFilesIterator: vi.fn(), - createFoldersIterator: vi.fn(), -})); - -describe('useReduxActions', () => { - const dispatch = vi.fn(); - const selectedWorkspace = { workspaceUser: { memberId: 'member1' } }; - const maxUploadFileSize = 1024; - const state = { workspaces: { selectedWorkspace }, fileVersions: { limits: { maxUploadFileSize } } }; - - const folderData: UploadFolderData & { taskId: string } = { - folder: { - name: 'folder1', - folderId: null, - childrenFiles: [], - childrenFolders: [], - isFolder: true, - }, - parentFolderId: 'parent1', - taskId: 'task1', - }; - - beforeEach(() => { - (uploadFoldersWithTracking as Mock).mockResolvedValue(undefined); - (useDispatch as Mock).mockReturnValue(dispatch); - (useSelector as Mock).mockImplementation((selector: (s: typeof state) => unknown) => selector(state)); - }); - - afterEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - }); - - test('when a failed folder upload is retried, then it resumes the same task through the folder upload pipeline', () => { - const { result } = renderHook(() => useReduxActions()); - const data = folderData; - - result.current.uploadFolder(data); - - expect(uploadFoldersWithTracking).toHaveBeenCalledWith({ - payload: [{ root: data.folder, currentFolderId: data.parentFolderId, options: { taskId: data.taskId } }], - selectedWorkspace, - dispatch, - maxUploadFileSize, - }); - expect(uploadFolderThunk).not.toHaveBeenCalled(); - }); - - test('when the folder retry upload fails, then the error is reported', async () => { - const error = new Error('upload failed'); - (uploadFoldersWithTracking as Mock).mockRejectedValue(error); - const { result } = renderHook(() => useReduxActions()); - - result.current.uploadFolder(folderData); - - await vi.waitFor(() => expect(errorService.reportError).toHaveBeenCalledWith(error)); - }); - - it('should dispatch uploadItemsThunk with correct arguments for uploadItem', () => { - const { result } = renderHook(() => useReduxActions()); - const data = { - uploadFile: new File(['content'], 'file.txt'), - parentFolderId: 'parent1', - taskId: 'task1', - fileType: 'text/plain', - }; - - result.current.uploadItem(data); - - expect(dispatch).toHaveBeenCalledWith( - uploadItemsThunk({ - files: [data.uploadFile], - parentFolderId: data.parentFolderId, - taskId: data.taskId, - fileType: data.fileType, - }), - ); - }); - - it('should dispatch uploadSharedItemsThunk with correct arguments', () => { - const { result } = renderHook(() => useReduxActions()); - const data = { - uploadFile: new File(['content'], 'file.txt'), - parentFolderId: 'parent1', - taskId: 'task1', - fileType: 'text/plain', - sharedItemAuthenticationData: { - currentFolderId: 'current1', - ownerUserAuthenticationData: { - token: 'token', - bridgeUser: 'bridgeUser', - bridgePass: 'bridgePass', - encryptionKey: 'encryptionKey', - bucketId: 'bucketId', - }, - isDeepFolder: true, - }, - }; - - result.current.uploadSharedItem(data); - - expect(dispatch).toHaveBeenCalledWith( - uploadSharedItemsThunk({ - files: [data.uploadFile], - parentFolderId: data.parentFolderId, - currentFolderId: data.sharedItemAuthenticationData.currentFolderId, - taskId: data.taskId, - fileType: data.fileType, - ownerUserAuthenticationData: data.sharedItemAuthenticationData.ownerUserAuthenticationData, - isDeepFolder: data.sharedItemAuthenticationData.isDeepFolder, - }), - ); - }); - - it('should dispatch uploadItemsThunk with correct arguments for uploadRetryItem', () => { - const { result } = renderHook(() => useReduxActions()); - const data = { - uploadFile: new File(['content'], 'file.txt'), - parentFolderId: 'parent1', - taskId: 'task1', - fileType: 'text/plain', - }; - - result.current.uploadRetryItem(data); - - expect(dispatch).toHaveBeenCalledWith( - uploadItemsThunk({ - files: [data.uploadFile], - parentFolderId: data.parentFolderId, - taskId: data.taskId, - fileType: data.fileType, - isRetry: true, - }), - ); - }); -}); diff --git a/src/app/store/slices/storage/hooks/useReduxActions.ts b/src/app/store/slices/storage/hooks/useReduxActions.ts deleted file mode 100644 index 3756919dab..0000000000 --- a/src/app/store/slices/storage/hooks/useReduxActions.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { useAppDispatch, useAppSelector } from 'app/store/hooks'; -import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; -import fileVersionsSelectors from 'app/store/slices/fileVersions/fileVersions.selectors'; -import { uploadFoldersWithTracking } from 'app/drive/services/folder.service/uploadFoldersWithTracking'; -import errorService from 'services/error.service'; -import { SharedItemAuthenticationData, UploadFolderData } from '../../../../tasks/types'; -import { uploadItemsThunk, uploadSharedItemsThunk } from '../storage.thunks/uploadItemsThunk'; - -export const useReduxActions = () => { - const dispatch = useAppDispatch(); - const selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace); - const maxUploadFileSize = useAppSelector(fileVersionsSelectors.getMaxFileSizeLimit); - - const uploadFolder = (data: UploadFolderData & { taskId: string }) => { - uploadFoldersWithTracking({ - payload: [{ root: data.folder, currentFolderId: data.parentFolderId, options: { taskId: data.taskId } }], - selectedWorkspace, - dispatch, - maxUploadFileSize, - }).catch((error) => errorService.reportError(error)); - }; - - const uploadItem = (data: { uploadFile: File; parentFolderId: string; taskId: string; fileType: string }) => { - void dispatch( - uploadItemsThunk({ - files: [data.uploadFile], - parentFolderId: data.parentFolderId, - taskId: data.taskId, - fileType: data.fileType, - }), - ); - }; - - const uploadSharedItem = (data: { - uploadFile: File; - parentFolderId: string; - taskId: string; - fileType: string; - sharedItemAuthenticationData: SharedItemAuthenticationData; - }) => { - void dispatch( - uploadSharedItemsThunk({ - files: [data.uploadFile], - parentFolderId: data.parentFolderId, - currentFolderId: data.sharedItemAuthenticationData.currentFolderId, - taskId: data.taskId, - fileType: data.fileType, - ownerUserAuthenticationData: data.sharedItemAuthenticationData.ownerUserAuthenticationData, - isDeepFolder: data.sharedItemAuthenticationData.isDeepFolder, - }), - ); - }; - - const uploadRetryItem = (data: { uploadFile: File; parentFolderId: string; taskId: string; fileType: string }) => { - return dispatch( - uploadItemsThunk({ - files: [data.uploadFile], - parentFolderId: data.parentFolderId, - taskId: data.taskId, - fileType: data.fileType, - isRetry: true, - }), - ); - }; - - return { uploadFolder, uploadItem, uploadSharedItem, uploadRetryItem }; -}; diff --git a/src/app/store/slices/storage/storage.thunks/index.ts b/src/app/store/slices/storage/storage.thunks/index.ts index 4231e46d4d..fd6ac138ab 100644 --- a/src/app/store/slices/storage/storage.thunks/index.ts +++ b/src/app/store/slices/storage/storage.thunks/index.ts @@ -23,17 +23,13 @@ import { moveItemsThunk, moveItemsThunkExtraReducers } from './moveItemsThunk'; import { renameItemsThunk, renameItemsThunkExtraReducers } from './renameItemsThunk'; import { resetNamePathThunk, resetNamePathThunkExtraReducers } from './resetNamePathThunk'; import { updateItemMetadataThunk, updateItemMetadataThunkExtraReducers } from './updateItemMetadataThunk'; -import { uploadFolderThunk, uploadFolderThunkExtraReducers } from './uploadFolderThunk'; -import { uploadItemsThunk, uploadItemsThunkExtraReducers, uploadSharedItemsThunk } from './uploadItemsThunk'; const storageThunks = { initializeThunk, resetNamePathThunk, - uploadItemsThunk, fetchPaginatedFolderContentThunk, deleteItemsThunk, goToFolderThunk, - uploadFolderThunk, updateItemMetadataThunk, fetchRecentsThunk, fetchFavoritesThunk, @@ -42,17 +38,14 @@ const storageThunks = { moveItemsThunk, fetchDeletedThunk, renameItemsThunk, - uploadSharedItemsThunk, }; export const storageExtraReducers = (builder: ActionReducerMapBuilder): void => { initializeThunkExtraReducers(builder); resetNamePathThunkExtraReducers(builder); - uploadItemsThunkExtraReducers(builder); fetchFolderContentThunkExtraReducers(builder); deleteItemsThunkExtraReducers(builder); goToFolderThunkExtraReducers(builder); - uploadFolderThunkExtraReducers(builder); updateItemMetadataThunkExtraReducers(builder); fetchRecentsThunkExtraReducers(builder); fetchFavoritesThunkExtraReducers(builder); diff --git a/src/app/store/slices/storage/storage.thunks/uploadFolderThunk.test.ts b/src/app/store/slices/storage/storage.thunks/uploadFolderThunk.test.ts deleted file mode 100644 index aadc350b2f..0000000000 --- a/src/app/store/slices/storage/storage.thunks/uploadFolderThunk.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { describe, expect, vi, beforeEach, Mock, test } from 'vitest'; -import { uploadFolderThunk } from './uploadFolderThunk'; -import { RootState } from '../../..'; -import { checkFolderDuplicated } from '../folderUtils/checkFolderDuplicated'; -import { uploadItemsParallelThunk } from './uploadItemsThunk'; -import tasksService from '../../../../tasks/services/tasks.service'; -import { TaskStatus } from '../../../../tasks/types'; -import { DriveFolderData } from 'app/drive/types'; -import { MAX_ALLOWED_UPLOAD_SIZE } from 'app/drive/services/network.service'; -import { wait } from 'utils/timeUtils'; -import workspacesSelectors from '../../workspaces/workspaces.selectors'; - -vi.mock('../folderUtils/checkFolderDuplicated', () => ({ - checkFolderDuplicated: vi.fn(), -})); - -vi.mock('../folderUtils/getUniqueFolderName', () => ({ - getUniqueFolderName: vi.fn(), -})); - -vi.mock('.', () => ({ - default: { - createFolderThunk: vi.fn(), - }, -})); - -vi.mock('./uploadItemsThunk', () => ({ - uploadItemsParallelThunk: vi.fn(), -})); - -vi.mock('./deleteItemsThunk', () => ({ - deleteItemsThunk: vi.fn(), -})); - -vi.mock('app/drive/services/new-storage.service', () => ({ - default: { - deleteFolderByUuid: vi.fn(), - }, -})); - -vi.mock('../../workspaces/workspaces.selectors', () => ({ - default: { - getSelectedWorkspace: vi.fn().mockReturnValue(null), - }, -})); - -vi.mock('../../plan', () => ({ - planThunks: { - fetchUsageThunk: vi.fn(), - fetchBusinessLimitUsageThunk: vi.fn(), - }, -})); - -vi.mock('services/referral.service', () => ({ - default: { - trackFolderUpload: vi.fn(), - }, -})); - -vi.mock('app/network/networkInformation', () => ({ - logNetworkInfoForUpload: vi.fn(), -})); - -vi.mock('services/error.service', () => ({ - default: { - castError: vi.fn().mockImplementation((e) => e), - reportError: vi.fn(), - }, -})); - -vi.mock('i18next', () => ({ - t: vi.fn((key) => key), -})); - -vi.mock('../../ui', () => ({ - uiActions: { - setOpenFileSizeLimitReachedDialog: vi.fn().mockReturnValue({ type: 'ui/setOpenFileSizeLimitReachedDialog' }), - }, -})); - -vi.mock('utils/timeUtils', () => ({ - wait: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock('../../fileVersions', () => ({ - fileVersionsSelectors: { - getMaxFileSizeLimit: vi.fn((state) => state.fileVersions.limits?.maxUploadFileSize ?? MAX_ALLOWED_UPLOAD_SIZE), - }, -})); - -const mockFolder: DriveFolderData = { - id: 1, - uuid: 'folder-uuid', - name: 'TestFolder', - bucket: 'bucket', - parentId: 0, - parent_id: 0, - parentUuid: 'parent-uuid', - userId: 0, - user_id: 0, - icon: null, - iconId: null, - icon_id: null, - isFolder: true, - color: null, - encrypt_version: null, - plain_name: 'TestFolder', - deleted: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), -}; - -const buildGetState = (maxUploadFileSize?: number) => () => - ({ - user: { user: { email: 'test@test.com' } }, - fileVersions: { limits: { maxUploadFileSize: maxUploadFileSize ?? MAX_ALLOWED_UPLOAD_SIZE } }, - }) as unknown as RootState; - -describe('Upload Folder Thunk', () => { - const taskId = 'task-1'; - - beforeEach(() => { - vi.clearAllMocks(); - - (checkFolderDuplicated as Mock).mockResolvedValue({ - duplicatedFoldersResponse: [], - foldersWithDuplicates: [], - foldersWithoutDuplicates: [mockFolder], - }); - vi.spyOn(tasksService, 'create').mockReturnValue(taskId); - vi.spyOn(tasksService, 'updateTask').mockReturnValue(); - vi.spyOn(tasksService, 'getTasks').mockReturnValue([]); - vi.spyOn(tasksService, 'findTask').mockReturnValue(undefined); - }); - - test('When all files in a folder exceed the size limit and there are no subfolders, then the task is marked as error', async () => { - const bigFile = new File([new ArrayBuffer(200)], 'big.mp4'); - const dispatch = vi.fn().mockResolvedValue({ unwrap: () => Promise.resolve(mockFolder) }); - dispatch.mockImplementation((action) => { - if (typeof action === 'function') return action(dispatch, buildGetState(100), {}); - return { unwrap: () => Promise.resolve(mockFolder) }; - }); - const updateTaskSpy = vi.spyOn(tasksService, 'updateTask'); - - await uploadFolderThunk({ - root: { - isFolder: true, - folderId: 'parent-uuid', - childrenFiles: [bigFile], - childrenFolders: [], - name: 'TestFolder', - }, - currentFolderId: 'parent-uuid', - options: { taskId }, - })(dispatch, buildGetState(100), {}); - - expect(updateTaskSpy).toHaveBeenCalledWith( - expect.objectContaining({ - merge: expect.objectContaining({ status: TaskStatus.Error }), - }), - ); - expect(uploadItemsParallelThunk).not.toHaveBeenCalled(); - }); - - test('When some files exceed the size limit and there are no subfolders, then only the allowed files are uploaded', async () => { - const smallFile = new File(['x'], 'small.txt'); - const bigFile = new File([new ArrayBuffer(200)], 'big.mp4'); - const uploadThunkAction = { unwrap: () => Promise.resolve() }; - (uploadItemsParallelThunk as unknown as Mock).mockReturnValue(() => uploadThunkAction); - const dispatch = vi.fn().mockImplementation((action) => { - if (typeof action === 'function') return action(dispatch, buildGetState(100), {}); - return { unwrap: () => Promise.resolve(mockFolder) }; - }); - - await uploadFolderThunk({ - root: { - isFolder: true, - folderId: 'parent-uuid', - childrenFiles: [smallFile, bigFile], - childrenFolders: [], - name: 'TestFolder', - }, - currentFolderId: 'parent-uuid', - options: { taskId }, - })(dispatch, buildGetState(100), {}); - - expect(uploadItemsParallelThunk).toHaveBeenCalledWith(expect.objectContaining({ files: [smallFile, bigFile] })); - }); - - test('When no size limit is configured, then all files below the default size limit are uploaded', async () => { - const bigFile = new File([new ArrayBuffer(999_999)], 'huge.mp4'); - const uploadThunkAction = { unwrap: () => Promise.resolve() }; - (uploadItemsParallelThunk as unknown as Mock).mockReturnValue(() => uploadThunkAction); - const dispatch = vi.fn().mockImplementation((action) => { - if (typeof action === 'function') return action(dispatch, buildGetState(undefined), {}); - return { unwrap: () => Promise.resolve(mockFolder) }; - }); - - await uploadFolderThunk({ - root: { - isFolder: true, - folderId: 'parent-uuid', - childrenFiles: [bigFile], - childrenFolders: [], - name: 'TestFolder', - }, - currentFolderId: 'parent-uuid', - options: { taskId }, - })(dispatch, buildGetState(), {}); - - expect(uploadItemsParallelThunk).toHaveBeenCalled(); - }); - test('When uploading a folder outside a workspace, then it does not wait after creating each folder', async () => { - const dispatch = vi.fn().mockImplementation((action) => { - if (typeof action === 'function') return action(dispatch, buildGetState(100), {}); - return { unwrap: () => Promise.resolve(mockFolder) }; - }); - - await uploadFolderThunk({ - root: { - isFolder: true, - folderId: 'parent-uuid', - childrenFiles: [], - childrenFolders: [], - name: 'TestFolder', - }, - currentFolderId: 'parent-uuid', - options: { taskId }, - })(dispatch, buildGetState(100), {}); - - expect(wait).not.toHaveBeenCalled(); - }); - - test('When uploading a folder inside a workspace, then it waits after creating each folder', async () => { - (workspacesSelectors.getSelectedWorkspace as Mock).mockReturnValueOnce({ workspaceUser: { memberId: 'member-1' } }); - const dispatch = vi.fn().mockImplementation((action) => { - if (typeof action === 'function') return action(dispatch, buildGetState(100), {}); - return { unwrap: () => Promise.resolve(mockFolder) }; - }); - - await uploadFolderThunk({ - root: { - isFolder: true, - folderId: 'parent-uuid', - childrenFiles: [], - childrenFolders: [], - name: 'TestFolder', - }, - currentFolderId: 'parent-uuid', - options: { taskId }, - })(dispatch, buildGetState(100), {}); - - expect(wait).toHaveBeenCalledWith(500); - }); -}); diff --git a/src/app/store/slices/storage/storage.thunks/uploadFolderThunk.ts b/src/app/store/slices/storage/storage.thunks/uploadFolderThunk.ts deleted file mode 100644 index e5d6a1092a..0000000000 --- a/src/app/store/slices/storage/storage.thunks/uploadFolderThunk.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { ActionReducerMapBuilder, AnyAction, ThunkDispatch, createAsyncThunk } from '@reduxjs/toolkit'; - -import { t } from 'i18next'; -import storageThunks from '.'; -import { RootState } from '../../..'; -import errorService from 'services/error.service'; -import { DriveFolderData, DriveItemData } from 'app/drive/types'; -import notificationsService, { ToastType } from '../../../../notifications/services/notifications.service'; -import tasksService from '../../../../tasks/services/tasks.service'; -import { TaskStatus, TaskType, UploadFolderTask } from '../../../../tasks/types'; -import { planThunks } from '../../plan'; -import workspacesSelectors from '../../workspaces/workspaces.selectors'; - -import referralService from 'services/referral.service'; -import { logNetworkInfoForUpload } from 'app/network/networkInformation'; -import { checkFolderDuplicated } from '../folderUtils/checkFolderDuplicated'; -import { getUniqueFolderName } from '../folderUtils/getUniqueFolderName'; -import { StorageState } from '../storage.model'; -import { deleteItemsThunk } from './deleteItemsThunk'; -import { uploadItemsParallelThunk } from './uploadItemsThunk'; -import { IRoot } from '../types'; -import { wait } from 'utils/timeUtils'; -import newStorageService from 'app/drive/services/new-storage.service'; -import { uiActions } from '../../ui'; -import { FilesExceedsSizeLimitError } from 'app/drive/services/file.service/upload.errors'; -import { filterFilesByMaxSize } from '../fileUtils/filterFilesByMaxSize'; -import { fileVersionsSelectors } from '../../fileVersions'; - -interface UploadFolderThunkPayload { - root: IRoot; - currentFolderId: string; - options?: { - taskId?: string; - withNotification?: boolean; - onSuccess?: () => void; - }; -} - -const handleFoldersRename = async (root: IRoot, currentFolderId: string) => { - const { duplicatedFoldersResponse } = await checkFolderDuplicated([root], currentFolderId); - - let finalFilename = root.name; - if (duplicatedFoldersResponse.length > 0) - finalFilename = await getUniqueFolderName( - root.name, - duplicatedFoldersResponse as DriveFolderData[], - currentFolderId, - ); - - const folder: IRoot = { ...root, name: finalFilename }; - - return folder; -}; - -const stopUploadTask = async ( - uploadFolderAbortController: AbortController, - dispatch: ThunkDispatch, - relatedTaskId?: string, - rootFolderItem?: DriveFolderData, -) => { - uploadFolderAbortController.abort(); - const relatedTasks = tasksService.getTasks({ relatedTaskId }); - const promises: Promise[] = []; - - // Cancels related tasks - promises.push( - ...(relatedTasks.map((task) => task.stop?.()).filter((promise) => promise !== undefined) as Promise[]), - ); - // Deletes the root folder - if (rootFolderItem) { - promises.push( - dispatch(deleteItemsThunk([rootFolderItem as DriveItemData])).unwrap(), - newStorageService.deleteFolderByUuid(rootFolderItem.uuid), - ); - } - await Promise.all(promises); -}; - -export const uploadFolderThunk = createAsyncThunk( - 'storage/createFolderStructure', - async ({ root, currentFolderId, options }, { dispatch, requestId, getState }) => { - const state = getState(); - const workspaceSelected = workspacesSelectors.getSelectedWorkspace(state); - const memberId = workspaceSelected?.workspaceUser?.memberId; - - options = { withNotification: true, ...options }; - const uploadFolderAbortController = new AbortController(); - - let alreadyUploaded = 0; - let rootFolderItem: DriveFolderData | undefined; - let rootFolderData: DriveFolderData | undefined; - - const renamedRoot = await handleFoldersRename(root, currentFolderId); - const levels = [renamedRoot]; - - const itemsUnderRoot = countItemsUnderRoot(renamedRoot); - - let taskId = options?.taskId; - const isRetriedUpload = !!taskId; - - if (isRetriedUpload && taskId) { - tasksService.updateTask({ - taskId, - merge: { - status: TaskStatus.InProcess, - progress: 0, - }, - }); - } else { - taskId = tasksService.create({ - action: TaskType.UploadFolder, - folderName: renamedRoot.name, - item: root, - parentFolderId: currentFolderId, - showNotification: !!options.withNotification, - cancellable: true, - stop: async () => { - uploadFolderAbortController.abort(); - const relatedTasks = tasksService.getTasks({ relatedTaskId: requestId }); - const promises: Promise[] = []; - - // Cancels related tasks - promises.push( - ...(relatedTasks - .map((task) => task.stop?.()) - .filter((promise) => promise !== undefined) as Promise[]), - ); - - // Deletes the root folder - if (rootFolderItem) { - promises.push(dispatch(deleteItemsThunk([rootFolderItem as DriveItemData])).unwrap()); - } - - await Promise.all(promises); - }, - }); - } - - try { - renamedRoot.folderId = currentFolderId; - while (levels.length > 0) { - const level: IRoot = levels.shift() as IRoot; - const createdFolder = await dispatch( - storageThunks.createFolderThunk({ - parentFolderId: level.folderId as string, - folderName: level.name, - options: { relatedTaskId: taskId, showErrors: false }, - }), - ).unwrap(); - if (workspaceSelected) { - // Workspace endpoints still validate the parent folder against the read replica - await wait(500); - } - - rootFolderItem ??= createdFolder; - - if (!rootFolderData) { - rootFolderData = createdFolder; - tasksService.updateTask({ - taskId, - merge: { - stop: () => stopUploadTask(uploadFolderAbortController, dispatch, taskId, rootFolderItem), - }, - }); - } - - if (level.childrenFiles) { - const maxUploadFileSize = fileVersionsSelectors.getMaxFileSizeLimit(state); - const hasNoChildFolders = level.childrenFolders.length === 0; - const { exceededFiles } = filterFilesByMaxSize({ - files: level.childrenFiles, - maxUploadFileSize: maxUploadFileSize, - }); - const allFilesExceedSizeLimit = - exceededFiles.length === level.childrenFiles.length && level.childrenFiles.length > 0; - - if (allFilesExceedSizeLimit && hasNoChildFolders) { - await stopUploadTask(uploadFolderAbortController, dispatch, taskId, rootFolderItem); - - dispatch( - uiActions.setOpenFileSizeLimitReachedDialog({ open: true, info: { exceededFiles: level.childrenFiles } }), - ); - throw new FilesExceedsSizeLimitError(); - } - - await dispatch( - uploadItemsParallelThunk({ - files: level.childrenFiles, - parentFolderId: createdFolder.uuid, - options: { - relatedTaskId: taskId, - showNotifications: false, - showErrors: false, - abortController: uploadFolderAbortController, - isRetriedUpload, - isUploadedFromFolder: true, - }, - filesProgress: { filesUploaded: alreadyUploaded, totalFilesToUpload: itemsUnderRoot }, - }), - ) - .unwrap() - .then(() => { - alreadyUploaded += level.childrenFiles.length; - alreadyUploaded += 1; - - tasksService.updateTask({ - taskId: taskId as string, - merge: { - progress: alreadyUploaded / itemsUnderRoot, - }, - }); - }); - } - - const childrenFolders = [] as IRoot[]; - for (const child of level.childrenFolders) { - childrenFolders.push({ ...child, folderId: createdFolder.uuid }); - } - - levels.push(...childrenFolders); - } - tasksService.isTaskProgressCompleted(taskId); - - tasksService.updateTask({ - taskId: taskId, - merge: { - status: TaskStatus.Success, - itemUUID: { rootFolderUUID: rootFolderData?.uuid }, - }, - }); - - options.onSuccess?.(); - referralService.trackFolderUpload(); - logNetworkInfoForUpload({ folderName: renamedRoot.name }); - setTimeout(() => { - dispatch(planThunks.fetchUsageThunk()); - if (memberId) dispatch(planThunks.fetchBusinessLimitUsageThunk()); - }, 1000); - } catch (err: unknown) { - const castedError = errorService.castError(err); - const updatedTask = tasksService.findTask(taskId); - - if (updatedTask?.status !== TaskStatus.Cancelled) { - tasksService.updateTask({ - taskId: taskId, - merge: { - status: TaskStatus.Error, - subtitle: t('tasks.subtitles.upload-failed') as string, - }, - }); - - throw castedError; - } - } - }, -); - -function countItemsUnderRoot(root: IRoot): number { - let count = 0; - - const queueOfFolders: Array = [root]; - - while (queueOfFolders.length > 0) { - const folder = queueOfFolders.shift() as IRoot; - - count += folder.childrenFiles.length; - - if (folder.childrenFolders) { - count += folder.childrenFolders.length; - queueOfFolders.push(...folder.childrenFolders); - } - } - - return count; -} - -export const uploadFolderThunkExtraReducers = (builder: ActionReducerMapBuilder): void => { - builder - .addCase(uploadFolderThunk.pending, () => undefined) - .addCase(uploadFolderThunk.fulfilled, () => undefined) - .addCase(uploadFolderThunk.rejected, (state, action) => { - let errorMessage = t('error.uploadingFolder'); - - if (action.error.message?.includes('already exists')) { - errorMessage = t('error.folderAlreadyExists'); - } else { - errorMessage = action.error.message || action.error + ''; - } - - notificationsService.show({ text: errorMessage, type: ToastType.Error }); - }); -}; diff --git a/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.test.ts b/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.test.ts deleted file mode 100644 index d3e2d0c621..0000000000 --- a/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.test.ts +++ /dev/null @@ -1,473 +0,0 @@ -import { describe, it, expect, vi, beforeEach, Mock, test } from 'vitest'; -import { - uploadItemsParallelThunk, - uploadItemsThunk, - uploadItemsThunkExtraReducers, - uploadSharedItemsThunk, -} from './uploadItemsThunk'; -import { RootState } from '../../..'; -import { prepareFilesToUpload } from '../fileUtils/prepareFilesToUpload'; -import { uploadFilesWithTasks } from 'app/tasks/upload/uploadFilesWithTasks'; -import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; -import RetryManager, { RetryableTaskType } from 'app/network/RetryManager'; -import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; -import { StorageState } from '../storage.model'; -import errorService from 'services/error.service'; -import { AppError } from '@internxt/sdk'; -import shareService from '../../../../share/services/share.service'; -import workspacesSelectors from '../../workspaces/workspaces.selectors'; -import { MAX_ALLOWED_UPLOAD_SIZE } from 'app/drive/services/network.service'; -import { planSelectors } from '../../plan'; - -vi.mock('../../../../share/services/share.service', () => ({ - default: { - getSharedFolderContent: vi.fn(), - }, -})); - -vi.mock('services/workspace.service', () => ({ - default: { - getWorkspaceCredentials: vi.fn(), - }, -})); - -vi.mock('../../plan', () => ({ - planThunks: { - fetchUsageThunk: vi.fn(), - }, - planSelectors: { - planLimitToShow: vi.fn(), - planUsageToShow: vi.fn(), - }, -})); -vi.mock('..', () => ({ - default: { - pushItems: vi.fn(), - }, - storageActions: vi.fn(), - storageSelectors: vi.fn(), -})); -vi.mock('i18next', () => ({ - t: vi.fn((key, params) => `${key} ${params?.reason ?? ''}`), -})); - -vi.mock('../../../../repositories/DatabaseUploadRepository', () => ({ - default: { - getInstance: vi.fn(), - }, -})); - -vi.mock('services/error.service', () => ({ - default: { - castError: vi.fn().mockImplementation((e) => e), - reportError: vi.fn(), - }, -})); - -vi.mock('../fileUtils/prepareFilesToUpload', () => ({ - prepareFilesToUpload: vi.fn(), -})); - -vi.mock('app/tasks/upload/uploadFilesWithTasks', () => ({ - uploadFilesWithTasks: vi.fn(), -})); - -vi.mock('../../workspaces/workspaces.selectors', () => ({ - default: { - getSelectedWorkspace: vi.fn(), - getWorkspaceCredentials: vi.fn(), - }, -})); - -vi.mock('../../fileVersions', () => ({ - fileVersionsSelectors: { - getMaxFileSizeLimit: vi.fn((state) => state.fileVersions.limits?.maxUploadFileSize ?? MAX_ALLOWED_UPLOAD_SIZE), - }, -})); - -const getState = () => { - return { - user: { user: { email: 'test@test.com' } }, - fileVersions: { limits: { maxUploadFileSize: MAX_ALLOWED_UPLOAD_SIZE } }, - }; -}; - -describe('uploadItemsThunk', () => { - const dispatch = vi.fn(); - - const mockFile = new File(['content'], 'file.txt', { type: 'text/plain' }); - - beforeEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - }); - - it('should upload files successfully', async () => { - (prepareFilesToUpload as Mock).mockResolvedValue({ - filesToUpload: [mockFile], - }); - - await uploadItemsThunk({ - files: [mockFile], - parentFolderId: 'parent1', - })(dispatch, getState as () => RootState, {}); - - expect(prepareFilesToUpload).toHaveBeenCalled(); - expect(uploadFilesWithTasks).toHaveBeenCalled(); - }); - - it('should handle upload errors', async () => { - (prepareFilesToUpload as Mock).mockResolvedValue({ - filesToUpload: [mockFile], - }); - const notificationsServiceSpy = vi.spyOn(notificationsService, 'show'); - (uploadFilesWithTasks as Mock).mockRejectedValue(new Error('Upload failed')); - - await uploadItemsThunk({ - files: [mockFile], - parentFolderId: 'parent1', - })(dispatch, getState as () => RootState, {}); - - expect(notificationsServiceSpy).toHaveBeenCalledWith({ - text: 'Upload failed', - type: ToastType.Error, - }); - }); - - it('should handle retry upload', async () => { - (prepareFilesToUpload as Mock).mockResolvedValue({ - filesToUpload: [mockFile], - }); - (uploadFilesWithTasks as Mock).mockRejectedValueOnce(new Error('Upload failed')); - const RetryChangeStatusSpy = vi.spyOn(RetryManager, 'changeStatus'); - - await uploadItemsThunk({ - files: [mockFile], - parentFolderId: 'parent1', - taskId: 'task1', - isRetry: true, - })(dispatch, getState as () => RootState, {}); - - expect(RetryChangeStatusSpy).toHaveBeenCalledWith('task1', 'failed'); - }); - - test.each([ - { - reason: 'is too big', - state: { ...getState(), fileVersions: { limits: { maxUploadFileSize: 1 } } }, - }, - { - reason: 'does not fit in the remaining storage', - state: getState(), - arrange: () => { - (planSelectors.planLimitToShow as Mock).mockReturnValueOnce(1); - (planSelectors.planUsageToShow as Mock).mockReturnValueOnce(0); - }, - }, - ])('when a retried file $reason, then it is marked as failed', async ({ state, arrange }) => { - arrange?.(); - const retryChangeStatusSpy = vi.spyOn(RetryManager, 'changeStatus'); - - await uploadItemsThunk({ - files: [mockFile], - parentFolderId: 'parent1', - taskId: 'task1', - isRetry: true, - })(dispatch, (() => state) as () => RootState, {}); - - expect(uploadFilesWithTasks).not.toHaveBeenCalled(); - expect(retryChangeStatusSpy).toHaveBeenCalledWith('task1', 'failed'); - }); - - test('when there is an error while uploading items, then a notification is shown', async () => { - const randomError = new AppError('Test Error', undefined, undefined, { - 'x-request-id': 'test-request-id', - }); - const castErrorSpy = vi.spyOn(errorService, 'castError'); - const showNotificationSpy = vi.spyOn(notificationsService, 'show'); - (prepareFilesToUpload as Mock).mockResolvedValue({ - filesToUpload: [mockFile], - }); - (uploadFilesWithTasks as Mock).mockRejectedValue(randomError); - - await uploadItemsThunk({ - files: [mockFile], - parentFolderId: 'parent1', - })(dispatch, getState as () => RootState, {}); - - expect(castErrorSpy).toHaveBeenCalledWith(randomError); - expect(showNotificationSpy).toHaveBeenCalledWith( - expect.objectContaining({ - requestId: 'test-request-id', - }), - ); - }); - - test('when the upload manager reports a 420 but the account still has storage available, then the storage full dialog is not opened', async () => { - (prepareFilesToUpload as Mock).mockResolvedValue({ - filesToUpload: [mockFile], - }); - (uploadFilesWithTasks as Mock).mockResolvedValue(undefined); - (planSelectors.planLimitToShow as Mock).mockReturnValue(20); - (planSelectors.planUsageToShow as Mock).mockReturnValue(12.1); - - await uploadItemsThunk({ - files: [mockFile], - parentFolderId: 'parent1', - })(dispatch, getState as () => RootState, {}); - - const { maxSpaceOccupiedCallback } = (uploadFilesWithTasks as Mock).mock.calls[0][0]; - maxSpaceOccupiedCallback(); - - expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'ui/setOpenReachedPlanLimitDialog' })); - }); - - test('when the upload manager reports a 420 and the account has actually run out of storage, then the storage full dialog is shown', async () => { - (prepareFilesToUpload as Mock).mockResolvedValue({ - filesToUpload: [mockFile], - }); - (uploadFilesWithTasks as Mock).mockResolvedValue(undefined); - (planSelectors.planLimitToShow as Mock).mockReturnValue(20); - (planSelectors.planUsageToShow as Mock).mockReturnValue(12.1); - - await uploadItemsThunk({ - files: [mockFile], - parentFolderId: 'parent1', - })(dispatch, getState as () => RootState, {}); - - (planSelectors.planUsageToShow as Mock).mockReturnValue(20); - const { maxSpaceOccupiedCallback } = (uploadFilesWithTasks as Mock).mock.calls[0][0]; - maxSpaceOccupiedCallback(); - - expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: 'ui/setOpenReachedPlanLimitDialog' })); - }); -}); - -describe('upload shared items thunk', () => { - const dispatch = vi.fn(); - const getState = () => { - return { user: { user: { email: 'test@test.com' } } }; - }; - const mockFile = new File(['content'], 'file.txt', { type: 'text/plain' }); - - beforeEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - }); - - test('when there is an error while uploading shared items, then a notification is shown', async () => { - const randomError = new AppError('Test Error', undefined, undefined, { - 'x-request-id': 'test-request-id', - }); - const castErrorSpy = vi.spyOn(errorService, 'castError'); - const showNotificationSpy = vi.spyOn(notificationsService, 'show'); - (workspacesSelectors.getSelectedWorkspace as Mock).mockReturnValue(null); - (shareService.getSharedFolderContent as Mock).mockResolvedValue({ items: [] }); - (uploadFilesWithTasks as Mock).mockRejectedValue(randomError); - - await uploadSharedItemsThunk({ - files: [mockFile], - parentFolderId: 'parent1', - currentFolderId: 'folder1', - isDeepFolder: false, - })(dispatch, getState as () => RootState, {}); - - expect(castErrorSpy).toHaveBeenCalledWith(randomError); - expect(showNotificationSpy).toHaveBeenCalledWith( - expect.objectContaining({ - requestId: 'test-request-id', - }), - ); - }); -}); - -describe('Upload items in parallel thunk', () => { - const dispatch = vi.fn(); - - const mockFile = new File(['content'], 'file.txt', { type: 'text/plain' }); - - beforeEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - }); - - test('when there is an error while uploading items in parallel, then a notification is shown', async () => { - const randomError = new AppError('Test Error', undefined, undefined, { - 'x-request-id': 'test-request-id', - }); - const castErrorSpy = vi.spyOn(errorService, 'castError'); - const showNotificationSpy = vi.spyOn(notificationsService, 'show'); - (prepareFilesToUpload as Mock).mockResolvedValue({ - filesToUpload: [mockFile], - }); - (uploadFilesWithTasks as Mock).mockRejectedValue(randomError); - - await uploadItemsParallelThunk({ - files: [mockFile], - parentFolderId: 'parent1', - })(dispatch, getState as () => RootState, {}); - - expect(castErrorSpy).toHaveBeenCalledWith(randomError); - expect(showNotificationSpy).toHaveBeenCalledWith( - expect.objectContaining({ - requestId: 'test-request-id', - }), - ); - }); - - test('When all files exceed the size limit, then the thunk throws without attempting the upload', async () => { - const bigFile = new File([new ArrayBuffer(200)], 'big.mp4'); - const getStateWithLimit = () => ({ - user: { user: { email: 'test@test.com' } }, - fileVersions: { limits: { maxUploadFileSize: 100 } }, - }); - - await uploadItemsParallelThunk({ - files: [bigFile], - parentFolderId: 'parent1', - })(dispatch, getStateWithLimit as () => RootState, {}); - - expect(prepareFilesToUpload).not.toHaveBeenCalled(); - expect(uploadFilesWithTasks).not.toHaveBeenCalled(); - }); - - test('When some files exceed the size limit and some do not, then only the allowed files are uploaded', async () => { - const smallFile = new File(['x'], 'small.txt'); - const bigFile = new File([new ArrayBuffer(200)], 'big.mp4'); - const getStateWithLimit = () => ({ - user: { user: { email: 'test@test.com' } }, - fileVersions: { limits: { maxUploadFileSize: 100 } }, - }); - (prepareFilesToUpload as Mock).mockResolvedValue({ filesToUpload: [smallFile] }); - - await uploadItemsParallelThunk({ - files: [smallFile, bigFile], - parentFolderId: 'parent1', - })(dispatch, getStateWithLimit as () => RootState, {}); - - expect(prepareFilesToUpload).toHaveBeenCalledWith(expect.objectContaining({ files: [smallFile] })); - }); -}); - -describe('uploadItemsThunkExtraReducers', () => { - const sampleFile = { taskId: 'task1' }; - - beforeEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - }); - - it('should handle rejected case and call RetryManager and notificationsService', () => { - const notificationsServiceSpy = vi.spyOn(notificationsService, 'show'); - const cases = new Map(); - const builder = { - addCase: (action, reducer) => { - cases.set(action, reducer); - return builder; - }, - }; - - uploadItemsThunkExtraReducers(builder as unknown as ActionReducerMapBuilder); - - expect(cases.has(uploadItemsParallelThunk.pending)).toBe(true); - expect(cases.has(uploadItemsParallelThunk.fulfilled)).toBe(true); - expect(cases.has(uploadItemsParallelThunk.rejected)).toBe(true); - - const rejectedHandler = cases.get(uploadItemsParallelThunk.rejected); - const RetryIsRetryingFileSpy = vi.spyOn(RetryManager, 'isRetryingTask'); - const RetryChangeStatusSpy = vi.spyOn(RetryManager, 'changeStatus'); - - RetryManager.addTask({ - type: RetryableTaskType.Upload, - taskId: sampleFile.taskId ?? 'task1', - params: sampleFile, - }); - - const state = {}; - const action = { - meta: { - arg: { - taskId: 'task1', - options: { showErrors: true }, - }, - }, - error: { message: 'Upload failed' }, - }; - - rejectedHandler(state, action); - - expect(RetryIsRetryingFileSpy).toHaveBeenCalledWith('task1'); - expect(RetryChangeStatusSpy).toHaveBeenCalledWith('task1', 'failed'); - expect(notificationsServiceSpy).toHaveBeenCalledWith({ - text: expect.stringContaining('Upload failed'), - type: ToastType.Error, - }); - }); - - it('should not leak a failed request options into later dispatches through DEFAULT_OPTIONS', () => { - const notificationsServiceSpy = vi.spyOn(notificationsService, 'show'); - const cases = new Map(); - const builder = { - addCase: (action, reducer) => { - cases.set(action, reducer); - return builder; - }, - }; - - uploadItemsThunkExtraReducers(builder as unknown as ActionReducerMapBuilder); - const rejectedHandler = cases.get(uploadItemsParallelThunk.rejected); - - rejectedHandler( - {}, - { - meta: { arg: { options: { showErrors: false, relatedTaskId: 'folder-task-id' } } }, - error: { message: 'folder upload failed' }, - }, - ); - expect(notificationsServiceSpy).not.toHaveBeenCalled(); - - rejectedHandler({}, { meta: { arg: {} }, error: { message: 'other upload failed' } }); - expect(notificationsServiceSpy).toHaveBeenCalledOnce(); - }); - - it('should handle rejected case and not call RetryManager if file is not retrying', () => { - const notificationsServiceSpy = vi.spyOn(notificationsService, 'show'); - const cases = new Map(); - const builder = { - addCase: (action, reducer) => { - cases.set(action, reducer); - return builder; - }, - }; - - uploadItemsThunkExtraReducers(builder as unknown as ActionReducerMapBuilder); - - expect(cases.has(uploadItemsParallelThunk.pending)).toBe(true); - expect(cases.has(uploadItemsParallelThunk.fulfilled)).toBe(true); - expect(cases.has(uploadItemsParallelThunk.rejected)).toBe(true); - - const rejectedHandler = cases.get(uploadItemsParallelThunk.rejected); - const RetryIsRetryingFileSpy = vi.spyOn(RetryManager, 'isRetryingTask'); - const RetryChangeStatusSpy = vi.spyOn(RetryManager, 'changeStatus'); - - const state = {}; - const action = { - meta: { - arg: { - taskId: 'task1', - options: { showErrors: true }, - }, - }, - error: { message: 'Test Error' }, - }; - - rejectedHandler(state, action); - - expect(RetryIsRetryingFileSpy).toHaveBeenCalledWith('task1'); - expect(RetryChangeStatusSpy).toHaveBeenCalledWith('task1', 'failed'); - expect(notificationsServiceSpy).toHaveBeenCalledWith({ - text: expect.stringContaining('Test Error'), - type: ToastType.Error, - }); - }); -}); diff --git a/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.ts b/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.ts deleted file mode 100644 index dcaa5e6a29..0000000000 --- a/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.ts +++ /dev/null @@ -1,558 +0,0 @@ -import { items as itemUtils } from '@internxt/lib'; -import { SharedFiles } from '@internxt/sdk/dist/drive/share/types'; -import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; - -import { ActionReducerMapBuilder, AnyAction, createAsyncThunk, ThunkDispatch } from '@reduxjs/toolkit'; - -import { renameFile } from 'app/crypto/services/utils'; -import { DriveFileData, DriveItemData, ExceededFile } from 'app/drive/types'; -import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; - -import { t } from 'i18next'; - -import { storageActions } from '..'; -import { RootState } from '../../..'; -import errorService from 'services/error.service'; -import workspacesService from 'services/workspace.service'; -import { uploadFilesWithTasks } from 'app/tasks/upload/uploadFilesWithTasks'; -import DatabaseUploadRepository from '../../../../repositories/DatabaseUploadRepository'; -import shareService from '../../../../share/services/share.service'; -import { planSelectors, planThunks } from '../../plan'; -import { uiActions } from '../../ui'; -import workspacesSelectors from '../../workspaces/workspaces.selectors'; - -import RetryManager, { RetryableTaskStatus } from 'app/network/RetryManager'; -import { FileToUpload } from 'app/drive/services/file.service/types'; -import { prepareFilesToUpload } from '../fileUtils/prepareFilesToUpload'; -import { StorageState } from '../storage.model'; -import { AppError } from '@internxt/sdk'; -import { filterFilesByMaxSize } from '../fileUtils/filterFilesByMaxSize'; -import { MAX_ALLOWED_UPLOAD_SIZE } from 'app/drive/services/network.service'; -import { fileVersionsSelectors } from '../../fileVersions'; - -interface UploadItemsThunkOptions { - relatedTaskId: string; - showNotifications: boolean; - showErrors: boolean; - abortController?: AbortController; - onSuccess: () => void; - isRetriedUpload?: boolean; - disableDuplicatedNamesCheck?: boolean; - disableExistenceCheck?: boolean; - isUploadedFromFolder?: boolean; - notUploadHiddenFiles?: boolean; -} - -interface UploadItemsPayload { - files: File[]; - taskId?: string; - fileType?: string; - parentFolderId: string; - options?: Partial; - filesProgress?: { filesUploaded: number; totalFilesToUpload: number }; - isRetry?: boolean; - onFileUploadCallback?: (driveFileData: DriveFileData) => void; -} - -const DEFAULT_OPTIONS: Partial = { - showNotifications: true, - showErrors: true, -}; - -const openReachedFileSizeLimitDialog = ( - exceededFiles: ExceededFile[], - dispatch: ThunkDispatch, -) => { - dispatch( - uiActions.setOpenFileSizeLimitReachedDialog({ - open: true, - info: { - exceededFiles, - }, - }), - ); -}; - -const validateFileSize = ( - dispatch: ThunkDispatch, - files: File[], - maxUploadFileSize = MAX_ALLOWED_UPLOAD_SIZE, -): File[] => { - const { allowedFilesToUpload, exceededFiles } = filterFilesByMaxSize({ files, maxUploadFileSize }); - - if (exceededFiles.length > 0) { - openReachedFileSizeLimitDialog(exceededFiles, dispatch); - } - - return allowedFilesToUpload; -}; - -const notifyMaxSpaceOccupied = (state: RootState, dispatch: ThunkDispatch) => { - const planLimit = planSelectors.planLimitToShow(state); - const planUsage = planSelectors.planUsageToShow(state); - const isStorageActuallyFull = !!planLimit && planUsage >= planLimit; - - if (isStorageActuallyFull) { - dispatch( - uiActions.setOpenReachedPlanLimitDialog({ - open: true, - }), - ); - } -}; - -const isUploadAllowed = ({ - state, - files, - dispatch, -}: { - state: RootState; - files: File[]; - dispatch: ThunkDispatch; -}): boolean => { - try { - const planLimit = planSelectors.planLimitToShow(state); - const planUsage = planSelectors.planUsageToShow(state); - const uploadItemsSize = Object.values(files).reduce((acum, file) => acum + file.size, 0); - - const totalItemsSize = uploadItemsSize + planUsage; - const isPlanSizeLimitExceeded = planLimit && totalItemsSize >= planLimit; - - if (isPlanSizeLimitExceeded) { - dispatch( - uiActions.setOpenReachedPlanLimitDialog({ - open: true, - }), - ); - return false; - } - } catch (err: unknown) { - errorService.reportError(err); - } - - return true; -}; - -/** - * @description - * 1. Prepare files to upload - * 2. Schedule tasks - */ -export const uploadItemsThunk = createAsyncThunk( - 'storage/uploadItems', - async ( - { files, parentFolderId, options: payloadOptions, taskId, fileType, isRetry = false }: UploadItemsPayload, - { getState, dispatch }, - ) => { - const user = getState().user.user as UserSettings; - const maxUploadFileSize = fileVersionsSelectors.getMaxFileSizeLimit(getState()); - const errors: AppError[] = []; - const options = { ...DEFAULT_OPTIONS, ...payloadOptions }; - - const state = getState(); - const selectedWorkspace = workspacesSelectors.getSelectedWorkspace(state); - const workspaceCredentials = workspacesSelectors.getWorkspaceCredentials(state); - const memberId = selectedWorkspace?.workspaceUser?.memberId; - - let ownerUserAuthenticationData: any = null; - const workspaceId = selectedWorkspace?.workspace?.id; - if (workspaceId) { - ownerUserAuthenticationData = { - bridgeUser: workspaceCredentials?.credentials.networkUser, - bridgePass: workspaceCredentials?.credentials.networkPass, - encryptionKey: selectedWorkspace?.workspaceUser?.key, - bucketId: workspaceCredentials?.bucket, - workspaceId: workspaceId, - workspacesToken: workspaceCredentials?.tokenHeader, - }; - } - - const markRetryAsFailed = () => { - if (taskId && isRetry) RetryManager.changeStatus(taskId, RetryableTaskStatus.Failed); - }; - - const allowedFilesToUpload = validateFileSize(dispatch, files, maxUploadFileSize); - - if (allowedFilesToUpload.length === 0) { - markRetryAsFailed(); - return; - } - - const continueWithUpload = isUploadAllowed({ - state: getState(), - files: allowedFilesToUpload, - dispatch, - }); - if (!continueWithUpload) { - markRetryAsFailed(); - return; - } - - const { filesToUpload } = await prepareFilesToUpload({ - files: allowedFilesToUpload, - parentFolderId, - disableDuplicatedNamesCheck: options.disableDuplicatedNamesCheck, - fileType, - }); - - const filesToUploadData = filesToUpload.map((file) => ({ - filecontent: file, - fileType, - userEmail: user.email, - parentFolderId, - taskId, - relatedTaskId: options?.relatedTaskId, - onFinishUploadFile: (driveItemData: DriveFileData, taskId: string) => { - const uploadRespository = DatabaseUploadRepository.getInstance(); - uploadRespository.removeUploadState(taskId); - - dispatch( - storageActions.pushItems({ - updateRecents: true, - folderIds: [parentFolderId], - items: [driveItemData as DriveItemData], - }), - ); - }, - abortController: new AbortController(), - })); - - const openFileSizeLimitDialog = () => openReachedFileSizeLimitDialog(allowedFilesToUpload, dispatch); - - try { - await uploadFilesWithTasks({ - files: filesToUploadData, - maxSpaceOccupiedCallback: () => notifyMaxSpaceOccupied(getState(), dispatch), - fileSizeExceededCallback: openFileSizeLimitDialog, - uploadRepository: DatabaseUploadRepository.getInstance(), - options: { - ownerUserAuthenticationData: ownerUserAuthenticationData ?? undefined, - sharedItemData: { - isDeepFolder: false, - currentFolderId: parentFolderId, - }, - }, - }); - } catch (error) { - markRetryAsFailed(); - errors.push(errorService.castError(error)); - } - - options.onSuccess?.(); - - setTimeout(() => { - dispatch(planThunks.fetchUsageThunk()); - if (memberId) dispatch(planThunks.fetchBusinessLimitUsageThunk()); - }, 1000); - - if (errors.length > 0) { - for (const error of errors) { - if (error.message) { - console.error('message Error when upload', error.message); - notificationsService.show({ text: error.message, type: ToastType.Error, requestId: error.requestId }); - } - } - } - }, -); - -interface UploadSharedItemsPayload extends UploadItemsPayload { - currentFolderId: string; - ownerUserAuthenticationData?: { - token: string; - bridgeUser: string; - bridgePass: string; - encryptionKey: string; - bucketId: string; - }; - isDeepFolder: boolean; -} -/** - * @description - * 1. Prepare files to upload - * 2. Schedule tasks - */ -export const uploadSharedItemsThunk = createAsyncThunk( - 'storage/uploadItems', - async ( - { - files, - taskId, - fileType, - parentFolderId, - options: payloadOptions, - ownerUserAuthenticationData, - currentFolderId, - isDeepFolder, - }: UploadSharedItemsPayload, - { getState, dispatch }, - ) => { - const state = getState(); - const user = state.user.user as UserSettings; - const filesToUpload: FileToUpload[] = []; - const errors: AppError[] = []; - - const selectedWorkspace = workspacesSelectors.getSelectedWorkspace(state); - const workspaceCredentials = workspacesSelectors.getWorkspaceCredentials(state); - - const workspaceId = selectedWorkspace?.workspace.id; - const memberId = selectedWorkspace?.workspaceUser?.memberId; - const teamId = selectedWorkspace?.workspace.defaultTeamId; - const options = { ...DEFAULT_OPTIONS, ...payloadOptions }; - - for (const file of files) { - if (file.size === 0) { - continue; - } - - const { filename, extension } = itemUtils.getFilenameAndExt(file.name); - - let page = 0; - const offset = 50; - let allItems: SharedFiles[] = []; - - let hasMoreItems = true; - - while (hasMoreItems) { - let parentFolderContent; - if (workspaceId && teamId) { - const [promise] = workspacesService.getAllWorkspaceTeamSharedFolderFiles( - workspaceId, - currentFolderId, - page, - offset, - isDeepFolder ? (ownerUserAuthenticationData?.token ?? '') : '', - ); - const response = await promise; - parentFolderContent = response; - } else { - parentFolderContent = await shareService.getSharedFolderContent( - currentFolderId, - 'files', - isDeepFolder ? (ownerUserAuthenticationData?.token ?? '') : '', - page, - offset, - ); - } - - const parentFolderFiles = parentFolderContent.items; - - if (parentFolderFiles.length === 0 || parentFolderFiles.length < offset) hasMoreItems = false; - - allItems = allItems.concat(parentFolderFiles as SharedFiles[]); - page++; - } - - const [, , finalFilename] = itemUtils.renameIfNeeded( - allItems.map((item) => ({ ...item, name: item.plainName })), - filename, - extension, - ); - const fileContent = renameFile(file, finalFilename); - - filesToUpload.push({ - name: finalFilename, - size: file.size, - type: fileType ?? extension, - content: fileContent, - parentFolderId, - }); - } - - let ownerUserAuthenticationDataForWorkspaces: any = null; - if (workspaceId) { - ownerUserAuthenticationDataForWorkspaces = { - bridgeUser: workspaceCredentials?.credentials.networkUser, - bridgePass: workspaceCredentials?.credentials.networkPass, - encryptionKey: selectedWorkspace?.workspaceUser?.key, - bucketId: workspaceCredentials?.bucket, - workspaceId: workspaceId, - workspacesToken: workspaceCredentials?.tokenHeader, - resourcesToken: ownerUserAuthenticationData?.token, - }; - } - const filesToUploadData = filesToUpload.map((file) => ({ - filecontent: file, - fileType: file.type, - userEmail: user.email, - taskId, - relatedTaskId: options?.relatedTaskId, - parentFolderId, - onFinishUploadFile: (driveItemData: DriveFileData, taskId: string) => { - const uploadRespository = DatabaseUploadRepository.getInstance(); - uploadRespository.removeUploadState(taskId); - dispatch( - storageActions.pushItems({ - updateRecents: true, - folderIds: [parentFolderId], - items: [driveItemData as DriveItemData], - }), - ); - }, - abortController: new AbortController(), - })); - - const openMaxSpaceOccupiedDialog = () => - dispatch( - uiActions.setOpenReachedPlanLimitDialog({ - open: true, - info: { - title: t('error.ownerStorageIsFull'), - description: t('error.ownerStorageIsFullDescription'), - hidePrimaryAction: true, - }, - }), - ); - - try { - await uploadFilesWithTasks({ - files: filesToUploadData, - maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialog, - uploadRepository: DatabaseUploadRepository.getInstance(), - options: { - ownerUserAuthenticationData: ownerUserAuthenticationDataForWorkspaces ?? ownerUserAuthenticationData, - sharedItemData: { - isDeepFolder, - currentFolderId, - }, - }, - }); - } catch (error) { - errors.push(errorService.castError(error)); - } - - options.onSuccess?.(); - - setTimeout(() => { - dispatch(planThunks.fetchUsageThunk()); - if (memberId) dispatch(planThunks.fetchBusinessLimitUsageThunk()); - }, 1000); - - if (errors.length > 0) { - for (const error of errors) { - if (error.message) - notificationsService.show({ text: error.message, type: ToastType.Error, requestId: error.requestId }); - } - } - }, -); - -/** - * @description - * 1. Prepare files to upload - * 2. Schedule tasks - */ -export const uploadItemsParallelThunk = createAsyncThunk( - 'storage/uploadItems', - async ( - { files, parentFolderId, options: payloadOptions, filesProgress, onFileUploadCallback }: UploadItemsPayload, - { getState, dispatch }, - ) => { - const state = getState(); - const user = state.user.user as UserSettings; - const maxFileSize = fileVersionsSelectors.getMaxFileSizeLimit(state); - const workspaceCredentials = workspacesSelectors.getWorkspaceCredentials(state); - const selectedWorkspace = workspacesSelectors.getSelectedWorkspace(state); - const errors: AppError[] = []; - const abortController = payloadOptions?.abortController ?? new AbortController(); - - const options = { ...DEFAULT_OPTIONS, ...payloadOptions }; - - // TODO: REMOVE ANY AND ADD TYPE - let ownerUserAuthenticationData: any = null; - const workspaceId = selectedWorkspace?.workspace?.id; - if (workspaceId) { - ownerUserAuthenticationData = { - bridgeUser: workspaceCredentials?.credentials.networkUser, - bridgePass: workspaceCredentials?.credentials.networkPass, - encryptionKey: selectedWorkspace?.workspaceUser?.key, - bucketId: workspaceCredentials?.bucket, - workspaceId: workspaceId, - workspacesToken: workspaceCredentials?.tokenHeader, - }; - } - - const allowedFilesToUpload = validateFileSize(dispatch, files, maxFileSize); - - if (allowedFilesToUpload.length === 0) { - return; - } - - const { filesToUpload } = await prepareFilesToUpload({ - files: allowedFilesToUpload, - parentFolderId, - disableDuplicatedNamesCheck: options.disableDuplicatedNamesCheck, - disableExistenceCheck: options.disableExistenceCheck, - notUploadHiddenFiles: options.notUploadHiddenFiles, - }); - - const filesToUploadData = filesToUpload.map((file) => ({ - filecontent: file, - userEmail: user.email, - parentFolderId, - relatedTaskId: options?.relatedTaskId, - // TODO: EXTRACT WHEN MANAGE UPLOAD TASK IS MERGED - onFinishUploadFile: (driveItemData: DriveFileData) => { - dispatch( - storageActions.pushItems({ - updateRecents: true, - folderIds: [parentFolderId], - items: [driveItemData as DriveItemData], - }), - ); - }, - })); - - const openFileSizeLimitDialog = () => openReachedFileSizeLimitDialog(allowedFilesToUpload, dispatch); - - try { - await uploadFilesWithTasks({ - files: filesToUploadData, - maxSpaceOccupiedCallback: () => notifyMaxSpaceOccupied(getState(), dispatch), - fileSizeExceededCallback: openFileSizeLimitDialog, - uploadRepository: DatabaseUploadRepository.getInstance(), - abortController, - options: { - ...options, - ownerUserAuthenticationData: ownerUserAuthenticationData ?? undefined, - sharedItemData: { - isDeepFolder: false, - currentFolderId: parentFolderId, - }, - }, - relatedTaskProgress: filesProgress, - onFileUploadCallback, - }); - } catch (error) { - errors.push(errorService.castError(error)); - } - - options.onSuccess?.(); - - if (errors.length > 0) { - for (const error of errors) { - if (error.message) - notificationsService.show({ text: error.message, type: ToastType.Error, requestId: error.requestId }); - } - - throw new Error(t('error.uploadingItems') as string); - } - }, -); - -export const uploadItemsThunkExtraReducers = (builder: ActionReducerMapBuilder): void => { - builder - .addCase(uploadItemsParallelThunk.pending, () => undefined) - .addCase(uploadItemsParallelThunk.fulfilled, () => undefined) - .addCase(uploadItemsParallelThunk.rejected, (state, action) => { - const requestOptions = { ...DEFAULT_OPTIONS, ...(action.meta.arg.options ?? {}) }; - const taskId = action.meta.arg.taskId; - if (taskId && RetryManager.isRetryingTask(taskId)) RetryManager.changeStatus(taskId, RetryableTaskStatus.Failed); - if (requestOptions?.showErrors) { - notificationsService.show({ - text: t('error.uploadingFile', { reason: action.error.message ?? '' }), - type: ToastType.Error, - }); - } - }); -}; diff --git a/src/app/tasks/components/TaskLogger/TaskLogger.tsx b/src/app/tasks/components/TaskLogger/TaskLogger.tsx index ffcda104fa..566420631f 100644 --- a/src/app/tasks/components/TaskLogger/TaskLogger.tsx +++ b/src/app/tasks/components/TaskLogger/TaskLogger.tsx @@ -27,12 +27,9 @@ const TaskLogger = (): JSX.Element => { const filesToRetryGroupedByTask = useMemo( () => filesToRetry.reduce>((acc, file) => { - const relatedTaskId = file.params?.relatedTaskId ?? file.taskId; - if (relatedTaskId) { - if (!acc[relatedTaskId]) { - acc[relatedTaskId] = []; - } - acc[relatedTaskId].push(file); + if (file.taskId) { + acc[file.taskId] ??= []; + acc[file.taskId].push(file); } return acc; }, {}), diff --git a/src/app/tasks/components/TaskLoggerItem/TaskLoggerItem.test.tsx b/src/app/tasks/components/TaskLoggerItem/TaskLoggerItem.test.tsx index 6888edfcad..3dd8447bea 100644 --- a/src/app/tasks/components/TaskLoggerItem/TaskLoggerItem.test.tsx +++ b/src/app/tasks/components/TaskLoggerItem/TaskLoggerItem.test.tsx @@ -15,15 +15,6 @@ vi.mock('../../hooks/useOpen', () => ({ vi.mock('../../hooks/useRetry', () => ({ useRetryDownload: vi.fn().mockReturnValue({ retryDownload: vi.fn() }), - useRetryUpload: vi.fn().mockReturnValue({ retryUpload: vi.fn() }), -})); - -vi.mock('../../../store/slices/storage/hooks/useReduxActions', () => ({ - useReduxActions: vi.fn().mockReturnValue({ - uploadFolder: vi.fn(), - uploadItem: vi.fn(), - uploadSharedItem: vi.fn(), - }), })); vi.mock('app/store/slices/workspaces/workspaces.selectors', () => ({ @@ -55,18 +46,8 @@ describe('TaskLoggerItem', () => { const createFileToRetry = (): RetryableTask => ({ taskId: 'task-1', - type: RetryableTaskType.Upload, - params: { - filecontent: { - content: new File(['content'], 'file'), - type: 'txt', - name: 'file', - size: 1024, - parentFolderId: 'folder', - }, - parentFolderId: 'folder', - userEmail: 'user@example.com', - }, + type: RetryableTaskType.Download, + params: { id: 'file-id', name: 'file', plainName: 'file', type: 'txt', size: 1024, isFolder: false }, status: RetryableTaskStatus.Failed, }); diff --git a/src/app/tasks/components/TaskLoggerItem/TaskLoggerItem.tsx b/src/app/tasks/components/TaskLoggerItem/TaskLoggerItem.tsx index f75dff3b41..2260f44e0f 100644 --- a/src/app/tasks/components/TaskLoggerItem/TaskLoggerItem.tsx +++ b/src/app/tasks/components/TaskLoggerItem/TaskLoggerItem.tsx @@ -1,19 +1,18 @@ import { memo, useState } from 'react'; import { useSelector } from 'react-redux'; import { useOpenItem } from '../../hooks/useOpen'; -import { useRetryDownload, useRetryUpload } from '../../hooks/useRetry'; +import { useRetryDownload } from '../../hooks/useRetry'; import { t } from 'i18next'; import notificationsService, { ToastType } from '../../../notifications/services/notifications.service'; -import { useReduxActions } from '../../../store/slices/storage/hooks/useReduxActions'; import tasksService from '../../services/tasks.service'; -import { TaskData, TaskNotification, TaskStatus, TaskType, UploadFileData, UploadFolderData } from '../../types'; +import { TaskData, TaskNotification, TaskStatus, UploadFileData, UploadFolderData } from '../../types'; import { TaskLoggerActions } from '../TaskLoggerActions/TaskLoggerActions'; import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; import TaskToRetry from '../TaskToRetry/TaskToRetry'; import { useRetryManagerFailedItems } from '../TaskToRetry/useRetryManagerFailedItems'; import { FailedItemToRetry } from '../TaskToRetryItem/TaskToRetryItem'; -import { RetryableTask, RetryableTaskType } from 'app/network/RetryManager'; +import { RetryableTask } from 'app/network/RetryManager'; const THREE_HUNDRED_MB_IN_BYTES = 3 * 100 * 1024 * 1024; interface TaskLoggerItemProps { @@ -98,7 +97,6 @@ const TaskLoggerItem = ({ notification, task, filesToRetry }: TaskLoggerItemProp }, selectedWorkspace, }); - const { uploadFolder, uploadItem, uploadSharedItem } = useReduxActions(); const { retryDownload } = useRetryDownload({ notification, task, @@ -109,16 +107,6 @@ const TaskLoggerItem = ({ notification, task, filesToRetry }: TaskLoggerItemProp selectedWorkspace, workspaceCredentials, }); - const { retryUpload } = useRetryUpload({ - notification, - uploadFolder, - uploadItem, - uploadSharedItem, - showErrorNotification() { - notificationsService.show({ text: t('tasks.generalErrorMessages.retryUploadFailed'), type: ToastType.Error }); - }, - resetProgress: resetTaskProgress, - }); const progressInPercentage = notification.progress ? (notification.progress * 100).toFixed(0) : 0; const notExistProgress = notification.progress && notification.progress === Infinity; @@ -133,28 +121,14 @@ const TaskLoggerItem = ({ notification, task, filesToRetry }: TaskLoggerItemProp setIsHovered(false); }; - const isDownloadAction = - [TaskStatus.Error, TaskStatus.Cancelled].includes(notification.status) && - (notification.action === TaskType.DownloadFile || notification.action === TaskType.DownloadFolder); - const someFileIsDownloaded = filesToRetry?.some( - (file) => file.taskId === notification.taskId && file.type === RetryableTaskType.Download, - ); - - const isDownloadError = isDownloadAction || someFileIsDownloaded; - - const getRetryActionFunction = (isDownload: boolean) => { - return isDownload ? retryDownload : retryUpload; - }; - const retryTask = task?.retry; - const retryFunction = retryTask ? () => retryTask() : getRetryActionFunction(isDownloadError); + const retryFunction = retryTask ? () => retryTask() : retryDownload; const retryManagerFailedItems = useRetryManagerFailedItems(filesToRetry ?? []); const failedUploadItems = getFailedUploadItems(task); const hasFailedUploadItems = failedUploadItems.length > 0; const failedItems = hasFailedUploadItems ? failedUploadItems : retryManagerFailedItems; const hasFailedItems = failedItems.length > 0; - const isUploadRetry = hasFailedUploadItems || filesToRetry?.[0]?.type === RetryableTaskType.Upload; const retryAllFailedUploadItems = hasFailedUploadItems ? () => task?.retry?.() : undefined; const handleRetryClick = () => { @@ -181,7 +155,7 @@ const TaskLoggerItem = ({ notification, task, filesToRetry }: TaskLoggerItemProp setIsModalOpen(false)} onRetryAll={retryAllFailedUploadItems} /> diff --git a/src/app/tasks/components/TaskToRetry/TaskToRetry.test.tsx b/src/app/tasks/components/TaskToRetry/TaskToRetry.test.tsx index 4381442607..74506d1ace 100644 --- a/src/app/tasks/components/TaskToRetry/TaskToRetry.test.tsx +++ b/src/app/tasks/components/TaskToRetry/TaskToRetry.test.tsx @@ -29,12 +29,6 @@ vi.mock('app/i18n/provider/TranslationProvider', () => ({ useTranslationContext: vi.fn().mockReturnValue({ translate: (value: string) => value }), })); -const mockUnwrapRetryUpload = vi.hoisted(() => vi.fn()); -const mockUploadRetryItem = vi.hoisted(() => vi.fn()); -vi.mock('app/store/slices/storage/hooks/useReduxActions', () => ({ - useReduxActions: vi.fn().mockReturnValue({ uploadRetryItem: mockUploadRetryItem }), -})); - const aFailedItem = (name: string, overrides: Partial = {}): FailedItemToRetry => ({ name, type: 'txt', @@ -159,51 +153,25 @@ describe('Retrying the failed items kept in the retry manager', () => { const RetryManagerDialog = ({ files }: { files: RetryableTask[] }) => { const failedItems = useRetryManagerFailedItems(files); - return ; + return ; }; const mockChangeStatus = vi.spyOn(RetryManager, 'changeStatus'); - const createUploadTask = (name: string, type: string): RetryableTask => ({ - taskId: 'task-1', - type: RetryableTaskType.Upload, - params: { - filecontent: { - content: new File(['file-content'], name), - type, - name, - size: 1024, - parentFolderId: 'folder-1', - }, - parentFolderId: 'folder-1', - taskId: 'task-1', - userEmail: 'user@example.com', - }, - status: RetryableTaskStatus.Failed, - }); - - const retryUpload = async (task: RetryableTask): Promise => { - const { getByTestId } = render(); - fireEvent.click(getByTestId('task-logger-button')); - await vi.waitFor(() => expect(mockUploadRetryItem).toHaveBeenCalled()); - return mockUploadRetryItem.mock.calls[0][0].uploadFile; - }; - const getRetryAllButton = (container: HTMLElement): HTMLElement | null => container.querySelector('[data-cy="retry-all-taskRetry-button"]'); - const createUploadTaskWithId = (taskId: string, overrides: Partial = {}): RetryableTask => { - const task = createUploadTask(`file-${taskId}`, 'txt'); - return { ...task, taskId, params: { ...task.params, taskId }, ...overrides }; - }; - - const createDownloadTaskWithId = (taskId: string): RetryableTask => ({ + const createDownloadTaskWithId = (taskId: string, overrides: Partial = {}): RetryableTask => ({ taskId, type: RetryableTaskType.Download, - params: { taskId }, + params: { taskId, id: `${taskId}-file` }, status: RetryableTaskStatus.Failed, + ...overrides, }); + const expectRetrying = (taskId: string) => + expect(mockChangeStatus).toHaveBeenCalledWith(taskId, RetryableTaskStatus.Retrying, { id: `${taskId}-file` }); + const createDeferred = (): { promise: Promise; resolve: () => void } => { let resolve: () => void = () => undefined; const promise = new Promise((res) => { @@ -222,12 +190,9 @@ describe('Retrying the failed items kept in the retry manager', () => { return deferredByFile; }; - const files: RetryableTask[] = [createUploadTask('file', 'txt')]; - beforeEach(() => { vi.clearAllMocks(); - mockUnwrapRetryUpload.mockResolvedValue(undefined); - mockUploadRetryItem.mockReturnValue({ unwrap: mockUnwrapRetryUpload }); + mockDownloadItem.mockResolvedValue(undefined); }); afterEach(async () => { @@ -235,32 +200,6 @@ describe('Retrying the failed items kept in the retry manager', () => { retryQueue.concurrency = MAX_CONCURRENT_UPLOADS; }); - test('when a failed upload is retried, then uploadRetryItem is called and status updates to Retrying', async () => { - const { getByTestId } = render(); - - const downloadItem = getByTestId('task-logger-button'); - - if (downloadItem) fireEvent.click(downloadItem); - - expect(mockChangeStatus).toHaveBeenCalledWith('task-1', RetryableTaskStatus.Retrying); - await vi.waitFor(() => expect(mockUploadRetryItem).toHaveBeenCalled()); - expect(mockUploadRetryItem).toHaveBeenCalledWith({ - uploadFile: expect.any(File), - parentFolderId: 'folder-1', - taskId: 'task-1', - fileType: 'txt', - }); - }); - - test('when the retried upload is rejected, then status goes back to Failed', async () => { - mockUnwrapRetryUpload.mockRejectedValueOnce(new Error('Upload failed')); - - await retryUpload(files[0]); - - expect(mockChangeStatus).toHaveBeenCalledWith('task-1', RetryableTaskStatus.Retrying); - await vi.waitFor(() => expect(mockChangeStatus).toHaveBeenCalledWith('task-1', RetryableTaskStatus.Failed)); - }); - test('when a failed download item is retried, then only that item changes status', async () => { const downloadTask = { ...createDownloadTaskWithId('download-1'), params: { taskId: 'download-1', id: 'file-a' } }; mockDownloadItem.mockResolvedValueOnce(undefined); @@ -275,7 +214,7 @@ describe('Retrying the failed items kept in the retry manager', () => { }); test('when there are failed, retryable files, then the retry all button is rendered', () => { - const retryableFiles = [createUploadTaskWithId('a'), createUploadTaskWithId('b')]; + const retryableFiles = [createDownloadTaskWithId('a'), createDownloadTaskWithId('b')]; const { container } = render(); @@ -284,8 +223,8 @@ describe('Retrying the failed items kept in the retry manager', () => { test('when there are no eligible files, then the retry all button is not rendered', () => { const noEligibleFiles = [ - createUploadTaskWithId('a', { status: RetryableTaskStatus.Retrying }), - createUploadTaskWithId('b', { retryable: false }), + createDownloadTaskWithId('a', { status: RetryableTaskStatus.Retrying }), + createDownloadTaskWithId('b', { retryable: false }), ]; const { container } = render(); @@ -294,45 +233,45 @@ describe('Retrying the failed items kept in the retry manager', () => { }); test('when retry all is clicked, then every failed and retryable file is retried', async () => { - const retryableFiles = [createUploadTaskWithId('a'), createUploadTaskWithId('b')]; + const retryableFiles = [createDownloadTaskWithId('a'), createDownloadTaskWithId('b')]; const { container } = render(); fireEvent.click(getRetryAllButton(container) as HTMLElement); - expect(mockChangeStatus).toHaveBeenCalledWith('a', RetryableTaskStatus.Retrying); - expect(mockChangeStatus).toHaveBeenCalledWith('b', RetryableTaskStatus.Retrying); - await vi.waitFor(() => expect(mockUploadRetryItem).toHaveBeenCalledTimes(2)); + expectRetrying('a'); + expectRetrying('b'); + await vi.waitFor(() => expect(mockDownloadItem).toHaveBeenCalledTimes(2)); }); test('when retry all is clicked, then files that are not failed or not retryable are skipped', async () => { const mixedFiles = [ - createUploadTaskWithId('a'), - createUploadTaskWithId('b', { status: RetryableTaskStatus.Retrying }), - createUploadTaskWithId('c', { retryable: false }), + createDownloadTaskWithId('a'), + createDownloadTaskWithId('b', { status: RetryableTaskStatus.Retrying }), + createDownloadTaskWithId('c', { retryable: false }), ]; const { container } = render(); fireEvent.click(getRetryAllButton(container) as HTMLElement); - expect(mockChangeStatus).toHaveBeenCalledWith('a', RetryableTaskStatus.Retrying); - expect(mockChangeStatus).not.toHaveBeenCalledWith('b', RetryableTaskStatus.Retrying); - expect(mockChangeStatus).not.toHaveBeenCalledWith('c', RetryableTaskStatus.Retrying); - await vi.waitFor(() => expect(mockUploadRetryItem).toHaveBeenCalledTimes(1)); + expectRetrying('a'); + expect(mockChangeStatus).not.toHaveBeenCalledWith('b', RetryableTaskStatus.Retrying, { id: 'b-file' }); + expect(mockChangeStatus).not.toHaveBeenCalledWith('c', RetryableTaskStatus.Retrying, { id: 'c-file' }); + await vi.waitFor(() => expect(mockDownloadItem).toHaveBeenCalledTimes(1)); }); test('when retry all is clicked, then every eligible file becomes Retrying immediately, regardless of the concurrency limit', () => { const totalFiles = MAX_CONCURRENT_UPLOADS + 2; - const retryableFiles = Array.from({ length: totalFiles }, (_, index) => createUploadTaskWithId(`upload-${index}`)); + const retryableFiles = Array.from({ length: totalFiles }, (_, index) => + createDownloadTaskWithId(`download-${index}`), + ); const { container } = render(); fireEvent.click(getRetryAllButton(container) as HTMLElement); - retryableFiles.forEach(({ taskId }) => { - expect(mockChangeStatus).toHaveBeenCalledWith(taskId, RetryableTaskStatus.Retrying); - }); + retryableFiles.forEach(({ taskId }) => expectRetrying(taskId)); }); const clickRetryAllOnce = (downloadTasks: RetryableTask[]): void => { @@ -383,26 +322,6 @@ describe('Retrying the failed items kept in the retry manager', () => { }); }); - test('when retry all retries uploads, then the concurrency cap waits for each upload to finish before starting the next', async () => { - const totalFiles = MAX_CONCURRENT_UPLOADS + 1; - const deferredByFile = wireSequentialDeferreds(mockUnwrapRetryUpload, totalFiles); - - const uploadTasks = Array.from({ length: totalFiles }, (_, index) => createUploadTaskWithId(`upload-${index}`)); - - const { container } = render(); - - fireEvent.click(getRetryAllButton(container) as HTMLElement); - - await vi.waitFor(() => expect(mockUploadRetryItem).toHaveBeenCalledTimes(MAX_CONCURRENT_UPLOADS)); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mockUploadRetryItem).toHaveBeenCalledTimes(MAX_CONCURRENT_UPLOADS); - - deferredByFile[0].resolve(); - - await vi.waitFor(() => expect(mockUploadRetryItem).toHaveBeenCalledTimes(totalFiles)); - deferredByFile.forEach(({ resolve }) => resolve()); - }); - test('when memory usage is high, then retry all ramps concurrency down and runs fewer than 6 at a time', async () => { const getConcurrencySpy = vi.spyOn(QueueUtilsService.instance, 'getConcurrencyUsingPerformance').mockReturnValue(1); diff --git a/src/app/tasks/components/TaskToRetry/useRetryManagerFailedItems.test.ts b/src/app/tasks/components/TaskToRetry/useRetryManagerFailedItems.test.ts index e34c754b4b..78fb1c3c39 100644 --- a/src/app/tasks/components/TaskToRetry/useRetryManagerFailedItems.test.ts +++ b/src/app/tasks/components/TaskToRetry/useRetryManagerFailedItems.test.ts @@ -1,6 +1,7 @@ import { renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import RetryManager, { RetryableTask, RetryableTaskStatus, RetryableTaskType } from 'app/network/RetryManager'; +import { DownloadManager } from 'app/network/DownloadManager'; import { useRetryManagerFailedItems } from './useRetryManagerFailedItems'; vi.mock('app/store/hooks', () => ({ useAppSelector: vi.fn() })); @@ -9,85 +10,45 @@ vi.mock('app/store/slices/workspaces/workspaces.selectors', () => ({ default: { getSelectedWorkspace: vi.fn(), getWorkspaceCredentials: vi.fn() }, })); -const mockUploadRetryItem = vi.hoisted(() => vi.fn()); -vi.mock('app/store/slices/storage/hooks/useReduxActions', () => ({ - useReduxActions: () => ({ uploadRetryItem: mockUploadRetryItem }), -})); - -const aFailedUpload = (name: string, type: string): RetryableTask => ({ - taskId: 'task-1', - type: RetryableTaskType.Upload, +const aFailedDownload = (): RetryableTask => ({ + taskId: 'task-2', + type: RetryableTaskType.Download, params: { - filecontent: { content: new File(['content'], name), type, name, size: 1024, parentFolderId: 'folder-1' }, - parentFolderId: 'folder-1', - taskId: 'task-1', - userEmail: 'user@example.com', + id: 'file-id', + plainName: 'Report', + name: 'encrypted', + type: 'pdf', + size: 10, + updatedAt: '2025-01-09', + isFolder: false, }, status: RetryableTaskStatus.Failed, }); -const retryFirstFailedItem = (task: RetryableTask) => { - const { result } = renderHook(() => useRetryManagerFailedItems([task])); - result.current[0].retry(); -}; - -describe('Failed items kept by the retry manager', () => { +describe('Failed downloads kept by the retry manager', () => { beforeEach(() => { vi.clearAllMocks(); vi.spyOn(RetryManager, 'changeStatus'); - mockUploadRetryItem.mockReturnValue({ unwrap: async () => undefined }); - }); - - test('When a failed upload is retried, then it is uploaded again into its folder and shown as retrying', async () => { - retryFirstFailedItem(aFailedUpload('file', 'txt')); - - expect(RetryManager.changeStatus).toHaveBeenCalledWith('task-1', RetryableTaskStatus.Retrying); - await vi.waitFor(() => expect(mockUploadRetryItem).toHaveBeenCalled()); - expect(mockUploadRetryItem).toHaveBeenCalledWith({ - uploadFile: expect.any(File), - parentFolderId: 'folder-1', - taskId: 'task-1', - fileType: 'txt', - }); + vi.mocked(DownloadManager.downloadItem).mockResolvedValue(undefined as never); }); - test.each([ - { name: 'Screenshot 2026-10-12 at 11.48.02 PM', type: 'png', expected: 'Screenshot 2026-10-12 at 11.48.02 PM.png' }, - { name: 'Makefile', type: '', expected: 'Makefile' }, - ])('When a file named "$name" is retried, then it is uploaded with its full original name', async (scenario) => { - retryFirstFailedItem(aFailedUpload(scenario.name, scenario.type)); + test('When a download failed, then it is shown with its decrypted name', () => { + const { result } = renderHook(() => useRetryManagerFailedItems([aFailedDownload()])); - await vi.waitFor(() => expect(mockUploadRetryItem).toHaveBeenCalled()); - expect(mockUploadRetryItem.mock.calls[0][0].uploadFile.name).toBe(scenario.expected); + expect(result.current[0]).toEqual(expect.objectContaining({ name: 'Report', type: 'pdf', isRetrying: false })); }); - test('When retrying an upload fails, then it is shown as failed again', async () => { - mockUploadRetryItem.mockReturnValueOnce({ unwrap: () => Promise.reject(new Error('Upload failed')) }); + test('When a failed download is retried, then only that item is shown as retrying and it is downloaded again', async () => { + const failedDownload = aFailedDownload(); + const { result } = renderHook(() => useRetryManagerFailedItems([failedDownload])); - retryFirstFailedItem(aFailedUpload('file', 'txt')); + result.current[0].retry(); + expect(RetryManager.changeStatus).toHaveBeenCalledWith('task-2', RetryableTaskStatus.Retrying, { id: 'file-id' }); await vi.waitFor(() => - expect(RetryManager.changeStatus).toHaveBeenLastCalledWith('task-1', RetryableTaskStatus.Failed), + expect(DownloadManager.downloadItem).toHaveBeenCalledWith( + expect.objectContaining({ payload: [failedDownload.params], taskId: 'task-2' }), + ), ); }); - - test('When a download failed, then it is shown with its decrypted name', () => { - const failedDownload: RetryableTask = { - taskId: 'task-2', - type: RetryableTaskType.Download, - params: { - plainName: 'Report', - name: 'encrypted', - type: 'pdf', - size: 10, - updatedAt: '2025-01-09', - isFolder: false, - }, - status: RetryableTaskStatus.Failed, - }; - - const { result } = renderHook(() => useRetryManagerFailedItems([failedDownload])); - - expect(result.current[0]).toEqual(expect.objectContaining({ name: 'Report', type: 'pdf', isRetrying: false })); - }); }); diff --git a/src/app/tasks/components/TaskToRetry/useRetryManagerFailedItems.ts b/src/app/tasks/components/TaskToRetry/useRetryManagerFailedItems.ts index fb70493d12..016fa1db0a 100644 --- a/src/app/tasks/components/TaskToRetry/useRetryManagerFailedItems.ts +++ b/src/app/tasks/components/TaskToRetry/useRetryManagerFailedItems.ts @@ -1,28 +1,14 @@ -import { useReduxActions } from 'app/store/slices/storage/hooks/useReduxActions'; -import RetryManager, { RetryableTask, RetryableTaskStatus, RetryableTaskType } from 'app/network/RetryManager'; +import RetryManager, { RetryableTask, RetryableTaskStatus } from 'app/network/RetryManager'; import { DownloadManager } from 'app/network/DownloadManager'; import { useAppSelector } from 'app/store/hooks'; import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; -import { renameFile } from 'app/crypto/services/utils'; -import { FileToUpload } from 'app/drive/services/file.service/types'; import { DownloadItemType } from 'app/drive/services/downloadManager.service'; -import { UploadManagerFileParams } from 'app/tasks/upload/uploadFilesWithTasks'; import { FailedItemToRetry } from '../TaskToRetryItem/TaskToRetryItem'; import { enqueueRetry } from '../../services/retryQueue'; type DisplayData = Pick; -const withFullName = ({ content, name, type }: FileToUpload): File => - renameFile(content, type ? `${name}.${type}` : name); - -const getUploadDisplayData = (params: UploadManagerFileParams): DisplayData => ({ - name: params.filecontent.name, - type: params.filecontent.type, - size: params.filecontent.size, - modifiedAt: params.filecontent.content.lastModified, -}); - -const getDownloadDisplayData = (item: DownloadItemType): DisplayData => ({ +const getDisplayData = (item: DownloadItemType): DisplayData => ({ name: item.plainName ?? item.name, type: item.type, size: item.size, @@ -30,47 +16,18 @@ const getDownloadDisplayData = (item: DownloadItemType): DisplayData => ({ isFolder: item.isFolder, }); -const getDisplayData = (task: RetryableTask): DisplayData => { - switch (task.type) { - case RetryableTaskType.Upload: - return getUploadDisplayData(task.params); - case RetryableTaskType.Download: - return getDownloadDisplayData(task.params); - } -}; - const getTaskId = ({ taskId, params }: RetryableTask): string => taskId ?? params?.taskId ?? ''; -const changeRetryStatus = (task: RetryableTask, status: RetryableTaskStatus): void => { - if (task.type === RetryableTaskType.Download) { - RetryManager.changeStatus(getTaskId(task), status, { id: task.params?.id }); - return; - } - RetryManager.changeStatus(getTaskId(task), status); -}; +const changeRetryStatus = (task: RetryableTask, status: RetryableTaskStatus): void => + RetryManager.changeStatus(getTaskId(task), status, { id: task.params?.id }); /** - * Failed items kept in the retry manager: downloads, and uploads not yet tracked by their task. + * Failed downloads kept in the retry manager. Failed uploads are kept by their own task instead. */ export const useRetryManagerFailedItems = (tasks: RetryableTask[]): FailedItemToRetry[] => { - const { uploadRetryItem } = useReduxActions(); const selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace); const workspaceCredentials = useAppSelector(workspacesSelectors.getWorkspaceCredentials); - const retryUpload = async (task: RetryableTask) => { - const { params } = task; - try { - await uploadRetryItem({ - uploadFile: withFullName(params.filecontent), - parentFolderId: params?.parentFolderId, - taskId: getTaskId(task), - fileType: params?.filecontent?.type ?? '', - }).unwrap(); - } catch { - changeRetryStatus(task, RetryableTaskStatus.Failed); - } - }; - const retryDownload = async (task: RetryableTask) => { try { await DownloadManager.downloadItem({ @@ -84,22 +41,13 @@ export const useRetryManagerFailedItems = (tasks: RetryableTask[]): FailedItemTo } }; - const runRetry = (task: RetryableTask) => { - switch (task.type) { - case RetryableTaskType.Upload: - return retryUpload(task); - case RetryableTaskType.Download: - return retryDownload(task); - } - }; - const retryTask = (task: RetryableTask) => { changeRetryStatus(task, RetryableTaskStatus.Retrying); - enqueueRetry(() => runRetry(task)); + enqueueRetry(() => retryDownload(task)); }; return tasks.map((task) => ({ - ...getDisplayData(task), + ...getDisplayData(task.params), isRetryable: task.retryable !== false, isRetrying: task.status === RetryableTaskStatus.Retrying, retry: () => retryTask(task), diff --git a/src/app/tasks/hooks/useRetry.ts b/src/app/tasks/hooks/useRetry.ts index 47ae6fed5e..e7d4e16c1c 100644 --- a/src/app/tasks/hooks/useRetry.ts +++ b/src/app/tasks/hooks/useRetry.ts @@ -1,14 +1,6 @@ import { useCallback } from 'react'; import { DriveItemData } from 'app/drive/types'; -import { - DownloadFileTask, - DownloadFilesData, - SharedItemAuthenticationData, - TaskData, - TaskNotification, - TaskType, - UploadFolderData, -} from '../types'; +import { DownloadFileTask, DownloadFilesData, TaskData, TaskNotification } from '../types'; import { DownloadManager } from '../../network/DownloadManager'; import { WorkspaceCredentialsDetails, WorkspaceData } from '@internxt/sdk/dist/workspaces'; @@ -69,79 +61,3 @@ export const useRetryDownload = ({ return { retryDownload }; }; - -interface RetryUpload { - retryUpload: () => void; -} - -type RetryUploadArgs = { - notification: TaskNotification; - uploadFolder: (data: UploadFolderData & { taskId: string }) => void; - uploadItem: (data: { uploadFile: File; parentFolderId: string; taskId: string; fileType: string }) => void; - uploadSharedItem: (data: { - uploadFile: File; - parentFolderId: string; - taskId: string; - fileType: string; - sharedItemAuthenticationData: SharedItemAuthenticationData; - }) => void; - showErrorNotification: () => void; - resetProgress: (notification: TaskNotification) => void; -}; - -export const useRetryUpload = ({ - notification, - uploadFolder, - uploadItem, - uploadSharedItem, - showErrorNotification, - resetProgress, -}: RetryUploadArgs): RetryUpload => { - const retryUpload = useCallback(() => { - const { item, taskId, action, sharedItemAuthenticationData } = notification; - - const isFolderUpload = action === TaskType.UploadFolder; - - if (isFolderUpload) { - const uploadFolderData: UploadFolderData | undefined = notification?.item as UploadFolderData; - const folder = uploadFolderData?.folder; - const currentFolderId = uploadFolderData?.parentFolderId; - - if (folder && currentFolderId) { - uploadFolder({ - folder, - parentFolderId: currentFolderId, - taskId, - }); - } else { - showErrorNotification(); - } - } else if (item && taskId) { - const uploadItemData = item as { uploadFile: File; parentFolderId: string }; - - const isSharedItem = !!sharedItemAuthenticationData; - if (isSharedItem) { - resetProgress(notification); - uploadSharedItem({ - uploadFile: uploadItemData.uploadFile, - parentFolderId: uploadItemData.parentFolderId, - taskId: notification.taskId, - fileType: notification.fileType as string, - sharedItemAuthenticationData, - }); - } else { - resetProgress(notification); - uploadItem({ - uploadFile: uploadItemData.uploadFile, - parentFolderId: uploadItemData.parentFolderId, - taskId: notification.taskId, - fileType: notification.fileType as string, - }); - } - } else { - showErrorNotification(); - } - }, [notification]); - - return { retryUpload }; -}; diff --git a/src/app/tasks/services/tasks.service/index.ts b/src/app/tasks/services/tasks.service/index.ts index 64a9d6e2ed..85c6e8ee83 100644 --- a/src/app/tasks/services/tasks.service/index.ts +++ b/src/app/tasks/services/tasks.service/index.ts @@ -109,7 +109,6 @@ class TaskManagerService { action: task.action, status: task.status, item: this.parseNotifcationItem(task), - sharedItemAuthenticationData: task.sharedItemAuthenticationData, fileType: task?.fileType, title: this.getTaskNotificationTitle(task), subtitle: this.getTaskNotificationSubtitle(task), diff --git a/src/app/tasks/types.ts b/src/app/tasks/types.ts index aa6ae820c0..e3906ac9d7 100644 --- a/src/app/tasks/types.ts +++ b/src/app/tasks/types.ts @@ -106,7 +106,6 @@ export interface UploadFileTask extends BaseTask { fileType: string; isFileNameValidated: boolean; item: { uploadFile: File; parentFolderId: string }; - sharedItemAuthenticationData?: SharedItemAuthenticationData; } export interface UploadFolderTask extends BaseTask { @@ -162,7 +161,6 @@ export type TaskData = ( } & { item?: UploadFileData } & { fileType?: string } & { item?: IRoot; parentFolderId?: number; - sharedItemAuthenticationData?: SharedItemAuthenticationData; itemUUID?: { rootFolderUUID?: string; fileUUID?: string }; }; @@ -170,17 +168,6 @@ export type DownloadFilesData = { name: string; type: string; items?: DriveItemD export type DownloadFolderData = { id: number; name: string; type: string; isFolder: boolean }; export type UploadFileData = { uploadFile: File; parentFolderId: string }; export type UploadFolderData = { folder: IRoot; parentFolderId: string }; -export type SharedItemAuthenticationData = { - currentFolderId: string; - ownerUserAuthenticationData: { - token: string; - bridgeUser: string; - bridgePass: string; - encryptionKey: string; - bucketId: string; - }; - isDeepFolder: boolean; -}; export interface TaskNotification { taskId: string; @@ -188,7 +175,6 @@ export interface TaskNotification { status: TaskStatus; title: string; item?: DownloadFilesData | DownloadFolderData | UploadFileData | UploadFolderData; - sharedItemAuthenticationData?: SharedItemAuthenticationData; itemUUID?: { rootFolderUUID?: string; fileUUID?: string }; fileType?: string; subtitle: string; diff --git a/src/app/tasks/upload/uploadFilesWithTasks.test.ts b/src/app/tasks/upload/uploadFilesWithTasks.test.ts deleted file mode 100644 index 448ba8557d..0000000000 --- a/src/app/tasks/upload/uploadFilesWithTasks.test.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { beforeEach, describe, expect, Mock, test, vi } from 'vitest'; -import { UploadFailure } from 'app/upload/uploadFailure'; -import { UploadFailureReason, UploadItem, UploadItemKind, UploadManagerEvents, UploadResult } from 'app/upload/types'; -import tasksService from 'app/tasks/services/tasks.service'; -import { TaskStatus } from 'app/tasks/types'; -import referralService from 'services/referral.service'; -import errorService from 'services/error.service'; -import * as networkInformation from 'app/network/networkInformation'; -import { UploadManager } from 'app/upload/UploadManager'; -import RetryManager from 'app/network/RetryManager'; -import { PersistUploadRepository } from 'app/repositories/DatabaseUploadRepository'; -import { uploadFilesWithTasks } from './uploadFilesWithTasks'; - -const { managerState } = vi.hoisted(() => ({ - managerState: { - result: { succeeded: [], failed: [] } as UploadResult, - instance: undefined as - | undefined - | { props: { events: UploadManagerEvents }; run: Mock; cancelItem: Mock; items: UploadItem[] }, - }, -})); - -vi.mock('app/upload/UploadManager', () => ({ - UploadManager: vi.fn(function (this: NonNullable, props) { - this.props = props; - this.cancelItem = vi.fn(); - this.run = vi.fn(async (items: UploadItem[]) => { - this.items = items; - return managerState.result; - }); - managerState.instance = this; - }), -})); - -vi.mock('app/tasks/services/tasks.service', () => ({ - default: { - create: vi.fn(), - updateTask: vi.fn(), - findTask: vi.fn(), - }, -})); - -vi.mock('i18next', () => ({ t: (key: string) => key })); - -vi.mock('services/referral.service', () => ({ - default: { - trackFileUpload: vi.fn(), - }, -})); - -vi.mock('app/network/networkInformation', () => ({ - logNetworkInfoForUpload: vi.fn(), -})); - -const buildFile = (overrides: Record = {}) => ({ - filecontent: { - content: 'content' as unknown as File, - type: 'text/plain', - name: 'file.txt', - size: 1024, - parentFolderId: 'folder', - }, - userEmail: '', - parentFolderId: 'folder', - ...overrides, -}); - -const uploadRepository = { - getUploadState: vi.fn(), - setUploadState: vi.fn(() => Promise.resolve()), - removeUploadState: vi.fn(() => Promise.resolve()), -} as unknown as PersistUploadRepository; - -const runUpload = async (files: ReturnType[], props: Record = {}) => { - await uploadFilesWithTasks({ - files, - maxSpaceOccupiedCallback: vi.fn(), - uploadRepository, - ...props, - }); - const manager = managerState.instance as NonNullable; - return { manager, events: manager.props.events, items: manager.items }; -}; - -const failWith = (failure: Partial) => { - vi.mocked(UploadManager).mockImplementationOnce(function (this: NonNullable, props) { - this.props = props; - this.run = vi.fn(async (items: UploadItem[]) => ({ - succeeded: [], - failed: [{ item: items[0], failure: { error: new Error('failed'), isRetryable: true, ...failure } }], - })); - managerState.instance = this; - } as never); -}; - -describe('Uploading files with tasks', () => { - beforeEach(() => { - vi.clearAllMocks(); - RetryManager.clearTasks(); - managerState.result = { succeeded: [], failed: [] }; - (tasksService.findTask as Mock).mockReturnValue(undefined); - }); - - test('When a new file upload begins, then a task is created to track it', async () => { - (tasksService.create as Mock).mockReturnValue('new-task-id'); - - const { items } = await runUpload([buildFile()]); - - expect(tasksService.create).toHaveBeenCalledWith( - expect.objectContaining({ - fileName: 'file.txt', - fileType: 'text/plain', - showNotification: true, - cancellable: true, - }), - ); - expect(items).toEqual([{ kind: UploadItemKind.File, file: buildFile().filecontent }]); - }); - - test('When an upload is retried, then its existing task resumes instead of creating a new one', async () => { - await runUpload([buildFile({ taskId: 'existing-task' })]); - - expect(tasksService.create).not.toHaveBeenCalled(); - expect(tasksService.updateTask).toHaveBeenCalledWith({ - taskId: 'existing-task', - merge: { status: TaskStatus.Encrypting, stop: expect.any(Function) }, - }); - }); - - test('When files from a folder upload begin, then the folder task shows as in progress', async () => { - (tasksService.create as Mock).mockReturnValue('t1'); - (uploadRepository.getUploadState as Mock).mockResolvedValueOnce(undefined); - - await runUpload([buildFile({ relatedTaskId: 'related-task' })]); - - expect(tasksService.updateTask).toHaveBeenCalledWith({ - taskId: 'related-task', - merge: { status: TaskStatus.InProcess }, - }); - }); - - test('When the folder task is paused, then it does not resume by itself', async () => { - (tasksService.create as Mock).mockReturnValue('t1'); - (uploadRepository.getUploadState as Mock).mockResolvedValueOnce(TaskStatus.Paused); - - await runUpload([buildFile({ relatedTaskId: 'related-task' })]); - - expect(tasksService.updateTask).not.toHaveBeenCalledWith({ - taskId: 'related-task', - merge: { status: TaskStatus.InProcess }, - }); - }); - - test('When a file upload starts, then only files uploaded outside a folder show as in progress', async () => { - (tasksService.create as Mock).mockReturnValueOnce('loose-task').mockReturnValueOnce('folder-file-task'); - const { events, items } = await runUpload([buildFile(), buildFile({ relatedTaskId: 'related' })]); - - events.onItemStarted?.(items[0]); - events.onItemStarted?.(items[1]); - - expect(tasksService.updateTask).toHaveBeenCalledWith({ - taskId: 'loose-task', - merge: { status: TaskStatus.InProcess }, - }); - expect(tasksService.updateTask).toHaveBeenCalledWith({ - taskId: 'folder-file-task', - merge: { status: TaskStatus.Encrypting }, - }); - }); - - test('When an upload advances, then the task progress updates unless the task is paused or cancelled', async () => { - (tasksService.create as Mock).mockReturnValue('t1'); - const { events, items } = await runUpload([buildFile()]); - - (tasksService.findTask as Mock).mockReturnValue({ status: TaskStatus.InProcess }); - events.onItemProgress?.(items[0], 0.5); - expect(tasksService.updateTask).toHaveBeenCalledWith({ taskId: 't1', merge: { progress: 0.5 } }); - - (tasksService.updateTask as Mock).mockClear(); - (tasksService.findTask as Mock).mockReturnValue({ status: TaskStatus.Paused }); - events.onItemProgress?.(items[0], 0.6); - (tasksService.findTask as Mock).mockReturnValue({ status: TaskStatus.Cancelled }); - events.onItemProgress?.(items[0], 0.7); - expect(tasksService.updateTask).not.toHaveBeenCalled(); - }); - - test('When a file finishes uploading, then its task succeeds, the upload is tracked and the caller is told', async () => { - (tasksService.create as Mock).mockReturnValue('t1'); - const onFinishUploadFile = vi.fn(); - const onFileUploadCallback = vi.fn(); - const { events, items } = await runUpload([buildFile({ onFinishUploadFile })], { onFileUploadCallback }); - const driveFile = { uuid: 'file-uuid' } as never; - - await events.onItemSucceeded?.(items[0], driveFile); - - expect(tasksService.updateTask).toHaveBeenCalledWith({ - taskId: 't1', - merge: { status: TaskStatus.Success, itemUUID: { fileUUID: 'file-uuid' } }, - }); - expect(referralService.trackFileUpload).toHaveBeenCalledOnce(); - expect(networkInformation.logNetworkInfoForUpload).toHaveBeenCalledWith({ fileName: 'file.txt', fileSize: 1024 }); - expect(onFinishUploadFile).toHaveBeenCalledWith(driveFile, 't1'); - expect(onFileUploadCallback).toHaveBeenCalledWith(driveFile); - }); - - test('When a retried file finishes uploading, then it leaves the retry list', async () => { - RetryManager.addTasks([{ taskId: 'existing-task', type: 'upload' as never, params: {} }]); - const { events, items } = await runUpload([buildFile({ taskId: 'existing-task' })]); - - await events.onItemSucceeded?.(items[0], { uuid: 'file-uuid' } as never); - - expect(RetryManager.getTasks()).toEqual([]); - }); - - test('When a file inside a folder finishes uploading, then the folder progress advances', async () => { - (tasksService.create as Mock).mockReturnValue('t1'); - const relatedTaskProgress = { filesUploaded: 0, totalFilesToUpload: 4 }; - const { events, items } = await runUpload([buildFile({ relatedTaskId: 'related-task' })], { - relatedTaskProgress, - }); - - await events.onItemSucceeded?.(items[0], { uuid: 'file-uuid' } as never); - - expect(relatedTaskProgress.filesUploaded).toBe(1); - expect(tasksService.updateTask).toHaveBeenCalledWith({ - taskId: 'related-task', - merge: { progress: 0.25 }, - }); - }); - - test('When the folder upload state cannot be read after a file inside it finishes, then the file is still shown and leaves the retry list', async () => { - vi.spyOn(errorService, 'reportError').mockReturnValue(); - (uploadRepository.getUploadState as Mock) - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error('IndexedDB error')); - RetryManager.addTasks([{ taskId: 'existing-task', type: 'upload' as never, params: {} }]); - const onFinishUploadFile = vi.fn(); - const relatedTaskProgress = { filesUploaded: 0, totalFilesToUpload: 4 }; - const { events, items } = await runUpload( - [buildFile({ taskId: 'existing-task', relatedTaskId: 'related-task', onFinishUploadFile })], - { relatedTaskProgress }, - ); - - await events.onItemSucceeded?.(items[0], { uuid: 'file-uuid' } as never); - - expect(onFinishUploadFile).toHaveBeenCalledOnce(); - expect(RetryManager.getTasks()).toEqual([]); - expect(relatedTaskProgress.filesUploaded).toBe(1); - }); - - test('When an upload fails, then the task shows an error message matching the cause', async () => { - (tasksService.create as Mock).mockReturnValue('t1'); - const { events, items } = await runUpload([buildFile()]); - const failure = { error: new Error('failed'), isRetryable: true }; - - events.onItemFailed?.(items[0], { ...failure, reason: UploadFailureReason.ConnectionLost }); - expect(tasksService.updateTask).toHaveBeenCalledWith({ - taskId: 't1', - merge: { status: TaskStatus.Error, subtitle: 'error.connectionLostError' }, - }); - - events.onItemFailed?.(items[0], { ...failure, reason: UploadFailureReason.UploadFailed }); - expect(tasksService.updateTask).toHaveBeenCalledWith({ - taskId: 't1', - merge: { status: TaskStatus.Error, subtitle: 'tasks.subtitles.upload-failed' }, - }); - }); - - test('When an upload is cancelled, then its task shows as cancelled', async () => { - (tasksService.create as Mock).mockReturnValue('t1'); - const { events, items } = await runUpload([buildFile()]); - - events.onItemCancelled?.(items[0]); - - expect(tasksService.updateTask).toHaveBeenCalledWith({ taskId: 't1', merge: { status: TaskStatus.Cancelled } }); - }); - - test('When a task is stopped by the user, then only that upload is cancelled', async () => { - const { manager, items } = await runUpload([buildFile(), buildFile()]); - const [{ stop }] = (tasksService.create as Mock).mock.calls[0] as [{ stop: () => Promise }]; - - await stop(); - - expect(manager.cancelItem).toHaveBeenCalledExactlyOnceWith(items[0]); - }); - - test('When the folder upload that owns the files is cancelled, then every file is cancelled', async () => { - const abortController = new AbortController(); - const { manager, items } = await runUpload([buildFile(), buildFile()], { abortController }); - - abortController.abort(); - - expect(manager.cancelItem.mock.calls).toEqual(items.map((item) => [item])); - }); - - test('When the folder upload that owns the files was cancelled before they start, then every file is cancelled', async () => { - const abortController = new AbortController(); - abortController.abort(); - - const { manager, items } = await runUpload([buildFile(), buildFile()], { abortController }); - - expect(manager.cancelItem.mock.calls).toEqual(items.map((item) => [item])); - }); - - test('When a file fails, then it is listed for retry and the upload rejects with its error', async () => { - (tasksService.create as Mock).mockReturnValue('t1'); - const error = new Error('Server error'); - failWith({ error, reason: UploadFailureReason.UploadFailed, isRetryable: false }); - - await expect(runUpload([buildFile()])).rejects.toThrow(error); - - expect(RetryManager.getTasks()).toEqual([ - expect.objectContaining({ taskId: 't1', retryable: false, params: expect.objectContaining({ taskId: 't1' }) }), - ]); - }); - - test.each([ - { problem: 'the storage is full', reason: UploadFailureReason.NoSpaceLeft, callback: 'maxSpaceOccupiedCallback' }, - { - problem: 'the file is too large', - reason: UploadFailureReason.FileTooLarge, - callback: 'fileSizeExceededCallback', - }, - ] as const)( - 'When $problem, then the user is warned as soon as a file fails and only once', - async ({ reason, callback }) => { - const warn = vi.fn(); - const { events, items } = await runUpload([buildFile(), buildFile()], { [callback]: warn }); - const failure = { error: new Error('failed'), reason, isRetryable: false }; - - events.onItemFailed?.(items[0], failure); - events.onItemFailed?.(items[1], failure); - - expect(warn).toHaveBeenCalledOnce(); - }, - ); -}); diff --git a/src/app/tasks/upload/uploadFilesWithTasks.ts b/src/app/tasks/upload/uploadFilesWithTasks.ts deleted file mode 100644 index 168faca2b8..0000000000 --- a/src/app/tasks/upload/uploadFilesWithTasks.ts +++ /dev/null @@ -1,237 +0,0 @@ -import tasksService from 'app/tasks/services/tasks.service'; -import { TaskStatus, TaskType, UploadFileTask } from 'app/tasks/types'; -import referralService from 'services/referral.service'; -import { logNetworkInfoForUpload } from 'app/network/networkInformation'; -import { DriveFileData } from 'app/drive/types'; -import { FileToUpload } from 'app/drive/services/file.service/types'; -import { UploadManager } from 'app/upload/UploadManager'; -import { UploadFailureReason, UploadItem, UploadItemKind, UploadManagerEvents } from 'app/upload/types'; -import RetryManager, { RetryableTaskType } from 'app/network/RetryManager'; -import { OwnerUserAuthenticationData } from 'app/network/types'; -import { PersistUploadRepository } from 'app/repositories/DatabaseUploadRepository'; -import errorService from 'services/error.service'; -import { errorMerge } from './uploadTaskErrors'; - -export type UploadManagerFileParams = { - filecontent: FileToUpload; - taskId?: string; - relatedTaskId?: string; - fileType?: string; - userEmail: string; - parentFolderId: string; - onFinishUploadFile?: (driveItemData: DriveFileData, taskId: string) => void; - abortController?: AbortController; -}; - -type UploadFileParamsWithTaskId = UploadManagerFileParams & { taskId: string }; - -type FolderProgress = { filesUploaded: number; totalFilesToUpload: number }; - -interface UploadFilesWithTasksProps { - files: UploadManagerFileParams[]; - maxSpaceOccupiedCallback: () => void; - fileSizeExceededCallback?: () => void; - onFileUploadCallback?: (driveFileData: DriveFileData) => void; - uploadRepository: PersistUploadRepository; - abortController?: AbortController; - options?: { - showNotifications?: boolean; - ownerUserAuthenticationData?: OwnerUserAuthenticationData; - sharedItemData?: { - isDeepFolder?: boolean; - currentFolderId?: string; - }; - }; - relatedTaskProgress?: FolderProgress; -} - -/** - * Uploads files while tracking each one as a task: creates (or resumes) one task per - * file, then observes the upload manager's events to keep the tasks in sync. The - * manager itself never touches tasks. - * @throws the first upload error once every file has settled - */ -export const uploadFilesWithTasks = async (props: UploadFilesWithTasksProps): Promise => { - const { files, options, abortController, uploadRepository, relatedTaskProgress } = props; - const fileByItem = new Map(); - const getFile = (item: UploadItem) => fileByItem.get(item) as UploadFileParamsWithTaskId; - const getPauseKey = (item: UploadItem) => { - const file = getFile(item); - return file.relatedTaskId ?? file.taskId; - }; - - const failureDialogByReason: Partial void>> = { - [UploadFailureReason.NoSpaceLeft]: props.maxSpaceOccupiedCallback, - [UploadFailureReason.FileTooLarge]: props.fileSizeExceededCallback, - }; - const reasonsWithDialogShown = new Set(); - - const showFailureDialogOnce = (reason: UploadFailureReason) => { - const showDialog = failureDialogByReason[reason]; - const hasNoDialog = !showDialog; - const wasAlreadyShown = reasonsWithDialogShown.has(reason); - if (hasNoDialog || wasAlreadyShown) return; - - reasonsWithDialogShown.add(reason); - showDialog(); - }; - - const updateFolderProgress = async (folderTaskId: string, folderProgress: FolderProgress) => { - const folderUploadState = await uploadRepository.getUploadState(folderTaskId); - const isPaused = folderUploadState === TaskStatus.Paused; - if (isPaused) return; - - tasksService.updateTask({ - taskId: folderTaskId, - merge: { progress: folderProgress.filesUploaded / folderProgress.totalFilesToUpload }, - }); - }; - - const events: UploadManagerEvents = { - onItemStarted: (item) => { - const file = getFile(item); - tasksService.updateTask({ - taskId: file.taskId, - merge: { status: file.relatedTaskId ? TaskStatus.Encrypting : TaskStatus.InProcess }, - }); - if (!file.relatedTaskId) { - uploadRepository - .setUploadState(file.taskId, TaskStatus.InProcess) - .catch((error) => errorService.reportError(error)); - } - }, - - onItemProgress: (item, progress) => { - const { taskId } = getFile(item); - const taskStatus = tasksService.findTask(taskId)?.status; - const isTaskPaused = taskStatus === TaskStatus.Paused; - const isTaskCancelled = taskStatus === TaskStatus.Cancelled; - - if (!isTaskCancelled && !isTaskPaused) { - tasksService.updateTask({ taskId, merge: { progress } }); - } - }, - - onItemSucceeded: async (item, uploadedItem) => { - const driveFileData = uploadedItem as DriveFileData; - const file = getFile(item); - - if (tasksService.findTask(file.taskId)?.status !== TaskStatus.Cancelled) { - tasksService.updateTask({ - taskId: file.taskId, - merge: { status: TaskStatus.Success, itemUUID: { fileUUID: driveFileData.uuid } }, - }); - } - RetryManager.removeTask(file.taskId); - - referralService.trackFileUpload(); - logNetworkInfoForUpload({ fileName: file.filecontent.name, fileSize: file.filecontent.size }); - file.onFinishUploadFile?.(driveFileData, file.taskId); - props.onFileUploadCallback?.(driveFileData); - - if (relatedTaskProgress && file.relatedTaskId) { - relatedTaskProgress.filesUploaded += 1; - await updateFolderProgress(file.relatedTaskId, relatedTaskProgress).catch((error) => - errorService.reportError(error), - ); - } - }, - - onItemFailed: (item, { reason }) => { - const { taskId } = getFile(item); - tasksService.updateTask({ taskId, merge: errorMerge(reason) }); - uploadRepository.removeUploadState(taskId).catch((error) => errorService.reportError(error)); - showFailureDialogOnce(reason); - }, - - onItemCancelled: (item) => { - tasksService.updateTask({ taskId: getFile(item).taskId, merge: { status: TaskStatus.Cancelled } }); - }, - }; - - const manager = new UploadManager({ - ownerUserAuthenticationData: options?.ownerUserAuthenticationData, - uploadRepository, - getPauseKey, - events, - }); - - const items = files.map((file): UploadItem => { - const item: UploadItem = { - kind: UploadItemKind.File, - file: { ...file.filecontent, type: file.fileType ?? file.filecontent.type }, - }; - fileByItem.set(item, { ...file, taskId: file.taskId ?? createUploadTask(file, item, manager, options) }); - file.abortController?.signal.addEventListener('abort', () => manager.cancelItem(item), { once: true }); - - if (file.taskId) { - tasksService.updateTask({ - taskId: file.taskId, - merge: { status: TaskStatus.Encrypting, stop: () => Promise.resolve(manager.cancelItem(item)) }, - }); - } - - return item; - }); - - const cancelEveryFile = () => items.forEach((item) => manager.cancelItem(item)); - if (abortController?.signal.aborted) cancelEveryFile(); - abortController?.signal.addEventListener('abort', cancelEveryFile, { once: true }); - - const relatedTaskId = files[0]?.relatedTaskId; - if (relatedTaskId) { - const folderUploadState = await uploadRepository.getUploadState(relatedTaskId); - const isPaused = folderUploadState === TaskStatus.Paused; - - if (!isPaused) { - tasksService.updateTask({ taskId: relatedTaskId, merge: { status: TaskStatus.InProcess } }); - } - } - - const { failed } = await manager.run(items); - - if (failed.length > 0) { - RetryManager.addTasks( - failed.map(({ item, failure }) => { - const file = getFile(item); - - return { - taskId: file.taskId, - type: RetryableTaskType.Upload, - params: file, - retryable: failure.isRetryable, - }; - }), - ); - - throw failed[0].failure.error; - } -}; - -const createUploadTask = ( - file: UploadManagerFileParams, - item: UploadItem, - manager: UploadManager, - options: UploadFilesWithTasksProps['options'], -): string => { - const sharedItemAuthenticationData = options?.ownerUserAuthenticationData - ? { - ownerUserAuthenticationData: { ...options.ownerUserAuthenticationData }, - isDeepFolder: !!options?.sharedItemData?.isDeepFolder, - currentFolderId: options?.sharedItemData?.currentFolderId as string, - } - : undefined; - - return tasksService.create({ - relatedTaskId: file.relatedTaskId, - action: TaskType.UploadFile, - item: { uploadFile: file.filecontent.content, parentFolderId: file.parentFolderId }, - fileName: file.filecontent.name, - fileType: file.filecontent.type, - isFileNameValidated: true, - showNotification: options?.showNotifications ?? true, - cancellable: true, - sharedItemAuthenticationData, - stop: () => Promise.resolve(manager.cancelItem(item)), - }); -}; diff --git a/src/app/tasks/upload/uploadFoldersWithTasks.test.ts b/src/app/tasks/upload/uploadFoldersWithTasks.test.ts deleted file mode 100644 index 9b4f3f041b..0000000000 --- a/src/app/tasks/upload/uploadFoldersWithTasks.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { beforeEach, describe, expect, Mock, test, vi } from 'vitest'; -import tasksService from 'app/tasks/services/tasks.service'; -import { TaskEvent, TaskStatus } from 'app/tasks/types'; -import referralService from 'services/referral.service'; -import * as networkInformation from 'app/network/networkInformation'; -import { uploadFoldersWithManager, UploadFolderManagerEvents } from 'app/network/UploadFolderManager'; -import { IRoot } from 'app/store/slices/storage/types'; -import RetryManager, { RetryableTaskType } from 'app/network/RetryManager'; -import { uploadFoldersWithTasks } from './uploadFoldersWithTasks'; -import { UploadFailureReason } from 'app/upload/types'; - -vi.mock('app/network/UploadFolderManager', () => ({ - uploadFoldersWithManager: vi.fn(() => Promise.resolve()), -})); - -vi.mock('app/tasks/services/tasks.service', () => ({ - default: { - create: vi.fn(), - updateTask: vi.fn(), - getTasks: vi.fn(), - addListener: vi.fn(), - removeListener: vi.fn(), - }, -})); - -vi.mock('i18next', () => ({ t: (key: string) => key })); - -vi.mock('services/referral.service', () => ({ - default: { - trackFolderUpload: vi.fn(), - }, -})); - -vi.mock('app/network/networkInformation', () => ({ - logNetworkInfoForUpload: vi.fn(), -})); - -const root: IRoot = { isFolder: true, name: 'Folder', childrenFiles: [], childrenFolders: [], folderId: 'parent' }; - -const runUpload = async ( - payload: Parameters[0]['payload'], - props: Record = {}, -) => { - await uploadFoldersWithTasks({ - payload, - selectedWorkspace: null, - dispatch: vi.fn() as never, - maxUploadFileSize: 100, - ...props, - }); - const managerProps = (uploadFoldersWithManager as Mock).mock.calls[0][0]; - return { managerProps, events: managerProps.events as UploadFolderManagerEvents }; -}; - -const addFailedFilesOf = (folderTaskId: string) => - RetryManager.addTasks( - ['a.txt', 'b.txt'].map((name) => ({ - taskId: `${folderTaskId}-${name}`, - type: RetryableTaskType.Upload, - params: { relatedTaskId: folderTaskId }, - })), - ); - -const retryTaskIds = () => RetryManager.getTasks().map(({ taskId }) => taskId); - -describe('uploadFoldersWithTasks', () => { - beforeEach(() => { - vi.clearAllMocks(); - RetryManager.clearTasks(); - }); - - test('When a folder upload is retried, then its existing task resumes instead of creating a new one', async () => { - const { managerProps } = await runUpload([ - { root, currentFolderId: 'parent', options: { taskId: 'existing-task', withNotification: true } }, - ]); - - expect(tasksService.updateTask).toHaveBeenCalledWith({ - taskId: 'existing-task', - merge: { folderName: 'Folder', status: TaskStatus.InProcess, progress: 0 }, - }); - expect(tasksService.create).not.toHaveBeenCalled(); - expect(managerProps.payload[0].options.taskId).toBe('existing-task'); - }); - - test('When a new folder upload begins, then a task is created to track it', async () => { - (tasksService.create as Mock).mockReturnValue('created-task-id'); - - const { managerProps } = await runUpload([ - { root, currentFolderId: 'parent', options: { withNotification: false } }, - ]); - - expect(tasksService.create).toHaveBeenCalledWith( - expect.objectContaining({ folderName: 'Folder', parentFolderId: 'parent', showNotification: false }), - ); - expect(managerProps.payload[0].options.taskId).toBe('created-task-id'); - }); - - test('When a folder upload starts, then the task shows the final folder name and can cancel, pause and resume it', async () => { - (tasksService.create as Mock).mockReturnValue('task-1'); - const listeners: Record void> = {}; - (tasksService.addListener as Mock).mockImplementation(({ event, listener }) => { - listeners[event] = listener; - }); - - const { events } = await runUpload([{ root, currentFolderId: 'parent' }]); - const controls = { cancelUpload: vi.fn(), pauseUpload: vi.fn(), resumeUpload: vi.fn() }; - - const cleanup = events.onFolderUploadStarted?.('task-1', { ...root, name: 'Folder (1)' }, controls); - - expect(tasksService.updateTask).toHaveBeenCalledWith({ - taskId: 'task-1', - merge: { folderName: 'Folder (1)', status: TaskStatus.InProcess, progress: 0 }, - }); - - listeners[TaskEvent.TaskCancelled]?.({ id: 'other-task', status: TaskStatus.Cancelled }); - expect(controls.cancelUpload).not.toHaveBeenCalled(); - listeners[TaskEvent.TaskCancelled]?.({ id: 'task-1', status: TaskStatus.Cancelled }); - expect(controls.cancelUpload).toHaveBeenCalledOnce(); - - listeners[TaskEvent.TaskUpdated]?.({ id: 'task-1', status: TaskStatus.Paused }); - expect(controls.pauseUpload).toHaveBeenCalledOnce(); - listeners[TaskEvent.TaskUpdated]?.({ id: 'task-1', status: TaskStatus.InProcess }); - expect(controls.resumeUpload).toHaveBeenCalledOnce(); - - cleanup?.(); - expect(tasksService.removeListener).toHaveBeenCalledTimes(2); - }); - - test('When a folder upload advances, then the task progress updates and the upload stays stoppable', async () => { - (tasksService.create as Mock).mockReturnValue('task-1'); - const { events } = await runUpload([{ root, currentFolderId: 'parent' }]); - const stop = vi.fn(); - - events.onFolderUploadProgress?.('task-1', 0.25, stop); - - expect(tasksService.updateTask).toHaveBeenCalledWith({ taskId: 'task-1', merge: { progress: 0.25, stop } }); - }); - - test('When a folder finishes uploading, then its task succeeds and the upload is tracked', async () => { - (tasksService.create as Mock).mockReturnValue('task-1'); - const onFolderUploadSucceeded = vi.fn(); - const { events } = await runUpload([{ root, currentFolderId: 'parent' }], { onFolderUploadSucceeded }); - - events.onFolderUploadSuccess?.('task-1', { - folderName: 'MyFolder', - rootFolderUUID: 'root-uuid', - hasFailedFiles: false, - }); - - expect(tasksService.updateTask).toHaveBeenCalledWith({ - taskId: 'task-1', - merge: { status: TaskStatus.Success, itemUUID: { rootFolderUUID: 'root-uuid' } }, - }); - expect(referralService.trackFolderUpload).toHaveBeenCalledOnce(); - expect(networkInformation.logNetworkInfoForUpload).toHaveBeenCalledWith({ folderName: 'MyFolder' }); - expect(onFolderUploadSucceeded).toHaveBeenCalledWith('task-1'); - }); - - test('When a folder finishes uploading but some files failed, then its task still succeeds so the failed files can be retried', async () => { - (tasksService.create as Mock).mockReturnValue('task-1'); - const onFolderUploadSucceeded = vi.fn(); - const { events } = await runUpload([{ root, currentFolderId: 'parent' }], { onFolderUploadSucceeded }); - - events.onFolderUploadSuccess?.('task-1', { - folderName: 'MyFolder', - rootFolderUUID: 'root-uuid', - hasFailedFiles: true, - }); - - expect(tasksService.updateTask).toHaveBeenCalledWith({ - taskId: 'task-1', - merge: { status: TaskStatus.Success, itemUUID: { rootFolderUUID: 'root-uuid' } }, - }); - expect(referralService.trackFolderUpload).not.toHaveBeenCalled(); - expect(networkInformation.logNetworkInfoForUpload).not.toHaveBeenCalled(); - expect(onFolderUploadSucceeded).toHaveBeenCalledWith('task-1'); - }); - - test('When a folder upload fails, then the task shows an error message matching the cause', async () => { - (tasksService.create as Mock).mockReturnValue('task-1'); - const { events } = await runUpload([{ root, currentFolderId: 'parent' }]); - - events.onFolderUploadError?.('task-1', UploadFailureReason.ConnectionLost); - - expect(tasksService.updateTask).toHaveBeenCalledWith({ - taskId: 'task-1', - merge: { status: TaskStatus.Error, subtitle: 'error.connectionLostError' }, - }); - }); - - test('When a retried folder upload fails before starting, then its resumed task ends in error instead of staying in progress', async () => { - (uploadFoldersWithManager as Mock).mockImplementationOnce(async ({ events }) => - events.onFolderUploadError('existing-task', UploadFailureReason.ConnectionLost), - ); - - await runUpload([{ root, currentFolderId: 'parent', options: { taskId: 'existing-task' } }]); - - expect((tasksService.updateTask as Mock).mock.lastCall).toEqual([ - { taskId: 'existing-task', merge: { status: TaskStatus.Error, subtitle: 'error.connectionLostError' } }, - ]); - }); - - test('When a folder upload stops, then only its cancellable ongoing uploads are stopped', async () => { - (tasksService.create as Mock).mockReturnValue('task-1'); - const resolvedStop = Promise.resolve(); - const nonCancellableStop = vi.fn(); - (tasksService.getTasks as Mock).mockReturnValue([ - { cancellable: true, stop: () => resolvedStop }, - { cancellable: false, stop: nonCancellableStop }, - { cancellable: true, stop: undefined }, - {}, - ]); - - const { events } = await runUpload([{ root, currentFolderId: 'parent' }]); - const promises = events.stopRelatedUploads?.('task-1'); - - expect(tasksService.getTasks).toHaveBeenCalledWith({ relatedTaskId: 'task-1' }); - expect(promises).toEqual([resolvedStop]); - expect(nonCancellableStop).not.toHaveBeenCalled(); - }); - - describe('when the folder has failed files listed for retry', () => { - beforeEach(() => { - (tasksService.create as Mock).mockReturnValue('task-1'); - addFailedFilesOf('task-1'); - addFailedFilesOf('other-task'); - }); - - test.each([ - { outcome: 'the connection is lost', reason: UploadFailureReason.ConnectionLost }, - { outcome: 'a fatal error', reason: UploadFailureReason.UploadFailed }, - ])( - 'when the folder upload ends because $outcome, then only its files are removed from the retry list', - async ({ reason }) => { - const { events } = await runUpload([{ root, currentFolderId: 'parent' }]); - - events.onFolderUploadError?.('task-1', reason); - - expect(retryTaskIds()).toEqual(['other-task-a.txt', 'other-task-b.txt']); - }, - ); - - test('when the folder upload is cancelled, then only its files are removed from the retry list', async () => { - const { events } = await runUpload([{ root, currentFolderId: 'parent' }]); - - events.onFolderUploadCancelled?.('task-1'); - - expect(retryTaskIds()).toEqual(['other-task-a.txt', 'other-task-b.txt']); - }); - - test('when the folder upload is retried, then its previous failed files are removed from the retry list', async () => { - await runUpload([{ root, currentFolderId: 'parent', options: { taskId: 'task-1' } }]); - - expect(retryTaskIds()).toEqual(['other-task-a.txt', 'other-task-b.txt']); - }); - - test('when the folder upload succeeds with failed files, then its files stay in the retry list', async () => { - const { events } = await runUpload([{ root, currentFolderId: 'parent' }]); - - events.onFolderUploadSuccess?.('task-1', { folderName: 'Folder', rootFolderUUID: 'uuid', hasFailedFiles: true }); - - expect(retryTaskIds()).toEqual(['task-1-a.txt', 'task-1-b.txt', 'other-task-a.txt', 'other-task-b.txt']); - }); - }); -}); diff --git a/src/app/tasks/upload/uploadFoldersWithTasks.ts b/src/app/tasks/upload/uploadFoldersWithTasks.ts deleted file mode 100644 index 7c281dba66..0000000000 --- a/src/app/tasks/upload/uploadFoldersWithTasks.ts +++ /dev/null @@ -1,123 +0,0 @@ -import tasksService from 'app/tasks/services/tasks.service'; -import { TaskData, TaskEvent, TaskStatus, TaskType, UploadFolderTask } from 'app/tasks/types'; -import referralService from 'services/referral.service'; -import { IRoot } from 'app/store/slices/storage/types'; -import { logNetworkInfoForUpload } from 'app/network/networkInformation'; -import RetryManager from 'app/network/RetryManager'; -import { - uploadFoldersWithManager, - UploadFolderManagerEvents, - UploadFolderPayload, -} from 'app/network/UploadFolderManager'; -import { errorMerge } from './uploadTaskErrors'; - -type UploadFoldersWithManagerProps = Parameters[0]; - -interface UploadFolderTasksPayload { - root: IRoot; - currentFolderId: string; - options?: { - taskId?: string; - withNotification?: boolean; - onSuccess?: () => void; - }; -} - -interface UploadFoldersWithTasksProps extends Omit { - payload: UploadFolderTasksPayload[]; - onFolderUploadSucceeded?: (taskId: string) => void; -} - -const createOrResumeTask = (folder: UploadFolderTasksPayload): string => { - const options = { withNotification: true, ...folder.options }; - - if (options.taskId) { - RetryManager.removeTasksOfRelatedTask(options.taskId); - tasksService.updateTask({ - taskId: options.taskId, - merge: { folderName: folder.root.name, status: TaskStatus.InProcess, progress: 0 }, - }); - return options.taskId; - } - - return tasksService.create({ - action: TaskType.UploadFolder, - folderName: folder.root.name, - item: folder.root, - parentFolderId: folder.currentFolderId, - showNotification: !!options.withNotification, - cancellable: true, - }); -}; - -/** - * Uploads folders while tracking each one as a task: creates (or resumes) one task per - * root folder, then observes the upload manager's events to keep the tasks in sync. - * The manager itself never touches tasks. - */ -export const uploadFoldersWithTasks = (props: UploadFoldersWithTasksProps): Promise => { - const { payload, onFolderUploadSucceeded, ...managerProps } = props; - - const payloadWithTaskIds: UploadFolderPayload[] = payload.map((folder) => ({ - root: folder.root, - currentFolderId: folder.currentFolderId, - options: { - taskId: createOrResumeTask(folder), - onSuccess: folder.options?.onSuccess, - }, - })); - - const events: UploadFolderManagerEvents = { - onFolderUploadStarted: (taskId, root, controls) => { - tasksService.updateTask({ - taskId, - merge: { folderName: root.name, status: TaskStatus.InProcess, progress: 0 }, - }); - - const cancelListener = (task: TaskData) => { - if (task.id === taskId && task.status === TaskStatus.Cancelled) controls.cancelUpload(); - }; - const updateListener = (task: TaskData) => { - if (task.id !== taskId) return; - if (task.status === TaskStatus.InProcess) controls.resumeUpload(); - else if (task.status === TaskStatus.Paused) controls.pauseUpload(); - }; - tasksService.addListener({ event: TaskEvent.TaskCancelled, listener: cancelListener }); - tasksService.addListener({ event: TaskEvent.TaskUpdated, listener: updateListener }); - - return () => { - tasksService.removeListener({ event: TaskEvent.TaskCancelled, listener: cancelListener }); - tasksService.removeListener({ event: TaskEvent.TaskUpdated, listener: updateListener }); - }; - }, - - onFolderUploadProgress: (taskId, progress, stopUpload) => - tasksService.updateTask({ taskId, merge: { progress, stop: stopUpload } }), - - onFolderUploadSuccess: (taskId, { folderName, rootFolderUUID, hasFailedFiles }) => { - tasksService.updateTask({ taskId, merge: { status: TaskStatus.Success, itemUUID: { rootFolderUUID } } }); - - if (!hasFailedFiles) { - referralService.trackFolderUpload(); - logNetworkInfoForUpload({ folderName }); - } - onFolderUploadSucceeded?.(taskId); - }, - - onFolderUploadError: (taskId, reason) => { - RetryManager.removeTasksOfRelatedTask(taskId); - tasksService.updateTask({ taskId, merge: errorMerge(reason) }); - }, - - onFolderUploadCancelled: (taskId) => RetryManager.removeTasksOfRelatedTask(taskId), - - stopRelatedUploads: (taskId) => - tasksService - .getTasks({ relatedTaskId: taskId }) - .filter((task) => task.cancellable) - .map((task) => task.stop?.()) - .filter((promise): promise is Promise => promise !== undefined), - }; - - return uploadFoldersWithManager({ ...managerProps, payload: payloadWithTaskIds, events }); -}; diff --git a/src/app/upload/Upload.ts b/src/app/upload/Upload.ts index c2696c1835..f51863b136 100644 --- a/src/app/upload/Upload.ts +++ b/src/app/upload/Upload.ts @@ -137,8 +137,6 @@ export class Upload { } private trackFile(item: UploadFileItem): void { - const { ownerUserAuthenticationData, destinationFolderUuid } = this.props; - const taskId = tasksService.create({ action: TaskType.UploadFile, item: { uploadFile: item.file.content, parentFolderId: item.file.parentFolderId }, @@ -147,11 +145,6 @@ export class Upload { isFileNameValidated: true, showNotification: true, cancellable: true, - sharedItemAuthenticationData: ownerUserAuthenticationData && { - ownerUserAuthenticationData, - isDeepFolder: false, - currentFolderId: destinationFolderUuid, - }, stop: () => Promise.resolve(this.manager.cancelItem(item)), });