From e875bc55925bd5e0bac3a8930f936bbea592dd0b Mon Sep 17 00:00:00 2001 From: Eugene Date: Tue, 12 Nov 2024 10:28:32 +0100 Subject: [PATCH] feat(core): Add internal API for test definitions (no-changelog) (#11591) Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> --- packages/cli/src/databases/entities/index.ts | 2 +- ...st-definition.ts => test-definition.ee.ts} | 10 +- .../test-definition.repository.ee.ts | 60 +++ .../src/evaluation/test-definition.schema.ts | 17 + .../evaluation/test-definition.service.ee.ts | 124 ++++++ .../test-definitions.controller.ee.ts | 138 +++++++ .../evaluation/test-definitions.types.ee.ts | 33 ++ packages/cli/src/generic-helpers.ts | 2 +- packages/cli/src/server.ts | 1 + .../evaluation/test-definitions.api.test.ts | 361 ++++++++++++++++++ .../cli/test/integration/shared/test-db.ts | 1 + packages/cli/test/integration/shared/types.ts | 3 +- .../integration/shared/utils/test-server.ts | 4 + 13 files changed, 750 insertions(+), 6 deletions(-) rename packages/cli/src/databases/entities/{test-definition.ts => test-definition.ee.ts} (86%) create mode 100644 packages/cli/src/databases/repositories/test-definition.repository.ee.ts create mode 100644 packages/cli/src/evaluation/test-definition.schema.ts create mode 100644 packages/cli/src/evaluation/test-definition.service.ee.ts create mode 100644 packages/cli/src/evaluation/test-definitions.controller.ee.ts create mode 100644 packages/cli/src/evaluation/test-definitions.types.ee.ts create mode 100644 packages/cli/test/integration/evaluation/test-definitions.api.test.ts diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index b73b348d8a..be73a0ed63 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -20,7 +20,7 @@ import { Settings } from './settings'; import { SharedCredentials } from './shared-credentials'; import { SharedWorkflow } from './shared-workflow'; import { TagEntity } from './tag-entity'; -import { TestDefinition } from './test-definition'; +import { TestDefinition } from './test-definition.ee'; import { User } from './user'; import { Variables } from './variables'; import { WebhookEntity } from './webhook-entity'; diff --git a/packages/cli/src/databases/entities/test-definition.ts b/packages/cli/src/databases/entities/test-definition.ee.ts similarity index 86% rename from packages/cli/src/databases/entities/test-definition.ts rename to packages/cli/src/databases/entities/test-definition.ee.ts index 5395bd0c4c..1ec08e9e66 100644 --- a/packages/cli/src/databases/entities/test-definition.ts +++ b/packages/cli/src/databases/entities/test-definition.ee.ts @@ -4,7 +4,6 @@ import { Generated, Index, ManyToOne, - OneToOne, PrimaryColumn, RelationId, } from '@n8n/typeorm'; @@ -31,7 +30,9 @@ export class TestDefinition extends WithTimestamps { id: number; @Column({ length: 255 }) - @Length(1, 255, { message: 'Test name must be $constraint1 to $constraint2 characters long.' }) + @Length(1, 255, { + message: 'Test definition name must be $constraint1 to $constraint2 characters long.', + }) name: string; /** @@ -56,6 +57,9 @@ export class TestDefinition extends WithTimestamps { * Relation to the annotation tag associated with the test * This tag will be used to select the test cases to run from previous executions */ - @OneToOne('AnnotationTagEntity', 'test') + @ManyToOne('AnnotationTagEntity', 'test') annotationTag: AnnotationTagEntity; + + @RelationId((test: TestDefinition) => test.annotationTag) + annotationTagId: string; } diff --git a/packages/cli/src/databases/repositories/test-definition.repository.ee.ts b/packages/cli/src/databases/repositories/test-definition.repository.ee.ts new file mode 100644 index 0000000000..e9ec3da65d --- /dev/null +++ b/packages/cli/src/databases/repositories/test-definition.repository.ee.ts @@ -0,0 +1,60 @@ +import type { FindManyOptions, FindOptionsWhere } from '@n8n/typeorm'; +import { DataSource, In, Repository } from '@n8n/typeorm'; +import { Service } from 'typedi'; + +import { TestDefinition } from '@/databases/entities/test-definition.ee'; +import type { ListQuery } from '@/requests'; + +@Service() +export class TestDefinitionRepository extends Repository { + constructor(dataSource: DataSource) { + super(TestDefinition, dataSource.manager); + } + + async getMany(accessibleWorkflowIds: string[], options?: ListQuery.Options) { + if (accessibleWorkflowIds.length === 0) return { tests: [], count: 0 }; + + const where: FindOptionsWhere = { + ...options?.filter, + workflow: { + id: In(accessibleWorkflowIds), + }, + }; + + const findManyOptions: FindManyOptions = { + where, + relations: ['annotationTag'], + order: { createdAt: 'DESC' }, + }; + + if (options?.take) { + findManyOptions.skip = options.skip; + findManyOptions.take = options.take; + } + + const [testDefinitions, count] = await this.findAndCount(findManyOptions); + + return { testDefinitions, count }; + } + + async getOne(id: number, accessibleWorkflowIds: string[]) { + return await this.findOne({ + where: { + id, + workflow: { + id: In(accessibleWorkflowIds), + }, + }, + relations: ['annotationTag'], + }); + } + + async deleteById(id: number, accessibleWorkflowIds: string[]) { + return await this.delete({ + id, + workflow: { + id: In(accessibleWorkflowIds), + }, + }); + } +} diff --git a/packages/cli/src/evaluation/test-definition.schema.ts b/packages/cli/src/evaluation/test-definition.schema.ts new file mode 100644 index 0000000000..ffb6d82ab5 --- /dev/null +++ b/packages/cli/src/evaluation/test-definition.schema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +export const testDefinitionCreateRequestBodySchema = z + .object({ + name: z.string().min(1).max(255), + workflowId: z.string().min(1), + evaluationWorkflowId: z.string().min(1).optional(), + }) + .strict(); + +export const testDefinitionPatchRequestBodySchema = z + .object({ + name: z.string().min(1).max(255).optional(), + evaluationWorkflowId: z.string().min(1).optional(), + annotationTagId: z.string().min(1).optional(), + }) + .strict(); diff --git a/packages/cli/src/evaluation/test-definition.service.ee.ts b/packages/cli/src/evaluation/test-definition.service.ee.ts new file mode 100644 index 0000000000..6431568363 --- /dev/null +++ b/packages/cli/src/evaluation/test-definition.service.ee.ts @@ -0,0 +1,124 @@ +import { Service } from 'typedi'; + +import type { TestDefinition } from '@/databases/entities/test-definition.ee'; +import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository.ee'; +import { TestDefinitionRepository } from '@/databases/repositories/test-definition.repository.ee'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { validateEntity } from '@/generic-helpers'; +import type { ListQuery } from '@/requests'; + +type TestDefinitionLike = Omit< + Partial, + 'workflow' | 'evaluationWorkflow' | 'annotationTag' +> & { + workflow?: { id: string }; + evaluationWorkflow?: { id: string }; + annotationTag?: { id: string }; +}; + +@Service() +export class TestDefinitionService { + constructor( + private testDefinitionRepository: TestDefinitionRepository, + private annotationTagRepository: AnnotationTagRepository, + ) {} + + private toEntityLike(attrs: { + name?: string; + workflowId?: string; + evaluationWorkflowId?: string; + annotationTagId?: string; + id?: number; + }) { + const entity: TestDefinitionLike = {}; + + if (attrs.id) { + entity.id = attrs.id; + } + + if (attrs.name) { + entity.name = attrs.name?.trim(); + } + + if (attrs.workflowId) { + entity.workflow = { + id: attrs.workflowId, + }; + } + + if (attrs.evaluationWorkflowId) { + entity.evaluationWorkflow = { + id: attrs.evaluationWorkflowId, + }; + } + + if (attrs.annotationTagId) { + entity.annotationTag = { + id: attrs.annotationTagId, + }; + } + + return entity; + } + + toEntity(attrs: { + name?: string; + workflowId?: string; + evaluationWorkflowId?: string; + annotationTagId?: string; + id?: number; + }) { + const entity = this.toEntityLike(attrs); + return this.testDefinitionRepository.create(entity); + } + + async findOne(id: number, accessibleWorkflowIds: string[]) { + return await this.testDefinitionRepository.getOne(id, accessibleWorkflowIds); + } + + async save(test: TestDefinition) { + await validateEntity(test); + + return await this.testDefinitionRepository.save(test); + } + + async update(id: number, attrs: TestDefinitionLike) { + if (attrs.name) { + const updatedTest = this.toEntity(attrs); + await validateEntity(updatedTest); + } + + // Check if the annotation tag exists + if (attrs.annotationTagId) { + const annotationTagExists = await this.annotationTagRepository.exists({ + where: { + id: attrs.annotationTagId, + }, + }); + + if (!annotationTagExists) { + throw new BadRequestError('Annotation tag not found'); + } + } + + // Update the test definition + const queryResult = await this.testDefinitionRepository.update(id, this.toEntityLike(attrs)); + + if (queryResult.affected === 0) { + throw new NotFoundError('Test definition not found'); + } + } + + async delete(id: number, accessibleWorkflowIds: string[]) { + const deleteResult = await this.testDefinitionRepository.deleteById(id, accessibleWorkflowIds); + + if (deleteResult.affected === 0) { + throw new NotFoundError('Test definition not found'); + } + } + + async getMany(options: ListQuery.Options, accessibleWorkflowIds: string[] = []) { + return await this.testDefinitionRepository.getMany(accessibleWorkflowIds, options); + } +} diff --git a/packages/cli/src/evaluation/test-definitions.controller.ee.ts b/packages/cli/src/evaluation/test-definitions.controller.ee.ts new file mode 100644 index 0000000000..c73afaeb3d --- /dev/null +++ b/packages/cli/src/evaluation/test-definitions.controller.ee.ts @@ -0,0 +1,138 @@ +import express from 'express'; +import assert from 'node:assert'; + +import { Get, Post, Patch, RestController, Delete } from '@/decorators'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { + testDefinitionCreateRequestBodySchema, + testDefinitionPatchRequestBodySchema, +} from '@/evaluation/test-definition.schema'; +import { listQueryMiddleware } from '@/middlewares'; +import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service'; +import { isPositiveInteger } from '@/utils'; + +import { TestDefinitionService } from './test-definition.service.ee'; +import { TestDefinitionsRequest } from './test-definitions.types.ee'; + +@RestController('/evaluation/test-definitions') +export class TestDefinitionsController { + private validateId(id: string) { + if (!isPositiveInteger(id)) { + throw new BadRequestError('Test ID is not a number'); + } + + return Number(id); + } + + constructor(private readonly testDefinitionService: TestDefinitionService) {} + + @Get('/', { middlewares: listQueryMiddleware }) + async getMany(req: TestDefinitionsRequest.GetMany) { + const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']); + + return await this.testDefinitionService.getMany( + req.listQueryOptions, + userAccessibleWorkflowIds, + ); + } + + @Get('/:id') + async getOne(req: TestDefinitionsRequest.GetOne) { + const testDefinitionId = this.validateId(req.params.id); + + const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']); + + const testDefinition = await this.testDefinitionService.findOne( + testDefinitionId, + userAccessibleWorkflowIds, + ); + + if (!testDefinition) throw new NotFoundError('Test definition not found'); + + return testDefinition; + } + + @Post('/') + async create(req: TestDefinitionsRequest.Create, res: express.Response) { + const bodyParseResult = testDefinitionCreateRequestBodySchema.safeParse(req.body); + if (!bodyParseResult.success) { + res.status(400).json({ errors: bodyParseResult.error.errors }); + return; + } + + const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']); + + if (!userAccessibleWorkflowIds.includes(req.body.workflowId)) { + throw new ForbiddenError('User does not have access to the workflow'); + } + + if ( + req.body.evaluationWorkflowId && + !userAccessibleWorkflowIds.includes(req.body.evaluationWorkflowId) + ) { + throw new ForbiddenError('User does not have access to the evaluation workflow'); + } + + return await this.testDefinitionService.save( + this.testDefinitionService.toEntity(bodyParseResult.data), + ); + } + + @Delete('/:id') + async delete(req: TestDefinitionsRequest.Delete) { + const testDefinitionId = this.validateId(req.params.id); + + const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']); + + if (userAccessibleWorkflowIds.length === 0) + throw new ForbiddenError('User does not have access to any workflows'); + + await this.testDefinitionService.delete(testDefinitionId, userAccessibleWorkflowIds); + + return { success: true }; + } + + @Patch('/:id') + async patch(req: TestDefinitionsRequest.Patch, res: express.Response) { + const testDefinitionId = this.validateId(req.params.id); + + const bodyParseResult = testDefinitionPatchRequestBodySchema.safeParse(req.body); + if (!bodyParseResult.success) { + res.status(400).json({ errors: bodyParseResult.error.errors }); + return; + } + + const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']); + + // Fail fast if no workflows are accessible + if (userAccessibleWorkflowIds.length === 0) + throw new ForbiddenError('User does not have access to any workflows'); + + const existingTest = await this.testDefinitionService.findOne( + testDefinitionId, + userAccessibleWorkflowIds, + ); + if (!existingTest) throw new NotFoundError('Test definition not found'); + + if ( + req.body.evaluationWorkflowId && + !userAccessibleWorkflowIds.includes(req.body.evaluationWorkflowId) + ) { + throw new ForbiddenError('User does not have access to the evaluation workflow'); + } + + await this.testDefinitionService.update(testDefinitionId, req.body); + + // Respond with the updated test definition + const testDefinition = await this.testDefinitionService.findOne( + testDefinitionId, + userAccessibleWorkflowIds, + ); + + assert(testDefinition, 'Test definition not found'); + + return testDefinition; + } +} diff --git a/packages/cli/src/evaluation/test-definitions.types.ee.ts b/packages/cli/src/evaluation/test-definitions.types.ee.ts new file mode 100644 index 0000000000..2814e6bb7f --- /dev/null +++ b/packages/cli/src/evaluation/test-definitions.types.ee.ts @@ -0,0 +1,33 @@ +import type { AuthenticatedRequest, ListQuery } from '@/requests'; + +// ---------------------------------- +// /test-definitions +// ---------------------------------- + +export declare namespace TestDefinitionsRequest { + namespace RouteParams { + type TestId = { + id: string; + }; + } + + type GetOne = AuthenticatedRequest; + + type GetMany = AuthenticatedRequest<{}, {}, {}, ListQuery.Params & { includeScopes?: string }> & { + listQueryOptions: ListQuery.Options; + }; + + type Create = AuthenticatedRequest< + {}, + {}, + { name: string; workflowId: string; evaluationWorkflowId?: string } + >; + + type Patch = AuthenticatedRequest< + RouteParams.TestId, + {}, + { name?: string; evaluationWorkflowId?: string; annotationTagId?: string } + >; + + type Delete = AuthenticatedRequest; +} diff --git a/packages/cli/src/generic-helpers.ts b/packages/cli/src/generic-helpers.ts index 378619a4e9..89ed1369ef 100644 --- a/packages/cli/src/generic-helpers.ts +++ b/packages/cli/src/generic-helpers.ts @@ -3,7 +3,7 @@ import { validate } from 'class-validator'; import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; import type { TagEntity } from '@/databases/entities/tag-entity'; -import type { TestDefinition } from '@/databases/entities/test-definition'; +import type { TestDefinition } from '@/databases/entities/test-definition.ee'; import type { User } from '@/databases/entities/user'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 3cfd93054b..e0572ab215 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -63,6 +63,7 @@ import '@/events/events.controller'; import '@/executions/executions.controller'; import '@/external-secrets/external-secrets.controller.ee'; import '@/license/license.controller'; +import '@/evaluation/test-definitions.controller.ee'; import '@/workflows/workflow-history/workflow-history.controller.ee'; import '@/workflows/workflows.controller'; diff --git a/packages/cli/test/integration/evaluation/test-definitions.api.test.ts b/packages/cli/test/integration/evaluation/test-definitions.api.test.ts new file mode 100644 index 0000000000..5d75b21ffd --- /dev/null +++ b/packages/cli/test/integration/evaluation/test-definitions.api.test.ts @@ -0,0 +1,361 @@ +import { Container } from 'typedi'; + +import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee'; +import type { User } from '@/databases/entities/user'; +import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; +import { TestDefinitionRepository } from '@/databases/repositories/test-definition.repository.ee'; +import { createAnnotationTags } from '@test-integration/db/executions'; + +import { createUserShell } from './../shared/db/users'; +import { createWorkflow } from './../shared/db/workflows'; +import * as testDb from './../shared/test-db'; +import type { SuperAgentTest } from './../shared/types'; +import * as utils from './../shared/utils/'; + +let authOwnerAgent: SuperAgentTest; +let workflowUnderTest: WorkflowEntity; +let evaluationWorkflow: WorkflowEntity; +let otherWorkflow: WorkflowEntity; +let ownerShell: User; +let annotationTag: AnnotationTagEntity; +const testServer = utils.setupTestServer({ endpointGroups: ['evaluation'] }); + +beforeAll(async () => { + ownerShell = await createUserShell('global:owner'); + authOwnerAgent = testServer.authAgentFor(ownerShell); +}); + +beforeEach(async () => { + await testDb.truncate(['TestDefinition', 'Workflow', 'AnnotationTag']); + + workflowUnderTest = await createWorkflow({ name: 'workflow-under-test' }, ownerShell); + evaluationWorkflow = await createWorkflow({ name: 'evaluation-workflow' }, ownerShell); + otherWorkflow = await createWorkflow({ name: 'other-workflow' }); + annotationTag = (await createAnnotationTags(['test-tag']))[0]; +}); + +describe('GET /evaluation/test-definitions', () => { + test('should retrieve empty test definitions list', async () => { + const resp = await authOwnerAgent.get('/evaluation/test-definitions'); + expect(resp.statusCode).toBe(200); + expect(resp.body.data.count).toBe(0); + expect(resp.body.data.testDefinitions).toHaveLength(0); + }); + + test('should retrieve test definitions list', async () => { + const newTest = Container.get(TestDefinitionRepository).create({ + name: 'test', + workflow: { id: workflowUnderTest.id }, + }); + await Container.get(TestDefinitionRepository).save(newTest); + + const resp = await authOwnerAgent.get('/evaluation/test-definitions'); + + expect(resp.statusCode).toBe(200); + expect(resp.body.data).toEqual({ + count: 1, + testDefinitions: [ + expect.objectContaining({ + name: 'test', + workflowId: workflowUnderTest.id, + evaluationWorkflowId: null, + }), + ], + }); + }); + + test('should retrieve test definitions list with pagination', async () => { + // Add a bunch of test definitions + const testDefinitions = []; + + for (let i = 0; i < 15; i++) { + const newTest = Container.get(TestDefinitionRepository).create({ + name: `test-${i}`, + workflow: { id: workflowUnderTest.id }, + }); + testDefinitions.push(newTest); + } + + await Container.get(TestDefinitionRepository).save(testDefinitions); + + // Fetch the first page + let resp = await authOwnerAgent.get('/evaluation/test-definitions?take=10'); + expect(resp.statusCode).toBe(200); + expect(resp.body.data.count).toBe(15); + expect(resp.body.data.testDefinitions).toHaveLength(10); + + // Fetch the second page + resp = await authOwnerAgent.get('/evaluation/test-definitions?take=10&skip=10'); + expect(resp.statusCode).toBe(200); + expect(resp.body.data.count).toBe(15); + expect(resp.body.data.testDefinitions).toHaveLength(5); + }); +}); + +describe('GET /evaluation/test-definitions/:id', () => { + test('should retrieve test definition', async () => { + const newTest = Container.get(TestDefinitionRepository).create({ + name: 'test', + workflow: { id: workflowUnderTest.id }, + }); + await Container.get(TestDefinitionRepository).save(newTest); + + const resp = await authOwnerAgent.get(`/evaluation/test-definitions/${newTest.id}`); + + expect(resp.statusCode).toBe(200); + expect(resp.body.data.name).toBe('test'); + expect(resp.body.data.workflowId).toBe(workflowUnderTest.id); + expect(resp.body.data.evaluationWorkflowId).toBe(null); + }); + + test('should return 404 for non-existent test definition', async () => { + const resp = await authOwnerAgent.get('/evaluation/test-definitions/123'); + + expect(resp.statusCode).toBe(404); + }); + + test('should retrieve test definition with evaluation workflow', async () => { + const newTest = Container.get(TestDefinitionRepository).create({ + name: 'test', + workflow: { id: workflowUnderTest.id }, + evaluationWorkflow: { id: evaluationWorkflow.id }, + }); + await Container.get(TestDefinitionRepository).save(newTest); + + const resp = await authOwnerAgent.get(`/evaluation/test-definitions/${newTest.id}`); + + expect(resp.statusCode).toBe(200); + expect(resp.body.data.name).toBe('test'); + expect(resp.body.data.workflowId).toBe(workflowUnderTest.id); + expect(resp.body.data.evaluationWorkflowId).toBe(evaluationWorkflow.id); + }); + + test('should not retrieve test definition if user does not have access to workflow under test', async () => { + const newTest = Container.get(TestDefinitionRepository).create({ + name: 'test', + workflow: { id: otherWorkflow.id }, + }); + await Container.get(TestDefinitionRepository).save(newTest); + + const resp = await authOwnerAgent.get(`/evaluation/test-definitions/${newTest.id}`); + + expect(resp.statusCode).toBe(404); + }); +}); + +describe('POST /evaluation/test-definitions', () => { + test('should create test definition', async () => { + const resp = await authOwnerAgent.post('/evaluation/test-definitions').send({ + name: 'test', + workflowId: workflowUnderTest.id, + }); + + expect(resp.statusCode).toBe(200); + expect(resp.body.data.name).toBe('test'); + expect(resp.body.data.workflowId).toBe(workflowUnderTest.id); + }); + + test('should create test definition with evaluation workflow', async () => { + const resp = await authOwnerAgent.post('/evaluation/test-definitions').send({ + name: 'test', + workflowId: workflowUnderTest.id, + evaluationWorkflowId: evaluationWorkflow.id, + }); + + expect(resp.statusCode).toBe(200); + expect(resp.body.data.name).toBe('test'); + expect(resp.body.data.workflowId).toBe(workflowUnderTest.id); + expect(resp.body.data.evaluationWorkflowId).toBe(evaluationWorkflow.id); + }); + + test('should return error if name is empty', async () => { + const resp = await authOwnerAgent.post('/evaluation/test-definitions').send({ + name: '', + workflowId: workflowUnderTest.id, + }); + + expect(resp.statusCode).toBe(400); + expect(resp.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'too_small', + path: ['name'], + }), + ]), + ); + }); + + test('should return error if user has no access to the workflow', async () => { + const resp = await authOwnerAgent.post('/evaluation/test-definitions').send({ + name: 'test', + workflowId: otherWorkflow.id, + }); + + expect(resp.statusCode).toBe(403); + expect(resp.body.message).toBe('User does not have access to the workflow'); + }); + + test('should return error if user has no access to the evaluation workflow', async () => { + const resp = await authOwnerAgent.post('/evaluation/test-definitions').send({ + name: 'test', + workflowId: workflowUnderTest.id, + evaluationWorkflowId: otherWorkflow.id, + }); + + expect(resp.statusCode).toBe(403); + expect(resp.body.message).toBe('User does not have access to the evaluation workflow'); + }); +}); + +describe('PATCH /evaluation/test-definitions/:id', () => { + test('should update test definition', async () => { + const newTest = Container.get(TestDefinitionRepository).create({ + name: 'test', + workflow: { id: workflowUnderTest.id }, + }); + await Container.get(TestDefinitionRepository).save(newTest); + + const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({ + name: 'updated-test', + }); + + expect(resp.statusCode).toBe(200); + expect(resp.body.data.name).toBe('updated-test'); + }); + + test('should return 404 if user has no access to the workflow', async () => { + const newTest = Container.get(TestDefinitionRepository).create({ + name: 'test', + workflow: { id: otherWorkflow.id }, + }); + await Container.get(TestDefinitionRepository).save(newTest); + + const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({ + name: 'updated-test', + }); + + expect(resp.statusCode).toBe(404); + expect(resp.body.message).toBe('Test definition not found'); + }); + + test('should update test definition with evaluation workflow', async () => { + const newTest = Container.get(TestDefinitionRepository).create({ + name: 'test', + workflow: { id: workflowUnderTest.id }, + }); + await Container.get(TestDefinitionRepository).save(newTest); + + const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({ + name: 'updated-test', + evaluationWorkflowId: evaluationWorkflow.id, + }); + + expect(resp.statusCode).toBe(200); + expect(resp.body.data.name).toBe('updated-test'); + expect(resp.body.data.evaluationWorkflowId).toBe(evaluationWorkflow.id); + }); + + test('should return error if user has no access to the evaluation workflow', async () => { + const newTest = Container.get(TestDefinitionRepository).create({ + name: 'test', + workflow: { id: workflowUnderTest.id }, + }); + await Container.get(TestDefinitionRepository).save(newTest); + + const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({ + name: 'updated-test', + evaluationWorkflowId: otherWorkflow.id, + }); + + expect(resp.statusCode).toBe(403); + expect(resp.body.message).toBe('User does not have access to the evaluation workflow'); + }); + + test('should disallow workflowId', async () => { + const newTest = Container.get(TestDefinitionRepository).create({ + name: 'test', + workflow: { id: workflowUnderTest.id }, + }); + await Container.get(TestDefinitionRepository).save(newTest); + + const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({ + name: 'updated-test', + workflowId: otherWorkflow.id, + }); + + expect(resp.statusCode).toBe(400); + expect(resp.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'unrecognized_keys', + keys: ['workflowId'], + }), + ]), + ); + }); + + test('should update annotationTagId', async () => { + const newTest = Container.get(TestDefinitionRepository).create({ + name: 'test', + workflow: { id: workflowUnderTest.id }, + }); + await Container.get(TestDefinitionRepository).save(newTest); + + const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({ + annotationTagId: annotationTag.id, + }); + + expect(resp.statusCode).toBe(200); + expect(resp.body.data.annotationTag.id).toBe(annotationTag.id); + }); + + test('should return error if annotationTagId is invalid', async () => { + const newTest = Container.get(TestDefinitionRepository).create({ + name: 'test', + workflow: { id: workflowUnderTest.id }, + }); + await Container.get(TestDefinitionRepository).save(newTest); + + const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({ + annotationTagId: '123', + }); + + expect(resp.statusCode).toBe(400); + expect(resp.body.message).toBe('Annotation tag not found'); + }); +}); + +describe('DELETE /evaluation/test-definitions/:id', () => { + test('should delete test definition', async () => { + const newTest = Container.get(TestDefinitionRepository).create({ + name: 'test', + workflow: { id: workflowUnderTest.id }, + }); + await Container.get(TestDefinitionRepository).save(newTest); + + const resp = await authOwnerAgent.delete(`/evaluation/test-definitions/${newTest.id}`); + + expect(resp.statusCode).toBe(200); + expect(resp.body.data.success).toBe(true); + }); + + test('should return 404 if test definition does not exist', async () => { + const resp = await authOwnerAgent.delete('/evaluation/test-definitions/123'); + + expect(resp.statusCode).toBe(404); + expect(resp.body.message).toBe('Test definition not found'); + }); + + test('should return 404 if user has no access to the workflow', async () => { + const newTest = Container.get(TestDefinitionRepository).create({ + name: 'test', + workflow: { id: otherWorkflow.id }, + }); + await Container.get(TestDefinitionRepository).save(newTest); + + const resp = await authOwnerAgent.delete(`/evaluation/test-definitions/${newTest.id}`); + + expect(resp.statusCode).toBe(404); + expect(resp.body.message).toBe('Test definition not found'); + }); +}); diff --git a/packages/cli/test/integration/shared/test-db.ts b/packages/cli/test/integration/shared/test-db.ts index 7faaa3f6eb..e85825115e 100644 --- a/packages/cli/test/integration/shared/test-db.ts +++ b/packages/cli/test/integration/shared/test-db.ts @@ -74,6 +74,7 @@ const repositories = [ 'SharedCredentials', 'SharedWorkflow', 'Tag', + 'TestDefinition', 'User', 'Variables', 'Webhook', diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 8dc922dda2..2afe6ec328 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -41,7 +41,8 @@ type EndpointGroup = | 'project' | 'role' | 'dynamic-node-parameters' - | 'apiKeys'; + | 'apiKeys' + | 'evaluation'; export interface SetupProps { endpointGroups?: EndpointGroup[]; diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index b69f21499a..b1e7d8a93c 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -277,6 +277,10 @@ export const setupTestServer = ({ case 'apiKeys': await import('@/controllers/api-keys.controller'); break; + + case 'evaluation': + await import('@/evaluation/test-definitions.controller.ee'); + break; } }