mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
feat(core): Update PATCH /projects/:projectId/folders/:folderId endpoint to allow moving folder (no-changelog) (#13574)
This commit is contained in:
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -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(),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -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(),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user