mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(core): Add endpoint GET /projects/:projectId/folders (no-changelog) (#13519)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
130
packages/@n8n/api-types/src/dto/folders/list-folder-query.dto.ts
Normal file
130
packages/@n8n/api-types/src/dto/folders/list-folder-query.dto.ts
Normal file
@@ -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<Record<SelectField, true>>(
|
||||
(acc, field) => ({ ...acc, [field]: true }),
|
||||
{} as Record<SelectField, true>,
|
||||
);
|
||||
} 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,
|
||||
}) {}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -213,7 +213,9 @@ export class FolderRepository extends Repository<FolderWithWorkflowCount> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user