diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 7a1e9a69..6f74d15c 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -12,6 +12,9 @@ on: workflow_dispatch: env: + BULLMQ_DASHBOARD_USERNAME: root + BULLMQ_DASHBOARD_PASSWORD: sample + ENABLE_BULLMQ_DASHBOARD: true ENCRYPTION_KEY: sample_encryption_key WEBHOOK_SECRET_KEY: sample_webhook_secret_key APP_SECRET_KEY: sample_app_secret_key @@ -22,6 +25,7 @@ env: POSTGRES_PASSWORD: automatisch_password REDIS_HOST: localhost APP_ENV: production + PORT: 3000 LICENSE_KEY: dummy_license_key jobs: diff --git a/packages/e2e-tests/.env-example b/packages/e2e-tests/.env-example index b7a2b62f..649279c7 100644 --- a/packages/e2e-tests/.env-example +++ b/packages/e2e-tests/.env-example @@ -2,4 +2,6 @@ POSTGRES_DB=automatisch POSTGRES_USER=automatisch_user POSTGRES_PASSWORD=automatisch_password POSTGRES_PORT=5432 -POSTGRES_HOST=localhost \ No newline at end of file +POSTGRES_HOST=localhost +BULLMQ_DASHBOARD_PASSWORD=sample +BULLMQ_DASHBOARD_USERNAME=root \ No newline at end of file diff --git a/packages/e2e-tests/fixtures/flows-page.js b/packages/e2e-tests/fixtures/flows-page.js new file mode 100644 index 00000000..177d8c4b --- /dev/null +++ b/packages/e2e-tests/fixtures/flows-page.js @@ -0,0 +1,35 @@ +const { AuthenticatedPage } = require('./authenticated-page'); +const { expect } = require('@playwright/test'); + +export class FlowsPage extends AuthenticatedPage { + constructor(page) { + super(page); + + this.flowRow = this.page.getByTestId('flow-row'); + this.flowCard = this.page.getByTestId('card-action-area'); + this.deleteFlowMenuItem = this.page.getByRole('menuitem', { + name: 'Delete', + }); + } + + async clickOnDeleteFlowMenuItem() { + await this.deleteFlowMenuItem.click(); + } + + async deleteFlow(flowId) { + const desiredFlow = await this.flowRow.filter({ + has: this.page.locator(`a[href="/editor/${flowId}"]`), + }); + await desiredFlow.locator('button').click(); + await this.clickOnDeleteFlowMenuItem(); + + await expect( + await this.flowRow.filter({ + has: this.page.locator(`a[href="/editor/${flowId}"]`), + }) + ).toHaveCount(0); + + const snackbar = await this.getSnackbarData(); + await expect(snackbar.variant).toBe('success'); + } +} diff --git a/packages/e2e-tests/fixtures/index.js b/packages/e2e-tests/fixtures/index.js index 069c2a7e..da663398 100644 --- a/packages/e2e-tests/fixtures/index.js +++ b/packages/e2e-tests/fixtures/index.js @@ -9,6 +9,7 @@ const { AcceptInvitation } = require('./accept-invitation-page'); const { adminFixtures } = require('./admin'); const { AdminSetupPage } = require('./admin-setup-page'); const { AdminCreateUserPage } = require('./admin/create-user-page'); +const { FlowsPage } = require('./flows-page'); exports.test = test.extend({ page: async ({ page }, use) => { @@ -35,6 +36,9 @@ exports.test = test.extend({ userInterfacePage: async ({ page }, use) => { await use(new UserInterfacePage(page)); }, + flowsPage: async ({ page }, use) => { + await use(new FlowsPage(page)); + }, ...adminFixtures, }); diff --git a/packages/e2e-tests/helpers/bullmq-helper.js b/packages/e2e-tests/helpers/bullmq-helper.js new file mode 100644 index 00000000..85ccf659 --- /dev/null +++ b/packages/e2e-tests/helpers/bullmq-helper.js @@ -0,0 +1,20 @@ +const { expect } = require('../fixtures/index'); + +export const expectNoDelayedJobForFlow = async (request, flowId) => { + const token = btoa( + `${process.env.BULLMQ_DASHBOARD_USERNAME}:${process.env.BULLMQ_DASHBOARD_PASSWORD}` + ); + const queues = await request.get( + `http://localhost:${process.env.PORT}/admin/queues/api/queues?activeQueue=flow&status=delayed&page=1`, + { + headers: { Authorization: `Basic ${token}` }, + } + ); + const queuesJsonResponse = await queues.json(); + const flowQueue = queuesJsonResponse.queues.find( + (queue) => queue.name === 'flow' + ); + await expect( + flowQueue.jobs.find((job) => job.name === `flow-${flowId}`) + ).toBeUndefined(); +}; diff --git a/packages/e2e-tests/helpers/flow-api-helper.js b/packages/e2e-tests/helpers/flow-api-helper.js new file mode 100644 index 00000000..8937e8f6 --- /dev/null +++ b/packages/e2e-tests/helpers/flow-api-helper.js @@ -0,0 +1,54 @@ +const { expect } = require('../fixtures/index'); + +export const createFlow = async (request, token) => { + const response = await request.post( + `http://localhost:${process.env.PORT}/api/v1/flows`, + { headers: { Authorization: token } } + ); + await expect(response.status()).toBe(201); + return await response.json(); +}; + +export const updateFlowName = async (request, token, flowId) => { + const updateFlowNameResponse = await request.patch( + `http://localhost:${process.env.PORT}/api/v1/flows/${flowId}`, + { + headers: { Authorization: token }, + data: { name: flowId }, + } + ); + await expect(updateFlowNameResponse.status()).toBe(200); +}; + +export const updateFlowStep = async (request, token, stepId, requestBody) => { + const updateTriggerStepResponse = await request.patch( + `http://localhost:${process.env.PORT}/api/v1/steps/${stepId}`, + { + headers: { Authorization: token }, + data: requestBody, + } + ); + await expect(updateTriggerStepResponse.status()).toBe(200); + return await updateTriggerStepResponse.json(); +}; + +export const testStep = async (request, token, stepId) => { + const testTriggerStepResponse = await request.post( + `http://localhost:${process.env.PORT}/api/v1/steps/${stepId}/test`, + { + headers: { Authorization: token }, + } + ); + await expect(testTriggerStepResponse.status()).toBe(200); +}; + +export const publishFlow = async (request, token, flowId) => { + const publishFlowResponse = await request.patch( + `http://localhost:${process.env.PORT}/api/v1/flows/${flowId}/status`, + { + headers: { Authorization: token }, + data: { active: true }, + } + ); + await expect(publishFlowResponse.status()).toBe(200); +}; diff --git a/packages/e2e-tests/tests/flow-editor/create-flow.spec.js b/packages/e2e-tests/tests/flow/create-flow.spec.js similarity index 100% rename from packages/e2e-tests/tests/flow-editor/create-flow.spec.js rename to packages/e2e-tests/tests/flow/create-flow.spec.js diff --git a/packages/e2e-tests/tests/flow/delete-flow.spec.js b/packages/e2e-tests/tests/flow/delete-flow.spec.js new file mode 100644 index 00000000..bad77150 --- /dev/null +++ b/packages/e2e-tests/tests/flow/delete-flow.spec.js @@ -0,0 +1,213 @@ +const { test, expect } = require('../../fixtures/index'); +const { expectNoDelayedJobForFlow } = require('../../helpers/bullmq-helper'); +const { + createFlow, + updateFlowName, + updateFlowStep, + testStep, + publishFlow, +} = require('../../helpers/flow-api-helper'); + +let tokenJsonResponse; + +test.beforeAll(async ({ request }) => { + const tokenResponse = await request.post( + `http://localhost:${process.env.PORT}/api/v1/access-tokens`, + { + data: { + email: process.env.LOGIN_EMAIL, + password: process.env.LOGIN_PASSWORD, + }, + } + ); + await expect(tokenResponse.status()).toBe(200); + tokenJsonResponse = await tokenResponse.json(); +}); + +test('Empty flow can be deleted', async ({ page, request, flowsPage }) => { + const flow = await createFlow(request, tokenJsonResponse.data.token); + const flowId = flow.data.id; + await updateFlowName(request, tokenJsonResponse.data.token, flowId); + + await page.reload(); + + await flowsPage.deleteFlow(flowId); +}); + +test('Completed webhook flow can be deleted', async ({ + page, + request, + flowsPage, +}) => { + const flow = await createFlow(request, tokenJsonResponse.data.token); + const flowId = flow.data.id; + const flowSteps = flow.data.steps; + await updateFlowName(request, tokenJsonResponse.data.token, flowId); + + const triggerStepId = flowSteps.find((step) => step.type === 'trigger').id; + const actionStepId = flowSteps.find((step) => step.type === 'action').id; + + const triggerStep = await updateFlowStep( + request, + tokenJsonResponse.data.token, + triggerStepId, + { + appKey: 'webhook', + key: 'catchRawWebhook', + parameters: { + workSynchronously: false, + }, + } + ); + await testStep(request, tokenJsonResponse.data.token, triggerStepId); + + await updateFlowStep(request, tokenJsonResponse.data.token, actionStepId, { + appKey: 'webhook', + key: 'respondWith', + parameters: { + statusCode: '200', + body: 'ok', + headers: [ + { + key: '', + value: '', + }, + ], + }, + }); + await testStep(request, tokenJsonResponse.data.token, actionStepId); + + await page.reload(); + + await flowsPage.deleteFlow(flowId); + const triggerWebhookResponse = await request.get(triggerStep.data.webhookUrl); + await expect(triggerWebhookResponse.status()).toBe(404); +}); + +test('Completed poll flow can be deleted', async ({ + page, + request, + flowsPage, +}) => { + const flow = await createFlow(request, tokenJsonResponse.data.token); + const flowId = flow.data.id; + const flowSteps = flow.data.steps; + await updateFlowName(request, tokenJsonResponse.data.token, flowId); + + const triggerStepId = flowSteps.find((step) => step.type === 'trigger').id; + const actionStepId = flowSteps.find((step) => step.type === 'action').id; + + await updateFlowStep(request, tokenJsonResponse.data.token, triggerStepId, { + appKey: 'rss', + key: 'newItemsInFeed', + parameters: { feedUrl: 'https://feeds.bbci.co.uk/news/rss.xml' }, + }); + await testStep(request, tokenJsonResponse.data.token, triggerStepId); + + await updateFlowStep(request, tokenJsonResponse.data.token, actionStepId, { + appKey: 'datastore', + key: 'setValue', + parameters: { + key: 'newsTitle', + value: '{{step.' + triggerStepId + '.title}}', + }, + }); + await testStep(request, tokenJsonResponse.data.token, actionStepId); + + await page.reload(); + + await flowsPage.deleteFlow(flowId); + + await expectNoDelayedJobForFlow(request, flowId); +}); + +test('Published webhook flow can be deleted', async ({ + page, + request, + flowsPage, +}) => { + const flow = await createFlow(request, tokenJsonResponse.data.token); + const flowId = flow.data.id; + const flowSteps = flow.data.steps; + await updateFlowName(request, tokenJsonResponse.data.token, flowId); + + const triggerStepId = flowSteps.find((step) => step.type === 'trigger').id; + const actionStepId = flowSteps.find((step) => step.type === 'action').id; + + const triggerStep = await updateFlowStep( + request, + tokenJsonResponse.data.token, + triggerStepId, + { + appKey: 'webhook', + key: 'catchRawWebhook', + parameters: { + workSynchronously: false, + }, + } + ); + await testStep(request, tokenJsonResponse.data.token, triggerStepId); + + await updateFlowStep(request, tokenJsonResponse.data.token, actionStepId, { + appKey: 'webhook', + key: 'respondWith', + parameters: { + statusCode: '200', + body: 'ok', + headers: [ + { + key: '', + value: '', + }, + ], + }, + }); + await testStep(request, tokenJsonResponse.data.token, actionStepId); + + await publishFlow(request, tokenJsonResponse.data.token, flowId); + + await page.reload(); + + await flowsPage.deleteFlow(flowId); + const triggerWebhookResponse = await request.get(triggerStep.data.webhookUrl); + await expect(triggerWebhookResponse.status()).toBe(404); +}); + +test('Published poll flow can be deleted', async ({ + page, + request, + flowsPage, +}) => { + const flow = await createFlow(request, tokenJsonResponse.data.token); + const flowId = flow.data.id; + const flowSteps = flow.data.steps; + await updateFlowName(request, tokenJsonResponse.data.token, flowId); + + const triggerStepId = flowSteps.find((step) => step.type === 'trigger').id; + const actionStepId = flowSteps.find((step) => step.type === 'action').id; + + await updateFlowStep(request, tokenJsonResponse.data.token, triggerStepId, { + appKey: 'rss', + key: 'newItemsInFeed', + parameters: { feedUrl: 'https://feeds.bbci.co.uk/news/rss.xml' }, + }); + await testStep(request, tokenJsonResponse.data.token, triggerStepId); + + await updateFlowStep(request, tokenJsonResponse.data.token, actionStepId, { + appKey: 'datastore', + key: 'setValue', + parameters: { + key: 'newsTitle', + value: '{{step.' + triggerStepId + '.title}}', + }, + }); + await testStep(request, tokenJsonResponse.data.token, actionStepId); + + await publishFlow(request, tokenJsonResponse.data.token, flowId); + + await page.reload(); + + await flowsPage.deleteFlow(flowId); + + await expectNoDelayedJobForFlow(request, flowId); +});