feat(API): Add cancel status filters to the public api executions endpoint (#19136)

This commit is contained in:
Irénée
2025-09-04 08:35:25 +01:00
committed by GitHub
parent a794ab6be3
commit 4a21f79f5c
6 changed files with 288 additions and 123 deletions

View File

@@ -32,7 +32,7 @@ export class ExecutionEntity {
id: string;
/**
* Whether the execution finished sucessfully.
* Whether the execution finished successfully.
*
* @deprecated Use `status` instead
*/

View File

@@ -0,0 +1,233 @@
import { Container, type Constructable } from '@n8n/di';
import { DataSource, EntityManager, In, LessThan, type EntityMetadata } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended';
import type { Class } from 'n8n-core';
import type { DeepPartial } from 'ts-essentials';
import { ExecutionEntity } from '../../entities';
import { ExecutionRepository } from '../execution.repository';
const mockInstance = <T>(
serviceClass: Constructable<T>,
data: DeepPartial<T> | undefined = undefined,
) => {
const instance = mock<T>(data);
Container.set(serviceClass, instance);
return instance;
};
const mockEntityManager = (entityClass: Class) => {
const entityManager = mockInstance(EntityManager);
const dataSource = mockInstance(DataSource, {
manager: entityManager,
getMetadata: () => mock<EntityMetadata>({ target: entityClass }),
});
Object.assign(entityManager, { connection: dataSource });
return entityManager;
};
describe('ExecutionRepository', () => {
const entityManager = mockEntityManager(ExecutionEntity);
const executionRepository = Container.get(ExecutionRepository);
beforeEach(() => {
jest.resetAllMocks();
});
describe('getExecutionsForPublicApi', () => {
test('should get executions matching the filter parameters', async () => {
const limit = 10;
const params = {
limit: 10,
lastId: '3',
workflowIds: ['3', '4'],
};
const mockEntities = [{ id: '1' }, { id: '2' }];
entityManager.find.mockResolvedValueOnce(mockEntities);
const result = await executionRepository.getExecutionsForPublicApi(params);
expect(entityManager.find).toHaveBeenCalledWith(ExecutionEntity, {
select: [
'id',
'mode',
'retryOf',
'retrySuccessId',
'startedAt',
'stoppedAt',
'workflowId',
'waitTill',
'finished',
'status',
],
where: {
id: LessThan(params.lastId),
workflowId: In(params.workflowIds),
},
order: { id: 'DESC' },
take: limit,
relations: ['executionData'],
});
expect(result.length).toBe(mockEntities.length);
expect(result[0].id).toEqual(mockEntities[0].id);
});
describe('with status filter', () => {
test.each`
filterStatus | entityStatus
${'canceled'} | ${'canceled'}
${'error'} | ${In(['error', 'crashed'])}
${'success'} | ${'success'}
${'waiting'} | ${'waiting'}
`('should find all "$filterStatus" executions', async ({ filterStatus, entityStatus }) => {
const limit = 10;
const mockEntities = [{ id: '1' }, { id: '2' }];
entityManager.find.mockResolvedValueOnce(mockEntities);
const result = await executionRepository.getExecutionsForPublicApi({
limit,
status: filterStatus,
});
expect(entityManager.find).toHaveBeenCalledWith(ExecutionEntity, {
select: [
'id',
'mode',
'retryOf',
'retrySuccessId',
'startedAt',
'stoppedAt',
'workflowId',
'waitTill',
'finished',
'status',
],
where: { status: entityStatus },
order: { id: 'DESC' },
take: limit,
relations: ['executionData'],
});
expect(result.length).toBe(mockEntities.length);
expect(result[0].id).toEqual(mockEntities[0].id);
});
test.each`
filterStatus
${'crashed'}
${'new'}
${'running'}
${'unknown'}
`(
'should find all executions and ignore status filter "$filterStatus"',
async ({ filterStatus }) => {
const limit = 10;
const mockEntities = [{ id: '1' }, { id: '2' }];
entityManager.find.mockResolvedValueOnce(mockEntities);
const result = await executionRepository.getExecutionsForPublicApi({
limit,
status: filterStatus,
});
expect(entityManager.find).toHaveBeenCalledWith(ExecutionEntity, {
select: [
'id',
'mode',
'retryOf',
'retrySuccessId',
'startedAt',
'stoppedAt',
'workflowId',
'waitTill',
'finished',
'status',
],
where: {},
order: { id: 'DESC' },
take: limit,
relations: ['executionData'],
});
expect(result.length).toBe(mockEntities.length);
expect(result[0].id).toEqual(mockEntities[0].id);
},
);
});
});
describe('getExecutionsCountForPublicApi', () => {
test('should get executions matching the filter parameters', async () => {
const limit = 10;
const mockCount = 20;
const params = {
limit: 10,
lastId: '3',
workflowIds: ['3', '4'],
};
entityManager.count.mockResolvedValueOnce(mockCount);
const result = await executionRepository.getExecutionsCountForPublicApi(params);
expect(entityManager.count).toHaveBeenCalledWith(ExecutionEntity, {
where: {
id: LessThan(params.lastId),
workflowId: In(params.workflowIds),
},
take: limit,
});
expect(result).toBe(mockCount);
});
describe('with status filter', () => {
test.each`
filterStatus | entityStatus
${'canceled'} | ${'canceled'}
${'error'} | ${In(['error', 'crashed'])}
${'success'} | ${'success'}
${'waiting'} | ${'waiting'}
`('should retrieve all $filterStatus executions', async ({ filterStatus, entityStatus }) => {
const limit = 10;
const mockCount = 20;
entityManager.count.mockResolvedValueOnce(mockCount);
const result = await executionRepository.getExecutionsCountForPublicApi({
limit,
status: filterStatus,
});
expect(entityManager.count).toHaveBeenCalledWith(ExecutionEntity, {
where: { status: entityStatus },
take: limit,
});
expect(result).toBe(mockCount);
});
test.each`
filterStatus
${'crashed'}
${'new'}
${'running'}
${'unknown'}
`(
'should find all executions and ignore status filter "$filterStatus"',
async ({ filterStatus }) => {
const limit = 10;
const mockCount = 20;
entityManager.count.mockResolvedValueOnce(mockCount);
const result = await executionRepository.getExecutionsCountForPublicApi({
limit,
status: filterStatus,
});
expect(entityManager.count).toHaveBeenCalledWith(ExecutionEntity, {
where: {},
take: limit,
});
expect(result).toBe(mockCount);
},
);
});
});
});

View File

@@ -638,7 +638,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
status?: ExecutionStatus;
excludedWorkflowIds?: string[];
}): Promise<number> {
const executions = await this.count({
const executionsCount = await this.count({
where: {
...(data.lastId && { id: LessThan(data.lastId) }),
...(data.status && { ...this.getStatusCondition(data.status) }),
@@ -648,7 +648,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
take: data.limit,
});
return executions;
return executionsCount;
}
private getStatusCondition(status: ExecutionStatus) {
@@ -660,6 +660,8 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
condition.status = 'waiting';
} else if (status === 'error') {
condition.status = In(['error', 'crashed']);
} else if (status === 'canceled') {
condition.status = 'canceled';
}
return condition;

View File

@@ -13,7 +13,7 @@ get:
required: false
schema:
type: string
enum: ['error', 'success', 'waiting']
enum: ['canceled', 'error', 'success', 'waiting']
- name: workflowId
in: query
description: Workflow to filter the executions by.

View File

@@ -1,22 +1,23 @@
import {
createTeamProject,
createManyWorkflows,
createTeamProject,
createWorkflow,
mockInstance,
shareWorkflowWithUsers,
testDb,
mockInstance,
} from '@n8n/backend-test-utils';
import type { User, ExecutionEntity } from '@n8n/db';
import type { ExecutionEntity, User } from '@n8n/db';
import type { ExecutionStatus } from 'n8n-workflow';
import type { ActiveWorkflowManager } from '@/active-workflow-manager';
import { Telemetry } from '@/telemetry';
import {
createdExecutionWithStatus,
createErrorExecution,
createExecution,
createManyExecutions,
createSuccessfulExecution,
createWaitingExecution,
} from '../shared/db/executions';
import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types';
@@ -238,46 +239,6 @@ describe('GET /executions', () => {
test('should fail due to invalid API Key', testWithAPIKey('get', '/executions', 'abcXYZ'));
test('should retrieve all successful executions', async () => {
const workflow = await createWorkflow({}, owner);
const successfulExecution = await createSuccessfulExecution(workflow);
await createErrorExecution(workflow);
const response = await authOwnerAgent.get('/executions').query({
status: 'success',
});
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(1);
expect(response.body.nextCursor).toBe(null);
const {
id,
finished,
mode,
retryOf,
retrySuccessId,
startedAt,
stoppedAt,
workflowId,
waitTill,
status,
} = response.body.data[0];
expect(id).toBeDefined();
expect(finished).toBe(true);
expect(mode).toEqual(successfulExecution.mode);
expect(retrySuccessId).toBeNull();
expect(retryOf).toBeNull();
expect(startedAt).not.toBeNull();
expect(stoppedAt).not.toBeNull();
expect(workflowId).toBe(successfulExecution.workflowId);
expect(waitTill).toBeNull();
expect(status).toBe(successfulExecution.status);
});
test('should paginate two executions', async () => {
const workflow = await createWorkflow({}, owner);
@@ -335,86 +296,41 @@ describe('GET /executions', () => {
}
});
test('should retrieve all error executions', async () => {
const workflow = await createWorkflow({}, owner);
describe('with query status', () => {
type AllowedQueryStatus = 'success' | 'error' | 'canceled' | 'waiting';
test.each`
queryStatus | entityStatus
${'canceled'} | ${'canceled'}
${'error'} | ${'error'}
${'error'} | ${'crashed'}
${'success'} | ${'success'}
${'waiting'} | ${'waiting'}
`(
'should retrieve all $queryStatus executions',
async ({
queryStatus,
entityStatus,
}: { queryStatus: AllowedQueryStatus; entityStatus: ExecutionStatus }) => {
const workflow = await createWorkflow({}, owner);
await createSuccessfulExecution(workflow);
await createdExecutionWithStatus(workflow, queryStatus === 'success' ? 'error' : 'success');
const errorExecution = await createErrorExecution(workflow);
const expectedExecution = await createdExecutionWithStatus(workflow, entityStatus);
const response = await authOwnerAgent.get('/executions').query({
status: 'error',
});
const response = await authOwnerAgent.get('/executions').query({
status: queryStatus,
});
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(1);
expect(response.body.nextCursor).toBe(null);
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(1);
expect(response.body.nextCursor).toBe(null);
const {
id,
finished,
mode,
retryOf,
retrySuccessId,
startedAt,
stoppedAt,
workflowId,
waitTill,
status,
} = response.body.data[0];
const { id, status } = response.body.data[0];
expect(id).toBeDefined();
expect(finished).toBe(false);
expect(mode).toEqual(errorExecution.mode);
expect(retrySuccessId).toBeNull();
expect(retryOf).toBeNull();
expect(startedAt).not.toBeNull();
expect(stoppedAt).not.toBeNull();
expect(workflowId).toBe(errorExecution.workflowId);
expect(waitTill).toBeNull();
expect(status).toBe(errorExecution.status);
});
test('should return all waiting executions', async () => {
const workflow = await createWorkflow({}, owner);
await createSuccessfulExecution(workflow);
await createErrorExecution(workflow);
const waitingExecution = await createWaitingExecution(workflow);
const response = await authOwnerAgent.get('/executions').query({
status: 'waiting',
});
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(1);
expect(response.body.nextCursor).toBe(null);
const {
id,
finished,
mode,
retryOf,
retrySuccessId,
startedAt,
stoppedAt,
workflowId,
waitTill,
status,
} = response.body.data[0];
expect(id).toBeDefined();
expect(finished).toBe(false);
expect(mode).toEqual(waitingExecution.mode);
expect(retrySuccessId).toBeNull();
expect(retryOf).toBeNull();
expect(startedAt).not.toBeNull();
expect(stoppedAt).not.toBeNull();
expect(workflowId).toBe(waitingExecution.workflowId);
expect(new Date(waitTill).getTime()).toBeGreaterThan(Date.now() - 1000);
expect(status).toBe(waitingExecution.status);
expect(id).toBeDefined();
expect(status).toBe(expectedExecution.status);
},
);
});
test('should retrieve all executions of specific workflow', async () => {

View File

@@ -7,7 +7,7 @@ import {
AnnotationTagRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import type { AnnotationVote, IWorkflowBase } from 'n8n-workflow';
import type { AnnotationVote, ExecutionStatus, IWorkflowBase } from 'n8n-workflow';
import { ExecutionService } from '@/executions/execution.service';
import { Telemetry } from '@/telemetry';
@@ -104,6 +104,20 @@ export async function createWaitingExecution(workflow: IWorkflowBase) {
);
}
/**
* Store an execution with a given status in the DB and assign it to a workflow.
*/
export async function createdExecutionWithStatus(workflow: IWorkflowBase, status: ExecutionStatus) {
const execution: Partial<ExecutionEntity> = {
status,
finished: status === 'success' ? true : false,
stoppedAt: ['crashed', 'error'].includes(status) ? new Date() : undefined,
waitTill: status === 'waiting' ? new Date() : undefined,
};
return await createExecution(execution, workflow);
}
export async function annotateExecution(
executionId: string,
annotation: { vote?: AnnotationVote | null; tags?: string[] },