diff --git a/src/modules/file/file.repository.spec.ts b/src/modules/file/file.repository.spec.ts index 42cade753..61e99af04 100644 --- a/src/modules/file/file.repository.spec.ts +++ b/src/modules/file/file.repository.spec.ts @@ -1,8 +1,4 @@ import { Test, type TestingModule } from '@nestjs/testing'; - -jest.mock('../../lib/query-timeout', () => ({ - withQueryTimeout: jest.fn((_sequelize, _timeout, cb) => cb({})), -})); import { getModelToken } from '@nestjs/sequelize'; import { createMock } from '@golevelup/ts-jest'; import { @@ -23,6 +19,10 @@ import { UserModel } from '../user/user.model'; import { WorkspaceItemUserModel } from '../workspaces/models/workspace-items-users.model'; import { Time } from '../../lib/time'; +jest.mock('../../lib/query-timeout', () => ({ + withQueryTimeout: jest.fn((_sequelize, _timeout, cb) => cb({})), +})); + describe('FileRepository', () => { let repository: FileRepository; let fileModel: typeof FileModel; @@ -534,13 +534,66 @@ describe('FileRepository', () => { }); }); + describe('findTrashedNotExpired', () => { + const userId = 1; + const limit = 10; + const offset = 0; + + it('When no expiration date is set, then it should return all trashed files regardless of when they were trashed', async () => { + jest.spyOn(fileModel, 'findAll').mockResolvedValue([]); + + await repository.findTrashedNotExpired(userId, null, limit, offset); + + expect(fileModel.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId, status: FileStatus.TRASHED }, + }), + ); + }); + + it('When an expiration date is set, then it should only return trashed files that have not yet expired', async () => { + const cutoffDate = new Date('2026-03-04'); + jest.spyOn(fileModel, 'findAll').mockResolvedValue([]); + + await repository.findTrashedNotExpired(userId, cutoffDate, limit, offset); + + expect(fileModel.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId, + status: FileStatus.TRASHED, + updatedAt: { [Op.gte]: cutoffDate }, + }, + }), + ); + }); + + it('When retrieving trashed files, then it should also load the name of the folder each file belongs to', async () => { + jest.spyOn(fileModel, 'findAll').mockResolvedValue([]); + + await repository.findTrashedNotExpired(userId, null, limit, offset); + + expect(fileModel.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + include: expect.arrayContaining([ + expect.objectContaining({ + as: 'folder', + attributes: ['plainName', 'removed', 'deleted', 'uuid'], + required: false, + }), + ]), + }), + ); + }); + }); + describe('findTrashedNotExpiredInWorkspace', () => { const createdBy = v4(); const workspaceId = v4(); const limit = 10; const offset = 0; - it('When cutoffDate is null, then it should query trashed files without a date filter', async () => { + it('When no expiration date is set, then it should return all trashed workspace files regardless of when they were trashed', async () => { jest.spyOn(fileModel, 'findAll').mockResolvedValue([]); await repository.findTrashedNotExpiredInWorkspace( @@ -558,7 +611,7 @@ describe('FileRepository', () => { ); }); - it('When cutoffDate is provided, then it should add an updatedAt >= cutoffDate filter', async () => { + it('When an expiration date is set, then it should only return workspace trashed files that have not yet expired', async () => { const cutoffDate = new Date('2026-03-04'); jest.spyOn(fileModel, 'findAll').mockResolvedValue([]); @@ -579,6 +632,30 @@ describe('FileRepository', () => { }), ); }); + + it('When retrieving workspace trashed files, then it should also load the name of the folder each file belongs to', async () => { + jest.spyOn(fileModel, 'findAll').mockResolvedValue([]); + + await repository.findTrashedNotExpiredInWorkspace( + createdBy, + workspaceId, + null, + limit, + offset, + ); + + expect(fileModel.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + include: expect.arrayContaining([ + expect.objectContaining({ + as: 'folder', + attributes: ['plainName', 'removed', 'deleted', 'uuid'], + required: false, + }), + ]), + }), + ); + }); }); describe('findRecent', () => { diff --git a/src/modules/file/file.repository.ts b/src/modules/file/file.repository.ts index 9d8005b33..4386ce7e9 100644 --- a/src/modules/file/file.repository.ts +++ b/src/modules/file/file.repository.ts @@ -9,6 +9,7 @@ import { } from './file.domain'; import { type FindOptions, + type Includeable, Op, QueryTypes, Sequelize, @@ -422,7 +423,7 @@ export class SequelizeFileRepository implements FileRepository { structuredClone(order); const [, orderDirection] = order[plainNameIndex]; newOrder[plainNameIndex] = Sequelize.literal( - `plain_name COLLATE "custom_numeric" ${ + `"FileModel"."plain_name" COLLATE "custom_numeric" ${ orderDirection === 'ASC' ? 'ASC' : 'DESC' }`, ); @@ -456,16 +457,15 @@ export class SequelizeFileRepository implements FileRepository { offset: number, order: Array<[keyof FileModel, string]> = [], ): Promise { - return this.findAllCursorWithThumbnails( - { - userId, - status: FileStatus.TRASHED, - ...(cutoffDate && { updatedAt: { [Op.gte]: cutoffDate } }), - }, + return this.trashedNotExpiredQuery({ + cutoffDate, limit, offset, order, - ); + where: { + userId, + }, + }); } async findTrashedNotExpiredInWorkspace( @@ -476,17 +476,76 @@ export class SequelizeFileRepository implements FileRepository { offset: number, order: Array<[keyof FileModel, string]> = [], ): Promise { - return this.findAllCursorWithThumbnailsInWorkspace( - createdBy, - workspaceId, - { - status: FileStatus.TRASHED, - ...(cutoffDate && { updatedAt: { [Op.gte]: cutoffDate } }), - }, + return this.trashedNotExpiredQuery({ + cutoffDate, limit, offset, order, - ); + include: [ + { + model: WorkspaceItemUserModel, + where: { + createdBy, + workspaceId, + itemType: WorkspaceItemType.File, + }, + as: 'workspaceUser', + include: [ + { + model: UserModel, + as: 'creator', + attributes: ['uuid', 'email', 'name', 'lastname', 'userId'], + required: true, + }, + ], + }, + ], + }); + } + + async trashedNotExpiredQuery({ + cutoffDate, + limit, + offset, + order = [], + where, + include = [], + }: { + cutoffDate: Date | null; + limit: number; + offset: number; + order: Array<[keyof FileModel, string]>; + where?: WhereOptions; + include?: Includeable[]; + }): Promise { + const appliedOrder = this.applyCollateToPlainNameSort(order); + + const files = await this.fileModel.findAll({ + limit, + offset, + where: { + status: FileStatus.TRASHED, + ...(cutoffDate && { updatedAt: { [Op.gte]: cutoffDate } }), + ...where, + }, + include: [ + { + model: FolderModel, + as: 'folder', + attributes: ['plainName', 'removed', 'deleted', 'uuid'], + required: false, + }, + { + model: this.thumbnailModel, + required: false, + }, + ...include, + ], + subQuery: false, + order: appliedOrder, + }); + + return files.map(this.toDomain.bind(this)); } async findAllCursorWhereUpdatedAfterInWorkspace( @@ -1087,9 +1146,9 @@ export class SequelizeFileRepository implements FileRepository { status: FileStatus.DELETED, updatedAt: { [Op.lt]: cutoffDate }, }, + order: [['updatedAt', 'ASC']], useMaster: opts?.useMaster, limit, - order: [['updatedAt', 'ASC']], }); return rows.map((r) => r.uuid); diff --git a/src/modules/folder/folder.repository.spec.ts b/src/modules/folder/folder.repository.spec.ts index c51cbf9ac..39c8679b7 100644 --- a/src/modules/folder/folder.repository.spec.ts +++ b/src/modules/folder/folder.repository.spec.ts @@ -1,8 +1,4 @@ import { createMock } from '@golevelup/ts-jest'; - -jest.mock('../../lib/query-timeout', () => ({ - withQueryTimeout: jest.fn((_sequelize, _timeout, cb) => cb({})), -})); import { CalculateFolderSizeTimeoutException } from './exception/calculate-folder-size-timeout.exception'; import { SequelizeFolderRepository } from './folder.repository'; import { FolderModel } from './folder.model'; @@ -19,6 +15,10 @@ import { v4 } from 'uuid'; import { randomInt } from 'crypto'; import { Time } from '../../lib/time'; +jest.mock('../../lib/query-timeout', () => ({ + withQueryTimeout: jest.fn((_sequelize, _timeout, cb) => cb({})), +})); + jest.mock('./folder.model', () => ({ FolderModel: { sequelize: { @@ -352,13 +352,67 @@ describe('SequelizeFolderRepository', () => { }); }); + describe('findTrashedNotExpired', () => { + const userId = 1; + const limit = 10; + const offset = 0; + + it('When no expiration date is set, then it should return all trashed folders regardless of when they were trashed', async () => { + jest.spyOn(folderModel, 'findAll').mockResolvedValueOnce([]); + + await repository.findTrashedNotExpired(userId, null, limit, offset); + + expect(folderModel.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId, deleted: true, removed: false }, + }), + ); + }); + + it('When an expiration date is set, then it should only return trashed folders that have not yet expired', async () => { + const cutoffDate = new Date('2026-03-04'); + jest.spyOn(folderModel, 'findAll').mockResolvedValueOnce([]); + + await repository.findTrashedNotExpired(userId, cutoffDate, limit, offset); + + expect(folderModel.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId, + deleted: true, + removed: false, + updatedAt: { [Op.gte]: cutoffDate }, + }, + }), + ); + }); + + it('When retrieving trashed folders, then it should also load the name of the parent folder each folder belongs to', async () => { + jest.spyOn(folderModel, 'findAll').mockResolvedValueOnce([]); + + await repository.findTrashedNotExpired(userId, null, limit, offset); + + expect(folderModel.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + include: expect.arrayContaining([ + expect.objectContaining({ + as: 'parent', + attributes: ['plainName', 'removed', 'deleted', 'uuid'], + required: false, + }), + ]), + }), + ); + }); + }); + describe('findTrashedNotExpiredInWorkspace', () => { const createdBy = v4(); const workspaceId = v4(); const limit = 10; const offset = 0; - it('When cutoffDate is null, then it should query trashed folders without a date filter', async () => { + it('When no expiration date is set, then it should return all trashed workspace folders regardless of when they were trashed', async () => { jest.spyOn(folderModel, 'findAll').mockResolvedValueOnce([]); await repository.findTrashedNotExpiredInWorkspace( @@ -376,7 +430,7 @@ describe('SequelizeFolderRepository', () => { ); }); - it('When cutoffDate is provided, then it should add an updatedAt >= cutoffDate filter', async () => { + it('When an expiration date is set, then it should only return workspace trashed folders that have not yet expired', async () => { const cutoffDate = new Date('2026-03-04'); jest.spyOn(folderModel, 'findAll').mockResolvedValueOnce([]); @@ -398,6 +452,30 @@ describe('SequelizeFolderRepository', () => { }), ); }); + + it('When retrieving workspace trashed folders, then it should also load the name of the parent folder each folder belongs to', async () => { + jest.spyOn(folderModel, 'findAll').mockResolvedValueOnce([]); + + await repository.findTrashedNotExpiredInWorkspace( + createdBy, + workspaceId, + null, + limit, + offset, + ); + + expect(folderModel.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + include: expect.arrayContaining([ + expect.objectContaining({ + as: 'parent', + attributes: ['plainName', 'removed', 'deleted', 'uuid'], + required: false, + }), + ]), + }), + ); + }); }); describe('createWithAttributes', () => { diff --git a/src/modules/folder/folder.repository.ts b/src/modules/folder/folder.repository.ts index 5d4896f0d..ac58665aa 100644 --- a/src/modules/folder/folder.repository.ts +++ b/src/modules/folder/folder.repository.ts @@ -1,10 +1,16 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { withQueryTimeout } from '../../lib/query-timeout'; import { InjectModel } from '@nestjs/sequelize'; -import { Op, QueryTypes, Sequelize, type WhereOptions } from 'sequelize'; +import { + type Includeable, + Op, + QueryTypes, + Sequelize, + type WhereOptions, +} from 'sequelize'; import { v4 } from 'uuid'; -import { Folder, FolderStatus } from './folder.domain'; +import { Folder } from './folder.domain'; import { type FolderAttributes } from './folder.attributes'; import { FolderModel } from './folder.model'; import { SharingModel } from '../sharing/models'; @@ -183,7 +189,7 @@ export class SequelizeFolderRepository implements FolderRepository { structuredClone(order); const [, orderDirection] = order[plainNameIndex]; newOrder[plainNameIndex] = Sequelize.literal( - `plain_name COLLATE "custom_numeric" ${ + `"FolderModel"."plain_name" COLLATE "custom_numeric" ${ orderDirection === 'ASC' ? 'ASC' : 'DESC' }`, ); @@ -250,17 +256,15 @@ export class SequelizeFolderRepository implements FolderRepository { offset: number, order: Array<[keyof FolderModel, 'ASC' | 'DESC']> = [], ): Promise { - return this.findAllCursor( - { - userId, - deleted: true, - removed: false, - ...(cutoffDate && { updatedAt: { [Op.gte]: cutoffDate } }), - }, + return this.trashNotExpiredQuery({ + cutoffDate, limit, offset, order, - ); + where: { + userId, + }, + }); } async findTrashedNotExpiredInWorkspace( @@ -271,18 +275,72 @@ export class SequelizeFolderRepository implements FolderRepository { offset: number, order: Array<[keyof FolderModel, 'ASC' | 'DESC']> = [], ): Promise { - return this.findAllCursorInWorkspace( - createdBy, - workspaceId, - { + return this.trashNotExpiredQuery({ + cutoffDate, + limit, + offset, + order, + include: [ + { + model: WorkspaceItemUserModel, + where: { + createdBy, + workspaceId, + itemType: WorkspaceItemType.Folder, + }, + as: 'workspaceUser', + include: [ + { + model: UserModel, + as: 'creator', + attributes: ['uuid', 'email', 'name', 'lastname', 'userId'], + }, + ], + }, + ], + }); + } + + async trashNotExpiredQuery({ + cutoffDate, + limit, + offset, + order, + include = [], + where, + }: { + cutoffDate: Date | null; + limit: number; + offset: number; + order: Array<[keyof FolderModel, 'ASC' | 'DESC']>; + include?: Includeable[]; + where?: WhereOptions; + }): Promise { + const appliedOrder = this.applyCollateToPlainNameSort(order); + + const folders = await this.folderModel.findAll({ + limit, + offset, + where: { deleted: true, removed: false, ...(cutoffDate && { updatedAt: { [Op.gte]: cutoffDate } }), + ...where, }, - limit, - offset, - order, - ); + include: [ + { + model: FolderModel, + as: 'parent', + attributes: ['plainName', 'removed', 'deleted', 'uuid'], + required: false, + }, + ...include, + ], + subQuery: false, + order: appliedOrder, + }); + + return folders.map(this.toDomain.bind(this)); } async findAllCursorWithParent( @@ -1202,19 +1260,6 @@ export class SequelizeFolderRepository implements FolderRepository { ); } - private folderStatusWhereCondition( - status: FolderStatus, - ): Pick { - switch (status) { - case FolderStatus.EXISTS: - return { deleted: false, removed: false }; - case FolderStatus.TRASHED: - return { deleted: true, removed: false }; - case FolderStatus.DELETED: - return { deleted: true, removed: true }; - } - } - private toDomain(model: FolderModel): Folder { const buildUser = (userData: UserModel | null) => userData ? User.build(userData) : null; @@ -1225,8 +1270,4 @@ export class SequelizeFolderRepository implements FolderRepository { user: buildUser(model.user || model.workspaceUser?.creator), }); } - - private toModel(domain: Folder): Partial { - return domain.toJSON(); - } } diff --git a/src/modules/trash/trash.controller.spec.ts b/src/modules/trash/trash.controller.spec.ts index 13e6b944e..13aad0cc5 100644 --- a/src/modules/trash/trash.controller.spec.ts +++ b/src/modules/trash/trash.controller.spec.ts @@ -415,9 +415,15 @@ describe('TrashController', () => { ).rejects.toThrow(BadRequestException); }); - it('When type is files, then it should return trashed files', async () => { + it('When trashed files are returned, then each file should include the parent folder name and not expose the full folder object', async () => { + const parentFolder = newFolder({ + attributes: { plainName: 'Parent folder' }, + }); const mockFiles = [ - newFile({ attributes: { status: FileStatus.TRASHED } }), + newFile({ + attributes: { status: FileStatus.TRASHED }, + folder: parentFolder, + }), ]; const expectedExpiresAt = new Date('2026-03-01'); jest.spyOn(fileUseCases, 'getTrashedFiles').mockResolvedValue(mockFiles); @@ -445,17 +451,53 @@ describe('TrashController', () => { }, ); expect(result).toEqual({ - result: mockFiles.map((file) => ({ - ...file.toJSON(), - expiresAt: expectedExpiresAt, - })), + result: mockFiles.map((file) => { + const { folder: _folder, ...fileJson } = file.toJSON(); + return { + ...fileJson, + parent: { + plainName: parentFolder.plainName, + status: parentFolder.status, + uuid: parentFolder.uuid, + }, + expiresAt: expectedExpiresAt, + }; + }), }); }); - it('When type is folders, then it should return trashed folders', async () => { - const mockFolders = [ - newFolder({ attributes: { deleted: true, removed: false } }), - ]; + it('When a trashed file has no parent folder, then the parent folder should be null', async () => { + const mockFile = newFile({ + attributes: { status: FileStatus.TRASHED }, + }); + mockFile.folder = null; + jest.spyOn(fileUseCases, 'getTrashedFiles').mockResolvedValue([mockFile]); + jest + .spyOn(trashUseCases, 'getTrashRetentionDays') + .mockResolvedValue(retentionDays); + jest + .spyOn(TrashExpirationUtils, 'calculateTrashExpirationDate') + .mockReturnValue(new Date('2026-03-01')); + + const result = await controller.getTrashedFilesPaginated( + user, + validPagination, + 'files', + ); + + expect((result.result[0] as any).parent.plainName).toBeUndefined(); + expect((result.result[0] as any).folder).toBeUndefined(); + }); + + it('When trashed folders are returned, then each folder should include the parent folder name and not expose the full parent object', async () => { + const parentFolder = newFolder({ + attributes: { plainName: 'Parent folder' }, + }); + const mockFolder = newFolder({ + attributes: { deleted: true, removed: false }, + }); + mockFolder.parent = parentFolder; + const mockFolders = [mockFolder]; const expectedExpiresAt = new Date('2026-03-01'); jest .spyOn(folderUseCases, 'getTrashedFolders') @@ -484,13 +526,45 @@ describe('TrashController', () => { }, ); expect(result).toEqual({ - result: mockFolders.map((folder) => ({ - ...folder.toJSON(), - expiresAt: expectedExpiresAt, - })), + result: mockFolders.map((folder) => { + const { parent: _parent, ...folderJson } = folder.toJSON(); + return { + ...folderJson, + parent: { + plainName: parentFolder.plainName, + status: parentFolder.status, + uuid: parentFolder.uuid, + }, + expiresAt: expectedExpiresAt, + }; + }), }); }); + it('When a trashed folder has no parent folder, then the parent folder name should be not returned', async () => { + const mockFolder = newFolder({ + attributes: { deleted: true, removed: false }, + }); + mockFolder.parent = null; + jest + .spyOn(folderUseCases, 'getTrashedFolders') + .mockResolvedValue([mockFolder]); + jest + .spyOn(trashUseCases, 'getTrashRetentionDays') + .mockResolvedValue(retentionDays); + jest + .spyOn(TrashExpirationUtils, 'calculateTrashExpirationDate') + .mockReturnValue(new Date('2026-03-01')); + + const result = await controller.getTrashedFilesPaginated( + user, + validPagination, + 'folders', + ); + + expect((result.result[0] as any).parent).toBeUndefined(); + }); + it('When type is files and a file has no updatedAt, then expiresAt should be null', async () => { const mockFile = newFile({ attributes: { status: FileStatus.TRASHED, updatedAt: null }, @@ -510,8 +584,19 @@ describe('TrashController', () => { expect( TrashExpirationUtils.calculateTrashExpirationDate, ).not.toHaveBeenCalled(); + const { folder: _folder, ...fileJson } = mockFile.toJSON(); expect(result).toEqual({ - result: [{ ...mockFile.toJSON(), expiresAt: null }], + result: [ + { + ...fileJson, + parent: { + plainName: mockFile.folder?.plainName ?? null, + status: mockFile.folder?.status ?? null, + uuid: mockFile.folder?.uuid ?? null, + }, + expiresAt: null, + }, + ], }); }); @@ -536,8 +621,19 @@ describe('TrashController', () => { expect( TrashExpirationUtils.calculateTrashExpirationDate, ).not.toHaveBeenCalled(); + const { parent: _parent, ...folderJson } = mockFolder.toJSON(); expect(result).toEqual({ - result: [{ ...mockFolder.toJSON(), expiresAt: null }], + result: [ + { + ...folderJson, + parent: { + plainName: mockFolder.parent?.plainName ?? null, + status: mockFolder.parent?.status ?? null, + uuid: mockFolder.parent?.uuid ?? null, + }, + expiresAt: null, + }, + ], }); }); diff --git a/src/modules/trash/trash.controller.ts b/src/modules/trash/trash.controller.ts index 09a2f10b7..f8e3e53a1 100644 --- a/src/modules/trash/trash.controller.ts +++ b/src/modules/trash/trash.controller.ts @@ -115,13 +115,21 @@ export class TrashController { }, ); - const result = files.map((file) => ({ - ...file.toJSON(), - expiresAt: - retentionDays && file.updatedAt - ? calculateTrashExpirationDate(retentionDays, file.updatedAt) - : null, - })); + const result = files.map((file) => { + const { folder: _folder, ...fileJson } = file.toJSON(); + return { + ...fileJson, + parent: { + plainName: file.folder?.plainName, + status: file.folder?.status, + uuid: file.folder?.uuid, + }, + expiresAt: + retentionDays && file.updatedAt + ? calculateTrashExpirationDate(retentionDays, file.updatedAt) + : null, + }; + }); return { result }; } else { @@ -135,13 +143,23 @@ export class TrashController { }, ); - const result = folders.map((folder) => ({ - ...folder.toJSON(), - expiresAt: - retentionDays && folder.updatedAt - ? calculateTrashExpirationDate(retentionDays, folder.updatedAt) - : null, - })); + const result = folders.map((folder) => { + const { parent: _parent, ...folderJson } = folder.toJSON(); + return { + ...folderJson, + ...(folder.parent !== null && { + parent: { + plainName: folder.parent?.plainName ?? null, + status: folder.parent?.status ?? null, + uuid: folder.parent?.uuid ?? null, + }, + }), + expiresAt: + retentionDays && folder.updatedAt + ? calculateTrashExpirationDate(retentionDays, folder.updatedAt) + : null, + }; + }); return { result }; } diff --git a/src/modules/workspaces/workspaces.usecase.spec.ts b/src/modules/workspaces/workspaces.usecase.spec.ts index 394c2a215..248b0fd4f 100644 --- a/src/modules/workspaces/workspaces.usecase.spec.ts +++ b/src/modules/workspaces/workspaces.usecase.spec.ts @@ -3898,8 +3898,15 @@ describe('WorkspacesUsecases', () => { ['plainName', 'ASC'] as any, ); + const { folder: _folder, ...fileJson } = trashedFiles[0].toJSON(); expect(result).toEqual({ - result: [{ ...trashedFiles[0].toJSON(), expiresAt: null }], + result: [ + { + ...fileJson, + parentFolderName: trashedFiles[0].folder?.plainName ?? null, + expiresAt: null, + }, + ], }); expect(fileUseCases.getTrashedFilesInWorkspace).toHaveBeenCalledWith( user.uuid, @@ -3930,8 +3937,15 @@ describe('WorkspacesUsecases', () => { ['plainName', 'ASC'] as any, ); + const { parent: _parent, ...folderJson } = trashedFolders[0].toJSON(); expect(result).toEqual({ - result: [{ ...trashedFolders[0].toJSON(), expiresAt: null }], + result: [ + { + ...folderJson, + parentFolderName: trashedFolders[0].parent?.plainName ?? null, + expiresAt: null, + }, + ], }); expect(folderUseCases.getTrashedFoldersInWorkspace).toHaveBeenCalledWith( user.uuid, @@ -3978,9 +3992,105 @@ describe('WorkspacesUsecases', () => { expectedCutoffDate, { limit, offset, sort: ['plainName', 'ASC'] }, ); + const { folder: _folder, ...fileJson } = trashedFiles[0].toJSON(); expect(result).toEqual({ - result: [{ ...trashedFiles[0].toJSON(), expiresAt: expectedExpiresAt }], + result: [ + { + ...fileJson, + parentFolderName: trashedFiles[0].folder?.plainName ?? null, + expiresAt: expectedExpiresAt, + }, + ], + }); + }); + + it('When a workspace trashed file has a parent folder, then its name should be returned as parentFolderName and the full folder object should not be exposed', async () => { + const parentFolder = newFolder({ + attributes: { plainName: 'My Parent Folder' }, }); + const trashedFile = newFile({ folder: parentFolder }); + jest + .spyOn(fileUseCases, 'getTrashedFilesInWorkspace') + .mockResolvedValue([trashedFile]); + jest + .spyOn(workspaceRepository, 'findWorkspaceResourcesOwner') + .mockResolvedValue(workspaceResourcesOwner); + jest + .spyOn(featureLimitsService, 'getUserLimitByLabel') + .mockResolvedValue(null); + + const result = await service.getWorkspaceUserTrashedItems( + user, + workspaceId, + WorkspaceItemType.File, + limit, + offset, + ); + + expect(result.result[0]).toEqual( + expect.objectContaining({ + parentFolderName: 'My Parent Folder', + }), + ); + expect(result.result[0]).not.toHaveProperty('folder'); + }); + + it('When a workspace trashed folder has a parent folder, then its name should be returned as parentFolderName and the full parent object should not be exposed', async () => { + const parentFolder = newFolder({ + attributes: { plainName: 'Outer Folder' }, + }); + const trashedFolder = newFolder({ attributes: { deleted: true } }); + trashedFolder.parent = parentFolder; + jest + .spyOn(folderUseCases, 'getTrashedFoldersInWorkspace') + .mockResolvedValue([trashedFolder]); + jest + .spyOn(workspaceRepository, 'findWorkspaceResourcesOwner') + .mockResolvedValue(workspaceResourcesOwner); + jest + .spyOn(featureLimitsService, 'getUserLimitByLabel') + .mockResolvedValue(null); + + const result = await service.getWorkspaceUserTrashedItems( + user, + workspaceId, + WorkspaceItemType.Folder, + limit, + offset, + ); + + expect(result.result[0]).toEqual( + expect.objectContaining({ + parentFolderName: 'Outer Folder', + }), + ); + expect(result.result[0]).not.toHaveProperty('parent'); + }); + + it('When a workspace trashed file has no parent folder, then parentFolderName should be null', async () => { + const trashedFile = newFile(); + trashedFile.folder = null; + jest + .spyOn(fileUseCases, 'getTrashedFilesInWorkspace') + .mockResolvedValue([trashedFile]); + jest + .spyOn(workspaceRepository, 'findWorkspaceResourcesOwner') + .mockResolvedValue(workspaceResourcesOwner); + jest + .spyOn(featureLimitsService, 'getUserLimitByLabel') + .mockResolvedValue(null); + + const result = await service.getWorkspaceUserTrashedItems( + user, + workspaceId, + WorkspaceItemType.File, + limit, + offset, + ); + + expect(result.result[0]).toEqual( + expect.objectContaining({ parentFolderName: null }), + ); }); }); diff --git a/src/modules/workspaces/workspaces.usecase.ts b/src/modules/workspaces/workspaces.usecase.ts index 9ea2863bb..9c49b249c 100644 --- a/src/modules/workspaces/workspaces.usecase.ts +++ b/src/modules/workspaces/workspaces.usecase.ts @@ -726,12 +726,24 @@ export class WorkspacesUsecases { ); return { - result: items.map((item) => ({ - ...item.toJSON(), - expiresAt: retentionDays - ? calculateTrashExpirationDate(retentionDays, item.updatedAt) - : null, - })), + result: items.map((item: File | Folder) => { + const parentFolderName = + itemType === WorkspaceItemType.File + ? ((item as File).folder?.plainName ?? null) + : ((item as Folder).parent?.plainName ?? null); + const { + folder: _folder, + parent: _parent, + ...rest + } = item.toJSON() as any; + return { + ...rest, + parentFolderName, + expiresAt: retentionDays + ? calculateTrashExpirationDate(retentionDays, item.updatedAt) + : null, + }; + }), }; }