diff --git a/packages/backend/src/controllers/api/v1/executions/get-executions.ee.js b/packages/backend/src/controllers/api/v1/executions/get-executions.ee.js new file mode 100644 index 00000000..e169adcb --- /dev/null +++ b/packages/backend/src/controllers/api/v1/executions/get-executions.ee.js @@ -0,0 +1,20 @@ +import Execution from '../../../../models/execution.js'; +import { renderObject } from '../../../../helpers/renderer.js'; +import paginateRest from '../../../../helpers/pagination.js'; + +export default async (request, response) => { + const executionsQuery = Execution.find(executionParams(request)); + + const executions = await paginateRest(executionsQuery, request.query.page); + + renderObject(response, executions); +}; + +const executionParams = (request) => { + return { + name: request.query.name, + status: request.query.status, + startDateTime: request.query.startDateTime, + endDateTime: request.query.endDateTime, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/executions/get-executions.ee.test.js b/packages/backend/src/controllers/api/v1/executions/get-executions.ee.test.js new file mode 100644 index 00000000..3c18aa40 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/executions/get-executions.ee.test.js @@ -0,0 +1,101 @@ +import request from 'supertest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import app from '../../../../app.js'; +import { createApiToken } from '../../../../../test/factories/api-token.js'; +import { createExecution } from '../../../../../test/factories/execution.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import getExecutionsMock from '../../../../../test/mocks/rest/api/v1/executions/get-executions.js'; +import * as license from '../../../../helpers/license.ee.js'; + +describe('GET /api/v1/executions', () => { + let userOne, userTwo, token; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + userOne = await createUser(); + userTwo = await createUser(); + + token = (await createApiToken()).token; + }); + + it('should return executions', async () => { + // Create a flow with steps and executions for userOne + const userOneFlow = await createFlow({ + userId: userOne.id, + }); + + const userOneFlowStepOne = await createStep({ + flowId: userOneFlow.id, + type: 'trigger', + }); + + const userOneFlowStepTwo = await createStep({ + flowId: userOneFlow.id, + type: 'action', + }); + + const userOneExecutionOne = await createExecution({ + flowId: userOneFlow.id, + }); + + const userOneExecutionTwo = await createExecution({ + flowId: userOneFlow.id, + }); + + userOneFlow.steps = [userOneFlowStepOne, userOneFlowStepTwo]; + userOneExecutionOne.flow = userOneFlow; + userOneExecutionTwo.flow = userOneFlow; + + await userOneExecutionTwo + .$query() + .patchAndFetch({ deletedAt: new Date().toISOString() }); + + // Create a flow with steps and executions for userTwo + const userTwoFlow = await createFlow({ + userId: userTwo.id, + }); + + const userTwoFlowStepOne = await createStep({ + flowId: userTwoFlow.id, + type: 'trigger', + }); + + const userTwoFlowStepTwo = await createStep({ + flowId: userTwoFlow.id, + type: 'action', + }); + + const userTwoExecutionOne = await createExecution({ + flowId: userTwoFlow.id, + }); + + const userTwoExecutionTwo = await createExecution({ + flowId: userTwoFlow.id, + }); + + userTwoFlow.steps = [userTwoFlowStepOne, userTwoFlowStepTwo]; + userTwoExecutionOne.flow = userTwoFlow; + userTwoExecutionTwo.flow = userTwoFlow; + + await userTwoExecutionTwo + .$query() + .patchAndFetch({ deletedAt: new Date().toISOString() }); + + const response = await request(app) + .get('/api/v1/executions') + .set('x-api-token', token) + .expect(200); + + const expectedPayload = await getExecutionsMock([ + userTwoExecutionTwo, + userTwoExecutionOne, + userOneExecutionTwo, + userOneExecutionOne, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/models/execution.js b/packages/backend/src/models/execution.js index c8481366..68fc97a2 100644 --- a/packages/backend/src/models/execution.js +++ b/packages/backend/src/models/execution.js @@ -1,3 +1,4 @@ +import { DateTime } from 'luxon'; import Base from './base.js'; import Flow from './flow.js'; import ExecutionStep from './execution-step.js'; @@ -40,6 +41,49 @@ class Execution extends Base { }, }); + static find({ name, status, startDateTime, endDateTime }) { + return this.query() + .withSoftDeleted() + .joinRelated({ + flow: true, + }) + .withGraphFetched({ + flow: { + steps: true, + }, + }) + .where((builder) => { + builder.withSoftDeleted(); + + if (name) { + builder.where('flow.name', 'ilike', `%${name}%`); + } + + if (status === 'success') { + builder.where('executions.status', 'success'); + } else if (status === 'failure') { + builder.where('executions.status', 'failure'); + } + + if (startDateTime) { + const startDate = DateTime.fromMillis(Number(startDateTime)); + + if (startDate.isValid) { + builder.where('executions.created_at', '>=', startDate.toISO()); + } + } + + if (endDateTime) { + const endDate = DateTime.fromMillis(Number(endDateTime)); + + if (endDate.isValid) { + builder.where('executions.created_at', '<=', endDate.toISO()); + } + } + }) + .orderBy('created_at', 'desc'); + } + async $afterInsert(queryContext) { await super.$afterInsert(queryContext); Telemetry.executionCreated(this); diff --git a/packages/backend/src/models/execution.test.js b/packages/backend/src/models/execution.test.js index 85917441..ae39554c 100644 --- a/packages/backend/src/models/execution.test.js +++ b/packages/backend/src/models/execution.test.js @@ -1,10 +1,14 @@ -import { vi, describe, it, expect } from 'vitest'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; import Execution from './execution'; import ExecutionStep from './execution-step'; import Flow from './flow'; import Base from './base'; import Telemetry from '../helpers/telemetry/index'; import { createExecution } from '../../test/factories/execution'; +import { createUser } from '../../test/factories/user'; +import { createFlow } from '../../test/factories/flow'; +import { createStep } from '../../test/factories/step'; +import { createPermission } from '../../test/factories/permission'; describe('Execution model', () => { it('tableName should return correct name', () => { @@ -40,6 +44,127 @@ describe('Execution model', () => { expect(relationMappings).toStrictEqual(expectedRelations); }); + describe('find', () => { + let currentUser, + currentUserRole, + anotherUser, + flow, + executionOne, + executionTwo, + executionThree; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + anotherUser = await createUser(); + + flow = await createFlow({ + userId: currentUser.id, + name: 'Test Flow', + }); + + const anotherUserFlow = await createFlow({ + userId: anotherUser.id, + name: 'Another User Flow', + }); + + executionOne = await createExecution({ + flowId: flow.id, + testRun: false, + status: 'success', + }); + + // sleep for 10 milliseconds to make sure the created_at values are different + await new Promise((resolve) => setTimeout(resolve, 10)); + + executionTwo = await createExecution({ + flowId: flow.id, + testRun: true, + status: 'failure', + }); + + // sleep for 10 milliseconds to make sure the created_at values are different + await new Promise((resolve) => setTimeout(resolve, 10)); + + executionThree = await createExecution({ + flowId: anotherUserFlow.id, + testRun: false, + status: 'success', + }); + + await createPermission({ + action: 'read', + subject: 'Execution', + roleId: currentUserRole.id, + conditions: [], + }); + + currentUser = await currentUser.$query().withGraphFetched({ + role: true, + permissions: true, + }); + }); + + it('should return executions filtered by name', async () => { + const executions = await Execution.find({ name: 'Test Flow' }); + + expect(executions).toHaveLength(2); + + expect(executions[0].id).toBe(executionTwo.id); + expect(executions[1].id).toBe(executionOne.id); + }); + + it('should return executions filtered by success status', async () => { + const executions = await Execution.find({ status: 'success' }); + + expect(executions).toHaveLength(2); + + expect(executions[0].id).toBe(executionThree.id); + expect(executions[1].id).toBe(executionOne.id); + }); + + it('should return executions filtered by failure status', async () => { + const executions = await Execution.find({ status: 'failure' }); + + expect(executions).toHaveLength(1); + expect(executions[0].id).toBe(executionTwo.id); + }); + + it('should return executions filtered by startDateTime and endDateTime', async () => { + const executions = await Execution.find({ + startDateTime: executionOne.createdAt, + endDateTime: executionTwo.createdAt, + }); + + expect(executions).toHaveLength(2); + expect(executions[0].id).toBe(executionTwo.id); + expect(executions[1].id).toBe(executionOne.id); + }); + + it('should return all executions when no filter is applied', async () => { + const executions = await Execution.find({}); + + expect(executions.length).toBeGreaterThanOrEqual(3); + + expect(executions.some((e) => e.id === executionOne.id)).toBe(true); + expect(executions.some((e) => e.id === executionTwo.id)).toBe(true); + expect(executions.some((e) => e.id === executionThree.id)).toBe(true); + }); + + it('should include flow and steps in the returned executions', async () => { + const step = await createStep({ + flowId: flow.id, + type: 'trigger', + }); + + const executions = await Execution.find({ name: 'Test Flow' }); + + expect(executions[0].flow.id).toBe(flow.id); + expect(executions[0].flow.steps[0].id).toBe(step.id); + }); + }); + it('$afterInsert should call Telemetry.executionCreated', async () => { const telemetryExecutionCreatedSpy = vi .spyOn(Telemetry, 'executionCreated') diff --git a/packages/backend/src/routes/api/index.js b/packages/backend/src/routes/api/index.js index b5a52346..c6fc2c45 100644 --- a/packages/backend/src/routes/api/index.js +++ b/packages/backend/src/routes/api/index.js @@ -1,11 +1,13 @@ import { Router } from 'express'; import appsRouter from './v1/apps.ee.js'; +import executionsRouter from './v1/executions.ee.js'; import foldersRouter from './v1/folders.ee.js'; import usersRouter from './v1/users.ee.js'; const router = Router(); router.use('/v1/apps', appsRouter); +router.use('/v1/executions', executionsRouter); router.use('/v1/folders', foldersRouter); router.use('/v1/users', usersRouter); diff --git a/packages/backend/src/routes/api/v1/executions.ee.js b/packages/backend/src/routes/api/v1/executions.ee.js new file mode 100644 index 00000000..2b443f11 --- /dev/null +++ b/packages/backend/src/routes/api/v1/executions.ee.js @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import getExecutionsAction from '../../../controllers/api/v1/executions/get-executions.ee.js'; + +const router = Router(); + +router.get('/', getExecutionsAction); + +export default router; diff --git a/packages/backend/test/mocks/rest/api/v1/executions/get-executions.js b/packages/backend/test/mocks/rest/api/v1/executions/get-executions.js new file mode 100644 index 00000000..f7d1b1a2 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/executions/get-executions.js @@ -0,0 +1,42 @@ +const getExecutionsMock = async (executions) => { + const data = executions.map((execution) => ({ + id: execution.id, + testRun: execution.testRun, + createdAt: execution.createdAt.getTime(), + updatedAt: execution.updatedAt.getTime(), + status: 'success', + flow: { + id: execution.flow.id, + name: execution.flow.name, + active: execution.flow.active, + status: execution.flow.active ? 'published' : 'draft', + createdAt: execution.flow.createdAt.getTime(), + updatedAt: execution.flow.updatedAt.getTime(), + steps: execution.flow.steps.map((step) => ({ + id: step.id, + type: step.type, + key: step.key, + name: step.name, + appKey: step.appKey, + iconUrl: step.iconUrl, + webhookUrl: step.webhookUrl, + status: step.status, + position: step.position, + parameters: step.parameters, + })), + }, + })); + + return { + data: data, + meta: { + count: executions.length, + currentPage: 1, + isArray: true, + totalPages: 1, + type: 'Execution', + }, + }; +}; + +export default getExecutionsMock; diff --git a/packages/backend/vitest.config.js b/packages/backend/vitest.config.js index 739e2a23..ccde8be0 100644 --- a/packages/backend/vitest.config.js +++ b/packages/backend/vitest.config.js @@ -28,10 +28,10 @@ export default defineConfig({ ], thresholds: { autoUpdate: true, - statements: 99.39, - branches: 98.29, - functions: 99.04, - lines: 99.39, + statements: 99.4, + branches: 98.31, + functions: 99.05, + lines: 99.4, }, }, },