feat(core): Add endpoint GET /projects/:projectId/folders (no-changelog) (#13519)

This commit is contained in:
Ricardo Espinoza
2025-03-12 13:04:55 +01:00
committed by GitHub
parent b2fcfe9d69
commit 0d7894f06a
12 changed files with 727 additions and 10 deletions

View File

@@ -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"

View File

@@ -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]);
}
}
});
});
});

View 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,
}) {}

View File

@@ -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';

View File

@@ -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;

View File

@@ -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 });
}
}

View File

@@ -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',

View File

@@ -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);
}

View File

@@ -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',
];

View File

@@ -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);
}
}

View File

@@ -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
View File

@@ -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: