diff --git a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx index 3d465ea21b..3ce86e54ad 100644 --- a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx +++ b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx @@ -48,6 +48,7 @@ const NameCollisionContainer: FC = ({ [moveDestinationFolderId, currentFolderId], ); const limits = useAppSelector(fileVersionsSelectors.getLimits); + const maxUploadFileSize = useAppSelector(fileVersionsSelectors.getMaxFileSizeLimit); const isVersioningEnabled = limits?.versioning?.enabled ?? false; const handleNewItems = (files: (File | DriveItemData)[], folders: (IRoot | DriveItemData)[]) => [ @@ -191,6 +192,7 @@ const NameCollisionContainer: FC = ({ ], selectedWorkspace, dispatch, + maxUploadFileSize, }); } else { const file = itemToUpload as File; @@ -214,6 +216,7 @@ const NameCollisionContainer: FC = ({ ], selectedWorkspace, dispatch, + maxUploadFileSize, }).then(() => { dispatch(fetchSortedFolderContentThunk(folderId)); }); diff --git a/src/app/drive/components/ReachedFileSizeLimitDialog/index.tsx b/src/app/drive/components/ReachedFileSizeLimitDialog/index.tsx index fec9f8afb5..e8d1a8c3c8 100644 --- a/src/app/drive/components/ReachedFileSizeLimitDialog/index.tsx +++ b/src/app/drive/components/ReachedFileSizeLimitDialog/index.tsx @@ -9,6 +9,7 @@ import { useEffect, useState } from 'react'; import { fetchPlanPrices } from 'views/NewSettings/services/plansApi'; import { Translate } from 'app/i18n/types'; import { bytesToString } from '../../services/size.service'; +import { fileVersionsSelectors } from 'app/store/slices/fileVersions'; const ReachedFileSizeLimitDialog = (): JSX.Element | null => { const dispatch = useAppDispatch(); @@ -16,7 +17,7 @@ const ReachedFileSizeLimitDialog = (): JSX.Element | null => { const isOpen = useAppSelector((state) => state.ui.isReachedFileSizeLimitDialogOpen); const fileSizeLimitInfo = useAppSelector((state) => state.ui.reachedFileSizeLimitDialogInfo); const exceededFiles = fileSizeLimitInfo?.exceededFiles; - const maxFileSize = useAppSelector((state) => state.fileVersions.limits?.maxUploadFileSize) ?? 0; + const maxUploadFileSize = useAppSelector(fileVersionsSelectors.getMaxFileSizeLimit); const selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace); const individualSubscription = useAppSelector((state) => state.plan.individualSubscription); const [availablePlans, setAvailablePlans] = useState([]); @@ -61,7 +62,7 @@ const ReachedFileSizeLimitDialog = (): JSX.Element | null => { }; }; - const text = getText(translate, maxFileSize, exceededFiles); + const text = getText(translate, maxUploadFileSize, exceededFiles); const onClose = (): void => { dispatch( diff --git a/src/app/drive/services/file.service/upload.errors.ts b/src/app/drive/services/file.service/upload.errors.ts index c60f540b9e..609d9316a0 100644 --- a/src/app/drive/services/file.service/upload.errors.ts +++ b/src/app/drive/services/file.service/upload.errors.ts @@ -14,3 +14,11 @@ export class BucketNotFoundError extends Error { Object.setPrototypeOf(this, BucketNotFoundError.prototype); } } + +export class FilesExceedsSizeLimitError extends Error { + constructor() { + super('Files exceeds the user size limit'); + this.name = 'FilesExceedsSizeLimitError'; + Object.setPrototypeOf(this, FilesExceedsSizeLimitError.prototype); + } +} diff --git a/src/app/drive/types/index.ts b/src/app/drive/types/index.ts index 845534239a..1d25ee2692 100644 --- a/src/app/drive/types/index.ts +++ b/src/app/drive/types/index.ts @@ -115,6 +115,15 @@ export interface ReachedPlanLimitDialogInfo { hidePrimaryAction?: boolean; } +export interface ExceededFile { + name: string; + size: number; +} + +export interface ReachedFileSizeLimitDialogInfo { + exceededFiles: ExceededFile[]; +} + export interface UpgradePlanDialogInfo { title: string; description: string; diff --git a/src/app/network/UploadFolderManager.test.ts b/src/app/network/UploadFolderManager.test.ts index 316ec73098..9ae36c1d3c 100644 --- a/src/app/network/UploadFolderManager.test.ts +++ b/src/app/network/UploadFolderManager.test.ts @@ -5,9 +5,11 @@ import { createFolder } from 'app/store/slices/storage/folderUtils/createFolder' import { checkFolderDuplicated } from 'app/store/slices/storage/folderUtils/checkFolderDuplicated'; import { getUniqueFolderName } from 'app/store/slices/storage/folderUtils/getUniqueFolderName'; import tasksService from 'app/tasks/services/tasks.service'; -import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { beforeEach, describe, expect, it, Mock, test, vi } from 'vitest'; import { TaskFolder, UploadFoldersManager, uploadFoldersWithManager } from './UploadFolderManager'; 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'; vi.mock('app/drive/services/new-storage.service', () => ({ default: { @@ -143,6 +145,7 @@ describe('checkUploadFolders', () => { ], selectedWorkspace: null, dispatch: mockDispatch, + maxUploadFileSize: 100, }); expect(createFolderSpy).toHaveBeenCalledOnce(); @@ -206,6 +209,7 @@ describe('checkUploadFolders', () => { ], selectedWorkspace: null, dispatch: mockDispatch, + maxUploadFileSize: 100, }); expect(createFolderSpy).toHaveBeenCalledOnce(); @@ -299,6 +303,7 @@ describe('checkUploadFolders', () => { ], selectedWorkspace: null, dispatch: mockDispatch, + maxUploadFileSize: 100, }); expect(createFolderSpy).toHaveBeenCalledTimes(2); @@ -357,12 +362,77 @@ describe('checkUploadFolders', () => { ], selectedWorkspace: null, dispatch: mockDispatch, + maxUploadFileSize: 100, }); expect(logNetworkInfoMock).toHaveBeenCalledOnce(); expect(logNetworkInfoMock).toHaveBeenCalledWith({ folderName: 'MyFolder' }); }); + describe('Handle File Uploads', () => { + const taskId = 'task-id'; + const mockCreatedFolder: DriveFolderData = { + id: 0, + uuid: 'folder-uuid', + name: 'Folder1', + bucket: 'bucket', + parentId: 0, + parent_id: 0, + parentUuid: 'parentUuid', + userId: 0, + user_id: 0, + icon: null, + iconId: null, + icon_id: null, + isFolder: true, + color: null, + encrypt_version: null, + plain_name: 'Folder1', + deleted: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const buildManager = (maxUploadFileSize?: number) => { + const manager = new UploadFoldersManager([], null, mockDispatch, maxUploadFileSize ?? 100); + manager['tasksInfo'][taskId] = { progress: { itemsUploaded: 0, totalItems: 1 } }; + return manager; + }; + + test('When all files exceed the size limit and there are no subfolders, then an error indicating so is thrown', async () => { + const bigFile = new File([new ArrayBuffer(200)], 'big.mp4'); + vi.spyOn(tasksService, 'updateTask').mockReturnValue(); + vi.spyOn(tasksService, 'getTasks').mockReturnValue([]); + const manager = buildManager(100); + const level = { childrenFiles: [bigFile], childrenFolders: [], folderId: 'f', name: 'F', fullPathEdited: '' }; + + await expect( + manager['handleFileUploads'](level, mockCreatedFolder, taskId, new AbortController()), + ).rejects.toThrow(FilesExceedsSizeLimitError); + }); + + test('When a folder has files exceeding the size limit but also has subfolders, then it dispatches the upload', async () => { + const bigFile = new File([new ArrayBuffer(200)], 'big.mp4'); + const dispatchWithUnwrap = vi.fn().mockReturnValue({ unwrap: () => Promise.resolve() }); + vi.spyOn(tasksService, 'updateTask').mockReturnValue(); + const manager = new UploadFoldersManager([], null, dispatchWithUnwrap, 100); + manager['tasksInfo'][taskId] = { progress: { itemsUploaded: 0, totalItems: 1 } }; + const level = { + childrenFiles: [bigFile], + childrenFolders: [{ folderId: 'c', childrenFiles: [], childrenFolders: [], name: 'Child', fullPathEdited: '' }], + folderId: 'f', + name: 'F', + fullPathEdited: '', + }; + + await manager['handleFileUploads'](level, mockCreatedFolder, taskId, new AbortController()); + + expect(uploadItemsParallelThunk).toHaveBeenCalledWith( + expect.objectContaining({ files: level.childrenFiles, parentFolderId: mockCreatedFolder.uuid }), + ); + }); + }); + it('should abort the upload if abortController is called', async () => { const mockParentFolder: DriveFolderData = { id: 1, @@ -410,7 +480,7 @@ describe('checkUploadFolders', () => { const selectedWorkspace = null; const taskId = 'task-id'; - const manager = new UploadFoldersManager(payload, selectedWorkspace, mockDispatch); + const manager = new UploadFoldersManager(payload, selectedWorkspace, mockDispatch, 100); const abortController = new AbortController(); const taskFolder: TaskFolder = { diff --git a/src/app/network/UploadFolderManager.ts b/src/app/network/UploadFolderManager.ts index ecd71db340..6450239c1d 100644 --- a/src/app/network/UploadFolderManager.ts +++ b/src/app/network/UploadFolderManager.ts @@ -20,6 +20,9 @@ import { wait } from 'utils/timeUtils'; import { ConnectionLostError } from './requests'; import referralService from 'services/referral.service'; import { logNetworkInfoForUpload } from './networkInformation'; +import { uiActions } from 'app/store/slices/ui'; +import { FilesExceedsSizeLimitError } from 'app/drive/services/file.service/upload.errors'; +import { filterFilesByMaxSize } from 'app/store/slices/storage/fileUtils/filterFilesByMaxSize'; interface UploadFolderPayload { root: IRoot; @@ -131,12 +134,14 @@ export const uploadFoldersWithManager = ({ payload, selectedWorkspace, dispatch, + maxUploadFileSize, }: { payload: UploadFolderPayload[]; selectedWorkspace: WorkspaceData | null; dispatch: ThunkDispatch; + maxUploadFileSize: number; }): Promise => { - const uploadFoldersManager = new UploadFoldersManager(payload, selectedWorkspace, dispatch); + const uploadFoldersManager = new UploadFoldersManager(payload, selectedWorkspace, dispatch, maxUploadFileSize); return uploadFoldersManager.run(); }; @@ -148,6 +153,7 @@ export class UploadFoldersManager { private readonly selectedWorkspace: WorkspaceData | null; private readonly dispatch: ThunkDispatch; private readonly abortController?: AbortController; + private readonly maxUploadFileSize: number; private tasksInfo: Record = {}; @@ -155,10 +161,12 @@ export class UploadFoldersManager { payload: UploadFolderPayload[], selectedWorkspace: WorkspaceData | null, dispatch: ThunkDispatch, + maxUploadFileSize: number, ) { this.payload = payload; this.selectedWorkspace = selectedWorkspace; this.dispatch = dispatch; + this.maxUploadFileSize = maxUploadFileSize; } private readonly uploadFoldersQueue: QueueObject = queue( @@ -238,6 +246,23 @@ export class UploadFoldersManager { if (level.childrenFiles.length === 0) return; if (abortController.signal.aborted) return; + const hasNoChildFolders = level.childrenFolders.length === 0; + const { exceededFiles } = filterFilesByMaxSize({ + files: level.childrenFiles, + maxUploadFileSize: this.maxUploadFileSize, + }); + const allFilesExceedSizeLimit = + exceededFiles.length === level.childrenFiles.length && level.childrenFiles.length > 0; + + if (allFilesExceedSizeLimit && hasNoChildFolders) { + await this.stopUploadTask(taskId, abortController); + this.killQueueAndNotifyError(taskId); + this.dispatch( + uiActions.setOpenFileSizeLimitReachedDialog({ open: true, info: { exceededFiles: level.childrenFiles } }), + ); + throw new FilesExceedsSizeLimitError(); + } + await this.dispatch( uploadItemsParallelThunk({ files: level.childrenFiles, @@ -314,8 +339,10 @@ export class UploadFoldersManager { // Deletes the root folder const rootFolderItem = this.tasksInfo[taskId].rootFolderItem; if (rootFolderItem) { - promises.push(this.dispatch(deleteItemsThunk([rootFolderItem as DriveItemData])).unwrap()); - promises.push(newStorageService.deleteFolderByUuid(rootFolderItem.uuid)); + promises.push( + this.dispatch(deleteItemsThunk([rootFolderItem as DriveItemData])).unwrap(), + newStorageService.deleteFolderByUuid(rootFolderItem.uuid), + ); } await Promise.allSettled(promises); }; diff --git a/src/app/network/UploadManager.test.ts b/src/app/network/UploadManager.test.ts index d8937d501b..df4d271623 100644 --- a/src/app/network/UploadManager.test.ts +++ b/src/app/network/UploadManager.test.ts @@ -106,10 +106,10 @@ describe('UploadManager memory usage conditions', () => { vi.spyOn(tasksService, 'updateTask').mockReturnValue(); vi.spyOn(tasksService, 'addListener').mockReturnValue(); vi.spyOn(tasksService, 'removeListener').mockReturnValue(); - vi.spyOn(errorService, 'castError').mockResolvedValue(new AppError('error')); + vi.spyOn(errorService, 'castError').mockReturnValue(new AppError('error')); - await uploadFileWithManager( - [ + await uploadFileWithManager({ + files: [ { taskId: 'taskId', filecontent: { @@ -123,10 +123,9 @@ describe('UploadManager memory usage conditions', () => { parentFolderId: '', }, ], - openMaxSpaceOccupiedDialogMock, - DatabaseUploadRepository.getInstance(), - undefined, - { + maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialogMock, + uploadRepository: DatabaseUploadRepository.getInstance(), + options: { ownerUserAuthenticationData: undefined, sharedItemData: { isDeepFolder: false, @@ -134,7 +133,7 @@ describe('UploadManager memory usage conditions', () => { }, isUploadedFromFolder: true, }, - ); + }); expect(uploadFileSpy).toHaveBeenCalledOnce(); @@ -163,10 +162,10 @@ describe('UploadManager memory usage conditions', () => { vi.spyOn(tasksService, 'updateTask').mockReturnValue(); vi.spyOn(tasksService, 'addListener').mockReturnValue(); vi.spyOn(tasksService, 'removeListener').mockReturnValue(); - vi.spyOn(errorService, 'castError').mockResolvedValue(new AppError('error')); + vi.spyOn(errorService, 'castError').mockReturnValue(new AppError('error')); - await uploadFileWithManager( - [ + await uploadFileWithManager({ + files: [ { taskId: 'taskId', filecontent: { @@ -180,10 +179,9 @@ describe('UploadManager memory usage conditions', () => { parentFolderId: '', }, ], - openMaxSpaceOccupiedDialogMock, - DatabaseUploadRepository.getInstance(), - undefined, - { + maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialogMock, + uploadRepository: DatabaseUploadRepository.getInstance(), + options: { ownerUserAuthenticationData: undefined, sharedItemData: { isDeepFolder: false, @@ -191,7 +189,7 @@ describe('UploadManager memory usage conditions', () => { }, isUploadedFromFolder: true, }, - ); + }); expect(uploadFileSpy).toHaveBeenCalledOnce(); @@ -207,7 +205,6 @@ describe('checkUploadFiles', () => { beforeEach(() => { RetryManager.clearTasks(); vi.clearAllMocks(); - vi.resetModules(); }); it('should upload file using an async queue', async () => { @@ -216,10 +213,10 @@ describe('checkUploadFiles', () => { vi.spyOn(tasksService, 'updateTask').mockReturnValue(); vi.spyOn(tasksService, 'addListener').mockReturnValue(); vi.spyOn(tasksService, 'removeListener').mockReturnValue(); - vi.spyOn(errorService, 'castError').mockResolvedValue(new AppError('error')); + vi.spyOn(errorService, 'castError').mockReturnValue(new AppError('error')); - await uploadFileWithManager( - [ + await uploadFileWithManager({ + files: [ { taskId: 'taskId', filecontent: { @@ -233,10 +230,9 @@ describe('checkUploadFiles', () => { parentFolderId: '', }, ], - openMaxSpaceOccupiedDialogMock, - DatabaseUploadRepository.getInstance(), - undefined, - { + maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialogMock, + uploadRepository: DatabaseUploadRepository.getInstance(), + options: { ownerUserAuthenticationData: undefined, sharedItemData: { isDeepFolder: false, @@ -244,7 +240,7 @@ describe('checkUploadFiles', () => { }, isUploadedFromFolder: true, }, - ); + }); expect(uploadFileSpy).toHaveBeenCalledOnce(); }); @@ -254,10 +250,10 @@ describe('checkUploadFiles', () => { vi.spyOn(tasksService, 'create').mockReturnValue(taskId); vi.spyOn(tasksService, 'updateTask').mockReturnValue(); - vi.spyOn(errorService, 'castError').mockResolvedValue(new AppError('error')); + vi.spyOn(errorService, 'castError').mockReturnValue(new AppError('error')); - await uploadFileWithManager( - [ + await uploadFileWithManager({ + files: [ { taskId: 'taskId1', filecontent: { @@ -283,10 +279,9 @@ describe('checkUploadFiles', () => { parentFolderId: '', }, ], - openMaxSpaceOccupiedDialogMock, - DatabaseUploadRepository.getInstance(), - undefined, - { + maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialogMock, + uploadRepository: DatabaseUploadRepository.getInstance(), + options: { ownerUserAuthenticationData: undefined, sharedItemData: { isDeepFolder: false, @@ -294,7 +289,7 @@ describe('checkUploadFiles', () => { }, isUploadedFromFolder: true, }, - ); + }); expect(uploadFileSpy).toHaveBeenCalledTimes(2); }); @@ -302,8 +297,8 @@ describe('checkUploadFiles', () => { it('should abort the file upload if abortController is called', async () => { const abortController = new AbortController(); - uploadFileWithManager( - [ + uploadFileWithManager({ + files: [ { taskId: 'taskId', filecontent: { @@ -317,10 +312,10 @@ describe('checkUploadFiles', () => { parentFolderId: '', }, ], - openMaxSpaceOccupiedDialogMock, - DatabaseUploadRepository.getInstance(), + maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialogMock, + uploadRepository: DatabaseUploadRepository.getInstance(), abortController, - { + options: { ownerUserAuthenticationData: undefined, sharedItemData: { isDeepFolder: false, @@ -328,7 +323,7 @@ describe('checkUploadFiles', () => { }, isUploadedFromFolder: true, }, - ); + }); abortController.abort(); expect(abortController.signal.aborted).toBe(true); @@ -344,10 +339,10 @@ describe('checkUploadFiles', () => { vi.spyOn(tasksService, 'updateTask').mockReturnValueOnce(); vi.spyOn(tasksService, 'addListener').mockReturnValue(); vi.spyOn(tasksService, 'removeListener').mockReturnValue(); - vi.spyOn(errorService, 'castError').mockResolvedValue(new AppError('error')); + vi.spyOn(errorService, 'castError').mockReturnValue(new AppError('error')); - await uploadFileWithManager( - [ + await uploadFileWithManager({ + files: [ { taskId: 'taskId', filecontent: { @@ -361,10 +356,9 @@ describe('checkUploadFiles', () => { parentFolderId: '', }, ], - openMaxSpaceOccupiedDialogMock, - DatabaseUploadRepository.getInstance(), - undefined, - { + maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialogMock, + uploadRepository: DatabaseUploadRepository.getInstance(), + options: { ownerUserAuthenticationData: undefined, sharedItemData: { isDeepFolder: false, @@ -372,7 +366,7 @@ describe('checkUploadFiles', () => { }, isUploadedFromFolder: true, }, - ); + }); expect(RetryAddFilesSpy).not.toHaveBeenCalled(); expect(RetrRemoveFileSpy).toHaveBeenCalledWith('taskId'); @@ -391,10 +385,10 @@ describe('checkUploadFiles', () => { vi.spyOn(tasksService, 'updateTask').mockReturnValue(); vi.spyOn(tasksService, 'addListener').mockReturnValue(); vi.spyOn(tasksService, 'removeListener').mockReturnValue(); - vi.spyOn(errorService, 'castError').mockResolvedValue(new AppError('error')); + vi.spyOn(errorService, 'castError').mockImplementation((e) => e as AppError); - await uploadFileWithManager( - [ + await uploadFileWithManager({ + files: [ { taskId: 'taskId', filecontent: { @@ -420,10 +414,9 @@ describe('checkUploadFiles', () => { parentFolderId: '', }, ], - openMaxSpaceOccupiedDialogMock, - DatabaseUploadRepository.getInstance(), - undefined, - { + maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialogMock, + uploadRepository: DatabaseUploadRepository.getInstance(), + options: { ownerUserAuthenticationData: undefined, sharedItemData: { isDeepFolder: false, @@ -431,7 +424,7 @@ describe('checkUploadFiles', () => { }, isUploadedFromFolder: true, }, - ); + }); expect(RetryAddFilesSpy).toHaveBeenCalled(); expect(RetryManager.getTasks().length).toBe(1); @@ -449,10 +442,10 @@ describe('checkUploadFiles', () => { vi.spyOn(tasksService, 'updateTask').mockReturnValue(); vi.spyOn(tasksService, 'addListener').mockReturnValue(); vi.spyOn(tasksService, 'removeListener').mockReturnValue(); - vi.spyOn(errorService, 'castError').mockResolvedValue(new AppError('error')); + vi.spyOn(errorService, 'castError').mockImplementation((e) => e as AppError); - await uploadFileWithManager( - [ + await uploadFileWithManager({ + files: [ { taskId: 'taskId', filecontent: { @@ -466,10 +459,9 @@ describe('checkUploadFiles', () => { parentFolderId: '', }, ], - openMaxSpaceOccupiedDialogMock, - DatabaseUploadRepository.getInstance(), - undefined, - { + maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialogMock, + uploadRepository: DatabaseUploadRepository.getInstance(), + options: { ownerUserAuthenticationData: undefined, sharedItemData: { isDeepFolder: false, @@ -477,7 +469,7 @@ describe('checkUploadFiles', () => { }, isUploadedFromFolder: true, }, - ); + }); expect(RetryChangeStatusSpy).toHaveBeenCalledWith('taskId', 'failed'); }); @@ -487,11 +479,12 @@ describe('checkUploadFiles', () => { (uploadFile as Mock).mockRejectedValueOnce(lostConnectionError); const updateTaskSpy = vi.spyOn(tasksService, 'updateTask'); + vi.spyOn(errorService, 'castError').mockImplementation((e) => e as AppError); vi.spyOn(errorService, 'reportError').mockReturnValue(); await expect( - uploadFileWithManager( - [ + uploadFileWithManager({ + files: [ { taskId: 'taskId', filecontent: { @@ -505,10 +498,9 @@ describe('checkUploadFiles', () => { parentFolderId: '', }, ], - openMaxSpaceOccupiedDialogMock, - DatabaseUploadRepository.getInstance(), - undefined, - { + maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialogMock, + uploadRepository: DatabaseUploadRepository.getInstance(), + options: { ownerUserAuthenticationData: undefined, sharedItemData: { isDeepFolder: false, @@ -516,7 +508,7 @@ describe('checkUploadFiles', () => { }, isUploadedFromFolder: true, }, - ), + }), ).rejects.toThrow(lostConnectionError); expect(errorService.reportError).toHaveBeenCalledWith(lostConnectionError); @@ -531,11 +523,12 @@ describe('checkUploadFiles', () => { (uploadFile as Mock).mockRejectedValue(unexpectedError); const updateTaskSpy = vi.spyOn(tasksService, 'updateTask'); + vi.spyOn(errorService, 'castError').mockImplementation((e) => e as AppError); const errorServiceSpy = vi.spyOn(errorService, 'reportError').mockReturnValue(); await expect( - uploadFileWithManager( - [ + uploadFileWithManager({ + files: [ { taskId: 'taskId', filecontent: { @@ -549,10 +542,9 @@ describe('checkUploadFiles', () => { parentFolderId: '', }, ], - openMaxSpaceOccupiedDialogMock, - DatabaseUploadRepository.getInstance(), - undefined, - { + maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialogMock, + uploadRepository: DatabaseUploadRepository.getInstance(), + options: { ownerUserAuthenticationData: undefined, sharedItemData: { isDeepFolder: false, @@ -560,7 +552,7 @@ describe('checkUploadFiles', () => { }, isUploadedFromFolder: true, }, - ), + }), ).rejects.toThrow(unexpectedError); expect(errorServiceSpy).toHaveBeenCalledWith(unexpectedError); @@ -579,10 +571,10 @@ describe('checkUploadFiles', () => { vi.spyOn(tasksService, 'updateTask').mockReturnValue(); vi.spyOn(tasksService, 'addListener').mockReturnValue(); vi.spyOn(tasksService, 'removeListener').mockReturnValue(); - vi.spyOn(errorService, 'castError').mockResolvedValue(new AppError('error')); + vi.spyOn(errorService, 'castError').mockReturnValue(new AppError('error')); - await uploadFileWithManager( - [ + await uploadFileWithManager({ + files: [ { taskId: 'taskId', filecontent: { @@ -596,10 +588,9 @@ describe('checkUploadFiles', () => { parentFolderId: '', }, ], - openMaxSpaceOccupiedDialogMock, - DatabaseUploadRepository.getInstance(), - undefined, - { + maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialogMock, + uploadRepository: DatabaseUploadRepository.getInstance(), + options: { ownerUserAuthenticationData: { bucketId: workspaceBucket, workspaceId: workspaceId, @@ -616,7 +607,7 @@ describe('checkUploadFiles', () => { }, isUploadedFromFolder: true, }, - ); + }); expect(uploadFileSpy).toHaveBeenCalledWith( 'user@test.com', @@ -641,8 +632,8 @@ describe('checkUploadFiles', () => { vi.spyOn(tasksService, 'addListener').mockReturnValue(); vi.spyOn(tasksService, 'removeListener').mockReturnValue(); - await uploadFileWithManager( - [ + await uploadFileWithManager({ + files: [ { taskId: 'taskId', filecontent: { @@ -656,9 +647,9 @@ describe('checkUploadFiles', () => { parentFolderId: '', }, ], - openMaxSpaceOccupiedDialogMock, - DatabaseUploadRepository.getInstance(), - ); + maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialogMock, + uploadRepository: DatabaseUploadRepository.getInstance(), + }); expect(logNetworkInfoMock).toHaveBeenCalledOnce(); expect(logNetworkInfoMock).toHaveBeenCalledWith({ fileName: 'file.txt', fileSize: 1024 }); @@ -674,8 +665,8 @@ describe('checkUploadFiles', () => { vi.spyOn(errorService, 'reportError').mockReturnValue(); await expect( - uploadFileWithManager( - [ + uploadFileWithManager({ + files: [ { taskId: 'taskId', filecontent: { @@ -689,9 +680,9 @@ describe('checkUploadFiles', () => { parentFolderId: '', }, ], - openMaxSpaceOccupiedDialogMock, - DatabaseUploadRepository.getInstance(), - ), + maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialogMock, + uploadRepository: DatabaseUploadRepository.getInstance(), + }), ).rejects.toThrow(); expect(logNetworkInfoMock).not.toHaveBeenCalled(); @@ -704,10 +695,10 @@ describe('checkUploadFiles', () => { vi.spyOn(tasksService, 'updateTask').mockReturnValue(); vi.spyOn(tasksService, 'addListener').mockReturnValue(); vi.spyOn(tasksService, 'removeListener').mockReturnValue(); - vi.spyOn(errorService, 'castError').mockResolvedValue(new AppError('error')); + vi.spyOn(errorService, 'castError').mockReturnValue(new AppError('error')); - await uploadFileWithManager( - [ + await uploadFileWithManager({ + files: [ { taskId: 'taskId', filecontent: { @@ -721,10 +712,9 @@ describe('checkUploadFiles', () => { parentFolderId: '', }, ], - openMaxSpaceOccupiedDialogMock, - DatabaseUploadRepository.getInstance(), - undefined, - { + maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialogMock, + uploadRepository: DatabaseUploadRepository.getInstance(), + options: { ownerUserAuthenticationData: undefined, sharedItemData: { isDeepFolder: false, @@ -732,7 +722,7 @@ describe('checkUploadFiles', () => { }, isUploadedFromFolder: true, }, - ); + }); expect(uploadFileSpy).toHaveBeenCalledWith( 'user@test.com', @@ -755,11 +745,12 @@ describe('checkUploadFiles', () => { 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( - [ + uploadFileWithManager({ + files: [ { taskId: 'taskId', filecontent: { @@ -773,10 +764,9 @@ describe('checkUploadFiles', () => { parentFolderId: '', }, ], - openMaxSpaceOccupiedDialogMock, - DatabaseUploadRepository.getInstance(), - undefined, - { + maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialogMock, + uploadRepository: DatabaseUploadRepository.getInstance(), + options: { ownerUserAuthenticationData: undefined, sharedItemData: { isDeepFolder: false, @@ -784,7 +774,7 @@ describe('checkUploadFiles', () => { }, isUploadedFromFolder: true, }, - ), + }), ).rejects.toThrow(err); expect(openMaxSpaceOccupiedDialogMock).toHaveBeenCalledOnce(); diff --git a/src/app/network/UploadManager.ts b/src/app/network/UploadManager.ts index a2353792b7..aba8125f3a 100644 --- a/src/app/network/UploadManager.ts +++ b/src/app/network/UploadManager.ts @@ -1,8 +1,8 @@ import { queue, QueueObject } from 'async'; -import { randomBytes } from 'crypto'; +import { randomBytes } from 'node:crypto'; import { t } from 'i18next'; import errorService from 'services/error.service'; -import { HTTP_STATUS_CODES } from '../core/constants'; +import { HTTP_CODE_ERRORS, HTTP_STATUS_CODES } from '../core/constants'; import uploadFile from 'app/drive/services/file.service/uploadFile'; import { DriveFileData } from 'app/drive/types'; import { PersistUploadRepository } from '../repositories/DatabaseUploadRepository'; @@ -47,15 +47,27 @@ export type UploadManagerFileParams = { isUploadedFromFolder?: boolean; }; -export const uploadFileWithManager = ( - files: UploadManagerFileParams[], - maxSpaceOccupiedCallback: () => void, - uploadRepository: PersistUploadRepository, - abortController?: AbortController, - options?: Options, - relatedTaskProgress?: { filesUploaded: number; totalFilesToUpload: number }, - onFileUploadCallback?: (driveFileData: DriveFileData) => void, -): Promise<{ uploadedFiles: DriveFileData[] }> => { +interface UploadFileWithManagerProps { + files: UploadManagerFileParams[]; + maxSpaceOccupiedCallback: () => void; + uploadRepository: PersistUploadRepository; + abortController?: AbortController; + options?: Options; + relatedTaskProgress?: { filesUploaded: number; totalFilesToUpload: number }; + onFileUploadCallback?: (driveFileData: DriveFileData) => void; + fileSizeExceededCallback?: () => void; +} + +export const uploadFileWithManager = ({ + files, + maxSpaceOccupiedCallback, + uploadRepository, + abortController, + fileSizeExceededCallback, + onFileUploadCallback, + options, + relatedTaskProgress, +}: UploadFileWithManagerProps): Promise<{ uploadedFiles: DriveFileData[] }> => { const uploadManager = new UploadManager( files, maxSpaceOccupiedCallback, @@ -63,6 +75,7 @@ export const uploadFileWithManager = ( abortController, options, relatedTaskProgress, + fileSizeExceededCallback, onFileUploadCallback, ); return uploadManager.run(); @@ -72,15 +85,16 @@ class UploadManager { private currentGroupBeingUploaded: FileSizeType = FileSizeType.Small; private errored = false; private uploadsProgress: Record = {}; - private abortController?: AbortController; - private items: UploadManagerFileParams[]; - private options?: Options; - private relatedTaskProgress?: { filesUploaded: number; totalFilesToUpload: number }; - private maxSpaceOccupiedCallback: () => void; - private onFileUploadCallback?: (driveFileData: DriveFileData) => void; - private uploadRepository?: PersistUploadRepository; - private filesUploadedList: (DriveFileData & { taskId: string })[] = []; - private filesGroups: Record< + private readonly abortController?: AbortController; + private readonly items: UploadManagerFileParams[]; + private readonly options?: Options; + private readonly relatedTaskProgress?: { filesUploaded: number; totalFilesToUpload: number }; + private readonly maxSpaceOccupiedCallback: () => void; + private readonly fileSizeExceededCallback?: () => void; + private readonly onFileUploadCallback?: (driveFileData: DriveFileData) => void; + private readonly uploadRepository?: PersistUploadRepository; + private readonly filesUploadedList: (DriveFileData & { taskId: string })[] = []; + private readonly filesGroups: Record< FileSizeType, { upperBound: number; @@ -280,6 +294,7 @@ class UploadManager { abortController?: AbortController, options?: Options, relatedTaskProgress?: { filesUploaded: number; totalFilesToUpload: number }, + fileSizeExceededCallback?: () => void, onFileUploadCallback?: (driveFileData: DriveFileData) => void, ) { this.items = items; @@ -287,6 +302,7 @@ class UploadManager { this.options = options; this.relatedTaskProgress = relatedTaskProgress; this.maxSpaceOccupiedCallback = maxSpaceOccupiedCallback; + this.fileSizeExceededCallback = fileSizeExceededCallback; this.uploadRepository = uploadRepository; this.onFileUploadCallback = onFileUploadCallback; } @@ -310,21 +326,22 @@ class UploadManager { task: TaskData | undefined; uploadId: string; }) { + const castedError = errorService.castError(error); // Handle retry error - if (error.message === 'Retryable file') { + if (castedError.message === 'Retryable file') { next(null); return; } // Handle lost connection error if (isLostConnectionError) { - errorService.reportError(error); + errorService.reportError(castedError); tasksService.updateTask({ taskId, merge: { status: TaskStatus.Error, subtitle: t('error.connectionLostError') ?? undefined }, }); this.errored = true; - next(error); + next(castedError); return; } @@ -336,7 +353,12 @@ class UploadManager { taskId: taskId, merge: { status: TaskStatus.Error, subtitle: t('tasks.subtitles.upload-failed') as string }, }); - errorService.reportError(error); + 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 (error?.status === HTTP_STATUS_CODES.MAX_SPACE_USED) { @@ -365,7 +387,7 @@ class UploadManager { this.uploadQueue.kill(); } - next(error); + next(castedError); } private getMemoryUsagePercentage(): number | null { diff --git a/src/app/store/slices/fileVersions/fileVersions.selectors.ts b/src/app/store/slices/fileVersions/fileVersions.selectors.ts index 55b0d24b2e..b3f0758b76 100644 --- a/src/app/store/slices/fileVersions/fileVersions.selectors.ts +++ b/src/app/store/slices/fileVersions/fileVersions.selectors.ts @@ -1,10 +1,14 @@ import { FileVersion, FileLimitsResponse } from '@internxt/sdk/dist/drive/storage/types'; import { RootState } from '../..'; +import { MAX_ALLOWED_UPLOAD_SIZE } from 'app/drive/services/network.service'; const fileVersionsSelectors = { getLimits(state: RootState): FileLimitsResponse | null { return state.fileVersions.limits; }, + getMaxFileSizeLimit(state: RootState): number { + return state.fileVersions.limits?.maxUploadFileSize ?? MAX_ALLOWED_UPLOAD_SIZE; + }, isLimitsLoading(state: RootState): boolean { return state.fileVersions.isLimitsLoading; }, diff --git a/src/app/store/slices/storage/fileUtils/filterFilesByMaxSize.test.ts b/src/app/store/slices/storage/fileUtils/filterFilesByMaxSize.test.ts new file mode 100644 index 0000000000..96554960de --- /dev/null +++ b/src/app/store/slices/storage/fileUtils/filterFilesByMaxSize.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'vitest'; +import { filterFilesByMaxSize } from './filterFilesByMaxSize'; + +const createFile = (name: string, size: number): File => new File([new ArrayBuffer(size)], name); + +describe('Filtering files by size limit', () => { + describe('When no max size limit is provided', () => { + test('When there is no max size limit, then all files are allowed regardless of their size', () => { + const files = [createFile('big.mp4', 999_999_999), createFile('small.txt', 1)]; + + const { allowedFilesToUpload, exceededFiles } = filterFilesByMaxSize({ files }); + + expect(allowedFilesToUpload).toEqual(files); + expect(exceededFiles).toHaveLength(0); + }); + }); + + describe('When a max size limit is provided', () => { + const maxUploadFileSize = 100; + + test('When all files are within the limit, then all files are allowed and none are exceeded', () => { + const files = [createFile('a.txt', 50), createFile('b.txt', 100)]; + + const { allowedFilesToUpload, exceededFiles } = filterFilesByMaxSize({ files, maxUploadFileSize }); + + expect(allowedFilesToUpload).toEqual(files); + expect(exceededFiles).toHaveLength(0); + }); + + test('When all files exceed the limit, then no files are allowed and all are exceeded', () => { + const files = [createFile('a.mp4', 101), createFile('b.mp4', 200)]; + + const { allowedFilesToUpload, exceededFiles } = filterFilesByMaxSize({ files, maxUploadFileSize }); + + expect(allowedFilesToUpload).toHaveLength(0); + expect(exceededFiles).toEqual(files); + }); + + test('When some files exceed the limit, then only the files within the limit are allowed', () => { + const smallFile = createFile('small.txt', 50); + const bigFile = createFile('big.mp4', 200); + + const { allowedFilesToUpload, exceededFiles } = filterFilesByMaxSize({ + files: [smallFile, bigFile], + maxUploadFileSize, + }); + + expect(allowedFilesToUpload).toEqual([smallFile]); + expect(exceededFiles).toEqual([bigFile]); + }); + + test('When a file size is exactly the limit, then it is allowed and not exceeded', () => { + const file = createFile('exact.zip', 100); + + const { allowedFilesToUpload, exceededFiles } = filterFilesByMaxSize({ + files: [file], + maxUploadFileSize, + }); + + expect(allowedFilesToUpload).toEqual([file]); + expect(exceededFiles).toHaveLength(0); + }); + + test('When the file list is empty, then both lists are empty', () => { + const { allowedFilesToUpload, exceededFiles } = filterFilesByMaxSize({ files: [], maxUploadFileSize }); + + expect(allowedFilesToUpload).toHaveLength(0); + expect(exceededFiles).toHaveLength(0); + }); + }); +}); diff --git a/src/app/store/slices/storage/fileUtils/filterFilesByMaxSize.ts b/src/app/store/slices/storage/fileUtils/filterFilesByMaxSize.ts new file mode 100644 index 0000000000..2c26cdbe8b --- /dev/null +++ b/src/app/store/slices/storage/fileUtils/filterFilesByMaxSize.ts @@ -0,0 +1,27 @@ +import { MAX_ALLOWED_UPLOAD_SIZE } from 'app/drive/services/network.service'; + +interface FilterFilesByMaxSizePayload { + files: File[]; + maxUploadFileSize?: number; +} + +export const filterFilesByMaxSize = ({ + files, + maxUploadFileSize = MAX_ALLOWED_UPLOAD_SIZE, +}: FilterFilesByMaxSizePayload): { + allowedFilesToUpload: File[]; + exceededFiles: File[]; +} => { + const allowedFilesToUpload: File[] = []; + const exceededFiles: File[] = []; + + for (const file of files) { + if (file.size <= maxUploadFileSize) { + allowedFilesToUpload.push(file); + } else { + exceededFiles.push(file); + } + } + + return { allowedFilesToUpload, exceededFiles }; +}; diff --git a/src/app/store/slices/storage/storage.thunks/uploadFolderThunk.test.ts b/src/app/store/slices/storage/storage.thunks/uploadFolderThunk.test.ts new file mode 100644 index 0000000000..b47faaf3e0 --- /dev/null +++ b/src/app/store/slices/storage/storage.thunks/uploadFolderThunk.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, vi, beforeEach, Mock, test } from 'vitest'; +import { uploadFolderThunk } from './uploadFolderThunk'; +import { RootState } from '../../..'; +import { checkFolderDuplicated } from '../folderUtils/checkFolderDuplicated'; +import { uploadItemsParallelThunk } from './uploadItemsThunk'; +import tasksService from '../../../../tasks/services/tasks.service'; +import { TaskStatus } from '../../../../tasks/types'; +import { DriveFolderData } from 'app/drive/types'; +import { MAX_ALLOWED_UPLOAD_SIZE } from 'app/drive/services/network.service'; + +vi.mock('../folderUtils/checkFolderDuplicated', () => ({ + checkFolderDuplicated: vi.fn(), +})); + +vi.mock('../folderUtils/getUniqueFolderName', () => ({ + getUniqueFolderName: vi.fn(), +})); + +vi.mock('.', () => ({ + default: { + createFolderThunk: vi.fn(), + }, +})); + +vi.mock('./uploadItemsThunk', () => ({ + uploadItemsParallelThunk: vi.fn(), +})); + +vi.mock('./deleteItemsThunk', () => ({ + deleteItemsThunk: vi.fn(), +})); + +vi.mock('app/drive/services/new-storage.service', () => ({ + default: { + deleteFolderByUuid: vi.fn(), + }, +})); + +vi.mock('../../workspaces/workspaces.selectors', () => ({ + default: { + getSelectedWorkspace: vi.fn().mockReturnValue(null), + }, +})); + +vi.mock('../../plan', () => ({ + planThunks: { + fetchUsageThunk: vi.fn(), + fetchBusinessLimitUsageThunk: vi.fn(), + }, +})); + +vi.mock('services/referral.service', () => ({ + default: { + trackFolderUpload: vi.fn(), + }, +})); + +vi.mock('app/network/networkInformation', () => ({ + logNetworkInfoForUpload: vi.fn(), +})); + +vi.mock('services/error.service', () => ({ + default: { + castError: vi.fn().mockImplementation((e) => e), + reportError: vi.fn(), + }, +})); + +vi.mock('i18next', () => ({ + t: vi.fn((key) => key), +})); + +vi.mock('../../ui', () => ({ + uiActions: { + setOpenFileSizeLimitReachedDialog: vi.fn().mockReturnValue({ type: 'ui/setOpenFileSizeLimitReachedDialog' }), + }, +})); + +vi.mock('utils/timeUtils', () => ({ + wait: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../fileVersions', () => ({ + fileVersionsSelectors: { + getMaxFileSizeLimit: vi.fn((state) => state.fileVersions.limits?.maxUploadFileSize ?? MAX_ALLOWED_UPLOAD_SIZE), + }, +})); + +const mockFolder: DriveFolderData = { + id: 1, + uuid: 'folder-uuid', + name: 'TestFolder', + bucket: 'bucket', + parentId: 0, + parent_id: 0, + parentUuid: 'parent-uuid', + userId: 0, + user_id: 0, + icon: null, + iconId: null, + icon_id: null, + isFolder: true, + color: null, + encrypt_version: null, + plain_name: 'TestFolder', + deleted: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}; + +const buildGetState = (maxUploadFileSize?: number) => () => + ({ + user: { user: { email: 'test@test.com' } }, + fileVersions: { limits: { maxUploadFileSize: maxUploadFileSize ?? MAX_ALLOWED_UPLOAD_SIZE } }, + }) as unknown as RootState; + +describe('Upload Folder Thunk', () => { + const taskId = 'task-1'; + + beforeEach(() => { + vi.clearAllMocks(); + + (checkFolderDuplicated as Mock).mockResolvedValue({ + duplicatedFoldersResponse: [], + foldersWithDuplicates: [], + foldersWithoutDuplicates: [mockFolder], + }); + vi.spyOn(tasksService, 'create').mockReturnValue(taskId); + vi.spyOn(tasksService, 'updateTask').mockReturnValue(); + vi.spyOn(tasksService, 'getTasks').mockReturnValue([]); + vi.spyOn(tasksService, 'findTask').mockReturnValue(undefined); + }); + + test('When all files in a folder exceed the size limit and there are no subfolders, then the task is marked as error', async () => { + const bigFile = new File([new ArrayBuffer(200)], 'big.mp4'); + const dispatch = vi.fn().mockResolvedValue({ unwrap: () => Promise.resolve(mockFolder) }); + dispatch.mockImplementation((action) => { + if (typeof action === 'function') return action(dispatch, buildGetState(100), {}); + return { unwrap: () => Promise.resolve(mockFolder) }; + }); + const updateTaskSpy = vi.spyOn(tasksService, 'updateTask'); + + await uploadFolderThunk({ + root: { + folderId: 'parent-uuid', + childrenFiles: [bigFile], + childrenFolders: [], + name: 'TestFolder', + fullPathEdited: 'path', + }, + currentFolderId: 'parent-uuid', + options: { taskId }, + })(dispatch, buildGetState(100), {}); + + expect(updateTaskSpy).toHaveBeenCalledWith( + expect.objectContaining({ + merge: expect.objectContaining({ status: TaskStatus.Error }), + }), + ); + expect(uploadItemsParallelThunk).not.toHaveBeenCalled(); + }); + + test('When some files exceed the size limit and there are no subfolders, then only the allowed files are uploaded', async () => { + const smallFile = new File(['x'], 'small.txt'); + const bigFile = new File([new ArrayBuffer(200)], 'big.mp4'); + const uploadThunkAction = { unwrap: () => Promise.resolve() }; + (uploadItemsParallelThunk as unknown as Mock).mockReturnValue(() => uploadThunkAction); + const dispatch = vi.fn().mockImplementation((action) => { + if (typeof action === 'function') return action(dispatch, buildGetState(100), {}); + return { unwrap: () => Promise.resolve(mockFolder) }; + }); + + await uploadFolderThunk({ + root: { + folderId: 'parent-uuid', + childrenFiles: [smallFile, bigFile], + childrenFolders: [], + name: 'TestFolder', + fullPathEdited: 'path', + }, + currentFolderId: 'parent-uuid', + options: { taskId }, + })(dispatch, buildGetState(100), {}); + + expect(uploadItemsParallelThunk).toHaveBeenCalledWith(expect.objectContaining({ files: [smallFile, bigFile] })); + }); + + test('When no size limit is configured, then all files below the default size limit are uploaded', async () => { + const bigFile = new File([new ArrayBuffer(999_999)], 'huge.mp4'); + const uploadThunkAction = { unwrap: () => Promise.resolve() }; + (uploadItemsParallelThunk as unknown as Mock).mockReturnValue(() => uploadThunkAction); + const dispatch = vi.fn().mockImplementation((action) => { + if (typeof action === 'function') return action(dispatch, buildGetState(undefined), {}); + return { unwrap: () => Promise.resolve(mockFolder) }; + }); + + await uploadFolderThunk({ + root: { + folderId: 'parent-uuid', + childrenFiles: [bigFile], + childrenFolders: [], + name: 'TestFolder', + fullPathEdited: 'path', + }, + currentFolderId: 'parent-uuid', + options: { taskId }, + })(dispatch, buildGetState(), {}); + + expect(uploadItemsParallelThunk).toHaveBeenCalled(); + }); +}); diff --git a/src/app/store/slices/storage/storage.thunks/uploadFolderThunk.ts b/src/app/store/slices/storage/storage.thunks/uploadFolderThunk.ts index da513af69b..cc2eeecaa1 100644 --- a/src/app/store/slices/storage/storage.thunks/uploadFolderThunk.ts +++ b/src/app/store/slices/storage/storage.thunks/uploadFolderThunk.ts @@ -21,6 +21,10 @@ import { uploadItemsParallelThunk } from './uploadItemsThunk'; import { IRoot } from '../types'; import { wait } from 'utils/timeUtils'; import newStorageService from 'app/drive/services/new-storage.service'; +import { uiActions } from '../../ui'; +import { FilesExceedsSizeLimitError } from 'app/drive/services/file.service/upload.errors'; +import { filterFilesByMaxSize } from '../fileUtils/filterFilesByMaxSize'; +import { fileVersionsSelectors } from '../../fileVersions'; interface UploadFolderThunkPayload { root: IRoot; @@ -64,8 +68,10 @@ const stopUploadTask = async ( ); // Deletes the root folder if (rootFolderItem) { - promises.push(dispatch(deleteItemsThunk([rootFolderItem as DriveItemData])).unwrap()); - promises.push(newStorageService.deleteFolderByUuid(rootFolderItem.uuid)); + promises.push( + dispatch(deleteItemsThunk([rootFolderItem as DriveItemData])).unwrap(), + newStorageService.deleteFolderByUuid(rootFolderItem.uuid), + ); } await Promise.all(promises); }; @@ -144,9 +150,7 @@ export const uploadFolderThunk = createAsyncThunk 0; + + if (allFilesExceedSizeLimit && hasNoChildFolders) { + await stopUploadTask(uploadFolderAbortController, dispatch, taskId, rootFolderItem); + dispatch( + uiActions.setOpenFileSizeLimitReachedDialog({ open: true, info: { exceededFiles: level.childrenFiles } }), + ); + throw new FilesExceedsSizeLimitError(); + } + await dispatch( uploadItemsParallelThunk({ files: level.childrenFiles, diff --git a/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.test.ts b/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.test.ts index 9d67ba8adf..0a66e8d2f6 100644 --- a/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.test.ts +++ b/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.test.ts @@ -16,6 +16,7 @@ import errorService from 'services/error.service'; import { AppError } from '@internxt/sdk'; import shareService from '../../../../share/services/share.service'; import workspacesSelectors from '../../workspaces/workspaces.selectors'; +import { MAX_ALLOWED_UPLOAD_SIZE } from 'app/drive/services/network.service'; vi.mock('../../../../share/services/share.service', () => ({ default: { @@ -73,11 +74,22 @@ vi.mock('../../workspaces/workspaces.selectors', () => ({ }, })); +vi.mock('../../fileVersions', () => ({ + fileVersionsSelectors: { + getMaxFileSizeLimit: vi.fn((state) => state.fileVersions.limits?.maxUploadFileSize ?? MAX_ALLOWED_UPLOAD_SIZE), + }, +})); + +const getState = () => { + return { + user: { user: { email: 'test@test.com' } }, + fileVersions: { limits: { maxUploadFileSize: MAX_ALLOWED_UPLOAD_SIZE } }, + }; +}; + describe('uploadItemsThunk', () => { const dispatch = vi.fn(); - const getState = () => { - return { user: { user: { email: 'test@test.com' } } }; - }; + const mockFile = new File(['content'], 'file.txt', { type: 'text/plain' }); beforeEach(() => { @@ -199,9 +211,7 @@ describe('upload shared items thunk', () => { describe('Upload items in parallel thunk', () => { const dispatch = vi.fn(); - const getState = () => { - return { user: { user: { email: 'test@test.com' } } }; - }; + const mockFile = new File(['content'], 'file.txt', { type: 'text/plain' }); beforeEach(() => { @@ -232,6 +242,39 @@ describe('Upload items in parallel thunk', () => { }), ); }); + + test('When all files exceed the size limit, then the thunk throws without attempting the upload', async () => { + const bigFile = new File([new ArrayBuffer(200)], 'big.mp4'); + const getStateWithLimit = () => ({ + user: { user: { email: 'test@test.com' } }, + fileVersions: { limits: { maxUploadFileSize: 100 } }, + }); + + await uploadItemsParallelThunk({ + files: [bigFile], + parentFolderId: 'parent1', + })(dispatch, getStateWithLimit as () => RootState, {}); + + expect(prepareFilesToUpload).not.toHaveBeenCalled(); + expect(uploadFileWithManager).not.toHaveBeenCalled(); + }); + + test('When some files exceed the size limit and some do not, then only the allowed files are uploaded', async () => { + const smallFile = new File(['x'], 'small.txt'); + const bigFile = new File([new ArrayBuffer(200)], 'big.mp4'); + const getStateWithLimit = () => ({ + user: { user: { email: 'test@test.com' } }, + fileVersions: { limits: { maxUploadFileSize: 100 } }, + }); + (prepareFilesToUpload as Mock).mockResolvedValue({ filesToUpload: [smallFile] }); + + await uploadItemsParallelThunk({ + files: [smallFile, bigFile], + parentFolderId: 'parent1', + })(dispatch, getStateWithLimit as () => RootState, {}); + + expect(prepareFilesToUpload).toHaveBeenCalledWith(expect.objectContaining({ files: [smallFile] })); + }); }); describe('uploadItemsThunkExtraReducers', () => { diff --git a/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.ts b/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.ts index c8f2071124..562461f347 100644 --- a/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.ts +++ b/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.ts @@ -2,11 +2,10 @@ import { items as itemUtils } from '@internxt/lib'; import { SharedFiles } from '@internxt/sdk/dist/drive/share/types'; import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; -import { ActionReducerMapBuilder, createAsyncThunk } from '@reduxjs/toolkit'; +import { ActionReducerMapBuilder, AnyAction, createAsyncThunk, ThunkDispatch } from '@reduxjs/toolkit'; import { renameFile } from 'app/crypto/services/utils'; -import { MAX_ALLOWED_UPLOAD_SIZE } from 'app/drive/services/network.service'; -import { DriveFileData, DriveItemData } from 'app/drive/types'; +import { DriveFileData, DriveItemData, ExceededFile } from 'app/drive/types'; import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; import { t } from 'i18next'; @@ -27,6 +26,9 @@ import { FileToUpload } from 'app/drive/services/file.service/types'; import { prepareFilesToUpload } from '../fileUtils/prepareFilesToUpload'; import { StorageState } from '../storage.model'; import { AppError } from '@internxt/sdk'; +import { filterFilesByMaxSize } from '../fileUtils/filterFilesByMaxSize'; +import { MAX_ALLOWED_UPLOAD_SIZE } from 'app/drive/services/network.service'; +import { fileVersionsSelectors } from '../../fileVersions'; interface UploadItemsThunkOptions { relatedTaskId: string; @@ -57,6 +59,34 @@ const DEFAULT_OPTIONS: Partial = { showErrors: true, }; +const openReachedFileSizeLimitDIalog = ( + exceededFiles: ExceededFile[], + dispatch: ThunkDispatch, +) => { + dispatch( + uiActions.setOpenFileSizeLimitReachedDialog({ + open: true, + info: { + exceededFiles, + }, + }), + ); +}; + +const validateFileSize = ( + dispatch: ThunkDispatch, + files: File[], + maxUploadFileSize = MAX_ALLOWED_UPLOAD_SIZE, +): File[] => { + const { allowedFilesToUpload, exceededFiles } = filterFilesByMaxSize({ files, maxUploadFileSize }); + + if (exceededFiles.length > 0) { + openReachedFileSizeLimitDIalog(exceededFiles, dispatch); + } + + return allowedFilesToUpload; +}; + const isUploadAllowed = ({ state, files, @@ -65,13 +95,14 @@ const isUploadAllowed = ({ }: { state: RootState; files: File[]; - dispatch; + dispatch: ThunkDispatch; isWorkspaceSelected: boolean; }): boolean => { try { const planLimit = isWorkspaceSelected ? state.plan.businessPlanLimit : state.plan.planLimit; const planUsage = isWorkspaceSelected ? state.plan.businessPlanUsage : state.plan.planUsage; const uploadItemsSize = Object.values(files).reduce((acum, file) => acum + file.size, 0); + const totalItemsSize = uploadItemsSize + planUsage; const isPlanSizeLimitExceeded = planLimit && totalItemsSize >= planLimit; @@ -87,15 +118,6 @@ const isUploadAllowed = ({ errorService.reportError(err); } - const isAnyFileExceededSizeLimit = files.some((file) => file.size > MAX_ALLOWED_UPLOAD_SIZE); - if (isAnyFileExceededSizeLimit) { - notificationsService.show({ - text: t('error.maxSizeUploadLimitError'), - type: ToastType.Warning, - }); - return false; - } - return true; }; @@ -111,6 +133,7 @@ export const uploadItemsThunk = createAsyncThunk { const user = getState().user.user as UserSettings; + const maxUploadFileSize = fileVersionsSelectors.getMaxFileSizeLimit(getState()); const errors: AppError[] = []; const options = { ...DEFAULT_OPTIONS, ...payloadOptions }; @@ -132,16 +155,22 @@ export const uploadItemsThunk = createAsyncThunk openReachedFileSizeLimitDIalog(allowedFilesToUpload, dispatch); + try { - await uploadFileWithManager( - filesToUploadData, - openMaxSpaceOccupiedDialog, - DatabaseUploadRepository.getInstance(), - undefined, - { + await uploadFileWithManager({ + files: filesToUploadData, + maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialog, + fileSizeExceededCallback: openFileSizeLimitDialog, + uploadRepository: DatabaseUploadRepository.getInstance(), + options: { ownerUserAuthenticationData: ownerUserAuthenticationData ?? undefined, sharedItemData: { isDeepFolder: false, @@ -190,7 +221,7 @@ export const uploadItemsThunk = createAsyncThunk { const state = getState(); const user = state.user.user as UserSettings; + const maxFileSize = fileVersionsSelectors.getMaxFileSizeLimit(state); const workspaceCredentials = workspacesSelectors.getWorkspaceCredentials(state); const selectedWorkspace = workspacesSelectors.getSelectedWorkspace(state); const errors: AppError[] = []; @@ -429,8 +460,15 @@ export const uploadItemsParallelThunk = createAsyncThunk openReachedFileSizeLimitDIalog(allowedFilesToUpload, dispatch); + try { - await uploadFileWithManager( - filesToUploadData, - openMaxSpaceOccupiedDialog, - DatabaseUploadRepository.getInstance(), + await uploadFileWithManager({ + files: filesToUploadData, + maxSpaceOccupiedCallback: openMaxSpaceOccupiedDialog, + fileSizeExceededCallback: openFileSizeLimitDialog, + uploadRepository: DatabaseUploadRepository.getInstance(), abortController, - { + options: { ...options, ownerUserAuthenticationData: ownerUserAuthenticationData ?? undefined, sharedItemData: { @@ -475,9 +516,9 @@ export const uploadItemsParallelThunk = createAsyncThunk{ + .changePassword({ currentEncryptedPassword: encryptedCurrentPassword, newEncryptedPassword: encryptedNewPassword, newEncryptedSalt: encryptedNewSalt, diff --git a/src/services/error.service.ts b/src/services/error.service.ts index 0239a53e76..5db644fa24 100644 --- a/src/services/error.service.ts +++ b/src/services/error.service.ts @@ -34,7 +34,7 @@ const errorService = { const data = err.data as { error?: string; message?: string } | undefined; const message = data?.message || data?.error || err.message || 'Unknown error'; const headers = err.xRequestId ? { 'x-request-id': err.xRequestId } : undefined; - return new AppError(message, err.status, undefined, headers); + return new AppError(message, err.status, data?.error, headers); } if (err instanceof AxiosUnknownError) { diff --git a/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx b/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx index 4ae58bf584..6325a08ca1 100644 --- a/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx +++ b/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx @@ -59,6 +59,7 @@ import WarningMessageWrapper from 'views/Home/components/WarningMessageWrapper'; import './DriveExplorer.scss'; import { DriveTopBarItems } from './DriveTopBarItems'; import { ShareDialogWrapper } from 'app/drive/components/ShareDialog/ShareDialogWrapper'; +import { fileVersionsSelectors } from 'app/store/slices/fileVersions'; const MenuItemToGetSize = ({ isTrash, @@ -135,6 +136,7 @@ interface DriveExplorerProps { namePath: FolderPath[]; dispatch: AppDispatch; selectedWorkspace: WorkspaceData | null; + maxUploadFileSize: number; isOver: boolean; connectDropTarget: ConnectDropTarget; folderOnTrashLength: number; @@ -726,7 +728,7 @@ declare module 'react' { } const uploadItems = async (props: DriveExplorerProps, rootList: IRoot[], files: File[]) => { - const { dispatch, currentFolderId, onDragAndDropEnd } = props; + const { dispatch, currentFolderId, maxUploadFileSize, onDragAndDropEnd } = props; if (files.length <= UPLOAD_ITEMS_LIMIT) { if (files.length) { @@ -765,6 +767,7 @@ const uploadItems = async (props: DriveExplorerProps, rootList: IRoot[], files: payload: folderDataToUpload, selectedWorkspace: props.selectedWorkspace, dispatch, + maxUploadFileSize, }); dispatch(fetchSortedFolderContentThunk(currentFolderId)); } @@ -813,11 +816,13 @@ const dropTargetCollect: DropTargetCollector< export default connect((state: RootState) => { const currentFolderId: string = storageSelectors.currentFolderId(state); const selectedWorkspace = workspacesSelectors.getSelectedWorkspace(state); + const maxUploadFileSize = fileVersionsSelectors.getMaxFileSizeLimit(state); const hasMoreFolders = state.storage.hasMoreDriveFolders[currentFolderId] ?? true; const hasMoreFiles = state.storage.hasMoreDriveFiles[currentFolderId] ?? true; return { isAuthenticated: state.user.isAuthenticated, + maxUploadFileSize, user: state.user.user, currentFolderId, selectedItems: state.storage.selectedItems, diff --git a/src/views/Drive/hooks/useDriveItemDragAndDrop.tsx b/src/views/Drive/hooks/useDriveItemDragAndDrop.tsx index 189833cbc1..ba352cff92 100644 --- a/src/views/Drive/hooks/useDriveItemDragAndDrop.tsx +++ b/src/views/Drive/hooks/useDriveItemDragAndDrop.tsx @@ -13,6 +13,7 @@ import { import { DriveItemData } from 'app/drive/types'; import { uploadFoldersWithManager } from 'app/network/UploadFolderManager'; import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; +import { fileVersionsSelectors } from 'app/store/slices/fileVersions'; interface DragSourceCollectorProps { isDraggingThisItem: boolean; @@ -89,6 +90,7 @@ const handleFileDrop = async ( folderPath: string, selectedWorkspace: ReturnType, dispatch: ReturnType, + maxUploadFileSize: number, ) => { const { rootList, files } = await transformDraggedItems(droppedData.items, folderPath); @@ -107,6 +109,7 @@ const handleFileDrop = async ( payload: folderDataToUpload, selectedWorkspace, dispatch, + maxUploadFileSize, }); } }; @@ -117,6 +120,7 @@ export const useDriveItemDrop = (item: DriveItemData): DriveItemDrop => { const { selectedItems } = useAppSelector((state) => state.storage); const namePath = useAppSelector((state) => state.storage.namePath); const selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace); + const maxUploadFileSize = useAppSelector(fileVersionsSelectors.getMaxFileSizeLimit); const [{ isDraggingOverThisItem, canDrop }, connectDropTarget] = useDrop< DriveItemData | DriveItemData[], @@ -146,7 +150,7 @@ export const useDriveItemDrop = (item: DriveItemData): DriveItemDrop => { await handleDriveItemDrop(droppedData, item, isSomeItemSelected, selectedItems, dispatch); } else if (droppedType === NativeTypes.FILE) { const droppedData = monitor.getItem<{ items: DataTransferItemList }>(); - await handleFileDrop(droppedData, item, folderPath, selectedWorkspace, dispatch); + await handleFileDrop(droppedData, item, folderPath, selectedWorkspace, dispatch, maxUploadFileSize); } }, }), diff --git a/vite.config.mts b/vite.config.mts index 9b94a759b4..9ea62fe91b 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -1,7 +1,7 @@ import react from '@vitejs/plugin-react'; import dotenv from 'dotenv'; -import fs from 'fs'; -import path from 'path'; +import fs from 'node:fs'; +import path from 'node:path'; import { defineConfig } from 'vite'; import obfuscator from 'vite-plugin-bundle-obfuscator'; import { nodePolyfills } from 'vite-plugin-node-polyfills';