diff --git a/src/app/core/constants.ts b/src/app/core/constants.ts index 6a510e3921..3241a66277 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 0753e29fac..f6c3213cdb 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", @@ -1414,7 +1415,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 b8175b3871..e455ad9a10 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", @@ -1491,7 +1492,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 78a8608dda..3f293c8da8 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", @@ -1469,7 +1470,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 dd2910a24a..1d100d241d 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", @@ -1419,7 +1420,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 9c0c68ecc9..84ab3e0df6 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", @@ -1526,7 +1527,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 2fadc1d4ec..2766f7e6cb 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": "Приостановлено", @@ -1434,7 +1435,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 83ee1f5a01..ae7abe4d4f 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": "已暫停", @@ -1421,7 +1422,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 6a355bb30e..3ebc502b21 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": "暂停", @@ -1456,7 +1457,8 @@ "errors": {} }, "subtitles": { - "upload-failed": "上传失败" + "upload-failed": "上传失败", + "upload-partially-failed": "部分文件无法上传" }, "generalErrorMessages": { "retryUploadFailed": "重试上传时出了问题。请再次上传", diff --git a/src/app/network/RetryManager.ts b/src/app/network/RetryManager.ts index bb3a1e1148..b2bf135082 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 9ae36c1d3c..6f759d4520 100644 --- a/src/app/network/UploadFolderManager.test.ts +++ b/src/app/network/UploadFolderManager.test.ts @@ -10,6 +10,10 @@ 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'; +import newStorageService from 'app/drive/services/new-storage.service'; +import referralService from 'services/referral.service'; vi.mock('app/drive/services/new-storage.service', () => ({ default: { @@ -82,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(); @@ -369,6 +396,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 = { @@ -433,6 +535,237 @@ 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 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 15c7f69d0d..12d30aad2e 100644 --- a/src/app/network/UploadFolderManager.ts +++ b/src/app/network/UploadFolderManager.ts @@ -51,6 +51,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 => { @@ -216,7 +219,7 @@ export class UploadFoldersManager { ); } catch { if (uploadAttempts >= UploadFoldersManager.MAX_UPLOAD_ATTEMPTS) { - this.stopUploadTask(taskId, abortController); + await this.stopUploadTask(taskId, abortController); this.killQueueAndNotifyError(taskId); return; } @@ -283,9 +286,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; }); }; @@ -359,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); @@ -375,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: { @@ -385,32 +447,9 @@ export class UploadFoldersManager { }, }; - tasksService.updateTask({ - taskId, - merge: { - status: TaskStatus.InProcess, - progress: 0, - }, - }); + 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 }); + const removeQueueListeners = this.attachQueueListeners(taskId); try { root.folderId = currentFolderId; @@ -422,51 +461,30 @@ export class UploadFoldersManager { if (connectionLost) throw new ConnectionLostError(); - tasksService.updateTask({ - taskId: 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); - const updatedTask = tasksService.findTask(taskId); if (connectionLost) { tasksService.updateTask({ - taskId: taskId, - merge: { - status: TaskStatus.Error, - subtitle: t('error.connectionLostError') as string, - }, + taskId, + merge: { status: TaskStatus.Error, subtitle: t('error.connectionLostError') as string }, }); errorService.reportError(castedError); break; - } else if (updatedTask?.status !== TaskStatus.Cancelled && taskId === updatedTask?.id) { + } + + const updatedTask = tasksService.findTask(taskId); + if (updatedTask?.status !== TaskStatus.Cancelled && taskId === updatedTask?.id) { tasksService.updateTask({ - taskId: taskId, - merge: { - status: TaskStatus.Error, - subtitle: t('tasks.subtitles.upload-failed') as string, - }, + 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 + // Report the error but keep going so the remaining folders are still processed. errorService.reportError(castedError); - continue; } } finally { - tasksService.removeListener({ event: TaskEvent.TaskCancelled, listener: cancelQueueListener }); - tasksService.removeListener({ event: TaskEvent.TaskUpdated, listener: updateQueueListener }); + removeQueueListeners(); } } }; diff --git a/src/app/network/UploadManager.test.ts b/src/app/network/UploadManager.test.ts index c879d30683..8f59590569 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((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[0]?.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 d0fd4d5d24..33ebc0f453 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'; @@ -84,6 +85,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[]; @@ -255,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({ @@ -306,6 +311,49 @@ 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 + ); + } + + // 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, @@ -346,29 +394,16 @@ class UploadManager { const isUnexpectedError = task?.status !== TaskStatus.Cancelled && !isUploadAborted; - // 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?.(); - } + const isMaxSpaceError = error?.status === HTTP_STATUS_CODES.MAX_SPACE_USED; + const continueWithRemainingFiles = !!this.options?.isUploadedFromFolder && !isMaxSpaceError; - // Handle max space used error - if (error?.status === HTTP_STATUS_CODES.MAX_SPACE_USED) { - this.maxSpaceOccupiedCallback(); - } + if (UploadManager.isNonRetryableError(error, castedError)) { + this.nonRetryableTaskIds.add(taskId); + } - // Ensure upload queue is killed and errored is set - if (!this.errored) { - this.uploadQueue.kill(); - } - this.errored = true; + // Handle unexpected errors + if (isUnexpectedError) { + this.applyUnexpectedUploadError({ taskId, castedError, isMaxSpaceError, continueWithRemainingFiles }); } // Handle upload aborted @@ -381,6 +416,13 @@ class UploadManager { return; } + if (continueWithRemainingFiles) { + this.someUploadFailed = true; + this.firstUploadError ??= castedError; + next(null); + return; + } + // If relatedTaskId is present, kill upload queue if (fileData.relatedTaskId) { this.uploadQueue.kill(); @@ -567,12 +609,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); @@ -594,6 +640,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 7bb08afd74..33c3440ce2 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 ed528eaf07..59817be49d 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 e2b407799a..ee968bfd6d 100644 --- a/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.tsx +++ b/src/app/tasks/components/TaskToRetryItem/TaskToRetryItem.tsx @@ -5,13 +5,14 @@ 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) => { 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' && ( + + )} ); };