From e1161857a0d47b52c54360e2b1f1e1b10383b074 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Fri, 3 Jul 2026 11:24:33 +0200 Subject: [PATCH 1/6] fix: upload unaccepted file deletes folder --- src/app/i18n/locales/de.json | 3 +- src/app/i18n/locales/en.json | 3 +- src/app/i18n/locales/es.json | 3 +- src/app/i18n/locales/fr.json | 3 +- src/app/i18n/locales/it.json | 3 +- src/app/i18n/locales/ru.json | 3 +- src/app/i18n/locales/tw.json | 3 +- src/app/i18n/locales/zh.json | 3 +- src/app/network/UploadFolderManager.test.ts | 77 +++++++ src/app/network/UploadFolderManager.ts | 227 ++++++++++++-------- 10 files changed, 231 insertions(+), 97 deletions(-) diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index 0753e29fa..62f4073be 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -1414,7 +1414,8 @@ "errors": {} }, "subtitles": { - "upload-failed": "Upload fehlgeschlagen" + "upload-failed": "Upload fehlgeschlagen", + "upload-partially-failed": "Einige Dateien konnten nicht hochgeladen werden" }, "generalErrorMessages": { "retryUploadFailed": "Beim erneuten Hochladen ist etwas schiefgegangen. Versuche es erneut", diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index b8175b387..207c088f3 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -1491,7 +1491,8 @@ "errors": {} }, "subtitles": { - "upload-failed": "Upload failed" + "upload-failed": "Upload failed", + "upload-partially-failed": "Some files could not be uploaded" }, "generalErrorMessages": { "retryUploadFailed": "Something went wrong when retrying the upload. Try uploading again", diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index 78a8608dd..940cbe848 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -1469,7 +1469,8 @@ "errors": {} }, "subtitles": { - "upload-failed": "Error al subir" + "upload-failed": "Error al subir", + "upload-partially-failed": "Algunos archivos no se pudieron subir" }, "generalErrorMessages": { "retryUploadFailed": "Algo salió mal al reintentar la subida. Intenta subirlo de nuevo", diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index dd2910a24..a5d011139 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -1419,7 +1419,8 @@ "errors": {} }, "subtitles": { - "upload-failed": "Échec du téléchargement" + "upload-failed": "Échec du téléchargement", + "upload-partially-failed": "Certains fichiers n'ont pas pu être téléchargés" }, "generalErrorMessages": { "retryUploadFailed": "Quelque chose s'est mal passé en réessayant de télécharger. Essayez de télécharger à nouveau", diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index 9c0c68ecc..8b2572217 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -1526,7 +1526,8 @@ "errors": {} }, "subtitles": { - "upload-failed": "Caricamento fallito" + "upload-failed": "Caricamento fallito", + "upload-partially-failed": "Alcuni file non sono stati caricati" }, "generalErrorMessages": { "retryUploadFailed": "Qualcosa è andato storto nel riprovare a caricare. Prova a caricare di nuovo", diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index 2fadc1d4e..8f104d487 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -1434,7 +1434,8 @@ "errors": {} }, "subtitles": { - "upload-failed": "Не удалось загрузить" + "upload-failed": "Не удалось загрузить", + "upload-partially-failed": "Некоторые файлы не удалось загрузить" }, "generalErrorMessages": { "retryUploadFailed": "Что-то пошло не так при повторной попытке загрузки. Попробуйте загрузить снова", diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index 83ee1f5a0..3490474db 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -1421,7 +1421,8 @@ "errors": {} }, "subtitles": { - "upload-failed": "上傳失敗" + "upload-failed": "上傳失敗", + "upload-partially-failed": "部分檔案無法上傳" }, "generalErrorMessages": { "retryUploadFailed": "重試上傳時出現問題。請重新上傳", diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index 6a355bb30..391a69012 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -1456,7 +1456,8 @@ "errors": {} }, "subtitles": { - "upload-failed": "上传失败" + "upload-failed": "上传失败", + "upload-partially-failed": "部分文件无法上传" }, "generalErrorMessages": { "retryUploadFailed": "重试上传时出了问题。请再次上传", diff --git a/src/app/network/UploadFolderManager.test.ts b/src/app/network/UploadFolderManager.test.ts index 9ae36c1d3..a62c27161 100644 --- a/src/app/network/UploadFolderManager.test.ts +++ b/src/app/network/UploadFolderManager.test.ts @@ -10,6 +10,8 @@ import { TaskFolder, UploadFoldersManager, uploadFoldersWithManager } from './Up import * as networkInformation from './networkInformation'; 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 { TaskStatus } from 'app/tasks/types'; vi.mock('app/drive/services/new-storage.service', () => ({ default: { @@ -369,6 +371,81 @@ describe('checkUploadFolders', () => { expect(logNetworkInfoMock).toHaveBeenCalledWith({ folderName: 'MyFolder' }); }); + it('should mark the task as Error but keep the already-uploaded content when a child file upload fails', async () => { + const mockFolder: DriveFolderData = { + id: 0, + uuid: 'uuid', + name: 'FolderWithFailingFile', + 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: 'FolderWithFailingFile', + deleted: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + const taskId = 'task-id'; + + (createFolder as Mock).mockResolvedValueOnce(mockFolder); + (checkFolderDuplicated as Mock).mockResolvedValueOnce({ + duplicatedFoldersResponse: [], + foldersWithDuplicates: [], + foldersWithoutDuplicates: [mockFolder], + }); + + // The file upload thunk rejects (e.g. a 402 Payment Required response) + const dispatch = vi.fn().mockReturnValue({ unwrap: () => Promise.reject(new AppError('Payment Required')) }); + + vi.spyOn(tasksService, 'create').mockReturnValue(taskId); + const updateTaskSpy = vi.spyOn(tasksService, 'updateTask').mockReturnValue(); + vi.spyOn(tasksService, 'getTasks').mockReturnValue([]); + // A file failure no longer marks the task as Error mid-flight; it stays in process until the + // whole folder finishes, at which point it is marked as Error (partial upload) by the manager + vi.spyOn(tasksService, 'findTask').mockReturnValue({ id: taskId, status: TaskStatus.InProcess } as never); + vi.spyOn(tasksService, 'addListener').mockReturnValue(); + vi.spyOn(tasksService, 'removeListener').mockReturnValue(); + vi.spyOn(errorService, 'castError').mockImplementation((e) => e as never); + + await uploadFoldersWithManager({ + payload: [ + { + currentFolderId: 'currentFolderId', + root: { + folderId: mockFolder.uuid, + childrenFiles: [new File([new ArrayBuffer(10)], 'failing.txt')], + childrenFolders: [], + name: mockFolder.name, + fullPathEdited: 'path1', + }, + options: { taskId }, + }, + ], + selectedWorkspace: null, + dispatch, + maxUploadFileSize: 100, + }); + + // The task must never be marked as Success + expect(updateTaskSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ merge: expect.objectContaining({ status: TaskStatus.Success }) }), + ); + // The task must be marked as Error (the subtitle key is resolved by i18n in the real app) + expect(updateTaskSpy).toHaveBeenCalledWith( + expect.objectContaining({ merge: expect.objectContaining({ status: TaskStatus.Error }) }), + ); + // The already-uploaded content must be preserved: the root folder must NOT be deleted + expect(deleteItemsThunk).not.toHaveBeenCalled(); + }); + describe('Handle File Uploads', () => { const taskId = 'task-id'; const mockCreatedFolder: DriveFolderData = { diff --git a/src/app/network/UploadFolderManager.ts b/src/app/network/UploadFolderManager.ts index abe9af51d..bd9909657 100644 --- a/src/app/network/UploadFolderManager.ts +++ b/src/app/network/UploadFolderManager.ts @@ -52,6 +52,9 @@ interface TaskInfo { itemsUploaded: number; totalItems: number; }; + // Set when at least one file failed to upload. We keep the already-uploaded content + // (no rollback) and mark the task as errored once the whole folder finishes. + hasFailedFiles?: boolean; } const generateTaskIdForFolders = async (foldersPayload: UploadFolderPayload[]): Promise => { @@ -217,7 +220,7 @@ export class UploadFoldersManager { ); } catch { if (uploadAttempts >= UploadFoldersManager.MAX_UPLOAD_ATTEMPTS) { - this.stopUploadTask(taskId, abortController); + await this.stopUploadTask(taskId, abortController); this.killQueueAndNotifyError(taskId); return; } @@ -287,9 +290,10 @@ export class UploadFoldersManager { ) .unwrap() .catch(() => { - this.stopUploadTask(taskId, abortController); - this.killQueueAndNotifyError(taskId); - return; + // A file failed to upload. Do NOT roll back: keep everything already uploaded and let + // the queue keep uploading the remaining files/folders (best-effort). The task is marked + // as errored once the whole folder finishes, in uploadFolderTask. + this.tasksInfo[taskId].hasFailedFiles = true; }); }; @@ -363,115 +367,160 @@ export class UploadFoldersManager { }); }; - public readonly run = async (): Promise => { - const payloadWithTaskId = await generateTaskIdForFolders(this.payload); + private readonly finalizeSuccessfulUpload = (taskFolder: TaskFolder, memberId?: string): void => { + const { root, taskId, options: payloadOptions } = taskFolder; + const options = { withNotification: true, ...payloadOptions }; - const memberId = this.selectedWorkspace?.workspaceUser?.memberId; + tasksService.updateTask({ + taskId, + merge: { + itemUUID: { rootFolderUUID: this.tasksInfo[taskId].rootFolderItem?.uuid }, + status: TaskStatus.Success, + }, + }); - const context = typeof window === 'undefined' ? self : window; + options.onSuccess?.(); + referralService.trackFolderUpload(); + logNetworkInfoForUpload({ folderName: root.name }); - let connectionLost = false; + setTimeout(() => { + this.dispatch(planThunks.fetchUsageThunk()); + if (memberId) this.dispatch(planThunks.fetchBusinessLimitUsageThunk()); + }, 1000); + }; - function connectionLostListener() { - connectionLost = true; - context.removeEventListener('offline', connectionLostListener); - } - context.addEventListener('offline', connectionLostListener); - for (const taskFolder of payloadWithTaskId) { - const { root, currentFolderId, options: payloadOptions, taskId } = taskFolder; - const options = { withNotification: true, ...payloadOptions }; + private readonly handleFolderUploadError = (err: unknown, taskId: string, connectionLost: boolean): boolean => { + const castedError = errorService.castError(err); + const updatedTask = tasksService.findTask(taskId); - this.tasksInfo[taskId] = { - progress: { - itemsUploaded: 0, - totalItems: countItemsUnderRoot(root), + if (connectionLost) { + tasksService.updateTask({ + taskId, + merge: { + status: TaskStatus.Error, + subtitle: t('error.connectionLostError') as string, }, - }; + }); + errorService.reportError(castedError); + return true; + } + const isActiveNonCancelledTask = updatedTask?.status !== TaskStatus.Cancelled && taskId === updatedTask?.id; + if (isActiveNonCancelledTask) { tasksService.updateTask({ taskId, merge: { - status: TaskStatus.InProcess, - progress: 0, + status: TaskStatus.Error, + subtitle: t('tasks.subtitles.upload-failed') as string, }, }); + errorService.reportError(castedError); + } - const cancelQueueListener = (task?: TaskData) => { - const isCurrentTask = task && task.id === taskId; - if (isCurrentTask && task.status === TaskStatus.Cancelled) { - this.uploadFoldersQueue.kill(); - } - }; - - const updateQueueListener = (task?: TaskData) => { - const isCurrentTask = task && task.id === taskId; - if (isCurrentTask && task.status === TaskStatus.InProcess) { - this.uploadFoldersQueue.resume(); - } else if (isCurrentTask && task.status === TaskStatus.Paused) { - this.uploadFoldersQueue.pause(); - } - }; + return false; + }; - tasksService.addListener({ event: TaskEvent.TaskCancelled, listener: cancelQueueListener }); - tasksService.addListener({ event: TaskEvent.TaskUpdated, listener: updateQueueListener }); + private readonly uploadFolderTask = async ( + taskFolder: TaskFolder, + memberId: string | undefined, + isConnectionLost: () => boolean, + ): Promise => { + const { root, currentFolderId, taskId } = taskFolder; - try { - root.folderId = currentFolderId; - await this.uploadFoldersQueue.pushAsync(taskFolder); + const taskAfterUpload = tasksService.findTask(taskId); - while (this.uploadFoldersQueue.running() > 0 || this.uploadFoldersQueue.length() > 0) { - await this.uploadFoldersQueue.drain(); - } + this.tasksInfo[taskId] = { + progress: { + itemsUploaded: 0, + totalItems: countItemsUnderRoot(root), + }, + }; - if (connectionLost) throw new ConnectionLostError(); + tasksService.updateTask({ + taskId, + merge: { + status: TaskStatus.InProcess, + progress: 0, + }, + }); + + const cancelQueueListener = (task?: TaskData) => { + const isCurrentTask = task && task.id === taskId; + if (isCurrentTask && task.status === TaskStatus.Cancelled) { + this.uploadFoldersQueue.kill(); + } + }; + + const updateQueueListener = (task?: TaskData) => { + const isCurrentTask = task && task.id === taskId; + if (isCurrentTask && task.status === TaskStatus.InProcess) { + this.uploadFoldersQueue.resume(); + } else if (isCurrentTask && task.status === TaskStatus.Paused) { + this.uploadFoldersQueue.pause(); + } + }; + + tasksService.addListener({ event: TaskEvent.TaskCancelled, listener: cancelQueueListener }); + tasksService.addListener({ event: TaskEvent.TaskUpdated, listener: updateQueueListener }); + + try { + root.folderId = currentFolderId; + await this.uploadFoldersQueue.pushAsync(taskFolder); + + while (this.uploadFoldersQueue.running() > 0 || this.uploadFoldersQueue.length() > 0) { + await this.uploadFoldersQueue.drain(); + } + if (isConnectionLost()) throw new ConnectionLostError(); + + const taskFailed = + taskAfterUpload?.status === TaskStatus.Error || taskAfterUpload?.status === TaskStatus.Cancelled; + if (taskFailed) { + return false; + } + + if (this.tasksInfo[taskId].hasFailedFiles) { + // Some files failed but the already-uploaded content was preserved. Mark the task as + // errored with an explanatory subtitle instead of rolling back the partial upload. tasksService.updateTask({ - taskId: taskId, + taskId, merge: { - itemUUID: { rootFolderUUID: this.tasksInfo[taskId].rootFolderItem?.uuid }, - status: TaskStatus.Success, + status: TaskStatus.Error, + subtitle: t('tasks.subtitles.upload-partially-failed') as string, }, }); - - options.onSuccess?.(); - referralService.trackFolderUpload(); - logNetworkInfoForUpload({ folderName: root.name }); - - setTimeout(() => { - this.dispatch(planThunks.fetchUsageThunk()); - if (memberId) this.dispatch(planThunks.fetchBusinessLimitUsageThunk()); - }, 1000); - } catch (err: unknown) { - const castedError = errorService.castError(err); - const updatedTask = tasksService.findTask(taskId); - - if (connectionLost) { - tasksService.updateTask({ - taskId: taskId, - merge: { - status: TaskStatus.Error, - subtitle: t('error.connectionLostError') as string, - }, - }); - errorService.reportError(castedError); - break; - } else if (updatedTask?.status !== TaskStatus.Cancelled && taskId === updatedTask?.id) { - tasksService.updateTask({ - taskId: taskId, - merge: { - status: TaskStatus.Error, - subtitle: t('tasks.subtitles.upload-failed') as string, - }, - }); - // 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 { - tasksService.removeListener({ event: TaskEvent.TaskCancelled, listener: cancelQueueListener }); - tasksService.removeListener({ event: TaskEvent.TaskUpdated, listener: updateQueueListener }); + return false; } + + this.finalizeSuccessfulUpload(taskFolder, memberId); + return false; + } catch (err: unknown) { + return this.handleFolderUploadError(err, taskId, isConnectionLost()); + } finally { + tasksService.removeListener({ event: TaskEvent.TaskCancelled, listener: cancelQueueListener }); + tasksService.removeListener({ event: TaskEvent.TaskUpdated, listener: updateQueueListener }); + } + }; + + public readonly run = async (): Promise => { + const payloadWithTaskId = await generateTaskIdForFolders(this.payload); + + const memberId = this.selectedWorkspace?.workspaceUser?.memberId; + + 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 payloadWithTaskId) { + const shouldStop = await this.uploadFolderTask(taskFolder, memberId, () => connectionLost); + if (shouldStop) break; } }; } From 13417f67be77a9ffbf513eff821e1e6d468c92eb Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Tue, 7 Jul 2026 11:13:52 +0200 Subject: [PATCH 2/6] fix: file fail deletes folder upload progress --- src/app/core/constants.ts | 3 +- src/app/i18n/locales/de.json | 3 +- src/app/i18n/locales/en.json | 3 +- src/app/i18n/locales/es.json | 3 +- src/app/i18n/locales/fr.json | 3 +- src/app/i18n/locales/it.json | 3 +- src/app/i18n/locales/ru.json | 3 +- src/app/i18n/locales/tw.json | 3 +- src/app/i18n/locales/zh.json | 3 +- src/app/network/RetryManager.ts | 1 + src/app/network/UploadFolderManager.test.ts | 83 ++++++++++++++ src/app/network/UploadFolderManager.ts | 8 +- src/app/network/UploadManager.test.ts | 102 ++++++++++++++++++ src/app/network/UploadManager.ts | 51 +++++++-- .../components/TaskLogger/TaskLogger.tsx | 2 +- .../TaskToRetryItem/TaskToRetryItem.test.tsx | 9 ++ .../TaskToRetryItem/TaskToRetryItem.tsx | 12 ++- 17 files changed, 270 insertions(+), 25 deletions(-) diff --git a/src/app/core/constants.ts b/src/app/core/constants.ts index 6a510e392..3241a6627 100644 --- a/src/app/core/constants.ts +++ b/src/app/core/constants.ts @@ -1,8 +1,9 @@ export const HUNDRED_TB = 109951162777600; export const HTTP_STATUS_CODES = { - MAX_SPACE_USED: 420, + PAYMENT_REQUIRED: 402, FORBIDDEN: 403, NOT_FOUND: 404, + MAX_SPACE_USED: 420, INTERNAL_SERVER_ERROR: 500, }; export enum ErrorMessages { diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index 62f4073be..f6c3213cd 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -1291,7 +1291,8 @@ "allProcessesHaveFinished": "Alle Prozesse sind abgeschlossen", "processing": "Wird bearbeitet {{pending}} von {{finished}}", "failedToUpload": "Fehler beim Hochladen", - "failedToDownload": "Fehler beim Herunterladen" + "failedToDownload": "Fehler beim Herunterladen", + "notAllowed": "Nicht erlaubt" }, "waiting": "Warten", "paused": "Pausiert", diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index 207c088f3..e455ad9a1 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -1376,7 +1376,8 @@ "allProcessesHaveFinished": "All processes have finished", "processing": "Processing {{pending}} of {{finished}}", "failedToUpload": "Failed to upload", - "failedToDownload": "Failed to download" + "failedToDownload": "Failed to download", + "notAllowed": "Not allowed" }, "waiting": "Waiting", "paused": "Paused", diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index 940cbe848..3f293c8da 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -1353,7 +1353,8 @@ "allProcessesHaveFinished": "Todos los procesos han finalizado", "processing": "Procesando {{pending}} de {{finished}}", "failedToUpload": "Error al subir", - "failedToDownload": "Error al descargar" + "failedToDownload": "Error al descargar", + "notAllowed": "No permitido" }, "waiting": "Esperando", "paused": "Pausado", diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index a5d011139..1d100d241 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -1304,7 +1304,8 @@ "allProcessesHaveFinished": "Tous les processus ont été complétés", "processing": "Traitement {{pending}} de {{finished}}", "failedToUpload": "Échec du téléchargement", - "failedToDownload": "Échec du téléchargement" + "failedToDownload": "Échec du téléchargement", + "notAllowed": "Non autorisé" }, "waiting": "En attente", "paused": "En pause", diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index 8b2572217..84ab3e0df 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -1411,7 +1411,8 @@ "allProcessesHaveFinished": "Tutti i processi sono stati completati", "processing": "Elaborazione {{pending}} di {{finished}}", "failedToUpload": "Caricamento fallito", - "failedToDownload": "Download fallito" + "failedToDownload": "Download fallito", + "notAllowed": "Non consentito" }, "waiting": "In attesa", "paused": "In pausa", diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index 8f104d487..2766f7e6c 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -1319,7 +1319,8 @@ "allProcessesHaveFinished": "Все процессы завершены", "processing": "Обработка {{pending}} из {{finished}}", "failedToUpload": "Не удалось загрузить", - "failedToDownload": "Не удалось загрузить" + "failedToDownload": "Не удалось загрузить", + "notAllowed": "Не разрешено" }, "waiting": "Ожидание", "paused": "Приостановлено", diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index 3490474db..ae7abe4d4 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -1306,7 +1306,8 @@ "allProcessesHaveFinished": "所有進程已完成", "processing": "處理中 {{pending}} 個,共 {{finished}} 個", "failedToUpload": "上傳失敗", - "failedToDownload": "下載失敗" + "failedToDownload": "下載失敗", + "notAllowed": "不允許" }, "waiting": "等待中", "paused": "已暫停", diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index 391a69012..3ebc502b2 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -1341,7 +1341,8 @@ "allProcessesHaveFinished": "所有处理都已完成", "processing": "正在处理 {{pending}}/{{finished}}", "failedToUpload": "上傳失敗", - "failedToDownload": "下載失敗" + "failedToDownload": "下載失敗", + "notAllowed": "不允許" }, "waiting": "等待中", "paused": "暂停", diff --git a/src/app/network/RetryManager.ts b/src/app/network/RetryManager.ts index bb3a1e114..b2bf13508 100644 --- a/src/app/network/RetryManager.ts +++ b/src/app/network/RetryManager.ts @@ -3,6 +3,7 @@ export type RetryableTask = { type: 'upload' | 'download'; params: any; status?: 'pending' | 'failed' | 'retrying'; + retryable?: boolean; }; class RetryManager { diff --git a/src/app/network/UploadFolderManager.test.ts b/src/app/network/UploadFolderManager.test.ts index a62c27161..7d6f0acb4 100644 --- a/src/app/network/UploadFolderManager.test.ts +++ b/src/app/network/UploadFolderManager.test.ts @@ -12,6 +12,7 @@ import { FilesExceedsSizeLimitError } from 'app/drive/services/file.service/uplo import { uploadItemsParallelThunk } from 'app/store/slices/storage/storage.thunks/uploadItemsThunk'; import { deleteItemsThunk } from '../store/slices/storage/storage.thunks/deleteItemsThunk'; import { TaskStatus } from 'app/tasks/types'; +import newStorageService from 'app/drive/services/new-storage.service'; vi.mock('app/drive/services/new-storage.service', () => ({ default: { @@ -510,6 +511,88 @@ describe('checkUploadFolders', () => { }); }); + it('should keep uploading the rest of the tree and not delete the root folder when a file fails', async () => { + const mockRootFolder: DriveFolderData = { + id: 1, + uuid: 'root-uuid', + name: 'Root', + 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: 'Root', + deleted: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + const mockChildFolder: DriveFolderData = { ...mockRootFolder, id: 2, uuid: 'child-uuid', name: 'Child' }; + const taskId = 'task-id'; + const failingFile = new File([new ArrayBuffer(0)], 'empty.txt'); + + const createFolderSpy = (createFolder as Mock) + .mockResolvedValueOnce(mockRootFolder) + .mockResolvedValueOnce(mockChildFolder); + (checkFolderDuplicated as Mock).mockResolvedValueOnce({ + duplicatedFoldersResponse: [] as DriveFolderData[], + foldersWithDuplicates: [] as DriveFolderData[], + foldersWithoutDuplicates: [mockRootFolder], + }); + + const failingDispatch = vi + .fn() + .mockReturnValue({ unwrap: () => Promise.reject(new AppError('Payment required', 402)) }); + + const updateTaskSpy = vi.spyOn(tasksService, 'updateTask').mockReturnValue(); + vi.spyOn(tasksService, 'create').mockReturnValue(taskId); + vi.spyOn(tasksService, 'getTasks').mockReturnValue([]); + vi.spyOn(tasksService, 'addListener').mockReturnValue(); + vi.spyOn(tasksService, 'removeListener').mockReturnValue(); + + await uploadFoldersWithManager({ + payload: [ + { + currentFolderId: 'currentFolderId', + root: { + folderId: mockRootFolder.uuid, + childrenFiles: [failingFile], + childrenFolders: [ + { + folderId: mockChildFolder.uuid, + childrenFiles: [], + childrenFolders: [], + name: mockChildFolder.name, + fullPathEdited: 'path/child', + }, + ], + name: mockRootFolder.name, + fullPathEdited: 'path', + }, + options: { taskId }, + }, + ], + selectedWorkspace: null, + dispatch: failingDispatch, + maxUploadFileSize: 100, + }); + + // The subfolder was still created -> the queue kept processing after the file failure + expect(createFolderSpy).toHaveBeenCalledTimes(2); + // The already-uploaded content was preserved: the root folder was NOT deleted + expect(newStorageService.deleteFolderByUuid as Mock).not.toHaveBeenCalled(); + // The folder task ends as Error (partial upload), never as Success + const mergedStatuses = updateTaskSpy.mock.calls.map((call) => call[0]?.merge?.status); + expect(mergedStatuses).toContain(TaskStatus.Error); + expect(mergedStatuses).not.toContain(TaskStatus.Success); + }); + it('should abort the upload if abortController is called', async () => { const mockParentFolder: DriveFolderData = { id: 1, diff --git a/src/app/network/UploadFolderManager.ts b/src/app/network/UploadFolderManager.ts index 56a3649bd..c25607223 100644 --- a/src/app/network/UploadFolderManager.ts +++ b/src/app/network/UploadFolderManager.ts @@ -385,7 +385,6 @@ export class UploadFoldersManager { }, 1000); }; - private readonly handleFolderUploadError = (err: unknown, taskId: string, connectionLost: boolean): boolean => { const castedError = errorService.castError(err); const updatedTask = tasksService.findTask(taskId); @@ -424,8 +423,6 @@ export class UploadFoldersManager { ): Promise => { const { root, currentFolderId, taskId } = taskFolder; - const taskAfterUpload = tasksService.findTask(taskId); - this.tasksInfo[taskId] = { progress: { itemsUploaded: 0, @@ -470,8 +467,11 @@ export class UploadFoldersManager { if (isConnectionLost()) throw new ConnectionLostError(); + // Re-read the task now that the upload has finished: it may have been marked Error (a fatal + // failure such as folder creation failing) or Cancelled while the queue was running. + const finishedTask = tasksService.findTask(taskId); const taskFailed = - taskAfterUpload?.status === TaskStatus.Error || taskAfterUpload?.status === TaskStatus.Cancelled; + finishedTask?.status === TaskStatus.Error || finishedTask?.status === TaskStatus.Cancelled; if (taskFailed) { return false; } diff --git a/src/app/network/UploadManager.test.ts b/src/app/network/UploadManager.test.ts index df4d27162..d69a04f8c 100644 --- a/src/app/network/UploadManager.test.ts +++ b/src/app/network/UploadManager.test.ts @@ -562,6 +562,108 @@ describe('checkUploadFiles', () => { }); }); + it('should keep uploading the remaining folder files when one file fails, then reject with the original error', async () => { + const bigSize = 60 * 1024 * 1024; + const failError = new AppError('Payment required', 402); + (uploadFile as Mock).mockImplementation((_email: string, file: { name: string }) => + file.name === 'fail.txt' ? Promise.reject(failError) : Promise.resolve(mockFile2), + ); + + vi.spyOn(tasksService, 'create').mockReturnValue('taskId'); + vi.spyOn(tasksService, 'updateTask').mockReturnValue(); + vi.spyOn(tasksService, 'addListener').mockReturnValue(); + vi.spyOn(tasksService, 'removeListener').mockReturnValue(); + vi.spyOn(errorService, 'castError').mockImplementation((e) => e as AppError); + vi.spyOn(errorService, 'reportError').mockReturnValue(); + + await expect( + uploadFileWithManager({ + files: [ + { + taskId: 'taskId', + relatedTaskId: 'folder-task', + filecontent: { + content: 'c1' as unknown as File, + type: 'text/plain', + name: 'fail.txt', + size: bigSize, + parentFolderId: 'folder-1', + }, + userEmail: '', + parentFolderId: 'folder-1', + }, + { + taskId: 'taskId2', + relatedTaskId: 'folder-task', + filecontent: { + content: 'c2' as unknown as File, + type: 'text/plain', + name: 'ok.txt', + size: bigSize, + parentFolderId: 'folder-1', + }, + userEmail: '', + parentFolderId: 'folder-1', + }, + ], + maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialogMock, + uploadRepository: DatabaseUploadRepository.getInstance(), + options: { + ownerUserAuthenticationData: undefined, + sharedItemData: { isDeepFolder: false, currentFolderId: 'parentFolderId' }, + isUploadedFromFolder: true, + }, + }), + ).rejects.toThrow(failError); + + const uploadedNames = (uploadFile as Mock).mock.calls.map((call) => call[1]?.name); + expect(uploadedNames).toContain('ok.txt'); + }); + + it('should not retry a not-allowed file (402) and mark it as non-retryable in RetryManager', async () => { + const notAllowedError = new AppError('Payment required', 402); + const uploadFileSpy = (uploadFile as Mock).mockRejectedValue(notAllowedError); + + vi.spyOn(tasksService, 'create').mockReturnValue('taskId'); + vi.spyOn(tasksService, 'updateTask').mockReturnValue(); + vi.spyOn(tasksService, 'addListener').mockReturnValue(); + vi.spyOn(tasksService, 'removeListener').mockReturnValue(); + vi.spyOn(errorService, 'castError').mockImplementation((e) => e as AppError); + vi.spyOn(errorService, 'reportError').mockReturnValue(); + + await expect( + uploadFileWithManager({ + files: [ + { + taskId: 'taskId', + relatedTaskId: 'folder-task', + filecontent: { + content: 'file-content' as unknown as File, + type: 'text/plain', + name: 'empty.txt', + size: 0, + parentFolderId: 'folder-1', + }, + userEmail: '', + parentFolderId: 'folder-1', + }, + ], + maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialogMock, + uploadRepository: DatabaseUploadRepository.getInstance(), + options: { + ownerUserAuthenticationData: undefined, + sharedItemData: { isDeepFolder: false, currentFolderId: 'parentFolderId' }, + isUploadedFromFolder: true, + }, + }), + ).rejects.toThrow(notAllowedError); + + expect(uploadFileSpy).toHaveBeenCalledTimes(1); + const retryTasks = RetryManager.getTasks(); + expect(retryTasks).toHaveLength(1); + expect(retryTasks[0].retryable).toBe(false); + }); + it('When uploading a file to a workspace, then it uses workspace credentials and same bucket for file and thumbnail', async () => { const workspaceBucket = 'workspace-bucket-123'; const workspaceId = 'workspace-id-456'; diff --git a/src/app/network/UploadManager.ts b/src/app/network/UploadManager.ts index aba8125f3..7776005c3 100644 --- a/src/app/network/UploadManager.ts +++ b/src/app/network/UploadManager.ts @@ -84,6 +84,9 @@ export const uploadFileWithManager = ({ class UploadManager { private currentGroupBeingUploaded: FileSizeType = FileSizeType.Small; private errored = false; + private someUploadFailed = false; + private firstUploadError?: Error; + private readonly nonRetryableTaskIds = new Set(); private uploadsProgress: Record = {}; private readonly abortController?: AbortController; private readonly items: UploadManagerFileParams[]; @@ -256,8 +259,9 @@ class UploadManager { !!this.abortController?.signal.aborted || !!fileData.abortController?.signal.aborted || error === 'abort'; const isLostConnectionError = error instanceof ConnectionLostError || error.message === ErrorMessages.NetworkError; + const isNonRetryableError = UploadManager.isNonRetryableError(error, errorService.castError(error)); - if (uploadAttempts < MAX_UPLOAD_ATTEMPTS && !isUploadAborted && !isLostConnectionError) { + if (uploadAttempts < MAX_UPLOAD_ATTEMPTS && !isUploadAborted && !isLostConnectionError && !isNonRetryableError) { upload(); } else { this.handleUploadErrors({ @@ -307,6 +311,13 @@ class UploadManager { this.onFileUploadCallback = onFileUploadCallback; } + private static isNonRetryableError(error: any, castedError: { code?: string }): boolean { + return ( + error?.status === HTTP_STATUS_CODES.PAYMENT_REQUIRED || + castedError?.code === HTTP_CODE_ERRORS.FILE_UPLOAD_SIZE_EXCEEDED + ); + } + private handleUploadErrors({ error, fileData, @@ -347,6 +358,13 @@ class UploadManager { const isUnexpectedError = task?.status !== TaskStatus.Cancelled && !isUploadAborted; + const isMaxSpaceError = error?.status === HTTP_STATUS_CODES.MAX_SPACE_USED; + const continueWithRemainingFiles = !!this.options?.isUploadedFromFolder && !isMaxSpaceError; + + if (UploadManager.isNonRetryableError(error, castedError)) { + this.nonRetryableTaskIds.add(taskId); + } + // Handle unexpected errors if (isUnexpectedError) { tasksService.updateTask({ @@ -361,15 +379,17 @@ class UploadManager { } // Handle max space used error - if (error?.status === HTTP_STATUS_CODES.MAX_SPACE_USED) { + if (isMaxSpaceError) { this.maxSpaceOccupiedCallback(); } - // Ensure upload queue is killed and errored is set - if (!this.errored) { - this.uploadQueue.kill(); + // Ensure upload queue is killed and errored is set, unless we keep going in a folder upload + if (!continueWithRemainingFiles) { + if (!this.errored) { + this.uploadQueue.kill(); + } + this.errored = true; } - this.errored = true; } // Handle upload aborted @@ -382,6 +402,13 @@ class UploadManager { return; } + if (continueWithRemainingFiles) { + this.someUploadFailed = true; + if (!this.firstUploadError) this.firstUploadError = castedError; + next(null); + return; + } + // If relatedTaskId is present, kill upload queue if (fileData.relatedTaskId) { this.uploadQueue.kill(); @@ -568,12 +595,16 @@ class UploadManager { for (let i = 0; i < uploadedFiles.length; i++) { const uploadedFile = uploadedFiles[i]; if (uploadedFile) uploadedFilesData.push(uploadedFile); - else + else { + const retryTaskId = files[i]?.taskId ?? files[i]?.relatedTaskId ?? ''; filesToRetry.push({ - taskId: files[i]?.taskId ?? files[i]?.relatedTaskId ?? '', + taskId: retryTaskId, type: 'upload', params: files[i], + // Not-allowed files stay visible in the list but cannot be retried. + retryable: !this.nonRetryableTaskIds.has(files[i]?.taskId ?? ''), }); + } } if (filesToRetry.length > 0) RetryManager.addTasks(filesToRetry); @@ -595,6 +626,10 @@ class UploadManager { if (bigSizedFiles.length > 0) await uploadFiles(bigSizedFiles, this.filesGroups.big.concurrency); + if (this.someUploadFailed) { + throw this.firstUploadError ?? new Error(t('error.uploadingItems') as string); + } + return { uploadedFiles: uploadedFilesData }; } catch (error) { this.handleFailedUploads(filesWithTaskId); diff --git a/src/app/tasks/components/TaskLogger/TaskLogger.tsx b/src/app/tasks/components/TaskLogger/TaskLogger.tsx index 7bb08afd7..33c3440ce 100644 --- a/src/app/tasks/components/TaskLogger/TaskLogger.tsx +++ b/src/app/tasks/components/TaskLogger/TaskLogger.tsx @@ -27,7 +27,7 @@ const TaskLogger = (): JSX.Element => { const filesToRetryGroupedByTask = useMemo( () => filesToRetry.reduce>((acc, file) => { - const relatedTaskId = file.taskId; + const relatedTaskId = file.params?.relatedTaskId ?? file.taskId; if (relatedTaskId) { if (!acc[relatedTaskId]) { acc[relatedTaskId] = []; diff --git a/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.test.tsx b/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.test.tsx index ed528eaf0..59817be49 100644 --- a/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.test.tsx +++ b/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.test.tsx @@ -58,6 +58,15 @@ describe('TaskToRetyItem', () => { expect(mockDownloadItem).toHaveBeenCalledWith(mockFile); }); + it('should not render a retry button and show the not-allowed label when the file is non-retryable', () => { + const notAllowedFile = { ...mockFile, retryable: false }; + const notAllowedProps = { ...defaultProps, data: { files: [notAllowedFile], downloadItem: mockDownloadItem } }; + const { queryByRole, getByText } = render(); + + expect(queryByRole('button')).not.toBeInTheDocument(); + expect(getByText('tasks.messages.notAllowed')).toBeInTheDocument(); + }); + it('should render a spinner when the file status is "retrying"', () => { const uploadingFile = { ...mockFile, status: 'retrying' }; const uploadingProps = { ...defaultProps, data: { files: [uploadingFile], downloadItem: mockDownloadItem } }; diff --git a/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.tsx b/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.tsx index e2b407799..20a60621f 100644 --- a/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.tsx +++ b/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.tsx @@ -10,8 +10,9 @@ import { RetryableTask } from 'app/network/RetryManager'; const TaskToRetyItem = ({ index, style, data }: ListChildComponentProps) => { const file: RetryableTask = data.files[index]; - const { params, status } = file; + const { params, status, retryable } = file; const { downloadItem } = data; + const isNotAllowed = retryable === false; const getFileIcon = (type: string) => { const IconComponent = iconService.getItemIcon(false, type); return ; @@ -33,8 +34,13 @@ const TaskToRetyItem = ({ index, style, data }: ListChildComponentProps) => {

- {status === 'failed' && downloadItem(file)} Icon={RestartIcon} />} - {status === 'retrying' && } + {isNotAllowed && {t('tasks.messages.notAllowed')}} + {!isNotAllowed && status === 'failed' && ( + downloadItem(file)} Icon={RestartIcon} /> + )} + {!isNotAllowed && status === 'retrying' && ( + + )} ); }; From 19da715059d62a17ffb0b66931142b8305fc6706 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Tue, 7 Jul 2026 11:51:49 +0200 Subject: [PATCH 3/6] feat: add coverage --- src/app/network/UploadFolderManager.test.ts | 173 ++++++++++++++++ src/app/network/UploadFolderManager.ts | 219 ++++++++------------ 2 files changed, 260 insertions(+), 132 deletions(-) diff --git a/src/app/network/UploadFolderManager.test.ts b/src/app/network/UploadFolderManager.test.ts index 7d6f0acb4..6f759d452 100644 --- a/src/app/network/UploadFolderManager.test.ts +++ b/src/app/network/UploadFolderManager.test.ts @@ -13,6 +13,7 @@ import { uploadItemsParallelThunk } from 'app/store/slices/storage/storage.thunk import { deleteItemsThunk } from '../store/slices/storage/storage.thunks/deleteItemsThunk'; import { TaskStatus } from 'app/tasks/types'; import newStorageService from 'app/drive/services/new-storage.service'; +import referralService from 'services/referral.service'; vi.mock('app/drive/services/new-storage.service', () => ({ default: { @@ -85,6 +86,29 @@ vi.mock('services/error.service', () => ({ }, })); +const buildFolder = (overrides: Partial = {}): DriveFolderData => ({ + id: 0, + uuid: 'uuid', + name: 'Folder', + 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: 'Folder', + deleted: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, +}); + describe('checkUploadFolders', () => { const mockDispatch = vi.fn(); @@ -593,6 +617,155 @@ describe('checkUploadFolders', () => { expect(mergedStatuses).not.toContain(TaskStatus.Success); }); + it('should mark the task as Success and run the success side-effects when the whole folder uploads', async () => { + const mockFolder = buildFolder({ uuid: 'ok-uuid', name: 'OkFolder' }); + const taskId = 'task-id'; + const onSuccess = vi.fn(); + + (createFolder as Mock).mockResolvedValueOnce(mockFolder); + (checkFolderDuplicated as Mock).mockResolvedValueOnce({ + duplicatedFoldersResponse: [] as DriveFolderData[], + foldersWithDuplicates: [] as DriveFolderData[], + foldersWithoutDuplicates: [mockFolder], + }); + + const updateTaskSpy = vi.spyOn(tasksService, 'updateTask').mockReturnValue(); + vi.spyOn(tasksService, 'create').mockReturnValue(taskId); + vi.spyOn(tasksService, 'findTask').mockReturnValue({ id: taskId, status: TaskStatus.InProcess } as never); + vi.spyOn(tasksService, 'addListener').mockReturnValue(); + vi.spyOn(tasksService, 'removeListener').mockReturnValue(); + + await uploadFoldersWithManager({ + payload: [ + { + currentFolderId: 'currentFolderId', + root: { + folderId: mockFolder.uuid, + childrenFiles: [], + childrenFolders: [], + name: mockFolder.name, + fullPathEdited: 'path', + }, + options: { taskId, onSuccess }, + }, + ], + selectedWorkspace: null, + dispatch: mockDispatch, + maxUploadFileSize: 100, + }); + + expect(updateTaskSpy).toHaveBeenCalledWith( + expect.objectContaining({ taskId, merge: expect.objectContaining({ status: TaskStatus.Success }) }), + ); + expect(onSuccess).toHaveBeenCalledOnce(); + expect(referralService.trackFolderUpload as Mock).toHaveBeenCalledOnce(); + }); + + it('should keep the task as Error (not overwrite with Success) when the folder cannot be created', async () => { + const taskId = 'task-id'; + + // Both creation attempts fail (MAX_UPLOAD_ATTEMPTS = 2) + const createFolderSpy = (createFolder as Mock).mockRejectedValue(new Error('creation failed')); + (checkFolderDuplicated as Mock).mockResolvedValueOnce({ + duplicatedFoldersResponse: [] as DriveFolderData[], + foldersWithDuplicates: [] as DriveFolderData[], + foldersWithoutDuplicates: [] as DriveFolderData[], + }); + + const updateTaskSpy = vi.spyOn(tasksService, 'updateTask').mockReturnValue(); + vi.spyOn(tasksService, 'create').mockReturnValue(taskId); + vi.spyOn(tasksService, 'getTasks').mockReturnValue([]); + // killQueueAndNotifyError already marked the task as Error; run() must not overwrite it + vi.spyOn(tasksService, 'findTask').mockReturnValue({ id: taskId, status: TaskStatus.Error } as never); + vi.spyOn(tasksService, 'addListener').mockReturnValue(); + vi.spyOn(tasksService, 'removeListener').mockReturnValue(); + + await uploadFoldersWithManager({ + payload: [ + { + currentFolderId: 'currentFolderId', + root: { + folderId: 'root', + childrenFiles: [], + childrenFolders: [], + name: 'F', + fullPathEdited: 'path', + }, + options: { taskId }, + }, + ], + selectedWorkspace: null, + dispatch: mockDispatch, + maxUploadFileSize: 100, + }); + + // Retried twice, then gave up + expect(createFolderSpy).toHaveBeenCalledTimes(2); + const mergedStatuses = updateTaskSpy.mock.calls.map((call) => call[0]?.merge?.status); + expect(mergedStatuses).not.toContain(TaskStatus.Success); + }); + + it('should continue with the next folder when a previous folder fails partially', async () => { + const folderA = buildFolder({ uuid: 'a-uuid', name: 'A' }); + const folderB = buildFolder({ uuid: 'b-uuid', name: 'B' }); + + const createFolderSpy = (createFolder as Mock).mockResolvedValueOnce(folderA).mockResolvedValueOnce(folderB); + (checkFolderDuplicated as Mock).mockResolvedValue({ + duplicatedFoldersResponse: [] as DriveFolderData[], + foldersWithDuplicates: [] as DriveFolderData[], + foldersWithoutDuplicates: [] as DriveFolderData[], + }); + + // Only folder A has a (failing) file; folder B has none and must still be uploaded + const failingDispatch = vi + .fn() + .mockReturnValue({ unwrap: () => Promise.reject(new AppError('Payment required', 402)) }); + + const updateTaskSpy = vi.spyOn(tasksService, 'updateTask').mockReturnValue(); + vi.spyOn(tasksService, 'create').mockReturnValue('task-id'); + vi.spyOn(tasksService, 'getTasks').mockReturnValue([]); + vi.spyOn(tasksService, 'findTask').mockReturnValue({ id: 'task-b', status: TaskStatus.InProcess } as never); + vi.spyOn(tasksService, 'addListener').mockReturnValue(); + vi.spyOn(tasksService, 'removeListener').mockReturnValue(); + + await uploadFoldersWithManager({ + payload: [ + { + currentFolderId: 'currentFolderId', + root: { + folderId: folderA.uuid, + childrenFiles: [new File([new ArrayBuffer(10)], 'a.txt')], + childrenFolders: [], + name: folderA.name, + fullPathEdited: 'a', + }, + options: { taskId: 'task-a' }, + }, + { + currentFolderId: 'currentFolderId', + root: { + folderId: folderB.uuid, + childrenFiles: [], + childrenFolders: [], + name: folderB.name, + fullPathEdited: 'b', + }, + options: { taskId: 'task-b' }, + }, + ], + selectedWorkspace: null, + dispatch: failingDispatch, + maxUploadFileSize: 100, + }); + + // Both folders were processed -> the loop did not stop on folder A's partial failure + expect(createFolderSpy).toHaveBeenCalledTimes(2); + // Folder B finished successfully + expect(updateTaskSpy).toHaveBeenCalledWith( + expect.objectContaining({ taskId: 'task-b', merge: expect.objectContaining({ status: TaskStatus.Success }) }), + ); + }); + it('should abort the upload if abortController is called', async () => { const mockParentFolder: DriveFolderData = { id: 1, diff --git a/src/app/network/UploadFolderManager.ts b/src/app/network/UploadFolderManager.ts index c25607223..b6cc5f948 100644 --- a/src/app/network/UploadFolderManager.ts +++ b/src/app/network/UploadFolderManager.ts @@ -363,160 +363,115 @@ export class UploadFoldersManager { }); }; - private readonly finalizeSuccessfulUpload = (taskFolder: TaskFolder, memberId?: string): void => { - const { root, taskId, options: payloadOptions } = taskFolder; - const options = { withNotification: true, ...payloadOptions }; - - tasksService.updateTask({ - taskId, - merge: { - itemUUID: { rootFolderUUID: this.tasksInfo[taskId].rootFolderItem?.uuid }, - status: TaskStatus.Success, - }, - }); + public readonly run = async (): Promise => { + const payloadWithTaskId = await generateTaskIdForFolders(this.payload); - options.onSuccess?.(); - referralService.trackFolderUpload(); - logNetworkInfoForUpload({ folderName: root.name }); + const memberId = this.selectedWorkspace?.workspaceUser?.memberId; - setTimeout(() => { - this.dispatch(planThunks.fetchUsageThunk()); - if (memberId) this.dispatch(planThunks.fetchBusinessLimitUsageThunk()); - }, 1000); - }; + const context = typeof window === 'undefined' ? self : window; - private readonly handleFolderUploadError = (err: unknown, taskId: string, connectionLost: boolean): boolean => { - const castedError = errorService.castError(err); - const updatedTask = tasksService.findTask(taskId); + let connectionLost = false; - if (connectionLost) { - tasksService.updateTask({ - taskId, - merge: { - status: TaskStatus.Error, - subtitle: t('error.connectionLostError') as string, - }, - }); - errorService.reportError(castedError); - return true; + function connectionLostListener() { + connectionLost = true; + context.removeEventListener('offline', connectionLostListener); } + context.addEventListener('offline', connectionLostListener); - const isActiveNonCancelledTask = updatedTask?.status !== TaskStatus.Cancelled && taskId === updatedTask?.id; - if (isActiveNonCancelledTask) { - tasksService.updateTask({ - taskId, - merge: { - status: TaskStatus.Error, - subtitle: t('tasks.subtitles.upload-failed') as string, + for (const taskFolder of payloadWithTaskId) { + const { root, currentFolderId, options: payloadOptions, taskId } = taskFolder; + const options = { withNotification: true, ...payloadOptions }; + + this.tasksInfo[taskId] = { + progress: { + itemsUploaded: 0, + totalItems: countItemsUnderRoot(root), }, - }); - errorService.reportError(castedError); - } + }; - return false; - }; + tasksService.updateTask({ taskId, merge: { status: TaskStatus.InProcess, progress: 0 } }); - private readonly uploadFolderTask = async ( - taskFolder: TaskFolder, - memberId: string | undefined, - isConnectionLost: () => boolean, - ): Promise => { - const { root, currentFolderId, taskId } = taskFolder; - - this.tasksInfo[taskId] = { - progress: { - itemsUploaded: 0, - totalItems: countItemsUnderRoot(root), - }, - }; + const cancelQueueListener = (task?: TaskData) => { + if (task?.id === taskId && task.status === TaskStatus.Cancelled) this.uploadFoldersQueue.kill(); + }; - tasksService.updateTask({ - taskId, - merge: { - status: TaskStatus.InProcess, - progress: 0, - }, - }); - - const cancelQueueListener = (task?: TaskData) => { - const isCurrentTask = task && task.id === taskId; - if (isCurrentTask && task.status === TaskStatus.Cancelled) { - this.uploadFoldersQueue.kill(); - } - }; - - const updateQueueListener = (task?: TaskData) => { - const isCurrentTask = task && task.id === taskId; - if (isCurrentTask && task.status === TaskStatus.InProcess) { - this.uploadFoldersQueue.resume(); - } else if (isCurrentTask && task.status === TaskStatus.Paused) { - this.uploadFoldersQueue.pause(); - } - }; + const updateQueueListener = (task?: TaskData) => { + if (task?.id !== taskId) return; + if (task.status === TaskStatus.InProcess) this.uploadFoldersQueue.resume(); + else if (task.status === TaskStatus.Paused) this.uploadFoldersQueue.pause(); + }; - tasksService.addListener({ event: TaskEvent.TaskCancelled, listener: cancelQueueListener }); - tasksService.addListener({ event: TaskEvent.TaskUpdated, listener: updateQueueListener }); + tasksService.addListener({ event: TaskEvent.TaskCancelled, listener: cancelQueueListener }); + tasksService.addListener({ event: TaskEvent.TaskUpdated, listener: updateQueueListener }); - try { - root.folderId = currentFolderId; - await this.uploadFoldersQueue.pushAsync(taskFolder); - - while (this.uploadFoldersQueue.running() > 0 || this.uploadFoldersQueue.length() > 0) { - await this.uploadFoldersQueue.drain(); - } + try { + root.folderId = currentFolderId; + await this.uploadFoldersQueue.pushAsync(taskFolder); - if (isConnectionLost()) throw new ConnectionLostError(); + while (this.uploadFoldersQueue.running() > 0 || this.uploadFoldersQueue.length() > 0) { + await this.uploadFoldersQueue.drain(); + } - // Re-read the task now that the upload has finished: it may have been marked Error (a fatal - // failure such as folder creation failing) or Cancelled while the queue was running. - const finishedTask = tasksService.findTask(taskId); - const taskFailed = - finishedTask?.status === TaskStatus.Error || finishedTask?.status === TaskStatus.Cancelled; - if (taskFailed) { - return false; - } + if (connectionLost) throw new ConnectionLostError(); + + // The task may have been marked Error (a fatal failure such as folder creation) or Cancelled + // while the queue was running: don't overwrite that final state. + const finishedTask = tasksService.findTask(taskId); + const alreadyFinalized = + finishedTask?.status === TaskStatus.Error || finishedTask?.status === TaskStatus.Cancelled; + if (alreadyFinalized) continue; + + if (this.tasksInfo[taskId].hasFailedFiles) { + // Some files failed, but the already-uploaded content is preserved (no rollback): mark the + // folder as errored with an explanatory subtitle. + tasksService.updateTask({ + taskId, + merge: { status: TaskStatus.Error, subtitle: t('tasks.subtitles.upload-partially-failed') as string }, + }); + continue; + } - if (this.tasksInfo[taskId].hasFailedFiles) { - // Some files failed but the already-uploaded content was preserved. Mark the task as - // errored with an explanatory subtitle instead of rolling back the partial upload. tasksService.updateTask({ taskId, merge: { - status: TaskStatus.Error, - subtitle: t('tasks.subtitles.upload-partially-failed') as string, + itemUUID: { rootFolderUUID: this.tasksInfo[taskId].rootFolderItem?.uuid }, + status: TaskStatus.Success, }, }); - return false; - } - - this.finalizeSuccessfulUpload(taskFolder, memberId); - return false; - } catch (err: unknown) { - return this.handleFolderUploadError(err, taskId, isConnectionLost()); - } finally { - tasksService.removeListener({ event: TaskEvent.TaskCancelled, listener: cancelQueueListener }); - tasksService.removeListener({ event: TaskEvent.TaskUpdated, listener: updateQueueListener }); - } - }; - public readonly run = async (): Promise => { - const payloadWithTaskId = await generateTaskIdForFolders(this.payload); - - const memberId = this.selectedWorkspace?.workspaceUser?.memberId; - - const context = typeof window === 'undefined' ? self : window; - - let connectionLost = false; - - function connectionLostListener() { - connectionLost = true; - context.removeEventListener('offline', connectionLostListener); - } - context.addEventListener('offline', connectionLostListener); + options.onSuccess?.(); + referralService.trackFolderUpload(); + logNetworkInfoForUpload({ folderName: root.name }); + + setTimeout(() => { + this.dispatch(planThunks.fetchUsageThunk()); + if (memberId) this.dispatch(planThunks.fetchBusinessLimitUsageThunk()); + }, 1000); + } catch (err: unknown) { + const castedError = errorService.castError(err); + + if (connectionLost) { + tasksService.updateTask({ + taskId, + merge: { status: TaskStatus.Error, subtitle: t('error.connectionLostError') as string }, + }); + errorService.reportError(castedError); + break; + } - for (const taskFolder of payloadWithTaskId) { - const shouldStop = await this.uploadFolderTask(taskFolder, memberId, () => connectionLost); - if (shouldStop) break; + const updatedTask = tasksService.findTask(taskId); + if (updatedTask?.status !== TaskStatus.Cancelled && taskId === updatedTask?.id) { + tasksService.updateTask({ + taskId, + merge: { status: TaskStatus.Error, subtitle: t('tasks.subtitles.upload-failed') as string }, + }); + // Report the error but keep going so the remaining folders are still processed. + errorService.reportError(castedError); + } + } finally { + tasksService.removeListener({ event: TaskEvent.TaskCancelled, listener: cancelQueueListener }); + tasksService.removeListener({ event: TaskEvent.TaskUpdated, listener: updateQueueListener }); + } } }; } From c2cc2db6e5d3e9a94360998d1c36632ab7dc2252 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Tue, 7 Jul 2026 12:16:52 +0200 Subject: [PATCH 4/6] feat: sonar coverage --- src/app/network/UploadFolderManager.ts | 112 ++++++++++-------- src/app/network/UploadManager.ts | 61 ++++++---- .../TaskToRetryItem/TaskToRetryItem.tsx | 4 +- 3 files changed, 103 insertions(+), 74 deletions(-) diff --git a/src/app/network/UploadFolderManager.ts b/src/app/network/UploadFolderManager.ts index b6cc5f948..12d30aad2 100644 --- a/src/app/network/UploadFolderManager.ts +++ b/src/app/network/UploadFolderManager.ts @@ -363,6 +363,65 @@ export class UploadFoldersManager { }); }; + // Registers the queue pause/resume/cancel listeners for a folder task and returns a cleanup fn. + private readonly attachQueueListeners = (taskId: string): (() => void) => { + const cancelQueueListener = (task?: TaskData) => { + if (task?.id === taskId && task.status === TaskStatus.Cancelled) this.uploadFoldersQueue.kill(); + }; + + const updateQueueListener = (task?: TaskData) => { + if (task?.id !== taskId) return; + if (task.status === TaskStatus.InProcess) this.uploadFoldersQueue.resume(); + else if (task.status === TaskStatus.Paused) this.uploadFoldersQueue.pause(); + }; + + tasksService.addListener({ event: TaskEvent.TaskCancelled, listener: cancelQueueListener }); + tasksService.addListener({ event: TaskEvent.TaskUpdated, listener: updateQueueListener }); + + return () => { + tasksService.removeListener({ event: TaskEvent.TaskCancelled, listener: cancelQueueListener }); + tasksService.removeListener({ event: TaskEvent.TaskUpdated, listener: updateQueueListener }); + }; + }; + + // Decides the final task status once the queue has drained: keep a fatal Error/Cancelled state, + // mark a partial failure, or finalize as Success and run the success side-effects. + private readonly finalizeFolderUpload = (taskFolder: TaskFolder, memberId?: string): void => { + const { root, taskId, options: payloadOptions } = taskFolder; + + // The task may have been marked Error (a fatal failure such as folder creation) or Cancelled + // while the queue was running: don't overwrite that final state. + const finishedTask = tasksService.findTask(taskId); + if (finishedTask?.status === TaskStatus.Error || finishedTask?.status === TaskStatus.Cancelled) return; + + if (this.tasksInfo[taskId].hasFailedFiles) { + // Some files failed, but the already-uploaded content is preserved (no rollback). + tasksService.updateTask({ + taskId, + merge: { status: TaskStatus.Error, subtitle: t('tasks.subtitles.upload-partially-failed') as string }, + }); + return; + } + + tasksService.updateTask({ + taskId, + merge: { + itemUUID: { rootFolderUUID: this.tasksInfo[taskId].rootFolderItem?.uuid }, + status: TaskStatus.Success, + }, + }); + + const options = { withNotification: true, ...payloadOptions }; + options.onSuccess?.(); + referralService.trackFolderUpload(); + logNetworkInfoForUpload({ folderName: root.name }); + + setTimeout(() => { + this.dispatch(planThunks.fetchUsageThunk()); + if (memberId) this.dispatch(planThunks.fetchBusinessLimitUsageThunk()); + }, 1000); + }; + public readonly run = async (): Promise => { const payloadWithTaskId = await generateTaskIdForFolders(this.payload); @@ -379,8 +438,7 @@ export class UploadFoldersManager { context.addEventListener('offline', connectionLostListener); for (const taskFolder of payloadWithTaskId) { - const { root, currentFolderId, options: payloadOptions, taskId } = taskFolder; - const options = { withNotification: true, ...payloadOptions }; + const { root, currentFolderId, taskId } = taskFolder; this.tasksInfo[taskId] = { progress: { @@ -391,18 +449,7 @@ export class UploadFoldersManager { tasksService.updateTask({ taskId, merge: { status: TaskStatus.InProcess, progress: 0 } }); - const cancelQueueListener = (task?: TaskData) => { - if (task?.id === taskId && task.status === TaskStatus.Cancelled) this.uploadFoldersQueue.kill(); - }; - - const updateQueueListener = (task?: TaskData) => { - if (task?.id !== taskId) return; - if (task.status === TaskStatus.InProcess) this.uploadFoldersQueue.resume(); - else if (task.status === TaskStatus.Paused) this.uploadFoldersQueue.pause(); - }; - - tasksService.addListener({ event: TaskEvent.TaskCancelled, listener: cancelQueueListener }); - tasksService.addListener({ event: TaskEvent.TaskUpdated, listener: updateQueueListener }); + const removeQueueListeners = this.attachQueueListeners(taskId); try { root.folderId = currentFolderId; @@ -414,39 +461,7 @@ export class UploadFoldersManager { if (connectionLost) throw new ConnectionLostError(); - // The task may have been marked Error (a fatal failure such as folder creation) or Cancelled - // while the queue was running: don't overwrite that final state. - const finishedTask = tasksService.findTask(taskId); - const alreadyFinalized = - finishedTask?.status === TaskStatus.Error || finishedTask?.status === TaskStatus.Cancelled; - if (alreadyFinalized) continue; - - if (this.tasksInfo[taskId].hasFailedFiles) { - // Some files failed, but the already-uploaded content is preserved (no rollback): mark the - // folder as errored with an explanatory subtitle. - tasksService.updateTask({ - taskId, - merge: { status: TaskStatus.Error, subtitle: t('tasks.subtitles.upload-partially-failed') as string }, - }); - continue; - } - - tasksService.updateTask({ - taskId, - merge: { - itemUUID: { rootFolderUUID: this.tasksInfo[taskId].rootFolderItem?.uuid }, - status: TaskStatus.Success, - }, - }); - - options.onSuccess?.(); - referralService.trackFolderUpload(); - logNetworkInfoForUpload({ folderName: root.name }); - - setTimeout(() => { - this.dispatch(planThunks.fetchUsageThunk()); - if (memberId) this.dispatch(planThunks.fetchBusinessLimitUsageThunk()); - }, 1000); + this.finalizeFolderUpload(taskFolder, memberId); } catch (err: unknown) { const castedError = errorService.castError(err); @@ -469,8 +484,7 @@ export class UploadFoldersManager { errorService.reportError(castedError); } } finally { - tasksService.removeListener({ event: TaskEvent.TaskCancelled, listener: cancelQueueListener }); - tasksService.removeListener({ event: TaskEvent.TaskUpdated, listener: updateQueueListener }); + removeQueueListeners(); } } }; diff --git a/src/app/network/UploadManager.ts b/src/app/network/UploadManager.ts index 7776005c3..a1fde4b86 100644 --- a/src/app/network/UploadManager.ts +++ b/src/app/network/UploadManager.ts @@ -1,6 +1,7 @@ import { queue, QueueObject } from 'async'; import { randomBytes } from 'node:crypto'; import { t } from 'i18next'; +import { AppError } from '@internxt/sdk'; import errorService from 'services/error.service'; import { HTTP_CODE_ERRORS, HTTP_STATUS_CODES } from '../core/constants'; import uploadFile from 'app/drive/services/file.service/uploadFile'; @@ -318,6 +319,42 @@ class UploadManager { ); } + // Marks the file task as errored, fires the relevant plan dialogs and (for non-folder uploads) + // kills the queue. Extracted from handleUploadErrors to keep its cognitive complexity low. + private applyUnexpectedUploadError({ + taskId, + castedError, + isMaxSpaceError, + continueWithRemainingFiles, + }: { + taskId: string; + castedError: AppError; + isMaxSpaceError: boolean; + continueWithRemainingFiles: boolean; + }): void { + tasksService.updateTask({ + taskId, + merge: { status: TaskStatus.Error, subtitle: t('tasks.subtitles.upload-failed') as string }, + }); + errorService.reportError(castedError); + + if (castedError.code === HTTP_CODE_ERRORS.FILE_UPLOAD_SIZE_EXCEEDED) { + this.fileSizeExceededCallback?.(); + } + + if (isMaxSpaceError) { + this.maxSpaceOccupiedCallback(); + } + + // Non-folder uploads stop the whole batch; folder uploads keep going with the remaining files. + if (!continueWithRemainingFiles) { + if (!this.errored) { + this.uploadQueue.kill(); + } + this.errored = true; + } + } + private handleUploadErrors({ error, fileData, @@ -367,29 +404,7 @@ class UploadManager { // Handle unexpected errors if (isUnexpectedError) { - tasksService.updateTask({ - taskId: taskId, - merge: { status: TaskStatus.Error, subtitle: t('tasks.subtitles.upload-failed') as string }, - }); - errorService.reportError(castedError); - - // Handle file size exceeded - if (castedError.code === HTTP_CODE_ERRORS.FILE_UPLOAD_SIZE_EXCEEDED) { - this.fileSizeExceededCallback?.(); - } - - // Handle max space used error - if (isMaxSpaceError) { - this.maxSpaceOccupiedCallback(); - } - - // Ensure upload queue is killed and errored is set, unless we keep going in a folder upload - if (!continueWithRemainingFiles) { - if (!this.errored) { - this.uploadQueue.kill(); - } - this.errored = true; - } + this.applyUnexpectedUploadError({ taskId, castedError, isMaxSpaceError, continueWithRemainingFiles }); } // Handle upload aborted diff --git a/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.tsx b/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.tsx index 20a60621f..ee968bfd6 100644 --- a/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.tsx +++ b/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.tsx @@ -5,7 +5,7 @@ import { bytesToString } from 'app/drive/services/size.service'; import { formatDefaultDate } from 'services/date.service'; import { t } from 'i18next'; import { TaskLoggerButton } from '../TaskLoggerButton/TaskLoggerButton'; -import { CircleNotch } from '@phosphor-icons/react'; +import { CircleNotchIcon } from '@phosphor-icons/react'; import { RetryableTask } from 'app/network/RetryManager'; const TaskToRetyItem = ({ index, style, data }: ListChildComponentProps) => { @@ -39,7 +39,7 @@ const TaskToRetyItem = ({ index, style, data }: ListChildComponentProps) => { downloadItem(file)} Icon={RestartIcon} /> )} {!isNotAllowed && status === 'retrying' && ( - + )} ); From 5c0ea1d911b762af1ba7fca69c7d80a4051a1ac8 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Tue, 7 Jul 2026 12:26:09 +0200 Subject: [PATCH 5/6] feat: sonar coverage --- src/app/network/UploadManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/network/UploadManager.ts b/src/app/network/UploadManager.ts index a1fde4b86..a5186bd14 100644 --- a/src/app/network/UploadManager.ts +++ b/src/app/network/UploadManager.ts @@ -419,7 +419,7 @@ class UploadManager { if (continueWithRemainingFiles) { this.someUploadFailed = true; - if (!this.firstUploadError) this.firstUploadError = castedError; + this.firstUploadError ??= castedError; next(null); return; } From 5614b240107c79d6b854e246575c3cd9c5b465c2 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Wed, 8 Jul 2026 09:52:03 +0200 Subject: [PATCH 6/6] fix: test errors --- src/app/network/UploadManager.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/network/UploadManager.test.ts b/src/app/network/UploadManager.test.ts index 20571b767..8f5959056 100644 --- a/src/app/network/UploadManager.test.ts +++ b/src/app/network/UploadManager.test.ts @@ -565,7 +565,7 @@ describe('checkUploadFiles', () => { it('should keep uploading the remaining folder files when one file fails, then reject with the original error', async () => { const bigSize = 60 * 1024 * 1024; const failError = new AppError('Payment required', 402); - (uploadFile as Mock).mockImplementation((_email: string, file: { name: string }) => + (uploadFile as Mock).mockImplementation((file: { name: string }) => file.name === 'fail.txt' ? Promise.reject(failError) : Promise.resolve(mockFile2), ); @@ -616,7 +616,7 @@ describe('checkUploadFiles', () => { }), ).rejects.toThrow(failError); - const uploadedNames = (uploadFile as Mock).mock.calls.map((call) => call[1]?.name); + const uploadedNames = (uploadFile as Mock).mock.calls.map((call) => call[0]?.name); expect(uploadedNames).toContain('ok.txt'); });