From 4a21f79f5cfa4e5fb8a0edff091384d8b8b595f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ir=C3=A9n=C3=A9e?= Date: Thu, 4 Sep 2025 08:35:25 +0100 Subject: [PATCH] feat(API): Add cancel status filters to the public api executions endpoint (#19136) --- .../@n8n/db/src/entities/execution-entity.ts | 2 +- .../__tests__/execution.repository.test.ts | 233 ++++++++++++++++++ .../src/repositories/execution.repository.ts | 6 +- .../executions/spec/paths/executions.yml | 2 +- .../integration/public-api/executions.test.ts | 152 +++--------- .../test/integration/shared/db/executions.ts | 16 +- 6 files changed, 288 insertions(+), 123 deletions(-) create mode 100644 packages/@n8n/db/src/repositories/__tests__/execution.repository.test.ts diff --git a/packages/@n8n/db/src/entities/execution-entity.ts b/packages/@n8n/db/src/entities/execution-entity.ts index 6d7efc0f3f..d3e37c99cd 100644 --- a/packages/@n8n/db/src/entities/execution-entity.ts +++ b/packages/@n8n/db/src/entities/execution-entity.ts @@ -32,7 +32,7 @@ export class ExecutionEntity { id: string; /** - * Whether the execution finished sucessfully. + * Whether the execution finished successfully. * * @deprecated Use `status` instead */ diff --git a/packages/@n8n/db/src/repositories/__tests__/execution.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/execution.repository.test.ts new file mode 100644 index 0000000000..4b62896981 --- /dev/null +++ b/packages/@n8n/db/src/repositories/__tests__/execution.repository.test.ts @@ -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 = ( + serviceClass: Constructable, + data: DeepPartial | undefined = undefined, +) => { + const instance = mock(data); + Container.set(serviceClass, instance); + return instance; +}; + +const mockEntityManager = (entityClass: Class) => { + const entityManager = mockInstance(EntityManager); + const dataSource = mockInstance(DataSource, { + manager: entityManager, + getMetadata: () => mock({ 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); + }, + ); + }); + }); +}); diff --git a/packages/@n8n/db/src/repositories/execution.repository.ts b/packages/@n8n/db/src/repositories/execution.repository.ts index 3d0e9c77a3..ad75c43c58 100644 --- a/packages/@n8n/db/src/repositories/execution.repository.ts +++ b/packages/@n8n/db/src/repositories/execution.repository.ts @@ -638,7 +638,7 @@ export class ExecutionRepository extends Repository { status?: ExecutionStatus; excludedWorkflowIds?: string[]; }): Promise { - 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 { take: data.limit, }); - return executions; + return executionsCount; } private getStatusCondition(status: ExecutionStatus) { @@ -660,6 +660,8 @@ export class ExecutionRepository extends Repository { condition.status = 'waiting'; } else if (status === 'error') { condition.status = In(['error', 'crashed']); + } else if (status === 'canceled') { + condition.status = 'canceled'; } return condition; diff --git a/packages/cli/src/public-api/v1/handlers/executions/spec/paths/executions.yml b/packages/cli/src/public-api/v1/handlers/executions/spec/paths/executions.yml index 6fcdaf356e..012f403558 100644 --- a/packages/cli/src/public-api/v1/handlers/executions/spec/paths/executions.yml +++ b/packages/cli/src/public-api/v1/handlers/executions/spec/paths/executions.yml @@ -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. diff --git a/packages/cli/test/integration/public-api/executions.test.ts b/packages/cli/test/integration/public-api/executions.test.ts index fabbd80cf3..97f352ce63 100644 --- a/packages/cli/test/integration/public-api/executions.test.ts +++ b/packages/cli/test/integration/public-api/executions.test.ts @@ -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 () => { diff --git a/packages/cli/test/integration/shared/db/executions.ts b/packages/cli/test/integration/shared/db/executions.ts index b6a5127bc0..efa9100829 100644 --- a/packages/cli/test/integration/shared/db/executions.ts +++ b/packages/cli/test/integration/shared/db/executions.ts @@ -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 = { + 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[] },