Skip to content

Commit

Permalink
feat(core): Add internal API for test definitions (no-changelog) (#11591
Browse files Browse the repository at this point in the history
)

Co-authored-by: Tomi Turtiainen <[email protected]>
  • Loading branch information
burivuhster and tomi authored Nov 12, 2024
1 parent c08d23c commit e875bc5
Show file tree
Hide file tree
Showing 13 changed files with 750 additions and 6 deletions.
2 changes: 1 addition & 1 deletion packages/cli/src/databases/entities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
Generated,
Index,
ManyToOne,
OneToOne,
PrimaryColumn,
RelationId,
} from '@n8n/typeorm';
Expand All @@ -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;

/**
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<TestDefinition> {
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<TestDefinition> = {
...options?.filter,
workflow: {
id: In(accessibleWorkflowIds),
},
};

const findManyOptions: FindManyOptions<TestDefinition> = {
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),
},
});
}
}
17 changes: 17 additions & 0 deletions packages/cli/src/evaluation/test-definition.schema.ts
Original file line number Diff line number Diff line change
@@ -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();
124 changes: 124 additions & 0 deletions packages/cli/src/evaluation/test-definition.service.ee.ts
Original file line number Diff line number Diff line change
@@ -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<TestDefinition>,
'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);
}
}
138 changes: 138 additions & 0 deletions packages/cli/src/evaluation/test-definitions.controller.ee.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading

0 comments on commit e875bc5

Please sign in to comment.