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": {
|
"devDependencies": {
|
||||||
"@n8n/typescript-config": "workspace:*",
|
"@n8n/typescript-config": "workspace:*",
|
||||||
"@n8n/config": "workspace:*",
|
"@n8n/config": "workspace:*"
|
||||||
"n8n-workflow": "workspace:*"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"n8n-workflow": "workspace:*",
|
||||||
"xss": "catalog:",
|
"xss": "catalog:",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
"zod-class": "0.0.16"
|
"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 { CreateFolderDto } from './folders/create-folder.dto';
|
||||||
export { UpdateFolderDto } from './folders/update-folder.dto';
|
export { UpdateFolderDto } from './folders/update-folder.dto';
|
||||||
export { DeleteFolderDto } from './folders/delete-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,
|
variable: [...DEFAULT_OPERATIONS] as const,
|
||||||
workersView: ['manage'] as const,
|
workersView: ['manage'] as const,
|
||||||
workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const,
|
workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const,
|
||||||
folder: ['create', 'read', 'update', 'delete'] as const,
|
folder: [...DEFAULT_OPERATIONS] as const,
|
||||||
} 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 { 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 { FolderNotFoundError } from '@/errors/folder-not-found.error';
|
||||||
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
||||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||||
|
import type { ListQuery } from '@/requests';
|
||||||
import { AuthenticatedRequest } from '@/requests';
|
import { AuthenticatedRequest } from '@/requests';
|
||||||
import { FolderService } from '@/services/folder.service';
|
import { FolderService } from '@/services/folder.service';
|
||||||
|
|
||||||
@@ -86,4 +92,21 @@ export class ProjectController {
|
|||||||
throw new InternalServerError(undefined, e);
|
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 () => {
|
it('should sort by createdAt:asc', async () => {
|
||||||
const [folders] = await folderRepository.getManyAndCount({
|
const [folders] = await folderRepository.getManyAndCount({
|
||||||
sortBy: 'createdAt:asc',
|
sortBy: 'createdAt:asc',
|
||||||
|
|||||||
@@ -213,7 +213,9 @@ export class FolderRepository extends Repository<FolderWithWorkflowCount> {
|
|||||||
direction: 'DESC' | 'ASC',
|
direction: 'DESC' | 'ASC',
|
||||||
): void {
|
): void {
|
||||||
if (field === 'name') {
|
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)) {
|
} else if (['createdAt', 'updatedAt'].includes(field)) {
|
||||||
query.orderBy(`folder.${field}`, direction);
|
query.orderBy(`folder.${field}`, direction);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [
|
|||||||
'folder:read',
|
'folder:read',
|
||||||
'folder:update',
|
'folder:update',
|
||||||
'folder:delete',
|
'folder:delete',
|
||||||
|
'folder:list',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [
|
export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [
|
||||||
@@ -53,6 +54,7 @@ export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [
|
|||||||
'folder:read',
|
'folder:read',
|
||||||
'folder:update',
|
'folder:update',
|
||||||
'folder:delete',
|
'folder:delete',
|
||||||
|
'folder:list',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PROJECT_EDITOR_SCOPES: Scope[] = [
|
export const PROJECT_EDITOR_SCOPES: Scope[] = [
|
||||||
@@ -73,6 +75,7 @@ export const PROJECT_EDITOR_SCOPES: Scope[] = [
|
|||||||
'folder:read',
|
'folder:read',
|
||||||
'folder:update',
|
'folder:update',
|
||||||
'folder:delete',
|
'folder:delete',
|
||||||
|
'folder:list',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PROJECT_VIEWER_SCOPES: Scope[] = [
|
export const PROJECT_VIEWER_SCOPES: Scope[] = [
|
||||||
@@ -83,4 +86,5 @@ export const PROJECT_VIEWER_SCOPES: Scope[] = [
|
|||||||
'workflow:list',
|
'workflow:list',
|
||||||
'workflow:read',
|
'workflow:read',
|
||||||
'folder:read',
|
'folder:read',
|
||||||
|
'folder:list',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { FolderTagMappingRepository } from '@/databases/repositories/folder-tag-
|
|||||||
import { FolderRepository } from '@/databases/repositories/folder.repository';
|
import { FolderRepository } from '@/databases/repositories/folder.repository';
|
||||||
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
import { FolderNotFoundError } from '@/errors/folder-not-found.error';
|
import { FolderNotFoundError } from '@/errors/folder-not-found.error';
|
||||||
|
import type { ListQuery } from '@/requests';
|
||||||
|
|
||||||
export interface SimpleFolderNode {
|
export interface SimpleFolderNode {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -156,4 +157,9 @@ export class FolderService {
|
|||||||
|
|
||||||
return rootNode ? [rootNode] : [];
|
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 { Container } from '@n8n/di';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import type { Project } from '@/databases/entities/project';
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
import { FolderRepository } from '@/databases/repositories/folder.repository';
|
import { FolderRepository } from '@/databases/repositories/folder.repository';
|
||||||
import { ProjectRepository } from '@/databases/repositories/project.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 { createTag } from '@test-integration/db/tags';
|
||||||
import { createWorkflow } from '@test-integration/db/workflows';
|
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 { createOwner, createMember } from '../shared/db/users';
|
||||||
import * as testDb from '../shared/test-db';
|
import * as testDb from '../shared/test-db';
|
||||||
import type { SuperAgentTest } from '../shared/types';
|
import type { SuperAgentTest } from '../shared/types';
|
||||||
@@ -18,6 +20,8 @@ let owner: User;
|
|||||||
let member: User;
|
let member: User;
|
||||||
let authOwnerAgent: SuperAgentTest;
|
let authOwnerAgent: SuperAgentTest;
|
||||||
let authMemberAgent: SuperAgentTest;
|
let authMemberAgent: SuperAgentTest;
|
||||||
|
let ownerProject: Project;
|
||||||
|
let memberProject: Project;
|
||||||
|
|
||||||
const testServer = utils.setupTestServer({
|
const testServer = utils.setupTestServer({
|
||||||
endpointGroups: ['folder'],
|
endpointGroups: ['folder'],
|
||||||
@@ -38,6 +42,9 @@ beforeEach(async () => {
|
|||||||
member = await createMember();
|
member = await createMember();
|
||||||
authOwnerAgent = testServer.authAgentFor(owner);
|
authOwnerAgent = testServer.authAgentFor(owner);
|
||||||
authMemberAgent = testServer.authAgentFor(member);
|
authMemberAgent = testServer.authAgentFor(member);
|
||||||
|
|
||||||
|
ownerProject = await getPersonalProject(owner);
|
||||||
|
memberProject = await getPersonalProject(member);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /projects/:projectId/folders', () => {
|
describe('POST /projects/:projectId/folders', () => {
|
||||||
@@ -669,3 +676,328 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => {
|
|||||||
expect(folderInDb).toBeDefined();
|
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:
|
packages/@n8n/api-types:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
n8n-workflow:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../workflow
|
||||||
xss:
|
xss:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 1.0.15
|
version: 1.0.15
|
||||||
@@ -309,9 +312,6 @@ importers:
|
|||||||
'@n8n/typescript-config':
|
'@n8n/typescript-config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../typescript-config
|
version: link:../typescript-config
|
||||||
n8n-workflow:
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../../workflow
|
|
||||||
|
|
||||||
packages/@n8n/benchmark:
|
packages/@n8n/benchmark:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user