From debafd6f9f69096fe9868f8f3118e431cc18c795 Mon Sep 17 00:00:00 2001 From: douglas-xt Date: Thu, 26 Feb 2026 16:29:44 +0100 Subject: [PATCH] test(file): increase test coverage for file module --- .../file/file-version.repository.spec.ts | 128 ++++ src/modules/file/file.controller.spec.ts | 151 +++++ src/modules/file/file.repository.spec.ts | 444 +++++++++++++ src/modules/file/file.usecase.spec.ts | 603 ++++++++++++++++++ 4 files changed, 1326 insertions(+) diff --git a/src/modules/file/file-version.repository.spec.ts b/src/modules/file/file-version.repository.spec.ts index 975901ff1..1e9e6633a 100644 --- a/src/modules/file/file-version.repository.spec.ts +++ b/src/modules/file/file-version.repository.spec.ts @@ -1,4 +1,5 @@ import { createMock } from '@golevelup/ts-jest'; +import { QueryTypes } from 'sequelize'; import { SequelizeFileVersionRepository } from './file-version.repository'; import { type FileVersionModel } from './file-version.model'; import { FileVersion, FileVersionStatus } from './file-version.domain'; @@ -418,6 +419,133 @@ describe('SequelizeFileVersionRepository', () => { }); }); + describe('updateStatusBatch', () => { + it('When updating status for multiple versions, then it calls model update with all ids', async () => { + const ids = ['id-1', 'id-2', 'id-3']; + const status = FileVersionStatus.DELETED; + + jest.spyOn(fileVersionModel, 'update').mockResolvedValue([3] as any); + + await repository.updateStatusBatch(ids, status); + + expect(fileVersionModel.update).toHaveBeenCalledWith( + { status }, + { where: { id: ids } }, + ); + }); + }); + + describe('delete', () => { + it('When deleting a version, then it calls model destroy with the id', async () => { + const versionId = 'version-id'; + + jest.spyOn(fileVersionModel, 'destroy').mockResolvedValue(1 as any); + + await repository.delete(versionId); + + expect(fileVersionModel.destroy).toHaveBeenCalledWith({ + where: { id: versionId }, + }); + }); + }); + + describe('deleteUserVersionsBatch', () => { + it('When deleting user versions in batch, then it executes the query and returns affected count', async () => { + const userId = 'user-uuid'; + const limit = 10; + const affectedRows = 5; + + jest + .spyOn(fileVersionModel.sequelize, 'query') + .mockResolvedValue([undefined, affectedRows] as any); + + const result = await repository.deleteUserVersionsBatch(userId, limit); + + expect(result).toBe(affectedRows); + expect(fileVersionModel.sequelize.query).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + replacements: expect.objectContaining({ + userId, + limit, + deletedStatus: FileVersionStatus.DELETED, + existsStatus: FileVersionStatus.EXISTS, + }), + type: QueryTypes.UPDATE, + }), + ); + }); + }); + + describe('deleteUserVersionsByLimits', () => { + it('When deleting user versions by limits, then it executes the query and returns affected count', async () => { + const userId = 'user-uuid'; + const retentionDays = 30; + const maxVersions = 5; + const limit = 100; + const affectedRows = 3; + + jest + .spyOn(fileVersionModel.sequelize, 'query') + .mockResolvedValue([undefined, affectedRows] as any); + + const result = await repository.deleteUserVersionsByLimits( + userId, + retentionDays, + maxVersions, + limit, + ); + + expect(result).toBe(affectedRows); + expect(fileVersionModel.sequelize.query).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + replacements: expect.objectContaining({ + userId, + retentionDays, + maxVersions, + limit, + deletedStatus: FileVersionStatus.DELETED, + existsStatus: FileVersionStatus.EXISTS, + }), + type: QueryTypes.UPDATE, + }), + ); + }); + }); + + describe('findExpiredVersionIdsByTierLimits', () => { + it('When finding expired version ids, then it returns the version ids', async () => { + const limit = 50; + const queryResults = [{ version_id: 'uuid-1' }, { version_id: 'uuid-2' }]; + + jest + .spyOn(fileVersionModel.sequelize, 'query') + .mockResolvedValue(queryResults as any); + + const result = await repository.findExpiredVersionIdsByTierLimits(limit); + + expect(result).toEqual(['uuid-1', 'uuid-2']); + expect(fileVersionModel.sequelize.query).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + replacements: { limit }, + type: QueryTypes.SELECT, + }), + ); + }); + + it('When no expired versions are found, then it returns an empty array', async () => { + jest + .spyOn(fileVersionModel.sequelize, 'query') + .mockResolvedValue([] as any); + + const result = await repository.findExpiredVersionIdsByTierLimits(50); + + expect(result).toEqual([]); + }); + }); + describe('sumExistingSizesByUser', () => { it('When user has versions, then it returns the sum', async () => { const userId = 'user-uuid-123'; diff --git a/src/modules/file/file.controller.spec.ts b/src/modules/file/file.controller.spec.ts index 164ae3591..b0ae5109f 100644 --- a/src/modules/file/file.controller.spec.ts +++ b/src/modules/file/file.controller.spec.ts @@ -12,6 +12,7 @@ import { newFolder, newUser, newVersioningLimits, + newWorkspace, } from '../../../test/fixtures'; import { FileUseCases } from './file.usecase'; import { User } from '../user/user.domain'; @@ -155,6 +156,15 @@ describe('FileController', () => { offset: 0, }); }); + + it('When getRecentFiles is requested with a limit out of range, then it should throw BadRequestException', async () => { + await expect( + fileController.getRecentFiles( + userMocked, + API_LIMITS.FILES.GET.LIMIT.UPPER_BOUND + 1, + ), + ).rejects.toThrow(BadRequestException); + }); }); describe('get file by path', () => { @@ -201,6 +211,20 @@ describe('FileController', () => { fileController.getFileMetaByPath(userMocked, longPath), ).rejects.toThrow('Path is too deep'); }); + + it('When get file metadata by path throws an unexpected error, then it should log and not throw', async () => { + const filePath = '/test/file.png'; + jest + .spyOn(fileUseCases, 'getFileMetadataByPath') + .mockRejectedValue(new Error('Unexpected error')); + + const result = await fileController.getFileMetaByPath( + userMocked, + filePath, + ); + + expect(result).toBeUndefined(); + }); }); describe('update File MetaData by uuid', () => { @@ -311,6 +335,29 @@ describe('FileController', () => { ), ).rejects.toThrow(InternalServerErrorException); }); + + it('When fileId is provided without fileUuid, then it should log a warning and still create the thumbnail', async () => { + const dtoWithFileIdOnly: CreateThumbnailDto = { + ...createThumbnailDto, + fileUuid: undefined, + }; + jest + .spyOn(thumbnailUseCases, 'createThumbnail') + .mockResolvedValue(thumbnailDto); + + const result = await fileController.createThumbnail( + userMocked, + dtoWithFileIdOnly, + 'drive-web', + '1.0.0', + ); + + expect(result).toEqual(thumbnailDto); + expect(thumbnailUseCases.createThumbnail).toHaveBeenCalledWith( + userMocked, + dtoWithFileIdOnly, + ); + }); }); describe('deleteFileByUuid', () => { @@ -634,6 +681,28 @@ describe('FileController', () => { ), ).rejects.toThrow(error); }); + + it('When replaceFile is called with a workspace context, then it should pass workspace info to use case', async () => { + const workspace = newWorkspace(); + const replacedFile = newFile(); + jest.spyOn(fileUseCases, 'replaceFile').mockResolvedValue(replacedFile); + + await fileController.replaceFile( + userMocked, + validUuid, + replaceFileDto, + clientId, + requester, + workspace, + ); + + expect(fileUseCases.replaceFile).toHaveBeenCalledWith( + userMocked, + validUuid, + replaceFileDto, + { workspace, memberId: requester.uuid }, + ); + }); }); describe('getFiles', () => { @@ -748,6 +817,31 @@ describe('FileController', () => { undefined, ); }); + + it('When getFiles returns a file without plainName, then it should decrypt the file name', async () => { + const fileWithoutPlainName = newFile({ + attributes: { plainName: null }, + }); + jest + .spyOn(fileUseCases, 'getNotTrashedFilesUpdatedAfter') + .mockResolvedValue([fileWithoutPlainName]); + jest + .spyOn(fileUseCases, 'decrypFileName') + .mockReturnValue({ plainName: 'decrypted-name' } as any); + + const queryParams: GetFilesDto = { + limit: validLimit, + offset: validOffset, + status: FileStatus.EXISTS, + }; + + const result = await fileController.getFiles(userMocked, queryParams); + + expect(fileUseCases.decrypFileName).toHaveBeenCalledWith( + fileWithoutPlainName, + ); + expect(result[0].plainName).toBe('decrypted-name'); + }); }); describe('getLimits', () => { @@ -791,4 +885,61 @@ describe('FileController', () => { ); }); }); + + describe('getFileVersions', () => { + it('When getFileVersions is called, then it should return file versions', async () => { + const fileUuid = v4(); + jest.spyOn(fileUseCases, 'getFileVersions').mockResolvedValue([]); + + const result = await fileController.getFileVersions(userMocked, fileUuid); + + expect(result).toEqual([]); + expect(fileUseCases.getFileVersions).toHaveBeenCalledWith( + userMocked, + fileUuid, + ); + }); + }); + + describe('deleteFileVersion', () => { + it('When deleteFileVersion is called, then it should call the use case', async () => { + const fileUuid = v4(); + const versionId = v4(); + jest + .spyOn(fileUseCases, 'deleteFileVersion') + .mockResolvedValue(undefined); + + await fileController.deleteFileVersion(userMocked, fileUuid, versionId); + + expect(fileUseCases.deleteFileVersion).toHaveBeenCalledWith( + userMocked, + fileUuid, + versionId, + ); + }); + }); + + describe('restoreFileVersion', () => { + it('When restoreFileVersion is called, then it should return the restored file', async () => { + const fileUuid = v4(); + const versionId = v4(); + const restoredFile = newFile(); + jest + .spyOn(fileUseCases, 'restoreFileVersion') + .mockResolvedValue(restoredFile); + + const result = await fileController.restoreFileVersion( + userMocked, + fileUuid, + versionId, + ); + + expect(result).toEqual(restoredFile); + expect(fileUseCases.restoreFileVersion).toHaveBeenCalledWith( + userMocked, + fileUuid, + versionId, + ); + }); + }); }); diff --git a/src/modules/file/file.repository.spec.ts b/src/modules/file/file.repository.spec.ts index a5eb1626f..a973ad21e 100644 --- a/src/modules/file/file.repository.spec.ts +++ b/src/modules/file/file.repository.spec.ts @@ -1163,4 +1163,448 @@ describe('FileRepository', () => { expect(result).toBe(0); }); }); + + describe('deleteByFileId', () => { + it('When called, then it should throw as method is not implemented', async () => { + await expect(repository.deleteByFileId('any-id')).rejects.toThrow(); + }); + }); + + describe('findById', () => { + it('When a file is found, then it should return the mapped file', async () => { + const mockFile = newFile(); + const model: FileModel = { + ...mockFile, + toJSON: mockFile.toJSON, + } as any; + + jest.spyOn(fileModel, 'findOne').mockResolvedValueOnce(model); + + const result = await (repository as SequelizeFileRepository).findById( + mockFile.uuid, + ); + + expect(fileModel.findOne).toHaveBeenCalledWith({ + where: { uuid: mockFile.uuid }, + }); + expect(result).toBeDefined(); + }); + + it('When additional where options are provided, then it should include them in the query', async () => { + const mockFile = newFile(); + const model: FileModel = { + ...mockFile, + toJSON: mockFile.toJSON, + } as any; + + jest.spyOn(fileModel, 'findOne').mockResolvedValueOnce(model); + + await (repository as SequelizeFileRepository).findById(mockFile.uuid, { + status: FileStatus.EXISTS, + } as any); + + expect(fileModel.findOne).toHaveBeenCalledWith({ + where: { uuid: mockFile.uuid, status: FileStatus.EXISTS }, + }); + }); + }); + + describe('findByUuids', () => { + it('When files are found by uuids, then it should return an array of files', async () => { + const mockFile = newFile(); + const uuids = [mockFile.uuid, v4()]; + const model: FileModel = { + ...mockFile, + toJSON: mockFile.toJSON, + } as any; + + jest.spyOn(fileModel, 'findAll').mockResolvedValueOnce([model]); + + const result = await (repository as SequelizeFileRepository).findByUuids( + uuids, + ); + + expect(fileModel.findAll).toHaveBeenCalledWith({ + where: expect.objectContaining({ + uuid: expect.objectContaining({ [Op.in]: uuids }), + }), + }); + expect(result).toHaveLength(1); + }); + + it('When no files are found, then it should return an empty array', async () => { + jest.spyOn(fileModel, 'findAll').mockResolvedValueOnce([]); + + const result = await (repository as SequelizeFileRepository).findByUuids([ + v4(), + ]); + + expect(result).toEqual([]); + }); + }); + + describe('findByUuid', () => { + it('When a file is found, then it should return the mapped file', async () => { + const mockFile = newFile(); + const model: FileModel = { + ...mockFile, + toJSON: mockFile.toJSON, + } as any; + + jest.spyOn(fileModel, 'findOne').mockResolvedValueOnce(model); + + const result = await repository.findByUuid( + mockFile.uuid, + mockFile.userId, + {}, + ); + + expect(fileModel.findOne).toHaveBeenCalledWith({ + where: { uuid: mockFile.uuid, userId: mockFile.userId }, + }); + expect(result).toBeDefined(); + }); + + it('When no file is found, then it should return null', async () => { + jest.spyOn(fileModel, 'findOne').mockResolvedValueOnce(null); + + const result = await repository.findByUuid(v4(), 1, {}); + + expect(result).toBeNull(); + }); + }); + + describe('findAllNotDeleted', () => { + it('When files are found, then it should return files excluding deleted ones', async () => { + const mockFile = newFile(); + const model: FileModel = { + ...mockFile, + toJSON: mockFile.toJSON, + } as any; + + jest.spyOn(fileModel, 'findAll').mockResolvedValueOnce([model]); + + const result = await ( + repository as SequelizeFileRepository + ).findAllNotDeleted({ userId: mockFile.userId }, 10, 0); + + expect(fileModel.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 10, + offset: 0, + where: expect.objectContaining({ + status: expect.objectContaining({ [Op.not]: FileStatus.DELETED }), + }), + }), + ); + expect(result).toHaveLength(1); + }); + + it('When no files are found, then it should return an empty array', async () => { + jest.spyOn(fileModel, 'findAll').mockResolvedValueOnce([]); + + const result = await ( + repository as SequelizeFileRepository + ).findAllNotDeleted({}, 10, 0); + + expect(result).toEqual([]); + }); + }); + + describe('findAllCursorWithThumbnails', () => { + it('When files are found, then it should return them with thumbnail includes', async () => { + const mockFile = newFile(); + const model: FileModel = { + ...mockFile, + toJSON: mockFile.toJSON, + } as any; + + jest.spyOn(fileModel, 'findAll').mockResolvedValueOnce([model]); + + const result = await ( + repository as SequelizeFileRepository + ).findAllCursorWithThumbnails({ userId: mockFile.userId }, 10, 0); + + expect(fileModel.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 10, + offset: 0, + include: expect.any(Array), + subQuery: false, + }), + ); + expect(result).toHaveLength(1); + }); + + it('When no files are found, then it should return an empty array', async () => { + jest.spyOn(fileModel, 'findAll').mockResolvedValueOnce([]); + + const result = await ( + repository as SequelizeFileRepository + ).findAllCursorWithThumbnails({}, 10, 0); + + expect(result).toEqual([]); + }); + }); + + describe('findByIds', () => { + it('When files are found by ids, then it should return an array of files', async () => { + const mockFile = newFile(); + const model: FileModel = { + ...mockFile, + toJSON: mockFile.toJSON, + } as any; + + jest.spyOn(fileModel, 'findAll').mockResolvedValueOnce([model]); + + const result = await (repository as SequelizeFileRepository).findByIds( + mockFile.userId, + [mockFile.id], + ); + + expect(fileModel.findAll).toHaveBeenCalledWith({ + where: { id: { [Op.in]: [mockFile.id] }, userId: mockFile.userId }, + }); + expect(result).toHaveLength(1); + }); + + it('When no files are found, then it should return an empty array', async () => { + jest.spyOn(fileModel, 'findAll').mockResolvedValueOnce([]); + + const result = await (repository as SequelizeFileRepository).findByIds( + 1, + [999], + ); + + expect(result).toEqual([]); + }); + }); + + describe('findAllByFolderIdAndUserId with pagination', () => { + it('When page and perPage are provided, then it should apply offset and limit to the query', async () => { + const mockFile = newFile(); + const model: FileModel = { + ...mockFile, + toJSON: mockFile.toJSON, + } as any; + + jest.spyOn(fileModel, 'findAll').mockResolvedValueOnce([model]); + + await repository.findAllByFolderIdAndUserId( + mockFile.folderId, + mockFile.userId, + { deleted: false, page: 1, perPage: 10 }, + ); + + expect(fileModel.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + offset: 10, + limit: 10, + where: expect.objectContaining({ + folderId: mockFile.folderId, + userId: mockFile.userId, + }), + }), + ); + }); + }); + + describe('getFilesByFolderUuid', () => { + it('When files are found in a folder, then it should return them', async () => { + const mockFile = newFile(); + const model: FileModel = { + ...mockFile, + toJSON: mockFile.toJSON, + } as any; + + jest.spyOn(fileModel, 'findAll').mockResolvedValueOnce([model]); + + const result = await repository.getFilesByFolderUuid( + mockFile.folderUuid, + FileStatus.EXISTS, + ); + + expect(fileModel.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + where: { folderUuid: mockFile.folderUuid, status: FileStatus.EXISTS }, + include: expect.any(Array), + }), + ); + expect(result).toHaveLength(1); + }); + + it('When no files are found, then it should return an empty array', async () => { + jest.spyOn(fileModel, 'findAll').mockResolvedValueOnce([]); + + const result = await repository.getFilesByFolderUuid( + v4(), + FileStatus.EXISTS, + ); + + expect(result).toEqual([]); + }); + }); + + describe('findOneBy', () => { + it('When a file is found, then it should return the file', async () => { + const mockFile = newFile(); + const model: FileModel = { + ...mockFile, + toJSON: mockFile.toJSON, + } as any; + + jest.spyOn(fileModel, 'findOne').mockResolvedValueOnce(model); + + const result = await repository.findOneBy({ uuid: mockFile.uuid }); + + expect(fileModel.findOne).toHaveBeenCalledWith({ + where: { uuid: mockFile.uuid }, + }); + expect(result).toBeDefined(); + }); + + it('When no file is found, then it should return null', async () => { + jest.spyOn(fileModel, 'findOne').mockResolvedValueOnce(null); + + const result = await repository.findOneBy({ uuid: v4() }); + + expect(result).toBeNull(); + }); + }); + + describe('updateByUuidAndUserId', () => { + it('When called, then it should call update with the given data and where clause', async () => { + const mockFile = newFile(); + const update = { plainName: 'new-name' }; + + await repository.updateByUuidAndUserId( + mockFile.uuid, + mockFile.userId, + update, + ); + + expect(fileModel.update).toHaveBeenCalledWith(update, { + where: { userId: mockFile.userId, uuid: mockFile.uuid }, + }); + }); + }); + + describe('getFilesWhoseFolderIdDoesNotExist', () => { + it('When called, then it should return the count of orphan files', async () => { + const count = 3; + + jest.spyOn(fileModel, 'findAndCountAll').mockResolvedValueOnce({ + rows: [], + count, + } as any); + + const result = await repository.getFilesWhoseFolderIdDoesNotExist( + user.id, + ); + + expect(result).toBe(count); + }); + }); + + describe('getFilesCountWhere', () => { + it('When called, then it should return the count of matching files', async () => { + const count = 5; + + jest.spyOn(fileModel, 'findAndCountAll').mockResolvedValueOnce({ + rows: [], + count, + } as any); + + const result = await repository.getFilesCountWhere({ + userId: user.id, + status: FileStatus.EXISTS, + }); + + expect(fileModel.findAndCountAll).toHaveBeenCalledWith({ + where: { userId: user.id, status: FileStatus.EXISTS }, + }); + expect(result).toBe(count); + }); + }); + + describe('updateFilesStatusToTrashed', () => { + it('When called, then it should update files status to trashed using fileIds', async () => { + const fileIds = [v4(), v4()]; + + await repository.updateFilesStatusToTrashed(user, fileIds); + + expect(fileModel.update).toHaveBeenCalledWith( + expect.objectContaining({ + deleted: true, + status: FileStatus.TRASHED, + }), + expect.objectContaining({ + where: expect.objectContaining({ + userId: user.id, + fileId: { [Op.in]: fileIds }, + }), + }), + ); + }); + }); + + describe('updateFilesStatusToTrashedByUuid', () => { + it('When called, then it should update files status to trashed using uuids', async () => { + const fileUuids = [v4(), v4()]; + + await repository.updateFilesStatusToTrashedByUuid(user, fileUuids); + + expect(fileModel.update).toHaveBeenCalledWith( + expect.objectContaining({ + deleted: true, + status: FileStatus.TRASHED, + }), + expect.objectContaining({ + where: expect.objectContaining({ + userId: user.id, + uuid: { [Op.in]: fileUuids }, + }), + }), + ); + }); + }); + + describe('markFilesInFolderAsRemoved', () => { + it('When called, then it should mark files as removed and return the updated count', async () => { + const parentUuids = [v4(), v4()]; + const updatedCount = 5; + + jest + .spyOn(fileModel, 'update') + .mockResolvedValueOnce([updatedCount] as any); + + const result = await ( + repository as SequelizeFileRepository + ).markFilesInFolderAsRemoved(parentUuids); + + expect(fileModel.update).toHaveBeenCalledWith( + expect.objectContaining({ + removed: true, + status: FileStatus.DELETED, + }), + expect.objectContaining({ + where: expect.objectContaining({ + folderUuid: { [Op.in]: parentUuids }, + status: { [Op.not]: FileStatus.DELETED }, + }), + }), + ); + expect(result).toEqual({ updatedCount }); + }); + + it('When no files are updated, then it should return zero count', async () => { + jest.spyOn(fileModel, 'update').mockResolvedValueOnce([0] as any); + + const result = await ( + repository as SequelizeFileRepository + ).markFilesInFolderAsRemoved([v4()]); + + expect(result).toEqual({ updatedCount: 0 }); + }); + }); }); diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index 4f172aaa8..d15cb35cd 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -3552,4 +3552,607 @@ describe('FileUseCases', () => { expect(result).toEqual({ deletedCount: 150 }); }); }); + + describe('getByUuid', () => { + it('When called with a uuid, then it should delegate to repository findById', async () => { + const mockFile = newFile(); + jest + .spyOn(fileRepository as any, 'findById') + .mockResolvedValueOnce(mockFile); + + const result = await service.getByUuid(mockFile.uuid); + + expect((fileRepository as any).findById).toHaveBeenCalledWith( + mockFile.uuid, + ); + expect(result).toEqual(mockFile); + }); + }); + + describe('getFilesAndUserByUuid', () => { + it('When called with uuids, then it should delegate to repository getFilesWithUserByUuuid', async () => { + const mockFiles = [newFile(), newFile()]; + const uuids = mockFiles.map((f) => f.uuid); + + jest + .spyOn(fileRepository, 'getFilesWithUserByUuuid') + .mockResolvedValueOnce(mockFiles); + + const result = await service.getFilesAndUserByUuid(uuids); + + expect(fileRepository.getFilesWithUserByUuuid).toHaveBeenCalledWith( + uuids, + undefined, + ); + expect(result).toEqual(mockFiles); + }); + }); + + describe('getByUuids', () => { + it('When called with uuids, then it should delegate to repository findByUuids', async () => { + const mockFiles = [newFile()]; + const uuids = mockFiles.map((f) => f.uuid); + + jest + .spyOn(fileRepository as any, 'findByUuids') + .mockResolvedValueOnce(mockFiles); + + const result = await service.getByUuids(uuids); + + expect((fileRepository as any).findByUuids).toHaveBeenCalledWith(uuids); + expect(result).toEqual(mockFiles); + }); + }); + + describe('getZeroSizeFilesInWorkspaceByMember', () => { + it('When called, then it should delegate to repository and return count', async () => { + const memberId = v4(); + const workspaceId = v4(); + const count = 3; + + jest + .spyOn(fileRepository, 'getZeroSizeFilesCountInWorkspaceByMember') + .mockResolvedValueOnce(count); + + const result = await service.getZeroSizeFilesInWorkspaceByMember( + memberId, + workspaceId, + ); + + expect( + fileRepository.getZeroSizeFilesCountInWorkspaceByMember, + ).toHaveBeenCalledWith(memberId, workspaceId); + expect(result).toBe(count); + }); + }); + + describe('updateFileMetaData ownership check', () => { + it('When file is not owned by user, then it should throw ForbiddenException', async () => { + const newFileMeta: UpdateFileMetaDto = { plainName: 'new-name' }; + const mockFile = newFile(); + + jest.spyOn(fileRepository, 'findOneBy').mockResolvedValueOnce(mockFile); + + await expect( + service.updateFileMetaData(userMocked, mockFile.uuid, newFileMeta), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('getFilesByIds', () => { + it('When called, then it should delegate to repository findByIds', async () => { + const mockFiles = [newFile()]; + const fileIds = [1, 2]; + + jest + .spyOn(fileRepository as any, 'findByIds') + .mockResolvedValueOnce(mockFiles); + + const result = await service.getFilesByIds(userMocked, fileIds); + + expect((fileRepository as any).findByIds).toHaveBeenCalledWith( + userMocked.id, + fileIds, + ); + expect(result).toEqual(mockFiles); + }); + }); + + describe('getAllFilesUpdatedAfter', () => { + const updatedAfter = new Date(); + const pagination = { limit: 10, offset: 0 }; + + it('When called without bucket, then it should query without bucket filter', async () => { + jest + .spyOn(fileRepository, 'findAllCursorWhereUpdatedAfter') + .mockResolvedValueOnce([]); + + await service.getAllFilesUpdatedAfter( + userMocked.id, + updatedAfter, + pagination, + ); + + expect( + fileRepository.findAllCursorWhereUpdatedAfter, + ).toHaveBeenCalledWith( + expect.not.objectContaining({ bucket: expect.anything() }), + updatedAfter, + pagination.limit, + pagination.offset, + expect.any(Array), + undefined, + ); + }); + + it('When called with bucket, then it should include bucket in the where clause', async () => { + const bucket = 'test-bucket'; + + jest + .spyOn(fileRepository, 'findAllCursorWhereUpdatedAfter') + .mockResolvedValueOnce([]); + + await service.getAllFilesUpdatedAfter( + userMocked.id, + updatedAfter, + pagination, + bucket, + ); + + expect( + fileRepository.findAllCursorWhereUpdatedAfter, + ).toHaveBeenCalledWith( + expect.objectContaining({ bucket }), + updatedAfter, + pagination.limit, + pagination.offset, + expect.any(Array), + undefined, + ); + }); + }); + + describe('getNotTrashedFilesUpdatedAfter', () => { + it('When called with bucket, then it should include bucket and EXISTS status in the where clause', async () => { + const updatedAfter = new Date(); + const bucket = 'test-bucket'; + + jest + .spyOn(fileRepository, 'findAllCursorWhereUpdatedAfter') + .mockResolvedValueOnce([]); + + await service.getNotTrashedFilesUpdatedAfter( + userMocked.id, + updatedAfter, + { limit: 10, offset: 0 }, + bucket, + ); + + expect( + fileRepository.findAllCursorWhereUpdatedAfter, + ).toHaveBeenCalledWith( + expect.objectContaining({ bucket, status: FileStatus.EXISTS }), + updatedAfter, + 10, + 0, + expect.any(Array), + undefined, + ); + }); + }); + + describe('getRemovedFilesUpdatedAfter', () => { + it('When called with bucket, then it should include bucket and DELETED status in the where clause', async () => { + const updatedAfter = new Date(); + const bucket = 'test-bucket'; + + jest + .spyOn(fileRepository, 'findAllCursorWhereUpdatedAfter') + .mockResolvedValueOnce([]); + + await service.getRemovedFilesUpdatedAfter( + userMocked.id, + updatedAfter, + { limit: 10, offset: 0 }, + bucket, + ); + + expect( + fileRepository.findAllCursorWhereUpdatedAfter, + ).toHaveBeenCalledWith( + expect.objectContaining({ bucket, status: FileStatus.DELETED }), + updatedAfter, + 10, + 0, + expect.any(Array), + undefined, + ); + }); + }); + + describe('getTrashedFilesUpdatedAfter', () => { + it('When called with bucket, then it should include bucket and TRASHED status in the where clause', async () => { + const updatedAfter = new Date(); + const bucket = 'test-bucket'; + + jest + .spyOn(fileRepository, 'findAllCursorWhereUpdatedAfter') + .mockResolvedValueOnce([]); + + await service.getTrashedFilesUpdatedAfter( + userMocked.id, + updatedAfter, + { limit: 10, offset: 0 }, + bucket, + ); + + expect( + fileRepository.findAllCursorWhereUpdatedAfter, + ).toHaveBeenCalledWith( + expect.objectContaining({ bucket, status: FileStatus.TRASHED }), + updatedAfter, + 10, + 0, + expect.any(Array), + undefined, + ); + }); + }); + + describe('getFilesUpdatedAfter', () => { + it('When no sort is provided, then it should default to updatedAt ASC', async () => { + const updatedAfter = new Date(); + + jest + .spyOn(fileRepository, 'findAllCursorWhereUpdatedAfter') + .mockResolvedValueOnce([]); + + await service.getFilesUpdatedAfter(userMocked.id, {}, updatedAfter, { + limit: 10, + offset: 0, + }); + + expect( + fileRepository.findAllCursorWhereUpdatedAfter, + ).toHaveBeenCalledWith( + expect.objectContaining({ userId: userMocked.id }), + updatedAfter, + 10, + 0, + [['updatedAt', 'ASC']], + undefined, + ); + }); + + it('When sort is provided, then it should use the provided sort', async () => { + const updatedAfter = new Date(); + const sort: Array<[SortableFileAttributes, 'ASC' | 'DESC']> = [ + ['plainName', 'ASC'], + ]; + + jest + .spyOn(fileRepository, 'findAllCursorWhereUpdatedAfter') + .mockResolvedValueOnce([]); + + await service.getFilesUpdatedAfter(userMocked.id, {}, updatedAfter, { + limit: 10, + offset: 0, + sort, + }); + + expect( + fileRepository.findAllCursorWhereUpdatedAfter, + ).toHaveBeenCalledWith( + expect.objectContaining({ userId: userMocked.id }), + updatedAfter, + 10, + 0, + sort, + undefined, + ); + }); + }); + + describe('getFiles', () => { + it('When withoutThumbnails is true, then it should call findAllCursor instead of findAllCursorWithThumbnails', async () => { + const mockFile = newFile({ + attributes: { plainName: 'file.txt', thumbnails: [] }, + }); + + jest + .spyOn(fileRepository, 'findAllCursor') + .mockResolvedValueOnce([mockFile]); + + const result = await service.getFiles( + userMocked.id, + {}, + { limit: 10, offset: 0, withoutThumbnails: true }, + ); + + expect(fileRepository.findAllCursor).toHaveBeenCalledWith( + expect.objectContaining({ userId: userMocked.id }), + 10, + 0, + undefined, + ); + expect(result).toHaveLength(1); + }); + + it('When files have no plainName, then it should decrypt the file name', async () => { + const mockFile = newFile({ + attributes: { plainName: null, thumbnails: [] }, + }); + + jest + .spyOn(fileRepository as any, 'findAllCursorWithThumbnails') + .mockResolvedValueOnce([mockFile]); + jest.spyOn(cryptoService, 'decryptName').mockReturnValue('decrypted'); + + const result = await service.getFiles( + userMocked.id, + {}, + { + limit: 10, + offset: 0, + }, + ); + + expect(result).toHaveLength(1); + }); + }); + + describe('getFilesInWorkspace', () => { + it('When withoutThumbnails is true, then it should call findAllCursorInWorkspace', async () => { + const createdBy = v4(); + const workspaceId = v4(); + const mockFile = newFile({ + attributes: { plainName: 'file.txt', thumbnails: [] }, + }); + + jest + .spyOn(fileRepository, 'findAllCursorInWorkspace') + .mockResolvedValueOnce([mockFile]); + + const result = await service.getFilesInWorkspace( + createdBy, + workspaceId, + {}, + { limit: 10, offset: 0, withoutThumbnails: true }, + ); + + expect(fileRepository.findAllCursorInWorkspace).toHaveBeenCalledWith( + createdBy, + workspaceId, + {}, + 10, + 0, + undefined, + ); + expect(result).toHaveLength(1); + }); + + it('When called without withoutThumbnails, then it should call findAllCursorWithThumbnailsInWorkspace', async () => { + const createdBy = v4(); + const workspaceId = v4(); + const mockFile = newFile({ + attributes: { plainName: 'file.txt', thumbnails: [] }, + }); + + jest + .spyOn(fileRepository, 'findAllCursorWithThumbnailsInWorkspace') + .mockResolvedValueOnce([mockFile]); + + const result = await service.getFilesInWorkspace( + createdBy, + workspaceId, + {}, + { limit: 10, offset: 0 }, + ); + + expect( + fileRepository.findAllCursorWithThumbnailsInWorkspace, + ).toHaveBeenCalled(); + expect(result).toHaveLength(1); + }); + }); + + describe('getFilesNotDeleted', () => { + it('When called, then it should delegate to repository findAllNotDeleted', async () => { + const mockFiles = [newFile()]; + + jest + .spyOn(fileRepository as any, 'findAllNotDeleted') + .mockResolvedValueOnce(mockFiles); + + const result = await service.getFilesNotDeleted( + userMocked.id, + {}, + { + limit: 10, + offset: 0, + }, + ); + + expect((fileRepository as any).findAllNotDeleted).toHaveBeenCalledWith( + expect.objectContaining({ userId: userMocked.id }), + 10, + 0, + ); + expect(result).toEqual(mockFiles); + }); + }); + + describe('moveFilesToTrash default fileUuids', () => { + it('When called without fileUuids, then it should use empty array as default', async () => { + jest.spyOn(fileRepository, 'findByFileIds').mockResolvedValueOnce([]); + jest + .spyOn(fileRepository, 'updateFilesStatusToTrashed') + .mockResolvedValue(); + jest + .spyOn(fileRepository, 'updateFilesStatusToTrashedByUuid') + .mockResolvedValue(); + jest.spyOn(trashUsecases, 'addItemsToTrash').mockResolvedValue(undefined); + + await service.moveFilesToTrash(userMocked, [fileId]); + + expect( + fileRepository.updateFilesStatusToTrashedByUuid, + ).toHaveBeenCalledWith(userMocked, []); + }); + + it('When addItemsToTrash fails, then error is caught and function still resolves', async () => { + jest.spyOn(fileRepository, 'findByFileIds').mockResolvedValueOnce([]); + jest + .spyOn(trashUsecases, 'addItemsToTrash') + .mockRejectedValue(new Error('trash error')); + + await expect( + service.moveFilesToTrash(userMocked, [fileId]), + ).resolves.not.toThrow(); + + await Promise.resolve(); + }); + }); + + describe('deleteUserTrashedFilesBatch', () => { + it('When called, then it should delegate to repository and return the deleted count', async () => { + const limit = 50; + const deletedCount = 10; + + jest + .spyOn(fileRepository, 'deleteUserTrashedFilesBatch') + .mockResolvedValueOnce(deletedCount); + + const result = await service.deleteUserTrashedFilesBatch( + userMocked, + limit, + ); + + expect(fileRepository.deleteUserTrashedFilesBatch).toHaveBeenCalledWith( + userMocked.id, + limit, + ); + expect(result).toBe(deletedCount); + }); + }); + + describe('replaceFile status check', () => { + it('When file status is not EXISTS, then it should throw BadRequestException', async () => { + const mockFile = newFile({ + attributes: { status: FileStatus.TRASHED }, + }); + + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValueOnce(mockFile); + + await expect( + service.replaceFile(userMocked, mockFile.uuid, { + fileId: 'new-id', + size: BigInt(100), + }), + ).rejects.toThrow(BadRequestException); + }); + + it('When addFileReplacementDelta fails, then error is caught and replace still succeeds', async () => { + const mockFile = newFile({ + attributes: { fileId: 'old-id', bucket: 'bucket' }, + }); + + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValueOnce(mockFile); + jest + .spyOn(service, 'isFileVersionable') + .mockResolvedValueOnce({ versionable: false, limits: null }); + jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); + jest + .spyOn(service, 'addFileReplacementDelta') + .mockRejectedValueOnce(new Error('delta error')); + jest.spyOn(bridgeService, 'deleteFile').mockResolvedValue(); + + const result = await service.replaceFile(userMocked, mockFile.uuid, { + fileId: 'new-id', + size: BigInt(100), + }); + + expect(result).toBeDefined(); + }); + + it('When network deleteFile fails, then error is caught and replace still succeeds', async () => { + const mockFile = newFile({ + attributes: { fileId: 'old-id', bucket: 'bucket' }, + }); + + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValueOnce(mockFile); + jest + .spyOn(service, 'isFileVersionable') + .mockResolvedValueOnce({ versionable: false, limits: null }); + jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); + jest + .spyOn(service, 'addFileReplacementDelta') + .mockResolvedValueOnce(null); + jest + .spyOn(bridgeService, 'deleteFile') + .mockRejectedValueOnce(new Error('network error')); + + const result = await service.replaceFile(userMocked, mockFile.uuid, { + fileId: 'new-id', + size: BigInt(100), + }); + + expect(result).toBeDefined(); + }); + }); + + describe('addFileReplacementDelta', () => { + it('When lock is not acquired, then it should return null', async () => { + const file = newFile(); + + jest + .spyOn(redisService, 'tryAcquireLock') + .mockResolvedValueOnce(false as any); + + const result = await service.addFileReplacementDelta( + userMocked, + file, + file, + ); + + expect(result).toBeNull(); + }); + }); + + describe('moveFile wasTrashed', () => { + it('When file was trashed before move, then it should remove it from trash', async () => { + const trashedFile = newFile({ + attributes: { + userId: userMocked.id, + status: FileStatus.TRASHED, + }, + }); + const destinationFolder = newFolder({ + attributes: { userId: userMocked.id }, + }); + + jest + .spyOn(fileRepository, 'findByUuid') + .mockResolvedValueOnce(trashedFile); + jest + .spyOn(folderUseCases, 'getFolderByUuid') + .mockResolvedValueOnce(destinationFolder); + jest + .spyOn(fileRepository, 'findByPlainNameAndFolderId') + .mockResolvedValueOnce(null); + jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); + jest + .spyOn(trashUsecases, 'removeItemsFromTrash') + .mockResolvedValueOnce(undefined); + + await service.moveFile(userMocked, trashedFile.uuid, { + destinationFolder: destinationFolder.uuid, + }); + + expect(trashUsecases.removeItemsFromTrash).toHaveBeenCalledWith( + [trashedFile.uuid], + TrashItemType.File, + ); + }); + }); });