feat(core): Update PATCH /projects/:projectId/folders/:folderId endpoint to allow moving folder (no-changelog) (#13574)

This commit is contained in:
Ricardo Espinoza
2025-03-12 13:53:45 +01:00
committed by GitHub
parent 0066bf890f
commit 2275b1780a
8 changed files with 587 additions and 11 deletions

View File

@@ -21,6 +21,12 @@ describe('UpdateFolderDto', () => {
tagIds: [], tagIds: [],
}, },
}, },
{
name: 'string parentFolderId',
request: {
parentFolderId: 'test',
},
},
])('should validate $name', ({ request }) => { ])('should validate $name', ({ request }) => {
const result = UpdateFolderDto.safeParse(request); const result = UpdateFolderDto.safeParse(request);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -50,6 +56,13 @@ describe('UpdateFolderDto', () => {
}, },
expectedErrorPath: ['tagIds'], expectedErrorPath: ['tagIds'],
}, },
{
name: 'non string parentFolderId',
request: {
parentFolderId: 0,
},
expectedErrorPath: ['parentFolderId'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => { ])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = UpdateFolderDto.safeParse(request); const result = UpdateFolderDto.safeParse(request);

View File

@@ -1,8 +1,8 @@
import { Z } from 'zod-class'; import { Z } from 'zod-class';
import { folderNameSchema, folderId } from '../../schemas/folder.schema'; import { folderNameSchema, folderIdSchema } from '../../schemas/folder.schema';
export class CreateFolderDto extends Z.class({ export class CreateFolderDto extends Z.class({
name: folderNameSchema, name: folderNameSchema,
parentFolderId: folderId.optional(), parentFolderId: folderIdSchema.optional(),
}) {} }) {}

View File

@@ -1,7 +1,7 @@
import { Z } from 'zod-class'; import { Z } from 'zod-class';
import { folderId } from '../../schemas/folder.schema'; import { folderIdSchema } from '../../schemas/folder.schema';
export class DeleteFolderDto extends Z.class({ export class DeleteFolderDto extends Z.class({
transferToFolderId: folderId.optional(), transferToFolderId: folderIdSchema.optional(),
}) {} }) {}

View File

@@ -1,8 +1,9 @@
import { z } from 'zod'; import { z } from 'zod';
import { Z } from 'zod-class'; import { Z } from 'zod-class';
import { folderNameSchema } from '../../schemas/folder.schema'; import { folderNameSchema, folderIdSchema } from '../../schemas/folder.schema';
export class UpdateFolderDto extends Z.class({ export class UpdateFolderDto extends Z.class({
name: folderNameSchema.optional(), name: folderNameSchema.optional(),
tagIds: z.array(z.string().max(24)).optional(), tagIds: z.array(z.string().max(24)).optional(),
parentFolderId: folderIdSchema.optional(),
}) {} }) {}

View File

@@ -1,4 +1,4 @@
import { z } from 'zod'; import { z } from 'zod';
export const folderNameSchema = z.string().trim().min(1).max(128); export const folderNameSchema = z.string().trim().min(1).max(128);
export const folderId = z.string().max(36); export const folderIdSchema = z.string().max(36);

View File

@@ -5,9 +5,11 @@ import {
UpdateFolderDto, UpdateFolderDto,
} from '@n8n/api-types'; } from '@n8n/api-types';
import { Response } from 'express'; import { Response } from 'express';
import { UserError } from 'n8n-workflow';
import { Post, RestController, ProjectScope, Body, Get, Patch, Delete, Query } 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 { BadRequestError } from '@/errors/response-errors/bad-request.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 type { ListQuery } from '@/requests';
@@ -69,6 +71,8 @@ export class ProjectController {
} catch (e) { } catch (e) {
if (e instanceof FolderNotFoundError) { if (e instanceof FolderNotFoundError) {
throw new NotFoundError(e.message); throw new NotFoundError(e.message);
} else if (e instanceof UserError) {
throw new BadRequestError(e.message);
} }
throw new InternalServerError(undefined, e); throw new InternalServerError(undefined, e);
} }
@@ -79,7 +83,7 @@ export class ProjectController {
async deleteFolder( async deleteFolder(
req: AuthenticatedRequest<{ projectId: string; folderId: string }>, req: AuthenticatedRequest<{ projectId: string; folderId: string }>,
_res: Response, _res: Response,
@Body payload: DeleteFolderDto, @Query payload: DeleteFolderDto,
) { ) {
const { projectId, folderId } = req.params; const { projectId, folderId } = req.params;
@@ -88,6 +92,8 @@ export class ProjectController {
} catch (e) { } catch (e) {
if (e instanceof FolderNotFoundError) { if (e instanceof FolderNotFoundError) {
throw new NotFoundError(e.message); throw new NotFoundError(e.message);
} else if (e instanceof UserError) {
throw new BadRequestError(e.message);
} }
throw new InternalServerError(undefined, e); throw new InternalServerError(undefined, e);
} }

View File

@@ -2,6 +2,7 @@ import type { CreateFolderDto, DeleteFolderDto, UpdateFolderDto } from '@n8n/api
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { EntityManager } from '@n8n/typeorm'; import type { EntityManager } from '@n8n/typeorm';
import { UserError, PROJECT_ROOT } from 'n8n-workflow';
import { Folder } from '@/databases/entities/folder'; import { Folder } from '@/databases/entities/folder';
import { FolderTagMappingRepository } from '@/databases/repositories/folder-tag-mapping.repository'; import { FolderTagMappingRepository } from '@/databases/repositories/folder-tag-mapping.repository';
@@ -47,7 +48,11 @@ export class FolderService {
return folder; return folder;
} }
async updateFolder(folderId: string, projectId: string, { name, tagIds }: UpdateFolderDto) { async updateFolder(
folderId: string,
projectId: string,
{ name, tagIds, parentFolderId }: UpdateFolderDto,
) {
await this.findFolderInProjectOrFail(folderId, projectId); await this.findFolderInProjectOrFail(folderId, projectId);
if (name) { if (name) {
await this.folderRepository.update({ id: folderId }, { name }); await this.folderRepository.update({ id: folderId }, { name });
@@ -55,6 +60,20 @@ export class FolderService {
if (tagIds) { if (tagIds) {
await this.folderTagMappingRepository.overwriteTags(folderId, tagIds); await this.folderTagMappingRepository.overwriteTags(folderId, tagIds);
} }
if (parentFolderId) {
if (folderId === parentFolderId) {
throw new UserError('Cannot set a folder as its own parent');
}
if (parentFolderId !== PROJECT_ROOT) {
await this.findFolderInProjectOrFail(parentFolderId, projectId);
}
await this.folderRepository.update(
{ id: folderId },
{ parentFolder: parentFolderId !== PROJECT_ROOT ? { id: parentFolderId } : null },
);
}
} }
async findFolderInProjectOrFail(folderId: string, projectId: string, em?: EntityManager) { async findFolderInProjectOrFail(folderId: string, projectId: string, em?: EntityManager) {
@@ -115,6 +134,10 @@ export class FolderService {
return; return;
} }
if (folderId === transferToFolderId) {
throw new UserError('Cannot transfer folder contents to the folder being deleted');
}
await this.findFolderInProjectOrFail(transferToFolderId, projectId); await this.findFolderInProjectOrFail(transferToFolderId, projectId);
return await this.folderRepository.manager.transaction(async (tx) => { return await this.folderRepository.manager.transaction(async (tx) => {

View File

@@ -473,6 +473,190 @@ describe('PATCH /projects/:projectId/folders/:folderId', () => {
expect(folderWithTags?.tags).toHaveLength(1); expect(folderWithTags?.tags).toHaveLength(1);
expect(folderWithTags?.tags[0].id).toBe(tag3.id); expect(folderWithTags?.tags[0].id).toBe(tag3.id);
}); });
test('should update folder parent folder ID', async () => {
const project = await createTeamProject('test project', owner);
await createFolder(project, { name: 'Original Folder' });
const targetFolder = await createFolder(project, { name: 'Target Folder' });
const folderToMove = await createFolder(project, {
name: 'Folder To Move',
});
const payload = {
parentFolderId: targetFolder.id,
};
await authOwnerAgent.patch(`/projects/${project.id}/folders/${folderToMove.id}`).send(payload);
const updatedFolder = await folderRepository.findOne({
where: { id: folderToMove.id },
relations: ['parentFolder'],
});
expect(updatedFolder).toBeDefined();
expect(updatedFolder?.parentFolder?.id).toBe(targetFolder.id);
});
test('should not update folder parent when target folder does not exist', async () => {
const project = await createTeamProject(undefined, owner);
const folderToMove = await createFolder(project, { name: 'Folder To Move' });
const payload = {
parentFolderId: 'non-existing-folder-id',
};
await authOwnerAgent
.patch(`/projects/${project.id}/folders/${folderToMove.id}`)
.send(payload)
.expect(404);
const updatedFolder = await folderRepository.findOne({
where: { id: folderToMove.id },
relations: ['parentFolder'],
});
expect(updatedFolder).toBeDefined();
expect(updatedFolder?.parentFolder).toBeNull();
});
test('should not update folder parent when target folder is in another project', async () => {
const project1 = await createTeamProject('Project 1', owner);
const project2 = await createTeamProject('Project 2', owner);
const folderToMove = await createFolder(project1, { name: 'Folder To Move' });
const targetFolder = await createFolder(project2, { name: 'Target Folder' });
const payload = {
parentFolderId: targetFolder.id,
};
await authOwnerAgent
.patch(`/projects/${project1.id}/folders/${folderToMove.id}`)
.send(payload)
.expect(404);
const updatedFolder = await folderRepository.findOne({
where: { id: folderToMove.id },
relations: ['parentFolder'],
});
expect(updatedFolder).toBeDefined();
expect(updatedFolder?.parentFolder).toBeNull();
});
test('should allow moving a folder to root level by setting parentFolderId to "0"', async () => {
const project = await createTeamProject(undefined, owner);
const parentFolder = await createFolder(project, { name: 'Parent Folder' });
const folderToMove = await createFolder(project, {
name: 'Folder To Move',
parentFolder,
});
// Verify initial state
let folder = await folderRepository.findOne({
where: { id: folderToMove.id },
relations: ['parentFolder'],
});
expect(folder?.parentFolder?.id).toBe(parentFolder.id);
const payload = {
parentFolderId: '0',
};
await authOwnerAgent
.patch(`/projects/${project.id}/folders/${folderToMove.id}`)
.send(payload)
.expect(200);
const updatedFolder = await folderRepository.findOne({
where: { id: folderToMove.id },
relations: ['parentFolder'],
});
expect(updatedFolder).toBeDefined();
expect(updatedFolder?.parentFolder).toBeNull();
});
test('should not update folder parent if user has project:viewer role in team project', async () => {
const project = await createTeamProject(undefined, owner);
await createFolder(project, { name: 'Parent Folder' });
const targetFolder = await createFolder(project, { name: 'Target Folder' });
const folderToMove = await createFolder(project, {
name: 'Folder To Move',
});
await linkUserToProject(member, project, 'project:viewer');
const payload = {
parentFolderId: targetFolder.id,
};
await authMemberAgent
.patch(`/projects/${project.id}/folders/${folderToMove.id}`)
.send(payload)
.expect(403);
const updatedFolder = await folderRepository.findOne({
where: { id: folderToMove.id },
relations: ['parentFolder'],
});
expect(updatedFolder).toBeDefined();
expect(updatedFolder?.parentFolder).toBeNull();
});
test('should update folder parent folder if user has project:editor role in team project', async () => {
const project = await createTeamProject(undefined, owner);
const targetFolder = await createFolder(project, { name: 'Target Folder' });
const folderToMove = await createFolder(project, {
name: 'Folder To Move',
});
await linkUserToProject(member, project, 'project:editor');
const payload = {
parentFolderId: targetFolder.id,
};
await authMemberAgent
.patch(`/projects/${project.id}/folders/${folderToMove.id}`)
.send(payload)
.expect(200);
const updatedFolder = await folderRepository.findOne({
where: { id: folderToMove.id },
relations: ['parentFolder'],
});
expect(updatedFolder).toBeDefined();
expect(updatedFolder?.parentFolder?.id).toBe(targetFolder.id);
});
test('should not allow setting a folder as its own parent', async () => {
const project = await createTeamProject(undefined, owner);
const folder = await createFolder(project, { name: 'Test Folder' });
const payload = {
parentFolderId: folder.id,
};
await authOwnerAgent
.patch(`/projects/${project.id}/folders/${folder.id}`)
.send(payload)
.expect(400);
const folderInDb = await folderRepository.findOne({
where: { id: folder.id },
relations: ['parentFolder'],
});
expect(folderInDb).toBeDefined();
expect(folderInDb?.parentFolder).toBeNull();
});
}); });
describe('DELETE /projects/:projectId/folders/:folderId', () => { describe('DELETE /projects/:projectId/folders/:folderId', () => {
@@ -607,7 +791,7 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => {
await authOwnerAgent await authOwnerAgent
.delete(`/projects/${project.id}/folders/${sourceFolder.id}`) .delete(`/projects/${project.id}/folders/${sourceFolder.id}`)
.send(payload) .query(payload)
.expect(200); .expect(200);
const sourceFolderInDb = await folderRepository.findOne({ const sourceFolderInDb = await folderRepository.findOne({
@@ -650,7 +834,7 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => {
await authOwnerAgent await authOwnerAgent
.delete(`/projects/${project.id}/folders/${folder.id}`) .delete(`/projects/${project.id}/folders/${folder.id}`)
.send(payload) .query(payload)
.expect(404); .expect(404);
const folderInDb = await folderRepository.findOneBy({ id: folder.id }); const folderInDb = await folderRepository.findOneBy({ id: folder.id });
@@ -669,12 +853,361 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => {
await authOwnerAgent await authOwnerAgent
.delete(`/projects/${project1.id}/folders/${sourceFolder.id}`) .delete(`/projects/${project1.id}/folders/${sourceFolder.id}`)
.send(payload) .query(payload)
.expect(404); .expect(404);
const folderInDb = await folderRepository.findOneBy({ id: sourceFolder.id }); const folderInDb = await folderRepository.findOneBy({ id: sourceFolder.id });
expect(folderInDb).toBeDefined(); expect(folderInDb).toBeDefined();
}); });
test('should not allow transferring contents to the same folder being deleted', async () => {
const project = await createTeamProject('test', owner);
const folder = await createFolder(project, { name: 'Folder To Delete' });
await createWorkflow({ parentFolder: folder }, owner);
const payload = {
transferToFolderId: folder.id, // Try to transfer contents to the same folder
};
const response = await authOwnerAgent
.delete(`/projects/${project.id}/folders/${folder.id}`)
.query(payload)
.expect(400);
expect(response.body.message).toContain(
'Cannot transfer folder contents to the folder being deleted',
);
// Verify the folder still exists
const folderInDb = await folderRepository.findOneBy({ id: folder.id });
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(),
);
});
}); });
describe('GET /projects/:projectId/folders', () => { describe('GET /projects/:projectId/folders', () => {