diff --git a/packages/backend/src/controllers/api/v1/flows/create-flow.js b/packages/backend/src/controllers/api/v1/flows/create-flow.js index 0b049193..39d12f33 100644 --- a/packages/backend/src/controllers/api/v1/flows/create-flow.js +++ b/packages/backend/src/controllers/api/v1/flows/create-flow.js @@ -1,11 +1,11 @@ import { renderObject } from '../../../../helpers/renderer.js'; export default async (request, response) => { - let flow = await request.currentUser.$relatedQuery('flows').insert({ + const flow = await request.currentUser.$relatedQuery('flows').insertAndFetch({ name: 'Name your flow', }); - flow = await flow.createInitialSteps(); + await flow.createInitialSteps(); renderObject(response, flow, { status: 201 }); }; diff --git a/packages/backend/src/models/__snapshots__/flow.test.js.snap b/packages/backend/src/models/__snapshots__/flow.test.js.snap new file mode 100644 index 00000000..a900ef13 --- /dev/null +++ b/packages/backend/src/models/__snapshots__/flow.test.js.snap @@ -0,0 +1,42 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Flow model > jsonSchema should have correct validations 1`] = ` +{ + "properties": { + "active": { + "type": "boolean", + }, + "createdAt": { + "type": "string", + }, + "deletedAt": { + "type": "string", + }, + "id": { + "format": "uuid", + "type": "string", + }, + "name": { + "minLength": 1, + "type": "string", + }, + "publishedAt": { + "type": "string", + }, + "remoteWebhookId": { + "type": "string", + }, + "updatedAt": { + "type": "string", + }, + "userId": { + "format": "uuid", + "type": "string", + }, + }, + "required": [ + "name", + ], + "type": "object", +} +`; diff --git a/packages/backend/src/models/flow.js b/packages/backend/src/models/flow.js index 163e32c9..2a387d84 100644 --- a/packages/backend/src/models/flow.js +++ b/packages/backend/src/models/flow.js @@ -123,13 +123,14 @@ class Flow extends Base { return lastExecutions.map((execution) => execution.internalId); } - get IncompleteStepsError() { + static get IncompleteStepsError() { return new ValidationError({ data: { flow: [ { - message: 'All steps should be completed before updating flow status!' - } + message: + 'All steps should be completed before updating flow status!', + }, ], }, type: 'incompleteStepsError', @@ -148,8 +149,6 @@ class Flow extends Base { type: 'action', position: 2, }); - - return this.$query().withGraphFetched('steps'); } async createActionStep(previousStepId) { @@ -291,6 +290,18 @@ class Flow extends Base { return duplicatedFlowWithSteps; } + async getTriggerStep() { + return await this.$relatedQuery('steps').findOne({ + type: 'trigger', + }); + } + + async isPaused() { + const user = await this.$relatedQuery('user').withSoftDeleted(); + const allowedToRunFlows = await user.isAllowedToRunFlows(); + return allowedToRunFlows ? false : true; + } + async updateStatus(newActiveValue) { if (this.active === newActiveValue) { return this; @@ -299,7 +310,7 @@ class Flow extends Base { const triggerStep = await this.getTriggerStep(); if (triggerStep.status === 'incomplete') { - throw this.IncompleteStepsError; + throw Flow.IncompleteStepsError; } const trigger = await triggerStep.getTriggerCommand(); @@ -365,7 +376,7 @@ class Flow extends Base { }); if (incompleteStep) { - throw this.IncompleteStepsError; + throw Flow.IncompleteStepsError; } const allSteps = await oldFlow.$relatedQuery('steps'); @@ -375,8 +386,9 @@ class Flow extends Base { data: { flow: [ { - message: 'There should be at least one trigger and one action steps in the flow!' - } + message: + 'There should be at least one trigger and one action steps in the flow!', + }, ], }, type: 'insufficientStepsError', @@ -395,18 +407,6 @@ class Flow extends Base { await super.$afterUpdate(opt, queryContext); Telemetry.flowUpdated(this); } - - async getTriggerStep() { - return await this.$relatedQuery('steps').findOne({ - type: 'trigger', - }); - } - - async isPaused() { - const user = await this.$relatedQuery('user').withSoftDeleted(); - const allowedToRunFlows = await user.isAllowedToRunFlows(); - return allowedToRunFlows ? false : true; - } } export default Flow; diff --git a/packages/backend/src/models/flow.test.js b/packages/backend/src/models/flow.test.js new file mode 100644 index 00000000..80b7335a --- /dev/null +++ b/packages/backend/src/models/flow.test.js @@ -0,0 +1,203 @@ +import { describe, it, expect, vi } from 'vitest'; +import Flow from './flow.js'; +import User from './user.js'; +import Base from './base.js'; +import Step from './step.js'; +import Execution from './execution.js'; +import { createFlow } from '../../test/factories/flow.js'; +import { createExecution } from '../../test/factories/execution.js'; + +describe('Flow model', () => { + it('tableName should return correct name', () => { + expect(Flow.tableName).toBe('flows'); + }); + + it('jsonSchema should have correct validations', () => { + expect(Flow.jsonSchema).toMatchSnapshot(); + }); + + describe('relationMappings', () => { + it('should return correct associations', () => { + const relationMappings = Flow.relationMappings(); + + const expectedRelations = { + steps: { + relation: Base.HasManyRelation, + modelClass: Step, + join: { + from: 'flows.id', + to: 'steps.flow_id', + }, + filter: expect.any(Function), + }, + triggerStep: { + relation: Base.HasOneRelation, + modelClass: Step, + join: { + from: 'flows.id', + to: 'steps.flow_id', + }, + filter: expect.any(Function), + }, + executions: { + relation: Base.HasManyRelation, + modelClass: Execution, + join: { + from: 'flows.id', + to: 'executions.flow_id', + }, + }, + lastExecution: { + relation: Base.HasOneRelation, + modelClass: Execution, + join: { + from: 'flows.id', + to: 'executions.flow_id', + }, + filter: expect.any(Function), + }, + user: { + relation: Base.HasOneRelation, + modelClass: User, + join: { + from: 'flows.user_id', + to: 'users.id', + }, + }, + }; + + expect(relationMappings).toStrictEqual(expectedRelations); + }); + + it('steps should return the steps', () => { + const relations = Flow.relationMappings(); + const orderBySpy = vi.fn(); + + relations.steps.filter({ orderBy: orderBySpy }); + + expect(orderBySpy).toHaveBeenCalledWith('position', 'asc'); + }); + + it('triggerStep should return the trigger step', () => { + const relations = Flow.relationMappings(); + + const firstSpy = vi.fn(); + + const limitSpy = vi.fn().mockImplementation(() => ({ + first: firstSpy, + })); + + const whereSpy = vi.fn().mockImplementation(() => ({ + limit: limitSpy, + })); + + relations.triggerStep.filter({ where: whereSpy }); + + expect(whereSpy).toHaveBeenCalledWith('type', 'trigger'); + expect(limitSpy).toHaveBeenCalledWith(1); + expect(firstSpy).toHaveBeenCalledOnce(); + }); + + it('lastExecution should return the last execution', () => { + const relations = Flow.relationMappings(); + + const firstSpy = vi.fn(); + + const limitSpy = vi.fn().mockImplementation(() => ({ + first: firstSpy, + })); + + const orderBySpy = vi.fn().mockImplementation(() => ({ + limit: limitSpy, + })); + + relations.lastExecution.filter({ orderBy: orderBySpy }); + + expect(orderBySpy).toHaveBeenCalledWith('created_at', 'desc'); + expect(limitSpy).toHaveBeenCalledWith(1); + expect(firstSpy).toHaveBeenCalledOnce(); + }); + }); + + describe.todo('afterFind - possibly refactor to persist'); + + describe('lastInternalId', () => { + it('should return internal ID of last execution when exists', async () => { + const flow = await createFlow(); + + await createExecution({ flowId: flow.id }); + await createExecution({ flowId: flow.id }); + const lastExecution = await createExecution({ flowId: flow.id }); + + expect(await flow.lastInternalId()).toBe(lastExecution.internalId); + }); + + it('should return null when no flow execution exists', async () => { + const flow = await createFlow(); + + expect(await flow.lastInternalId()).toBe(null); + }); + }); + + describe('lastInternalIds', () => { + it('should return last internal IDs', async () => { + const flow = await createFlow(); + + const internalIds = [ + await createExecution({ flowId: flow.id }), + await createExecution({ flowId: flow.id }), + await createExecution({ flowId: flow.id }), + ].map((execution) => execution.internalId); + + expect(await flow.lastInternalIds()).toStrictEqual(internalIds); + }); + + it('should return last 50 internal IDs by default', async () => { + const flow = new Flow(); + + const limitSpy = vi.fn().mockResolvedValue([]); + + vi.spyOn(flow, '$relatedQuery').mockReturnValue({ + select: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: limitSpy, + }); + + await flow.lastInternalIds(); + + expect(limitSpy).toHaveBeenCalledWith(50); + }); + }); + + it('IncompleteStepsError should return validation error for incomplete steps', () => { + expect(() => { + throw Flow.IncompleteStepsError; + }).toThrowError( + 'flow: All steps should be completed before updating flow status!' + ); + }); + + it('createInitialSteps should create one trigger and one action step', async () => { + const flow = await createFlow(); + await flow.createInitialSteps(); + const steps = await flow.$relatedQuery('steps'); + + expect(steps.length).toBe(2); + + expect(steps[0]).toMatchObject({ + flowId: flow.id, + type: 'trigger', + position: 1, + }); + + expect(steps[1]).toMatchObject({ + flowId: flow.id, + type: 'action', + position: 2, + }); + }); + + it.todo('createActionStep'); + + it.todo('delete'); +}); diff --git a/packages/backend/test/mocks/rest/api/v1/flows/create-flow.js b/packages/backend/test/mocks/rest/api/v1/flows/create-flow.js index e63d1b03..cb7606cb 100644 --- a/packages/backend/test/mocks/rest/api/v1/flows/create-flow.js +++ b/packages/backend/test/mocks/rest/api/v1/flows/create-flow.js @@ -6,18 +6,6 @@ const createFlowMock = async (flow) => { status: flow.status, createdAt: flow.createdAt.getTime(), updatedAt: flow.updatedAt.getTime(), - steps: [ - { - position: 1, - status: 'incomplete', - type: 'trigger', - }, - { - position: 2, - status: 'incomplete', - type: 'action', - }, - ], }; return {