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');
});