From 813646e392665057c13b88fe4caa5a882339ff90 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Fri, 6 Sep 2024 17:30:10 +0000 Subject: [PATCH] feat: write REST API endpoint to create step --- .../controllers/api/v1/flows/create-step.js | 14 +++ .../api/v1/flows/create-step.test.js | 113 ++++++++++++++++++ packages/backend/src/helpers/authorization.js | 4 + packages/backend/src/models/flow.js | 25 ++++ packages/backend/src/models/step.js | 2 + packages/backend/src/routes/api/v1/flows.js | 7 ++ packages/backend/test/factories/role.js | 79 ++++++++++++ .../mocks/rest/api/v1/flows/create-step.js | 26 ++++ 8 files changed, 270 insertions(+) create mode 100644 packages/backend/src/controllers/api/v1/flows/create-step.js create mode 100644 packages/backend/src/controllers/api/v1/flows/create-step.test.js create mode 100644 packages/backend/test/mocks/rest/api/v1/flows/create-step.js diff --git a/packages/backend/src/controllers/api/v1/flows/create-step.js b/packages/backend/src/controllers/api/v1/flows/create-step.js new file mode 100644 index 00000000..f0bb71f7 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/create-step.js @@ -0,0 +1,14 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const flow = await request.currentUser.authorizedFlows + .clone() + .findById(request.params.flowId) + .throwIfNotFound(); + + const createdActionStep = await flow.createActionStep( + request.body.previousStepId + ); + + renderObject(response, createdActionStep, { status: 201 }); +}; diff --git a/packages/backend/src/controllers/api/v1/flows/create-step.test.js b/packages/backend/src/controllers/api/v1/flows/create-step.test.js new file mode 100644 index 00000000..358831dd --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/create-step.test.js @@ -0,0 +1,113 @@ +import Crypto from 'node:crypto'; +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; + +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createAdminRole } from '../../../../../test/factories/role.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import createStepMock from '../../../../../test/mocks/rest/api/v1/flows/create-step.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; + +describe('POST /api/v1/flows/:flowId/steps', () => { + let currentUser, flow, triggerStep, token; + + beforeEach(async () => { + const adminRole = await createAdminRole(); + currentUser = await createUser({ roleId: adminRole.id }); + + flow = await createFlow({ userId: currentUser.id }); + + triggerStep = await createStep({ flowId: flow.id, type: 'trigger' }); + await createStep({ flowId: flow.id, type: 'action' }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return created step', async () => { + const response = await request(app) + .post(`/api/v1/flows/${flow.id}/steps`) + .set('Authorization', token) + .send({ + previousStepId: triggerStep.id, + }) + .expect(201); + + const expectedPayload = await createStepMock({ + id: response.body.data.id, + position: 2, + }); + + expect(response.body).toMatchObject(expectedPayload); + }); + + it('should return created step for another user', async () => { + const anotherUser = await createUser(); + const anotherUsertoken = await createAuthTokenByUserId(anotherUser.id); + + await createPermission({ + roleId: anotherUser.roleId, + subject: 'Flow', + action: 'read', + conditions: [], + }); + + await createPermission({ + roleId: anotherUser.roleId, + subject: 'Flow', + action: 'update', + conditions: [], + }); + + const response = await request(app) + .post(`/api/v1/flows/${flow.id}/steps`) + .set('Authorization', anotherUsertoken) + .send({ + previousStepId: triggerStep.id, + }) + .expect(201); + + const expectedPayload = await createStepMock({ + id: response.body.data.id, + position: 2, + }); + + expect(response.body).toMatchObject(expectedPayload); + }); + + it('should return bad request response for invalid flow UUID', async () => { + await request(app) + .post('/api/v1/flows/invalidFlowUUID/steps') + .set('Authorization', token) + .send({ + previousStepId: triggerStep.id, + }) + .expect(400); + }); + + it('should return not found response for invalid flow UUID', async () => { + const notExistingFlowUUID = Crypto.randomUUID(); + + await request(app) + .post(`/api/v1/flows/${notExistingFlowUUID}/steps`) + .set('Authorization', token) + .send({ + previousStepId: triggerStep.id, + }) + .expect(404); + }); + + it('should return not found response for invalid flow UUID', async () => { + const notExistingStepUUID = Crypto.randomUUID(); + + await request(app) + .post(`/api/v1/flows/${flow.id}/steps`) + .set('Authorization', token) + .send({ + previousStepId: notExistingStepUUID, + }) + .expect(404); + }); +}); diff --git a/packages/backend/src/helpers/authorization.js b/packages/backend/src/helpers/authorization.js index eb71fda1..36210615 100644 --- a/packages/backend/src/helpers/authorization.js +++ b/packages/backend/src/helpers/authorization.js @@ -93,6 +93,10 @@ const authorizationList = { action: 'update', subject: 'Flow', }, + 'POST /api/v1/flows/:flowId/steps': { + action: 'update', + subject: 'Flow', + }, }; export const authorizeUser = async (request, response, next) => { diff --git a/packages/backend/src/models/flow.js b/packages/backend/src/models/flow.js index 7e657fbb..3c8a2811 100644 --- a/packages/backend/src/models/flow.js +++ b/packages/backend/src/models/flow.js @@ -135,6 +135,31 @@ class Flow extends Base { return this.$query().withGraphFetched('steps'); } + async createActionStep(previousStepId) { + const previousStep = await this.$relatedQuery('steps') + .findById(previousStepId) + .throwIfNotFound(); + + const createdStep = await this.$relatedQuery('steps').insertAndFetch({ + type: 'action', + position: previousStep.position + 1, + }); + + const nextSteps = await this.$relatedQuery('steps') + .where('position', '>=', createdStep.position) + .whereNot('id', createdStep.id); + + const nextStepQueries = nextSteps.map(async (nextStep, index) => { + return await nextStep.$query().patchAndFetch({ + position: createdStep.position + index + 1, + }); + }); + + await Promise.all(nextStepQueries); + + return createdStep; + } + async $beforeUpdate(opt, queryContext) { await super.$beforeUpdate(opt, queryContext); diff --git a/packages/backend/src/models/step.js b/packages/backend/src/models/step.js index 01f46190..4781d8b6 100644 --- a/packages/backend/src/models/step.js +++ b/packages/backend/src/models/step.js @@ -82,6 +82,8 @@ class Step extends Base { }); get webhookUrl() { + if (!this.webhookPath) return null; + return new URL(this.webhookPath, appConfig.webhookUrl).toString(); } diff --git a/packages/backend/src/routes/api/v1/flows.js b/packages/backend/src/routes/api/v1/flows.js index 38ce3201..e7d21e3b 100644 --- a/packages/backend/src/routes/api/v1/flows.js +++ b/packages/backend/src/routes/api/v1/flows.js @@ -5,6 +5,7 @@ import getFlowsAction from '../../../controllers/api/v1/flows/get-flows.js'; import getFlowAction from '../../../controllers/api/v1/flows/get-flow.js'; import updateFlowAction from '../../../controllers/api/v1/flows/update-flow.js'; import createFlowAction from '../../../controllers/api/v1/flows/create-flow.js'; +import createStepAction from '../../../controllers/api/v1/flows/create-step.js'; const router = Router(); @@ -12,5 +13,11 @@ router.get('/', authenticateUser, authorizeUser, getFlowsAction); router.get('/:flowId', authenticateUser, authorizeUser, getFlowAction); router.post('/', authenticateUser, authorizeUser, createFlowAction); router.patch('/:flowId', authenticateUser, authorizeUser, updateFlowAction); +router.post( + '/:flowId/steps', + authenticateUser, + authorizeUser, + createStepAction +); export default router; diff --git a/packages/backend/test/factories/role.js b/packages/backend/test/factories/role.js index 28ac9960..3d3b165a 100644 --- a/packages/backend/test/factories/role.js +++ b/packages/backend/test/factories/role.js @@ -1,5 +1,6 @@ import { faker } from '@faker-js/faker'; import Role from '../../src/models/role'; +import { createPermission } from './permission'; export const createRole = async (params = {}) => { const name = faker.lorem.word(); @@ -16,3 +17,81 @@ export const createRole = async (params = {}) => { return role; }; + +export const createAdminRole = async (params = {}) => { + const adminRole = await createRole({ ...params, name: 'Admin' }); + + await createPermission({ + roleId: adminRole.id, + action: 'read', + subject: 'Flow', + }); + + await createPermission({ + roleId: adminRole.id, + action: 'create', + subject: 'Flow', + }); + + await createPermission({ + roleId: adminRole.id, + action: 'update', + subject: 'Flow', + }); + + await createPermission({ + roleId: adminRole.id, + action: 'delete', + subject: 'Flow', + }); + + await createPermission({ + roleId: adminRole.id, + action: 'publish', + subject: 'Flow', + }); + + await createPermission({ + roleId: adminRole.id, + action: 'read', + subject: 'Connection', + }); + + await createPermission({ + roleId: adminRole.id, + action: 'create', + subject: 'Connection', + }); + + await createPermission({ + roleId: adminRole.id, + action: 'update', + subject: 'Connection', + }); + + await createPermission({ + roleId: adminRole.id, + action: 'delete', + subject: 'Connection', + }); + + await createPermission({ + roleId: adminRole.id, + action: 'read', + subject: 'Execution', + }); + + await createPermission({ + roleId: adminRole.id, + action: 'create', + subject: 'Execution', + }); + + await createPermission({ + roleId: adminRole.id, + action: 'update', + subject: 'Execution', + }); + + return adminRole; +}; diff --git a/packages/backend/test/mocks/rest/api/v1/flows/create-step.js b/packages/backend/test/mocks/rest/api/v1/flows/create-step.js new file mode 100644 index 00000000..da23f51c --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/create-step.js @@ -0,0 +1,26 @@ +const createStepMock = async (step) => { + const data = { + id: step.id, + type: step.type || 'action', + key: step.key || null, + appKey: step.appKey || null, + iconUrl: step.iconUrl || null, + webhookUrl: step.webhookUrl || null, + status: step.status || 'incomplete', + position: step.position, + parameters: step.parameters || {}, + }; + + return { + data, + meta: { + type: 'Step', + count: 1, + isArray: false, + currentPage: null, + totalPages: null, + }, + }; +}; + +export default createStepMock;