diff --git a/src/modules/file/file.repository.ts b/src/modules/file/file.repository.ts index eec47a77d..c9a7b288b 100644 --- a/src/modules/file/file.repository.ts +++ b/src/modules/file/file.repository.ts @@ -56,6 +56,14 @@ export interface FileRepository { ): Promise; getFilesWhoseFolderIdDoesNotExist(userId: File['userId']): Promise; getFilesCountWhere(where: Partial): Promise; + updateFilesStatusToTrashed( + user: User, + fileIds: File['fileId'][], + ): Promise; + updateFilesStatusToTrashedByUuid( + user: User, + fileUuids: File['uuid'][], + ): Promise; } @Injectable() @@ -409,7 +417,32 @@ export class SequelizeFileRepository implements FileRepository { [Op.in]: fileIds, }, status: { - [Op.not]: FileStatus.DELETED, + [Op.eq]: FileStatus.EXISTS, + }, + }, + }, + ); + } + + async updateFilesStatusToTrashedByUuid( + user: User, + fileUuids: File['uuid'][], + ): Promise { + await this.fileModel.update( + { + deleted: true, + deletedAt: new Date(), + status: FileStatus.TRASHED, + updatedAt: new Date(), + }, + { + where: { + userId: user.id, + uuid: { + [Op.in]: fileUuids, + }, + status: { + [Op.eq]: FileStatus.EXISTS, }, }, }, diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index e2b71057f..c1cb9b1be 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -4,7 +4,6 @@ import { FileUseCases } from './file.usecase'; import { SequelizeFileRepository, FileRepository } from './file.repository'; import { ForbiddenException, - NotFoundException, UnprocessableEntityException, } from '@nestjs/common'; import { File, FileAttributes, FileStatus } from './file.domain'; @@ -89,61 +88,45 @@ describe('FileUseCases', () => { }); describe('move file to trash', () => { - it.skip('calls moveFilesToTrash and return file', async () => { - const mockFile = File.build({ - id: 1, - fileId: '', - name: '', - type: '', - size: null, - bucket: '', - folderId: 4, - encryptVersion: '', - deleted: false, - deletedAt: new Date(), - userId: 1, - modificationTime: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - uuid: '', - folderUuid: '', - removed: false, - removedAt: undefined, - plainName: '', - status: FileStatus.EXISTS, - }); - jest - .spyOn(fileRepository, 'updateByFieldIdAndUserId') - .mockResolvedValue(mockFile); - const result = await service.moveFilesToTrash(userMocked, [fileId]); - expect(result).toEqual(mockFile); - }); - - it.skip('throws an error if the file is not found', async () => { - jest - .spyOn(fileRepository, 'updateByFieldIdAndUserId') - .mockRejectedValue(new NotFoundException()); - expect(service.moveFilesToTrash(userMocked, [fileId])).rejects.toThrow( - NotFoundException, - ); + const mockFile = File.build({ + id: 1, + fileId: '', + name: '', + type: '', + size: null, + bucket: '', + folderId: 4, + encryptVersion: '', + deleted: false, + deletedAt: new Date(), + userId: 1, + modificationTime: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + uuid: '723274e5-ca2a-4e61-bf17-d9fba3b8d430', + folderUuid: '', + removed: false, + removedAt: undefined, + plainName: '', + status: FileStatus.EXISTS, }); - }); - describe('move multiple files to trash', () => { - it.skip('calls moveFilesToTrash', async () => { + it('When you try to trash files with id and uuid, then functions are called with respective values', async () => { const fileIds = [fileId]; - jest - .spyOn(fileRepository, 'updateManyByFieldIdAndUserId') - .mockImplementation(() => { - return new Promise((resolve) => { - resolve(); - }); - }); - const result = await service.moveFilesToTrash(userMocked, fileIds); - expect(result).toEqual(undefined); - expect(fileRepository.updateManyByFieldIdAndUserId).toHaveBeenCalledTimes( + const fileUuids = [mockFile.uuid]; + jest.spyOn(fileRepository, 'updateFilesStatusToTrashed'); + jest.spyOn(fileRepository, 'updateFilesStatusToTrashedByUuid'); + await service.moveFilesToTrash(userMocked, fileIds, fileUuids); + expect(fileRepository.updateFilesStatusToTrashed).toHaveBeenCalledTimes( 1, ); + expect(fileRepository.updateFilesStatusToTrashed).toHaveBeenCalledWith( + userMocked, + fileIds, + ); + expect( + fileRepository.updateFilesStatusToTrashedByUuid, + ).toHaveBeenCalledWith(userMocked, fileUuids); }); }); diff --git a/src/modules/file/file.usecase.ts b/src/modules/file/file.usecase.ts index 517ad4630..f1bb24c42 100644 --- a/src/modules/file/file.usecase.ts +++ b/src/modules/file/file.usecase.ts @@ -248,8 +248,12 @@ export class FileUseCases { moveFilesToTrash( user: User, fileIds: FileAttributes['fileId'][], - ): Promise { - return this.fileRepository.updateFilesStatusToTrashed(user, fileIds); + fileUuids: FileAttributes['uuid'][] = [], + ): Promise<[void, void]> { + return Promise.all([ + this.fileRepository.updateFilesStatusToTrashed(user, fileIds), + this.fileRepository.updateFilesStatusToTrashedByUuid(user, fileUuids), + ]); } async getEncryptionKeyFileFromShare( diff --git a/src/modules/folder/folder.repository.ts b/src/modules/folder/folder.repository.ts index e97608772..7068ba465 100644 --- a/src/modules/folder/folder.repository.ts +++ b/src/modules/folder/folder.repository.ts @@ -65,6 +65,10 @@ export interface FolderRepository { deleteById(folderId: FolderAttributes['id']): Promise; clearOrphansFolders(userId: FolderAttributes['userId']): Promise; calculateFolderSize(folderUuid: string): Promise; + findUserFoldersByUuid( + user: User, + uuids: FolderAttributes['uuid'][], + ): Promise; } @Injectable() @@ -143,6 +147,18 @@ export class SequelizeFolderRepository implements FolderRepository { return folders.map((folder) => this.toDomain(folder)); } + async findUserFoldersByUuid(user: User, uuids: FolderAttributes['uuid'][]) { + const folders = await this.folderModel.findAll({ + where: { + uuid: { [Op.in]: uuids }, + userId: user.id, + deleted: false, + removed: false, + }, + }); + return folders.map((folder) => this.toDomain(folder)); + } + async findAllByParentIdCursor( where: Partial, limit: number, diff --git a/src/modules/folder/folder.usecase.spec.ts b/src/modules/folder/folder.usecase.spec.ts index 9f0416adb..34243fb2c 100644 --- a/src/modules/folder/folder.usecase.spec.ts +++ b/src/modules/folder/folder.usecase.spec.ts @@ -15,10 +15,12 @@ import { BridgeModule } from '../../externals/bridge/bridge.module'; import { CryptoModule } from '../../externals/crypto/crypto.module'; import { CryptoService } from '../../externals/crypto/crypto.service'; import { User } from '../user/user.domain'; -import { newFolder } from '../../../test/fixtures'; +import { newFolder, newUser } from '../../../test/fixtures'; import { CalculateFolderSizeTimeoutException } from './exception/calculate-folder-size-timeout.exception'; const folderId = 4; +const user = newUser(); + describe('FolderUseCases', () => { let service: FolderUseCases; let folderRepository: FolderRepository; @@ -107,6 +109,97 @@ describe('FolderUseCases', () => { }); }); + describe('move multiple folders to trash', () => { + const rootFolderBucket = 'bucketRoot'; + const mockFolder = Folder.build({ + id: 1, + parentId: null, + parentUuid: null, + name: 'name', + bucket: rootFolderBucket, + userId: 1, + encryptVersion: '03-aes', + deleted: true, + deletedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + uuid: '2545feaf-4d6b-40d8-9bf8-550285268bd3', + plainName: '', + removed: false, + removedAt: null, + }); + + it('When uuid and id are passed and there is a backup and drive folder, then backups and drive folders should be updated', async () => { + const mockBackupFolder = Folder.build({ + id: 1, + parentId: null, + parentUuid: null, + name: 'name', + bucket: 'bucketIdforBackup', + userId: 1, + encryptVersion: '03-aes', + deleted: true, + deletedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + uuid: '', + plainName: '', + removed: false, + removedAt: null, + }); + + jest + .spyOn(service, 'getFoldersByIds') + .mockResolvedValue([mockBackupFolder]); + jest + .spyOn(folderRepository, 'findUserFoldersByUuid') + .mockResolvedValue([mockFolder]); + jest.spyOn(service, 'getFolder').mockResolvedValue({ + bucket: rootFolderBucket, + } as Folder); + jest.spyOn(folderRepository, 'updateManyByFolderId'); + + await service.moveFoldersToTrash( + user, + [mockBackupFolder.id], + [mockFolder.uuid], + ); + + expect(folderRepository.updateManyByFolderId).toHaveBeenCalledTimes(2); + expect(folderRepository.updateManyByFolderId).toHaveBeenCalledWith( + [mockFolder.id], + { + deleted: true, + deletedAt: expect.any(Date), + }, + ); + expect(folderRepository.updateManyByFolderId).toHaveBeenCalledWith( + [mockBackupFolder.id], + { + deleted: true, + deletedAt: expect.any(Date), + removed: true, + removedAt: expect.any(Date), + }, + ); + }); + + it('When only ids are passed, then only folders by id should be searched', async () => { + jest.spyOn(service, 'getFoldersByIds').mockResolvedValue([mockFolder]); + jest.spyOn(service, 'getFolder').mockResolvedValue({ + bucket: rootFolderBucket, + } as Folder); + jest.spyOn(folderRepository, 'findUserFoldersByUuid'); + jest.spyOn(service, 'getFoldersByIds'); + + await service.moveFoldersToTrash(user, [mockFolder.id]); + expect(folderRepository.findUserFoldersByUuid).not.toHaveBeenCalled(); + expect(service.getFoldersByIds).toHaveBeenCalledWith(user, [ + mockFolder.id, + ]); + }); + }); + describe('get folder use case', () => { it('calls getFolder and return folder', async () => { const mockFolder = Folder.build({ diff --git a/src/modules/folder/folder.usecase.ts b/src/modules/folder/folder.usecase.ts index a2f7359e2..6e1508d31 100644 --- a/src/modules/folder/folder.usecase.ts +++ b/src/modules/folder/folder.usecase.ts @@ -293,12 +293,18 @@ export class FolderUseCases { async moveFoldersToTrash( user: User, folderIds: FolderAttributes['id'][], + folderUuids: FolderAttributes['uuid'][] = [], ): Promise { - const [folders, driveRootFolder] = await Promise.all([ + const [foldersById, driveRootFolder, foldersByUuid] = await Promise.all([ this.getFoldersByIds(user, folderIds), this.getFolder(user.rootFolderId), + folderUuids.length > 0 + ? this.folderRepository.findUserFoldersByUuid(user, folderUuids) + : Promise.resolve([]), ]); + const folders = foldersById.concat(foldersByUuid); + const backups = folders.filter((f) => f.isBackup(driveRootFolder)); const driveFolders = folders.filter((f) => !f.isBackup(driveRootFolder)); diff --git a/src/modules/trash/dto/controllers/move-items-to-trash.dto.spec.ts b/src/modules/trash/dto/controllers/move-items-to-trash.dto.spec.ts new file mode 100644 index 000000000..9ddb26ac5 --- /dev/null +++ b/src/modules/trash/dto/controllers/move-items-to-trash.dto.spec.ts @@ -0,0 +1,65 @@ +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; +import { + ItemToTrash, + ItemType, + MoveItemsToTrashDto, +} from './move-items-to-trash.dto'; + +describe('MoveItemsToTrashDto', () => { + it('When valid data is passed, then no errors should be returned', async () => { + const dto = plainToInstance(MoveItemsToTrashDto, { + items: [ + { id: '1', type: ItemType.FILE }, + { uuid: '5bf9dca1-fd68-4864-9a16-ef36b77d063b', type: ItemType.FOLDER }, + ], + }); + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('When items array exceeds max size, then should fail', async () => { + const items = Array.from({ length: 51 }, (_, i) => ({ + id: `${i + 1}`, + type: ItemType.FILE, + })); + const dto = plainToInstance(MoveItemsToTrashDto, { items }); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].constraints).toBeDefined(); + }); + + describe('ItemToTrash', () => { + it('When both id and uuid are provided in one item, then should fail', async () => { + const item = plainToInstance(ItemToTrash, { + id: '1', + uuid: '5bf9dca1-fd68-4864-9a16-ef36b77d063b', + type: ItemType.FILE, + }); + const errors = await validate(item); + expect(errors.length).toBeGreaterThan(0); + }); + + it('When neither id nor uuid are provided in one item, then should fail', async () => { + const item = plainToInstance(ItemToTrash, { type: ItemType.FILE }); + const errors = await validate(item); + expect(errors.length).toBeGreaterThan(0); + }); + + it('when either id or uuid are provided, then should validate successfuly ', async () => { + const onlyIdErrors = await validate( + plainToInstance(ItemToTrash, { id: '1', type: ItemType.FILE }), + ); + const onlyUuidErrors = await validate( + plainToInstance(ItemToTrash, { + uuid: '5bf9dca1-fd68-4864-9a16-ef36b77d063b', + type: ItemType.FILE, + }), + ); + expect(onlyIdErrors.length).toBe(0); + expect(onlyUuidErrors.length).toBe(0); + }); + }); +}); diff --git a/src/modules/trash/dto/controllers/move-items-to-trash.dto.ts b/src/modules/trash/dto/controllers/move-items-to-trash.dto.ts index c4106e350..ea3624da2 100644 --- a/src/modules/trash/dto/controllers/move-items-to-trash.dto.ts +++ b/src/modules/trash/dto/controllers/move-items-to-trash.dto.ts @@ -1,5 +1,12 @@ import { Type } from 'class-transformer'; -import { ArrayMaxSize, IsEnum, IsNotEmpty } from 'class-validator'; +import { + ArrayMaxSize, + IsDefined, + IsEnum, + IsNotEmpty, + ValidateIf, + ValidateNested, +} from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export enum ItemType { @@ -8,12 +15,21 @@ export enum ItemType { } export class ItemToTrash { - @IsNotEmpty() @ApiProperty({ example: '4', description: 'Id of file or folder', }) - id: string; + id?: string; + + @ApiProperty({ + example: '4', + description: 'Uuid of file or folder', + }) + uuid?: string; + + @ValidateIf((item) => (!item.id && !item.uuid) || (item.id && item.uuid)) + @IsDefined({ message: 'Provide either item id or uuid, and not both' }) + readonly AreUuidAndIdDefined?: boolean; @IsEnum(ItemType) @ApiProperty({ @@ -29,6 +45,7 @@ export class MoveItemsToTrashDto { description: 'Array of items with files and folders ids', }) @ArrayMaxSize(50) + @ValidateNested() @Type(() => ItemToTrash) items: ItemToTrash[]; } diff --git a/src/modules/trash/trash.controller.spec.ts b/src/modules/trash/trash.controller.spec.ts new file mode 100644 index 000000000..ede9834a9 --- /dev/null +++ b/src/modules/trash/trash.controller.spec.ts @@ -0,0 +1,113 @@ +import { createMock } from '@golevelup/ts-jest'; +import { TrashController } from './trash.controller'; +import { FileUseCases } from '../file/file.usecase'; +import { FolderUseCases } from '../folder/folder.usecase'; +import { UserUseCases } from '../user/user.usecase'; +import { TrashUseCases } from './trash.usecase'; +import { NotificationService } from '../../externals/notifications/notification.service'; +import { newUser } from '../../../test/fixtures'; +import { BadRequestException } from '@nestjs/common'; +import { ItemType } from './dto/controllers/move-items-to-trash.dto'; + +const user = newUser(); + +describe('TrashController', () => { + let controller: TrashController; + let folderUseCases: FolderUseCases; + let fileUseCases: FileUseCases; + let userUseCases: UserUseCases; + let trashUseCases: TrashUseCases; + let notificationService: NotificationService; + + beforeEach(async () => { + folderUseCases = createMock(); + fileUseCases = createMock(); + userUseCases = createMock(); + trashUseCases = createMock(); + notificationService = createMock(); + controller = new TrashController( + fileUseCases, + folderUseCases, + userUseCases, + notificationService, + trashUseCases, + ); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('When item type is invalid, then it should throw', async () => { + await expect( + controller.moveItemsToTrash( + { + items: [ + { + uuid: '5bf9dca1-fd68-4864-9a16-ef36b77d063b', + type: 'test' as ItemType, + }, + ], + }, + user, + 'anyid', + ), + ).rejects.toThrow(BadRequestException); + }); + + it('When array is empty, then it should not call anything', async () => { + const body = { items: [] }; + jest.spyOn(fileUseCases, 'moveFilesToTrash'); + + await controller.moveItemsToTrash(body, user, ''); + expect(fileUseCases.moveFilesToTrash).not.toHaveBeenCalled(); + }); + + it('When items are passed, then items should be deleted with their respective uuid or id', async () => { + const fileItems = [ + { + uuid: '5bf9dca1-fd68-4864-9a16-ef36b77d063b', + type: ItemType.FILE, + }, + { + id: '2', + type: ItemType.FILE, + }, + ]; + const folderItems = [ + { + id: '1', + type: ItemType.FOLDER, + }, + { + uuid: '9af7dca1-fd68-4864-9b60-ef36b77d0903', + type: ItemType.FOLDER, + }, + ]; + + jest.spyOn(fileUseCases, 'moveFilesToTrash'); + jest.spyOn(folderUseCases, 'moveFoldersToTrash'); + jest + .spyOn(userUseCases, 'getWorkspaceMembersByBrigeUser') + .mockResolvedValue([]); + + await controller.moveItemsToTrash( + { + items: [...fileItems, ...folderItems], + }, + user, + '', + ); + + expect(fileUseCases.moveFilesToTrash).toHaveBeenCalledWith( + user, + [fileItems[1].id], + [fileItems[0].uuid], + ); + expect(folderUseCases.moveFoldersToTrash).toHaveBeenCalledWith( + user, + [parseInt(folderItems[0].id)], + [folderItems[1].uuid], + ); + }); +}); diff --git a/src/modules/trash/trash.controller.ts b/src/modules/trash/trash.controller.ts index 484e9feab..3e7bd3d49 100644 --- a/src/modules/trash/trash.controller.ts +++ b/src/modules/trash/trash.controller.ts @@ -21,7 +21,10 @@ import { ApiOperation, ApiTags, } from '@nestjs/swagger'; -import { MoveItemsToTrashDto } from './dto/controllers/move-items-to-trash.dto'; +import { + ItemType, + MoveItemsToTrashDto, +} from './dto/controllers/move-items-to-trash.dto'; import { User as UserDecorator } from '../auth/decorators/user.decorator'; import { Client } from '../auth/decorators/client.decorator'; import { FileUseCases } from '../file/file.usecase'; @@ -124,7 +127,6 @@ export class TrashController { @Body() moveItemsDto: MoveItemsToTrashDto, @UserDecorator() user: User, @Client() clientId: string, - @Res({ passthrough: true }) res: Response, ) { if (moveItemsDto.items.length === 0) { logger('error', { @@ -137,19 +139,33 @@ export class TrashController { try { const fileIds: string[] = []; + const fileUuids: string[] = []; const folderIds: number[] = []; + const folderUuids: string[] = []; + for (const item of moveItemsDto.items) { - if (item.type === 'file') { - fileIds.push(item.id); - } else if (item.type === 'folder') { - folderIds.push(parseInt(item.id)); - } else { - throw new BadRequestException(`type ${item.type} invalid`); + switch (item.type) { + case ItemType.FILE: + if (item.id) { + fileIds.push(item.id); + } else { + fileUuids.push(item.uuid); + } + break; + case ItemType.FOLDER: + if (item.id) { + folderIds.push(parseInt(item.id, 10)); + } else { + folderUuids.push(item.uuid); + } + break; + default: + throw new BadRequestException(`type ${item.type} invalid`); } } await Promise.all([ - this.fileUseCases.moveFilesToTrash(user, fileIds), - this.folderUseCases.moveFoldersToTrash(user, folderIds), + this.fileUseCases.moveFilesToTrash(user, fileIds, fileUuids), + this.folderUseCases.moveFoldersToTrash(user, folderIds, folderUuids), ]); this.userUseCases @@ -179,9 +195,13 @@ export class TrashController { user: { email, uuid }, })}, STACK: ${(err as Error).stack}`, ); - res.status(HttpStatus.INTERNAL_SERVER_ERROR); + if (err instanceof BadRequestException) { + throw err; + } - return { error: 'Internal Server Error' }; + throw new InternalServerErrorException({ + error: 'Internal Server Error', + }); } }