diff --git a/src/app/core/constants.ts b/src/app/core/constants.ts index ec48fb17f7..f7459f9d72 100644 --- a/src/app/core/constants.ts +++ b/src/app/core/constants.ts @@ -3,6 +3,7 @@ export const HTTP_CODES = { MAX_SPACE_USED: 420, FORBIDDEN: 403, NOT_FOUND: 404, + PAYMENT_REQUIRED: 402, }; export enum ErrorMessages { ServerUnavailable = 'Server Unavailable', diff --git a/src/app/drive/services/file.service/upload.errors.ts b/src/app/drive/services/file.service/upload.errors.ts index c60f540b9e..42cc71073f 100644 --- a/src/app/drive/services/file.service/upload.errors.ts +++ b/src/app/drive/services/file.service/upload.errors.ts @@ -14,3 +14,14 @@ export class BucketNotFoundError extends Error { Object.setPrototypeOf(this, BucketNotFoundError.prototype); } } + +export class EmptyFileNotAllowedError extends Error { + readonly fileName: string; + + constructor(fileName: string) { + super('Empty files are not allowed by current plan'); + this.name = 'EmptyFileNotAllowedError'; + this.fileName = fileName; + Object.setPrototypeOf(this, EmptyFileNotAllowedError.prototype); + } +} diff --git a/src/app/drive/services/file.service/uploadFile.test.ts b/src/app/drive/services/file.service/uploadFile.test.ts index b9ae554b39..1e6656c8ae 100644 --- a/src/app/drive/services/file.service/uploadFile.test.ts +++ b/src/app/drive/services/file.service/uploadFile.test.ts @@ -37,7 +37,7 @@ vi.mock('services/navigation.service', () => ({ import { SdkFactory } from 'app/core/factory/sdk'; import workspacesService from 'services/workspace.service'; import { Network, getEnvironmentConfig } from 'app/drive/services/network.service'; -import { BucketNotFoundError, FileIdRequiredError } from './upload.errors'; +import { BucketNotFoundError, EmptyFileNotAllowedError, FileIdRequiredError } from './upload.errors'; import { DriveFileData } from 'app/drive/types'; const mockSdkFactory = vi.mocked(SdkFactory); @@ -340,6 +340,79 @@ describe('Uploading a file', () => { expect(mockNetwork).not.toHaveBeenCalled(); }); + test('When the plan does not allow empty files, then the user is informed', async () => { + const file: FileToUpload = { + name: 'empty-file.txt', + size: 0, + type: 'txt', + content: new File([], 'empty-file.txt'), + parentFolderId: 'folder-123', + }; + const bucketId = 'bucket-123'; + + mockGetEnvironmentConfig.mockReturnValue({ + bridgeUser: 'user', + bridgePass: 'pass', + encryptionKey: 'key', + bucketId, + } as any); + + const paymentRequiredError = Object.assign(new Error('Payment required'), { status: 402 }); + const mockCreateFileEntryByUuid = vi.fn().mockRejectedValue(paymentRequiredError); + mockSdkFactory.getNewApiInstance.mockReturnValue({ + createNewStorageClient: vi.fn(() => ({ + createFileEntryByUuid: mockCreateFileEntryByUuid, + })), + } as any); + + await expect( + uploadFile( + 'user@test.com', + file, + vi.fn(), + { isTeam: false }, + { taskId: 'task-1', isPaused: false, isRetriedUpload: false }, + ), + ).rejects.toThrow(EmptyFileNotAllowedError); + expect(mockNetwork).not.toHaveBeenCalled(); + }); + + test('When uploading an empty file fails unexpectedly, then the original error is shown', async () => { + const file: FileToUpload = { + name: 'empty-file.txt', + size: 0, + type: 'txt', + content: new File([], 'empty-file.txt'), + parentFolderId: 'folder-123', + }; + const bucketId = 'bucket-123'; + + mockGetEnvironmentConfig.mockReturnValue({ + bridgeUser: 'user', + bridgePass: 'pass', + encryptionKey: 'key', + bucketId, + } as any); + + const serverError = Object.assign(new Error('Server error'), { status: 500 }); + const mockCreateFileEntryByUuid = vi.fn().mockRejectedValue(serverError); + mockSdkFactory.getNewApiInstance.mockReturnValue({ + createNewStorageClient: vi.fn(() => ({ + createFileEntryByUuid: mockCreateFileEntryByUuid, + })), + } as any); + + await expect( + uploadFile( + 'user@test.com', + file, + vi.fn(), + { isTeam: false }, + { taskId: 'task-1', isPaused: false, isRetriedUpload: false }, + ), + ).rejects.toThrow('Server error'); + }); + test('When uploading a file without bucket id, then an error indicating so is thrown', async () => { const file: FileToUpload = { name: 'test-file', diff --git a/src/app/drive/services/file.service/uploadFile.ts b/src/app/drive/services/file.service/uploadFile.ts index 15aad35bd4..3af2283d20 100644 --- a/src/app/drive/services/file.service/uploadFile.ts +++ b/src/app/drive/services/file.service/uploadFile.ts @@ -11,7 +11,9 @@ import { generateThumbnailFromFile } from '../thumbnail.service'; import { OwnerUserAuthenticationData } from 'app/network/types'; import { FileToUpload } from './types'; import { DriveFileData } from '@internxt/sdk/dist/drive/storage/types'; -import { BucketNotFoundError, FileIdRequiredError } from './upload.errors'; +import { BucketNotFoundError, EmptyFileNotAllowedError, FileIdRequiredError } from './upload.errors'; +import { HTTP_CODES } from 'app/core/constants'; +import errorService from 'services/error.service'; import { isFileEmpty } from 'utils/isFileEmpty'; import { FileEntry } from '@internxt/sdk/dist/workspaces'; @@ -101,13 +103,21 @@ export async function uploadFile( const isWorkspacesUpload = workspaceId && workspacesToken; if (isFileEmpty(file.content) && !isWorkspacesUpload) { - return createFileEntry({ - bucketId: bucketId, - file, - resourcesToken: resourcesToken, - workspaceId: workspaceId, - ownerToken: workspacesToken, - }); + try { + return await createFileEntry({ + bucketId: bucketId, + file, + resourcesToken: resourcesToken, + workspaceId: workspaceId, + ownerToken: workspacesToken, + }); + } catch (err) { + const error = errorService.castError(err); + if (error.status === HTTP_CODES.PAYMENT_REQUIRED) { + throw new EmptyFileNotAllowedError(file.name); + } + throw err; + } } if (!bucketId) { diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index 5d8202a6ee..6deed94811 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -1126,6 +1126,7 @@ "errorMovingToTrash": "Beim Verschieben der Elemente in den Papierkorb ist ein Fehler aufgetreten.", "errorDeletingFromTrash": "Beim Löschen der Elemente aus dem Papierkorb ist ein Fehler aufgetreten.", "maxSizeUploadLimitError": "Datei zu groß (größer als 40 GB)", + "emptyFileNotAllowed": "\"{{fileName}}\" wurde übersprungen: Aktualisieren Sie Ihren Tarif, um leere Dateien hochzuladen", "braveNotSupportMultiplePhotosDowload": "Brave unterstützt nicht das Herunterladen mehrerer Fotos.", "updateAvatarError": "Fehler beim Aktualisieren des Avatars", "featuresUnavailable": "Einige Funktionen sind möglicherweise nicht verfügbar", diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index 88138249a8..72a597838c 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -1200,6 +1200,7 @@ "errorMovingToTrash": "An error occurred while moving the items to trash.", "errorDeletingFromTrash": "An error occurred while deleting the items from trash.", "maxSizeUploadLimitError": "File too large (greater than 40GB)", + "emptyFileNotAllowed": "\"{{fileName}}\" was skipped: upgrade your plan to upload empty files", "connectionLostError": "Internet connection lost", "errorLoadingTrashItems": "Error loading trash items", "updateAvatarError": "Error updating avatar", diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index fb61b59a78..b20ff476b3 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -1177,6 +1177,7 @@ "errorMovingToTrash": "Ha ocurrido un error mientras se movian los items a la basura.", "errorDeletingFromTrash": "Ha ocurrido un error mientras se eliminaban los items de la basura.", "maxSizeUploadLimitError": "Archivo demasiado grande (mayor de 40GB)", + "emptyFileNotAllowed": "\"{{fileName}}\" fue omitido: actualiza tu plan para subir archivos vacíos", "connectionLostError": "Conexión a internet perdida", "errorLoadingTrashItems": "Error al cargar items de la papelera", "updateAvatarError": "Error al actualizar el avatar", diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index bca35a092e..17c0088d08 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -1128,6 +1128,7 @@ "emptyPassword": "Le mot de passe précédent ne doit pas être vide", "errorMovingToTrash": "Error lors du déplacement des éléments.", "maxSizeUploadLimitError": "Fichier trop volumineux (plus de 40GB)", + "emptyFileNotAllowed": "\"{{fileName}}\" a été ignoré : améliorez votre forfait pour téléverser des fichiers vides", "connectionLostError": "Lost Internet connection", "errorLoadingTrashItems": "Erreur de chargement des éléments de la corbeille", "updateAvatarError": "Erreur lors de la mise à jour de l'avatar", diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index 44595ea6b9..3ba6880485 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -1235,6 +1235,7 @@ "errorMovingToTrash": "Si è verificato un errore durante lo spostamento degli elementi nel cestino.", "errorDeletingFromTrash": "Si è verificato un errore durante l'eliminazione degli elementi dal cestino.", "maxSizeUploadLimitError": "File troppo grande (superiore a 40GB)", + "emptyFileNotAllowed": "\"{{fileName}}\" è stato ignorato: aggiorna il tuo piano per caricare file vuoti", "connectionLostError": "Connessione Internet persa", "errorLoadingTrashItems": "Errore nel caricamento degli elementi del cestino", "updateAvatarError": "Errore nell'aggiornamento dell'avatar", diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index ed1c1f90e7..5f5be2c4c5 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -1143,6 +1143,7 @@ "errorMovingToTrash": "При перемещении элементов в корзину произошла ошибка.", "errorDeletingFromTrash": "При удалении элементов из корзины произошла ошибка.", "maxSizeUploadLimitError": "Слишком большой файл (более 40 ГБ)", + "emptyFileNotAllowed": "\"{{fileName}}\" пропущен: обновите тарифный план, чтобы загружать пустые файлы", "connectionLostError": "Потеряно подключение к Интернету", "errorLoadingTrashItems": "Ошибка при загрузке элементов корзины", "updateAvatarError": "Ошибка при обновлении аватара", diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index 6b4eb71346..cdd3b22c2b 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -1130,6 +1130,7 @@ "errorMovingToTrash": "將項目移動到垃圾桶時出錯。", "errorDeletingFromTrash": "從垃圾桶中刪除項目時出錯。", "maxSizeUploadLimitError": "文件過大(大於40GB)", + "emptyFileNotAllowed": "已略過 \"{{fileName}}\":請升級方案以上傳空檔案", "connectionLostError": "網絡連接已丟失", "errorLoadingTrashItems": "加載垃圾桶項目時出錯", "updateAvatarError": "更新頭像時發生錯誤", diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index c6c1c69b0a..e621f47199 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -1165,6 +1165,7 @@ "errorMovingToTrash": "将项目移至垃圾箱时出错。", "errorDeletingFromTrash": "从垃圾箱中删除项目时出错。", "maxSizeUploadLimitError": "文件太大(大于40GB)", + "emptyFileNotAllowed": "已跳过 \"{{fileName}}\":请升级套餐以上传空文件", "connectionLostError": "互联网连接丢失", "errorLoadingTrashItems": "加载垃圾箱内的项目时出错", "updateAvatarError": "更新头像时出错", diff --git a/src/app/network/UploadFolderManager.ts b/src/app/network/UploadFolderManager.ts index 5d3b3e7aea..5a2314f562 100644 --- a/src/app/network/UploadFolderManager.ts +++ b/src/app/network/UploadFolderManager.ts @@ -255,10 +255,8 @@ export class UploadFoldersManager { }), ) .unwrap() - .catch(() => { - this.stopUploadTask(taskId, abortController); - this.killQueueAndNotifyError(taskId); - return; + .catch((error) => { + errorService.reportError(error); }); }; diff --git a/src/app/network/UploadManager.test.ts b/src/app/network/UploadManager.test.ts index 45edfd10eb..897cd09b04 100644 --- a/src/app/network/UploadManager.test.ts +++ b/src/app/network/UploadManager.test.ts @@ -9,6 +9,7 @@ import { DriveFileData } from 'app/drive/types'; import RetryManager from './RetryManager'; import { TaskStatus } from 'app/tasks/types'; import { ErrorMessages } from 'app/core/constants'; +import { EmptyFileNotAllowedError } from 'app/drive/services/file.service/upload.errors'; vi.mock('app/drive/services/file.service/uploadFile', () => ({ default: vi.fn(() => Promise.resolve({} as DriveFileData)), @@ -670,4 +671,52 @@ describe('checkUploadFiles', () => { expect.any(Object), ); }); + + it('When the plan does not allow empty files, then the upload is skipped and the user is informed', async () => { + const fileName = 'empty-file.txt'; + (uploadFile as Mock).mockRejectedValueOnce(new EmptyFileNotAllowedError(fileName)); + + const updateTaskSpy = vi.spyOn(tasksService, 'updateTask').mockReturnValue(); + vi.spyOn(tasksService, 'create').mockReturnValue('taskId'); + vi.spyOn(tasksService, 'addListener').mockReturnValue(); + vi.spyOn(tasksService, 'removeListener').mockReturnValue(); + const reportErrorSpy = vi.spyOn(errorService, 'reportError').mockReturnValue(); + const emptyFileNotAllowedCallback = vi.fn(); + + await uploadFileWithManager( + [ + { + taskId: 'taskId', + filecontent: { + content: 'file-content' as unknown as File, + type: 'text/plain', + name: fileName, + size: 0, + parentFolderId: 'folder-1', + }, + userEmail: '', + parentFolderId: '', + }, + ], + openMaxSpaceOccupiedDialogMock, + DatabaseUploadRepository.getInstance(), + undefined, + { + ownerUserAuthenticationData: undefined, + sharedItemData: { + isDeepFolder: false, + currentFolderId: 'parentFolderId', + }, + isUploadedFromFolder: true, + }, + { emptyFileNotAllowedCallback }, + ); + + expect(emptyFileNotAllowedCallback).toHaveBeenCalledWith(fileName); + expect(updateTaskSpy).toHaveBeenCalledWith({ + taskId: 'taskId', + merge: { status: TaskStatus.Error, subtitle: expect.any(String) }, + }); + expect(reportErrorSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/network/UploadManager.ts b/src/app/network/UploadManager.ts index d10f621c8f..228a97e505 100644 --- a/src/app/network/UploadManager.ts +++ b/src/app/network/UploadManager.ts @@ -4,6 +4,7 @@ import { t } from 'i18next'; import errorService from 'services/error.service'; import { HTTP_CODES } from '../core/constants'; import uploadFile from 'app/drive/services/file.service/uploadFile'; +import { EmptyFileNotAllowedError } from 'app/drive/services/file.service/upload.errors'; import { DriveFileData } from 'app/drive/types'; import { PersistUploadRepository } from '../repositories/DatabaseUploadRepository'; import tasksService from '../tasks/services/tasks.service'; @@ -46,14 +47,19 @@ export type UploadManagerFileParams = { isUploadedFromFolder?: boolean; }; +export type UploadFileWithManagerCallbacks = { + relatedTaskProgress?: { filesUploaded: number; totalFilesToUpload: number }; + onFileUploadCallback?: (driveFileData: DriveFileData) => void; + emptyFileNotAllowedCallback?: (fileName: string) => void; +}; + export const uploadFileWithManager = ( files: UploadManagerFileParams[], maxSpaceOccupiedCallback: () => void, uploadRepository: PersistUploadRepository, abortController?: AbortController, options?: Options, - relatedTaskProgress?: { filesUploaded: number; totalFilesToUpload: number }, - onFileUploadCallback?: (driveFileData: DriveFileData) => void, + callbacks?: UploadFileWithManagerCallbacks, ): Promise<{ uploadedFiles: DriveFileData[] }> => { const uploadManager = new UploadManager( files, @@ -61,8 +67,7 @@ export const uploadFileWithManager = ( uploadRepository, abortController, options, - relatedTaskProgress, - onFileUploadCallback, + callbacks, ); return uploadManager.run(); }; @@ -76,6 +81,7 @@ class UploadManager { private options?: Options; private relatedTaskProgress?: { filesUploaded: number; totalFilesToUpload: number }; private maxSpaceOccupiedCallback: () => void; + private readonly emptyFileNotAllowedCallback?: (fileName: string) => void; private onFileUploadCallback?: (driveFileData: DriveFileData) => void; private uploadRepository?: PersistUploadRepository; private filesUploadedList: (DriveFileData & { taskId: string })[] = []; @@ -240,6 +246,17 @@ class UploadManager { !!this.abortController?.signal.aborted || !!fileData.abortController?.signal.aborted || error === 'abort'; const isLostConnectionError = error instanceof ConnectionLostError || error.message === ErrorMessages.NetworkError; + const isEmptyFileNotAllowed = error instanceof EmptyFileNotAllowedError; + + if (isEmptyFileNotAllowed) { + this.emptyFileNotAllowedCallback?.(error.fileName); + tasksService.updateTask({ + taskId, + merge: { status: TaskStatus.Error, subtitle: t('tasks.subtitles.upload-failed') as string }, + }); + next(null); + return; + } if (uploadAttempts < MAX_UPLOAD_ATTEMPTS && !isUploadAborted && !isLostConnectionError) { upload(); @@ -277,16 +294,16 @@ class UploadManager { uploadRepository?: PersistUploadRepository, abortController?: AbortController, options?: Options, - relatedTaskProgress?: { filesUploaded: number; totalFilesToUpload: number }, - onFileUploadCallback?: (driveFileData: DriveFileData) => void, + callbacks?: UploadFileWithManagerCallbacks, ) { this.items = items; this.abortController = abortController; this.options = options; - this.relatedTaskProgress = relatedTaskProgress; + this.relatedTaskProgress = callbacks?.relatedTaskProgress; this.maxSpaceOccupiedCallback = maxSpaceOccupiedCallback; + this.emptyFileNotAllowedCallback = callbacks?.emptyFileNotAllowedCallback; this.uploadRepository = uploadRepository; - this.onFileUploadCallback = onFileUploadCallback; + this.onFileUploadCallback = callbacks?.onFileUploadCallback; } private handleUploadErrors({ diff --git a/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.ts b/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.ts index 91e58869ec..a9d7ec7497 100644 --- a/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.ts +++ b/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.ts @@ -95,6 +95,12 @@ const isUploadAllowed = ({ return true; }; +const notifyEmptyFileSkipped = (fileName: string) => + notificationsService.show({ + text: t('error.emptyFileNotAllowed', { fileName }), + type: ToastType.Error, + }); + /** * @description * 1. Prepare files to upload @@ -166,7 +172,6 @@ export const uploadItemsThunk = createAsyncThunk dispatch(uiActions.setIsReachedPlanLimitDialogOpen(true)); - try { await uploadFileWithManager( filesToUploadData, @@ -181,6 +186,9 @@ export const uploadItemsThunk = createAsyncThunk dispatch(uiActions.setIsReachedPlanLimitDialogOpen(true)); - try { await uploadFileWithManager( filesToUploadData, @@ -455,8 +462,11 @@ export const uploadItemsParallelThunk = createAsyncThunk