From 0d7894f06a5ef49a589e1c0980aec0b1ef6bbefb Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Wed, 12 Mar 2025 13:04:55 +0100 Subject: [PATCH] feat(core): Add endpoint GET `/projects/:projectId/folders` (no-changelog) (#13519) --- packages/@n8n/api-types/package.json | 4 +- .../__tests__/list-folder-query.dto.test.ts | 210 +++++++++++ .../src/dto/folders/list-folder-query.dto.ts | 130 +++++++ packages/@n8n/api-types/src/dto/index.ts | 1 + packages/@n8n/permissions/src/constants.ee.ts | 2 +- .../cli/src/controllers/folder.controller.ts | 27 +- .../__tests__/folder.repository.test.ts | 9 + .../repositories/folder.repository.ts | 4 +- .../cli/src/permissions.ee/project-roles.ts | 4 + packages/cli/src/services/folder.service.ts | 6 + .../folder/folder.controller.test.ts | 334 +++++++++++++++++- pnpm-lock.yaml | 6 +- 12 files changed, 727 insertions(+), 10 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/folders/__tests__/list-folder-query.dto.test.ts create mode 100644 packages/@n8n/api-types/src/dto/folders/list-folder-query.dto.ts diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index 661512917d..33d709dad2 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -22,10 +22,10 @@ ], "devDependencies": { "@n8n/typescript-config": "workspace:*", - "@n8n/config": "workspace:*", - "n8n-workflow": "workspace:*" + "@n8n/config": "workspace:*" }, "dependencies": { + "n8n-workflow": "workspace:*", "xss": "catalog:", "zod": "catalog:", "zod-class": "0.0.16" diff --git a/packages/@n8n/api-types/src/dto/folders/__tests__/list-folder-query.dto.test.ts b/packages/@n8n/api-types/src/dto/folders/__tests__/list-folder-query.dto.test.ts new file mode 100644 index 0000000000..fd08f68c15 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/folders/__tests__/list-folder-query.dto.test.ts @@ -0,0 +1,210 @@ +import { ListFolderQueryDto } from '../list-folder-query.dto'; + +const DEFAULT_PAGINATION = { skip: 0, take: 10 }; + +describe('ListFolderQueryDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'empty object (no filters)', + request: {}, + parsedResult: DEFAULT_PAGINATION, + }, + { + name: 'valid filter', + request: { + filter: '{"name":"test"}', + }, + parsedResult: { + ...DEFAULT_PAGINATION, + filter: { name: 'test' }, + }, + }, + { + name: 'filter with parentFolderId', + request: { + filter: '{"parentFolderId":"abc123"}', + }, + parsedResult: { + ...DEFAULT_PAGINATION, + filter: { parentFolderId: 'abc123' }, + }, + }, + { + name: 'filter with name and parentFolderId', + request: { + filter: '{"name":"test","parentFolderId":"abc123"}', + }, + parsedResult: { + ...DEFAULT_PAGINATION, + filter: { parentFolderId: 'abc123', name: 'test' }, + }, + }, + { + name: 'filter with tags array', + request: { + filter: '{"tags":["important","archived"]}', + }, + parsedResult: { + ...DEFAULT_PAGINATION, + filter: { tags: ['important', 'archived'] }, + }, + }, + { + name: 'filter with empty tags array', + request: { + filter: '{"tags":[]}', + }, + parsedResult: { + ...DEFAULT_PAGINATION, + filter: { tags: [] }, + }, + }, + { + name: 'filter with all properties', + request: { + filter: '{"name":"test","parentFolderId":"abc123","tags":["important"]}', + }, + parsedResult: { + ...DEFAULT_PAGINATION, + filter: { tags: ['important'], name: 'test', parentFolderId: 'abc123' }, + }, + }, + { + name: 'valid select', + request: { + select: '["id","name"]', + }, + parsedResult: { + ...DEFAULT_PAGINATION, + select: { id: true, name: true }, + }, + }, + { + name: 'valid sortBy', + request: { + sortBy: 'name:asc', + }, + parsedResult: { + ...DEFAULT_PAGINATION, + sortBy: 'name:asc', + }, + }, + { + name: 'valid skip and take', + request: { + skip: '0', + take: '20', + }, + parsedResult: { + skip: 0, + take: 20, + }, + }, + { + name: 'full query parameters', + request: { + filter: '{"name":"test","tags":["important"]}', + select: '["id","name","createdAt","tags"]', + skip: '0', + take: '10', + sortBy: 'createdAt:desc', + }, + parsedResult: { + filter: { name: 'test', tags: ['important'] }, + select: { id: true, name: true, createdAt: true, tags: true }, + skip: 0, + take: 10, + sortBy: 'createdAt:desc', + }, + }, + ])('should validate $name', ({ request, parsedResult }) => { + const result = ListFolderQueryDto.safeParse(request); + expect(result.success).toBe(true); + if (parsedResult) { + expect(result.data).toMatchObject(parsedResult); + } + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'invalid filter format', + request: { + filter: 'not-json', + }, + expectedErrorPath: ['filter'], + }, + { + name: 'filter with invalid field', + request: { + filter: '{"unknownField":"test"}', + }, + expectedErrorPath: ['filter'], + }, + { + name: 'filter with tags not as array', + request: { + filter: '{"tags":"important"}', + }, + expectedErrorPath: ['filter'], + }, + { + name: 'filter with tags array containing non-string values', + request: { + filter: '{"tags":["important", 123]}', + }, + expectedErrorPath: ['filter'], + }, + { + name: 'invalid select format', + request: { + select: 'id,name', // Not an array + }, + expectedErrorPath: ['select'], + }, + { + name: 'select with invalid field', + request: { + select: '["id","invalidField"]', + }, + expectedErrorPath: ['select'], + }, + { + name: 'invalid skip format', + request: { + skip: 'not-a-number', + take: '10', + }, + expectedErrorPath: ['skip'], + }, + { + name: 'invalid take format', + request: { + skip: '0', + take: 'not-a-number', + }, + expectedErrorPath: ['take'], + }, + { + name: 'invalid sortBy value', + request: { + sortBy: 'invalid-value', + }, + expectedErrorPath: ['sortBy'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = ListFolderQueryDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath && !result.success) { + if (Array.isArray(expectedErrorPath)) { + const errorPaths = result.error.issues[0].path; + expect(errorPaths).toContain(expectedErrorPath[0]); + } + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/folders/list-folder-query.dto.ts b/packages/@n8n/api-types/src/dto/folders/list-folder-query.dto.ts new file mode 100644 index 0000000000..f513272de1 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/folders/list-folder-query.dto.ts @@ -0,0 +1,130 @@ +import { jsonParse } from 'n8n-workflow'; +import { z } from 'zod'; +import { Z } from 'zod-class'; + +const VALID_SELECT_FIELDS = [ + 'id', + 'name', + 'createdAt', + 'updatedAt', + 'project', + 'tags', + 'parentFolder', + 'workflowCount', +] as const; + +const VALID_SORT_OPTIONS = [ + 'name:asc', + 'name:desc', + 'createdAt:asc', + 'createdAt:desc', + 'updatedAt:asc', + 'updatedAt:desc', +] as const; + +// Filter schema - only allow specific properties +export const filterSchema = z + .object({ + parentFolderId: z.string().optional(), + name: z.string().optional(), + tags: z.array(z.string()).optional(), + }) + .strict(); + +// --------------------- +// Parameter Validators +// --------------------- + +// Filter parameter validation +const filterValidator = z + .string() + .optional() + .transform((val, ctx) => { + if (!val) return undefined; + try { + const parsed: unknown = jsonParse(val); + try { + return filterSchema.parse(parsed); + } catch (e) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid filter fields', + path: ['filter'], + }); + return z.NEVER; + } + } catch (e) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid filter format', + path: ['filter'], + }); + return z.NEVER; + } + }); + +// Skip parameter validation +const skipValidator = z + .string() + .optional() + .transform((val) => (val ? parseInt(val, 10) : 0)) + .refine((val) => !isNaN(val), { + message: 'Skip must be a valid number', + }); + +// Take parameter validation +const takeValidator = z + .string() + .optional() + .transform((val) => (val ? parseInt(val, 10) : 10)) + .refine((val) => !isNaN(val), { + message: 'Take must be a valid number', + }); + +// Select parameter validation +const selectFieldsValidator = z.array(z.enum(VALID_SELECT_FIELDS)); +const selectValidator = z + .string() + .optional() + .transform((val, ctx) => { + if (!val) return undefined; + try { + const parsed: unknown = JSON.parse(val); + try { + const selectFields = selectFieldsValidator.parse(parsed); + if (selectFields.length === 0) return undefined; + type SelectField = (typeof VALID_SELECT_FIELDS)[number]; + return selectFields.reduce>( + (acc, field) => ({ ...acc, [field]: true }), + {} as Record, + ); + } catch (e) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid select fields. Valid fields are: ${VALID_SELECT_FIELDS.join(', ')}`, + path: ['select'], + }); + return z.NEVER; + } + } catch (e) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid select format', + path: ['select'], + }); + return z.NEVER; + } + }); + +// SortBy parameter validation +const sortByValidator = z + .enum(VALID_SORT_OPTIONS, { message: `sortBy must be one of: ${VALID_SORT_OPTIONS.join(', ')}` }) + .optional(); + +export class ListFolderQueryDto extends Z.class({ + filter: filterValidator, + skip: skipValidator, + take: takeValidator, + select: selectValidator, + sortBy: sortByValidator, +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index f2dd481ee2..a71042048e 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -58,3 +58,4 @@ export { CreateApiKeyRequestDto } from './api-keys/create-api-key-request.dto'; export { CreateFolderDto } from './folders/create-folder.dto'; export { UpdateFolderDto } from './folders/update-folder.dto'; export { DeleteFolderDto } from './folders/delete-folder.dto'; +export { ListFolderQueryDto } from './folders/list-folder-query.dto'; diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index f1fbfbf0f4..70ad68c52c 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -22,5 +22,5 @@ export const RESOURCES = { variable: [...DEFAULT_OPERATIONS] as const, workersView: ['manage'] as const, workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const, - folder: ['create', 'read', 'update', 'delete'] as const, + folder: [...DEFAULT_OPERATIONS] as const, } as const; diff --git a/packages/cli/src/controllers/folder.controller.ts b/packages/cli/src/controllers/folder.controller.ts index 774369768d..cb31433acb 100644 --- a/packages/cli/src/controllers/folder.controller.ts +++ b/packages/cli/src/controllers/folder.controller.ts @@ -1,10 +1,16 @@ -import { CreateFolderDto, DeleteFolderDto, UpdateFolderDto } from '@n8n/api-types'; +import { + CreateFolderDto, + DeleteFolderDto, + ListFolderQueryDto, + UpdateFolderDto, +} from '@n8n/api-types'; import { Response } from 'express'; -import { Post, RestController, ProjectScope, Body, Get, Patch, Delete } from '@/decorators'; +import { Post, RestController, ProjectScope, Body, Get, Patch, Delete, Query } from '@/decorators'; import { FolderNotFoundError } from '@/errors/folder-not-found.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import type { ListQuery } from '@/requests'; import { AuthenticatedRequest } from '@/requests'; import { FolderService } from '@/services/folder.service'; @@ -86,4 +92,21 @@ export class ProjectController { throw new InternalServerError(undefined, e); } } + + @Get('/') + @ProjectScope('folder:list') + async listFolders( + req: AuthenticatedRequest<{ projectId: string }>, + res: Response, + @Query payload: ListFolderQueryDto, + ) { + const { projectId } = req.params; + + const [data, count] = await this.folderService.getManyAndCount( + projectId, + payload as ListQuery.Options, + ); + + res.json({ count, data }); + } } diff --git a/packages/cli/src/databases/repositories/__tests__/folder.repository.test.ts b/packages/cli/src/databases/repositories/__tests__/folder.repository.test.ts index 033c74b742..0b200d4e39 100644 --- a/packages/cli/src/databases/repositories/__tests__/folder.repository.test.ts +++ b/packages/cli/src/databases/repositories/__tests__/folder.repository.test.ts @@ -556,6 +556,15 @@ describe('FolderRepository', () => { ]); }); + it('should sort by name:desc when select does not include the name', async () => { + const [folders] = await folderRepository.getManyAndCount({ + sortBy: 'name:desc', + select: { id: true }, + }); + + expect(folders.length).toBe(4); + }); + it('should sort by createdAt:asc', async () => { const [folders] = await folderRepository.getManyAndCount({ sortBy: 'createdAt:asc', diff --git a/packages/cli/src/databases/repositories/folder.repository.ts b/packages/cli/src/databases/repositories/folder.repository.ts index d0e3cb0c63..6eb5d6e208 100644 --- a/packages/cli/src/databases/repositories/folder.repository.ts +++ b/packages/cli/src/databases/repositories/folder.repository.ts @@ -213,7 +213,9 @@ export class FolderRepository extends Repository { direction: 'DESC' | 'ASC', ): void { if (field === 'name') { - query.orderBy('LOWER(folder.name)', direction); + query + .addSelect('LOWER(folder.name)', 'folder_name_lower') + .orderBy('folder_name_lower', direction); } else if (['createdAt', 'updatedAt'].includes(field)) { query.orderBy(`folder.${field}`, direction); } diff --git a/packages/cli/src/permissions.ee/project-roles.ts b/packages/cli/src/permissions.ee/project-roles.ts index f642d4b252..9a4547ea43 100644 --- a/packages/cli/src/permissions.ee/project-roles.ts +++ b/packages/cli/src/permissions.ee/project-roles.ts @@ -29,6 +29,7 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [ 'folder:read', 'folder:update', 'folder:delete', + 'folder:list', ]; export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [ @@ -53,6 +54,7 @@ export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [ 'folder:read', 'folder:update', 'folder:delete', + 'folder:list', ]; export const PROJECT_EDITOR_SCOPES: Scope[] = [ @@ -73,6 +75,7 @@ export const PROJECT_EDITOR_SCOPES: Scope[] = [ 'folder:read', 'folder:update', 'folder:delete', + 'folder:list', ]; export const PROJECT_VIEWER_SCOPES: Scope[] = [ @@ -83,4 +86,5 @@ export const PROJECT_VIEWER_SCOPES: Scope[] = [ 'workflow:list', 'workflow:read', 'folder:read', + 'folder:list', ]; diff --git a/packages/cli/src/services/folder.service.ts b/packages/cli/src/services/folder.service.ts index 2a7512f9bc..670e8f25bc 100644 --- a/packages/cli/src/services/folder.service.ts +++ b/packages/cli/src/services/folder.service.ts @@ -8,6 +8,7 @@ import { FolderTagMappingRepository } from '@/databases/repositories/folder-tag- import { FolderRepository } from '@/databases/repositories/folder.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { FolderNotFoundError } from '@/errors/folder-not-found.error'; +import type { ListQuery } from '@/requests'; export interface SimpleFolderNode { id: string; @@ -156,4 +157,9 @@ export class FolderService { return rootNode ? [rootNode] : []; } + + async getManyAndCount(projectId: string, options: ListQuery.Options) { + options.filter = { ...options.filter, projectId }; + return await this.folderRepository.getManyAndCount(options); + } } diff --git a/packages/cli/test/integration/folder/folder.controller.test.ts b/packages/cli/test/integration/folder/folder.controller.test.ts index 9935f4ec2d..246335bd80 100644 --- a/packages/cli/test/integration/folder/folder.controller.test.ts +++ b/packages/cli/test/integration/folder/folder.controller.test.ts @@ -1,5 +1,7 @@ import { Container } from '@n8n/di'; +import { DateTime } from 'luxon'; +import type { Project } from '@/databases/entities/project'; import type { User } from '@/databases/entities/user'; import { FolderRepository } from '@/databases/repositories/folder.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; @@ -8,7 +10,7 @@ import { createFolder } from '@test-integration/db/folders'; import { createTag } from '@test-integration/db/tags'; import { createWorkflow } from '@test-integration/db/workflows'; -import { createTeamProject, linkUserToProject } from '../shared/db/projects'; +import { createTeamProject, getPersonalProject, linkUserToProject } from '../shared/db/projects'; import { createOwner, createMember } from '../shared/db/users'; import * as testDb from '../shared/test-db'; import type { SuperAgentTest } from '../shared/types'; @@ -18,6 +20,8 @@ let owner: User; let member: User; let authOwnerAgent: SuperAgentTest; let authMemberAgent: SuperAgentTest; +let ownerProject: Project; +let memberProject: Project; const testServer = utils.setupTestServer({ endpointGroups: ['folder'], @@ -38,6 +42,9 @@ beforeEach(async () => { member = await createMember(); authOwnerAgent = testServer.authAgentFor(owner); authMemberAgent = testServer.authAgentFor(member); + + ownerProject = await getPersonalProject(owner); + memberProject = await getPersonalProject(member); }); describe('POST /projects/:projectId/folders', () => { @@ -669,3 +676,328 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => { 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(), + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68524d8dfb..21d44f728b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -293,6 +293,9 @@ importers: packages/@n8n/api-types: dependencies: + n8n-workflow: + specifier: workspace:* + version: link:../../workflow xss: specifier: 'catalog:' version: 1.0.15 @@ -309,9 +312,6 @@ importers: '@n8n/typescript-config': specifier: workspace:* version: link:../typescript-config - n8n-workflow: - specifier: workspace:* - version: link:../../workflow packages/@n8n/benchmark: dependencies: