import { databaseTestProviders } from "../../../integrations/tests/utils"
import tk from "timekeeper"
import { outputProcessing } from "../../../utilities/rowProcessor"
import * as setup from "./utilities"
import { context, roles, tenancy } from "@budibase/backend-core"
import { quotas } from "@budibase/pro"
import {
FieldType,
MonthlyQuotaName,
PermissionLevel,
QuotaUsageType,
Row,
SaveTableRequest,
SortOrder,
SortType,
StaticQuotaName,
Table,
} from "@budibase/types"
import {
expectAnyExternalColsAttributes,
expectAnyInternalColsAttributes,
generator,
mocks,
structures,
} from "@budibase/backend-core/tests"
const timestamp = new Date("2023-01-26T11:48:57.597Z").toISOString()
tk.freeze(timestamp)
const { basicRow } = setup.structures
describe.each([
["internal", undefined],
["postgres", databaseTestProviders.postgres],
])("/rows (%s)", (_, dsProvider) => {
const isInternal = !dsProvider
let request = setup.getRequest()
let config = setup.getConfig()
let table: Table
afterAll(setup.afterAll)
beforeAll(async () => {
await config.init()
if (dsProvider) {
await config.createDatasource({
datasource: await dsProvider.getDsConfig(),
})
}
})
const generateTableConfig: () => SaveTableRequest = () => {
return {
name: generator.word(),
type: "table",
primary: ["id"],
primaryDisplay: "name",
schema: {
id: {
type: FieldType.AUTO,
name: "id",
autocolumn: true,
constraints: {
presence: true,
},
},
name: {
type: FieldType.STRING,
name: "name",
constraints: {
type: "string",
},
},
description: {
type: FieldType.STRING,
name: "description",
constraints: {
type: "string",
},
},
},
}
}
beforeEach(async () => {
mocks.licenses.useCloudFree()
const tableConfig = generateTableConfig()
table = await config.createTable(tableConfig)
})
const loadRow = async (id: string, tbl_Id: string, status = 200) =>
await request
.get(`/api/${tbl_Id}/rows/${id}`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(status)
const getRowUsage = async () => {
const { total } = await config.doInContext(null, () =>
quotas.getCurrentUsageValues(QuotaUsageType.STATIC, StaticQuotaName.ROWS)
)
return total
}
const getQueryUsage = async () => {
const { total } = await config.doInContext(null, () =>
quotas.getCurrentUsageValues(
QuotaUsageType.MONTHLY,
MonthlyQuotaName.QUERIES
)
)
return total
}
const assertRowUsage = async (expected: number) => {
const usage = await getRowUsage()
expect(usage).toBe(expected)
}
const assertQueryUsage = async (expected: number) => {
const usage = await getQueryUsage()
expect(usage).toBe(expected)
}
const defaultRowFields = isInternal
? {
type: "row",
createdAt: timestamp,
updatedAt: timestamp,
}
: undefined
describe("save, load, update", () => {
it("returns a success message when the row is created", async () => {
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await request
.post(`/api/${config.table!._id}/rows`)
.send(basicRow(config.table!._id!))
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect((res as any).res.statusMessage).toEqual(
`${table.name} saved successfully`
)
expect(res.body.name).toEqual("Test Contact")
expect(res.body._rev).toBeDefined()
await assertRowUsage(rowUsage + 1)
await assertQueryUsage(queryUsage + 1)
})
it("Increment row autoId per create row request", async () => {
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const tableConfig = generateTableConfig()
const newTable = await config.createTable({
...tableConfig,
name: "TestTableAuto",
schema: {
...tableConfig.schema,
"Row ID": {
name: "Row ID",
type: FieldType.NUMBER,
subtype: "autoID",
icon: "ri-magic-line",
autocolumn: true,
constraints: {
type: "number",
presence: true,
numericality: {
greaterThanOrEqualTo: "",
lessThanOrEqualTo: "",
},
},
},
},
})
const ids = [1, 2, 3]
// Performing several create row requests should increment the autoID fields accordingly
const createRow = async (id: number) => {
const res = await request
.post(`/api/${newTable._id}/rows`)
.send({
name: "row_" + id,
})
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect((res as any).res.statusMessage).toEqual(
`${newTable.name} saved successfully`
)
expect(res.body.name).toEqual("row_" + id)
expect(res.body._rev).toBeDefined()
expect(res.body["Row ID"]).toEqual(id)
}
for (let i = 0; i < ids.length; i++) {
await createRow(ids[i])
}
await assertRowUsage(rowUsage + ids.length)
await assertQueryUsage(queryUsage + ids.length)
})
it("updates a row successfully", async () => {
const existing = await config.createRow()
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await request
.post(`/api/${table._id}/rows`)
.send({
_id: existing._id,
_rev: existing._rev,
tableId: table._id,
name: "Updated Name",
})
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect((res as any).res.statusMessage).toEqual(
`${table.name} updated successfully.`
)
expect(res.body.name).toEqual("Updated Name")
await assertRowUsage(rowUsage)
await assertQueryUsage(queryUsage + 1)
})
it("should load a row", async () => {
const existing = await config.createRow()
const queryUsage = await getQueryUsage()
const res = await request
.get(`/api/${table._id}/rows/${existing._id}`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body).toEqual({
...existing,
...defaultRowFields,
})
await assertQueryUsage(queryUsage + 1)
})
it("should list all rows for given tableId", async () => {
const newRow = {
tableId: table._id,
name: "Second Contact",
status: "new",
}
const firstRow = await config.createRow()
await config.createRow(newRow)
const queryUsage = await getQueryUsage()
const res = await request
.get(`/api/${table._id}/rows`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.length).toBe(2)
expect(res.body.find((r: Row) => r.name === newRow.name)).toBeDefined()
expect(res.body.find((r: Row) => r.name === firstRow.name)).toBeDefined()
await assertQueryUsage(queryUsage + 1)
})
it("load should return 404 when row does not exist", async () => {
await config.createRow()
const queryUsage = await getQueryUsage()
await config.api.row.get(table._id!, "1234567", {
expectStatus: 404,
})
await assertQueryUsage(queryUsage) // no change
})
isInternal &&
it("row values are coerced", async () => {
const str = {
type: FieldType.STRING,
name: "str",
constraints: { type: "string", presence: false },
}
const attachment = {
type: FieldType.ATTACHMENT,
name: "attachment",
constraints: { type: "array", presence: false },
}
const bool = {
type: FieldType.BOOLEAN,
name: "boolean",
constraints: { type: "boolean", presence: false },
}
const number = {
type: FieldType.NUMBER,
name: "str",
constraints: { type: "number", presence: false },
}
const datetime = {
type: FieldType.DATETIME,
name: "datetime",
constraints: {
type: "string",
presence: false,
datetime: { earliest: "", latest: "" },
},
}
const arrayField = {
type: FieldType.ARRAY,
constraints: {
type: "array",
presence: false,
inclusion: ["One", "Two", "Three"],
},
name: "Sample Tags",
sortable: false,
}
const optsField = {
fieldName: "Sample Opts",
name: "Sample Opts",
type: FieldType.OPTIONS,
constraints: {
type: "string",
presence: false,
inclusion: ["Alpha", "Beta", "Gamma"],
},
},
table = await config.createTable({
name: "TestTable2",
type: "table",
schema: {
name: str,
stringUndefined: str,
stringNull: str,
stringString: str,
numberEmptyString: number,
numberNull: number,
numberUndefined: number,
numberString: number,
numberNumber: number,
datetimeEmptyString: datetime,
datetimeNull: datetime,
datetimeUndefined: datetime,
datetimeString: datetime,
datetimeDate: datetime,
boolNull: bool,
boolEmpty: bool,
boolUndefined: bool,
boolString: bool,
boolBool: bool,
attachmentNull: attachment,
attachmentUndefined: attachment,
attachmentEmpty: attachment,
attachmentEmptyArrayStr: attachment,
arrayFieldEmptyArrayStr: arrayField,
arrayFieldArrayStrKnown: arrayField,
arrayFieldNull: arrayField,
arrayFieldUndefined: arrayField,
optsFieldEmptyStr: optsField,
optsFieldUndefined: optsField,
optsFieldNull: optsField,
optsFieldStrKnown: optsField,
},
})
const row = {
name: "Test Row",
stringUndefined: undefined,
stringNull: null,
stringString: "i am a string",
numberEmptyString: "",
numberNull: null,
numberUndefined: undefined,
numberString: "123",
numberNumber: 123,
datetimeEmptyString: "",
datetimeNull: null,
datetimeUndefined: undefined,
datetimeString: "1984-04-20T00:00:00.000Z",
datetimeDate: new Date("1984-04-20"),
boolNull: null,
boolEmpty: "",
boolUndefined: undefined,
boolString: "true",
boolBool: true,
tableId: table._id,
attachmentNull: null,
attachmentUndefined: undefined,
attachmentEmpty: "",
attachmentEmptyArrayStr: "[]",
arrayFieldEmptyArrayStr: "[]",
arrayFieldUndefined: undefined,
arrayFieldNull: null,
arrayFieldArrayStrKnown: "['One']",
optsFieldEmptyStr: "",
optsFieldUndefined: undefined,
optsFieldNull: null,
optsFieldStrKnown: "Alpha",
}
const createdRow = await config.createRow(row)
const id = createdRow._id!
const saved = (await loadRow(id, table._id!)).body
expect(saved.stringUndefined).toBe(undefined)
expect(saved.stringNull).toBe("")
expect(saved.stringString).toBe("i am a string")
expect(saved.numberEmptyString).toBe(null)
expect(saved.numberNull).toBe(null)
expect(saved.numberUndefined).toBe(undefined)
expect(saved.numberString).toBe(123)
expect(saved.numberNumber).toBe(123)
expect(saved.datetimeEmptyString).toBe(null)
expect(saved.datetimeNull).toBe(null)
expect(saved.datetimeUndefined).toBe(undefined)
expect(saved.datetimeString).toBe(
new Date(row.datetimeString).toISOString()
)
expect(saved.datetimeDate).toBe(row.datetimeDate.toISOString())
expect(saved.boolNull).toBe(null)
expect(saved.boolEmpty).toBe(null)
expect(saved.boolUndefined).toBe(undefined)
expect(saved.boolString).toBe(true)
expect(saved.boolBool).toBe(true)
expect(saved.attachmentNull).toEqual([])
expect(saved.attachmentUndefined).toBe(undefined)
expect(saved.attachmentEmpty).toEqual([])
expect(saved.attachmentEmptyArrayStr).toEqual([])
expect(saved.arrayFieldEmptyArrayStr).toEqual([])
expect(saved.arrayFieldNull).toEqual([])
expect(saved.arrayFieldUndefined).toEqual(undefined)
expect(saved.optsFieldEmptyStr).toEqual(null)
expect(saved.optsFieldUndefined).toEqual(undefined)
expect(saved.optsFieldNull).toEqual(null)
expect(saved.arrayFieldArrayStrKnown).toEqual(["One"])
expect(saved.optsFieldStrKnown).toEqual("Alpha")
})
})
describe("view save", () => {
async function orderTable(): Promise
{
return {
name: "orders",
primary: ["OrderID"],
schema: {
Country: {
type: FieldType.STRING,
name: "Country",
},
OrderID: {
type: FieldType.NUMBER,
name: "OrderID",
},
Story: {
type: FieldType.STRING,
name: "Story",
},
},
}
}
it("views have extra data trimmed", async () => {
const table = await config.createTable(await orderTable())
const createViewResponse = await config.createView({
name: generator.word(),
schema: {
Country: {
visible: true,
},
OrderID: {
visible: true,
},
},
})
const createRowResponse = await config.api.row.save(
createViewResponse.id,
{
OrderID: "1111",
Country: "Aussy",
Story: "aaaaa",
}
)
const row = await config.api.row.get(table._id!, createRowResponse._id!)
expect(row.body.Story).toBeUndefined()
expect(row.body).toEqual({
...defaultRowFields,
OrderID: 1111,
Country: "Aussy",
_id: createRowResponse._id,
_rev: createRowResponse._rev,
tableId: table._id,
})
})
})
describe("patch", () => {
it("should update only the fields that are supplied", async () => {
const existing = await config.createRow()
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await config.api.row.patch(table._id!, {
_id: existing._id!,
_rev: existing._rev!,
tableId: table._id!,
name: "Updated Name",
})
expect((res as any).res.statusMessage).toEqual(
`${table.name} updated successfully.`
)
expect(res.body.name).toEqual("Updated Name")
expect(res.body.description).toEqual(existing.description)
const savedRow = await loadRow(res.body._id, table._id!)
expect(savedRow.body.description).toEqual(existing.description)
expect(savedRow.body.name).toEqual("Updated Name")
await assertRowUsage(rowUsage)
await assertQueryUsage(queryUsage + 2) // account for the second load
})
it("should throw an error when given improper types", async () => {
const existing = await config.createRow()
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
await config.api.row.patch(
table._id!,
{
_id: existing._id!,
_rev: existing._rev!,
tableId: table._id!,
name: 1,
},
{ expectStatus: 400 }
)
await assertRowUsage(rowUsage)
await assertQueryUsage(queryUsage)
})
})
describe("destroy", () => {
it("should be able to delete a row", async () => {
const createdRow = await config.createRow()
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await request
.delete(`/api/${table._id}/rows`)
.send({
rows: [createdRow],
})
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body[0]._id).toEqual(createdRow._id)
await assertRowUsage(rowUsage - 1)
await assertQueryUsage(queryUsage + 1)
})
})
describe("validate", () => {
it("should return no errors on valid row", async () => {
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await request
.post(`/api/${table._id}/rows/validate`)
.send({ name: "ivan" })
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.valid).toBe(true)
expect(Object.keys(res.body.errors)).toEqual([])
await assertRowUsage(rowUsage)
await assertQueryUsage(queryUsage)
})
it("should errors on invalid row", async () => {
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await request
.post(`/api/${table._id}/rows/validate`)
.send({ name: 1 })
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
if (isInternal) {
expect(res.body.valid).toBe(false)
expect(Object.keys(res.body.errors)).toEqual(["name"])
} else {
// Validation for external is not implemented, so it will always return valid
expect(res.body.valid).toBe(true)
expect(Object.keys(res.body.errors)).toEqual([])
}
await assertRowUsage(rowUsage)
await assertQueryUsage(queryUsage)
})
})
describe("bulkDelete", () => {
it("should be able to delete a bulk set of rows", async () => {
const row1 = await config.createRow()
const row2 = await config.createRow()
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await request
.delete(`/api/${table._id}/rows`)
.send({
rows: [row1, row2],
})
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.length).toEqual(2)
await loadRow(row1._id!, table._id!, 404)
await assertRowUsage(rowUsage - 2)
await assertQueryUsage(queryUsage + 1)
})
it("should be able to delete a variety of row set types", async () => {
const row1 = await config.createRow()
const row2 = await config.createRow()
const row3 = await config.createRow()
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await request
.delete(`/api/${table._id}/rows`)
.send({
rows: [row1, row2._id, { _id: row3._id }],
})
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.length).toEqual(3)
await loadRow(row1._id!, table._id!, 404)
await assertRowUsage(rowUsage - 3)
await assertQueryUsage(queryUsage + 1)
})
it("should accept a valid row object and delete the row", async () => {
const row1 = await config.createRow()
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await request
.delete(`/api/${table._id}/rows`)
.send(row1)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.id).toEqual(row1._id)
await loadRow(row1._id!, table._id!, 404)
await assertRowUsage(rowUsage - 1)
await assertQueryUsage(queryUsage + 1)
})
it("Should ignore malformed/invalid delete requests", async () => {
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await request
.delete(`/api/${table._id}/rows`)
.send({ not: "valid" })
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(400)
expect(res.body.message).toEqual("Invalid delete rows request")
const res2 = await request
.delete(`/api/${table._id}/rows`)
.send({ rows: 123 })
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(400)
expect(res2.body.message).toEqual("Invalid delete rows request")
const res3 = await request
.delete(`/api/${table._id}/rows`)
.send("invalid")
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(400)
expect(res3.body.message).toEqual("Invalid delete rows request")
await assertRowUsage(rowUsage)
await assertQueryUsage(queryUsage)
})
})
// Legacy views are not available for external
isInternal &&
describe("fetchView", () => {
it("should be able to fetch tables contents via 'view'", async () => {
const row = await config.createRow()
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await request
.get(`/api/views/${table._id}`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.length).toEqual(1)
expect(res.body[0]._id).toEqual(row._id)
await assertRowUsage(rowUsage)
await assertQueryUsage(queryUsage + 1)
})
it("should throw an error if view doesn't exist", async () => {
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
await request
.get(`/api/views/derp`)
.set(config.defaultHeaders())
.expect(404)
await assertRowUsage(rowUsage)
await assertQueryUsage(queryUsage)
})
it("should be able to run on a view", async () => {
const view = await config.createLegacyView({
tableId: table._id!,
name: "ViewTest",
filters: [],
schema: {},
})
const row = await config.createRow()
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await request
.get(`/api/views/${view.name}`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.length).toEqual(1)
expect(res.body[0]._id).toEqual(row._id)
await assertRowUsage(rowUsage)
await assertQueryUsage(queryUsage + 1)
})
})
describe("fetchEnrichedRows", () => {
it("should allow enriching some linked rows", async () => {
const { linkedTable, firstRow, secondRow } = await tenancy.doInTenant(
config.getTenantId(),
async () => {
const linkedTable = await config.createLinkedTable({
name: generator.word(),
type: "table",
primary: ["id"],
primaryDisplay: "id",
schema: {
id: {
type: FieldType.AUTO,
name: "id",
autocolumn: true,
constraints: {
presence: true,
},
},
},
})
const firstRow = await config.createRow({
name: "Test Contact",
description: "original description",
tableId: table._id,
})
const secondRow = await config.createRow({
name: "Test 2",
description: "og desc",
link: [{ _id: firstRow._id }],
tableId: linkedTable._id,
})
return { linkedTable, firstRow, secondRow }
}
)
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
// test basic enrichment
const resBasic = await request
.get(`/api/${linkedTable._id}/rows/${secondRow._id}`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(resBasic.body.link.length).toBe(1)
expect(resBasic.body.link[0]).toEqual({
_id: firstRow._id,
primaryDisplay: firstRow.name,
})
// test full enrichment
const resEnriched = await request
.get(`/api/${linkedTable._id}/${secondRow._id}/enrich`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(resEnriched.body.link.length).toBe(1)
expect(resEnriched.body.link[0]._id).toBe(firstRow._id)
expect(resEnriched.body.link[0].name).toBe("Test Contact")
expect(resEnriched.body.link[0].description).toBe("original description")
await assertRowUsage(rowUsage)
await assertQueryUsage(queryUsage + 2)
})
})
isInternal &&
describe("attachments", () => {
it("should allow enriching attachment rows", async () => {
const table = await config.createAttachmentTable()
const attachmentId = `${structures.uuid()}.csv`
const row = await config.createRow({
name: "test",
description: "test",
attachment: [
{
key: `${config.getAppId()}/attachments/${attachmentId}`,
},
],
tableId: table._id,
})
// the environment needs configured for this
await setup.switchToSelfHosted(async () => {
return context.doInAppContext(config.getAppId(), async () => {
const enriched = await outputProcessing(table, [row])
expect((enriched as Row[])[0].attachment[0].url).toBe(
`/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}`
)
})
})
})
})
describe("exportData", () => {
it("should allow exporting all columns", async () => {
const existing = await config.createRow()
const res = await request
.post(`/api/${table._id}/rows/exportRows?format=json`)
.set(config.defaultHeaders())
.send({
rows: [existing._id],
})
.expect("Content-Type", /json/)
.expect(200)
const results = JSON.parse(res.text)
expect(results.length).toEqual(1)
const row = results[0]
// Ensure all original columns were exported
expect(Object.keys(row).length).toBeGreaterThanOrEqual(
Object.keys(existing).length
)
Object.keys(existing).forEach(key => {
expect(row[key]).toEqual(existing[key])
})
})
it("should allow exporting only certain columns", async () => {
const existing = await config.createRow()
const res = await request
.post(`/api/${table._id}/rows/exportRows?format=json`)
.set(config.defaultHeaders())
.send({
rows: [existing._id],
columns: ["_id"],
})
.expect("Content-Type", /json/)
.expect(200)
const results = JSON.parse(res.text)
expect(results.length).toEqual(1)
const row = results[0]
// Ensure only the _id column was exported
expect(Object.keys(row).length).toEqual(1)
expect(row._id).toEqual(existing._id)
})
})
describe("view 2.0", () => {
async function userTable(): Promise {
return {
name: `users_${generator.word()}`,
type: "table",
primary: ["id"],
schema: {
id: {
type: FieldType.AUTO,
name: "id",
autocolumn: true,
constraints: {
presence: true,
},
},
name: {
type: FieldType.STRING,
name: "name",
},
surname: {
type: FieldType.STRING,
name: "surname",
},
age: {
type: FieldType.NUMBER,
name: "age",
},
address: {
type: FieldType.STRING,
name: "address",
},
jobTitle: {
type: FieldType.STRING,
name: "jobTitle",
},
},
}
}
const randomRowData = () => ({
name: generator.first(),
surname: generator.last(),
age: generator.age(),
address: generator.address(),
jobTitle: generator.word(),
})
describe("create", () => {
it("should persist a new row with only the provided view fields", async () => {
const table = await config.createTable(await userTable())
const view = await config.createView({
schema: {
name: { visible: true },
surname: { visible: true },
address: { visible: true },
},
})
const data = randomRowData()
const newRow = await config.api.row.save(view.id, {
tableId: table!._id,
_viewId: view.id,
...data,
})
const row = await config.api.row.get(table._id!, newRow._id!)
expect(row.body).toEqual({
name: data.name,
surname: data.surname,
address: data.address,
tableId: table!._id,
_id: newRow._id,
_rev: newRow._rev,
id: newRow.id,
...defaultRowFields,
})
expect(row.body._viewId).toBeUndefined()
expect(row.body.age).toBeUndefined()
expect(row.body.jobTitle).toBeUndefined()
})
})
describe("patch", () => {
it("should update only the view fields for a row", async () => {
const table = await config.createTable(await userTable())
const tableId = table._id!
const view = await config.createView({
schema: {
name: { visible: true },
address: { visible: true },
},
})
const newRow = await config.api.row.save(view.id, {
tableId,
_viewId: view.id,
...randomRowData(),
})
const newData = randomRowData()
await config.api.row.patch(view.id, {
tableId,
_viewId: view.id,
_id: newRow._id!,
_rev: newRow._rev!,
...newData,
})
const row = await config.api.row.get(tableId, newRow._id!)
expect(row.body).toEqual({
...newRow,
name: newData.name,
address: newData.address,
_id: newRow._id,
_rev: expect.any(String),
id: newRow.id,
...defaultRowFields,
})
expect(row.body._viewId).toBeUndefined()
expect(row.body.age).toBeUndefined()
expect(row.body.jobTitle).toBeUndefined()
})
})
describe("destroy", () => {
it("should be able to delete a row", async () => {
const table = await config.createTable(await userTable())
const tableId = table._id!
const view = await config.createView({
schema: {
name: { visible: true },
address: { visible: true },
},
})
const createdRow = await config.createRow()
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
await config.api.row.delete(view.id, [createdRow])
await assertRowUsage(rowUsage - 1)
await assertQueryUsage(queryUsage + 1)
await config.api.row.get(tableId, createdRow._id!, {
expectStatus: 404,
})
})
it("should be able to delete multiple rows", async () => {
const table = await config.createTable(await userTable())
const tableId = table._id!
const view = await config.createView({
schema: {
name: { visible: true },
address: { visible: true },
},
})
const rows = await Promise.all([
config.createRow(),
config.createRow(),
config.createRow(),
])
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
await config.api.row.delete(view.id, [rows[0], rows[2]])
await assertRowUsage(rowUsage - 2)
await assertQueryUsage(queryUsage + 1)
await config.api.row.get(tableId, rows[0]._id!, {
expectStatus: 404,
})
await config.api.row.get(tableId, rows[2]._id!, {
expectStatus: 404,
})
await config.api.row.get(tableId, rows[1]._id!, { expectStatus: 200 })
})
})
describe("view search", () => {
const viewSchema = { age: { visible: true }, name: { visible: true } }
async function userTable(): Promise {
return {
name: `users_${generator.word()}`,
type: "table",
primary: ["id"],
schema: {
id: {
type: FieldType.AUTO,
name: "id",
autocolumn: true,
constraints: {
presence: true,
},
},
name: {
type: FieldType.STRING,
name: "name",
constraints: { type: "string" },
},
age: {
type: FieldType.NUMBER,
name: "age",
constraints: {},
},
},
}
}
it("returns empty rows from view when no schema is passed", async () => {
const table = await config.createTable(await userTable())
const rows = []
for (let i = 0; i < 10; i++) {
rows.push(
await config.api.row.save(table._id!, { tableId: table._id })
)
}
const createViewResponse = await config.createView()
const response = await config.api.viewV2.search(createViewResponse.id)
expect(response.body.rows).toHaveLength(10)
expect(response.body).toEqual({
rows: expect.arrayContaining(
rows.map(r => ({
_viewId: createViewResponse.id,
tableId: table._id,
_id: r._id,
_rev: r._rev,
...defaultRowFields,
}))
),
hasNextPage: false,
bookmark: null,
})
})
it("searching respects the view filters", async () => {
const table = await config.createTable(await userTable())
const expectedRows = []
for (let i = 0; i < 10; i++)
await config.api.row.save(table._id!, {
tableId: table._id,
name: generator.name(),
age: generator.integer({ min: 10, max: 30 }),
})
for (let i = 0; i < 5; i++)
expectedRows.push(
await config.api.row.save(table._id!, {
tableId: table._id,
name: generator.name(),
age: 40,
})
)
const createViewResponse = await config.createView({
query: [{ operator: "equal", field: "age", value: 40 }],
schema: viewSchema,
})
const response = await config.api.viewV2.search(createViewResponse.id)
expect(response.body.rows).toHaveLength(5)
expect(response.body).toEqual({
rows: expect.arrayContaining(
expectedRows.map(r => ({
_viewId: createViewResponse.id,
tableId: table._id,
name: r.name,
age: r.age,
_id: r._id,
_rev: r._rev,
...defaultRowFields,
}))
),
hasNextPage: false,
bookmark: null,
})
})
const sortTestOptions: [
{
field: string
order?: SortOrder
type?: SortType
},
string[]
][] = [
[
{
field: "name",
order: SortOrder.ASCENDING,
type: SortType.STRING,
},
["Alice", "Bob", "Charly", "Danny"],
],
[
{
field: "name",
},
["Alice", "Bob", "Charly", "Danny"],
],
[
{
field: "name",
order: SortOrder.DESCENDING,
},
["Danny", "Charly", "Bob", "Alice"],
],
[
{
field: "name",
order: SortOrder.DESCENDING,
type: SortType.STRING,
},
["Danny", "Charly", "Bob", "Alice"],
],
[
{
field: "age",
order: SortOrder.ASCENDING,
type: SortType.number,
},
["Danny", "Alice", "Charly", "Bob"],
],
[
{
field: "age",
order: SortOrder.ASCENDING,
},
["Danny", "Alice", "Charly", "Bob"],
],
[
{
field: "age",
order: SortOrder.DESCENDING,
},
["Bob", "Charly", "Alice", "Danny"],
],
[
{
field: "age",
order: SortOrder.DESCENDING,
type: SortType.number,
},
["Bob", "Charly", "Alice", "Danny"],
],
]
it.each(sortTestOptions)(
"allow sorting (%s)",
async (sortParams, expected) => {
const table = await config.createTable(await userTable())
const users = [
{ name: "Alice", age: 25 },
{ name: "Bob", age: 30 },
{ name: "Charly", age: 27 },
{ name: "Danny", age: 15 },
]
for (const user of users) {
await config.api.row.save(table._id!, {
tableId: table._id,
...user,
})
}
const createViewResponse = await config.createView({
sort: sortParams,
schema: viewSchema,
})
const response = await config.api.viewV2.search(createViewResponse.id)
expect(response.body.rows).toHaveLength(4)
expect(response.body.rows).toEqual(
expected.map(name => expect.objectContaining({ name }))
)
}
)
it.each(sortTestOptions)(
"allow override the default view sorting (%s)",
async (sortParams, expected) => {
const table = await config.createTable(await userTable())
const users = [
{ name: "Alice", age: 25 },
{ name: "Bob", age: 30 },
{ name: "Charly", age: 27 },
{ name: "Danny", age: 15 },
]
for (const user of users) {
await config.api.row.save(table._id!, {
tableId: table._id,
...user,
})
}
const createViewResponse = await config.createView({
sort: {
field: "name",
order: SortOrder.ASCENDING,
type: SortType.STRING,
},
schema: viewSchema,
})
const response = await config.api.viewV2.search(
createViewResponse.id,
{
sort: sortParams.field,
sortOrder: sortParams.order,
sortType: sortParams.type,
query: {},
}
)
expect(response.body.rows).toHaveLength(4)
expect(response.body.rows).toEqual(
expected.map(name => expect.objectContaining({ name }))
)
}
)
it("when schema is defined, defined columns and row attributes are returned", async () => {
const table = await config.createTable(await userTable())
const rows = []
for (let i = 0; i < 10; i++) {
rows.push(
await config.api.row.save(table._id!, {
tableId: table._id,
name: generator.name(),
age: generator.age(),
})
)
}
const view = await config.createView({
schema: { name: { visible: true } },
})
const response = await config.api.viewV2.search(view.id)
expect(response.body.rows).toHaveLength(10)
expect(response.body.rows).toEqual(
expect.arrayContaining(
rows.map(r => ({
...(isInternal
? expectAnyInternalColsAttributes
: expectAnyExternalColsAttributes),
_viewId: view.id,
name: r.name,
}))
)
)
})
it("views without data can be returned", async () => {
const table = await config.createTable(await userTable())
const createViewResponse = await config.createView()
const response = await config.api.viewV2.search(createViewResponse.id)
expect(response.body.rows).toHaveLength(0)
})
it("respects the limit parameter", async () => {
const table = await config.createTable(await userTable())
const rows = []
for (let i = 0; i < 10; i++) {
rows.push(await config.createRow())
}
const limit = generator.integer({ min: 1, max: 8 })
const createViewResponse = await config.createView()
const response = await config.api.viewV2.search(createViewResponse.id, {
limit,
query: {},
})
expect(response.body.rows).toHaveLength(limit)
})
it("can handle pagination", async () => {
const table = await config.createTable(await userTable())
const rows = []
for (let i = 0; i < 10; i++) {
rows.push(await config.createRow())
}
const createViewResponse = await config.createView()
const allRows = (await config.api.viewV2.search(createViewResponse.id))
.body.rows
const firstPageResponse = await config.api.viewV2.search(
createViewResponse.id,
{
paginate: true,
limit: 4,
query: {},
}
)
expect(firstPageResponse.body).toEqual({
rows: expect.arrayContaining(allRows.slice(0, 4)),
totalRows: isInternal ? 10 : undefined,
hasNextPage: true,
bookmark: expect.anything(),
})
const secondPageResponse = await config.api.viewV2.search(
createViewResponse.id,
{
paginate: true,
limit: 4,
bookmark: firstPageResponse.body.bookmark,
query: {},
}
)
expect(secondPageResponse.body).toEqual({
rows: expect.arrayContaining(allRows.slice(4, 8)),
totalRows: isInternal ? 10 : undefined,
hasNextPage: true,
bookmark: expect.anything(),
})
const lastPageResponse = await config.api.viewV2.search(
createViewResponse.id,
{
paginate: true,
limit: 4,
bookmark: secondPageResponse.body.bookmark,
query: {},
}
)
expect(lastPageResponse.body).toEqual({
rows: expect.arrayContaining(allRows.slice(8)),
totalRows: isInternal ? 10 : undefined,
hasNextPage: false,
bookmark: expect.anything(),
})
})
describe("permissions", () => {
let viewId: string
let tableId: string
beforeAll(async () => {
const table = await config.createTable(await userTable())
const rows = []
for (let i = 0; i < 10; i++) {
rows.push(await config.createRow())
}
const createViewResponse = await config.createView()
tableId = table._id!
viewId = createViewResponse.id
})
beforeEach(() => {
mocks.licenses.useViewPermissions()
})
it("does not allow public users to fetch by default", async () => {
await config.publish()
await config.api.viewV2.search(viewId, undefined, {
expectStatus: 403,
usePublicUser: true,
})
})
it("allow public users to fetch when permissions are explicit", async () => {
await config.api.permission.set({
roleId: roles.BUILTIN_ROLE_IDS.PUBLIC,
level: PermissionLevel.READ,
resourceId: viewId,
})
await config.publish()
const response = await config.api.viewV2.search(viewId, undefined, {
usePublicUser: true,
})
expect(response.body.rows).toHaveLength(10)
})
it("allow public users to fetch when permissions are inherited", async () => {
await config.api.permission.set({
roleId: roles.BUILTIN_ROLE_IDS.PUBLIC,
level: PermissionLevel.READ,
resourceId: tableId,
})
await config.publish()
const response = await config.api.viewV2.search(viewId, undefined, {
usePublicUser: true,
})
expect(response.body.rows).toHaveLength(10)
})
it("respects inherited permissions, not allowing not public views from public tables", async () => {
await config.api.permission.set({
roleId: roles.BUILTIN_ROLE_IDS.PUBLIC,
level: PermissionLevel.READ,
resourceId: tableId,
})
await config.api.permission.set({
roleId: roles.BUILTIN_ROLE_IDS.POWER,
level: PermissionLevel.READ,
resourceId: viewId,
})
await config.publish()
await config.api.viewV2.search(viewId, undefined, {
usePublicUser: true,
expectStatus: 403,
})
})
})
})
})
})