From da32fcc779e70cd80b62827e64c0589ed1039eb9 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Mon, 3 Feb 2025 17:25:21 +0000 Subject: [PATCH 1/4] Modernise automation step tests. --- .../src/automations/tests/automation.spec.ts | 6 +- .../tests/{scenarios => }/branching.spec.ts | 14 +- .../src/automations/tests/delay.spec.ts | 22 -- .../src/automations/tests/discord.spec.ts | 26 -- .../src/automations/tests/filter.spec.ts | 64 ----- .../server/src/automations/tests/loop.spec.ts | 153 ------------ .../server/src/automations/tests/make.spec.ts | 58 ----- .../server/src/automations/tests/n8n.spec.ts | 71 ------ .../src/automations/tests/openai.spec.ts | 165 ------------ .../automations/tests/outgoingWebhook.spec.ts | 37 --- .../tests/{scenarios => }/scenarios.spec.ts | 36 +-- .../src/automations/tests/serverLog.spec.ts | 20 -- .../tests/{ => steps}/bash.spec.ts | 48 ++-- .../tests/{ => steps}/createRow.spec.ts | 76 +++--- .../{ => steps}/cron-automations.spec.ts | 28 +-- .../src/automations/tests/steps/delay.spec.ts | 29 +++ .../tests/{ => steps}/deleteRow.spec.ts | 45 ++-- .../automations/tests/steps/discord.spec.ts | 33 +++ .../tests/{ => steps}/executeQuery.spec.ts | 4 +- .../tests/{ => steps}/executeScript.spec.ts | 51 ++-- .../automations/tests/steps/filter.spec.ts | 71 ++++++ .../looping.spec.ts => steps/loop.spec.ts} | 235 ++++++++++++------ .../src/automations/tests/steps/make.spec.ts | 74 ++++++ .../src/automations/tests/steps/n8n.spec.ts | 88 +++++++ .../automations/tests/steps/openai.spec.ts | 119 +++++++++ .../tests/steps/outgoingWebhook.spec.ts | 53 ++++ .../tests/{ => steps}/queryRows.spec.ts | 26 +- .../tests/{ => steps}/sendSmtpEmail.spec.ts | 17 +- .../automations/tests/steps/serverLog.spec.ts | 25 ++ .../tests/steps/triggerAutomationRun.spec.ts | 52 ++++ .../tests/{ => steps}/updateRow.spec.ts | 15 +- .../automations/tests/steps/zapier.spec.ts | 72 ++++++ .../tests/triggerAutomationRun.spec.ts | 54 ---- .../{scenarios => triggers}/webhook.spec.ts | 12 +- .../tests/utilities/AutomationTestBuilder.ts | 109 +++++++- .../src/automations/tests/zapier.spec.ts | 58 ----- .../src/tests/utilities/mocks/openai.ts | 88 ++++++- .../server/src/tests/utilities/structures.ts | 82 ------ .../app/automation/StepInputsOutputs.ts | 7 +- 39 files changed, 1137 insertions(+), 1106 deletions(-) rename packages/server/src/automations/tests/{scenarios => }/branching.spec.ts (97%) delete mode 100644 packages/server/src/automations/tests/delay.spec.ts delete mode 100644 packages/server/src/automations/tests/discord.spec.ts delete mode 100644 packages/server/src/automations/tests/filter.spec.ts delete mode 100644 packages/server/src/automations/tests/loop.spec.ts delete mode 100644 packages/server/src/automations/tests/make.spec.ts delete mode 100644 packages/server/src/automations/tests/n8n.spec.ts delete mode 100644 packages/server/src/automations/tests/openai.spec.ts delete mode 100644 packages/server/src/automations/tests/outgoingWebhook.spec.ts rename packages/server/src/automations/tests/{scenarios => }/scenarios.spec.ts (94%) delete mode 100644 packages/server/src/automations/tests/serverLog.spec.ts rename packages/server/src/automations/tests/{ => steps}/bash.spec.ts (79%) rename packages/server/src/automations/tests/{ => steps}/createRow.spec.ts (83%) rename packages/server/src/automations/tests/{ => steps}/cron-automations.spec.ts (50%) create mode 100644 packages/server/src/automations/tests/steps/delay.spec.ts rename packages/server/src/automations/tests/{ => steps}/deleteRow.spec.ts (52%) create mode 100644 packages/server/src/automations/tests/steps/discord.spec.ts rename packages/server/src/automations/tests/{ => steps}/executeQuery.spec.ts (96%) rename packages/server/src/automations/tests/{ => steps}/executeScript.spec.ts (72%) create mode 100644 packages/server/src/automations/tests/steps/filter.spec.ts rename packages/server/src/automations/tests/{scenarios/looping.spec.ts => steps/loop.spec.ts} (72%) create mode 100644 packages/server/src/automations/tests/steps/make.spec.ts create mode 100644 packages/server/src/automations/tests/steps/n8n.spec.ts create mode 100644 packages/server/src/automations/tests/steps/openai.spec.ts create mode 100644 packages/server/src/automations/tests/steps/outgoingWebhook.spec.ts rename packages/server/src/automations/tests/{ => steps}/queryRows.spec.ts (88%) rename packages/server/src/automations/tests/{ => steps}/sendSmtpEmail.spec.ts (84%) create mode 100644 packages/server/src/automations/tests/steps/serverLog.spec.ts create mode 100644 packages/server/src/automations/tests/steps/triggerAutomationRun.spec.ts rename packages/server/src/automations/tests/{ => steps}/updateRow.spec.ts (95%) create mode 100644 packages/server/src/automations/tests/steps/zapier.spec.ts delete mode 100644 packages/server/src/automations/tests/triggerAutomationRun.spec.ts rename packages/server/src/automations/tests/{scenarios => triggers}/webhook.spec.ts (89%) delete mode 100644 packages/server/src/automations/tests/zapier.spec.ts diff --git a/packages/server/src/automations/tests/automation.spec.ts b/packages/server/src/automations/tests/automation.spec.ts index c37c9cc7ce..7cd49f664f 100644 --- a/packages/server/src/automations/tests/automation.spec.ts +++ b/packages/server/src/automations/tests/automation.spec.ts @@ -17,11 +17,11 @@ import { basicAutomation } from "../../tests/utilities/structures" import { wait } from "../../utilities" import { makePartial } from "../../tests/utilities" import { cleanInputValues } from "../automationUtils" -import * as setup from "./utilities" import { Automation } from "@budibase/types" +import TestConfiguration from "../../tests/utilities/TestConfiguration" describe("Run through some parts of the automations system", () => { - let config = setup.getConfig() + const config = new TestConfiguration() beforeAll(async () => { await automation.init() @@ -30,7 +30,7 @@ describe("Run through some parts of the automations system", () => { afterAll(async () => { await automation.shutdown() - setup.afterAll() + config.end() }) it("should be able to init in builder", async () => { diff --git a/packages/server/src/automations/tests/scenarios/branching.spec.ts b/packages/server/src/automations/tests/branching.spec.ts similarity index 97% rename from packages/server/src/automations/tests/scenarios/branching.spec.ts rename to packages/server/src/automations/tests/branching.spec.ts index c05ec2f663..c2e3f50b8a 100644 --- a/packages/server/src/automations/tests/scenarios/branching.spec.ts +++ b/packages/server/src/automations/tests/branching.spec.ts @@ -1,11 +1,11 @@ -import * as automation from "../../index" -import * as setup from "../utilities" +import * as automation from "../index" import { Table, AutomationStatus } from "@budibase/types" -import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" +import { createAutomationBuilder } from "./utilities/AutomationTestBuilder" +import TestConfiguration from "../../tests/utilities/TestConfiguration" describe("Branching automations", () => { - let config = setup.getConfig(), - table: Table + const config = new TestConfiguration() + let table: Table beforeEach(async () => { await automation.init() @@ -14,7 +14,9 @@ describe("Branching automations", () => { await config.createRow() }) - afterAll(setup.afterAll) + afterAll(() => { + config.end() + }) it("should run a multiple nested branching automation", async () => { const firstLogId = "11111111-1111-1111-1111-111111111111" diff --git a/packages/server/src/automations/tests/delay.spec.ts b/packages/server/src/automations/tests/delay.spec.ts deleted file mode 100644 index 7ed5fe7482..0000000000 --- a/packages/server/src/automations/tests/delay.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { runStep, actions, getConfig } from "./utilities" -import { reset } from "timekeeper" - -// need real Date for this test -reset() - -describe("test the delay logic", () => { - const config = getConfig() - - beforeAll(async () => { - await config.init() - }) - - it("should be able to run the delay", async () => { - const time = 100 - const before = Date.now() - await runStep(config, actions.DELAY.stepId, { time: time }) - const now = Date.now() - // divide by two just so that test will always pass as long as there was some sort of delay - expect(now - before).toBeGreaterThanOrEqual(time / 2) - }) -}) diff --git a/packages/server/src/automations/tests/discord.spec.ts b/packages/server/src/automations/tests/discord.spec.ts deleted file mode 100644 index 491fe0fb25..0000000000 --- a/packages/server/src/automations/tests/discord.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { getConfig, afterAll as _afterAll, runStep, actions } from "./utilities" -import nock from "nock" - -describe("test the outgoing webhook action", () => { - let config = getConfig() - - beforeAll(async () => { - await config.init() - }) - - afterAll(_afterAll) - - beforeEach(() => { - nock.cleanAll() - }) - - it("should be able to run the action", async () => { - nock("http://www.example.com/").post("/").reply(200, { foo: "bar" }) - const res = await runStep(config, actions.discord.stepId, { - url: "http://www.example.com", - username: "joe_bloggs", - }) - expect(res.response.foo).toEqual("bar") - expect(res.success).toEqual(true) - }) -}) diff --git a/packages/server/src/automations/tests/filter.spec.ts b/packages/server/src/automations/tests/filter.spec.ts deleted file mode 100644 index 674516517a..0000000000 --- a/packages/server/src/automations/tests/filter.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import * as setup from "./utilities" -import { automations } from "@budibase/shared-core" - -const FilterConditions = automations.steps.filter.FilterConditions - -describe("test the filter logic", () => { - const config = setup.getConfig() - - beforeAll(async () => { - await config.init() - }) - - async function checkFilter( - field: any, - condition: string, - value: any, - pass = true - ) { - let res = await setup.runStep(config, setup.actions.FILTER.stepId, { - field, - condition, - value, - }) - expect(res.result).toEqual(pass) - expect(res.success).toEqual(true) - } - - it("should be able test equality", async () => { - await checkFilter("hello", FilterConditions.EQUAL, "hello", true) - await checkFilter("hello", FilterConditions.EQUAL, "no", false) - }) - - it("should be able to test greater than", async () => { - await checkFilter(10, FilterConditions.GREATER_THAN, 5, true) - await checkFilter(10, FilterConditions.GREATER_THAN, 15, false) - }) - - it("should be able to test less than", async () => { - await checkFilter(5, FilterConditions.LESS_THAN, 10, true) - await checkFilter(15, FilterConditions.LESS_THAN, 10, false) - }) - - it("should be able to in-equality", async () => { - await checkFilter("hello", FilterConditions.NOT_EQUAL, "no", true) - await checkFilter(10, FilterConditions.NOT_EQUAL, 10, false) - }) - - it("check number coercion", async () => { - await checkFilter("10", FilterConditions.GREATER_THAN, "5", true) - }) - - it("check date coercion", async () => { - await checkFilter( - new Date().toISOString(), - FilterConditions.GREATER_THAN, - new Date(-10000).toISOString(), - true - ) - }) - - it("check objects always false", async () => { - await checkFilter({}, FilterConditions.EQUAL, {}, false) - }) -}) diff --git a/packages/server/src/automations/tests/loop.spec.ts b/packages/server/src/automations/tests/loop.spec.ts deleted file mode 100644 index 2199a2a3a0..0000000000 --- a/packages/server/src/automations/tests/loop.spec.ts +++ /dev/null @@ -1,153 +0,0 @@ -import * as automation from "../index" -import * as triggers from "../triggers" -import { loopAutomation } from "../../tests/utilities/structures" -import { context } from "@budibase/backend-core" -import * as setup from "./utilities" -import { Table, LoopStepType, AutomationResults } from "@budibase/types" -import * as loopUtils from "../loopUtils" -import { LoopInput } from "../../definitions/automations" - -describe("Attempt to run a basic loop automation", () => { - let config = setup.getConfig(), - table: Table - - beforeEach(async () => { - await automation.init() - await config.init() - table = await config.createTable() - await config.createRow() - }) - - afterAll(setup.afterAll) - - async function runLoop(loopOpts?: LoopInput): Promise { - const appId = config.getAppId() - return await context.doInAppContext(appId, async () => { - const params = { fields: { appId } } - const result = await triggers.externalTrigger( - loopAutomation(table._id!, loopOpts), - params, - { getResponses: true } - ) - if ("outputs" in result && !result.outputs.success) { - throw new Error("Unable to proceed - failed to return anything.") - } - return result as AutomationResults - }) - } - - it("attempt to run a basic loop", async () => { - const resp = await runLoop() - expect(resp.steps[2].outputs.iterations).toBe(1) - }) - - it("test a loop with a string", async () => { - const resp = await runLoop({ - option: LoopStepType.STRING, - binding: "a,b,c", - }) - expect(resp.steps[2].outputs.iterations).toBe(3) - }) - - it("test a loop with a binding that returns an integer", async () => { - const resp = await runLoop({ - option: LoopStepType.ARRAY, - binding: "{{ 1 }}", - }) - expect(resp.steps[2].outputs.iterations).toBe(1) - }) - - describe("replaceFakeBindings", () => { - it("should replace loop bindings in nested objects", () => { - const originalStepInput = { - schema: { - name: { - type: "string", - constraints: { - type: "string", - length: { maximum: null }, - presence: false, - }, - name: "name", - display: { type: "Text" }, - }, - }, - row: { - tableId: "ta_aaad4296e9f74b12b1b90ef7a84afcad", - name: "{{ loop.currentItem.pokemon }}", - }, - } - - const loopStepNumber = 3 - - const result = loopUtils.replaceFakeBindings( - originalStepInput, - loopStepNumber - ) - - expect(result).toEqual({ - schema: { - name: { - type: "string", - constraints: { - type: "string", - length: { maximum: null }, - presence: false, - }, - name: "name", - display: { type: "Text" }, - }, - }, - row: { - tableId: "ta_aaad4296e9f74b12b1b90ef7a84afcad", - name: "{{ steps.3.currentItem.pokemon }}", - }, - }) - }) - - it("should handle null values in nested objects", () => { - const originalStepInput = { - nullValue: null, - nestedNull: { - someKey: null, - }, - validValue: "{{ loop.someValue }}", - } - - const loopStepNumber = 2 - - const result = loopUtils.replaceFakeBindings( - originalStepInput, - loopStepNumber - ) - - expect(result).toEqual({ - nullValue: null, - nestedNull: { - someKey: null, - }, - validValue: "{{ steps.2.someValue }}", - }) - }) - - it("should handle empty objects and arrays", () => { - const originalStepInput = { - emptyObject: {}, - emptyArray: [], - nestedEmpty: { - emptyObj: {}, - emptyArr: [], - }, - } - - const loopStepNumber = 1 - - const result = loopUtils.replaceFakeBindings( - originalStepInput, - loopStepNumber - ) - - expect(result).toEqual(originalStepInput) - }) - }) -}) diff --git a/packages/server/src/automations/tests/make.spec.ts b/packages/server/src/automations/tests/make.spec.ts deleted file mode 100644 index 414ac676d5..0000000000 --- a/packages/server/src/automations/tests/make.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { getConfig, afterAll, runStep, actions } from "./utilities" -import nock from "nock" - -describe("test the outgoing webhook action", () => { - let config = getConfig() - - beforeAll(async () => { - await config.init() - }) - - afterAll() - - beforeEach(() => { - nock.cleanAll() - }) - - it("should be able to run the action", async () => { - nock("http://www.example.com/").post("/").reply(200, { foo: "bar" }) - const res = await runStep(config, actions.integromat.stepId, { - url: "http://www.example.com", - }) - expect(res.response.foo).toEqual("bar") - expect(res.success).toEqual(true) - }) - - it("should add the payload props when a JSON string is provided", async () => { - const payload = { - value1: 1, - value2: 2, - value3: 3, - value4: 4, - value5: 5, - name: "Adam", - age: 9, - } - - nock("http://www.example.com/") - .post("/", payload) - .reply(200, { foo: "bar" }) - - const res = await runStep(config, actions.integromat.stepId, { - body: { value: JSON.stringify(payload) }, - url: "http://www.example.com", - }) - expect(res.response.foo).toEqual("bar") - expect(res.success).toEqual(true) - }) - - it("should return a 400 if the JSON payload string is malformed", async () => { - const res = await runStep(config, actions.integromat.stepId, { - body: { value: "{ invalid json }" }, - url: "http://www.example.com", - }) - expect(res.httpStatus).toEqual(400) - expect(res.response).toEqual("Invalid payload JSON") - expect(res.success).toEqual(false) - }) -}) diff --git a/packages/server/src/automations/tests/n8n.spec.ts b/packages/server/src/automations/tests/n8n.spec.ts deleted file mode 100644 index 5f27e4323a..0000000000 --- a/packages/server/src/automations/tests/n8n.spec.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { getConfig, afterAll, runStep, actions } from "./utilities" -import nock from "nock" - -describe("test the outgoing webhook action", () => { - let config = getConfig() - - beforeAll(async () => { - await config.init() - }) - - afterAll() - - beforeEach(() => { - nock.cleanAll() - }) - - it("should be able to run the action and default to 'get'", async () => { - nock("http://www.example.com/").get("/").reply(200, { foo: "bar" }) - const res = await runStep(config, actions.n8n.stepId, { - url: "http://www.example.com", - body: { - test: "IGNORE_ME", - }, - }) - expect(res.response.foo).toEqual("bar") - expect(res.success).toEqual(true) - }) - - it("should add the payload props when a JSON string is provided", async () => { - nock("http://www.example.com/") - .post("/", { name: "Adam", age: 9 }) - .reply(200) - const res = await runStep(config, actions.n8n.stepId, { - body: { - value: JSON.stringify({ name: "Adam", age: 9 }), - }, - method: "POST", - url: "http://www.example.com", - }) - expect(res.success).toEqual(true) - }) - - it("should return a 400 if the JSON payload string is malformed", async () => { - const payload = `{ value1 1 }` - const res = await runStep(config, actions.n8n.stepId, { - value1: "ONE", - body: { - value: payload, - }, - method: "POST", - url: "http://www.example.com", - }) - expect(res.httpStatus).toEqual(400) - expect(res.response).toEqual("Invalid payload JSON") - expect(res.success).toEqual(false) - }) - - it("should not append the body if the method is HEAD", async () => { - nock("http://www.example.com/") - .head("/", body => body === "") - .reply(200) - const res = await runStep(config, actions.n8n.stepId, { - url: "http://www.example.com", - method: "HEAD", - body: { - test: "IGNORE_ME", - }, - }) - expect(res.success).toEqual(true) - }) -}) diff --git a/packages/server/src/automations/tests/openai.spec.ts b/packages/server/src/automations/tests/openai.spec.ts deleted file mode 100644 index 1985465fc0..0000000000 --- a/packages/server/src/automations/tests/openai.spec.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { getConfig, afterAll as _afterAll } from "./utilities" -import { createAutomationBuilder } from "./utilities/AutomationTestBuilder" -import { OpenAI } from "openai" -import { setEnv as setCoreEnv } from "@budibase/backend-core" -import * as pro from "@budibase/pro" -import { Model } from "@budibase/types" - -jest.mock("openai", () => ({ - OpenAI: jest.fn().mockImplementation(() => ({ - chat: { - completions: { - create: jest.fn(() => ({ - choices: [ - { - message: { - content: "This is a test", - }, - }, - ], - })), - }, - }, - })), -})) -jest.mock("@budibase/pro", () => ({ - ...jest.requireActual("@budibase/pro"), - ai: { - LargeLanguageModel: { - forCurrentTenant: jest.fn().mockImplementation(() => ({ - llm: {}, - init: jest.fn(), - run: jest.fn(), - })), - }, - }, - features: { - isAICustomConfigsEnabled: jest.fn(), - isBudibaseAIEnabled: jest.fn(), - }, -})) - -const mockedPro = jest.mocked(pro) -const mockedOpenAI = OpenAI as jest.MockedClass - -const OPENAI_PROMPT = "What is the meaning of life?" - -describe("test the openai action", () => { - let config = getConfig() - let resetEnv: () => void | undefined - - beforeAll(async () => { - setCoreEnv({ SELF_HOSTED: true }) - await config.init() - }) - - beforeEach(() => { - resetEnv = setCoreEnv({ OPENAI_API_KEY: "abc123" }) - }) - - afterEach(() => { - resetEnv() - jest.clearAllMocks() - }) - - afterAll(_afterAll) - - it("should be able to receive a response from ChatGPT given a prompt", async () => { - setCoreEnv({ SELF_HOSTED: true }) - - const result = await createAutomationBuilder({ - name: "Test OpenAI Response", - config, - }) - .appAction({ fields: {} }) - .openai( - { prompt: OPENAI_PROMPT, model: Model.GPT_4O_MINI }, - { stepName: "Basic OpenAI Query" } - ) - .run() - - expect(result.steps[0].outputs.response).toEqual("This is a test") - expect(result.steps[0].outputs.success).toBeTruthy() - }) - - it("should present the correct error message when a prompt is not provided", async () => { - const result = await createAutomationBuilder({ - name: "Test OpenAI No Prompt", - config, - }) - .appAction({ fields: {} }) - .openai( - { prompt: "", model: Model.GPT_4O_MINI }, - { stepName: "Empty Prompt Query" } - ) - .run() - - expect(result.steps[0].outputs.response).toEqual( - "Budibase OpenAI Automation Failed: No prompt supplied" - ) - expect(result.steps[0].outputs.success).toBeFalsy() - }) - - it("should present the correct error message when an error is thrown from the createChatCompletion call", async () => { - mockedOpenAI.mockImplementation( - () => - ({ - chat: { - completions: { - create: jest.fn(() => { - throw new Error( - "An error occurred while calling createChatCompletion" - ) - }), - }, - }, - } as any) - ) - - const result = await createAutomationBuilder({ - name: "Test OpenAI Error", - config, - }) - .appAction({ fields: {} }) - .openai( - { prompt: OPENAI_PROMPT, model: Model.GPT_4O_MINI }, - { stepName: "Error Producing Query" } - ) - .run() - - expect(result.steps[0].outputs.response).toEqual( - "Error: An error occurred while calling createChatCompletion" - ) - expect(result.steps[0].outputs.success).toBeFalsy() - }) - - it("should ensure that the pro AI module is called when the budibase AI features are enabled", async () => { - jest.spyOn(pro.features, "isBudibaseAIEnabled").mockResolvedValue(true) - jest.spyOn(pro.features, "isAICustomConfigsEnabled").mockResolvedValue(true) - - const prompt = "What is the meaning of life?" - await createAutomationBuilder({ - name: "Test OpenAI Pro Features", - config, - }) - .appAction({ fields: {} }) - .openai( - { - model: Model.GPT_4O_MINI, - prompt, - }, - { stepName: "Pro Features Query" } - ) - .run() - - expect(pro.ai.LargeLanguageModel.forCurrentTenant).toHaveBeenCalledWith( - "gpt-4o-mini" - ) - - const llmInstance = - mockedPro.ai.LargeLanguageModel.forCurrentTenant.mock.results[0].value - // init does not appear to be called currently - // expect(llmInstance.init).toHaveBeenCalled() - expect(llmInstance.run).toHaveBeenCalledWith(prompt) - }) -}) diff --git a/packages/server/src/automations/tests/outgoingWebhook.spec.ts b/packages/server/src/automations/tests/outgoingWebhook.spec.ts deleted file mode 100644 index 995ab24bac..0000000000 --- a/packages/server/src/automations/tests/outgoingWebhook.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { getConfig, afterAll as _afterAll, runStep, actions } from "./utilities" -import nock from "nock" - -describe("test the outgoing webhook action", () => { - const config = getConfig() - - beforeAll(async () => { - await config.init() - }) - - afterAll(_afterAll) - - beforeEach(() => { - nock.cleanAll() - }) - - it("should be able to run the action", async () => { - nock("http://www.example.com") - .post("/", { a: 1 }) - .reply(200, { foo: "bar" }) - const res = await runStep(config, actions.OUTGOING_WEBHOOK.stepId, { - requestMethod: "POST", - url: "www.example.com", - requestBody: JSON.stringify({ a: 1 }), - }) - expect(res.success).toEqual(true) - expect(res.response.foo).toEqual("bar") - }) - - it("should return an error if something goes wrong in fetch", async () => { - const res = await runStep(config, actions.OUTGOING_WEBHOOK.stepId, { - requestMethod: "GET", - url: "www.invalid.com", - }) - expect(res.success).toEqual(false) - }) -}) diff --git a/packages/server/src/automations/tests/scenarios/scenarios.spec.ts b/packages/server/src/automations/tests/scenarios.spec.ts similarity index 94% rename from packages/server/src/automations/tests/scenarios/scenarios.spec.ts rename to packages/server/src/automations/tests/scenarios.spec.ts index 3e203b7959..55cfb61337 100644 --- a/packages/server/src/automations/tests/scenarios/scenarios.spec.ts +++ b/packages/server/src/automations/tests/scenarios.spec.ts @@ -1,26 +1,28 @@ -import * as automation from "../../index" -import * as setup from "../utilities" +import * as automation from "../index" import { LoopStepType, FieldType, Table, Datasource } from "@budibase/types" -import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" +import { createAutomationBuilder } from "./utilities/AutomationTestBuilder" import { DatabaseName, datasourceDescribe, -} from "../../../integrations/tests/utils" +} from "../../integrations/tests/utils" import { Knex } from "knex" import { generator } from "@budibase/backend-core/tests" import { automations } from "@budibase/shared-core" +import TestConfiguration from "../../tests/utilities/TestConfiguration" const FilterConditions = automations.steps.filter.FilterConditions describe("Automation Scenarios", () => { - let config = setup.getConfig() + const config = new TestConfiguration() beforeEach(async () => { await automation.init() await config.init() }) - afterAll(setup.afterAll) + afterAll(() => { + config.end() + }) describe("Row Automations", () => { it("should trigger an automation which then creates a row", async () => { @@ -58,7 +60,7 @@ describe("Automation Scenarios", () => { }) }) - it("should trigger an automation which querys the database", async () => { + it("should trigger an automation which queries the database", async () => { const table = await config.createTable() const row = { name: "Test Row", @@ -82,7 +84,7 @@ describe("Automation Scenarios", () => { expect(results.steps[0].outputs.rows).toHaveLength(2) }) - it("should trigger an automation which querys the database then deletes a row", async () => { + it("should trigger an automation which queries the database then deletes a row", async () => { const table = await config.createTable() const row = { name: "DFN", @@ -484,12 +486,18 @@ if (descriptions.length) { await client(tableName).insert(rows) - const query = await setup.saveTestQuery( - config, - client, - tableName, - datasource - ) + const query = await config.api.query.save({ + name: "test query", + datasourceId: datasource._id!, + parameters: [], + fields: { + sql: client(tableName).select("*").toSQL().toNative().sql, + }, + transformer: "", + schema: {}, + readable: true, + queryVerb: "read", + }) const builder = createAutomationBuilder({ name: "Test external query and save", diff --git a/packages/server/src/automations/tests/serverLog.spec.ts b/packages/server/src/automations/tests/serverLog.spec.ts deleted file mode 100644 index c2c1c385b6..0000000000 --- a/packages/server/src/automations/tests/serverLog.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { getConfig, afterAll as _afterAll, runStep, actions } from "./utilities" - -describe("test the server log action", () => { - let config = getConfig() - let inputs: any - - beforeAll(async () => { - await config.init() - inputs = { - text: "log message", - } - }) - afterAll(_afterAll) - - it("should be able to log the text", async () => { - let res = await runStep(config, actions.SERVER_LOG.stepId, inputs) - expect(res.message).toEqual(`App ${config.getAppId()} - ${inputs.text}`) - expect(res.success).toEqual(true) - }) -}) diff --git a/packages/server/src/automations/tests/bash.spec.ts b/packages/server/src/automations/tests/steps/bash.spec.ts similarity index 79% rename from packages/server/src/automations/tests/bash.spec.ts rename to packages/server/src/automations/tests/steps/bash.spec.ts index 12ed784268..ef10f9b568 100644 --- a/packages/server/src/automations/tests/bash.spec.ts +++ b/packages/server/src/automations/tests/steps/bash.spec.ts @@ -1,30 +1,30 @@ -import { createAutomationBuilder } from "./utilities/AutomationTestBuilder" -import * as automation from "../index" -import * as setup from "./utilities" +import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" +import * as automation from "../../index" import { Table } from "@budibase/types" +import TestConfiguration from "../../../tests/utilities/TestConfiguration" +import { basicTable } from "../../../tests/utilities/structures" describe("Execute Bash Automations", () => { - let config = setup.getConfig(), - table: Table + const config = new TestConfiguration() + let table: Table beforeAll(async () => { await automation.init() await config.init() - table = await config.createTable() - await config.createRow({ + table = await config.api.table.save(basicTable()) + await config.api.row.save(table._id!, { name: "test row", description: "test description", - tableId: table._id!, }) }) - afterAll(setup.afterAll) + afterAll(() => { + automation.shutdown() + config.end() + }) it("should use trigger data in bash command and pass output to subsequent steps", async () => { - const result = await createAutomationBuilder({ - name: "Bash with Trigger Data", - config, - }) + const result = await createAutomationBuilder({ config }) .appAction({ fields: { command: "hello world" } }) .bash( { code: "echo '{{ trigger.fields.command }}'" }, @@ -43,10 +43,7 @@ describe("Execute Bash Automations", () => { }) it("should chain multiple bash commands using previous outputs", async () => { - const result = await createAutomationBuilder({ - name: "Chained Bash Commands", - config, - }) + const result = await createAutomationBuilder({ config }) .appAction({ fields: { filename: "testfile.txt" } }) .bash( { code: "echo 'initial content' > {{ trigger.fields.filename }}" }, @@ -67,10 +64,7 @@ describe("Execute Bash Automations", () => { }) it("should integrate bash output with row operations", async () => { - const result = await createAutomationBuilder({ - name: "Bash with Row Operations", - config, - }) + const result = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .queryRows( { @@ -100,10 +94,7 @@ describe("Execute Bash Automations", () => { }) it("should handle bash output in conditional logic", async () => { - const result = await createAutomationBuilder({ - name: "Bash with Conditional", - config, - }) + const result = await createAutomationBuilder({ config }) .appAction({ fields: { threshold: "5" } }) .bash( { code: "echo $(( {{ trigger.fields.threshold }} + 5 ))" }, @@ -130,13 +121,10 @@ describe("Execute Bash Automations", () => { }) it("should handle null values gracefully", async () => { - const result = await createAutomationBuilder({ - name: "Null Bash Input", - config, - }) + const result = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .bash( - //@ts-ignore + // @ts-expect-error - testing null input { code: null }, { stepName: "Null Command" } ) diff --git a/packages/server/src/automations/tests/createRow.spec.ts b/packages/server/src/automations/tests/steps/createRow.spec.ts similarity index 83% rename from packages/server/src/automations/tests/createRow.spec.ts rename to packages/server/src/automations/tests/steps/createRow.spec.ts index bd78de2217..5b86556ae3 100644 --- a/packages/server/src/automations/tests/createRow.spec.ts +++ b/packages/server/src/automations/tests/steps/createRow.spec.ts @@ -1,7 +1,11 @@ -import * as setup from "./utilities" -import { basicTableWithAttachmentField } from "../../tests/utilities/structures" +import { + basicTable, + basicTableWithAttachmentField, +} from "../../../tests/utilities/structures" import { objectStore } from "@budibase/backend-core" -import { createAutomationBuilder } from "./utilities/AutomationTestBuilder" +import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" +import { Row, Table } from "@budibase/types" +import TestConfiguration from "../../../tests/utilities/TestConfiguration" async function uploadTestFile(filename: string) { let bucket = "testbucket" @@ -10,19 +14,20 @@ async function uploadTestFile(filename: string) { filename, body: Buffer.from("test data"), }) - let presignedUrl = await objectStore.getPresignedUrl(bucket, filename, 60000) + let presignedUrl = objectStore.getPresignedUrl(bucket, filename, 60000) return presignedUrl } describe("test the create row action", () => { - let table: any - let row: any - let config = setup.getConfig() + const config = new TestConfiguration() + + let table: Table + let row: Row beforeEach(async () => { await config.init() - table = await config.createTable() + table = await config.api.table.save(basicTable()) row = { tableId: table._id, name: "test", @@ -30,14 +35,12 @@ describe("test the create row action", () => { } }) - afterAll(setup.afterAll) + afterAll(() => { + config.end() + }) it("should be able to run the action", async () => { - const result = await createAutomationBuilder({ - name: "Test Create Row Flow", - appId: config.getAppId(), - config, - }) + const result = await createAutomationBuilder({ config }) .appAction({ fields: { status: "new" } }) .serverLog({ text: "Starting create row flow" }, { stepName: "StartLog" }) .createRow({ row }, { stepName: "CreateRow" }) @@ -50,8 +53,9 @@ describe("test the create row action", () => { expect(result.steps[1].outputs.success).toBeDefined() expect(result.steps[1].outputs.id).toBeDefined() expect(result.steps[1].outputs.revision).toBeDefined() + const gottenRow = await config.api.row.get( - table._id, + table._id!, result.steps[1].outputs.id ) expect(gottenRow.name).toEqual("test") @@ -62,11 +66,7 @@ describe("test the create row action", () => { }) it("should return an error (not throw) when bad info provided", async () => { - const result = await createAutomationBuilder({ - name: "Test Create Row Error Flow", - appId: config.getAppId(), - config, - }) + const result = await createAutomationBuilder({ config }) .appAction({ fields: { status: "error" } }) .serverLog({ text: "Starting error test flow" }, { stepName: "StartLog" }) .createRow( @@ -84,11 +84,7 @@ describe("test the create row action", () => { }) it("should check invalid inputs return an error", async () => { - const result = await createAutomationBuilder({ - name: "Test Create Row Invalid Flow", - appId: config.getAppId(), - config, - }) + const result = await createAutomationBuilder({ config }) .appAction({ fields: { status: "invalid" } }) .serverLog({ text: "Testing invalid input" }, { stepName: "StartLog" }) .createRow({ row: {} }, { stepName: "CreateRow" }) @@ -108,11 +104,11 @@ describe("test the create row action", () => { }) it("should check that an attachment field is sent to storage and parsed", async () => { - let attachmentTable = await config.createTable( + let attachmentTable = await config.api.table.save( basicTableWithAttachmentField() ) - let attachmentRow: any = { + let attachmentRow: Row = { tableId: attachmentTable._id, } @@ -126,11 +122,7 @@ describe("test the create row action", () => { ] attachmentRow.file_attachment = attachmentObject - const result = await createAutomationBuilder({ - name: "Test Create Row Attachment Flow", - appId: config.getAppId(), - config, - }) + const result = await createAutomationBuilder({ config }) .appAction({ fields: { type: "attachment" } }) .serverLog( { text: "Processing attachment upload" }, @@ -165,11 +157,11 @@ describe("test the create row action", () => { }) it("should check that an single attachment field is sent to storage and parsed", async () => { - let attachmentTable = await config.createTable( + let attachmentTable = await config.api.table.save( basicTableWithAttachmentField() ) - let attachmentRow: any = { + let attachmentRow: Row = { tableId: attachmentTable._id, } @@ -181,11 +173,7 @@ describe("test the create row action", () => { } attachmentRow.single_file_attachment = attachmentObject - const result = await createAutomationBuilder({ - name: "Test Create Row Single Attachment Flow", - appId: config.getAppId(), - config, - }) + const result = await createAutomationBuilder({ config }) .appAction({ fields: { type: "single-attachment" } }) .serverLog( { text: "Processing single attachment" }, @@ -240,11 +228,11 @@ describe("test the create row action", () => { }) it("should check that attachment without the correct keys throws an error", async () => { - let attachmentTable = await config.createTable( + let attachmentTable = await config.api.table.save( basicTableWithAttachmentField() ) - let attachmentRow: any = { + let attachmentRow: Row = { tableId: attachmentTable._id, } @@ -256,11 +244,7 @@ describe("test the create row action", () => { } attachmentRow.single_file_attachment = attachmentObject - const result = await createAutomationBuilder({ - name: "Test Create Row Invalid Attachment Flow", - appId: config.getAppId(), - config, - }) + const result = await createAutomationBuilder({ config }) .appAction({ fields: { type: "invalid-attachment" } }) .serverLog( { text: "Testing invalid attachment keys" }, diff --git a/packages/server/src/automations/tests/cron-automations.spec.ts b/packages/server/src/automations/tests/steps/cron-automations.spec.ts similarity index 50% rename from packages/server/src/automations/tests/cron-automations.spec.ts rename to packages/server/src/automations/tests/steps/cron-automations.spec.ts index 62c8ccd612..6a82ac1cde 100644 --- a/packages/server/src/automations/tests/cron-automations.spec.ts +++ b/packages/server/src/automations/tests/steps/cron-automations.spec.ts @@ -1,8 +1,8 @@ import tk from "timekeeper" -import "../../environment" -import * as automations from "../index" -import * as setup from "./utilities" -import { basicCronAutomation } from "../../tests/utilities/structures" +import "../../../environment" +import * as automations from "../../index" +import TestConfiguration from "../../../tests/utilities/TestConfiguration" +import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" const initialTime = Date.now() tk.freeze(initialTime) @@ -10,7 +10,7 @@ tk.freeze(initialTime) const oneMinuteInMs = 60 * 1000 describe("cron automations", () => { - let config = setup.getConfig() + const config = new TestConfiguration() beforeAll(async () => { await automations.init() @@ -19,26 +19,22 @@ describe("cron automations", () => { afterAll(async () => { await automations.shutdown() - setup.afterAll() + config.end() }) beforeEach(() => { tk.freeze(initialTime) }) - async function travel(ms: number) { - tk.travel(Date.now() + ms) - } - it("should initialise the automation timestamp", async () => { - const automation = basicCronAutomation(config.appId!, "* * * * *") - await config.api.automation.post(automation) - await travel(oneMinuteInMs) + await createAutomationBuilder({ config }).cron({ cron: "* * * * *" }).save() + + tk.travel(Date.now() + oneMinuteInMs) await config.publish() - const automationLogs = await config.getAutomationLogs() - expect(automationLogs.data).toHaveLength(1) - expect(automationLogs.data).toEqual([ + const { data } = await config.getAutomationLogs() + expect(data).toHaveLength(1) + expect(data).toEqual([ expect.objectContaining({ trigger: expect.objectContaining({ outputs: { timestamp: initialTime + oneMinuteInMs }, diff --git a/packages/server/src/automations/tests/steps/delay.spec.ts b/packages/server/src/automations/tests/steps/delay.spec.ts new file mode 100644 index 0000000000..a9e97b502f --- /dev/null +++ b/packages/server/src/automations/tests/steps/delay.spec.ts @@ -0,0 +1,29 @@ +import TestConfiguration from "../../../tests/utilities/TestConfiguration" +import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" + +describe("test the delay logic", () => { + const config = new TestConfiguration() + + beforeAll(async () => { + await config.init() + }) + + afterAll(() => { + config.end() + }) + + it("should be able to run the delay", async () => { + const time = 100 + const before = performance.now() + + await createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .delay({ time }) + .run() + + const now = performance.now() + + // divide by two just so that test will always pass as long as there was some sort of delay + expect(now - before).toBeGreaterThanOrEqual(time / 2) + }) +}) diff --git a/packages/server/src/automations/tests/deleteRow.spec.ts b/packages/server/src/automations/tests/steps/deleteRow.spec.ts similarity index 52% rename from packages/server/src/automations/tests/deleteRow.spec.ts rename to packages/server/src/automations/tests/steps/deleteRow.spec.ts index cabf590421..1815edb4d3 100644 --- a/packages/server/src/automations/tests/deleteRow.spec.ts +++ b/packages/server/src/automations/tests/steps/deleteRow.spec.ts @@ -1,44 +1,41 @@ -import { createAutomationBuilder } from "./utilities/AutomationTestBuilder" -import * as setup from "./utilities" +import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" +import { Row, Table } from "@budibase/types" +import TestConfiguration from "../../../tests/utilities/TestConfiguration" +import { basicTable } from "../../../tests/utilities/structures" describe("test the delete row action", () => { - let table: any, - row: any, - config = setup.getConfig() + const config = new TestConfiguration() + + let table: Table + let row: Row beforeAll(async () => { await config.init() - table = await config.createTable() - row = await config.createRow() + table = await config.api.table.save(basicTable()) + row = await config.api.row.save(table._id!, {}) }) - afterAll(setup.afterAll) + afterAll(() => { + config.end() + }) it("should be able to run the delete row action", async () => { - const builder = createAutomationBuilder({ - name: "Delete Row Automation", - }) - - await builder + await createAutomationBuilder({ config }) .appAction({ fields: {} }) .deleteRow({ - tableId: table._id, - id: row._id, + tableId: table._id!, + id: row._id!, revision: row._rev, }) .run() - await config.api.row.get(table._id, row._id, { + await config.api.row.get(table._id!, row._id!, { status: 404, }) }) it("should check invalid inputs return an error", async () => { - const builder = createAutomationBuilder({ - name: "Invalid Inputs Automation", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .deleteRow({ tableId: "", id: "", revision: "" }) .run() @@ -47,11 +44,7 @@ describe("test the delete row action", () => { }) it("should return an error when table doesn't exist", async () => { - const builder = createAutomationBuilder({ - name: "Nonexistent Table Automation", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .deleteRow({ tableId: "invalid", diff --git a/packages/server/src/automations/tests/steps/discord.spec.ts b/packages/server/src/automations/tests/steps/discord.spec.ts new file mode 100644 index 0000000000..5dc9c1088a --- /dev/null +++ b/packages/server/src/automations/tests/steps/discord.spec.ts @@ -0,0 +1,33 @@ +import TestConfiguration from "../../../tests/utilities/TestConfiguration" +import nock from "nock" +import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" + +describe("test the outgoing webhook action", () => { + const config = new TestConfiguration() + + beforeAll(async () => { + await config.init() + }) + + afterAll(() => { + config.end() + }) + + beforeEach(() => { + nock.cleanAll() + }) + + it("should be able to run the action", async () => { + nock("http://www.example.com/").post("/").reply(200, { foo: "bar" }) + const result = await createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .discord({ + url: "http://www.example.com", + username: "joe_bloggs", + content: "Hello, world", + }) + .run() + expect(result.steps[0].outputs.response.foo).toEqual("bar") + expect(result.steps[0].outputs.success).toEqual(true) + }) +}) diff --git a/packages/server/src/automations/tests/executeQuery.spec.ts b/packages/server/src/automations/tests/steps/executeQuery.spec.ts similarity index 96% rename from packages/server/src/automations/tests/executeQuery.spec.ts rename to packages/server/src/automations/tests/steps/executeQuery.spec.ts index 2d65be6e58..64dd808b85 100644 --- a/packages/server/src/automations/tests/executeQuery.spec.ts +++ b/packages/server/src/automations/tests/steps/executeQuery.spec.ts @@ -1,9 +1,9 @@ import { Datasource, Query } from "@budibase/types" -import * as setup from "./utilities" +import * as setup from "../utilities" import { DatabaseName, datasourceDescribe, -} from "../../integrations/tests/utils" +} from "../../../integrations/tests/utils" import { Knex } from "knex" import { generator } from "@budibase/backend-core/tests" diff --git a/packages/server/src/automations/tests/executeScript.spec.ts b/packages/server/src/automations/tests/steps/executeScript.spec.ts similarity index 72% rename from packages/server/src/automations/tests/executeScript.spec.ts rename to packages/server/src/automations/tests/steps/executeScript.spec.ts index f5845093bc..4470b8e760 100644 --- a/packages/server/src/automations/tests/executeScript.spec.ts +++ b/packages/server/src/automations/tests/steps/executeScript.spec.ts @@ -1,27 +1,26 @@ -import { createAutomationBuilder } from "./utilities/AutomationTestBuilder" -import * as automation from "../index" -import * as setup from "./utilities" +import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" +import * as automation from "../../index" import { Table } from "@budibase/types" +import TestConfiguration from "../../../tests/utilities/TestConfiguration" +import { basicTable } from "../../../tests/utilities/structures" describe("Execute Script Automations", () => { - let config = setup.getConfig(), - table: Table + const config = new TestConfiguration() + let table: Table - beforeEach(async () => { + beforeAll(async () => { await automation.init() await config.init() - table = await config.createTable() - await config.createRow() + table = await config.api.table.save(basicTable()) + await config.api.row.save(table._id!, {}) }) - afterAll(setup.afterAll) + afterAll(() => { + config.end() + }) it("should execute a basic script and return the result", async () => { - const builder = createAutomationBuilder({ - name: "Basic Script Execution", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .executeScript({ code: "return 2 + 2" }) .run() @@ -30,11 +29,7 @@ describe("Execute Script Automations", () => { }) it("should access bindings from previous steps", async () => { - const builder = createAutomationBuilder({ - name: "Access Bindings", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: { data: [1, 2, 3] } }) .executeScript( { @@ -48,11 +43,7 @@ describe("Execute Script Automations", () => { }) it("should handle script execution errors gracefully", async () => { - const builder = createAutomationBuilder({ - name: "Handle Script Errors", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .executeScript({ code: "return nonexistentVariable.map(x => x)" }) .run() @@ -64,11 +55,7 @@ describe("Execute Script Automations", () => { }) it("should handle conditional logic in scripts", async () => { - const builder = createAutomationBuilder({ - name: "Conditional Script Logic", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: { value: 10 } }) .executeScript({ code: ` @@ -85,11 +72,7 @@ describe("Execute Script Automations", () => { }) it("should use multiple steps and validate script execution", async () => { - const builder = createAutomationBuilder({ - name: "Multi-Step Script Execution", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .serverLog( { text: "Starting multi-step automation" }, diff --git a/packages/server/src/automations/tests/steps/filter.spec.ts b/packages/server/src/automations/tests/steps/filter.spec.ts new file mode 100644 index 0000000000..b0943f3fa8 --- /dev/null +++ b/packages/server/src/automations/tests/steps/filter.spec.ts @@ -0,0 +1,71 @@ +import { automations } from "@budibase/shared-core" +import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" +import TestConfiguration from "../../../tests/utilities/TestConfiguration" + +const FilterConditions = automations.steps.filter.FilterConditions + +function stringToFilterCondition(condition: "==" | "!=" | ">" | "<"): string { + switch (condition) { + case "==": + return FilterConditions.EQUAL + case "!=": + return FilterConditions.NOT_EQUAL + case ">": + return FilterConditions.GREATER_THAN + case "<": + return FilterConditions.LESS_THAN + } +} + +type TestCase = [any, "==" | "!=" | ">" | "<", any] + +describe("test the filter logic", () => { + const config = new TestConfiguration() + + beforeAll(async () => { + await config.init() + }) + + afterAll(() => { + config.end() + }) + + const pass: TestCase[] = [ + [10, ">", 5], + ["10", ">", 5], + [10, ">", "5"], + ["10", ">", "5"], + [10, "==", 10], + [10, "<", 15], + ["hello", "==", "hello"], + ["hello", "!=", "no"], + [new Date().toISOString(), ">", new Date(-10000).toISOString()], + ] + it.each(pass)("should pass %p %p %p", async (field, condition, value) => { + const result = await createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .filter({ field, condition: stringToFilterCondition(condition), value }) + .run() + + expect(result.steps[0].outputs.result).toEqual(true) + expect(result.steps[0].outputs.success).toEqual(true) + }) + + const fail: TestCase[] = [ + [10, ">", 15], + [10, "<", 5], + [10, "==", 5], + ["hello", "==", "no"], + ["hello", "!=", "hello"], + [{}, "==", {}], + ] + it.each(fail)("should fail %p %p %p", async (field, condition, value) => { + const result = await createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .filter({ field, condition: stringToFilterCondition(condition), value }) + .run() + + expect(result.steps[0].outputs.result).toEqual(false) + expect(result.steps[0].outputs.success).toEqual(true) + }) +}) diff --git a/packages/server/src/automations/tests/scenarios/looping.spec.ts b/packages/server/src/automations/tests/steps/loop.spec.ts similarity index 72% rename from packages/server/src/automations/tests/scenarios/looping.spec.ts rename to packages/server/src/automations/tests/steps/loop.spec.ts index 0baa69b3bc..af174c33ca 100644 --- a/packages/server/src/automations/tests/scenarios/looping.spec.ts +++ b/packages/server/src/automations/tests/steps/loop.spec.ts @@ -1,33 +1,78 @@ import * as automation from "../../index" -import * as setup from "../utilities" +import * as triggers from "../../triggers" +import { basicTable, loopAutomation } from "../../../tests/utilities/structures" +import { context } from "@budibase/backend-core" import { Table, LoopStepType, - CreateRowStepOutputs, + AutomationResults, ServerLogStepOutputs, + CreateRowStepOutputs, FieldType, } from "@budibase/types" +import * as loopUtils from "../../loopUtils" +import { LoopInput } from "../../../definitions/automations" import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" +import TestConfiguration from "../../../tests/utilities/TestConfiguration" -describe("Loop automations", () => { - let config = setup.getConfig(), - table: Table +describe("Attempt to run a basic loop automation", () => { + const config = new TestConfiguration() + let table: Table - beforeEach(async () => { - await automation.init() + beforeAll(async () => { await config.init() - table = await config.createTable() - await config.createRow() + await automation.init() }) - afterAll(setup.afterAll) + beforeEach(async () => { + table = await config.api.table.save(basicTable()) + await config.api.row.save(table._id!, {}) + }) + + afterAll(() => { + automation.shutdown() + config.end() + }) + + async function runLoop(loopOpts?: LoopInput): Promise { + const appId = config.getAppId() + return await context.doInAppContext(appId, async () => { + const params = { fields: { appId } } + const result = await triggers.externalTrigger( + loopAutomation(table._id!, loopOpts), + params, + { getResponses: true } + ) + if ("outputs" in result && !result.outputs.success) { + throw new Error("Unable to proceed - failed to return anything.") + } + return result as AutomationResults + }) + } + + it("attempt to run a basic loop", async () => { + const resp = await runLoop() + expect(resp.steps[2].outputs.iterations).toBe(1) + }) + + it("test a loop with a string", async () => { + const resp = await runLoop({ + option: LoopStepType.STRING, + binding: "a,b,c", + }) + expect(resp.steps[2].outputs.iterations).toBe(3) + }) + + it("test a loop with a binding that returns an integer", async () => { + const resp = await runLoop({ + option: LoopStepType.ARRAY, + binding: "{{ 1 }}", + }) + expect(resp.steps[2].outputs.iterations).toBe(1) + }) it("should run an automation with a trigger, loop, and create row step", async () => { - const builder = createAutomationBuilder({ - name: "Test Trigger with Loop and Create Row", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .rowSaved( { tableId: table._id! }, { @@ -70,11 +115,7 @@ describe("Loop automations", () => { }) it("should run an automation where a loop step is between two normal steps to ensure context correctness", async () => { - const builder = createAutomationBuilder({ - name: "Test Trigger with Loop and Create Row", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .rowSaved( { tableId: table._id! }, { @@ -110,11 +151,7 @@ describe("Loop automations", () => { }) it("if an incorrect type is passed to the loop it should return an error", async () => { - const builder = createAutomationBuilder({ - name: "Test Loop error", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .loop({ option: LoopStepType.ARRAY, @@ -130,11 +167,7 @@ describe("Loop automations", () => { }) it("ensure the loop stops if the failure condition is reached", async () => { - const builder = createAutomationBuilder({ - name: "Test Loop error", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .loop({ option: LoopStepType.ARRAY, @@ -153,11 +186,7 @@ describe("Loop automations", () => { }) it("ensure the loop stops if the max iterations are reached", async () => { - const builder = createAutomationBuilder({ - name: "Test Loop max iterations", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .loop({ option: LoopStepType.ARRAY, @@ -172,11 +201,7 @@ describe("Loop automations", () => { }) it("should run an automation with loop and max iterations to ensure context correctness further down the tree", async () => { - const builder = createAutomationBuilder({ - name: "Test context down tree with Loop and max iterations", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .loop({ option: LoopStepType.ARRAY, @@ -191,11 +216,7 @@ describe("Loop automations", () => { }) it("should run an automation where a loop is successfully run twice", async () => { - const builder = createAutomationBuilder({ - name: "Test Trigger with Loop and Create Row", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .rowSaved( { tableId: table._id! }, { @@ -257,11 +278,7 @@ describe("Loop automations", () => { }) it("should run an automation where a loop is used twice to ensure context correctness further down the tree", async () => { - const builder = createAutomationBuilder({ - name: "Test Trigger with Loop and Create Row", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .loop({ option: LoopStepType.ARRAY, @@ -283,11 +300,7 @@ describe("Loop automations", () => { }) it("should use automation names to loop with", async () => { - const builder = createAutomationBuilder({ - name: "Test Trigger with Loop and Create Row", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .loop( { @@ -339,11 +352,7 @@ describe("Loop automations", () => { await config.api.row.bulkImport(table._id!, { rows }) - const builder = createAutomationBuilder({ - name: "Test Loop and Update Row", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .queryRows({ tableId: table._id!, @@ -423,11 +432,7 @@ describe("Loop automations", () => { await config.api.row.bulkImport(table._id!, { rows }) - const builder = createAutomationBuilder({ - name: "Test Loop and Update Row", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .queryRows( { @@ -510,11 +515,7 @@ describe("Loop automations", () => { await config.api.row.bulkImport(table._id!, { rows }) - const builder = createAutomationBuilder({ - name: "Test Loop and Delete Row", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .queryRows({ tableId: table._id!, @@ -536,4 +537,98 @@ describe("Loop automations", () => { expect(results.steps[2].outputs.rows).toHaveLength(0) }) + + describe("replaceFakeBindings", () => { + it("should replace loop bindings in nested objects", () => { + const originalStepInput = { + schema: { + name: { + type: "string", + constraints: { + type: "string", + length: { maximum: null }, + presence: false, + }, + name: "name", + display: { type: "Text" }, + }, + }, + row: { + tableId: "ta_aaad4296e9f74b12b1b90ef7a84afcad", + name: "{{ loop.currentItem.pokemon }}", + }, + } + + const loopStepNumber = 3 + + const result = loopUtils.replaceFakeBindings( + originalStepInput, + loopStepNumber + ) + + expect(result).toEqual({ + schema: { + name: { + type: "string", + constraints: { + type: "string", + length: { maximum: null }, + presence: false, + }, + name: "name", + display: { type: "Text" }, + }, + }, + row: { + tableId: "ta_aaad4296e9f74b12b1b90ef7a84afcad", + name: "{{ steps.3.currentItem.pokemon }}", + }, + }) + }) + + it("should handle null values in nested objects", () => { + const originalStepInput = { + nullValue: null, + nestedNull: { + someKey: null, + }, + validValue: "{{ loop.someValue }}", + } + + const loopStepNumber = 2 + + const result = loopUtils.replaceFakeBindings( + originalStepInput, + loopStepNumber + ) + + expect(result).toEqual({ + nullValue: null, + nestedNull: { + someKey: null, + }, + validValue: "{{ steps.2.someValue }}", + }) + }) + + it("should handle empty objects and arrays", () => { + const originalStepInput = { + emptyObject: {}, + emptyArray: [], + nestedEmpty: { + emptyObj: {}, + emptyArr: [], + }, + } + + const loopStepNumber = 1 + + const result = loopUtils.replaceFakeBindings( + originalStepInput, + loopStepNumber + ) + + expect(result).toEqual(originalStepInput) + }) + }) }) diff --git a/packages/server/src/automations/tests/steps/make.spec.ts b/packages/server/src/automations/tests/steps/make.spec.ts new file mode 100644 index 0000000000..8ba5d3f8b7 --- /dev/null +++ b/packages/server/src/automations/tests/steps/make.spec.ts @@ -0,0 +1,74 @@ +import TestConfiguration from "../../../tests/utilities/TestConfiguration" +import nock from "nock" +import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" + +describe("test the outgoing webhook action", () => { + const config = new TestConfiguration() + + beforeAll(async () => { + await config.init() + }) + + afterAll(() => { + config.end() + }) + + beforeEach(() => { + nock.cleanAll() + }) + + it("should be able to run the action", async () => { + nock("http://www.example.com/").post("/").reply(200, { foo: "bar" }) + const result = await createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .make({ + url: "http://www.example.com", + body: null, + }) + .run() + + expect(result.steps[0].outputs.response.foo).toEqual("bar") + expect(result.steps[0].outputs.success).toEqual(true) + }) + + it("should add the payload props when a JSON string is provided", async () => { + const payload = { + value1: 1, + value2: 2, + value3: 3, + value4: 4, + value5: 5, + name: "Adam", + age: 9, + } + + nock("http://www.example.com/") + .post("/", payload) + .reply(200, { foo: "bar" }) + + const result = await createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .make({ + body: { value: JSON.stringify(payload) }, + url: "http://www.example.com", + }) + .run() + + expect(result.steps[0].outputs.response.foo).toEqual("bar") + expect(result.steps[0].outputs.success).toEqual(true) + }) + + it("should return a 400 if the JSON payload string is malformed", async () => { + const result = await createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .make({ + body: { value: "{ invalid json }" }, + url: "http://www.example.com", + }) + .run() + + expect(result.steps[0].outputs.httpStatus).toEqual(400) + expect(result.steps[0].outputs.response).toEqual("Invalid payload JSON") + expect(result.steps[0].outputs.success).toEqual(false) + }) +}) diff --git a/packages/server/src/automations/tests/steps/n8n.spec.ts b/packages/server/src/automations/tests/steps/n8n.spec.ts new file mode 100644 index 0000000000..4427ea33e0 --- /dev/null +++ b/packages/server/src/automations/tests/steps/n8n.spec.ts @@ -0,0 +1,88 @@ +import TestConfiguration from "../../..//tests/utilities/TestConfiguration" +import nock from "nock" +import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" +import { HttpMethod } from "@budibase/types" + +describe("test the outgoing webhook action", () => { + const config = new TestConfiguration() + + beforeAll(async () => { + await config.init() + }) + + afterAll(() => { + config.end() + }) + + beforeEach(() => { + nock.cleanAll() + }) + + it("should be able to run the action and default to 'get'", async () => { + nock("http://www.example.com/").get("/").reply(200, { foo: "bar" }) + const result = await createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .n8n({ + url: "http://www.example.com", + body: { test: "IGNORE_ME" }, + authorization: "", + }) + .run() + + expect(result.steps[0].outputs.response).toEqual({ foo: "bar" }) + expect(result.steps[0].outputs.httpStatus).toEqual(200) + expect(result.steps[0].outputs.success).toEqual(true) + }) + + it("should add the payload props when a JSON string is provided", async () => { + nock("http://www.example.com/") + .post("/", { name: "Adam", age: 9 }) + .reply(200) + + const result = await createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .n8n({ + url: "http://www.example.com", + body: { value: JSON.stringify({ name: "Adam", age: 9 }) }, + method: HttpMethod.POST, + authorization: "", + }) + .run() + + expect(result.steps[0].outputs.success).toEqual(true) + }) + + it("should return a 400 if the JSON payload string is malformed", async () => { + const result = await createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .n8n({ + url: "http://www.example.com", + body: { value: "{ value1 1 }" }, + method: HttpMethod.POST, + authorization: "", + }) + .run() + + expect(result.steps[0].outputs.httpStatus).toEqual(400) + expect(result.steps[0].outputs.response).toEqual("Invalid payload JSON") + expect(result.steps[0].outputs.success).toEqual(false) + }) + + it("should not append the body if the method is HEAD", async () => { + nock("http://www.example.com/") + .head("/", body => body === "") + .reply(200) + + const result = await createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .n8n({ + url: "http://www.example.com", + method: HttpMethod.HEAD, + body: { test: "IGNORE_ME" }, + authorization: "", + }) + .run() + + expect(result.steps[0].outputs.success).toEqual(true) + }) +}) diff --git a/packages/server/src/automations/tests/steps/openai.spec.ts b/packages/server/src/automations/tests/steps/openai.spec.ts new file mode 100644 index 0000000000..3030b3db39 --- /dev/null +++ b/packages/server/src/automations/tests/steps/openai.spec.ts @@ -0,0 +1,119 @@ +import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" +import { setEnv as setCoreEnv } from "@budibase/backend-core" +import { Model, MonthlyQuotaName, QuotaUsageType } from "@budibase/types" +import TestConfiguration from "../../..//tests/utilities/TestConfiguration" +import { + mockChatGPTError, + mockChatGPTResponse, +} from "../../../tests/utilities/mocks/openai" +import nock from "nock" +import { mocks } from "@budibase/backend-core/tests" +import { quotas } from "@budibase/pro" + +describe("test the openai action", () => { + const config = new TestConfiguration() + let resetEnv: () => void | undefined + + beforeAll(async () => { + await config.init() + }) + + beforeEach(() => { + resetEnv = setCoreEnv({ SELF_HOSTED: true, OPENAI_API_KEY: "abc123" }) + }) + + afterEach(() => { + resetEnv() + jest.clearAllMocks() + nock.cleanAll() + }) + + afterAll(() => { + config.end() + }) + + const getAIUsage = async () => { + const { total } = await config.doInContext(config.getProdAppId(), () => + quotas.getCurrentUsageValues( + QuotaUsageType.MONTHLY, + MonthlyQuotaName.BUDIBASE_AI_CREDITS + ) + ) + return total + } + + const expectAIUsage = async (expected: number, f: () => Promise) => { + const before = await getAIUsage() + const result = await f() + const after = await getAIUsage() + expect(after - before).toEqual(expected) + return result + } + + it("should be able to receive a response from ChatGPT given a prompt", async () => { + mockChatGPTResponse("This is a test") + + // The AI usage is 0 because the AI feature is disabled by default, which + // means it goes through the "legacy" path which requires you to set your + // own API key. We don't count this against your quota. + const result = await expectAIUsage(0, () => + createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .openai({ prompt: "Hello, world", model: Model.GPT_4O_MINI }) + .run() + ) + + expect(result.steps[0].outputs.response).toEqual("This is a test") + expect(result.steps[0].outputs.success).toBeTruthy() + }) + + it("should present the correct error message when a prompt is not provided", async () => { + const result = await expectAIUsage(0, () => + createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .openai({ prompt: "", model: Model.GPT_4O_MINI }) + .run() + ) + + expect(result.steps[0].outputs.response).toEqual( + "Budibase OpenAI Automation Failed: No prompt supplied" + ) + expect(result.steps[0].outputs.success).toBeFalsy() + }) + + it("should present the correct error message when an error is thrown from the createChatCompletion call", async () => { + mockChatGPTError() + + const result = await expectAIUsage(0, () => + createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .openai({ prompt: "Hello, world", model: Model.GPT_4O_MINI }) + .run() + ) + + expect(result.steps[0].outputs.response).toEqual( + "Error: 500 Internal Server Error" + ) + expect(result.steps[0].outputs.success).toBeFalsy() + }) + + it("should ensure that the pro AI module is called when the budibase AI features are enabled", async () => { + mocks.licenses.useBudibaseAI() + mocks.licenses.useAICustomConfigs() + + mockChatGPTResponse("This is a test") + + // We expect a non-0 AI usage here because it goes through the @budibase/pro + // path, because we've enabled Budibase AI. The exact value depends on a + // calculation we use to approximate cost. This uses Budibase's OpenAI API + // key, so we charge users for it. + const result = await expectAIUsage(14, () => + createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .openai({ model: Model.GPT_4O_MINI, prompt: "Hello, world" }) + .run() + ) + + expect(result.steps[0].outputs.response).toEqual("This is a test") + }) +}) diff --git a/packages/server/src/automations/tests/steps/outgoingWebhook.spec.ts b/packages/server/src/automations/tests/steps/outgoingWebhook.spec.ts new file mode 100644 index 0000000000..b1ff5aa264 --- /dev/null +++ b/packages/server/src/automations/tests/steps/outgoingWebhook.spec.ts @@ -0,0 +1,53 @@ +import TestConfiguration from "../../../tests/utilities/TestConfiguration" +import nock from "nock" +import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" +import { RequestType } from "@budibase/types" + +describe("test the outgoing webhook action", () => { + const config = new TestConfiguration() + + beforeAll(async () => { + await config.init() + }) + + afterAll(() => { + config.end() + }) + + beforeEach(() => { + nock.cleanAll() + }) + + it("should be able to run the action", async () => { + nock("http://www.example.com") + .post("/", { a: 1 }) + .reply(200, { foo: "bar" }) + + const result = await createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .outgoingWebhook({ + requestMethod: RequestType.POST, + url: "http://www.example.com", + requestBody: JSON.stringify({ a: 1 }), + headers: {}, + }) + .run() + + expect(result.steps[0].outputs.success).toEqual(true) + expect(result.steps[0].outputs.httpStatus).toEqual(200) + expect(result.steps[0].outputs.response.foo).toEqual("bar") + }) + + it("should return an error if something goes wrong in fetch", async () => { + const result = await createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .outgoingWebhook({ + requestMethod: RequestType.GET, + url: "www.invalid.com", + requestBody: "", + headers: {}, + }) + .run() + expect(result.steps[0].outputs.success).toEqual(false) + }) +}) diff --git a/packages/server/src/automations/tests/queryRows.spec.ts b/packages/server/src/automations/tests/steps/queryRows.spec.ts similarity index 88% rename from packages/server/src/automations/tests/queryRows.spec.ts rename to packages/server/src/automations/tests/steps/queryRows.spec.ts index 6ec7b7abfb..0030e7fc61 100644 --- a/packages/server/src/automations/tests/queryRows.spec.ts +++ b/packages/server/src/automations/tests/steps/queryRows.spec.ts @@ -1,30 +1,31 @@ import { EmptyFilterOption, SortOrder, Table } from "@budibase/types" -import * as setup from "./utilities" -import { createAutomationBuilder } from "./utilities/AutomationTestBuilder" -import * as automation from "../index" -import { basicTable } from "../../tests/utilities/structures" +import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" +import * as automation from "../../index" +import { basicTable } from "../../../tests/utilities/structures" +import TestConfiguration from "../../../tests/utilities/TestConfiguration" const NAME = "Test" describe("Test a query step automation", () => { + const config = new TestConfiguration() let table: Table - let config = setup.getConfig() beforeAll(async () => { await automation.init() await config.init() - table = await config.createTable() + table = await config.api.table.save(basicTable()) const row = { name: NAME, description: "original description", - tableId: table._id, } - await config.createRow(row) - await config.createRow(row) + await config.api.row.save(table._id!, row) + await config.api.row.save(table._id!, row) }) - afterAll(setup.afterAll) + afterAll(() => { + config.end() + }) it("should be able to run the query step", async () => { const result = await createAutomationBuilder({ @@ -157,13 +158,12 @@ describe("Test a query step automation", () => { }) it("return rows when querying a table with a space in the name", async () => { - const tableWithSpaces = await config.createTable({ + const tableWithSpaces = await config.api.table.save({ ...basicTable(), name: "table with spaces", }) - await config.createRow({ + await config.api.row.save(tableWithSpaces._id!, { name: NAME, - tableId: tableWithSpaces._id, }) const result = await createAutomationBuilder({ name: "Return All Test", diff --git a/packages/server/src/automations/tests/sendSmtpEmail.spec.ts b/packages/server/src/automations/tests/steps/sendSmtpEmail.spec.ts similarity index 84% rename from packages/server/src/automations/tests/sendSmtpEmail.spec.ts rename to packages/server/src/automations/tests/steps/sendSmtpEmail.spec.ts index 2977e8d64f..6ab0f32b65 100644 --- a/packages/server/src/automations/tests/sendSmtpEmail.spec.ts +++ b/packages/server/src/automations/tests/steps/sendSmtpEmail.spec.ts @@ -1,6 +1,7 @@ -import * as workerRequests from "../../utilities/workerRequests" +import TestConfiguration from "../../../tests/utilities/TestConfiguration" +import * as workerRequests from "../../../utilities/workerRequests" -jest.mock("../../utilities/workerRequests", () => ({ +jest.mock("../../../utilities/workerRequests", () => ({ sendSmtpEmail: jest.fn(), })) @@ -18,16 +19,18 @@ function generateResponse(to: string, from: string) { } } -import * as setup from "./utilities" +import * as setup from "../utilities" describe("test the outgoing webhook action", () => { - let inputs - let config = setup.getConfig() + const config = new TestConfiguration() + beforeAll(async () => { await config.init() }) - afterAll(setup.afterAll) + afterAll(() => { + config.end() + }) it("should be able to run the action", async () => { jest @@ -42,7 +45,7 @@ describe("test the outgoing webhook action", () => { location: "location", url: "url", } - inputs = { + const inputs = { to: "user1@example.com", from: "admin@example.com", subject: "hello", diff --git a/packages/server/src/automations/tests/steps/serverLog.spec.ts b/packages/server/src/automations/tests/steps/serverLog.spec.ts new file mode 100644 index 0000000000..556f7b174c --- /dev/null +++ b/packages/server/src/automations/tests/steps/serverLog.spec.ts @@ -0,0 +1,25 @@ +import TestConfiguration from "../../../tests/utilities/TestConfiguration" +import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" + +describe("test the server log action", () => { + const config = new TestConfiguration() + + beforeAll(async () => { + await config.init() + }) + + afterAll(() => { + config.end() + }) + + it("should be able to log the text", async () => { + const result = await createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .serverLog({ text: "Hello World" }) + .run() + expect(result.steps[0].outputs.message).toEqual( + `App ${config.getAppId()} - Hello World` + ) + expect(result.steps[0].outputs.success).toEqual(true) + }) +}) diff --git a/packages/server/src/automations/tests/steps/triggerAutomationRun.spec.ts b/packages/server/src/automations/tests/steps/triggerAutomationRun.spec.ts new file mode 100644 index 0000000000..674f2386c1 --- /dev/null +++ b/packages/server/src/automations/tests/steps/triggerAutomationRun.spec.ts @@ -0,0 +1,52 @@ +import * as automation from "../../index" +import env from "../../../environment" +import TestConfiguration from "../../../tests/utilities/TestConfiguration" +import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" + +describe("Test triggering an automation from another automation", () => { + const config = new TestConfiguration() + + beforeAll(async () => { + await automation.init() + await config.init() + }) + + afterAll(async () => { + await automation.shutdown() + config.end() + }) + + it("should trigger an other server log automation", async () => { + const automation = await createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .serverLog({ text: "Hello World" }) + .save() + + const result = await createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .triggerAutomationRun({ + automation: { + automationId: automation._id!, + }, + timeout: env.getDefaults().AUTOMATION_THREAD_TIMEOUT, + }) + .run() + + expect(result.steps[0].outputs.success).toBe(true) + }) + + it("should fail gracefully if the automation id is incorrect", async () => { + const result = await createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .triggerAutomationRun({ + automation: { + // @ts-expect-error - incorrect on purpose + automationId: null, + }, + timeout: env.getDefaults().AUTOMATION_THREAD_TIMEOUT, + }) + .run() + + expect(result.steps[0].outputs.success).toBe(false) + }) +}) diff --git a/packages/server/src/automations/tests/updateRow.spec.ts b/packages/server/src/automations/tests/steps/updateRow.spec.ts similarity index 95% rename from packages/server/src/automations/tests/updateRow.spec.ts rename to packages/server/src/automations/tests/steps/updateRow.spec.ts index 45f78826f6..5dc80e2df0 100644 --- a/packages/server/src/automations/tests/updateRow.spec.ts +++ b/packages/server/src/automations/tests/steps/updateRow.spec.ts @@ -8,15 +8,16 @@ import { Table, TableSourceType, } from "@budibase/types" -import { createAutomationBuilder } from "./utilities/AutomationTestBuilder" +import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" -import * as setup from "./utilities" import * as uuid from "uuid" +import TestConfiguration from "../../../tests/utilities/TestConfiguration" describe("test the update row action", () => { - let table: Table, - row: Row, - config = setup.getConfig() + const config = new TestConfiguration() + + let table: Table + let row: Row beforeAll(async () => { await config.init() @@ -24,7 +25,9 @@ describe("test the update row action", () => { row = await config.createRow() }) - afterAll(setup.afterAll) + afterAll(() => { + config.end() + }) it("should be able to run the update row action", async () => { const builder = createAutomationBuilder({ diff --git a/packages/server/src/automations/tests/steps/zapier.spec.ts b/packages/server/src/automations/tests/steps/zapier.spec.ts new file mode 100644 index 0000000000..17a0a7c7bf --- /dev/null +++ b/packages/server/src/automations/tests/steps/zapier.spec.ts @@ -0,0 +1,72 @@ +import TestConfiguration from "../../../tests/utilities/TestConfiguration" +import nock from "nock" +import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" + +describe("test the outgoing webhook action", () => { + const config = new TestConfiguration() + + beforeAll(async () => { + await config.init() + }) + + afterAll(() => { + config.end() + }) + + beforeEach(() => { + nock.cleanAll() + }) + + it("should be able to run the action", async () => { + nock("http://www.example.com/").post("/").reply(200, { foo: "bar" }) + + const result = await createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .zapier({ url: "http://www.example.com", body: null }) + .run() + + expect(result.steps[0].outputs.response.foo).toEqual("bar") + expect(result.steps[0].outputs.success).toEqual(true) + }) + + it("should add the payload props when a JSON string is provided", async () => { + const payload = { + value1: 1, + value2: 2, + value3: 3, + value4: 4, + value5: 5, + name: "Adam", + age: 9, + } + + nock("http://www.example.com/") + .post("/", { ...payload, platform: "budibase" }) + .reply(200, { foo: "bar" }) + + const result = await createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .zapier({ + url: "http://www.example.com", + body: { value: JSON.stringify(payload) }, + }) + .run() + + expect(result.steps[0].outputs.response.foo).toEqual("bar") + expect(result.steps[0].outputs.success).toEqual(true) + }) + + it("should return a 400 if the JSON payload string is malformed", async () => { + const result = await createAutomationBuilder({ config }) + .appAction({ fields: {} }) + .zapier({ + url: "http://www.example.com", + body: { value: "{ invalid json }" }, + }) + .run() + + expect(result.steps[0].outputs.success).toEqual(false) + expect(result.steps[0].outputs.response).toEqual("Invalid payload JSON") + expect(result.steps[0].outputs.httpStatus).toEqual(400) + }) +}) diff --git a/packages/server/src/automations/tests/triggerAutomationRun.spec.ts b/packages/server/src/automations/tests/triggerAutomationRun.spec.ts deleted file mode 100644 index e4d93d200f..0000000000 --- a/packages/server/src/automations/tests/triggerAutomationRun.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -jest.spyOn(global.console, "error") - -import * as setup from "./utilities" -import * as automation from "../index" -import { serverLogAutomation } from "../../tests/utilities/structures" -import env from "../../environment" - -describe("Test triggering an automation from another automation", () => { - let config = setup.getConfig() - - beforeAll(async () => { - await automation.init() - await config.init() - }) - - afterAll(async () => { - await automation.shutdown() - setup.afterAll() - }) - - it("should trigger an other server log automation", async () => { - let automation = serverLogAutomation() - let newAutomation = await config.createAutomation(automation) - - const inputs: any = { - automation: { - automationId: newAutomation._id, - timeout: env.getDefaults().AUTOMATION_THREAD_TIMEOUT, - }, - } - const res = await setup.runStep( - config, - setup.actions.TRIGGER_AUTOMATION_RUN.stepId, - inputs - ) - // Check if the SERVER_LOG step was successful - expect(res.value[1].outputs.success).toBe(true) - }) - - it("should fail gracefully if the automation id is incorrect", async () => { - const inputs: any = { - automation: { - automationId: null, - timeout: env.getDefaults().AUTOMATION_THREAD_TIMEOUT, - }, - } - const res = await setup.runStep( - config, - setup.actions.TRIGGER_AUTOMATION_RUN.stepId, - inputs - ) - expect(res.success).toBe(false) - }) -}) diff --git a/packages/server/src/automations/tests/scenarios/webhook.spec.ts b/packages/server/src/automations/tests/triggers/webhook.spec.ts similarity index 89% rename from packages/server/src/automations/tests/scenarios/webhook.spec.ts rename to packages/server/src/automations/tests/triggers/webhook.spec.ts index cb15a96824..bce454371f 100644 --- a/packages/server/src/automations/tests/scenarios/webhook.spec.ts +++ b/packages/server/src/automations/tests/triggers/webhook.spec.ts @@ -1,15 +1,15 @@ import * as automation from "../../index" -import * as setup from "../utilities" import { Table, Webhook, WebhookActionType } from "@budibase/types" import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" import { mocks } from "@budibase/backend-core/tests" +import TestConfiguration from "../../../tests/utilities/TestConfiguration" mocks.licenses.useSyncAutomations() describe("Branching automations", () => { - let config = setup.getConfig(), - table: Table, - webhook: Webhook + const config = new TestConfiguration() + let table: Table + let webhook: Webhook async function createWebhookAutomation(testName: string) { const builder = createAutomationBuilder({ @@ -45,7 +45,9 @@ describe("Branching automations", () => { table = await config.createTable() }) - afterAll(setup.afterAll) + afterAll(() => { + config.end() + }) it("should run the webhook automation - checking for parameters", async () => { const { webhook } = await createWebhookAutomation( diff --git a/packages/server/src/automations/tests/utilities/AutomationTestBuilder.ts b/packages/server/src/automations/tests/utilities/AutomationTestBuilder.ts index 50527d97af..74e0ae87fb 100644 --- a/packages/server/src/automations/tests/utilities/AutomationTestBuilder.ts +++ b/packages/server/src/automations/tests/utilities/AutomationTestBuilder.ts @@ -17,14 +17,20 @@ import { BranchStepInputs, CollectStepInputs, CreateRowStepInputs, + CronTriggerInputs, CronTriggerOutputs, + DelayStepInputs, DeleteRowStepInputs, + DiscordStepInputs, ExecuteQueryStepInputs, ExecuteScriptStepInputs, FilterStepInputs, isDidNotTriggerResponse, LoopStepInputs, + MakeIntegrationInputs, + n8nStepInputs, OpenAIStepInputs, + OutgoingWebhookStepInputs, QueryRowsStepInputs, RowCreatedTriggerInputs, RowCreatedTriggerOutputs, @@ -36,9 +42,11 @@ import { ServerLogStepInputs, SmtpEmailStepInputs, TestAutomationRequest, + TriggerAutomationStepInputs, UpdateRowStepInputs, WebhookTriggerInputs, WebhookTriggerOutputs, + ZapierStepInputs, } from "@budibase/types" import TestConfiguration from "../../../tests/utilities/TestConfiguration" import * as setup from "../utilities" @@ -263,6 +271,90 @@ class BaseStepBuilder { opts ) } + + zapier( + input: ZapierStepInputs, + opts?: { stepName?: string; stepId?: string } + ): this { + return this.step( + AutomationActionStepId.zapier, + BUILTIN_ACTION_DEFINITIONS.zapier, + input, + opts + ) + } + + triggerAutomationRun( + input: TriggerAutomationStepInputs, + opts?: { stepName?: string; stepId?: string } + ): this { + return this.step( + AutomationActionStepId.TRIGGER_AUTOMATION_RUN, + BUILTIN_ACTION_DEFINITIONS.TRIGGER_AUTOMATION_RUN, + input, + opts + ) + } + + outgoingWebhook( + input: OutgoingWebhookStepInputs, + opts?: { stepName?: string; stepId?: string } + ): this { + return this.step( + AutomationActionStepId.OUTGOING_WEBHOOK, + BUILTIN_ACTION_DEFINITIONS.OUTGOING_WEBHOOK, + input, + opts + ) + } + + n8n( + input: n8nStepInputs, + opts?: { stepName?: string; stepId?: string } + ): this { + return this.step( + AutomationActionStepId.n8n, + BUILTIN_ACTION_DEFINITIONS.n8n, + input, + opts + ) + } + + make( + input: MakeIntegrationInputs, + opts?: { stepName?: string; stepId?: string } + ): this { + return this.step( + AutomationActionStepId.integromat, + BUILTIN_ACTION_DEFINITIONS.integromat, + input, + opts + ) + } + + discord( + input: DiscordStepInputs, + opts?: { stepName?: string; stepId?: string } + ): this { + return this.step( + AutomationActionStepId.discord, + BUILTIN_ACTION_DEFINITIONS.discord, + input, + opts + ) + } + + delay( + input: DelayStepInputs, + opts?: { stepName?: string; stepId?: string } + ): this { + return this.step( + AutomationActionStepId.DELAY, + BUILTIN_ACTION_DEFINITIONS.DELAY, + input, + opts + ) + } } class StepBuilder extends BaseStepBuilder { @@ -286,6 +378,7 @@ class AutomationBuilder extends BaseStepBuilder { options: { name?: string; appId?: string; config?: TestConfiguration } = {} ) { super() + this.config = options.config || setup.getConfig() this.automationConfig = { name: options.name || `Test Automation ${uuidv4()}`, definition: { @@ -294,9 +387,8 @@ class AutomationBuilder extends BaseStepBuilder { stepNames: {}, }, type: "automation", - appId: options.appId ?? setup.getConfig().getAppId(), + appId: options.appId ?? this.config.getAppId(), } - this.config = options.config || setup.getConfig() } // TRIGGERS @@ -356,6 +448,16 @@ class AutomationBuilder extends BaseStepBuilder { ) } + cron(inputs: CronTriggerInputs, outputs?: CronTriggerOutputs) { + this.triggerOutputs = outputs + return this.trigger( + TRIGGER_DEFINITIONS.CRON, + AutomationTriggerStepId.CRON, + inputs, + outputs + ) + } + private trigger( triggerSchema: AutomationTriggerDefinition, stepId: TStep, @@ -393,7 +495,8 @@ class AutomationBuilder extends BaseStepBuilder { throw new Error("Please add a trigger to this automation test") } this.automationConfig.definition.steps = this.steps - return await this.config.createAutomation(this.build()) + const { automation } = await this.config.api.automation.post(this.build()) + return automation } async run() { diff --git a/packages/server/src/automations/tests/zapier.spec.ts b/packages/server/src/automations/tests/zapier.spec.ts deleted file mode 100644 index 1288e7efec..0000000000 --- a/packages/server/src/automations/tests/zapier.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { getConfig, afterAll, runStep, actions } from "./utilities" -import nock from "nock" - -describe("test the outgoing webhook action", () => { - let config = getConfig() - - beforeAll(async () => { - await config.init() - }) - - afterAll() - - beforeEach(() => { - nock.cleanAll() - }) - - it("should be able to run the action", async () => { - nock("http://www.example.com/").post("/").reply(200, { foo: "bar" }) - const res = await runStep(config, actions.zapier.stepId, { - url: "http://www.example.com", - }) - expect(res.response.foo).toEqual("bar") - expect(res.success).toEqual(true) - }) - - it("should add the payload props when a JSON string is provided", async () => { - const payload = { - value1: 1, - value2: 2, - value3: 3, - value4: 4, - value5: 5, - name: "Adam", - age: 9, - } - - nock("http://www.example.com/") - .post("/", { ...payload, platform: "budibase" }) - .reply(200, { foo: "bar" }) - - const res = await runStep(config, actions.zapier.stepId, { - body: { value: JSON.stringify(payload) }, - url: "http://www.example.com", - }) - expect(res.response.foo).toEqual("bar") - expect(res.success).toEqual(true) - }) - - it("should return a 400 if the JSON payload string is malformed", async () => { - const res = await runStep(config, actions.zapier.stepId, { - body: { value: "{ invalid json }" }, - url: "http://www.example.com", - }) - expect(res.httpStatus).toEqual(400) - expect(res.response).toEqual("Invalid payload JSON") - expect(res.success).toEqual(false) - }) -}) diff --git a/packages/server/src/tests/utilities/mocks/openai.ts b/packages/server/src/tests/utilities/mocks/openai.ts index b17491808c..7fcc0c08fc 100644 --- a/packages/server/src/tests/utilities/mocks/openai.ts +++ b/packages/server/src/tests/utilities/mocks/openai.ts @@ -1,26 +1,82 @@ import nock from "nock" let chatID = 1 +const SPACE_REGEX = /\s+/g + +interface MockChatGPTResponseOpts { + host?: string +} + +interface Message { + role: string + content: string +} + +interface Choice { + index: number + message: Message + logprobs: null + finish_reason: string +} + +interface CompletionTokensDetails { + reasoning_tokens: number + accepted_prediction_tokens: number + rejected_prediction_tokens: number +} + +interface Usage { + prompt_tokens: number + completion_tokens: number + total_tokens: number + completion_tokens_details: CompletionTokensDetails +} + +interface ChatCompletionRequest { + messages: Message[] + model: string +} + +interface ChatCompletionResponse { + id: string + object: string + created: number + model: string + system_fingerprint: string + choices: Choice[] + usage: Usage +} export function mockChatGPTResponse( - response: string | ((prompt: string) => string) + answer: string | ((prompt: string) => string), + opts?: MockChatGPTResponseOpts ) { - return nock("https://api.openai.com") + return nock(opts?.host || "https://api.openai.com") .post("/v1/chat/completions") - .reply(200, (uri, requestBody) => { - let content = response - if (typeof response === "function") { - const messages = (requestBody as any).messages - content = response(messages[0].content) + .reply(200, (uri: string, requestBody: ChatCompletionRequest) => { + const messages = requestBody.messages + const prompt = messages[0].content + + let content + if (typeof answer === "function") { + content = answer(prompt) + } else { + content = answer } chatID++ - return { + // We mock token usage because we use it to calculate Budibase AI quota + // usage when Budibase AI is enabled, and some tests assert against quota + // usage to make sure we're tracking correctly. + const prompt_tokens = messages[0].content.split(SPACE_REGEX).length + const completion_tokens = content.split(SPACE_REGEX).length + + const response: ChatCompletionResponse = { id: `chatcmpl-${chatID}`, object: "chat.completion", created: Math.floor(Date.now() / 1000), - model: "gpt-4o-mini", + model: requestBody.model, system_fingerprint: `fp_${chatID}`, choices: [ { @@ -31,9 +87,9 @@ export function mockChatGPTResponse( }, ], usage: { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, + prompt_tokens, + completion_tokens, + total_tokens: prompt_tokens + completion_tokens, completion_tokens_details: { reasoning_tokens: 0, accepted_prediction_tokens: 0, @@ -41,6 +97,14 @@ export function mockChatGPTResponse( }, }, } + return response }) .persist() } + +export function mockChatGPTError() { + return nock("https://api.openai.com") + .post("/v1/chat/completions") + .reply(500, "Internal Server Error") + .persist() +} diff --git a/packages/server/src/tests/utilities/structures.ts b/packages/server/src/tests/utilities/structures.ts index 0c74a0faa2..f99d961ae6 100644 --- a/packages/server/src/tests/utilities/structures.ts +++ b/packages/server/src/tests/utilities/structures.ts @@ -238,88 +238,6 @@ export function basicAutomation(opts?: DeepPartial): Automation { return merge(baseAutomation, opts) } -export function basicCronAutomation(appId: string, cron: string): Automation { - const automation: Automation = { - name: `Automation ${generator.guid()}`, - definition: { - trigger: { - stepId: AutomationTriggerStepId.CRON, - name: "test", - tagline: "test", - icon: "test", - description: "test", - type: AutomationStepType.TRIGGER, - id: "test", - inputs: { - cron, - }, - schema: { - inputs: { - properties: {}, - }, - outputs: { - properties: {}, - }, - }, - }, - steps: [], - }, - type: "automation", - appId, - } - return automation -} - -export function serverLogAutomation(appId?: string): Automation { - return { - name: "My Automation", - screenId: "kasdkfldsafkl", - live: true, - uiTree: {}, - definition: { - trigger: { - stepId: AutomationTriggerStepId.APP, - name: "test", - tagline: "test", - icon: "test", - description: "test", - type: AutomationStepType.TRIGGER, - id: "test", - inputs: { fields: {} }, - schema: { - inputs: { - properties: {}, - }, - outputs: { - properties: {}, - }, - }, - }, - steps: [ - { - stepId: AutomationActionStepId.SERVER_LOG, - name: "Backend log", - tagline: "Console log a value in the backend", - icon: "Monitoring", - description: "Logs the given text to the server (using console.log)", - internal: true, - features: { - LOOPING: true, - }, - inputs: { - text: "log statement", - }, - schema: BUILTIN_ACTION_DEFINITIONS.SERVER_LOG.schema, - id: "y8lkZbeSe", - type: AutomationStepType.ACTION, - }, - ], - }, - type: "automation", - appId: appId!, - } -} - export function loopAutomation( tableId: string, loopOpts?: LoopInput diff --git a/packages/types/src/documents/app/automation/StepInputsOutputs.ts b/packages/types/src/documents/app/automation/StepInputsOutputs.ts index 6f7223300d..b9c54cec34 100644 --- a/packages/types/src/documents/app/automation/StepInputsOutputs.ts +++ b/packages/types/src/documents/app/automation/StepInputsOutputs.ts @@ -141,7 +141,7 @@ export type MakeIntegrationInputs = { export type n8nStepInputs = { url: string - method: HttpMethod + method?: HttpMethod authorization: string body: any } @@ -237,7 +237,8 @@ export type ZapierStepInputs = { export type ZapierStepOutputs = Omit & { response: string } -enum RequestType { + +export enum RequestType { POST = "POST", GET = "GET", PUT = "PUT", @@ -249,7 +250,7 @@ export type OutgoingWebhookStepInputs = { requestMethod: RequestType url: string requestBody: string - headers: string + headers: string | Record } export type AppActionTriggerInputs = { From a3c4dede60ad0a8ecbbf774ce35e82c908784b0f Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Mon, 3 Feb 2025 17:41:16 +0000 Subject: [PATCH 2/4] Fix scenarios.spec.ts --- .../src/automations/tests/scenarios.spec.ts | 102 +++++------------- 1 file changed, 29 insertions(+), 73 deletions(-) diff --git a/packages/server/src/automations/tests/scenarios.spec.ts b/packages/server/src/automations/tests/scenarios.spec.ts index 55cfb61337..8d6e051ce2 100644 --- a/packages/server/src/automations/tests/scenarios.spec.ts +++ b/packages/server/src/automations/tests/scenarios.spec.ts @@ -9,6 +9,7 @@ import { Knex } from "knex" import { generator } from "@budibase/backend-core/tests" import { automations } from "@budibase/shared-core" import TestConfiguration from "../../tests/utilities/TestConfiguration" +import { basicTable } from "../../tests/utilities/structures" const FilterConditions = automations.steps.filter.FilterConditions @@ -26,13 +27,9 @@ describe("Automation Scenarios", () => { describe("Row Automations", () => { it("should trigger an automation which then creates a row", async () => { - const table = await config.createTable() + const table = await config.api.table.save(basicTable()) - const builder = createAutomationBuilder({ - name: "Test Row Save and Create", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .rowUpdated( { tableId: table._id! }, { @@ -61,19 +58,14 @@ describe("Automation Scenarios", () => { }) it("should trigger an automation which queries the database", async () => { - const table = await config.createTable() + const table = await config.api.table.save(basicTable()) const row = { name: "Test Row", description: "original description", - tableId: table._id, } - await config.createRow(row) - await config.createRow(row) - const builder = createAutomationBuilder({ - name: "Test Row Save and Create", - }) - - const results = await builder + await config.api.row.save(table._id!, row) + await config.api.row.save(table._id!, row) + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .queryRows({ tableId: table._id!, @@ -85,19 +77,14 @@ describe("Automation Scenarios", () => { }) it("should trigger an automation which queries the database then deletes a row", async () => { - const table = await config.createTable() + const table = await config.api.table.save(basicTable()) const row = { name: "DFN", description: "original description", - tableId: table._id, } - await config.createRow(row) - await config.createRow(row) - const builder = createAutomationBuilder({ - name: "Test Row Save and Create", - }) - - const results = await builder + await config.api.row.save(table._id!, row) + await config.api.row.save(table._id!, row) + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .queryRows({ tableId: table._id!, @@ -117,7 +104,8 @@ describe("Automation Scenarios", () => { }) it("should trigger an automation which creates and then updates a row", async () => { - const table = await config.createTable({ + const table = await config.api.table.save({ + ...basicTable(), name: "TestTable", type: "table", schema: { @@ -138,11 +126,7 @@ describe("Automation Scenarios", () => { }, }) - const builder = createAutomationBuilder({ - name: "Test Create and Update Row", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .createRow( { @@ -204,19 +188,14 @@ describe("Automation Scenarios", () => { describe("Name Based Automations", () => { it("should fetch and delete a rpw using automation naming", async () => { - const table = await config.createTable() + const table = await config.api.table.save(basicTable()) const row = { name: "DFN", description: "original description", - tableId: table._id, } - await config.createRow(row) - await config.createRow(row) - const builder = createAutomationBuilder({ - name: "Test Query and Delete Row", - }) - - const results = await builder + await config.api.row.save(table._id!, row) + await config.api.row.save(table._id!, row) + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .queryRows( { @@ -242,7 +221,8 @@ describe("Automation Scenarios", () => { let table: Table beforeEach(async () => { - table = await config.createTable({ + table = await config.api.table.save({ + ...basicTable(), name: "TestTable", type: "table", schema: { @@ -265,11 +245,7 @@ describe("Automation Scenarios", () => { }) it("should stop an automation if the condition is not met", async () => { - const builder = createAutomationBuilder({ - name: "Test Equal", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .createRow({ row: { @@ -295,11 +271,7 @@ describe("Automation Scenarios", () => { }) it("should continue the automation if the condition is met", async () => { - const builder = createAutomationBuilder({ - name: "Test Not Equal", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .createRow({ row: { @@ -366,11 +338,7 @@ describe("Automation Scenarios", () => { it.each(testCases)( "should pass the filter when condition is $condition", async ({ condition, value, rowValue, expectPass }) => { - const builder = createAutomationBuilder({ - name: `Test ${condition}`, - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .createRow({ row: { @@ -403,13 +371,9 @@ describe("Automation Scenarios", () => { }) it("Check user is passed through from row trigger", async () => { - const table = await config.createTable() + const table = await config.api.table.save(basicTable()) - const builder = createAutomationBuilder({ - name: "Test a user is successfully passed from the trigger", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .rowUpdated( { tableId: table._id! }, { @@ -424,11 +388,7 @@ describe("Automation Scenarios", () => { }) it("Check user is passed through from app trigger", async () => { - const builder = createAutomationBuilder({ - name: "Test a user is successfully passed from the trigger", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .serverLog({ text: "{{ [user].[email] }}" }) .run() @@ -451,7 +411,8 @@ if (descriptions.length) { }) it("should query an external database for some data then insert than into an internal table", async () => { - const newTable = await config.createTable({ + const newTable = await config.api.table.save({ + ...basicTable(), name: "table", type: "table", schema: { @@ -499,12 +460,7 @@ if (descriptions.length) { queryVerb: "read", }) - const builder = createAutomationBuilder({ - name: "Test external query and save", - config, - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {}, }) From 72a645b80e05bbc73645e197ec948106f0487a56 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Tue, 4 Feb 2025 10:43:49 +0000 Subject: [PATCH 3/4] Fix row.spec.ts --- .../tests/core/utilities/mocks/licenses.ts | 39 ++++++++++++------- .../server/src/api/routes/tests/row.spec.ts | 18 +++++++++ 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/packages/backend-core/tests/core/utilities/mocks/licenses.ts b/packages/backend-core/tests/core/utilities/mocks/licenses.ts index 5ba6fb36a1..436e915b81 100644 --- a/packages/backend-core/tests/core/utilities/mocks/licenses.ts +++ b/packages/backend-core/tests/core/utilities/mocks/licenses.ts @@ -1,5 +1,12 @@ -import { Feature, License, Quotas } from "@budibase/types" +import { + Feature, + License, + MonthlyQuotaName, + QuotaType, + QuotaUsageType, +} from "@budibase/types" import cloneDeep from "lodash/cloneDeep" +import merge from "lodash/merge" let CLOUD_FREE_LICENSE: License let UNLIMITED_LICENSE: License @@ -27,18 +34,19 @@ export function initInternal(opts: { export interface UseLicenseOpts { features?: Feature[] - quotas?: Quotas + monthlyQuotas?: [MonthlyQuotaName, number][] } // LICENSES export const useLicense = (license: License, opts?: UseLicenseOpts) => { - if (opts) { - if (opts.features) { - license.features.push(...opts.features) - } - if (opts.quotas) { - license.quotas = opts.quotas + if (opts?.features) { + license.features.push(...opts.features) + } + if (opts?.monthlyQuotas) { + for (const [name, value] of opts.monthlyQuotas) { + license.quotas[QuotaType.USAGE][QuotaUsageType.MONTHLY][name].value = + value } } @@ -57,12 +65,9 @@ export const useCloudFree = () => { // FEATURES -const useFeature = (feature: Feature) => { +const useFeature = (feature: Feature, extra?: Partial) => { const license = cloneDeep(getCachedLicense() || UNLIMITED_LICENSE) - const opts: UseLicenseOpts = { - features: [feature], - } - + const opts: UseLicenseOpts = merge({ features: [feature] }, extra) return useLicense(license, opts) } @@ -102,8 +107,12 @@ export const useAppBuilders = () => { return useFeature(Feature.APP_BUILDERS) } -export const useBudibaseAI = () => { - return useFeature(Feature.BUDIBASE_AI) +export const useBudibaseAI = (opts?: { monthlyQuota?: number }) => { + return useFeature(Feature.BUDIBASE_AI, { + monthlyQuotas: [ + [MonthlyQuotaName.BUDIBASE_AI_CREDITS, opts?.monthlyQuota || 1000], + ], + }) } export const useAICustomConfigs = () => { diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 576f0bb663..70a0bb2de9 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -39,6 +39,7 @@ import { RowExportFormat, RelationSchemaField, FormulaResponseType, + MonthlyQuotaName, } from "@budibase/types" import { generator, mocks } from "@budibase/backend-core/tests" import _, { merge } from "lodash" @@ -169,6 +170,18 @@ if (descriptions.length) { ) } + const resetRowUsage = async () => { + await config.doInContext( + undefined, + async () => + await quotas.setUsage( + 0, + StaticQuotaName.ROWS, + QuotaUsageType.STATIC + ) + ) + } + const getRowUsage = async () => { const { total } = await config.doInContext(undefined, () => quotas.getCurrentUsageValues( @@ -206,6 +219,10 @@ if (descriptions.length) { table = await config.api.table.save(defaultTable()) }) + beforeEach(async () => { + await resetRowUsage() + }) + describe("create", () => { it("creates a new row successfully", async () => { const rowUsage = await getRowUsage() @@ -3317,6 +3334,7 @@ if (descriptions.length) { beforeAll(async () => { mocks.licenses.useBudibaseAI() mocks.licenses.useAICustomConfigs() + envCleanup = setEnv({ OPENAI_API_KEY: "sk-abcdefghijklmnopqrstuvwxyz1234567890abcd", }) From b491d8c784eab30b128e6c5d648bfffbbcc3a7eb Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Tue, 4 Feb 2025 11:16:27 +0000 Subject: [PATCH 4/4] Fix a few more test files. --- .../server/src/api/routes/tests/row.spec.ts | 1 - .../src/automations/tests/branching.spec.ts | 43 +++---------------- .../automations/tests/steps/updateRow.spec.ts | 30 +++---------- .../tests/triggers/webhook.spec.ts | 11 ++--- 4 files changed, 15 insertions(+), 70 deletions(-) diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 70a0bb2de9..87002670b7 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -39,7 +39,6 @@ import { RowExportFormat, RelationSchemaField, FormulaResponseType, - MonthlyQuotaName, } from "@budibase/types" import { generator, mocks } from "@budibase/backend-core/tests" import _, { merge } from "lodash" diff --git a/packages/server/src/automations/tests/branching.spec.ts b/packages/server/src/automations/tests/branching.spec.ts index c2e3f50b8a..1a514e0537 100644 --- a/packages/server/src/automations/tests/branching.spec.ts +++ b/packages/server/src/automations/tests/branching.spec.ts @@ -24,11 +24,7 @@ describe("Branching automations", () => { const branch2LogId = "33333333-3333-3333-3333-333333333333" const branch2Id = "44444444-4444-4444-4444-444444444444" - const builder = createAutomationBuilder({ - name: "Test Trigger with Loop and Create Row", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .serverLog( { text: "Starting automation" }, @@ -87,11 +83,7 @@ describe("Branching automations", () => { }) it("should execute correct branch based on string equality", async () => { - const builder = createAutomationBuilder({ - name: "String Equality Branching", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: { status: "active" } }) .branch({ activeBranch: { @@ -116,11 +108,7 @@ describe("Branching automations", () => { }) it("should handle multiple conditions with AND operator", async () => { - const builder = createAutomationBuilder({ - name: "Multiple AND Conditions Branching", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: { status: "active", role: "admin" } }) .branch({ activeAdminBranch: { @@ -148,11 +136,7 @@ describe("Branching automations", () => { }) it("should handle multiple conditions with OR operator", async () => { - const builder = createAutomationBuilder({ - name: "Multiple OR Conditions Branching", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: { status: "test", role: "user" } }) .branch({ specialBranch: { @@ -184,11 +168,7 @@ describe("Branching automations", () => { }) it("should stop the branch automation when no conditions are met", async () => { - const builder = createAutomationBuilder({ - name: "Multiple OR Conditions Branching", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: { status: "test", role: "user" } }) .createRow({ row: { name: "Test", tableId: table._id } }) .branch({ @@ -215,7 +195,6 @@ describe("Branching automations", () => { }, }, }) - .serverLog({ text: "Test" }) .run() expect(results.steps[1].outputs.status).toEqual( @@ -225,11 +204,7 @@ describe("Branching automations", () => { }) it("evaluate multiple conditions", async () => { - const builder = createAutomationBuilder({ - name: "evaluate multiple conditions", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: { test_trigger: true } }) .serverLog({ text: "Starting automation" }, { stepId: "aN6znRYHG" }) .branch({ @@ -270,11 +245,7 @@ describe("Branching automations", () => { }) it("evaluate multiple conditions with interpolated text", async () => { - const builder = createAutomationBuilder({ - name: "evaluate multiple conditions", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: { test_trigger: true } }) .serverLog({ text: "Starting automation" }, { stepId: "aN6znRYHG" }) .branch({ diff --git a/packages/server/src/automations/tests/steps/updateRow.spec.ts b/packages/server/src/automations/tests/steps/updateRow.spec.ts index 5dc80e2df0..79fed5f613 100644 --- a/packages/server/src/automations/tests/steps/updateRow.spec.ts +++ b/packages/server/src/automations/tests/steps/updateRow.spec.ts @@ -30,11 +30,7 @@ describe("test the update row action", () => { }) it("should be able to run the update row action", async () => { - const builder = createAutomationBuilder({ - name: "Update Row Automation", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .updateRow({ rowId: row._id!, @@ -57,11 +53,7 @@ describe("test the update row action", () => { }) it("should check invalid inputs return an error", async () => { - const builder = createAutomationBuilder({ - name: "Invalid Inputs Automation", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .updateRow({ meta: {}, row: {}, rowId: "" }) .run() @@ -70,11 +62,7 @@ describe("test the update row action", () => { }) it("should return an error when table doesn't exist", async () => { - const builder = createAutomationBuilder({ - name: "Nonexistent Table Automation", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .updateRow({ row: { _id: "invalid" }, @@ -118,11 +106,7 @@ describe("test the update row action", () => { user2: [{ _id: user2._id }], }) - const builder = createAutomationBuilder({ - name: "Link Preservation Automation", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .updateRow({ rowId: row._id!, @@ -176,11 +160,7 @@ describe("test the update row action", () => { user2: [{ _id: user2._id }], }) - const builder = createAutomationBuilder({ - name: "Link Overwrite Automation", - }) - - const results = await builder + const results = await createAutomationBuilder({ config }) .appAction({ fields: {} }) .updateRow({ rowId: row._id!, diff --git a/packages/server/src/automations/tests/triggers/webhook.spec.ts b/packages/server/src/automations/tests/triggers/webhook.spec.ts index bce454371f..2e0df5de49 100644 --- a/packages/server/src/automations/tests/triggers/webhook.spec.ts +++ b/packages/server/src/automations/tests/triggers/webhook.spec.ts @@ -11,11 +11,8 @@ describe("Branching automations", () => { let table: Table let webhook: Webhook - async function createWebhookAutomation(testName: string) { - const builder = createAutomationBuilder({ - name: testName, - }) - const automation = await builder + async function createWebhookAutomation() { + const automation = await createAutomationBuilder({ config }) .webhook({ fields: { parameter: "string" } }) .createRow({ row: { tableId: table._id!, name: "{{ trigger.parameter }}" }, @@ -50,9 +47,7 @@ describe("Branching automations", () => { }) it("should run the webhook automation - checking for parameters", async () => { - const { webhook } = await createWebhookAutomation( - "Check a basic webhook works as expected" - ) + const { webhook } = await createWebhookAutomation() const res = await config.api.webhook.trigger( config.getProdAppId(), webhook._id!,