From 2275b1780a8a6e34b2ae96f8a4f7fd6bbfb67af4 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Wed, 12 Mar 2025 13:53:45 +0100 Subject: [PATCH] feat(core): Update PATCH `/projects/:projectId/folders/:folderId` endpoint to allow moving folder (no-changelog) (#13574) --- .../update-folder.request.dto.test.ts | 13 + .../src/dto/folders/create-folder.dto.ts | 4 +- .../src/dto/folders/delete-folder.dto.ts | 4 +- .../src/dto/folders/update-folder.dto.ts | 3 +- .../api-types/src/schemas/folder.schema.ts | 2 +- .../cli/src/controllers/folder.controller.ts | 8 +- packages/cli/src/services/folder.service.ts | 25 +- .../folder/folder.controller.test.ts | 539 +++++++++++++++++- 8 files changed, 587 insertions(+), 11 deletions(-) diff --git a/packages/@n8n/api-types/src/dto/folders/__tests__/update-folder.request.dto.test.ts b/packages/@n8n/api-types/src/dto/folders/__tests__/update-folder.request.dto.test.ts index 28067c1a25..31a002e11f 100644 --- a/packages/@n8n/api-types/src/dto/folders/__tests__/update-folder.request.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/folders/__tests__/update-folder.request.dto.test.ts @@ -21,6 +21,12 @@ describe('UpdateFolderDto', () => { tagIds: [], }, }, + { + name: 'string parentFolderId', + request: { + parentFolderId: 'test', + }, + }, ])('should validate $name', ({ request }) => { const result = UpdateFolderDto.safeParse(request); expect(result.success).toBe(true); @@ -50,6 +56,13 @@ describe('UpdateFolderDto', () => { }, expectedErrorPath: ['tagIds'], }, + { + name: 'non string parentFolderId', + request: { + parentFolderId: 0, + }, + expectedErrorPath: ['parentFolderId'], + }, ])('should fail validation for $name', ({ request, expectedErrorPath }) => { const result = UpdateFolderDto.safeParse(request); diff --git a/packages/@n8n/api-types/src/dto/folders/create-folder.dto.ts b/packages/@n8n/api-types/src/dto/folders/create-folder.dto.ts index a673284f51..42e131bd93 100644 --- a/packages/@n8n/api-types/src/dto/folders/create-folder.dto.ts +++ b/packages/@n8n/api-types/src/dto/folders/create-folder.dto.ts @@ -1,8 +1,8 @@ import { Z } from 'zod-class'; -import { folderNameSchema, folderId } from '../../schemas/folder.schema'; +import { folderNameSchema, folderIdSchema } from '../../schemas/folder.schema'; export class CreateFolderDto extends Z.class({ name: folderNameSchema, - parentFolderId: folderId.optional(), + parentFolderId: folderIdSchema.optional(), }) {} diff --git a/packages/@n8n/api-types/src/dto/folders/delete-folder.dto.ts b/packages/@n8n/api-types/src/dto/folders/delete-folder.dto.ts index c03659ad18..9d0c938bd4 100644 --- a/packages/@n8n/api-types/src/dto/folders/delete-folder.dto.ts +++ b/packages/@n8n/api-types/src/dto/folders/delete-folder.dto.ts @@ -1,7 +1,7 @@ import { Z } from 'zod-class'; -import { folderId } from '../../schemas/folder.schema'; +import { folderIdSchema } from '../../schemas/folder.schema'; export class DeleteFolderDto extends Z.class({ - transferToFolderId: folderId.optional(), + transferToFolderId: folderIdSchema.optional(), }) {} diff --git a/packages/@n8n/api-types/src/dto/folders/update-folder.dto.ts b/packages/@n8n/api-types/src/dto/folders/update-folder.dto.ts index f002f6aa00..b87b828afb 100644 --- a/packages/@n8n/api-types/src/dto/folders/update-folder.dto.ts +++ b/packages/@n8n/api-types/src/dto/folders/update-folder.dto.ts @@ -1,8 +1,9 @@ import { z } from 'zod'; import { Z } from 'zod-class'; -import { folderNameSchema } from '../../schemas/folder.schema'; +import { folderNameSchema, folderIdSchema } from '../../schemas/folder.schema'; export class UpdateFolderDto extends Z.class({ name: folderNameSchema.optional(), tagIds: z.array(z.string().max(24)).optional(), + parentFolderId: folderIdSchema.optional(), }) {} diff --git a/packages/@n8n/api-types/src/schemas/folder.schema.ts b/packages/@n8n/api-types/src/schemas/folder.schema.ts index 4544a1b58c..25d6aa2bbd 100644 --- a/packages/@n8n/api-types/src/schemas/folder.schema.ts +++ b/packages/@n8n/api-types/src/schemas/folder.schema.ts @@ -1,4 +1,4 @@ import { z } from 'zod'; export const folderNameSchema = z.string().trim().min(1).max(128); -export const folderId = z.string().max(36); +export const folderIdSchema = z.string().max(36); diff --git a/packages/cli/src/controllers/folder.controller.ts b/packages/cli/src/controllers/folder.controller.ts index cb31433acb..4e5c9759b5 100644 --- a/packages/cli/src/controllers/folder.controller.ts +++ b/packages/cli/src/controllers/folder.controller.ts @@ -5,9 +5,11 @@ import { UpdateFolderDto, } from '@n8n/api-types'; import { Response } from 'express'; +import { UserError } from 'n8n-workflow'; import { Post, RestController, ProjectScope, Body, Get, Patch, Delete, Query } from '@/decorators'; import { FolderNotFoundError } from '@/errors/folder-not-found.error'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import type { ListQuery } from '@/requests'; @@ -69,6 +71,8 @@ export class ProjectController { } catch (e) { if (e instanceof FolderNotFoundError) { throw new NotFoundError(e.message); + } else if (e instanceof UserError) { + throw new BadRequestError(e.message); } throw new InternalServerError(undefined, e); } @@ -79,7 +83,7 @@ export class ProjectController { async deleteFolder( req: AuthenticatedRequest<{ projectId: string; folderId: string }>, _res: Response, - @Body payload: DeleteFolderDto, + @Query payload: DeleteFolderDto, ) { const { projectId, folderId } = req.params; @@ -88,6 +92,8 @@ export class ProjectController { } catch (e) { if (e instanceof FolderNotFoundError) { throw new NotFoundError(e.message); + } else if (e instanceof UserError) { + throw new BadRequestError(e.message); } throw new InternalServerError(undefined, e); } diff --git a/packages/cli/src/services/folder.service.ts b/packages/cli/src/services/folder.service.ts index 670e8f25bc..6f09e49931 100644 --- a/packages/cli/src/services/folder.service.ts +++ b/packages/cli/src/services/folder.service.ts @@ -2,6 +2,7 @@ import type { CreateFolderDto, DeleteFolderDto, UpdateFolderDto } from '@n8n/api import { Service } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { EntityManager } from '@n8n/typeorm'; +import { UserError, PROJECT_ROOT } from 'n8n-workflow'; import { Folder } from '@/databases/entities/folder'; import { FolderTagMappingRepository } from '@/databases/repositories/folder-tag-mapping.repository'; @@ -47,7 +48,11 @@ export class FolderService { return folder; } - async updateFolder(folderId: string, projectId: string, { name, tagIds }: UpdateFolderDto) { + async updateFolder( + folderId: string, + projectId: string, + { name, tagIds, parentFolderId }: UpdateFolderDto, + ) { await this.findFolderInProjectOrFail(folderId, projectId); if (name) { await this.folderRepository.update({ id: folderId }, { name }); @@ -55,6 +60,20 @@ export class FolderService { if (tagIds) { await this.folderTagMappingRepository.overwriteTags(folderId, tagIds); } + + if (parentFolderId) { + if (folderId === parentFolderId) { + throw new UserError('Cannot set a folder as its own parent'); + } + + if (parentFolderId !== PROJECT_ROOT) { + await this.findFolderInProjectOrFail(parentFolderId, projectId); + } + await this.folderRepository.update( + { id: folderId }, + { parentFolder: parentFolderId !== PROJECT_ROOT ? { id: parentFolderId } : null }, + ); + } } async findFolderInProjectOrFail(folderId: string, projectId: string, em?: EntityManager) { @@ -115,6 +134,10 @@ export class FolderService { return; } + if (folderId === transferToFolderId) { + throw new UserError('Cannot transfer folder contents to the folder being deleted'); + } + await this.findFolderInProjectOrFail(transferToFolderId, projectId); return await this.folderRepository.manager.transaction(async (tx) => { diff --git a/packages/cli/test/integration/folder/folder.controller.test.ts b/packages/cli/test/integration/folder/folder.controller.test.ts index 246335bd80..ecef673256 100644 --- a/packages/cli/test/integration/folder/folder.controller.test.ts +++ b/packages/cli/test/integration/folder/folder.controller.test.ts @@ -473,6 +473,190 @@ describe('PATCH /projects/:projectId/folders/:folderId', () => { expect(folderWithTags?.tags).toHaveLength(1); expect(folderWithTags?.tags[0].id).toBe(tag3.id); }); + + test('should update folder parent folder ID', async () => { + const project = await createTeamProject('test project', owner); + await createFolder(project, { name: 'Original Folder' }); + const targetFolder = await createFolder(project, { name: 'Target Folder' }); + + const folderToMove = await createFolder(project, { + name: 'Folder To Move', + }); + + const payload = { + parentFolderId: targetFolder.id, + }; + + await authOwnerAgent.patch(`/projects/${project.id}/folders/${folderToMove.id}`).send(payload); + + const updatedFolder = await folderRepository.findOne({ + where: { id: folderToMove.id }, + relations: ['parentFolder'], + }); + + expect(updatedFolder).toBeDefined(); + expect(updatedFolder?.parentFolder?.id).toBe(targetFolder.id); + }); + + test('should not update folder parent when target folder does not exist', async () => { + const project = await createTeamProject(undefined, owner); + const folderToMove = await createFolder(project, { name: 'Folder To Move' }); + + const payload = { + parentFolderId: 'non-existing-folder-id', + }; + + await authOwnerAgent + .patch(`/projects/${project.id}/folders/${folderToMove.id}`) + .send(payload) + .expect(404); + + const updatedFolder = await folderRepository.findOne({ + where: { id: folderToMove.id }, + relations: ['parentFolder'], + }); + + expect(updatedFolder).toBeDefined(); + expect(updatedFolder?.parentFolder).toBeNull(); + }); + + test('should not update folder parent when target folder is in another project', async () => { + const project1 = await createTeamProject('Project 1', owner); + const project2 = await createTeamProject('Project 2', owner); + + const folderToMove = await createFolder(project1, { name: 'Folder To Move' }); + const targetFolder = await createFolder(project2, { name: 'Target Folder' }); + + const payload = { + parentFolderId: targetFolder.id, + }; + + await authOwnerAgent + .patch(`/projects/${project1.id}/folders/${folderToMove.id}`) + .send(payload) + .expect(404); + + const updatedFolder = await folderRepository.findOne({ + where: { id: folderToMove.id }, + relations: ['parentFolder'], + }); + + expect(updatedFolder).toBeDefined(); + expect(updatedFolder?.parentFolder).toBeNull(); + }); + + test('should allow moving a folder to root level by setting parentFolderId to "0"', async () => { + const project = await createTeamProject(undefined, owner); + const parentFolder = await createFolder(project, { name: 'Parent Folder' }); + + const folderToMove = await createFolder(project, { + name: 'Folder To Move', + parentFolder, + }); + + // Verify initial state + let folder = await folderRepository.findOne({ + where: { id: folderToMove.id }, + relations: ['parentFolder'], + }); + expect(folder?.parentFolder?.id).toBe(parentFolder.id); + + const payload = { + parentFolderId: '0', + }; + + await authOwnerAgent + .patch(`/projects/${project.id}/folders/${folderToMove.id}`) + .send(payload) + .expect(200); + + const updatedFolder = await folderRepository.findOne({ + where: { id: folderToMove.id }, + relations: ['parentFolder'], + }); + + expect(updatedFolder).toBeDefined(); + expect(updatedFolder?.parentFolder).toBeNull(); + }); + + test('should not update folder parent if user has project:viewer role in team project', async () => { + const project = await createTeamProject(undefined, owner); + await createFolder(project, { name: 'Parent Folder' }); + const targetFolder = await createFolder(project, { name: 'Target Folder' }); + + const folderToMove = await createFolder(project, { + name: 'Folder To Move', + }); + + await linkUserToProject(member, project, 'project:viewer'); + + const payload = { + parentFolderId: targetFolder.id, + }; + + await authMemberAgent + .patch(`/projects/${project.id}/folders/${folderToMove.id}`) + .send(payload) + .expect(403); + + const updatedFolder = await folderRepository.findOne({ + where: { id: folderToMove.id }, + relations: ['parentFolder'], + }); + + expect(updatedFolder).toBeDefined(); + expect(updatedFolder?.parentFolder).toBeNull(); + }); + + test('should update folder parent folder if user has project:editor role in team project', async () => { + const project = await createTeamProject(undefined, owner); + const targetFolder = await createFolder(project, { name: 'Target Folder' }); + + const folderToMove = await createFolder(project, { + name: 'Folder To Move', + }); + + await linkUserToProject(member, project, 'project:editor'); + + const payload = { + parentFolderId: targetFolder.id, + }; + + await authMemberAgent + .patch(`/projects/${project.id}/folders/${folderToMove.id}`) + .send(payload) + .expect(200); + + const updatedFolder = await folderRepository.findOne({ + where: { id: folderToMove.id }, + relations: ['parentFolder'], + }); + + expect(updatedFolder).toBeDefined(); + expect(updatedFolder?.parentFolder?.id).toBe(targetFolder.id); + }); + + test('should not allow setting a folder as its own parent', async () => { + const project = await createTeamProject(undefined, owner); + const folder = await createFolder(project, { name: 'Test Folder' }); + + const payload = { + parentFolderId: folder.id, + }; + + await authOwnerAgent + .patch(`/projects/${project.id}/folders/${folder.id}`) + .send(payload) + .expect(400); + + const folderInDb = await folderRepository.findOne({ + where: { id: folder.id }, + relations: ['parentFolder'], + }); + + expect(folderInDb).toBeDefined(); + expect(folderInDb?.parentFolder).toBeNull(); + }); }); describe('DELETE /projects/:projectId/folders/:folderId', () => { @@ -607,7 +791,7 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => { await authOwnerAgent .delete(`/projects/${project.id}/folders/${sourceFolder.id}`) - .send(payload) + .query(payload) .expect(200); const sourceFolderInDb = await folderRepository.findOne({ @@ -650,7 +834,7 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => { await authOwnerAgent .delete(`/projects/${project.id}/folders/${folder.id}`) - .send(payload) + .query(payload) .expect(404); const folderInDb = await folderRepository.findOneBy({ id: folder.id }); @@ -669,12 +853,361 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => { await authOwnerAgent .delete(`/projects/${project1.id}/folders/${sourceFolder.id}`) - .send(payload) + .query(payload) .expect(404); const folderInDb = await folderRepository.findOneBy({ id: sourceFolder.id }); expect(folderInDb).toBeDefined(); }); + + test('should not allow transferring contents to the same folder being deleted', async () => { + const project = await createTeamProject('test', owner); + const folder = await createFolder(project, { name: 'Folder To Delete' }); + + await createWorkflow({ parentFolder: folder }, owner); + + const payload = { + transferToFolderId: folder.id, // Try to transfer contents to the same folder + }; + + const response = await authOwnerAgent + .delete(`/projects/${project.id}/folders/${folder.id}`) + .query(payload) + .expect(400); + + expect(response.body.message).toContain( + 'Cannot transfer folder contents to the folder being deleted', + ); + + // Verify the folder still exists + const folderInDb = await folderRepository.findOneBy({ id: folder.id }); + expect(folderInDb).toBeDefined(); + }); +}); + +describe('GET /projects/:projectId/folders', () => { + test('should not list folders when project does not exist', async () => { + await authOwnerAgent.get('/projects/non-existing-id/folders').expect(403); + }); + + test('should not list folders if user has no access to project', async () => { + const project = await createTeamProject('test project', owner); + + await authMemberAgent.get(`/projects/${project.id}/folders`).expect(403); + }); + + test("should not allow listing folders from another user's personal project", async () => { + await authMemberAgent.get(`/projects/${ownerProject.id}/folders`).expect(403); + }); + + test('should list folders if user has project:viewer role in team project', async () => { + const project = await createTeamProject('test project', owner); + await linkUserToProject(member, project, 'project:viewer'); + await createFolder(project, { name: 'Test Folder' }); + + const response = await authMemberAgent.get(`/projects/${project.id}/folders`).expect(200); + + expect(response.body.count).toBe(1); + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0].name).toBe('Test Folder'); + }); + + test('should list folders from personal project', async () => { + await createFolder(ownerProject, { name: 'Personal Folder 1' }); + await createFolder(ownerProject, { name: 'Personal Folder 2' }); + + const response = await authOwnerAgent.get(`/projects/${ownerProject.id}/folders`).expect(200); + + expect(response.body.count).toBe(2); + expect(response.body.data).toHaveLength(2); + expect(response.body.data.map((f: any) => f.name).sort()).toEqual( + ['Personal Folder 1', 'Personal Folder 2'].sort(), + ); + }); + + test('should filter folders by name', async () => { + await createFolder(ownerProject, { name: 'Test Folder' }); + await createFolder(ownerProject, { name: 'Another Folder' }); + await createFolder(ownerProject, { name: 'Test Something Else' }); + + const response = await authOwnerAgent + .get(`/projects/${ownerProject.id}/folders`) + .query({ filter: '{ "name": "test" }' }) + .expect(200); + + expect(response.body.count).toBe(2); + expect(response.body.data).toHaveLength(2); + expect(response.body.data.map((f: any) => f.name).sort()).toEqual( + ['Test Folder', 'Test Something Else'].sort(), + ); + }); + + test('should filter folders by parent folder ID', async () => { + const parentFolder = await createFolder(ownerProject, { name: 'Parent' }); + await createFolder(ownerProject, { name: 'Child 1', parentFolder }); + await createFolder(ownerProject, { name: 'Child 2', parentFolder }); + await createFolder(ownerProject, { name: 'Standalone' }); + + const response = await authOwnerAgent + .get(`/projects/${ownerProject.id}/folders`) + .query({ filter: `{ "parentFolderId": "${parentFolder.id}" }` }) + .expect(200); + + expect(response.body.count).toBe(2); + expect(response.body.data).toHaveLength(2); + expect(response.body.data.map((f: any) => f.name).sort()).toEqual( + ['Child 1', 'Child 2'].sort(), + ); + }); + + test('should filter root-level folders when parentFolderId=0', async () => { + const parentFolder = await createFolder(ownerProject, { name: 'Parent' }); + await createFolder(ownerProject, { name: 'Child 1', parentFolder }); + await createFolder(ownerProject, { name: 'Standalone 1' }); + await createFolder(ownerProject, { name: 'Standalone 2' }); + + const response = await authOwnerAgent + .get(`/projects/${ownerProject.id}/folders`) + .query({ filter: '{ "parentFolderId": "0" }' }) + .expect(200); + + expect(response.body.count).toBe(3); + expect(response.body.data).toHaveLength(3); + expect(response.body.data.map((f: any) => f.name).sort()).toEqual( + ['Parent', 'Standalone 1', 'Standalone 2'].sort(), + ); + }); + + test('should filter folders by tag', async () => { + const tag1 = await createTag({ name: 'important' }); + const tag2 = await createTag({ name: 'archived' }); + + await createFolder(ownerProject, { name: 'Folder 1', tags: [tag1] }); + await createFolder(ownerProject, { name: 'Folder 2', tags: [tag2] }); + await createFolder(ownerProject, { name: 'Folder 3', tags: [tag1, tag2] }); + + const response = await authOwnerAgent.get( + `/projects/${ownerProject.id}/folders?filter={ "tags": ["important"]}`, + ); + + expect(response.body.count).toBe(2); + expect(response.body.data).toHaveLength(2); + expect(response.body.data.map((f: any) => f.name).sort()).toEqual( + ['Folder 1', 'Folder 3'].sort(), + ); + }); + + test('should filter folders by multiple tags (AND operator)', async () => { + const tag1 = await createTag({ name: 'important' }); + const tag2 = await createTag({ name: 'active' }); + + await createFolder(ownerProject, { name: 'Folder 1', tags: [tag1] }); + await createFolder(ownerProject, { name: 'Folder 2', tags: [tag2] }); + await createFolder(ownerProject, { name: 'Folder 3', tags: [tag1, tag2] }); + + const response = await authOwnerAgent + .get(`/projects/${ownerProject.id}/folders?filter={ "tags": ["important", "active"]}`) + .expect(200); + + expect(response.body.count).toBe(1); + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0].name).toBe('Folder 3'); + }); + + test('should apply pagination with take parameter', async () => { + // Create folders with consistent timestamps + for (let i = 1; i <= 5; i++) { + await createFolder(ownerProject, { + name: `Folder ${i}`, + updatedAt: DateTime.now() + .minus({ minutes: 6 - i }) + .toJSDate(), + }); + } + + const response = await authOwnerAgent + .get(`/projects/${ownerProject.id}/folders`) + .query({ take: 3 }) + .expect(200); + + expect(response.body.count).toBe(5); // Total count should be 5 + expect(response.body.data).toHaveLength(3); // But only 3 returned + expect(response.body.data.map((f: any) => f.name)).toEqual([ + 'Folder 5', + 'Folder 4', + 'Folder 3', + ]); + }); + + test('should apply pagination with skip parameter', async () => { + // Create folders with consistent timestamps + for (let i = 1; i <= 5; i++) { + await createFolder(ownerProject, { + name: `Folder ${i}`, + updatedAt: DateTime.now() + .minus({ minutes: 6 - i }) + .toJSDate(), + }); + } + + const response = await authOwnerAgent + .get(`/projects/${ownerProject.id}/folders`) + .query({ skip: 2 }) + .expect(200); + + expect(response.body.count).toBe(5); + expect(response.body.data).toHaveLength(3); + expect(response.body.data.map((f: any) => f.name)).toEqual([ + 'Folder 3', + 'Folder 2', + 'Folder 1', + ]); + }); + + test('should apply combined skip and take parameters', async () => { + // Create folders with consistent timestamps + for (let i = 1; i <= 5; i++) { + await createFolder(ownerProject, { + name: `Folder ${i}`, + updatedAt: DateTime.now() + .minus({ minutes: 6 - i }) + .toJSDate(), + }); + } + + const response = await authOwnerAgent + .get(`/projects/${ownerProject.id}/folders`) + .query({ skip: 1, take: 2 }) + .expect(200); + + expect(response.body.count).toBe(5); + expect(response.body.data).toHaveLength(2); + expect(response.body.data.map((f: any) => f.name)).toEqual(['Folder 4', 'Folder 3']); + }); + + test('should sort folders by name ascending', async () => { + await createFolder(ownerProject, { name: 'Z Folder' }); + await createFolder(ownerProject, { name: 'A Folder' }); + await createFolder(ownerProject, { name: 'M Folder' }); + + const response = await authOwnerAgent + .get(`/projects/${ownerProject.id}/folders`) + .query({ sortBy: 'name:asc' }) + .expect(200); + + expect(response.body.data.map((f: any) => f.name)).toEqual([ + 'A Folder', + 'M Folder', + 'Z Folder', + ]); + }); + + test('should sort folders by name descending', async () => { + await createFolder(ownerProject, { name: 'Z Folder' }); + await createFolder(ownerProject, { name: 'A Folder' }); + await createFolder(ownerProject, { name: 'M Folder' }); + + const response = await authOwnerAgent + .get(`/projects/${ownerProject.id}/folders`) + .query({ sortBy: 'name:desc' }) + .expect(200); + + expect(response.body.data.map((f: any) => f.name)).toEqual([ + 'Z Folder', + 'M Folder', + 'A Folder', + ]); + }); + + test('should sort folders by updatedAt', async () => { + await createFolder(ownerProject, { + name: 'Older Folder', + updatedAt: DateTime.now().minus({ days: 2 }).toJSDate(), + }); + await createFolder(ownerProject, { + name: 'Newest Folder', + updatedAt: DateTime.now().toJSDate(), + }); + await createFolder(ownerProject, { + name: 'Middle Folder', + updatedAt: DateTime.now().minus({ days: 1 }).toJSDate(), + }); + + const response = await authOwnerAgent + .get(`/projects/${ownerProject.id}/folders`) + .query({ sortBy: 'updatedAt:desc' }) + .expect(200); + + expect(response.body.data.map((f: any) => f.name)).toEqual([ + 'Newest Folder', + 'Middle Folder', + 'Older Folder', + ]); + }); + + test('should select specific fields when requested', async () => { + await createFolder(ownerProject, { name: 'Test Folder' }); + + const response = await authOwnerAgent + .get(`/projects/${ownerProject.id}/folders?select=["id","name"]`) + .expect(200); + + expect(response.body.data[0]).toEqual({ + id: expect.any(String), + name: 'Test Folder', + }); + + // Other fields should not be present + expect(response.body.data[0].createdAt).toBeUndefined(); + expect(response.body.data[0].updatedAt).toBeUndefined(); + expect(response.body.data[0].parentFolder).toBeUndefined(); + }); + + test('should combine multiple query parameters correctly', async () => { + const tag = await createTag({ name: 'important' }); + const parentFolder = await createFolder(ownerProject, { name: 'Parent' }); + + await createFolder(ownerProject, { + name: 'Test Child 1', + parentFolder, + tags: [tag], + }); + + await createFolder(ownerProject, { + name: 'Another Child', + parentFolder, + }); + + await createFolder(ownerProject, { + name: 'Test Standalone', + tags: [tag], + }); + + const response = await authOwnerAgent + .get( + `/projects/${ownerProject.id}/folders?filter={"name": "test", "parentFolderId": "${parentFolder.id}", "tags": ["important"]}&sortBy=name:asc`, + ) + .expect(200); + + expect(response.body.count).toBe(1); + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0].name).toBe('Test Child 1'); + }); + + test('should filter by projectId automatically based on URL', async () => { + // Create folders in both owner and member projects + await createFolder(ownerProject, { name: 'Owner Folder 1' }); + await createFolder(ownerProject, { name: 'Owner Folder 2' }); + await createFolder(memberProject, { name: 'Member Folder' }); + + const response = await authOwnerAgent.get(`/projects/${ownerProject.id}/folders`).expect(200); + + expect(response.body.count).toBe(2); + expect(response.body.data).toHaveLength(2); + expect(response.body.data.map((f: any) => f.name).sort()).toEqual( + ['Owner Folder 1', 'Owner Folder 2'].sort(), + ); + }); }); describe('GET /projects/:projectId/folders', () => {