diff --git a/lerna.json b/lerna.json index faedd55ccb..bdb933c0c5 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "3.4.1", + "version": "3.4.3", "npmClient": "yarn", "concurrency": 20, "command": { diff --git a/packages/backend-core/src/queue/inMemoryQueue.ts b/packages/backend-core/src/queue/inMemoryQueue.ts index dd8d3daa37..6833c9a306 100644 --- a/packages/backend-core/src/queue/inMemoryQueue.ts +++ b/packages/backend-core/src/queue/inMemoryQueue.ts @@ -1,45 +1,58 @@ import events from "events" import { newid } from "../utils" import { Queue, QueueOptions, JobOptions } from "./queue" +import { helpers } from "@budibase/shared-core" +import { Job, JobId, JobInformation } from "bull" -interface JobMessage { +function jobToJobInformation(job: Job): JobInformation { + let cron = "" + let every = -1 + let tz: string | undefined = undefined + let endDate: number | undefined = undefined + + const repeat = job.opts?.repeat + if (repeat) { + endDate = repeat.endDate ? new Date(repeat.endDate).getTime() : Date.now() + tz = repeat.tz + if ("cron" in repeat) { + cron = repeat.cron + } else { + every = repeat.every + } + } + + return { + id: job.id.toString(), + name: "", + key: job.id.toString(), + tz, + endDate, + cron, + every, + next: 0, + } +} + +interface JobMessage extends Partial> { id: string timestamp: number - queue: string + queue: Queue data: any opts?: JobOptions } /** - * Bull works with a Job wrapper around all messages that contains a lot more information about - * the state of the message, this object constructor implements the same schema of Bull jobs - * for the sake of maintaining API consistency. - * @param queue The name of the queue which the message will be carried on. - * @param message The JSON message which will be passed back to the consumer. - * @returns A new job which can now be put onto the queue, this is mostly an - * internal structure so that an in memory queue can be easily swapped for a Bull queue. - */ -function newJob(queue: string, message: any, opts?: JobOptions): JobMessage { - return { - id: newid(), - timestamp: Date.now(), - queue: queue, - data: message, - opts, - } -} - -/** - * This is designed to replicate Bull (https://github.com/OptimalBits/bull) in memory as a sort of mock. - * It is relatively simple, using an event emitter internally to register when messages are available - * to the consumers - in can support many inputs and many consumers. + * This is designed to replicate Bull (https://github.com/OptimalBits/bull) in + * memory as a sort of mock. It is relatively simple, using an event emitter + * internally to register when messages are available to the consumers - in can + * support many inputs and many consumers. */ class InMemoryQueue implements Partial { _name: string _opts?: QueueOptions _messages: JobMessage[] _queuedJobIds: Set - _emitter: NodeJS.EventEmitter + _emitter: NodeJS.EventEmitter<{ message: [JobMessage]; completed: [Job] }> _runCount: number _addCount: number @@ -69,34 +82,29 @@ class InMemoryQueue implements Partial { */ async process(concurrencyOrFunc: number | any, func?: any) { func = typeof concurrencyOrFunc === "number" ? func : concurrencyOrFunc - this._emitter.on("message", async () => { - if (this._messages.length <= 0) { - return - } - let msg = this._messages.shift() - - let resp = func(msg) + this._emitter.on("message", async message => { + let resp = func(message) async function retryFunc(fnc: any) { try { await fnc } catch (e: any) { - await new Promise(r => setTimeout(() => r(), 50)) - - await retryFunc(func(msg)) + await helpers.wait(50) + await retryFunc(func(message)) } } if (resp.then != null) { try { await retryFunc(resp) + this._emitter.emit("completed", message as Job) } catch (e: any) { console.error(e) } } this._runCount++ - const jobId = msg?.opts?.jobId?.toString() - if (jobId && msg?.opts?.removeOnComplete) { + const jobId = message.opts?.jobId?.toString() + if (jobId && message.opts?.removeOnComplete) { this._queuedJobIds.delete(jobId) } }) @@ -130,9 +138,16 @@ class InMemoryQueue implements Partial { } const pushMessage = () => { - this._messages.push(newJob(this._name, data, opts)) + const message: JobMessage = { + id: newid(), + timestamp: Date.now(), + queue: this as unknown as Queue, + data, + opts, + } + this._messages.push(message) this._addCount++ - this._emitter.emit("message") + this._emitter.emit("message", message) } const delay = opts?.delay @@ -158,13 +173,6 @@ class InMemoryQueue implements Partial { console.log(cronJobId) } - /** - * Implemented for tests - */ - async getRepeatableJobs() { - return [] - } - async removeJobs(_pattern: string) { // no-op } @@ -176,13 +184,31 @@ class InMemoryQueue implements Partial { return [] } - async getJob() { + async getJob(id: JobId) { + for (const message of this._messages) { + if (message.id === id) { + return message as Job + } + } return null } - on() { - // do nothing - return this as any + on(event: string, callback: (...args: any[]) => void): Queue { + // @ts-expect-error - this callback can be one of many types + this._emitter.on(event, callback) + return this as unknown as Queue + } + + async count() { + return this._messages.length + } + + async getCompletedCount() { + return this._runCount + } + + async getRepeatableJobs() { + return this._messages.map(job => jobToJobInformation(job as Job)) } } diff --git a/packages/backend-core/src/sql/sql.ts b/packages/backend-core/src/sql/sql.ts index 334f1efdd4..7791ecb28b 100644 --- a/packages/backend-core/src/sql/sql.ts +++ b/packages/backend-core/src/sql/sql.ts @@ -388,7 +388,7 @@ class InternalBuilder { } } - if (typeof input === "string") { + if (typeof input === "string" && schema.type === FieldType.DATETIME) { if (isInvalidISODateString(input)) { return null } 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/bbui/src/Actions/click_outside.ts b/packages/bbui/src/Actions/click_outside.ts index 248a03039e..0c2eb036bc 100644 --- a/packages/bbui/src/Actions/click_outside.ts +++ b/packages/bbui/src/Actions/click_outside.ts @@ -34,7 +34,7 @@ let candidateTarget: HTMLElement | undefined // Processes a "click outside" event and invokes callbacks if our source element // is valid const handleClick = (e: MouseEvent) => { - const target = e.target as HTMLElement + const target = (e.target || e.relatedTarget) as HTMLElement // Ignore click if this is an ignored class if (target.closest('[data-ignore-click-outside="true"]')) { @@ -91,9 +91,19 @@ const handleMouseDown = (e: MouseEvent) => { document.addEventListener("click", handleMouseUp, true) } +// Handle iframe clicks by detecting a loss of focus on the main window +const handleBlur = () => { + if (document.activeElement?.tagName === "IFRAME") { + handleClick( + new MouseEvent("click", { relatedTarget: document.activeElement }) + ) + } +} + // Global singleton listeners for our events document.addEventListener("mousedown", handleMouseDown) document.addEventListener("contextmenu", handleClick) +window.addEventListener("blur", handleBlur) /** * Adds or updates a click handler diff --git a/packages/pro b/packages/pro index 43a5785ccb..8cbaa80a9c 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit 43a5785ccb4f83ce929b29f05ea0a62199fcdf23 +Subproject commit 8cbaa80a9cc1152c6cd53722e64da7d824da6e16 diff --git a/packages/server/src/api/routes/tests/automation.spec.ts b/packages/server/src/api/routes/tests/automation.spec.ts index 94517db67a..5b451eef4a 100644 --- a/packages/server/src/api/routes/tests/automation.spec.ts +++ b/packages/server/src/api/routes/tests/automation.spec.ts @@ -107,10 +107,7 @@ describe("/automations", () => { }) it("Should ensure you can't have a branch as not a last step", async () => { - const automation = createAutomationBuilder({ - name: "String Equality Branching", - appId: config.getAppId(), - }) + const automation = createAutomationBuilder(config) .appAction({ fields: { status: "active" } }) .branch({ activeBranch: { @@ -134,10 +131,7 @@ describe("/automations", () => { }) it("Should check validation on an automation that has a branch step with no children", async () => { - const automation = createAutomationBuilder({ - name: "String Equality Branching", - appId: config.getAppId(), - }) + const automation = createAutomationBuilder(config) .appAction({ fields: { status: "active" } }) .branch({}) .serverLog({ text: "Inactive user" }) @@ -153,10 +147,7 @@ describe("/automations", () => { }) it("Should check validation on a branch step with empty conditions", async () => { - const automation = createAutomationBuilder({ - name: "String Equality Branching", - appId: config.getAppId(), - }) + const automation = createAutomationBuilder(config) .appAction({ fields: { status: "active" } }) .branch({ activeBranch: { @@ -177,10 +168,7 @@ describe("/automations", () => { }) it("Should check validation on an branch that has a condition that is not valid", async () => { - const automation = createAutomationBuilder({ - name: "String Equality Branching", - appId: config.getAppId(), - }) + const automation = createAutomationBuilder(config) .appAction({ fields: { status: "active" } }) .branch({ activeBranch: { @@ -252,12 +240,7 @@ describe("/automations", () => { }) it("should be able to access platformUrl, logoUrl and company in the automation", async () => { - const result = await createAutomationBuilder({ - name: "Test Automation", - appId: config.getAppId(), - config, - }) - .appAction({ fields: {} }) + const result = await createAutomationBuilder(config) .serverLog({ text: "{{ settings.url }}", }) diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 576f0bb663..87002670b7 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -169,6 +169,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 +218,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 +3333,7 @@ if (descriptions.length) { beforeAll(async () => { mocks.licenses.useBudibaseAI() mocks.licenses.useAICustomConfigs() + envCleanup = setEnv({ OPENAI_API_KEY: "sk-abcdefghijklmnopqrstuvwxyz1234567890abcd", }) diff --git a/packages/server/src/api/routes/tests/search.spec.ts b/packages/server/src/api/routes/tests/search.spec.ts index c3b274d5f4..ee372914d7 100644 --- a/packages/server/src/api/routes/tests/search.spec.ts +++ b/packages/server/src/api/routes/tests/search.spec.ts @@ -1040,6 +1040,12 @@ if (descriptions.length) { string: { name: "FO" }, }).toContainExactly([{ name: "foo" }]) }) + + it("should not coerce string to date for string columns", async () => { + await expectQuery({ + string: { name: "2020-01-01" }, + }).toFindNothing() + }) }) describe("range", () => { diff --git a/packages/server/src/automations/bullboard.ts b/packages/server/src/automations/bullboard.ts index aa4287b2d0..349282e863 100644 --- a/packages/server/src/automations/bullboard.ts +++ b/packages/server/src/automations/bullboard.ts @@ -5,9 +5,9 @@ import * as automation from "../threads/automation" import { backups } from "@budibase/pro" import { getAppMigrationQueue } from "../appMigrations/queue" import { createBullBoard } from "@bull-board/api" -import BullQueue from "bull" +import { AutomationData } from "@budibase/types" -export const automationQueue: BullQueue.Queue = queue.createQueue( +export const automationQueue = queue.createQueue( queue.JobQueue.AUTOMATION, { removeStalledCb: automation.removeStalled } ) @@ -16,24 +16,20 @@ const PATH_PREFIX = "/bulladmin" export async function init() { // Set up queues for bull board admin + const queues = [new BullAdapter(automationQueue)] + const backupQueue = backups.getBackupQueue() - const appMigrationQueue = getAppMigrationQueue() - const queues = [automationQueue] if (backupQueue) { - queues.push(backupQueue) + queues.push(new BullAdapter(backupQueue)) } + + const appMigrationQueue = getAppMigrationQueue() if (appMigrationQueue) { - queues.push(appMigrationQueue) + queues.push(new BullAdapter(appMigrationQueue)) } - const adapters = [] - const serverAdapter: any = new KoaAdapter() - for (let queue of queues) { - adapters.push(new BullAdapter(queue)) - } - createBullBoard({ - queues: adapters, - serverAdapter, - }) + + const serverAdapter = new KoaAdapter() + createBullBoard({ queues, serverAdapter }) serverAdapter.setBasePath(PATH_PREFIX) return serverAdapter.registerPlugin() } diff --git a/packages/server/src/automations/index.ts b/packages/server/src/automations/index.ts index 4ef3210932..5f9ca1aa60 100644 --- a/packages/server/src/automations/index.ts +++ b/packages/server/src/automations/index.ts @@ -1,7 +1,6 @@ import { processEvent } from "./utils" import { automationQueue } from "./bullboard" import { rebootTrigger } from "./triggers" -import BullQueue from "bull" import { automationsEnabled } from "../features" export { automationQueue } from "./bullboard" @@ -25,6 +24,6 @@ export async function init() { return promise } -export function getQueues(): BullQueue.Queue[] { - return [automationQueue] +export function getQueue() { + return automationQueue } 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 87% rename from packages/server/src/automations/tests/scenarios/branching.spec.ts rename to packages/server/src/automations/tests/branching.spec.ts index c05ec2f663..d9fad543f6 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" @@ -22,12 +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 - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .serverLog( { text: "Starting automation" }, { stepName: "FirstLog", stepId: firstLogId } @@ -85,11 +82,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: { @@ -114,11 +107,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: { @@ -146,11 +135,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: { @@ -182,11 +167,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({ @@ -213,7 +194,6 @@ describe("Branching automations", () => { }, }, }) - .serverLog({ text: "Test" }) .run() expect(results.steps[1].outputs.status).toEqual( @@ -223,11 +203,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({ @@ -268,11 +244,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/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/deleteRow.spec.ts b/packages/server/src/automations/tests/deleteRow.spec.ts deleted file mode 100644 index cabf590421..0000000000 --- a/packages/server/src/automations/tests/deleteRow.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { createAutomationBuilder } from "./utilities/AutomationTestBuilder" -import * as setup from "./utilities" - -describe("test the delete row action", () => { - let table: any, - row: any, - config = setup.getConfig() - - beforeAll(async () => { - await config.init() - table = await config.createTable() - row = await config.createRow() - }) - - afterAll(setup.afterAll) - - it("should be able to run the delete row action", async () => { - const builder = createAutomationBuilder({ - name: "Delete Row Automation", - }) - - await builder - .appAction({ fields: {} }) - .deleteRow({ - tableId: table._id, - id: row._id, - revision: row._rev, - }) - .run() - - 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 - .appAction({ fields: {} }) - .deleteRow({ tableId: "", id: "", revision: "" }) - .run() - - expect(results.steps[0].outputs.success).toEqual(false) - }) - - it("should return an error when table doesn't exist", async () => { - const builder = createAutomationBuilder({ - name: "Nonexistent Table Automation", - }) - - const results = await builder - .appAction({ fields: {} }) - .deleteRow({ - tableId: "invalid", - id: "invalid", - revision: "invalid", - }) - .run() - - expect(results.steps[0].outputs.success).toEqual(false) - }) -}) 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 78% rename from packages/server/src/automations/tests/scenarios/scenarios.spec.ts rename to packages/server/src/automations/tests/scenarios.spec.ts index 3e203b7959..2c13b7c019 100644 --- a/packages/server/src/automations/tests/scenarios/scenarios.spec.ts +++ b/packages/server/src/automations/tests/scenarios.spec.ts @@ -1,36 +1,35 @@ -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" +import { basicTable } from "../../tests/utilities/structures" 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 () => { - 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! }, { @@ -58,21 +57,15 @@ describe("Automation Scenarios", () => { }) }) - it("should trigger an automation which querys the database", async () => { - const table = await config.createTable() + it("should trigger an automation which queries the database", async () => { + 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 - .appAction({ fields: {} }) + await config.api.row.save(table._id!, row) + await config.api.row.save(table._id!, row) + const results = await createAutomationBuilder(config) .queryRows({ tableId: table._id!, }) @@ -82,21 +75,15 @@ 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 () => { - const table = await config.createTable() + it("should trigger an automation which queries the database then deletes a row", async () => { + 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 - .appAction({ fields: {} }) + await config.api.row.save(table._id!, row) + await config.api.row.save(table._id!, row) + const results = await createAutomationBuilder(config) .queryRows({ tableId: table._id!, }) @@ -115,7 +102,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: { @@ -136,12 +124,7 @@ describe("Automation Scenarios", () => { }, }) - const builder = createAutomationBuilder({ - name: "Test Create and Update Row", - }) - - const results = await builder - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .createRow( { row: { @@ -202,20 +185,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 - .appAction({ fields: {} }) + await config.api.row.save(table._id!, row) + await config.api.row.save(table._id!, row) + const results = await createAutomationBuilder(config) .queryRows( { tableId: table._id!, @@ -240,7 +217,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: { @@ -263,12 +241,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 - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .createRow({ row: { name: "Equal Test", @@ -293,12 +266,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 - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .createRow({ row: { name: "Not Equal Test", @@ -364,12 +332,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 - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .createRow({ row: { name: `${condition} Test`, @@ -401,13 +364,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! }, { @@ -422,12 +381,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 - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .serverLog({ text: "{{ [user].[email] }}" }) .run() @@ -449,7 +403,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: { @@ -484,19 +439,20 @@ if (descriptions.length) { await client(tableName).insert(rows) - const query = await setup.saveTestQuery( - config, - client, - tableName, - datasource - ) - - const builder = createAutomationBuilder({ - name: "Test external query and save", - config, + 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 results = await builder + const results = await createAutomationBuilder(config) .appAction({ fields: {}, }) 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 77% rename from packages/server/src/automations/tests/bash.spec.ts rename to packages/server/src/automations/tests/steps/bash.spec.ts index 12ed784268..9b797f9479 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,11 +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, - }) - .appAction({ fields: {} }) + const result = await createAutomationBuilder(config) .queryRows( { tableId: table._id!, @@ -100,10 +93,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 +120,9 @@ describe("Execute Bash Automations", () => { }) it("should handle null values gracefully", async () => { - const result = await createAutomationBuilder({ - name: "Null Bash Input", - config, - }) - .appAction({ fields: {} }) + const result = await createAutomationBuilder(config) .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..4456550cb3 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..ed0729bd38 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..9dc6470f5a --- /dev/null +++ b/packages/server/src/automations/tests/steps/delay.spec.ts @@ -0,0 +1,26 @@ +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).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/steps/deleteRow.spec.ts b/packages/server/src/automations/tests/steps/deleteRow.spec.ts new file mode 100644 index 0000000000..ee8c9b329f --- /dev/null +++ b/packages/server/src/automations/tests/steps/deleteRow.spec.ts @@ -0,0 +1,55 @@ +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", () => { + const config = new TestConfiguration() + + let table: Table + let row: Row + + beforeAll(async () => { + await config.init() + table = await config.api.table.save(basicTable()) + row = await config.api.row.save(table._id!, {}) + }) + + afterAll(() => { + config.end() + }) + + it("should be able to run the delete row action", async () => { + await createAutomationBuilder(config) + .deleteRow({ + tableId: table._id!, + id: row._id!, + revision: row._rev, + }) + .run() + + await config.api.row.get(table._id!, row._id!, { + status: 404, + }) + }) + + it("should check invalid inputs return an error", async () => { + const results = await createAutomationBuilder(config) + .deleteRow({ tableId: "", id: "", revision: "" }) + .run() + + expect(results.steps[0].outputs.success).toEqual(false) + }) + + it("should return an error when table doesn't exist", async () => { + const results = await createAutomationBuilder(config) + .deleteRow({ + tableId: "invalid", + id: "invalid", + revision: "invalid", + }) + .run() + + expect(results.steps[0].outputs.success).toEqual(false) + }) +}) 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..361b3517f3 --- /dev/null +++ b/packages/server/src/automations/tests/steps/discord.spec.ts @@ -0,0 +1,32 @@ +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) + .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 70% rename from packages/server/src/automations/tests/executeScript.spec.ts rename to packages/server/src/automations/tests/steps/executeScript.spec.ts index f5845093bc..e2bea9d0c1 100644 --- a/packages/server/src/automations/tests/executeScript.spec.ts +++ b/packages/server/src/automations/tests/steps/executeScript.spec.ts @@ -1,28 +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 - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .executeScript({ code: "return 2 + 2" }) .run() @@ -30,11 +28,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,12 +42,7 @@ describe("Execute Script Automations", () => { }) it("should handle script execution errors gracefully", async () => { - const builder = createAutomationBuilder({ - name: "Handle Script Errors", - }) - - const results = await builder - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .executeScript({ code: "return nonexistentVariable.map(x => x)" }) .run() @@ -64,11 +53,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 +70,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..51af262aff --- /dev/null +++ b/packages/server/src/automations/tests/steps/filter.spec.ts @@ -0,0 +1,69 @@ +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) + .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) + .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..ba74bf6778 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,12 +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 - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .loop({ option: LoopStepType.ARRAY, binding: "1, 2, 3", @@ -130,12 +166,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 - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .loop({ option: LoopStepType.ARRAY, binding: ["test", "test2", "test3"], @@ -153,12 +184,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 - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .loop({ option: LoopStepType.ARRAY, binding: ["test", "test2", "test3"], @@ -172,12 +198,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 - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .loop({ option: LoopStepType.ARRAY, binding: ["test", "test2", "test3"], @@ -191,11 +212,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,12 +274,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 - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .loop({ option: LoopStepType.ARRAY, binding: [1, 2, 3], @@ -283,12 +295,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 - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .loop( { option: LoopStepType.ARRAY, @@ -339,12 +346,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 - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .queryRows({ tableId: table._id!, }) @@ -423,12 +425,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 - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .queryRows( { tableId: table._id!, @@ -510,12 +507,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 - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .queryRows({ tableId: table._id!, }) @@ -536,4 +528,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..2d118d943f --- /dev/null +++ b/packages/server/src/automations/tests/steps/make.spec.ts @@ -0,0 +1,71 @@ +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) + .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) + .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) + .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..d3efb1aaeb --- /dev/null +++ b/packages/server/src/automations/tests/steps/n8n.spec.ts @@ -0,0 +1,84 @@ +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) + .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) + .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) + .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) + .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..4fbf0aa6d6 --- /dev/null +++ b/packages/server/src/automations/tests/steps/openai.spec.ts @@ -0,0 +1,115 @@ +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) + .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) + .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) + .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) + .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..b1d13c6917 --- /dev/null +++ b/packages/server/src/automations/tests/steps/outgoingWebhook.spec.ts @@ -0,0 +1,51 @@ +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) + .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) + .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 74% rename from packages/server/src/automations/tests/queryRows.spec.ts rename to packages/server/src/automations/tests/steps/queryRows.spec.ts index 6ec7b7abfb..9cda9c94c0 100644 --- a/packages/server/src/automations/tests/queryRows.spec.ts +++ b/packages/server/src/automations/tests/steps/queryRows.spec.ts @@ -1,37 +1,34 @@ 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({ - name: "Basic Query Test", - config, - }) - .appAction({ fields: {} }) + const result = await createAutomationBuilder(config) .queryRows( { tableId: table._id!, @@ -55,11 +52,7 @@ describe("Test a query step automation", () => { }) it("Returns all rows when onEmptyFilter has no value and no filters are passed", async () => { - const result = await createAutomationBuilder({ - name: "Empty Filter Test", - config, - }) - .appAction({ fields: {} }) + const result = await createAutomationBuilder(config) .queryRows( { tableId: table._id!, @@ -79,11 +72,7 @@ describe("Test a query step automation", () => { }) it("Returns no rows when onEmptyFilter is RETURN_NONE and theres no filters", async () => { - const result = await createAutomationBuilder({ - name: "Return None Test", - config, - }) - .appAction({ fields: {} }) + const result = await createAutomationBuilder(config) .queryRows( { tableId: table._id!, @@ -104,11 +93,7 @@ describe("Test a query step automation", () => { }) it("Returns no rows when onEmptyFilters RETURN_NONE and a filter is passed with a null value", async () => { - const result = await createAutomationBuilder({ - name: "Null Filter Test", - config, - }) - .appAction({ fields: {} }) + const result = await createAutomationBuilder(config) .queryRows( { tableId: table._id!, @@ -133,11 +118,7 @@ describe("Test a query step automation", () => { }) it("Returns rows when onEmptyFilter is RETURN_ALL and no filter is passed", async () => { - const result = await createAutomationBuilder({ - name: "Return All Test", - config, - }) - .appAction({ fields: {} }) + const result = await createAutomationBuilder(config) .queryRows( { tableId: table._id!, @@ -157,19 +138,14 @@ 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", - config, - }) - .appAction({ fields: {} }) + const result = await createAutomationBuilder(config) .queryRows( { tableId: tableWithSpaces._id!, 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..44a9f068b1 --- /dev/null +++ b/packages/server/src/automations/tests/steps/serverLog.spec.ts @@ -0,0 +1,24 @@ +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) + .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..ef851bc047 --- /dev/null +++ b/packages/server/src/automations/tests/steps/triggerAutomationRun.spec.ts @@ -0,0 +1,49 @@ +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) + .serverLog({ text: "Hello World" }) + .save() + + const result = await createAutomationBuilder(config) + .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) + .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 80% rename from packages/server/src/automations/tests/updateRow.spec.ts rename to packages/server/src/automations/tests/steps/updateRow.spec.ts index 45f78826f6..32c7b90446 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,15 +25,12 @@ 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({ - name: "Update Row Automation", - }) - - const results = await builder - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .updateRow({ rowId: row._id!, row: { @@ -54,12 +52,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 - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .updateRow({ meta: {}, row: {}, rowId: "" }) .run() @@ -67,12 +60,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 - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .updateRow({ row: { _id: "invalid" }, rowId: "invalid", @@ -115,12 +103,7 @@ describe("test the update row action", () => { user2: [{ _id: user2._id }], }) - const builder = createAutomationBuilder({ - name: "Link Preservation Automation", - }) - - const results = await builder - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .updateRow({ rowId: row._id!, row: { @@ -173,12 +156,7 @@ describe("test the update row action", () => { user2: [{ _id: user2._id }], }) - const builder = createAutomationBuilder({ - name: "Link Overwrite Automation", - }) - - const results = await builder - .appAction({ fields: {} }) + const results = await createAutomationBuilder(config) .updateRow({ rowId: row._id!, row: { 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..e897083d18 --- /dev/null +++ b/packages/server/src/automations/tests/steps/zapier.spec.ts @@ -0,0 +1,69 @@ +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) + .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) + .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) + .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/triggers/cron.spec.ts b/packages/server/src/automations/tests/triggers/cron.spec.ts new file mode 100644 index 0000000000..84fe90c314 --- /dev/null +++ b/packages/server/src/automations/tests/triggers/cron.spec.ts @@ -0,0 +1,62 @@ +import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" +import TestConfiguration from "../../../tests/utilities/TestConfiguration" +import { getQueue } from "../.." +import { Job } from "bull" + +describe("cron trigger", () => { + const config = new TestConfiguration() + + beforeAll(async () => { + await config.init() + }) + + afterAll(() => { + config.end() + }) + + it("should queue a Bull cron job", async () => { + const queue = getQueue() + expect(await queue.getCompletedCount()).toEqual(0) + + const jobPromise = new Promise(resolve => { + queue.on("completed", async job => { + resolve(job) + }) + }) + + await createAutomationBuilder(config) + .cron({ cron: "* * * * *" }) + .serverLog({ + text: "Hello, world!", + }) + .save() + + await config.api.application.publish(config.getAppId()) + + expect(await queue.getCompletedCount()).toEqual(1) + + const job = await jobPromise + const repeat = job.opts?.repeat + if (!repeat || !("cron" in repeat)) { + throw new Error("Expected cron repeat") + } + expect(repeat.cron).toEqual("* * * * *") + }) + + it("should fail if the cron expression is invalid", async () => { + await createAutomationBuilder(config) + .cron({ cron: "* * * * * *" }) + .serverLog({ + text: "Hello, world!", + }) + .save() + + await config.api.application.publish(config.getAppId(), { + status: 500, + body: { + message: + 'Deployment Failed: Invalid automation CRON "* * * * * *" - Expected 5 values, but got 6.', + }, + }) + }) +}) diff --git a/packages/server/src/automations/tests/scenarios/webhook.spec.ts b/packages/server/src/automations/tests/triggers/webhook.spec.ts similarity index 73% rename from packages/server/src/automations/tests/scenarios/webhook.spec.ts rename to packages/server/src/automations/tests/triggers/webhook.spec.ts index cb15a96824..61fab1e891 100644 --- a/packages/server/src/automations/tests/scenarios/webhook.spec.ts +++ b/packages/server/src/automations/tests/triggers/webhook.spec.ts @@ -1,21 +1,17 @@ -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({ - 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 }}" }, @@ -40,17 +36,16 @@ describe("Branching automations", () => { } beforeEach(async () => { - await automation.init() await config.init() table = await config.createTable() }) - afterAll(setup.afterAll) + afterAll(() => { + config.end() + }) 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!, diff --git a/packages/server/src/automations/tests/utilities/AutomationTestBuilder.ts b/packages/server/src/automations/tests/utilities/AutomationTestBuilder.ts index 50527d97af..16a049c556 100644 --- a/packages/server/src/automations/tests/utilities/AutomationTestBuilder.ts +++ b/packages/server/src/automations/tests/utilities/AutomationTestBuilder.ts @@ -2,42 +2,23 @@ import { v4 as uuidv4 } from "uuid" import { BUILTIN_ACTION_DEFINITIONS } from "../../actions" import { TRIGGER_DEFINITIONS } from "../../triggers" import { - AppActionTriggerInputs, AppActionTriggerOutputs, Automation, AutomationActionStepId, AutomationStep, AutomationStepInputs, AutomationTrigger, - AutomationTriggerDefinition, AutomationTriggerInputs, + AutomationTriggerOutputs, AutomationTriggerStepId, - BashStepInputs, - Branch, BranchStepInputs, - CollectStepInputs, - CreateRowStepInputs, CronTriggerOutputs, - DeleteRowStepInputs, - ExecuteQueryStepInputs, - ExecuteScriptStepInputs, - FilterStepInputs, isDidNotTriggerResponse, - LoopStepInputs, - OpenAIStepInputs, - QueryRowsStepInputs, - RowCreatedTriggerInputs, RowCreatedTriggerOutputs, - RowDeletedTriggerInputs, RowDeletedTriggerOutputs, - RowUpdatedTriggerInputs, RowUpdatedTriggerOutputs, SearchFilters, - ServerLogStepInputs, - SmtpEmailStepInputs, TestAutomationRequest, - UpdateRowStepInputs, - WebhookTriggerInputs, WebhookTriggerOutputs, } from "@budibase/types" import TestConfiguration from "../../../tests/utilities/TestConfiguration" @@ -66,28 +47,53 @@ class BaseStepBuilder { protected steps: AutomationStep[] = [] protected stepNames: { [key: string]: string } = {} - protected step( - stepId: TStep, - stepSchema: Omit, - inputs: AutomationStepInputs, - opts?: { stepName?: string; stepId?: string } - ): this { - const id = opts?.stepId || uuidv4() - this.steps.push({ - ...stepSchema, - inputs: inputs as any, - id, - stepId, - name: opts?.stepName || stepSchema.name, - }) - if (opts?.stepName) { - this.stepNames[id] = opts.stepName + protected createStepFn(stepId: TStep) { + return ( + inputs: AutomationStepInputs, + opts?: { stepName?: string; stepId?: string } + ) => { + const schema = BUILTIN_ACTION_DEFINITIONS[stepId] + const id = opts?.stepId || uuidv4() + this.steps.push({ + ...schema, + inputs: inputs as any, + id, + stepId, + name: opts?.stepName || schema.name, + }) + if (opts?.stepName) { + this.stepNames[id] = opts.stepName + } + return this } - return this } + + createRow = this.createStepFn(AutomationActionStepId.CREATE_ROW) + updateRow = this.createStepFn(AutomationActionStepId.UPDATE_ROW) + deleteRow = this.createStepFn(AutomationActionStepId.DELETE_ROW) + sendSmtpEmail = this.createStepFn(AutomationActionStepId.SEND_EMAIL_SMTP) + executeQuery = this.createStepFn(AutomationActionStepId.EXECUTE_QUERY) + queryRows = this.createStepFn(AutomationActionStepId.QUERY_ROWS) + loop = this.createStepFn(AutomationActionStepId.LOOP) + serverLog = this.createStepFn(AutomationActionStepId.SERVER_LOG) + executeScript = this.createStepFn(AutomationActionStepId.EXECUTE_SCRIPT) + filter = this.createStepFn(AutomationActionStepId.FILTER) + bash = this.createStepFn(AutomationActionStepId.EXECUTE_BASH) + openai = this.createStepFn(AutomationActionStepId.OPENAI) + collect = this.createStepFn(AutomationActionStepId.COLLECT) + zapier = this.createStepFn(AutomationActionStepId.zapier) + triggerAutomationRun = this.createStepFn( + AutomationActionStepId.TRIGGER_AUTOMATION_RUN + ) + outgoingWebhook = this.createStepFn(AutomationActionStepId.OUTGOING_WEBHOOK) + n8n = this.createStepFn(AutomationActionStepId.n8n) + make = this.createStepFn(AutomationActionStepId.integromat) + discord = this.createStepFn(AutomationActionStepId.discord) + delay = this.createStepFn(AutomationActionStepId.DELAY) + protected addBranchStep(branchConfig: BranchConfig): void { const branchStepInputs: BranchStepInputs = { - branches: [] as Branch[], + branches: [], children: {}, } @@ -110,159 +116,6 @@ class BaseStepBuilder { } this.steps.push(branchStep) } - - // STEPS - createRow( - inputs: CreateRowStepInputs, - opts?: { stepName?: string; stepId?: string } - ): this { - return this.step( - AutomationActionStepId.CREATE_ROW, - BUILTIN_ACTION_DEFINITIONS.CREATE_ROW, - inputs, - opts - ) - } - - updateRow( - inputs: UpdateRowStepInputs, - opts?: { stepName?: string; stepId?: string } - ): this { - return this.step( - AutomationActionStepId.UPDATE_ROW, - BUILTIN_ACTION_DEFINITIONS.UPDATE_ROW, - inputs, - opts - ) - } - - deleteRow( - inputs: DeleteRowStepInputs, - opts?: { stepName?: string; stepId?: string } - ): this { - return this.step( - AutomationActionStepId.DELETE_ROW, - BUILTIN_ACTION_DEFINITIONS.DELETE_ROW, - inputs, - opts - ) - } - - sendSmtpEmail( - inputs: SmtpEmailStepInputs, - opts?: { stepName?: string; stepId?: string } - ): this { - return this.step( - AutomationActionStepId.SEND_EMAIL_SMTP, - BUILTIN_ACTION_DEFINITIONS.SEND_EMAIL_SMTP, - inputs, - opts - ) - } - - executeQuery( - inputs: ExecuteQueryStepInputs, - opts?: { stepName?: string; stepId?: string } - ): this { - return this.step( - AutomationActionStepId.EXECUTE_QUERY, - BUILTIN_ACTION_DEFINITIONS.EXECUTE_QUERY, - inputs, - opts - ) - } - - queryRows( - inputs: QueryRowsStepInputs, - opts?: { stepName?: string; stepId?: string } - ): this { - return this.step( - AutomationActionStepId.QUERY_ROWS, - BUILTIN_ACTION_DEFINITIONS.QUERY_ROWS, - inputs, - opts - ) - } - - loop( - inputs: LoopStepInputs, - opts?: { stepName?: string; stepId?: string } - ): this { - return this.step( - AutomationActionStepId.LOOP, - BUILTIN_ACTION_DEFINITIONS.LOOP, - inputs, - opts - ) - } - - serverLog( - input: ServerLogStepInputs, - opts?: { stepName?: string; stepId?: string } - ): this { - return this.step( - AutomationActionStepId.SERVER_LOG, - BUILTIN_ACTION_DEFINITIONS.SERVER_LOG, - input, - opts - ) - } - - executeScript( - input: ExecuteScriptStepInputs, - opts?: { stepName?: string; stepId?: string } - ): this { - return this.step( - AutomationActionStepId.EXECUTE_SCRIPT, - BUILTIN_ACTION_DEFINITIONS.EXECUTE_SCRIPT, - input, - opts - ) - } - - filter(input: FilterStepInputs): this { - return this.step( - AutomationActionStepId.FILTER, - BUILTIN_ACTION_DEFINITIONS.FILTER, - input - ) - } - - bash( - input: BashStepInputs, - opts?: { stepName?: string; stepId?: string } - ): this { - return this.step( - AutomationActionStepId.EXECUTE_BASH, - BUILTIN_ACTION_DEFINITIONS.EXECUTE_BASH, - input, - opts - ) - } - - openai( - input: OpenAIStepInputs, - opts?: { stepName?: string; stepId?: string } - ): this { - return this.step( - AutomationActionStepId.OPENAI, - BUILTIN_ACTION_DEFINITIONS.OPENAI, - input, - opts - ) - } - - collect( - input: CollectStepInputs, - opts?: { stepName?: string; stepId?: string } - ): this { - return this.step( - AutomationActionStepId.COLLECT, - BUILTIN_ACTION_DEFINITIONS.COLLECT, - input, - opts - ) - } } class StepBuilder extends BaseStepBuilder { @@ -282,101 +135,79 @@ class AutomationBuilder extends BaseStepBuilder { private triggerOutputs: TriggerOutputs private triggerSet = false - constructor( - options: { name?: string; appId?: string; config?: TestConfiguration } = {} - ) { + constructor(config?: TestConfiguration) { super() + this.config = config || setup.getConfig() + this.triggerOutputs = { fields: {} } this.automationConfig = { - name: options.name || `Test Automation ${uuidv4()}`, + name: `Test Automation ${uuidv4()}`, definition: { steps: [], - trigger: {} as AutomationTrigger, + trigger: { + ...TRIGGER_DEFINITIONS[AutomationTriggerStepId.APP], + stepId: AutomationTriggerStepId.APP, + inputs: this.triggerOutputs, + id: uuidv4(), + }, stepNames: {}, }, type: "automation", - appId: options.appId ?? setup.getConfig().getAppId(), + appId: this.config.getAppId(), } - this.config = options.config || setup.getConfig() } - // TRIGGERS - rowSaved(inputs: RowCreatedTriggerInputs, outputs: RowCreatedTriggerOutputs) { - this.triggerOutputs = outputs - return this.trigger( - TRIGGER_DEFINITIONS.ROW_SAVED, - AutomationTriggerStepId.ROW_SAVED, - inputs, - outputs - ) - } - - rowUpdated( - inputs: RowUpdatedTriggerInputs, - outputs: RowUpdatedTriggerOutputs - ) { - this.triggerOutputs = outputs - return this.trigger( - TRIGGER_DEFINITIONS.ROW_UPDATED, - AutomationTriggerStepId.ROW_UPDATED, - inputs, - outputs - ) - } - - rowDeleted( - inputs: RowDeletedTriggerInputs, - outputs: RowDeletedTriggerOutputs - ) { - this.triggerOutputs = outputs - return this.trigger( - TRIGGER_DEFINITIONS.ROW_DELETED, - AutomationTriggerStepId.ROW_DELETED, - inputs, - outputs - ) - } - - appAction(outputs: AppActionTriggerOutputs, inputs?: AppActionTriggerInputs) { - this.triggerOutputs = outputs - return this.trigger( - TRIGGER_DEFINITIONS.APP, - AutomationTriggerStepId.APP, - inputs, - outputs - ) - } - - webhook(outputs: WebhookTriggerOutputs, inputs?: WebhookTriggerInputs) { - this.triggerOutputs = outputs - return this.trigger( - TRIGGER_DEFINITIONS.WEBHOOK, - AutomationTriggerStepId.WEBHOOK, - inputs, - outputs - ) - } - - private trigger( - triggerSchema: AutomationTriggerDefinition, - stepId: TStep, - inputs?: AutomationTriggerInputs, - outputs?: TriggerOutputs - ): this { - if (this.triggerSet) { - throw new Error("Only one trigger can be set for an automation.") - } - this.automationConfig.definition.trigger = { - ...triggerSchema, - stepId, - inputs: inputs || ({} as any), - id: uuidv4(), - } - this.triggerOutputs = outputs - this.triggerSet = true - + name(n: string): this { + this.automationConfig.name = n return this } + protected triggerInputOutput< + TStep extends AutomationTriggerStepId, + TInput = AutomationTriggerInputs, + TOutput = AutomationTriggerOutputs + >(stepId: TStep) { + return (inputs: TInput, outputs?: TOutput) => { + if (this.triggerSet) { + throw new Error("Only one trigger can be set for an automation.") + } + this.triggerOutputs = outputs as TriggerOutputs | undefined + this.automationConfig.definition.trigger = { + ...TRIGGER_DEFINITIONS[stepId], + stepId, + inputs, + id: uuidv4(), + } as AutomationTrigger + this.triggerSet = true + return this + } + } + + protected triggerOutputOnly< + TStep extends AutomationTriggerStepId, + TOutput = AutomationTriggerOutputs + >(stepId: TStep) { + return (outputs: TOutput) => { + this.triggerOutputs = outputs as TriggerOutputs + this.automationConfig.definition.trigger = { + ...TRIGGER_DEFINITIONS[stepId], + stepId, + id: uuidv4(), + } as AutomationTrigger + this.triggerSet = true + return this + } + } + + // The input and output for appAction is identical, and we only ever seem to + // set the output, so we're ignoring the input for now. + appAction = this.triggerOutputOnly(AutomationTriggerStepId.APP) + + rowSaved = this.triggerInputOutput(AutomationTriggerStepId.ROW_SAVED) + rowUpdated = this.triggerInputOutput(AutomationTriggerStepId.ROW_UPDATED) + rowDeleted = this.triggerInputOutput(AutomationTriggerStepId.ROW_DELETED) + webhook = this.triggerInputOutput(AutomationTriggerStepId.WEBHOOK) + cron = this.triggerInputOutput(AutomationTriggerStepId.CRON) + branch(branchConfig: BranchConfig): this { this.addBranchStep(branchConfig) return this @@ -389,11 +220,9 @@ class AutomationBuilder extends BaseStepBuilder { } async save() { - if (!Object.keys(this.automationConfig.definition.trigger).length) { - 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() { @@ -415,10 +244,6 @@ class AutomationBuilder extends BaseStepBuilder { } } -export function createAutomationBuilder(options?: { - name?: string - appId?: string - config?: TestConfiguration -}) { - return new AutomationBuilder(options) +export function createAutomationBuilder(config: TestConfiguration) { + return new AutomationBuilder(config) } 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/automations/utils.ts b/packages/server/src/automations/utils.ts index 9bfcc6cf8a..83665fc975 100644 --- a/packages/server/src/automations/utils.ts +++ b/packages/server/src/automations/utils.ts @@ -230,7 +230,7 @@ export async function enableCronTrigger(appId: any, automation: Automation) { // can't use getAppDB here as this is likely to be called from dev app, // but this call could be for dev app or prod app, need to just use what // was passed in - await dbCore.doWithDB(appId, async (db: any) => { + await dbCore.doWithDB(appId, async db => { const response = await db.put(automation) automation._id = response.id automation._rev = response.rev diff --git a/packages/server/src/integrations/tests/utils/googlesheets.ts b/packages/server/src/integrations/tests/utils/googlesheets.ts index 4b9445ebca..184ae15b09 100644 --- a/packages/server/src/integrations/tests/utils/googlesheets.ts +++ b/packages/server/src/integrations/tests/utils/googlesheets.ts @@ -609,7 +609,15 @@ export class GoogleSheetsMock { for (let col = startColumnIndex; col <= endColumnIndex; col++) { const cell = this.getCellNumericIndexes(sheetId, row, col) if (!cell) { - throw new Error("Cell not found") + const sheet = this.getSheetById(sheetId) + if (!sheet) { + throw new Error(`Sheet ${sheetId} not found`) + } + const sheetRows = sheet.data[0].rowData.length + const sheetCols = sheet.data[0].rowData[0].values.length + throw new Error( + `Failed to find cell at ${row}, ${col}. Range: ${valueRange.range}. Sheet dimensions: ${sheetRows}x${sheetCols}.` + ) } const value = valueRange.values[row - startRowIndex][col - startColumnIndex] @@ -638,7 +646,15 @@ export class GoogleSheetsMock { for (let col = startColumnIndex; col <= endColumnIndex; col++) { const cell = this.getCellNumericIndexes(sheetId, row, col) if (!cell) { - throw new Error("Cell not found") + const sheet = this.getSheetById(sheetId) + if (!sheet) { + throw new Error(`Sheet ${sheetId} not found`) + } + const sheetRows = sheet.data[0].rowData.length + const sheetCols = sheet.data[0].rowData[0].values.length + throw new Error( + `Failed to find cell at ${row}, ${col}. Range: ${valueRange.range}. Sheet dimensions: ${sheetRows}x${sheetCols}.` + ) } values.push(this.cellValue(cell)) } diff --git a/packages/server/src/tests/utilities/api/application.ts b/packages/server/src/tests/utilities/api/application.ts index 1fe9840c1d..d0fcb60804 100644 --- a/packages/server/src/tests/utilities/api/application.ts +++ b/packages/server/src/tests/utilities/api/application.ts @@ -33,7 +33,10 @@ export class ApplicationAPI extends TestAPI { await this._delete(`/api/applications/${appId}`, { expectations }) } - publish = async (appId: string): Promise => { + publish = async ( + appId: string, + expectations?: Expectations + ): Promise => { return await this._post( `/api/applications/${appId}/publish`, { @@ -42,14 +45,16 @@ export class ApplicationAPI extends TestAPI { headers: { [constants.Header.APP_ID]: appId, }, + expectations, } ) } - unpublish = async (appId: string): Promise => { - await this._post(`/api/applications/${appId}/unpublish`, { - expectations: { status: 200 }, - }) + unpublish = async ( + appId: string, + expectations?: Expectations + ): Promise => { + await this._post(`/api/applications/${appId}/unpublish`, { expectations }) } sync = async ( @@ -144,13 +149,20 @@ export class ApplicationAPI extends TestAPI { }) } - fetch = async ({ status }: { status?: AppStatus } = {}): Promise => { + fetch = async ( + { status }: { status?: AppStatus } = {}, + expectations?: Expectations + ): Promise => { return await this._get("/api/applications", { query: { status }, + expectations, }) } - addSampleData = async (appId: string): Promise => { - await this._post(`/api/applications/${appId}/sample`) + addSampleData = async ( + appId: string, + expectations?: Expectations + ): Promise => { + await this._post(`/api/applications/${appId}/sample`, { expectations }) } } 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/api/account/license.ts b/packages/types/src/api/account/license.ts index edb1267ecf..342e4882e6 100644 --- a/packages/types/src/api/account/license.ts +++ b/packages/types/src/api/account/license.ts @@ -20,7 +20,8 @@ export interface QuotaTriggeredRequest { } export interface LicenseActivateRequest { - installVersion?: string + installVersion: string + installId: string } export interface UpdateLicenseRequest { 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 = { diff --git a/packages/types/src/documents/app/automation/schema.ts b/packages/types/src/documents/app/automation/schema.ts index 84bfebf6bf..952397b511 100644 --- a/packages/types/src/documents/app/automation/schema.ts +++ b/packages/types/src/documents/app/automation/schema.ts @@ -52,6 +52,12 @@ import { RowDeletedTriggerInputs, BranchStepInputs, BaseAutomationOutputs, + AppActionTriggerOutputs, + CronTriggerOutputs, + RowDeletedTriggerOutputs, + RowCreatedTriggerOutputs, + RowUpdatedTriggerOutputs, + WebhookTriggerOutputs, } from "./StepInputsOutputs" export type ActionImplementations = { @@ -341,6 +347,23 @@ export type AutomationTriggerInputs = ? Record : never +export type AutomationTriggerOutputs = + T extends AutomationTriggerStepId.APP + ? AppActionTriggerOutputs + : T extends AutomationTriggerStepId.CRON + ? CronTriggerOutputs + : T extends AutomationTriggerStepId.ROW_ACTION + ? Record + : T extends AutomationTriggerStepId.ROW_DELETED + ? RowDeletedTriggerOutputs + : T extends AutomationTriggerStepId.ROW_SAVED + ? RowCreatedTriggerOutputs + : T extends AutomationTriggerStepId.ROW_UPDATED + ? RowUpdatedTriggerOutputs + : T extends AutomationTriggerStepId.WEBHOOK + ? WebhookTriggerOutputs + : never + export interface AutomationTriggerSchema< TTrigger extends AutomationTriggerStepId > extends AutomationStepSchemaBase {