From 6013dcc8e0e12b3c38b13e1fcc4bf90dfb4ffd29 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Fri, 25 Apr 2025 14:01:42 +0000 Subject: [PATCH] feat(api): add get flows endpoint --- .../controllers/api/v1/flows/get-flows.ee.js | 20 ++ .../api/v1/flows/get-flows.ee.test.js | 284 ++++++++++++++++++ packages/backend/src/models/flow.js | 32 ++ packages/backend/src/models/flow.test.js | 128 +++++++- .../backend/src/routes/api/v1/flows.ee.js | 4 +- .../test/mocks/rest/api/v1/flows/get-flows.js | 39 +++ packages/backend/vitest.config.js | 8 +- 7 files changed, 509 insertions(+), 6 deletions(-) create mode 100644 packages/backend/src/controllers/api/v1/flows/get-flows.ee.js create mode 100644 packages/backend/src/controllers/api/v1/flows/get-flows.ee.test.js create mode 100644 packages/backend/test/mocks/rest/api/v1/flows/get-flows.js diff --git a/packages/backend/src/controllers/api/v1/flows/get-flows.ee.js b/packages/backend/src/controllers/api/v1/flows/get-flows.ee.js new file mode 100644 index 00000000..dd13415a --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/get-flows.ee.js @@ -0,0 +1,20 @@ +import paginateRest from '../../../../helpers/pagination.js'; +import { renderObject } from '../../../../helpers/renderer.js'; +import Flow from '../../../../models/flow.js'; + +export default async (request, response) => { + const flowsQuery = Flow.find(flowParams(request)); + + const flows = await paginateRest(flowsQuery, request.query.page); + + renderObject(response, flows); +}; + +const flowParams = (request) => { + return { + folderId: request.query.folderId, + name: request.query.name, + status: request.query.status, + onlyOwnedFlows: request.query.onlyOwnedFlows, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/flows/get-flows.ee.test.js b/packages/backend/src/controllers/api/v1/flows/get-flows.ee.test.js new file mode 100644 index 00000000..0779dd0d --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/get-flows.ee.test.js @@ -0,0 +1,284 @@ +import request from 'supertest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createApiToken } from '../../../../../test/factories/api-token.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createFolder } from '../../../../../test/factories/folder.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import getFlowsMock from '../../../../../test/mocks/rest/api/v1/flows/get-flows.js'; +import app from '../../../../app.js'; +import * as license from '../../../../helpers/license.ee.js'; + +describe('GET /api/v1/flows', () => { + let currentUser, token; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + currentUser = await createUser(); + token = (await createApiToken()).token; + }); + + it('should return the flows data', async () => { + const flowOne = await createFlow(); + + const triggerStepFlowOne = await createStep({ + flowId: flowOne.id, + type: 'trigger', + }); + const actionStepFlowOne = await createStep({ + flowId: flowOne.id, + type: 'action', + }); + + const flowTwo = await createFlow(); + + const triggerStepFlowTwo = await createStep({ + flowId: flowTwo.id, + type: 'trigger', + }); + const actionStepFlowTwo = await createStep({ + flowId: flowTwo.id, + type: 'action', + }); + + const response = await request(app) + .get('/api/v1/flows') + .set('x-api-token', token) + .expect(200); + + const expectedPayload = await getFlowsMock( + [flowTwo, flowOne], + [ + triggerStepFlowOne, + actionStepFlowOne, + triggerStepFlowTwo, + actionStepFlowTwo, + ] + ); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return all flows data of the given user', async () => { + const folderOne = await createFolder({ userId: currentUser.id }); + + const currentUserFlowOne = await createFlow({ + userId: currentUser.id, + folderId: folderOne.id, + }); + + const triggerStepFlowOne = await createStep({ + flowId: currentUserFlowOne.id, + type: 'trigger', + }); + const actionStepFlowOne = await createStep({ + flowId: currentUserFlowOne.id, + type: 'action', + }); + + const folderTwo = await createFolder({ userId: currentUser.id }); + + const currentUserFlowTwo = await createFlow({ + userId: currentUser.id, + folderId: folderTwo.id, + }); + + const triggerStepFlowTwo = await createStep({ + flowId: currentUserFlowTwo.id, + type: 'trigger', + }); + + const actionStepFlowTwo = await createStep({ + flowId: currentUserFlowTwo.id, + type: 'action', + }); + + const currentUserFlowThree = await createFlow({ userId: currentUser.id }); + + const triggerStepFlowThree = await createStep({ + flowId: currentUserFlowThree.id, + type: 'trigger', + }); + + const actionStepFlowThree = await createStep({ + flowId: currentUserFlowThree.id, + type: 'action', + }); + + const response = await request(app) + .get('/api/v1/flows?userId=' + currentUser.id) + .set('x-api-token', token) + .expect(200); + + const expectedPayload = await getFlowsMock( + [currentUserFlowThree, currentUserFlowTwo, currentUserFlowOne], + [ + triggerStepFlowOne, + actionStepFlowOne, + triggerStepFlowTwo, + actionStepFlowTwo, + triggerStepFlowThree, + actionStepFlowThree, + ] + ); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return all uncategorized flows data', async () => { + const folderOne = await createFolder(); + + const currentUserFlowOne = await createFlow({ + userId: currentUser.id, + folderId: folderOne.id, + }); + + await createStep({ + flowId: currentUserFlowOne.id, + type: 'trigger', + }); + + await createStep({ + flowId: currentUserFlowOne.id, + type: 'action', + }); + + const folderTwo = await createFolder(); + + const currentUserFlowTwo = await createFlow({ + userId: currentUser.id, + folderId: folderTwo.id, + }); + + await createStep({ + flowId: currentUserFlowTwo.id, + type: 'trigger', + }); + + await createStep({ + flowId: currentUserFlowTwo.id, + type: 'action', + }); + + const currentUserFlowThree = await createFlow(); + + const triggerStepFlowThree = await createStep({ + flowId: currentUserFlowThree.id, + type: 'trigger', + }); + + const actionStepFlowThree = await createStep({ + flowId: currentUserFlowThree.id, + type: 'action', + }); + + const currentUserFlowFour = await createFlow(); + + const triggerStepFlowFour = await createStep({ + flowId: currentUserFlowFour.id, + type: 'trigger', + }); + + const actionStepFlowFour = await createStep({ + flowId: currentUserFlowFour.id, + type: 'action', + }); + + const response = await request(app) + .get(`/api/v1/flows?folderId=null`) + .set('x-api-token', token) + .expect(200); + + const expectedPayload = await getFlowsMock( + [currentUserFlowFour, currentUserFlowThree], + [ + triggerStepFlowThree, + actionStepFlowThree, + triggerStepFlowFour, + actionStepFlowFour, + ] + ); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return all flows data of the given user for specified folder', async () => { + const folderOne = await createFolder({ userId: currentUser.id }); + + const currentUserFlowOne = await createFlow({ + userId: currentUser.id, + folderId: folderOne.id, + }); + + const triggerStepFlowOne = await createStep({ + flowId: currentUserFlowOne.id, + type: 'trigger', + }); + const actionStepFlowOne = await createStep({ + flowId: currentUserFlowOne.id, + type: 'action', + }); + + const currentUserFlowTwo = await createFlow({ + userId: currentUser.id, + folderId: folderOne.id, + }); + + const triggerStepFlowTwo = await createStep({ + flowId: currentUserFlowTwo.id, + type: 'trigger', + }); + + const actionStepFlowTwo = await createStep({ + flowId: currentUserFlowTwo.id, + type: 'action', + }); + + const folderTwo = await createFolder({ userId: currentUser.id }); + + const currentUserFlowThree = await createFlow({ + userId: currentUser.id, + folderId: folderTwo.id, + }); + + await createStep({ + flowId: currentUserFlowThree.id, + type: 'trigger', + }); + + await createStep({ + flowId: currentUserFlowThree.id, + type: 'action', + }); + + const currentUserFlowFour = await createFlow({ userId: currentUser.id }); + + await createStep({ + flowId: currentUserFlowFour.id, + type: 'trigger', + }); + + await createStep({ + flowId: currentUserFlowFour.id, + type: 'action', + }); + + const response = await request(app) + .get(`/api/v1/flows?userId=${currentUser.id}&folderId=${folderOne.id}`) + .set('x-api-token', token) + .expect(200); + + const expectedPayload = await getFlowsMock( + [currentUserFlowTwo, currentUserFlowOne], + [ + triggerStepFlowOne, + actionStepFlowOne, + triggerStepFlowTwo, + actionStepFlowTwo, + ] + ); + + expect(response.body).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/models/flow.js b/packages/backend/src/models/flow.js index 9c05868b..1788fbb7 100644 --- a/packages/backend/src/models/flow.js +++ b/packages/backend/src/models/flow.js @@ -467,6 +467,38 @@ class Flow extends Base { return await exportFlow(this); } + static find({ folderId, name, status, userId }) { + return this.query() + .withGraphFetched({ + steps: true, + }) + .where((builder) => { + if (name) { + builder.where('flows.name', 'ilike', `%${name}%`); + } + + if (status === 'published') { + builder.where('flows.active', true); + } else if (status === 'draft') { + builder.where('flows.active', false); + } + + if (userId) { + builder.where('flows.user_id', userId); + } + + if (folderId === 'null') { + builder.where((builder) => { + builder.whereNull('flows.folder_id'); + }); + } else if (folderId) { + builder.where('flows.folder_id', folderId); + } + }) + .orderBy('active', 'desc') + .orderBy('updated_at', 'desc'); + } + async $beforeUpdate(opt, queryContext) { await super.$beforeUpdate(opt, queryContext); diff --git a/packages/backend/src/models/flow.test.js b/packages/backend/src/models/flow.test.js index 5976880b..b2430aa4 100644 --- a/packages/backend/src/models/flow.test.js +++ b/packages/backend/src/models/flow.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import Flow from './flow.js'; import User from './user.js'; import Base from './base.js'; @@ -614,6 +614,132 @@ describe('Flow model', () => { }); }); + describe('find', () => { + let userOne, + userTwo, + userOneFolder, + userTwoFolder, + flowOne, + flowTwo, + flowThree; + + beforeEach(async () => { + userOne = await createUser(); + userTwo = await createUser(); + + userOneFolder = await createFolder({ userId: userOne.id }); + userTwoFolder = await createFolder({ userId: userTwo.id }); + + flowOne = await createFlow({ + userId: userOne.id, + folderId: userOneFolder.id, + active: true, + name: 'Flow One', + }); + + flowTwo = await createFlow({ + userId: userOne.id, + active: false, + name: 'Flow Two', + }); + + flowThree = await createFlow({ + userId: userTwo.id, + name: 'Flow Three', + folderId: userTwoFolder.id, + }); + }); + + it('should return flows filtered by name', async () => { + const flows = await Flow.find({ name: 'Flow Two' }); + + expect(flows).toHaveLength(1); + expect(flows[0].id).toBe(flowTwo.id); + }); + + it('should return flows filtered by published status', async () => { + const flows = await Flow.find({ status: 'published' }); + + expect(flows).toHaveLength(1); + expect(flows[0].id).toBe(flowOne.id); + }); + + it('should return flows filtered by draft status', async () => { + const flows = await Flow.find({ status: 'draft' }); + + expect(flows).toHaveLength(2); + expect(flows[1].id).toBe(flowTwo.id); + expect(flows[0].id).toBe(flowThree.id); + }); + + it('should return flows filtered by name and status', async () => { + const flows = await Flow.find({ name: 'Flow One', status: 'published' }); + + expect(flows).toHaveLength(1); + expect(flows[0].id).toBe(flowOne.id); + }); + + it('should return flows filtered by userId', async () => { + const flows = await Flow.find({ userId: userOne.id }); + + expect(flows).toHaveLength(2); + expect(flows[0].id).toBe(flowOne.id); + expect(flows[1].id).toBe(flowTwo.id); + }); + + it('should return flows filtered by onlyOwnedFlows with null folderId', async () => { + const flows = await Flow.find({ onlyOwnedFlows: true, folderId: 'null' }); + + expect(flows).toHaveLength(1); + expect(flows[0].id).toBe(flowTwo.id); + }); + + it('should return flows with specific folder ID', async () => { + const flows = await Flow.find({ folderId: userOneFolder.id }); + + expect(flows.length).toBe(1); + expect(flows[0].id).toBe(flowOne.id); + }); + + it('should return flows filtered by folderId and name', async () => { + const flows = await Flow.find({ + folderId: userOneFolder.id, + name: 'Flow One', + }); + + expect(flows).toHaveLength(1); + expect(flows[0].id).toBe(flowOne.id); + }); + + it('should return all flows if no filters are provided', async () => { + const flows = await Flow.find({}); + + expect(flows).toHaveLength(3); + expect(flows.map((flow) => flow.id)).toEqual( + expect.arrayContaining([flowOne.id, flowTwo.id, flowThree.id]) + ); + }); + + it('should return uncategorized flows if the folderId is null', async () => { + const flows = await Flow.find({ folderId: 'null' }); + + expect(flows).toHaveLength(1); + expect(flows.map((flow) => flow.id)).toEqual([flowTwo.id]); + }); + + it('should return specified flows with all filters together', async () => { + const flows = await Flow.find({ + folderId: userOneFolder.id, + name: 'Flow One', + status: 'published', + onlyOwnedFlows: true, + }); + + expect(flows).toHaveLength(1); + expect(flows[0].id).toBe(flowOne.id); + }); + }); + describe('$beforeUpdate', () => { it('should invoke throwIfHavingIncompleteSteps when flow is becoming active', async () => { const flow = await createFlow({ active: false }); diff --git a/packages/backend/src/routes/api/v1/flows.ee.js b/packages/backend/src/routes/api/v1/flows.ee.js index d25055cb..2da68381 100644 --- a/packages/backend/src/routes/api/v1/flows.ee.js +++ b/packages/backend/src/routes/api/v1/flows.ee.js @@ -1,10 +1,12 @@ import { Router } from 'express'; -import getFlowAction from '../../../controllers/api/v1/flows/get-flow.ee.js'; import deleteFlowAction from '../../../controllers/api/v1/flows/delete-flow.ee.js'; +import getFlowAction from '../../../controllers/api/v1/flows/get-flow.ee.js'; +import getFlowsAction from '../../../controllers/api/v1/flows/get-flows.ee.js'; import updateFlowStatusAction from '../../../controllers/api/v1/flows/update-flow-status.ee.js'; const router = Router(); +router.get('/', getFlowsAction); router.get('/:flowId', getFlowAction); router.delete('/:flowId', deleteFlowAction); router.patch('/:flowId/status', updateFlowStatusAction); diff --git a/packages/backend/test/mocks/rest/api/v1/flows/get-flows.js b/packages/backend/test/mocks/rest/api/v1/flows/get-flows.js new file mode 100644 index 00000000..6012a6f6 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/get-flows.js @@ -0,0 +1,39 @@ +const getFlowsMock = async (flows, steps) => { + const data = flows.map((flow) => { + const flowSteps = steps.filter((step) => step.flowId === flow.id); + + return { + active: flow.active, + id: flow.id, + name: flow.name, + status: flow.active ? 'published' : 'draft', + createdAt: flow.createdAt.getTime(), + updatedAt: flow.updatedAt.getTime(), + steps: flowSteps.map((step) => ({ + appKey: step.appKey, + iconUrl: step.iconUrl, + id: step.id, + key: step.key, + name: step.name, + parameters: step.parameters, + position: step.position, + status: step.status, + type: step.type, + webhookUrl: step.webhookUrl, + })), + }; + }); + + return { + data: data, + meta: { + count: data.length, + currentPage: 1, + isArray: true, + totalPages: 1, + type: 'Flow', + }, + }; +}; + +export default getFlowsMock; diff --git a/packages/backend/vitest.config.js b/packages/backend/vitest.config.js index fa3e01ee..397c36bd 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.42, - branches: 98.37, - functions: 99.08, - lines: 99.42, + statements: 99.43, + branches: 98.4, + functions: 99.09, + lines: 99.43, }, }, },