@budibase/server
Version:
Budibase Web Server
610 lines (518 loc) • 18.9 kB
text/typescript
import { constants, context, db as dbCore } from "@budibase/backend-core"
import { structures } from "@budibase/backend-core/tests"
import {
Automation,
FieldType,
FormulaType,
PublishResourceState,
Row,
Table,
WorkspaceApp,
} from "@budibase/types"
import { cloneDeep } from "lodash/fp"
import { createAutomationBuilder } from "../../../automations/tests/utilities/AutomationTestBuilder"
import { getRowParams } from "../../../db/utils"
import { basicTable } from "../../../tests/utilities/structures"
import * as setup from "./utilities"
describe("/api/deploy", () => {
let config = setup.getConfig()
afterAll(() => {
setup.afterAll()
})
beforeAll(async () => {
await config.init()
})
beforeEach(async () => {
await config.newTenant()
})
describe("GET /api/deploy/status", () => {
it("returns empty state when unpublished", async () => {
await config.api.workspace.unpublish(config.devWorkspaceId!)
const res = await config.api.deploy.publishStatus()
for (const automation of Object.values(res.automations)) {
expect(automation.published).toBe(false)
}
// default screens will appear here
for (const workspaceApp of Object.values(res.workspaceApps)) {
expect(workspaceApp.published).toBe(false)
}
})
it("returns disabled state for development-only resources", async () => {
const table = await config.api.table.save(basicTable())
// Create automation
const { automation } = await createAutomationBuilder(config)
.onRowSaved({ tableId: table._id! })
.serverLog({ text: "Test automation" })
.save()
// Create workspace app
const { workspaceApp } = await config.api.workspaceApp.create(
structures.workspaceApps.createRequest({
name: "Test Workspace App",
url: "/testapp",
})
)
const res = await config.api.deploy.publishStatus()
expect(res.automations[automation._id!]).toEqual({
published: false,
name: automation.name,
unpublishedChanges: true,
state: "disabled",
})
expect(res.workspaceApps[workspaceApp._id!]).toEqual({
published: false,
name: workspaceApp.name,
unpublishedChanges: true,
state: "disabled",
})
})
it("returns published state after full publish", async () => {
const table = await config.api.table.save(basicTable())
const { automation } = await createAutomationBuilder(config)
.onRowSaved({ tableId: table._id! })
.serverLog({ text: "Test automation" })
.save()
const { workspaceApp } = await config.api.workspaceApp.create(
structures.workspaceApps.createRequest({
name: "Test Workspace App",
url: "/testapp",
})
)
await config.api.workspace.publish(config.devWorkspace!.appId)
const res = await config.api.deploy.publishStatus()
expect(res.automations[automation._id!]).toEqual({
publishedAt: expect.any(String),
published: true,
name: automation.name,
unpublishedChanges: false,
state: "published",
})
expect(res.workspaceApps[workspaceApp._id!]).toEqual({
publishedAt: expect.any(String),
published: true,
name: workspaceApp.name,
unpublishedChanges: false,
state: "published",
})
expect(res.tables[table._id!]).toEqual({
publishedAt: expect.any(String),
published: true,
name: table.name,
unpublishedChanges: false,
state: "published",
})
})
it("returns mixed state after filtered publish", async () => {
const table = await config.api.table.save(basicTable())
// Create two automations
const { automation: publishedAutomation } = await createAutomationBuilder(
config
)
.onRowSaved({ tableId: table._id! })
.serverLog({ text: "Published automation" })
.save()
const { workspaceApp: publishedWorkspaceApp } =
await config.api.workspaceApp.create(
structures.workspaceApps.createRequest({
name: "Published Workspace App",
url: "/publishedapp",
})
)
await config.api.workspace.publish(config.devWorkspace!.appId)
const { automation: unpublishedAutomation } =
await createAutomationBuilder(config)
.onRowSaved({ tableId: table._id! })
.serverLog({ text: "Unpublished automation" })
.save()
const { workspaceApp: unpublishedWorkspaceApp } =
await config.api.workspaceApp.create(
structures.workspaceApps.createRequest({
name: "Unpublished Workspace App",
url: "/unpublishedapp",
})
)
const res = await config.api.deploy.publishStatus()
expect(res.automations[publishedAutomation._id!]).toEqual({
published: true,
name: publishedAutomation.name,
publishedAt: expect.any(String),
unpublishedChanges: false,
state: "published",
})
expect(res.workspaceApps[publishedWorkspaceApp._id!]).toEqual({
published: true,
name: publishedWorkspaceApp.name,
publishedAt: expect.any(String),
unpublishedChanges: false,
state: "published",
})
expect(res.automations[unpublishedAutomation._id!]).toEqual({
published: false,
name: unpublishedAutomation.name,
unpublishedChanges: true,
state: "disabled",
})
expect(res.workspaceApps[unpublishedWorkspaceApp._id!]).toEqual({
published: false,
name: unpublishedWorkspaceApp.name,
unpublishedChanges: true,
state: "disabled",
})
})
it("handles app with disabled automation/workspace app", async () => {
const table = await config.api.table.save(basicTable())
const { automation } = await createAutomationBuilder(config)
.onRowSaved({ tableId: table._id! })
.serverLog({ text: "Test automation" })
.save({ disabled: true })
const { workspaceApp } = await config.api.workspaceApp.create(
structures.workspaceApps.createRequest({
name: "Test Workspace App",
url: "/testapp",
disabled: true,
})
)
await config.api.workspace.publish(config.devWorkspace!.appId)
const res = await config.api.deploy.publishStatus()
expect(res.automations[automation._id!]).toEqual({
published: true,
publishedAt: expect.any(String),
name: automation.name,
unpublishedChanges: false,
state: "disabled",
})
expect(res.workspaceApps[workspaceApp._id!]).toEqual({
published: true,
publishedAt: expect.any(String),
name: workspaceApp.name,
unpublishedChanges: false,
state: "disabled",
})
})
it("returns only development resources that exist", async () => {
const table = await config.api.table.save(basicTable())
const { automation } = await createAutomationBuilder(config)
.onRowSaved({ tableId: table._id! })
.serverLog({ text: "Test automation" })
.save()
await config.api.workspace.publish(config.devWorkspace!.appId)
// Delete automation from development
await config.api.automation.delete(automation)
const res = await config.api.deploy.publishStatus()
// Should not include deleted automation
expect(res.automations[automation._id!]).toBeUndefined()
expect(Object.keys(res.automations)).toHaveLength(0)
})
})
describe("POST /api/deploy", () => {
beforeAll(async () => {
await config.init()
})
beforeEach(async () => {
await config.unpublish()
})
function expectApp(workspace: WorkspaceApp) {
return {
disabled: async (
disabled: boolean | undefined,
state: PublishResourceState
) => {
expect(
(await config.api.workspaceApp.find(workspace._id!)).disabled
).toBe(disabled)
const status = await config.api.deploy.publishStatus()
expect(status.workspaceApps[workspace._id!]).toEqual(
expect.objectContaining({
state,
})
)
},
}
}
function expectAutomation(automation: Automation) {
return {
disabled: async (
disabled: boolean | undefined,
state: PublishResourceState
) => {
expect(
(await config.api.automation.get(automation._id!)).disabled
).toBe(disabled)
const status = await config.api.deploy.publishStatus()
expect(status.automations[automation._id!]).toEqual(
expect.objectContaining({
state,
})
)
},
}
}
async function publishProdApp() {
await config.api.workspace.publish(config.getDevWorkspaceId())
await config.api.workspace.sync(config.getDevWorkspaceId())
}
it("should define the disable value for all workspace apps when publishing for the first time", async () => {
const { workspaceApp: publishedApp } =
await config.api.workspaceApp.create({
name: "Test App 1",
url: "/app1",
disabled: false,
})
const { workspaceApp: appWithoutInfo } =
await config.api.workspaceApp.create({
name: "Test App 2",
url: "/app2",
})
const { workspaceApp: disabledApp } =
await config.api.workspaceApp.create(
structures.workspaceApps.createRequest({
name: "Disabled App",
url: "/disabled",
disabled: true,
})
)
expect(publishedApp.disabled).toBe(false)
expect(appWithoutInfo.disabled).toBeUndefined()
expect(disabledApp.disabled).toBe(true)
// Publish the app for the first time
await publishProdApp()
await expectApp(publishedApp).disabled(
false,
PublishResourceState.PUBLISHED
)
await expectApp(appWithoutInfo).disabled(
true,
PublishResourceState.DISABLED
)
await expectApp(disabledApp).disabled(true, PublishResourceState.DISABLED)
})
it("should define the disable value for all automations when publishing for the first time", async () => {
const table = await config.api.table.save(basicTable())
const { automation: disabledAutomation } = await createAutomationBuilder(
config
)
.onRowSaved({ tableId: table._id! })
.save({ disabled: true })
const { automation: enabledAutomation } = await createAutomationBuilder(
config
)
.onRowSaved({ tableId: table._id! })
.save({ disabled: false })
const { automation: automationWithoutInfo } =
await createAutomationBuilder(config)
.onRowSaved({ tableId: table._id! })
.save({ disabled: undefined })
// Verify apps are not disabled before publishing
expect(disabledAutomation.disabled).toBe(true)
expect(enabledAutomation.disabled).toBe(false)
expect(automationWithoutInfo.disabled).toBe(undefined)
// Publish the app for the first time
await publishProdApp()
await expectAutomation(disabledAutomation).disabled(
true,
PublishResourceState.DISABLED
)
await expectAutomation(enabledAutomation).disabled(
false,
PublishResourceState.PUBLISHED
)
await expectAutomation(automationWithoutInfo).disabled(
true,
PublishResourceState.DISABLED
)
})
it("should not disable workspace apps on subsequent publishes", async () => {
const { workspaceApp: initialApp } = await config.api.workspaceApp.create(
{
name: "Test App 1",
url: "/app1",
disabled: undefined,
}
)
await publishProdApp()
// Remove disabled flag, simulating old apps
const db = dbCore.getDB(config.getDevWorkspaceId())
await db.put({
...(await config.api.workspaceApp.find(initialApp._id)),
disabled: undefined,
})
const { workspaceApp: secondApp } = await config.api.workspaceApp.create({
name: "Test App 2",
url: "/app2",
disabled: true,
})
await publishProdApp()
await expectApp(initialApp).disabled(
undefined,
PublishResourceState.PUBLISHED
)
await expectApp(secondApp).disabled(true, PublishResourceState.DISABLED)
})
it("should not disable automations on subsequent publishes", async () => {
const table = await config.api.table.save(basicTable())
const { automation: initialAutomation } = await createAutomationBuilder(
config
)
.onRowSaved({ tableId: table._id! })
.save({ disabled: undefined })
await publishProdApp()
// Remove disabled flag, simulating old automations
const db = dbCore.getDB(config.getDevWorkspaceId())
await db.put({
...(await config.api.automation.get(initialAutomation._id!)),
disabled: undefined,
})
const { automation: secondAutomation } = await createAutomationBuilder(
config
)
.onRowSaved({ tableId: table._id! })
.save({ disabled: true })
await publishProdApp()
await expectAutomation(initialAutomation).disabled(
undefined,
PublishResourceState.PUBLISHED
)
await expectAutomation(secondAutomation).disabled(
true,
PublishResourceState.DISABLED
)
})
})
it("updates production rows with new static formulas when published", async () => {
const amountFieldName = "amount"
const tableDefinition = basicTable(undefined, {
schema: {
[amountFieldName]: {
name: amountFieldName,
type: FieldType.NUMBER,
constraints: {},
},
},
})
const table = await config.api.table.save(tableDefinition)
// Initial publish so a production workspace exists
await config.api.workspace.publish(config.devWorkspace!.appId)
// Create a row directly in production to simulate live data
const productionRow = await config.withHeaders(
{ [constants.Header.APP_ID]: config.getProdWorkspaceId() },
async () =>
await config.api.row.save(table._id!, {
tableId: table._id!,
name: "Prod row",
description: "Prod description",
[amountFieldName]: 5,
})
)
const formulaFieldName = "amountPlusOne"
const formula = "{{ add amount 1 }}"
const updatedTable = cloneDeep(table)
updatedTable.schema[formulaFieldName] = {
name: formulaFieldName,
type: FieldType.FORMULA,
formula,
formulaType: FormulaType.STATIC,
responseType: FieldType.NUMBER,
}
await config.api.table.save(updatedTable)
await config.api.workspace.publish(config.devWorkspace!.appId)
const prodRowAfterPublish = await config.withHeaders(
{ [constants.Header.APP_ID]: config.getProdWorkspaceId() },
async () => await config.api.row.get(table._id!, productionRow._id!)
)
expect(prodRowAfterPublish[formulaFieldName]).toBe(6)
})
it("migrates production row data when a column is renamed in development", async () => {
const table = await config.api.table.save(basicTable())
await config.api.row.save(table._id!, {
name: "Test Row",
description: "original value",
})
await config.api.workspace.publish(config.devWorkspace!.appId)
const renamedSchema = {
...table.schema,
details: {
...table.schema.description,
name: "details",
},
}
// casting to any here because TS can't infer the description property properly.
delete (renamedSchema as any).description
const renamedTable = await config.api.table.save({
...table,
schema: renamedSchema,
_rename: { old: "description", updated: "details" },
})
const devRows = await config.api.row.search(renamedTable._id!, {
query: {},
})
expect(devRows.rows[0].details).toBe("original value")
await config.api.workspace.publish(config.devWorkspace!.appId)
await config.withProdApp(async () => {
const prodRows = await config.api.row.search(renamedTable._id!, {
query: {},
})
expect(prodRows.rows[0].details).toBe("original value")
expect(prodRows.rows[0].description).toBeUndefined()
})
})
it("applies pending renames even when the replicated schema is stale", async () => {
const table = await config.api.table.save(basicTable())
await config.api.row.save(table._id!, {
name: "Test Row",
description: "original value",
})
await config.api.workspace.publish(config.devWorkspace!.appId)
const rename = { old: "description", updated: "details" }
const renamedSchema = {
...table.schema,
details: {
...table.schema.description,
name: "details",
},
}
delete (renamedSchema as any).description
const renamedTable = await config.api.table.save({
...table,
schema: renamedSchema,
_rename: rename,
})
// Simulating that we got a stale schema that still uses the old column.
await config.doInContext(config.getDevWorkspaceId(), async () => {
const db = context.getWorkspaceDB()
const tableDoc = await db.tryGet<Table>(renamedTable._id!)
if (tableDoc) {
tableDoc.schema = {
...tableDoc.schema,
description: { ...table.schema.description, name: "description" },
}
delete tableDoc.schema.details
await db.put(tableDoc)
}
const rows = (
await db.allDocs<Row>(
getRowParams(renamedTable._id!, null, { include_docs: true })
)
).rows.map(row => row.doc!)
await db.bulkDocs(
rows.map(row => {
const updated: Row = {
...row,
description: row.details || row.description,
}
delete updated.details
return updated
})
)
})
await config.api.workspace.publish(config.devWorkspace!.appId)
await config.withProdApp(async () => {
const prodRows = await config.api.row.search(renamedTable._id!, {
query: {},
})
expect(prodRows.rows[0].details).toBe("original value")
expect(prodRows.rows[0].description).toBeUndefined()
const prodTable = await config.api.table.get(renamedTable._id!)
expect(prodTable.pendingColumnRenames || []).toHaveLength(0)
expect(prodTable.schema.details).toBeDefined()
})
})
})