Merge branch 'master' into BUDI-9077/type-bbui-multiselect

This commit is contained in:
Adria Navarro 2025-03-03 22:29:20 +01:00 committed by GitHub
commit a1a0ef2a70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 1035 additions and 857 deletions

View File

@ -1,18 +1,15 @@
<script lang="ts">
import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
import { FieldType } from "@budibase/types"
import { FieldType, InternalTable } from "@budibase/types"
import { fetchData, Utils } from "@budibase/frontend-core"
import { getContext } from "svelte"
import Field from "./Field.svelte"
import type {
SearchFilter,
RelationshipFieldMetadata,
Table,
Row,
} from "@budibase/types"
const { API } = getContext("sdk")
export let field: string | undefined = undefined
export let label: string | undefined = undefined
export let placeholder: any = undefined
@ -20,10 +17,10 @@
export let readonly: boolean = false
export let validation: any
export let autocomplete: boolean = true
export let defaultValue: string | undefined = undefined
export let defaultValue: string | string[] | undefined = undefined
export let onChange: any
export let filter: SearchFilter[]
export let datasourceType: "table" | "user" | "groupUser" = "table"
export let datasourceType: "table" | "user" = "table"
export let primaryDisplay: string | undefined = undefined
export let span: number | undefined = undefined
export let helpText: string | undefined = undefined
@ -32,191 +29,305 @@
| FieldType.BB_REFERENCE
| FieldType.BB_REFERENCE_SINGLE = FieldType.LINK
type RelationshipValue = { _id: string; [key: string]: any }
type OptionObj = Record<string, RelationshipValue>
type OptionsObjType = Record<string, OptionObj>
type BasicRelatedRow = { _id: string; primaryDisplay: string }
type OptionsMap = Record<string, BasicRelatedRow>
const { API } = getContext("sdk")
// Field state
let fieldState: any
let fieldApi: any
let fieldSchema: RelationshipFieldMetadata | undefined
let tableDefinition: Table | null | undefined
let searchTerm: any
let open: boolean
let selectedValue: string[] | string
// need a cast version of this for reactivity, components below aren't typed
$: castSelectedValue = selectedValue as any
// Local UI state
let searchTerm: any
let open: boolean = false
// Options state
let options: BasicRelatedRow[] = []
let optionsMap: OptionsMap = {}
let loadingMissingOptions: boolean = false
// Determine if we can select multiple rows or not
$: multiselect =
[FieldType.LINK, FieldType.BB_REFERENCE].includes(type) &&
fieldSchema?.relationshipType !== "one-to-many"
$: linkedTableId = fieldSchema?.tableId!
$: fetch = fetchData({
API,
datasource: {
// typing here doesn't seem correct - we have the correct datasourceType options
// but when we configure the fetchData, it seems to think only "table" is valid
type: datasourceType as any,
tableId: linkedTableId,
},
options: {
filter,
limit: 100,
},
})
$: tableDefinition = $fetch.definition
$: selectedValue = multiselect
? flatten(fieldState?.value) ?? []
: flatten(fieldState?.value)?.[0]
$: component = multiselect ? CoreMultiselect : CoreSelect
$: primaryDisplay = primaryDisplay || tableDefinition?.primaryDisplay
// Get the proper string representation of the value
$: realValue = fieldState?.value
$: selectedValue = parseSelectedValue(realValue, multiselect)
$: selectedIDs = getSelectedIDs(selectedValue)
let optionsObj: OptionsObjType = {}
const debouncedFetchRows = Utils.debounce(fetchRows, 250)
// If writable, we use a fetch to load options
$: linkedTableId = fieldSchema?.tableId
$: writable = !disabled && !readonly
$: fetch = createFetch(writable, datasourceType, filter, linkedTableId)
$: {
if (primaryDisplay && fieldState && !optionsObj) {
// Persist the initial values as options, allowing them to be present in the dropdown,
// even if they are not in the inital fetch results
let valueAsSafeArray = fieldState.value || []
if (!Array.isArray(valueAsSafeArray)) {
valueAsSafeArray = [fieldState.value]
}
optionsObj = valueAsSafeArray.reduce(
(
accumulator: OptionObj,
value: { _id: string; primaryDisplay: any }
) => {
// fieldState has to be an array of strings to be valid for an update
// therefore we cannot guarantee value will be an object
// https://linear.app/budibase/issue/BUDI-7577/refactor-the-relationshipfield-component-to-have-better-support-for
if (!value._id) {
return accumulator
// Attempt to determine the primary display field to use
$: tableDefinition = $fetch?.definition
$: primaryDisplayField = primaryDisplay || tableDefinition?.primaryDisplay
// Build our options map
$: rows = $fetch?.rows || []
$: processOptions(realValue, rows, primaryDisplayField)
// If we ever have a value selected for which we don't have an option, we must
// fetch those rows to ensure we can render them as options
$: missingIDs = selectedIDs.filter(id => !optionsMap[id])
$: loadMissingOptions(missingIDs, linkedTableId, primaryDisplayField)
// Convert our options map into an array for display
$: updateOptions(optionsMap)
$: !open && sortOptions()
// Search for new options when search term changes
$: debouncedSearchOptions(searchTerm || "", primaryDisplayField)
// Ensure backwards compatibility
$: enrichedDefaultValue = enrichDefaultValue(defaultValue)
// We need to cast value to pass it down, as those components aren't typed
$: emptyValue = multiselect ? [] : null
$: displayValue = missingIDs.length ? emptyValue : (selectedValue as any)
// Ensures that we flatten any objects so that only the IDs of the selected
// rows are passed down. Not sure how this can be an object to begin with?
const parseSelectedValue = (
value: any,
multiselect: boolean
): undefined | string | string[] => {
return multiselect ? flatten(value) : flatten(value)[0]
}
// Where applicable, creates the fetch instance to load row options
const createFetch = (
writable: boolean,
dsType: typeof datasourceType,
filter: SearchFilter[],
linkedTableId?: string
) => {
if (!linkedTableId) {
return undefined
}
const datasource =
datasourceType === "table"
? {
type: datasourceType,
tableId: fieldSchema?.tableId!,
}
accumulator[value._id] = {
_id: value._id,
[primaryDisplay]: value.primaryDisplay,
: {
type: datasourceType,
tableId: InternalTable.USER_METADATA,
}
return accumulator
},
{}
)
}
}
$: enrichedOptions = enrichOptions(optionsObj, $fetch.rows)
const enrichOptions = (optionsObj: OptionsObjType, fetchResults: Row[]) => {
const result = (fetchResults || [])?.reduce((accumulator, row) => {
if (!accumulator[row._id!]) {
accumulator[row._id!] = row
}
return accumulator
}, optionsObj || {})
return Object.values(result)
}
$: {
// We don't want to reorder while the dropdown is open, to avoid UX jumps
if (!open && primaryDisplay) {
enrichedOptions = enrichedOptions.sort((a: OptionObj, b: OptionObj) => {
const selectedValues = flatten(fieldState?.value) || []
const aIsSelected = selectedValues.find(
(v: RelationshipValue) => v === a._id
)
const bIsSelected = selectedValues.find(
(v: RelationshipValue) => v === b._id
)
if (aIsSelected && !bIsSelected) {
return -1
} else if (!aIsSelected && bIsSelected) {
return 1
}
return (a[primaryDisplay] > b[primaryDisplay]) as unknown as number
})
}
}
$: {
if (filter || defaultValue) {
forceFetchRows()
}
}
$: debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
const forceFetchRows = async () => {
// if the filter has changed, then we need to reset the options, clear the selection, and re-fetch
optionsObj = {}
fieldApi?.setValue([])
selectedValue = []
debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
}
async function fetchRows(
searchTerm: any,
primaryDisplay: string,
defaultVal: string | string[]
) {
const allRowsFetched =
$fetch.loaded &&
!Object.keys($fetch.query?.string || {}).length &&
!$fetch.hasNextPage
// Don't request until we have the primary display or default value has been fetched
if (allRowsFetched || !primaryDisplay) {
return
}
// must be an array
const defaultValArray: string[] = !defaultVal
? []
: !Array.isArray(defaultVal)
? defaultVal.split(",")
: defaultVal
if (
defaultVal &&
optionsObj &&
defaultValArray.some(val => !optionsObj[val])
) {
await fetch.update({
query: { oneOf: { _id: defaultValArray } },
})
}
if (
(Array.isArray(selectedValue) &&
selectedValue.some(val => !optionsObj[val])) ||
(selectedValue && !optionsObj[selectedValue as string])
) {
await fetch.update({
query: {
oneOf: {
_id: Array.isArray(selectedValue) ? selectedValue : [selectedValue],
},
},
})
}
// Ensure we match all filters, rather than any
// @ts-expect-error this doesn't fit types, but don't want to change it yet
const baseFilter: any = (filter || []).filter(x => x.operator !== "allOr")
await fetch.update({
filter: [
...baseFilter,
{
// Use a big numeric prefix to avoid clashing with an existing filter
field: `999:${primaryDisplay}`,
operator: "string",
value: searchTerm,
},
],
return fetchData({
API,
datasource,
options: {
filter,
limit: writable ? 100 : 1,
},
})
}
const flatten = (values: any | any[]) => {
// Small helper to represent the selected value as an array
const getSelectedIDs = (
selectedValue: undefined | string | string[]
): string[] => {
if (!selectedValue) {
return []
}
return Array.isArray(selectedValue) ? selectedValue : [selectedValue]
}
// Builds a map of all available options, in a consistent structure
const processOptions = (
realValue: any | any[],
rows: Row[],
primaryDisplay?: string
) => {
// First ensure that all options included in the value are present as valid
// options. These can be basic related row shapes which already include
// a value for primary display
if (realValue) {
const valueArray = Array.isArray(realValue) ? realValue : [realValue]
for (let val of valueArray) {
const option = parseOption(val, primaryDisplay)
if (option) {
optionsMap[option._id] = option
}
}
}
// Process all rows loaded from our fetch
for (let row of rows) {
const option = parseOption(row, primaryDisplay)
if (option) {
optionsMap[option._id] = option
}
}
// Reassign to trigger reactivity
optionsMap = optionsMap
}
// Parses a row-like structure into a properly shaped option
const parseOption = (
option: any | BasicRelatedRow | Row,
primaryDisplay?: string
): BasicRelatedRow | null => {
if (!option || typeof option !== "object" || !option?._id) {
return null
}
// If this is a basic related row shape (_id and PD only) then just use
// that
if (Object.keys(option).length === 2 && "primaryDisplay" in option) {
return {
_id: option._id,
primaryDisplay: ensureString(option.primaryDisplay),
}
}
// Otherwise use the primary display field specified
if (primaryDisplay) {
return {
_id: option._id,
primaryDisplay: ensureString(
option[primaryDisplay as keyof typeof option]
),
}
} else {
return {
_id: option._id,
primaryDisplay: option._id,
}
}
}
// Loads any rows which are selected and aren't present in the currently
// available option set. This is typically only IDs specified as default
// values.
const loadMissingOptions = async (
missingIDs: string[],
linkedTableId?: string,
primaryDisplay?: string
) => {
if (
loadingMissingOptions ||
!missingIDs.length ||
!linkedTableId ||
!primaryDisplay
) {
return
}
loadingMissingOptions = true
try {
const res = await API.searchTable(linkedTableId, {
query: {
oneOf: {
_id: missingIDs,
},
},
})
for (let row of res.rows) {
const option = parseOption(row, primaryDisplay)
if (option) {
optionsMap[option._id] = option
}
}
// Reassign to trigger reactivity
optionsMap = optionsMap
updateOptions(optionsMap)
} catch (error) {
console.error("Error loading missing row IDs", error)
} finally {
// Ensure we have some sort of option for all IDs
for (let id of missingIDs) {
if (!optionsMap[id]) {
optionsMap[id] = {
_id: id,
primaryDisplay: id,
}
}
}
loadingMissingOptions = false
}
}
// Updates the options list to reflect the currently available options
const updateOptions = (optionsMap: OptionsMap) => {
let newOptions = Object.values(optionsMap)
// Only override options if the quantity of options changes
if (newOptions.length !== options.length) {
options = newOptions
sortOptions()
}
}
// Sorts the options list by selected state, then by primary display
const sortOptions = () => {
// Create a quick lookup map so we can test whether options are selected
const selectedMap: Record<string, boolean> = selectedIDs.reduce(
(map, id) => ({ ...map, [id]: true }),
{}
)
options.sort((a, b) => {
const aSelected = !!selectedMap[a._id]
const bSelected = !!selectedMap[b._id]
if (aSelected === bSelected) {
return a.primaryDisplay < b.primaryDisplay ? -1 : 1
} else {
return aSelected ? -1 : 1
}
})
}
// Util to ensure a value is stringified
const ensureString = (val: any): string => {
return typeof val === "string" ? val : JSON.stringify(val)
}
// We previously included logic to manually process default value, which
// should not be done as it is handled by the core form logic.
// This logic included handling a comma separated list of IDs, so for
// backwards compatibility we must now unfortunately continue to handle that
// at this level.
const enrichDefaultValue = (val: any) => {
if (!val || typeof val !== "string") {
return val
}
return val.includes(",") ? val.split(",") : val
}
// Searches for new options matching the given term
async function searchOptions(searchTerm: string, primaryDisplay?: string) {
if (!primaryDisplay) {
return
}
// Ensure we match all filters, rather than any
let newFilter: any = filter
if (searchTerm) {
// @ts-expect-error this doesn't fit types, but don't want to change it yet
newFilter = (newFilter || []).filter(x => x.operator !== "allOr")
newFilter.push({
// Use a big numeric prefix to avoid clashing with an existing filter
field: `999:${primaryDisplay}`,
operator: "string",
value: searchTerm,
})
}
await fetch?.update({
filter: newFilter,
})
}
const debouncedSearchOptions = Utils.debounce(searchOptions, 250)
// Flattens an array of row-like objects into a simple array of row IDs
const flatten = (values: any | any[]): string[] => {
if (!values) {
return []
}
if (!Array.isArray(values)) {
values = [values]
}
@ -226,16 +337,11 @@
return values
}
const getDisplayName = (row: Row) => {
return row?.[primaryDisplay!] || "-"
}
const handleChange = (e: any) => {
let value = e.detail
if (!multiselect) {
value = value == null ? [] : [value]
}
if (
type === FieldType.BB_REFERENCE_SINGLE &&
value &&
@ -243,7 +349,6 @@
) {
value = value[0] || null
}
const changed = fieldApi.setValue(value)
if (onChange && changed) {
onChange({
@ -251,12 +356,6 @@
})
}
}
const loadMore = () => {
if (!$fetch.loading) {
fetch.nextPage()
}
}
</script>
<Field
@ -265,31 +364,31 @@
{disabled}
{readonly}
{validation}
{defaultValue}
{type}
{span}
{helpText}
defaultValue={enrichedDefaultValue}
bind:fieldState
bind:fieldApi
bind:fieldSchema
>
{#if fieldState}
<svelte:component
this={component}
options={enrichedOptions}
{autocomplete}
value={castSelectedValue}
on:change={handleChange}
on:loadMore={loadMore}
id={fieldState.fieldId}
disabled={fieldState.disabled}
readonly={fieldState.readonly}
getOptionLabel={getDisplayName}
this={multiselect ? CoreMultiselect : CoreSelect}
value={displayValue}
id={fieldState?.fieldId}
disabled={fieldState?.disabled}
readonly={fieldState?.readonly}
loading={!!$fetch?.loading}
getOptionLabel={option => option.primaryDisplay}
getOptionValue={option => option._id}
{options}
{placeholder}
{autocomplete}
bind:searchTerm
loading={$fetch.loading}
bind:open
on:change={handleChange}
on:loadMore={() => fetch?.nextPage()}
/>
{/if}
</Field>

View File

@ -4,7 +4,7 @@ import { GroupUserDatasource, InternalTable } from "@budibase/types"
interface GroupUserQuery {
groupId: string
emailSearch: string
emailSearch?: string
}
interface GroupUserDefinition {

View File

@ -9,8 +9,8 @@ import {
} from "@budibase/types"
interface UserFetchQuery {
appId: string
paginated: boolean
appId?: string
paginated?: boolean
}
interface UserDefinition {

View File

@ -156,6 +156,7 @@
"@types/pouchdb": "6.4.2",
"@types/server-destroy": "1.0.1",
"@types/supertest": "2.0.14",
"@types/swagger-jsdoc": "^6.0.4",
"@types/tar": "6.1.5",
"@types/tmp": "0.2.6",
"@types/uuid": "8.3.4",

View File

@ -4,11 +4,11 @@ import { examples, schemas } from "./resources"
import * as parameters from "./parameters"
import * as security from "./security"
const swaggerJsdoc = require("swagger-jsdoc")
import swaggerJsdoc from "swagger-jsdoc"
const VARIABLES = {}
const options = {
const opts: swaggerJsdoc.Options = {
definition: {
openapi: "3.0.0",
info: {
@ -58,30 +58,27 @@ const options = {
}
function writeFile(output: any, filename: string) {
try {
const path = join(__dirname, filename)
let spec = output
if (filename.endsWith("json")) {
spec = JSON.stringify(output, null, 2)
}
// input the static variables
for (let [key, replacement] of Object.entries(VARIABLES)) {
spec = spec.replace(new RegExp(`{${key}}`, "g"), replacement)
}
writeFileSync(path, spec)
console.log(`Wrote spec to ${path}`)
return path
} catch (err) {
console.error("Error writing spec file", err)
const path = join(__dirname, filename)
let spec = output
if (filename.endsWith("json")) {
spec = JSON.stringify(output, null, 2)
}
// input the static variables
for (let [key, replacement] of Object.entries(VARIABLES)) {
spec = spec.replace(new RegExp(`{${key}}`, "g"), replacement)
}
writeFileSync(path, spec)
console.log(`Wrote spec to ${path}`)
return path
}
export function spec() {
return swaggerJsdoc({ ...opts, format: ".json" })
}
export function run() {
const outputJSON = swaggerJsdoc(options)
options.format = ".yaml"
const outputYAML = swaggerJsdoc(options)
writeFile(outputJSON, "openapi.json")
return writeFile(outputYAML, "openapi.yaml")
writeFile(swaggerJsdoc({ ...opts, format: ".json" }), "openapi.json")
return writeFile(swaggerJsdoc({ ...opts, format: ".yaml" }), "openapi.yaml")
}
if (require.main === module) {

View File

@ -0,0 +1,21 @@
import { object } from "./utils"
import Resource from "./utils/Resource"
const errorSchema = object({
status: {
type: "number",
description: "The HTTP status code of the error.",
},
message: {
type: "string",
description: "A descriptive message about the error.",
},
})
export default new Resource()
.setExamples({
error: {},
})
.setSchemas({
error: errorSchema,
})

View File

@ -9,6 +9,7 @@ import {
const DISABLED_EXTERNAL_INTEGRATIONS = [
SourceName.AIRTABLE,
SourceName.BUDIBASE,
SourceName.ARANGODB,
]
export async function fetch(ctx: UserCtx<void, FetchIntegrationsResponse>) {

View File

@ -48,7 +48,7 @@ function getUser(ctx: UserCtx, userId?: string) {
if (userId) {
ctx.params = { userId }
} else if (!ctx.params?.userId) {
throw "No user ID provided for getting"
throw new Error("No user ID provided for getting")
}
return readGlobalUser(ctx)
}

View File

@ -12,6 +12,7 @@ import { paramResource, paramSubResource } from "../../../middleware/resourceId"
import { PermissionLevel, PermissionType } from "@budibase/types"
import { CtxFn } from "./utils/Endpoint"
import mapperMiddleware from "./middleware/mapper"
import testErrorHandling from "./middleware/testErrorHandling"
import env from "../../../environment"
import { middleware, redis } from "@budibase/backend-core"
import { SelectableDatabase } from "@budibase/backend-core/src/redis/utils"
@ -144,6 +145,10 @@ function applyRoutes(
// add the output mapper middleware
addMiddleware(endpoints.read, mapperMiddleware, { output: true })
addMiddleware(endpoints.write, mapperMiddleware, { output: true })
if (env.isTest()) {
addMiddleware(endpoints.read, testErrorHandling())
addMiddleware(endpoints.write, testErrorHandling())
}
addToRouter(endpoints.read)
addToRouter(endpoints.write)
}

View File

@ -0,0 +1,28 @@
import { Ctx } from "@budibase/types"
import environment from "../../../../environment"
export default () => {
if (!environment.isTest()) {
throw new Error("This middleware is only for testing")
}
return async (ctx: Ctx, next: any) => {
try {
await next()
} catch (err: any) {
if (!ctx.headers["x-budibase-include-stacktrace"]) {
throw err
}
const status = err.status || err.statusCode || 500
let error = err
while (error.cause) {
error = error.cause
}
ctx.status = status
ctx.body = { status, message: error.message, stack: error.stack }
}
}
}

View File

@ -2,184 +2,174 @@ import jestOpenAPI from "jest-openapi"
import { run as generateSchema } from "../../../../../specs/generate"
import * as setup from "../../tests/utilities"
import { generateMakeRequest } from "./utils"
import { Table, App, Row, User } from "@budibase/types"
import { Table, App, Row } from "@budibase/types"
import nock from "nock"
import environment from "../../../../environment"
const yamlPath = generateSchema()
jestOpenAPI(yamlPath!)
let config = setup.getConfig()
let apiKey: string, table: Table, app: App, makeRequest: any
describe("compare", () => {
let config = setup.getConfig()
let apiKey: string, table: Table, app: App, makeRequest: any
beforeAll(async () => {
app = await config.init()
table = await config.upsertTable()
apiKey = await config.generateApiKey()
makeRequest = generateMakeRequest(apiKey)
})
afterAll(setup.afterAll)
describe("check the applications endpoints", () => {
it("should allow retrieving applications through search", async () => {
const res = await makeRequest("post", "/applications/search")
expect(res).toSatisfyApiSpec()
})
it("should allow creating an application", async () => {
const res = await makeRequest(
"post",
"/applications",
{
name: "new App",
},
null
)
expect(res).toSatisfyApiSpec()
})
it("should allow updating an application", async () => {
const app = config.getApp()
const appId = config.getAppId()
const res = await makeRequest(
"put",
`/applications/${appId}`,
{
...app,
name: "updated app name",
},
appId
)
expect(res).toSatisfyApiSpec()
})
it("should allow retrieving an application", async () => {
const res = await makeRequest("get", `/applications/${config.getAppId()}`)
expect(res).toSatisfyApiSpec()
})
it("should allow deleting an application", async () => {
const res = await makeRequest(
"delete",
`/applications/${config.getAppId()}`
)
expect(res).toSatisfyApiSpec()
})
})
describe("check the tables endpoints", () => {
it("should allow retrieving tables through search", async () => {
await config.createApp("new app 1")
beforeAll(async () => {
app = await config.init()
table = await config.upsertTable()
const res = await makeRequest("post", "/tables/search")
expect(res).toSatisfyApiSpec()
apiKey = await config.generateApiKey()
makeRequest = generateMakeRequest(apiKey)
})
it("should allow creating a table", async () => {
const res = await makeRequest("post", "/tables", {
name: "table name",
primaryDisplay: "column1",
schema: {
column1: {
type: "string",
constraints: {},
afterAll(setup.afterAll)
beforeEach(() => {
nock.cleanAll()
})
describe("check the applications endpoints", () => {
it("should allow retrieving applications through search", async () => {
const res = await makeRequest("post", "/applications/search")
expect(res).toSatisfyApiSpec()
})
it("should allow creating an application", async () => {
const res = await makeRequest(
"post",
"/applications",
{
name: "new App",
},
},
null
)
expect(res).toSatisfyApiSpec()
})
expect(res).toSatisfyApiSpec()
})
it("should allow updating a table", async () => {
const updated = { ...table, _rev: undefined, name: "new name" }
const res = await makeRequest("put", `/tables/${table._id}`, updated)
expect(res).toSatisfyApiSpec()
})
it("should allow retrieving a table", async () => {
const res = await makeRequest("get", `/tables/${table._id}`)
expect(res).toSatisfyApiSpec()
})
it("should allow deleting a table", async () => {
const res = await makeRequest("delete", `/tables/${table._id}`)
expect(res).toSatisfyApiSpec()
})
})
describe("check the rows endpoints", () => {
let row: Row
it("should allow retrieving rows through search", async () => {
table = await config.upsertTable()
const res = await makeRequest("post", `/tables/${table._id}/rows/search`, {
query: {},
it("should allow updating an application", async () => {
const app = config.getApp()
const appId = config.getAppId()
const res = await makeRequest(
"put",
`/applications/${appId}`,
{
...app,
name: "updated app name",
},
appId
)
expect(res).toSatisfyApiSpec()
})
expect(res).toSatisfyApiSpec()
})
it("should allow creating a row", async () => {
const res = await makeRequest("post", `/tables/${table._id}/rows`, {
name: "test row",
it("should allow retrieving an application", async () => {
const res = await makeRequest("get", `/applications/${config.getAppId()}`)
expect(res).toSatisfyApiSpec()
})
it("should allow deleting an application", async () => {
nock(environment.WORKER_URL!)
.delete(`/api/global/roles/${config.getProdAppId()}`)
.reply(200, {})
const res = await makeRequest(
"delete",
`/applications/${config.getAppId()}`
)
expect(res).toSatisfyApiSpec()
})
expect(res).toSatisfyApiSpec()
row = res.body.data
})
it("should allow updating a row", async () => {
const res = await makeRequest(
"put",
`/tables/${table._id}/rows/${row._id}`,
{
name: "test row updated",
}
)
expect(res).toSatisfyApiSpec()
describe("check the tables endpoints", () => {
it("should allow retrieving tables through search", async () => {
await config.createApp("new app 1")
table = await config.upsertTable()
const res = await makeRequest("post", "/tables/search")
expect(res).toSatisfyApiSpec()
})
it("should allow creating a table", async () => {
const res = await makeRequest("post", "/tables", {
name: "table name",
primaryDisplay: "column1",
schema: {
column1: {
type: "string",
constraints: {},
},
},
})
expect(res).toSatisfyApiSpec()
})
it("should allow updating a table", async () => {
const updated = { ...table, _rev: undefined, name: "new name" }
const res = await makeRequest("put", `/tables/${table._id}`, updated)
expect(res).toSatisfyApiSpec()
})
it("should allow retrieving a table", async () => {
const res = await makeRequest("get", `/tables/${table._id}`)
expect(res).toSatisfyApiSpec()
})
it("should allow deleting a table", async () => {
const res = await makeRequest("delete", `/tables/${table._id}`)
expect(res).toSatisfyApiSpec()
})
})
it("should allow retrieving a row", async () => {
const res = await makeRequest("get", `/tables/${table._id}/rows/${row._id}`)
expect(res).toSatisfyApiSpec()
describe("check the rows endpoints", () => {
let row: Row
it("should allow retrieving rows through search", async () => {
table = await config.upsertTable()
const res = await makeRequest(
"post",
`/tables/${table._id}/rows/search`,
{
query: {},
}
)
expect(res).toSatisfyApiSpec()
})
it("should allow creating a row", async () => {
const res = await makeRequest("post", `/tables/${table._id}/rows`, {
name: "test row",
})
expect(res).toSatisfyApiSpec()
row = res.body.data
})
it("should allow updating a row", async () => {
const res = await makeRequest(
"put",
`/tables/${table._id}/rows/${row._id}`,
{
name: "test row updated",
}
)
expect(res).toSatisfyApiSpec()
})
it("should allow retrieving a row", async () => {
const res = await makeRequest(
"get",
`/tables/${table._id}/rows/${row._id}`
)
expect(res).toSatisfyApiSpec()
})
it("should allow deleting a row", async () => {
const res = await makeRequest(
"delete",
`/tables/${table._id}/rows/${row._id}`
)
expect(res).toSatisfyApiSpec()
})
})
it("should allow deleting a row", async () => {
const res = await makeRequest(
"delete",
`/tables/${table._id}/rows/${row._id}`
)
expect(res).toSatisfyApiSpec()
})
})
describe("check the users endpoints", () => {
let user: User
it("should allow retrieving users through search", async () => {
user = await config.createUser()
const res = await makeRequest("post", "/users/search")
expect(res).toSatisfyApiSpec()
})
it("should allow creating a user", async () => {
const res = await makeRequest("post", "/users")
expect(res).toSatisfyApiSpec()
})
it("should allow updating a user", async () => {
const res = await makeRequest("put", `/users/${user._id}`)
expect(res).toSatisfyApiSpec()
})
it("should allow retrieving a user", async () => {
const res = await makeRequest("get", `/users/${user._id}`)
expect(res).toSatisfyApiSpec()
})
it("should allow deleting a user", async () => {
const res = await makeRequest("delete", `/users/${user._id}`)
expect(res).toSatisfyApiSpec()
})
})
describe("check the queries endpoints", () => {
it("should allow retrieving queries through search", async () => {
const res = await makeRequest("post", "/queries/search")
expect(res).toSatisfyApiSpec()
describe("check the queries endpoints", () => {
it("should allow retrieving queries through search", async () => {
const res = await makeRequest("post", "/queries/search")
expect(res).toSatisfyApiSpec()
})
})
})

View File

@ -1,132 +1,143 @@
import * as setup from "../../tests/utilities"
import { generateMakeRequest, MakeRequestResponse } from "./utils"
import { User } from "@budibase/types"
import { mocks } from "@budibase/backend-core/tests"
import { generator, mocks } from "@budibase/backend-core/tests"
import nock from "nock"
import TestConfiguration from "../../../../tests/utilities/TestConfiguration"
import { mockWorkerUserAPI } from "./utils"
import * as workerRequests from "../../../../utilities/workerRequests"
describe("public users API", () => {
const config = new TestConfiguration()
let globalUser: User
const mockedWorkerReq = jest.mocked(workerRequests)
let config = setup.getConfig()
let apiKey: string, globalUser: User, makeRequest: MakeRequestResponse
beforeAll(async () => {
await config.init()
globalUser = await config.globalUser()
apiKey = await config.generateApiKey(globalUser._id)
makeRequest = generateMakeRequest(apiKey)
mockedWorkerReq.readGlobalUser.mockImplementation(() =>
Promise.resolve(globalUser)
)
})
afterAll(setup.afterAll)
function base() {
return {
tenantId: config.getTenantId(),
firstName: "Test",
lastName: "Test",
}
}
function updateMock() {
mockedWorkerReq.readGlobalUser.mockImplementation(ctx => ctx.request.body)
}
describe("check user endpoints", () => {
it("should not allow a user to update their own roles", async () => {
const res = await makeRequest("put", `/users/${globalUser._id}`, {
...globalUser,
roles: {
app_1: "ADMIN",
},
})
expect(
mockedWorkerReq.saveGlobalUser.mock.lastCall?.[0].body.data.roles["app_1"]
).toBeUndefined()
expect(res.status).toBe(200)
expect(res.body.data.roles["app_1"]).toBeUndefined()
beforeAll(async () => {
await config.init()
})
it("should not allow a user to delete themselves", async () => {
const res = await makeRequest("delete", `/users/${globalUser._id}`)
expect(res.status).toBe(405)
expect(mockedWorkerReq.deleteGlobalUser.mock.lastCall).toBeUndefined()
})
})
describe("no user role update in free", () => {
beforeAll(() => {
updateMock()
})
it("should not allow 'roles' to be updated", async () => {
const res = await makeRequest("post", "/users", {
...base(),
roles: { app_a: "BASIC" },
})
expect(res.status).toBe(200)
expect(res.body.data.roles["app_a"]).toBeUndefined()
expect(res.body.message).toBeDefined()
})
it("should not allow 'admin' to be updated", async () => {
const res = await makeRequest("post", "/users", {
...base(),
admin: { global: true },
})
expect(res.status).toBe(200)
expect(res.body.data.admin).toBeUndefined()
expect(res.body.message).toBeDefined()
})
it("should not allow 'builder' to be updated", async () => {
const res = await makeRequest("post", "/users", {
...base(),
builder: { global: true },
})
expect(res.status).toBe(200)
expect(res.body.data.builder).toBeUndefined()
expect(res.body.message).toBeDefined()
})
})
describe("no user role update in business", () => {
beforeAll(() => {
updateMock()
mocks.licenses.useExpandedPublicApi()
})
it("should allow 'roles' to be updated", async () => {
const res = await makeRequest("post", "/users", {
...base(),
roles: { app_a: "BASIC" },
})
expect(res.status).toBe(200)
expect(res.body.data.roles["app_a"]).toBe("BASIC")
expect(res.body.message).toBeUndefined()
})
it("should allow 'admin' to be updated", async () => {
mocks.licenses.useExpandedPublicApi()
const res = await makeRequest("post", "/users", {
...base(),
admin: { global: true },
})
expect(res.status).toBe(200)
expect(res.body.data.admin.global).toBe(true)
expect(res.body.message).toBeUndefined()
})
it("should allow 'builder' to be updated", async () => {
mocks.licenses.useExpandedPublicApi()
const res = await makeRequest("post", "/users", {
...base(),
builder: { global: true },
})
expect(res.status).toBe(200)
expect(res.body.data.builder.global).toBe(true)
expect(res.body.message).toBeUndefined()
afterAll(setup.afterAll)
beforeEach(async () => {
globalUser = await config.globalUser()
nock.cleanAll()
mockWorkerUserAPI(globalUser)
})
describe("read", () => {
it("should allow a user to read themselves", async () => {
const user = await config.api.user.find(globalUser._id!)
expect(user._id).toBe(globalUser._id)
})
it("should allow a user to read another user", async () => {
const otherUser = await config.api.public.user.create({
email: generator.email({ domain: "example.com" }),
roles: {},
})
const user = await config.withUser(globalUser, () =>
config.api.public.user.find(otherUser._id!)
)
expect(user._id).toBe(otherUser._id)
})
})
describe("create", () => {
it("can successfully create a new user", async () => {
const email = generator.email({ domain: "example.com" })
const newUser = await config.api.public.user.create({
email,
roles: {},
})
expect(newUser.email).toBe(email)
expect(newUser._id).toBeDefined()
})
describe("role creation on free tier", () => {
it("should not allow 'roles' to be updated", async () => {
const newUser = await config.api.public.user.create({
email: generator.email({ domain: "example.com" }),
roles: { app_a: "BASIC" },
})
expect(newUser.roles["app_a"]).toBeUndefined()
})
it("should not allow 'admin' to be updated", async () => {
const newUser = await config.api.public.user.create({
email: generator.email({ domain: "example.com" }),
roles: {},
admin: { global: true },
})
expect(newUser.admin).toBeUndefined()
})
it("should not allow 'builder' to be updated", async () => {
const newUser = await config.api.public.user.create({
email: generator.email({ domain: "example.com" }),
roles: {},
builder: { global: true },
})
expect(newUser.builder).toBeUndefined()
})
})
describe("role creation on business tier", () => {
beforeAll(() => {
mocks.licenses.useExpandedPublicApi()
})
it("should allow 'roles' to be updated", async () => {
const newUser = await config.api.public.user.create({
email: generator.email({ domain: "example.com" }),
roles: { app_a: "BASIC" },
})
expect(newUser.roles["app_a"]).toBe("BASIC")
})
it("should allow 'admin' to be updated", async () => {
const newUser = await config.api.public.user.create({
email: generator.email({ domain: "example.com" }),
roles: {},
admin: { global: true },
})
expect(newUser.admin?.global).toBe(true)
})
it("should allow 'builder' to be updated", async () => {
const newUser = await config.api.public.user.create({
email: generator.email({ domain: "example.com" }),
roles: {},
builder: { global: true },
})
expect(newUser.builder?.global).toBe(true)
})
})
})
describe("update", () => {
it("can update a user", async () => {
const updatedUser = await config.api.public.user.update({
...globalUser,
email: `updated-${globalUser.email}`,
})
expect(updatedUser.email).toBe(`updated-${globalUser.email}`)
})
it("should not allow a user to update their own roles", async () => {
await config.withUser(globalUser, () =>
config.api.public.user.update({
...globalUser,
roles: { app_1: "ADMIN" },
})
)
const updatedUser = await config.api.user.find(globalUser._id!)
expect(updatedUser.roles?.app_1).toBeUndefined()
})
})
describe("delete", () => {
it("should not allow a user to delete themselves", async () => {
await config.withUser(globalUser, () =>
config.api.public.user.destroy(globalUser._id!, { status: 405 })
)
})
})
})

View File

@ -1,6 +1,10 @@
import * as setup from "../../tests/utilities"
import { checkSlashesInUrl } from "../../../../utilities"
import supertest from "supertest"
import { User } from "@budibase/types"
import environment from "../../../../environment"
import nock from "nock"
import { generator } from "@budibase/backend-core/tests"
export type HttpMethod = "post" | "get" | "put" | "delete" | "patch"
@ -91,3 +95,43 @@ export function generateMakeRequestWithFormData(
return res
}
}
export function mockWorkerUserAPI(...seedUsers: User[]) {
const users: Record<string, User> = {
...seedUsers.reduce((acc, user) => {
acc[user._id!] = user
return acc
}, {} as Record<string, User>),
}
nock(environment.WORKER_URL!)
.get(new RegExp(`/api/global/users/.*`))
.reply(200, (uri, body) => {
const id = uri.split("/").pop()
return users[id!]
})
.persist()
nock(environment.WORKER_URL!)
.post(`/api/global/users`)
.reply(200, (uri, body) => {
const newUser = body as User
if (!newUser._id) {
newUser._id = `us_${generator.guid()}`
}
users[newUser._id!] = newUser
return newUser
})
.persist()
nock(environment.WORKER_URL!)
.put(new RegExp(`/api/global/users/.*`))
.reply(200, (uri, body) => {
const id = uri.split("/").pop()!
const updatedUser = body as User
const existingUser = users[id] || {}
users[id] = { ...existingUser, ...updatedUser }
return users[id]
})
.persist()
}

View File

@ -1,153 +1,106 @@
const pg = require("pg")
jest.mock("pg", () => {
return {
Client: jest.fn().mockImplementation(() => ({
connect: jest.fn(),
query: jest.fn().mockImplementation(() => ({ rows: [] })),
end: jest.fn().mockImplementation((fn: any) => fn()),
})),
queryMock: jest.fn().mockImplementation(() => {}),
on: jest.fn(),
}
})
import * as setup from "./utilities"
import { structures } from "./utilities"
import { mocks } from "@budibase/backend-core/tests"
import { env, events } from "@budibase/backend-core"
import { QueryPreview } from "@budibase/types"
import { setEnv } from "@budibase/backend-core"
import { Datasource } from "@budibase/types"
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
import {
DatabaseName,
datasourceDescribe,
} from "../../../integrations/tests/utils"
const structures = setup.structures
const describes = datasourceDescribe({ only: [DatabaseName.POSTGRES] })
env._set("ENCRYPTION_KEY", "budibase")
mocks.licenses.useEnvironmentVariables()
if (describes.length > 0) {
describe.each(describes)("/api/env/variables", ({ dsProvider }) => {
const config = new TestConfiguration()
describe("/api/env/variables", () => {
let request = setup.getRequest()
let config = setup.getConfig()
let rawDatasource: Datasource
let restoreEnv: () => void
afterAll(setup.afterAll)
beforeAll(async () => {
await config.init()
restoreEnv = setEnv({ ENCRYPTION_KEY: "budibase" })
mocks.licenses.useEnvironmentVariables()
beforeAll(async () => {
await config.init()
})
const ds = await dsProvider()
rawDatasource = ds.rawDatasource!
})
it("should be able check the status of env var API", async () => {
const res = await request
.get(`/api/env/variables/status`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
afterAll(() => {
restoreEnv()
})
expect(res.body.encryptionKeyAvailable).toEqual(true)
})
it("should be able to create an environment variable", async () => {
await request
.post(`/api/env/variables`)
.send(structures.basicEnvironmentVariable("test", "test"))
.set(config.defaultHeaders())
.expect(200)
})
it("should be able to fetch the 'test' variable name", async () => {
const res = await request
.get(`/api/env/variables`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.variables.length).toEqual(1)
expect(res.body.variables[0]).toEqual("test")
})
it("should be able to update the environment variable 'test'", async () => {
const varName = "test"
await request
.patch(`/api/env/variables/${varName}`)
.send(structures.basicEnvironmentVariable("test", "test1"))
.set(config.defaultHeaders())
.expect(200)
})
it("should be able to delete the environment variable 'test'", async () => {
const varName = "test"
await request
.delete(`/api/env/variables/${varName}`)
.set(config.defaultHeaders())
.expect(200)
})
it("should create a datasource (using the environment variable) and query", async () => {
const datasourceBase = structures.basicDatasource()
await request
.post(`/api/env/variables`)
.send(structures.basicEnvironmentVariable("test", "test"))
.set(config.defaultHeaders())
datasourceBase.datasource.config = {
password: "{{ env.test }}",
}
const response = await request
.post(`/api/datasources`)
.send(datasourceBase)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(response.body.datasource._id).toBeDefined()
const response2 = await request
.post(`/api/queries`)
.send(structures.basicQuery(response.body.datasource._id))
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(response2.body._id).toBeDefined()
})
it("should run a query preview and check the mocked results", async () => {
const datasourceBase = structures.basicDatasource()
await request
.post(`/api/env/variables`)
.send(structures.basicEnvironmentVariable("test", "test"))
.set(config.defaultHeaders())
datasourceBase.datasource.config = {
password: "{{ env.test }}",
}
const response = await request
.post(`/api/datasources`)
.send(datasourceBase)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(response.body.datasource._id).toBeDefined()
const queryPreview: QueryPreview = {
datasourceId: response.body.datasource._id,
parameters: [],
fields: {},
queryVerb: "read",
name: response.body.datasource.name,
transformer: null,
schema: {},
readable: true,
}
const res = await request
.post(`/api/queries/preview`)
.send(queryPreview)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.rows.length).toEqual(0)
expect(events.query.previewed).toHaveBeenCalledTimes(1)
// API doesn't include config in response
delete response.body.datasource.config
expect(events.query.previewed).toHaveBeenCalledWith(
response.body.datasource,
{
...queryPreview,
nullDefaultSupport: true,
beforeEach(async () => {
const { variables } = await config.api.environment.fetch()
for (const variable of variables) {
await config.api.environment.destroy(variable)
}
)
expect(pg.Client).toHaveBeenCalledWith({ password: "test", ssl: undefined })
await config.api.environment.create({
name: "test",
production: rawDatasource.config!.password,
development: rawDatasource.config!.password,
})
})
it("should be able check the status of env var API", async () => {
const { encryptionKeyAvailable } = await config.api.environment.status()
expect(encryptionKeyAvailable).toEqual(true)
})
it("should be able to fetch the 'test' variable name", async () => {
const { variables } = await config.api.environment.fetch()
expect(variables.length).toEqual(1)
expect(variables[0]).toEqual("test")
})
it("should be able to update the environment variable 'test'", async () => {
await config.api.environment.update("test", {
production: "test1",
development: "test1",
})
})
it("should be able to delete the environment variable 'test'", async () => {
await config.api.environment.destroy("test")
})
it("should create a datasource (using the environment variable) and query", async () => {
const datasource = await config.api.datasource.create({
...structures.basicDatasource().datasource,
config: {
...rawDatasource.config,
password: "{{ env.test }}",
},
})
const query = await config.api.query.save({
...structures.basicQuery(datasource._id!),
fields: { sql: "SELECT 1" },
})
expect(query._id).toBeDefined()
})
it("should run a query preview and check the mocked results", async () => {
const datasource = await config.api.datasource.create({
...structures.basicDatasource().datasource,
config: {
...rawDatasource.config,
password: "{{ env.test }}",
},
})
const query = await config.api.query.save({
...structures.basicQuery(datasource._id!),
fields: { sql: "SELECT 1 as id" },
})
const { rows } = await config.api.query.preview({
...query,
queryId: query._id!,
})
expect(rows).toEqual([{ id: 1 }])
})
})
})
}

View File

@ -6,6 +6,7 @@ import {
docIds,
MAX_VALID_DATE,
MIN_VALID_DATE,
setEnv,
SQLITE_DESIGN_DOC_ID,
utils,
withEnv as withCoreEnv,
@ -43,19 +44,7 @@ import { generator, structures, mocks } from "@budibase/backend-core/tests"
import { DEFAULT_EMPLOYEE_TABLE_SCHEMA } from "../../../db/defaultData/datasource_bb_default"
import { generateRowIdField } from "../../../integrations/utils"
import { cloneDeep } from "lodash/fp"
jest.mock("@budibase/pro", () => ({
...jest.requireActual("@budibase/pro"),
ai: {
LargeLanguageModel: {
forCurrentTenant: async () => ({
llm: {},
run: jest.fn(() => `Mock LLM Response`),
buildPromptFromAIOperation: jest.fn(),
}),
},
},
}))
import { mockChatGPTResponse } from "../../../tests/utilities/mocks/openai"
const descriptions = datasourceDescribe({ plus: true })
@ -1896,11 +1885,15 @@ if (descriptions.length) {
!isInMemory &&
describe("AI Column", () => {
const UNEXISTING_AI_COLUMN = "Real LLM Response"
let envCleanup: () => void
beforeAll(async () => {
mocks.licenses.useBudibaseAI()
mocks.licenses.useAICustomConfigs()
envCleanup = setEnv({ OPENAI_API_KEY: "mock" })
mockChatGPTResponse("Mock LLM Response")
tableOrViewId = await createTableOrView({
product: { name: "product", type: FieldType.STRING },
ai: {
@ -1917,6 +1910,10 @@ if (descriptions.length) {
])
})
afterAll(() => {
envCleanup()
})
describe("equal", () => {
it("successfully finds rows based on AI column", async () => {
await expectQuery({

View File

@ -3,44 +3,6 @@ import supertest from "supertest"
export * as structures from "../../../../tests/utilities/structures"
function user() {
return {
_id: "user",
_rev: "rev",
createdAt: Date.now(),
email: "test@example.com",
roles: {},
tenantId: "default",
status: "active",
}
}
jest.mock("../../../../utilities/workerRequests", () => ({
getGlobalUsers: jest.fn(() => {
return {
_id: "us_uuid1",
}
}),
getGlobalSelf: jest.fn(() => {
return {
_id: "us_uuid1",
}
}),
allGlobalUsers: jest.fn(() => {
return [user()]
}),
readGlobalUser: jest.fn(() => {
return user()
}),
saveGlobalUser: jest.fn(() => {
return { _id: "user", _rev: "rev" }
}),
deleteGlobalUser: jest.fn(() => {
return { message: "deleted user" }
}),
removeAppFromUserRoles: jest.fn(),
}))
export function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}

View File

@ -16,6 +16,7 @@ describe("Execute Bash Automations", () => {
name: "test row",
description: "test description",
})
await config.api.automation.deleteAll()
})
afterAll(() => {

View File

@ -33,6 +33,7 @@ describe("test the create row action", () => {
name: "test",
description: "test",
}
await config.api.automation.deleteAll()
})
afterAll(() => {

View File

@ -6,6 +6,7 @@ describe("test the delay logic", () => {
beforeAll(async () => {
await config.init()
await config.api.automation.deleteAll()
})
afterAll(() => {

View File

@ -13,6 +13,7 @@ describe("test the delete row action", () => {
await config.init()
table = await config.api.table.save(basicTable())
row = await config.api.row.save(table._id!, {})
await config.api.automation.deleteAll()
})
afterAll(() => {

View File

@ -7,6 +7,7 @@ describe("test the outgoing webhook action", () => {
beforeAll(async () => {
await config.init()
await config.api.automation.deleteAll()
})
afterAll(() => {

View File

@ -26,6 +26,7 @@ if (descriptions.length) {
const ds = await dsProvider()
datasource = ds.datasource!
client = ds.client!
await config.api.automation.deleteAll()
})
beforeEach(async () => {

View File

@ -13,6 +13,7 @@ describe("Execute Script Automations", () => {
await config.init()
table = await config.api.table.save(basicTable())
await config.api.row.save(table._id!, {})
await config.api.automation.deleteAll()
})
afterAll(() => {

View File

@ -26,6 +26,7 @@ describe("test the filter logic", () => {
beforeAll(async () => {
await config.init()
await config.api.automation.deleteAll()
})
afterAll(() => {

View File

@ -22,10 +22,7 @@ describe("Attempt to run a basic loop automation", () => {
})
beforeEach(async () => {
const { automations } = await config.api.automation.fetch()
for (const automation of automations) {
await config.api.automation.delete(automation)
}
await config.api.automation.deleteAll()
table = await config.api.table.save(basicTable())
await config.api.row.save(table._id!, {})

View File

@ -7,6 +7,7 @@ describe("test the outgoing webhook action", () => {
beforeAll(async () => {
await config.init()
await config.api.automation.deleteAll()
})
afterAll(() => {

View File

@ -8,6 +8,7 @@ describe("test the outgoing webhook action", () => {
beforeAll(async () => {
await config.init()
await config.api.automation.deleteAll()
})
afterAll(() => {

View File

@ -16,6 +16,7 @@ describe("test the openai action", () => {
beforeAll(async () => {
await config.init()
await config.api.automation.deleteAll()
})
beforeEach(() => {

View File

@ -8,6 +8,7 @@ describe("test the outgoing webhook action", () => {
beforeAll(async () => {
await config.init()
await config.api.automation.deleteAll()
})
afterAll(() => {

View File

@ -21,6 +21,7 @@ describe("Test a query step automation", () => {
}
await config.api.row.save(table._id!, row)
await config.api.row.save(table._id!, row)
await config.api.automation.deleteAll()
})
afterAll(() => {

View File

@ -28,6 +28,7 @@ describe("test the outgoing webhook action", () => {
beforeAll(async () => {
await config.init()
await config.api.automation.deleteAll()
})
afterAll(() => {

View File

@ -6,6 +6,7 @@ describe("test the server log action", () => {
beforeAll(async () => {
await config.init()
await config.api.automation.deleteAll()
})
afterAll(() => {

View File

@ -9,6 +9,7 @@ describe("Test triggering an automation from another automation", () => {
beforeAll(async () => {
await automation.init()
await config.init()
await config.api.automation.deleteAll()
})
afterAll(async () => {

View File

@ -23,6 +23,7 @@ describe("test the update row action", () => {
await config.init()
table = await config.createTable()
row = await config.createRow()
await config.api.automation.deleteAll()
})
afterAll(() => {

View File

@ -7,6 +7,7 @@ describe("test the outgoing webhook action", () => {
beforeAll(async () => {
await config.init()
await config.api.automation.deleteAll()
})
afterAll(() => {

View File

@ -9,6 +9,8 @@ describe("app action trigger", () => {
beforeAll(async () => {
await config.init()
await config.api.automation.deleteAll()
automation = await createAutomationBuilder(config)
.onAppAction()
.serverLog({

View File

@ -16,6 +16,7 @@ describe("cron trigger", () => {
beforeAll(async () => {
await config.init()
await config.api.automation.deleteAll()
})
afterAll(() => {

View File

@ -11,6 +11,7 @@ describe("row deleted trigger", () => {
beforeAll(async () => {
await config.init()
await config.api.automation.deleteAll()
table = await config.api.table.save(basicTable())
automation = await createAutomationBuilder(config)
.onRowDeleted({ tableId: table._id! })

View File

@ -11,6 +11,7 @@ describe("row saved trigger", () => {
beforeAll(async () => {
await config.init()
await config.api.automation.deleteAll()
table = await config.api.table.save(basicTable())
automation = await createAutomationBuilder(config)
.onRowSaved({ tableId: table._id! })

View File

@ -11,6 +11,7 @@ describe("row updated trigger", () => {
beforeAll(async () => {
await config.init()
await config.api.automation.deleteAll()
table = await config.api.table.save(basicTable())
automation = await createAutomationBuilder(config)
.onRowUpdated({ tableId: table._id! })

View File

@ -37,6 +37,7 @@ describe("Webhook trigger test", () => {
beforeEach(async () => {
await config.init()
await config.api.automation.deleteAll()
table = await config.createTable()
})

View File

@ -9,6 +9,11 @@ import {
import { Database, aql } from "arangojs"
/**
* @deprecated 3rd March 2025
* datasource disabled - this datasource is marked for deprecation and removal
*/
interface ArangodbConfig {
url: string
username: string

View File

@ -33,15 +33,17 @@ const DEFINITIONS: Record<SourceName, Integration | undefined> = {
[SourceName.COUCHDB]: couchdb.schema,
[SourceName.SQL_SERVER]: sqlServer.schema,
[SourceName.S3]: s3.schema,
[SourceName.AIRTABLE]: airtable.schema,
[SourceName.MYSQL]: mysql.schema,
[SourceName.ARANGODB]: arangodb.schema,
[SourceName.REST]: rest.schema,
[SourceName.FIRESTORE]: firebase.schema,
[SourceName.GOOGLE_SHEETS]: googlesheets.schema,
[SourceName.REDIS]: redis.schema,
[SourceName.SNOWFLAKE]: snowflake.schema,
[SourceName.ORACLE]: oracle.schema,
/* deprecated - not available through UI */
[SourceName.ARANGODB]: arangodb.schema,
[SourceName.AIRTABLE]: airtable.schema,
/* un-used */
[SourceName.BUDIBASE]: undefined,
}
@ -56,15 +58,17 @@ const INTEGRATIONS: Record<SourceName, IntegrationBaseConstructor | undefined> =
[SourceName.COUCHDB]: couchdb.integration,
[SourceName.SQL_SERVER]: sqlServer.integration,
[SourceName.S3]: s3.integration,
[SourceName.AIRTABLE]: airtable.integration,
[SourceName.MYSQL]: mysql.integration,
[SourceName.ARANGODB]: arangodb.integration,
[SourceName.REST]: rest.integration,
[SourceName.FIRESTORE]: firebase.integration,
[SourceName.GOOGLE_SHEETS]: googlesheets.integration,
[SourceName.REDIS]: redis.integration,
[SourceName.SNOWFLAKE]: snowflake.integration,
[SourceName.ORACLE]: oracle.integration,
/* deprecated - not available through UI */
[SourceName.ARANGODB]: arangodb.integration,
[SourceName.AIRTABLE]: airtable.integration,
/* un-used */
[SourceName.BUDIBASE]: undefined,
}

View File

@ -1,76 +0,0 @@
import { default as AirtableIntegration } from "../airtable"
jest.mock("airtable")
class TestConfiguration {
integration: any
client: any
constructor(config: any = {}) {
this.integration = new AirtableIntegration.integration(config)
this.client = {
create: jest.fn(),
select: jest.fn(() => ({
firstPage: jest.fn(() => []),
})),
update: jest.fn(),
destroy: jest.fn(),
}
this.integration.client = () => this.client
}
}
describe("Airtable Integration", () => {
let config: any
beforeEach(() => {
config = new TestConfiguration()
})
it("calls the create method with the correct params", async () => {
await config.integration.create({
table: "test",
json: {},
})
expect(config.client.create).toHaveBeenCalledWith([
{
fields: {},
},
])
})
it("calls the read method with the correct params", async () => {
await config.integration.read({
table: "test",
view: "Grid view",
})
expect(config.client.select).toHaveBeenCalledWith({
maxRecords: 10,
view: "Grid view",
})
})
it("calls the update method with the correct params", async () => {
await config.integration.update({
table: "table",
id: "123",
json: {
name: "test",
},
})
expect(config.client.update).toHaveBeenCalledWith([
{
id: "123",
fields: { name: "test" },
},
])
})
it("calls the delete method with the correct params", async () => {
const ids = [1, 2, 3, 4]
await config.integration.delete({
ids,
})
expect(config.client.destroy).toHaveBeenCalledWith(ids)
})
})

View File

@ -1,38 +0,0 @@
import { default as ArangoDBIntegration } from "../arangodb"
jest.mock("arangojs")
class TestConfiguration {
integration: any
constructor(config: any = {}) {
this.integration = new ArangoDBIntegration.integration(config)
}
}
describe("ArangoDB Integration", () => {
let config: any
beforeEach(() => {
config = new TestConfiguration()
})
it("calls the create method with the correct params", async () => {
const body = {
json: "Hello",
}
await config.integration.create(body)
expect(config.integration.client.query).toHaveBeenCalledWith(
`INSERT Hello INTO collection RETURN NEW`
)
})
it("calls the read method with the correct params", async () => {
const query = {
sql: `test`,
}
await config.integration.read(query)
expect(config.integration.client.query).toHaveBeenCalledWith(query.sql)
})
})

View File

@ -67,6 +67,7 @@ import {
View,
Webhook,
WithRequired,
DevInfo,
} from "@budibase/types"
import API from "./api"
@ -248,7 +249,7 @@ export default class TestConfiguration {
}
}
async withUser(user: User, f: () => Promise<void>) {
async withUser<T>(user: User, f: () => Promise<T>): Promise<T> {
const oldUser = this.user
this.user = user
try {
@ -469,7 +470,10 @@ export default class TestConfiguration {
}
}
defaultHeaders(extras = {}, prodApp = false) {
defaultHeaders(
extras: Record<string, string | string[]> = {},
prodApp = false
) {
const tenantId = this.getTenantId()
const user = this.getUser()
const authObj: AuthToken = {
@ -498,10 +502,13 @@ export default class TestConfiguration {
}
}
publicHeaders({ prodApp = true } = {}) {
publicHeaders({
prodApp = true,
extras = {},
}: { prodApp?: boolean; extras?: Record<string, string | string[]> } = {}) {
const appId = prodApp ? this.prodAppId : this.appId
const headers: any = {
const headers: Record<string, string> = {
Accept: "application/json",
Cookie: "",
}
@ -514,6 +521,7 @@ export default class TestConfiguration {
return {
...headers,
...this.temporaryHeaders,
...extras,
}
}
@ -577,17 +585,17 @@ export default class TestConfiguration {
}
const db = tenancy.getTenantDB(this.getTenantId())
const id = dbCore.generateDevInfoID(userId)
let devInfo: any
try {
devInfo = await db.get(id)
} catch (err) {
devInfo = { _id: id, userId }
const devInfo = await db.tryGet<DevInfo>(id)
if (devInfo && devInfo.apiKey) {
return devInfo.apiKey
}
devInfo.apiKey = encryption.encrypt(
const apiKey = encryption.encrypt(
`${this.getTenantId()}${dbCore.SEPARATOR}${newid()}`
)
await db.put(devInfo)
return devInfo.apiKey
const newDevInfo: DevInfo = { _id: id, userId, apiKey }
await db.put(newDevInfo)
return apiKey
}
// APP

View File

@ -133,4 +133,11 @@ export class AutomationAPI extends TestAPI {
}
)
}
deleteAll = async (expectations?: Expectations): Promise<void> => {
const { automations } = await this.fetch()
await Promise.all(
automations.map(automation => this.delete(automation, expectations))
)
}
}

View File

@ -1,8 +1,12 @@
import jestOpenAPI from "jest-openapi"
import { spec } from "../../../../specs/generate"
import TestConfiguration from "../TestConfiguration"
import request, { SuperTest, Test, Response } from "supertest"
import { ReadStream } from "fs"
import { getServer } from "../../../app"
jestOpenAPI(spec() as any)
type Headers = Record<string, string | string[] | undefined>
type Method = "get" | "post" | "put" | "patch" | "delete"
@ -46,6 +50,7 @@ export interface RequestOpts {
export abstract class TestAPI {
config: TestConfiguration
request: SuperTest<Test>
prefix = ""
constructor(config: TestConfiguration) {
this.config = config
@ -53,26 +58,26 @@ export abstract class TestAPI {
}
protected _get = async <T>(url: string, opts?: RequestOpts): Promise<T> => {
return await this._request<T>("get", url, opts)
return await this._request<T>("get", `${this.prefix}${url}`, opts)
}
protected _post = async <T>(url: string, opts?: RequestOpts): Promise<T> => {
return await this._request<T>("post", url, opts)
return await this._request<T>("post", `${this.prefix}${url}`, opts)
}
protected _put = async <T>(url: string, opts?: RequestOpts): Promise<T> => {
return await this._request<T>("put", url, opts)
return await this._request<T>("put", `${this.prefix}${url}`, opts)
}
protected _patch = async <T>(url: string, opts?: RequestOpts): Promise<T> => {
return await this._request<T>("patch", url, opts)
return await this._request<T>("patch", `${this.prefix}${url}`, opts)
}
protected _delete = async <T>(
url: string,
opts?: RequestOpts
): Promise<T> => {
return await this._request<T>("delete", url, opts)
return await this._request<T>("delete", `${this.prefix}${url}`, opts)
}
protected _requestRaw = async (
@ -88,7 +93,6 @@ export abstract class TestAPI {
fields = {},
files = {},
expectations,
publicUser = false,
} = opts || {}
const { status = 200 } = expectations || {}
const expectHeaders = expectations?.headers || {}
@ -97,7 +101,7 @@ export abstract class TestAPI {
expectHeaders["Content-Type"] = /^application\/json/
}
let queryParams = []
let queryParams: string[] = []
for (const [key, value] of Object.entries(query)) {
if (value) {
queryParams.push(`${key}=${value}`)
@ -107,18 +111,10 @@ export abstract class TestAPI {
url += `?${queryParams.join("&")}`
}
const headersFn = publicUser
? (_extras = {}) =>
this.config.publicHeaders.bind(this.config)({
prodApp: opts?.useProdApp,
})
: (extras = {}) =>
this.config.defaultHeaders.bind(this.config)(extras, opts?.useProdApp)
const app = getServer()
let req = request(app)[method](url)
req = req.set(
headersFn({
await this.getHeaders(opts, {
"x-budibase-include-stacktrace": "true",
})
)
@ -167,10 +163,18 @@ export abstract class TestAPI {
}
}
protected _checkResponse = (
response: Response,
expectations?: Expectations
) => {
protected async getHeaders(
opts?: RequestOpts,
extras?: Record<string, string | string[]>
): Promise<Record<string, string | string[]>> {
if (opts?.publicUser) {
return this.config.publicHeaders({ prodApp: opts?.useProdApp, extras })
} else {
return this.config.defaultHeaders(extras, opts?.useProdApp)
}
}
protected _checkResponse(response: Response, expectations?: Expectations) {
const { status = 200 } = expectations || {}
if (response.status !== status) {
@ -236,3 +240,34 @@ export abstract class TestAPI {
).body
}
}
export abstract class PublicAPI extends TestAPI {
prefix = "/api/public/v1"
protected async getHeaders(
opts?: RequestOpts,
extras?: Record<string, string | string[]>
): Promise<Record<string, string | string[]>> {
const apiKey = await this.config.generateApiKey()
const headers: Record<string, string | string[]> = {
Accept: "application/json",
Host: this.config.tenantHost(),
"x-budibase-api-key": apiKey,
"x-budibase-app-id": this.config.getAppId(),
...extras,
}
return headers
}
protected _checkResponse(response: Response, expectations?: Expectations) {
const checked = super._checkResponse(response, expectations)
if (checked.status >= 200 && checked.status < 300) {
// We don't seem to have documented our errors yet, so for the time being
// we'll only do the schema check for successful responses.
expect(checked).toSatisfyApiSpec()
}
return checked
}
}

View File

@ -0,0 +1,51 @@
import { Expectations, TestAPI } from "./base"
import {
CreateEnvironmentVariableRequest,
CreateEnvironmentVariableResponse,
GetEnvironmentVariablesResponse,
StatusEnvironmentVariableResponse,
UpdateEnvironmentVariableRequest,
} from "@budibase/types"
export class EnvironmentAPI extends TestAPI {
create = async (
body: CreateEnvironmentVariableRequest,
expectations?: Expectations
) => {
return await this._post<CreateEnvironmentVariableResponse>(
`/api/env/variables`,
{ body, expectations }
)
}
status = async (expectations?: Expectations) => {
return await this._get<StatusEnvironmentVariableResponse>(
`/api/env/variables/status`,
{ expectations }
)
}
fetch = async (expectations?: Expectations) => {
return await this._get<GetEnvironmentVariablesResponse>(
`/api/env/variables`,
{ expectations }
)
}
update = async (
varName: string,
body: UpdateEnvironmentVariableRequest,
expectations?: Expectations
) => {
return await this._patch<void>(`/api/env/variables/${varName}`, {
body,
expectations,
})
}
destroy = async (varName: string, expectations?: Expectations) => {
return await this._delete<void>(`/api/env/variables/${varName}`, {
expectations,
})
}
}

View File

@ -17,6 +17,8 @@ import { RowActionAPI } from "./rowAction"
import { AutomationAPI } from "./automation"
import { PluginAPI } from "./plugin"
import { WebhookAPI } from "./webhook"
import { EnvironmentAPI } from "./environment"
import { UserPublicAPI } from "./public/user"
export default class API {
application: ApplicationAPI
@ -24,6 +26,7 @@ export default class API {
automation: AutomationAPI
backup: BackupAPI
datasource: DatasourceAPI
environment: EnvironmentAPI
legacyView: LegacyViewAPI
permission: PermissionAPI
plugin: PluginAPI
@ -38,12 +41,17 @@ export default class API {
viewV2: ViewV2API
webhook: WebhookAPI
public: {
user: UserPublicAPI
}
constructor(config: TestConfiguration) {
this.application = new ApplicationAPI(config)
this.attachment = new AttachmentAPI(config)
this.automation = new AutomationAPI(config)
this.backup = new BackupAPI(config)
this.datasource = new DatasourceAPI(config)
this.environment = new EnvironmentAPI(config)
this.legacyView = new LegacyViewAPI(config)
this.permission = new PermissionAPI(config)
this.plugin = new PluginAPI(config)
@ -57,5 +65,8 @@ export default class API {
this.user = new UserAPI(config)
this.viewV2 = new ViewV2API(config)
this.webhook = new WebhookAPI(config)
this.public = {
user: new UserPublicAPI(config),
}
}
}

View File

@ -0,0 +1,34 @@
import { UnsavedUser, User } from "@budibase/types"
import { Expectations, PublicAPI } from "../base"
export class UserPublicAPI extends PublicAPI {
find = async (id: string, expectations?: Expectations): Promise<User> => {
const response = await this._get<{ data: User }>(`/users/${id}`, {
expectations,
})
return response.data
}
update = async (user: User, expectations?: Expectations): Promise<User> => {
const response = await this._put<{ data: User }>(`/users/${user._id}`, {
body: user,
expectations,
})
return response.data
}
destroy = async (id: string, expectations?: Expectations): Promise<void> => {
return await this._delete(`/users/${id}`, { expectations })
}
create = async (
user: UnsavedUser,
expectations?: Expectations
): Promise<User> => {
const response = await this._post<{ data: User }>("/users", {
body: user,
expectations,
})
return response.data
}
}

View File

@ -37,6 +37,7 @@ import {
DeepPartial,
FilterCondition,
AutomationTriggerResult,
CreateEnvironmentVariableRequest,
} from "@budibase/types"
import { LoopInput } from "../../definitions/automations"
import { merge } from "lodash"
@ -574,7 +575,7 @@ export function basicEnvironmentVariable(
name: string,
prod: string,
dev?: string
) {
): CreateEnvironmentVariableRequest {
return {
name,
production: prod,

View File

@ -2796,9 +2796,9 @@
through2 "^2.0.0"
"@budibase/pro@npm:@budibase/pro@latest":
version "3.4.20"
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-3.4.20.tgz#0d855d6ed8fe92fd178c74a8963d879cc124b034"
integrity sha512-hUteGvhMOKjBo0fluxcqNs7d4x8OU5W8Oqqrm7eIS9Ohe7ala2iWNCcrj+x+S9CavIm6s7JZZnAewa2Maiz2zQ==
version "3.4.22"
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-3.4.22.tgz#943f23cb7056041bc1f433ee60b3d093145e7a4a"
integrity sha512-Du3iZsmRLopfoi2SvxQyY1P2Su3Nw0WbITOrKmZFsVLjZ9MzzTZs0Ph/SJHzrfJpM7rn9+8788BLSf3Z3l9KcQ==
dependencies:
"@anthropic-ai/sdk" "^0.27.3"
"@budibase/backend-core" "*"
@ -7142,6 +7142,11 @@
dependencies:
"@types/superagent" "*"
"@types/swagger-jsdoc@^6.0.4":
version "6.0.4"
resolved "https://registry.yarnpkg.com/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz#bb4f60f3a5f103818e022f2e29ff8935113fb83d"
integrity sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==
"@types/tar-fs@2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@types/tar-fs/-/tar-fs-2.0.1.tgz#6391dcad1b03dea2d79fac07371585ab54472bb1"