chore: Migrate GET /api/v1/folders to @PublicApiController + Zod (#38620)

Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ali Elkhateeb <ali.elkhateeb@n8n.io>
This commit is contained in:
n8n-cat-bot[bot] 2026-09-16 13:42:04 +00:00 committed by GitHub
parent 94c12a6e7f
commit 7867844fbb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 662 additions and 120 deletions

View File

@ -0,0 +1,121 @@
import {
FolderListPublicDto,
folderPublicSchema,
ListFoldersQueryPublicDto,
} from '../folder-public.dto';
const folder = {
id: 'zLXWgYyZrZ4Gn1Vk',
name: 'Campaigns',
parentFolderId: 'aBXWgYyZrZ4Gn1Vk',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
homeProject: {
id: 'VmwOO9HeTEj20kxM',
name: 'Marketing',
type: 'team',
icon: { type: 'icon', value: 'layers' },
},
parentFolder: { id: 'aBXWgYyZrZ4Gn1Vk', name: 'Marketing', parentFolderId: null },
tags: [{ id: 'tag-1', name: 'campaign' }],
workflowCount: 3,
subFolderCount: 1,
path: ['Marketing', 'Campaigns'],
};
describe('folderPublicSchema', () => {
test('accepts every field the list query can load', () => {
const result = folderPublicSchema.safeParse(folder);
expect(result.success).toBe(true);
expect(result.data).toEqual(folder);
});
test('accepts a folder reduced by the select query option', () => {
const result = folderPublicSchema.safeParse({ id: folder.id, name: folder.name });
expect(result.success).toBe(true);
expect(result.data).toEqual({ id: folder.id, name: folder.name });
});
test('accepts a root folder', () => {
const result = folderPublicSchema.safeParse({
...folder,
parentFolderId: null,
parentFolder: null,
});
expect(result.success).toBe(true);
});
test('accepts a project type outside the current enum', () => {
const result = folderPublicSchema.safeParse({
...folder,
homeProject: { ...folder.homeProject, type: 'legacy' },
});
expect(result.success).toBe(true);
});
test('strips fields the Public API does not publish', () => {
const result = folderPublicSchema.parse({ ...folder, projectId: 'VmwOO9HeTEj20kxM' });
expect(result).not.toHaveProperty('projectId');
});
});
describe('FolderListPublicDto', () => {
test('accepts a count and a list of folders', () => {
expect(FolderListPublicDto.safeParse({ count: 1, data: [folder] }).success).toBe(true);
});
test('rejects a bare array', () => {
expect(FolderListPublicDto.safeParse([folder]).success).toBe(false);
});
});
describe('ListFoldersQueryPublicDto', () => {
test('defaults skip and take when neither is sent', () => {
const result = ListFoldersQueryPublicDto.safeParse({});
expect(result.success).toBe(true);
expect(result.data).toEqual({ skip: 0, take: 10 });
});
test('decodes the filter and select parameters', () => {
const result = ListFoldersQueryPublicDto.safeParse({
filter: '{"name":"Campaigns"}',
select: '["id","name"]',
sortBy: 'name:asc',
skip: '5',
take: '25',
});
expect(result.success).toBe(true);
expect(result.data).toEqual({
filter: { name: 'Campaigns' },
select: { id: true, name: true },
sortBy: 'name:asc',
skip: 5,
take: 25,
});
});
test('keeps the legacy wording for an invalid sortBy', () => {
const result = ListFoldersQueryPublicDto.safeParse({ sortBy: 'size:asc' });
expect(result.success).toBe(false);
expect(result.error?.errors[0]).toMatchObject({
path: ['sortBy'],
message:
'must be equal to one of the allowed values: name:asc, name:desc, createdAt:asc, createdAt:desc, updatedAt:asc, updatedAt:desc',
});
});
test('rejects an undocumented query parameter', () => {
const result = ListFoldersQueryPublicDto.safeParse({ offset: '10' });
expect(result.success).toBe(false);
expect(result.error?.errors[0].code).toBe('unrecognized_keys');
});
});

View File

@ -198,12 +198,8 @@ describe('ListFolderQueryDto', () => {
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]);
}
if (!result.success) {
expect(result.error.issues[0].path).toEqual(expectedErrorPath);
}
});
});

View File

@ -0,0 +1,87 @@
import '../../openapi-extend';
import { z } from 'zod';
import {
folderFieldDocs,
folderListFieldDocs,
folderListQueryFieldDocs,
folderProjectFieldDocs,
folderProjectIconOpenApi,
} from './folder-public.openapi';
import {
filterValidator,
selectValidator,
skipValidator,
takeValidator,
VALID_SORT_OPTIONS,
} from './list-folder-query.dto';
import { nullableObjectGuardSchema } from '../../schemas/object-guard.schema';
import { projectTypeSchema, type ProjectIcon } from '../../schemas/project.schema';
import { Z } from '../../zod-class';
/** The project a folder lives in, reduced to the columns the list query loads. */
const folderProjectPublicSchema = z.object({
id: z.string().openapi(folderProjectFieldDocs.id),
name: z.string().openapi(folderProjectFieldDocs.name),
// Legacy rows may hold a type the current enum no longer lists, and a response mismatch is a 500.
type: z
.string()
.openapi({ ...folderProjectFieldDocs.type, enum: [...projectTypeSchema.options] }),
icon: nullableObjectGuardSchema<ProjectIcon>().openapi(folderProjectIconOpenApi),
});
const folderParentPublicSchema = z.object({
id: z.string().openapi(folderFieldDocs.id),
name: z.string().openapi(folderFieldDocs.name),
parentFolderId: z.string().nullable(),
});
const folderTagPublicSchema = z.object({
id: z.string(),
name: z.string(),
});
/**
* Every field is optional because the `select` query parameter decides which columns the route
* loads: an unselected field is absent from the response, not null.
*/
export const folderPublicSchema = z.object({
id: z.string().openapi(folderFieldDocs.id),
name: z.string().optional().openapi(folderFieldDocs.name),
parentFolderId: z.string().nullable().optional(),
createdAt: z.string().datetime().optional().openapi(folderFieldDocs.createdAt),
updatedAt: z.string().datetime().optional().openapi(folderFieldDocs.updatedAt),
homeProject: folderProjectPublicSchema.optional(),
parentFolder: folderParentPublicSchema.nullable().optional(),
tags: z.array(folderTagPublicSchema).optional(),
workflowCount: z.number().optional().openapi(folderFieldDocs.workflowCount),
subFolderCount: z.number().optional().openapi(folderFieldDocs.subFolderCount),
path: z.array(z.string()).optional().openapi(folderFieldDocs.path),
});
export type FolderPublic = z.infer<typeof folderPublicSchema>;
export class FolderPublicDto extends Z.class(folderPublicSchema.shape) {}
export class FolderListPublicDto extends Z.class({
count: z.number().int().openapi(folderListFieldDocs.count),
data: z.array(folderPublicSchema),
}) {}
export class ListFoldersQueryPublicDto extends Z.class(
{
filter: filterValidator.openapi(folderListQueryFieldDocs.filter),
select: selectValidator.openapi(folderListQueryFieldDocs.select),
sortBy: z
.enum(VALID_SORT_OPTIONS, {
// Keep the wording the legacy validator produced, so the 400 body does not change.
message: `must be equal to one of the allowed values: ${VALID_SORT_OPTIONS.join(', ')}`,
})
.optional()
.openapi(folderListQueryFieldDocs.sortBy),
skip: skipValidator.openapi(folderListQueryFieldDocs.skip),
take: takeValidator.openapi(folderListQueryFieldDocs.take),
},
{ strict: true },
) {}

View File

@ -0,0 +1,60 @@
import type { ZodOpenAPIMetadata } from '@asteasolutions/zod-to-openapi';
import { alsoNullable } from '../openapi-nullable';
export const folderFieldDocs = {
id: { readOnly: true, example: 'zLXWgYyZrZ4Gn1Vk' },
name: { example: 'My Folder' },
createdAt: { readOnly: true },
updatedAt: { readOnly: true },
workflowCount: { description: 'Number of workflows the folder holds.', readOnly: true },
subFolderCount: { description: 'Number of folders the folder holds.', readOnly: true },
path: {
description: 'Names of every folder from the project root down to this folder.',
readOnly: true,
example: ['Marketing', 'Campaigns'],
},
} satisfies Record<string, ZodOpenAPIMetadata>;
export const folderProjectFieldDocs = {
id: { readOnly: true, example: 'VmwOO9HeTEj20kxM' },
name: { example: 'Marketing' },
type: { readOnly: true, example: 'team' },
} as const satisfies Record<string, ZodOpenAPIMetadata>;
/**
* `icon` is a JSON column, so the schema only guards the basic type and this metadata carries the
* shape. Same reasoning as `projectIconOpenApi`, but the folder route returns a reduced project.
*/
export const folderProjectIconOpenApi: ZodOpenAPIMetadata = alsoNullable({
type: 'object',
description: 'Icon of the project, or null when the project has none',
properties: {
type: { type: 'string', enum: ['emoji', 'icon'] },
value: { type: 'string' },
color: { type: 'string' },
},
required: ['type', 'value'],
});
export const folderListFieldDocs = {
count: { description: 'Total number of folders matching the query.' },
} as const satisfies Record<string, ZodOpenAPIMetadata>;
export const folderListQueryFieldDocs = {
filter: {
type: 'string',
description:
'JSON-encoded filter object. Supported fields — parentFolderId, name, tags (array of tag names), excludeFolderIdAndDescendants.',
example: '{"parentFolderId":"abc123","name":"My Folder"}',
},
select: {
type: 'string',
description:
'JSON-encoded array of fields to include. Valid fields — id, name, createdAt, updatedAt, project, tags, parentFolder, workflowCount, subFolderCount, path.',
example: '["id","name","tags","workflowCount"]',
},
sortBy: { description: 'Sort order for results.' },
skip: { type: 'string', description: 'Number of items to skip for pagination. Defaults to 0.' },
take: { type: 'string', description: 'Number of items to return. Defaults to 10.' },
} as const satisfies Record<string, ZodOpenAPIMetadata>;

View File

@ -3,7 +3,7 @@ import { z } from 'zod';
import { Z } from '../../zod-class';
const VALID_SELECT_FIELDS = [
export const VALID_SELECT_FIELDS = [
'id',
'name',
'createdAt',
@ -16,7 +16,7 @@ const VALID_SELECT_FIELDS = [
'path',
] as const;
const VALID_SORT_OPTIONS = [
export const VALID_SORT_OPTIONS = [
'name:asc',
'name:desc',
'createdAt:asc',
@ -40,7 +40,7 @@ export const filterSchema = z
// ---------------------
// Filter parameter validation
const filterValidator = z
export const filterValidator = z
.string()
.optional()
.transform((val, ctx) => {
@ -53,7 +53,6 @@ const filterValidator = z
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid filter fields',
path: ['filter'],
});
return z.NEVER;
}
@ -61,14 +60,13 @@ const filterValidator = z
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid filter format',
path: ['filter'],
});
return z.NEVER;
}
});
// Skip parameter validation
const skipValidator = z
export const skipValidator = z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : 0))
@ -77,7 +75,7 @@ const skipValidator = z
});
// Take parameter validation
const takeValidator = z
export const takeValidator = z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : 10))
@ -87,7 +85,7 @@ const takeValidator = z
// Select parameter validation
const selectFieldsValidator = z.array(z.enum(VALID_SELECT_FIELDS));
const selectValidator = z
export const selectValidator = z
.string()
.optional()
.transform((val, ctx) => {
@ -106,7 +104,6 @@ const selectValidator = z
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid select fields. Valid fields are: ${VALID_SELECT_FIELDS.join(', ')}`,
path: ['select'],
});
return z.NEVER;
}
@ -114,7 +111,6 @@ const selectValidator = z
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid select format',
path: ['select'],
});
return z.NEVER;
}

View File

@ -252,6 +252,13 @@ export { UpdateFolderDto } from './folders/update-folder.dto';
export { DeleteFolderDto } from './folders/delete-folder.dto';
export { ListFolderQueryDto } from './folders/list-folder-query.dto';
export { TransferFolderBodyDto } from './folders/transfer-folder.dto';
export {
FolderListPublicDto,
FolderPublicDto,
ListFoldersQueryPublicDto,
folderPublicSchema,
} from './folders/folder-public.dto';
export type { FolderPublic } from './folders/folder-public.dto';
export { ListInsightsWorkflowQueryDto } from './insights/list-workflow-query.dto';
export { InsightsDateFilterDto } from './insights/date-filter.dto';

View File

@ -339,7 +339,7 @@ export function isAuthProviderType(value: string): value is AuthProviderType {
}
export type FolderWithWorkflowAndSubFolderCount = Folder & {
workflowCount?: boolean;
workflowCount?: number;
subFolderCount?: number;
};

View File

@ -0,0 +1,83 @@
import {
FolderListPublicDto,
type FolderPublic,
ListFoldersQueryPublicDto,
projectIdParamSchema,
} from '@n8n/api-types';
import { LICENSE_FEATURES } from '@n8n/constants';
import type { AuthenticatedRequest, FolderWithWorkflowAndSubFolderCountAndPath } from '@n8n/db';
import {
ApiDescription,
ApiErrorResponse,
ApiKeyScope,
ApiResponse,
ApiSummary,
ApiTags,
Get,
Licensed,
Param,
PublicApiController,
Query,
} from '@n8n/decorators';
import type { Response } from 'express';
import { assertProjectScope } from '@/public-api/v1/shared/services/utils.service';
import { FolderService } from '@/services/folder.service';
const tags = ['Folders'];
/**
* The `select` query parameter decides which columns the query loads, so a field the caller did not
* ask for is left `undefined` and drops out of the JSON body.
*/
const toFolderPublicDto = (folder: FolderWithWorkflowAndSubFolderCountAndPath): FolderPublic => ({
id: folder.id,
name: folder.name,
parentFolderId: folder.parentFolderId,
createdAt: folder.createdAt?.toISOString(),
updatedAt: folder.updatedAt?.toISOString(),
homeProject: folder.homeProject && {
id: folder.homeProject.id,
name: folder.homeProject.name,
type: folder.homeProject.type,
icon: folder.homeProject.icon,
},
parentFolder: folder.parentFolder && {
id: folder.parentFolder.id,
name: folder.parentFolder.name,
parentFolderId: folder.parentFolder.parentFolderId,
},
tags: folder.tags?.map((tag) => ({ id: tag.id, name: tag.name })),
workflowCount: folder.workflowCount,
subFolderCount: folder.subFolderCount,
path: folder.path,
});
@PublicApiController('/projects/:projectId/folders')
export class FoldersPublicController {
constructor(private readonly folderService: FolderService) {}
@Get('/')
@Licensed(LICENSE_FEATURES.FOLDERS)
@ApiKeyScope('folder:list')
@ApiSummary('Retrieve folders')
@ApiDescription(
'Retrieve folders within a project. Supports filtering, sorting, field selection, and pagination.',
)
@ApiTags(tags)
@ApiResponse(200, FolderListPublicDto)
@ApiErrorResponse(404)
async listFolders(
req: AuthenticatedRequest,
_res: Response,
@Param('projectId', projectIdParamSchema) projectId: string,
@Query query: ListFoldersQueryPublicDto,
): Promise<FolderListPublicDto> {
// Kept in the handler: it answers 404 for an unknown project, which @ProjectScope cannot do.
await assertProjectScope(req.user, projectId, ['folder:list']);
const [data, count] = await this.folderService.getManyAndCount(projectId, query);
return { count, data: data.map(toFolderPublicDto) };
}
}

View File

@ -5,6 +5,7 @@
*/
import './credentials.public.controller';
import './executions.public.controller';
import './folders.public.controller';
import './node-type-policies.public.controller';
import './projects.public.controller';
import './promotions.public.controller';

View File

@ -1,9 +1,4 @@
import {
CreateFolderDto,
DeleteFolderDto,
ListFolderQueryDto,
UpdateFolderDto,
} from '@n8n/api-types';
import { CreateFolderDto, DeleteFolderDto, UpdateFolderDto } from '@n8n/api-types';
import type { AuthenticatedRequest } from '@n8n/db';
import { Container } from '@n8n/di';
import { UserError } from 'n8n-workflow';
@ -36,7 +31,6 @@ const handleError = (error: unknown) => {
type FolderHandlers = {
createFolder: PublicAPIEndpoint<AuthenticatedRequest<{ projectId: string }>>;
getFolders: PublicAPIEndpoint<AuthenticatedRequest<{ projectId: string }>>;
deleteFolder: PublicAPIEndpoint<AuthenticatedRequest<{ projectId: string; folderId: string }>>;
getFolder: PublicAPIEndpoint<AuthenticatedRequest<{ projectId: string; folderId: string }>>;
updateFolder: PublicAPIEndpoint<AuthenticatedRequest<{ projectId: string; folderId: string }>>;
@ -71,25 +65,6 @@ const folderHandlers: FolderHandlers = {
}
},
],
getFolders: [
isLicensed('feat:folders'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'folder:list' }),
async (req, res) => {
const { projectId } = req.params;
await assertProjectScope(req.user, projectId, ['folder:list']);
const query = ListFolderQueryDto.safeParse(req.query);
if (query.error) {
throw new BadRequestError(query.error.errors[0].message);
}
const [data, count] = await Container.get(FolderService).getManyAndCount(
projectId,
query.data,
);
return res.json({ count, data });
},
],
deleteFolder: [
isLicensed('feat:folders'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'folder:delete' }),

View File

@ -37,80 +37,3 @@ post:
$ref: '../../../../shared/spec/responses/forbidden.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'
get:
x-eov-operation-id: getFolders
x-required-scope: folder:list
x-eov-operation-handler: v1/handlers/folders/folders.handler
tags:
- Folders
summary: Retrieve folders
description: Retrieve folders within a project. Supports filtering, sorting, field selection, and pagination.
parameters:
- name: projectId
in: path
description: The ID of the project.
required: true
schema:
type: string
- name: filter
in: query
required: false
description: JSON-encoded filter object. Supported fields — parentFolderId, name, tags (array of tag names), excludeFolderIdAndDescendants.
schema:
type: string
example: '{"parentFolderId":"abc123","name":"My Folder"}'
- name: select
in: query
required: false
description: JSON-encoded array of fields to include. Valid fields — id, name, createdAt, updatedAt, project, tags, parentFolder, workflowCount, subFolderCount, path.
schema:
type: string
example: '["id","name","tags","workflowCount"]'
- name: sortBy
in: query
required: false
description: Sort order for results.
schema:
type: string
enum:
- name:asc
- name:desc
- createdAt:asc
- createdAt:desc
- updatedAt:asc
- updatedAt:desc
- name: skip
in: query
required: false
description: Number of items to skip for pagination. Defaults to 0.
schema:
type: string
- name: take
in: query
required: false
description: Number of items to return. Defaults to 10.
schema:
type: string
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
type: object
properties:
count:
type: integer
description: Total number of folders matching the query.
data:
type: array
items:
$ref: '../schemas/folder.yml'
'400':
$ref: '../../../../shared/spec/responses/badRequest.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'403':
$ref: '../../../../shared/spec/responses/forbidden.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'

View File

@ -0,0 +1,188 @@
operationId: listFolders
tags:
- Folders
summary: Retrieve folders
description: Retrieve folders within a project. Supports filtering, sorting, field selection, and pagination.
x-required-scope: folder:list
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
parameters:
- schema:
type: string
required: true
description: The ID of the project.
name: projectId
in: path
- schema:
type: string
description: JSON-encoded filter object. Supported fields — parentFolderId, name, tags (array of tag names), excludeFolderIdAndDescendants.
example: '{"parentFolderId":"abc123","name":"My Folder"}'
required: false
name: filter
in: query
- schema:
type: string
description: JSON-encoded array of fields to include. Valid fields — id, name, createdAt, updatedAt, project, tags, parentFolder, workflowCount, subFolderCount, path.
example: '["id","name","tags","workflowCount"]'
required: false
name: select
in: query
- schema:
type: string
enum:
- name:asc
- name:desc
- createdAt:asc
- createdAt:desc
- updatedAt:asc
- updatedAt:desc
description: Sort order for results.
required: false
name: sortBy
in: query
- schema:
type: string
description: Number of items to skip for pagination. Defaults to 0.
required: false
name: skip
in: query
- schema:
type: string
description: Number of items to return. Defaults to 10.
required: false
name: take
in: query
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
type: object
properties:
count:
type: integer
description: Total number of folders matching the query.
data:
type: array
items:
type: object
properties:
id:
type: string
readOnly: true
example: zLXWgYyZrZ4Gn1Vk
name:
type: string
example: My Folder
parentFolderId:
type: string
nullable: true
createdAt:
type: string
format: date-time
readOnly: true
updatedAt:
type: string
format: date-time
readOnly: true
homeProject:
type: object
properties:
id:
type: string
readOnly: true
example: VmwOO9HeTEj20kxM
name:
type: string
example: Marketing
type:
type: string
readOnly: true
example: team
enum:
- personal
- team
icon:
type: object
description: Icon of the project, or null when the project has none
properties:
type:
type: string
enum:
- emoji
- icon
value:
type: string
color:
type: string
required:
- type
- value
nullable: true
required:
- id
- name
- type
- icon
parentFolder:
type: object
nullable: true
properties:
id:
type: string
readOnly: true
example: zLXWgYyZrZ4Gn1Vk
name:
type: string
example: My Folder
parentFolderId:
type: string
nullable: true
required:
- id
- name
- parentFolderId
tags:
type: array
items:
type: object
properties:
id:
type: string
name:
type: string
required:
- id
- name
workflowCount:
type: number
description: Number of workflows the folder holds.
readOnly: true
subFolderCount:
type: number
description: Number of folders the folder holds.
readOnly: true
path:
type: array
items:
type: string
description: Names of every folder from the project root down to this folder.
readOnly: true
example:
- Marketing
- Campaigns
required:
- id
required:
- count
- data
'400':
$ref: ../../../../shared/spec/responses/badRequest.yml
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml

View File

@ -40,6 +40,9 @@ paths:
/executions/{executionId}/retry:
post:
$ref: ./handlers/executions/spec/paths/retryExecution.generated.yml
/projects/{projectId}/folders:
get:
$ref: ./handlers/projects/spec/paths/listFolders.generated.yml
/node-type-policies/instance:
get:
$ref: ./handlers/node-type-policies/spec/paths/getInstancePolicy.generated.yml

View File

@ -8,6 +8,7 @@ import { FolderService } from '@/services/folder.service';
import { ProjectService } from '@/services/project.service.ee';
import { createFolder } from '../shared/db/folders';
import { createTag } from '../shared/db/tags';
import { createOwnerWithApiKey, createMemberWithApiKey } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/';
@ -355,6 +356,107 @@ describe('GET /projects/:projectId/folders', () => {
expect(response.body.data[0].name).toBe('Child B');
});
test('should return every public folder field and nothing else', async () => {
testServer.license.enable('feat:folders');
const tag = await createTag({ name: 'Marketing' });
const parentFolder = await createFolder(ownerPersonalProject, { name: 'Parent' });
const folder = await createFolder(ownerPersonalProject, {
name: 'Child',
parentFolder,
tags: [tag],
});
const response = await authOwnerAgent
.get(`/projects/${ownerPersonalProject.id}/folders`)
.query({ filter: JSON.stringify({ name: 'Child' }) });
expect(response.statusCode).toBe(200);
expect(response.body.count).toBe(1);
expect(response.body.data[0]).toEqual({
id: folder.id,
name: 'Child',
parentFolderId: parentFolder.id,
createdAt: folder.createdAt.toISOString(),
updatedAt: folder.updatedAt.toISOString(),
homeProject: {
id: ownerPersonalProject.id,
name: ownerPersonalProject.name,
type: 'personal',
icon: null,
},
parentFolder: { id: parentFolder.id, name: 'Parent', parentFolderId: null },
tags: [{ id: tag.id, name: 'Marketing' }],
workflowCount: 0,
subFolderCount: 0,
});
});
test('should return only the fields the select query option names', async () => {
testServer.license.enable('feat:folders');
const parentFolder = await createFolder(ownerPersonalProject, { name: 'Parent' });
const folder = await createFolder(ownerPersonalProject, { name: 'Child', parentFolder });
const response = await authOwnerAgent
.get(`/projects/${ownerPersonalProject.id}/folders`)
.query({
filter: JSON.stringify({ name: 'Child' }),
select: JSON.stringify(['id', 'name', 'path']),
});
expect(response.statusCode).toBe(200);
expect(response.body.data[0]).toEqual({
id: folder.id,
name: 'Child',
path: ['Parent', 'Child'],
});
});
test.each([
{
name: 'an invalid sortBy value',
query: { sortBy: 'bogus' },
message:
'request/query/sortBy must be equal to one of the allowed values: name:asc, name:desc, createdAt:asc, createdAt:desc, updatedAt:asc, updatedAt:desc',
},
{
name: 'a filter that is not JSON',
query: { filter: 'not-json' },
message: 'request/query/filter Invalid filter format',
},
{
name: 'an unknown filter field',
query: { filter: JSON.stringify({ unknownField: 'x' }) },
message: 'request/query/filter Invalid filter fields',
},
{
name: 'an unknown select field',
query: { select: JSON.stringify(['unknownField']) },
message:
'request/query/select Invalid select fields. Valid fields are: id, name, createdAt, updatedAt, project, tags, parentFolder, workflowCount, subFolderCount, path',
},
{
name: 'a take that is not a number',
query: { take: 'abc' },
message: 'request/query/take Take must be a valid number',
},
{
name: 'an unknown query parameter',
query: { unknownParameter: '1' },
message: "request/query Unrecognized key(s) in object: 'unknownParameter'",
},
])('should return 400 for $name', async ({ query, message }) => {
testServer.license.enable('feat:folders');
const response = await authOwnerAgent
.get(`/projects/${ownerPersonalProject.id}/folders`)
.query(query);
expect(response.statusCode).toBe(400);
expect(response.body).toEqual({ message });
});
test('should return 500 when getManyAndCount throws an unexpected error', async () => {
testServer.license.enable('feat:folders');
vi.spyOn(Container.get(FolderService), 'getManyAndCount').mockRejectedValueOnce(