QA Core repo, and Jest API tests running end to end

This commit is contained in:
Martin McKeaveney 2022-09-05 18:28:53 +01:00
parent 9756d9bd17
commit c3f15b5af2
35 changed files with 3518 additions and 12 deletions

View File

@ -54,8 +54,10 @@ jobs:
verbose: true
# TODO: parallelise this
- name: Cypress run
uses: cypress-io/github-action@v2
with:
install: false
command: yarn test:e2e:ci
# - name: Cypress run
# uses: cypress-io/github-action@v2
# with:
# install: false
# command: yarn test:e2e:ci
- run: yarn test:api:ci

View File

@ -8,4 +8,4 @@ packages/server/client
packages/server/src/definitions/openapi.ts
packages/builder/.routify
packages/builder/cypress/support/queryLevelTransformerFunction.js
packages/builder/cypress/support/queryLevelTransformerFunctionWithData.js
packages/builder/cypress/support/queryLevelTransformerFunctionWithData.js

View File

@ -52,6 +52,8 @@
"test:e2e:ci": "lerna run cy:ci --stream",
"test:e2e:ci:record": "lerna run cy:ci:record --stream",
"test:e2e:ci:notify": "lerna run cy:ci:notify",
"test:api:ci": "npm --prefix ./qa-core run api:test:ci",
"test:api": "npm --prefix ./qa-core run api:test",
"build:specs": "lerna run specs",
"build:docker": "lerna run build:docker && npm run build:docker:proxy:compose && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh $BUDIBASE_RELEASE_VERSION && cd -",
"build:docker:pre": "lerna run build && lerna run predocker",

View File

@ -22,6 +22,12 @@ process.env.COUCH_DB_PASSWORD = "budibase"
process.env.INTERNAL_API_KEY = "budibase"
process.env.ALLOW_DEV_AUTOMATIONS = 1
// TODO: inject at the qa-core level
process.env.BB_ADMIN_USER_EMAIL = "qa@budibase.com"
process.env.BB_ADMIN_USER_PASSWORD = "budibase"
process.env.ENCRYPTED_TEST_PUBLIC_API_KEY =
"a65722f06bee5caeadc5d7ca2f543a43-d610e627344210c643bb726f"
// Stop info logs polluting test outputs
process.env.LOG_LEVEL = "error"

View File

@ -23,7 +23,6 @@ describe("/users", () => {
})
describe("fetch", () => {
it("returns a list of users from an instance db", async () => {
await config.createUser("uuidx")
await config.createUser("uuidy")

View File

@ -29,7 +29,11 @@ const { Thread } = require("./threads")
import redis from "./utilities/redis"
import * as migrations from "./migrations"
import { events, installation, tenancy } from "@budibase/backend-core"
import { createAdminUser, getChecklist } from "./utilities/workerRequests"
import {
createAdminUser,
generateApiKey,
getChecklist,
} from "./utilities/workerRequests"
const app = new Koa()
@ -123,11 +127,16 @@ module.exports = server.listen(env.PORT || 0, async () => {
if (!checklist?.adminUser?.checked) {
try {
const tenantId = tenancy.getTenantId()
await createAdminUser(
const user = await createAdminUser(
env.BB_ADMIN_USER_EMAIL,
env.BB_ADMIN_USER_PASSWORD,
tenantId
)
// Need to set up an API key for automated integration tests
if (env.isTest()) {
await generateApiKey(user._id)
}
console.log(
"Admin account automatically created for",
env.BB_ADMIN_USER_EMAIL

View File

@ -153,3 +153,11 @@ exports.getChecklist = async () => {
)
return checkResponse(response, "get checklist")
}
exports.generateApiKey = async userId => {
const response = await fetch(
checkSlashesInUrl(env.WORKER_URL + "/api/global/self/api_key"),
request(null, { method: "POST", body: { userId } })
)
return checkResponse(response, "generate API key")
}

View File

@ -16,6 +16,11 @@ const { newid } = require("@budibase/backend-core/utils")
const { users } = require("../../../sdk")
const { Cookies } = require("@budibase/backend-core/constants")
const { events, featureFlags } = require("@budibase/backend-core")
const env = require("../../../environment")
function newTestApiKey() {
return env.ENCRYPTED_TEST_PUBLIC_API_KEY
}
function newApiKey() {
return encrypt(`${getTenantId()}${SEPARATOR}${newid()}`)
@ -29,15 +34,25 @@ function cleanupDevInfo(info) {
}
exports.generateAPIKey = async ctx => {
let userId
let apiKey
if (env.isTest() && ctx.request.body.userId) {
userId = ctx.request.body.userId
apiKey = newTestApiKey()
} else {
userId = ctx.user._id
apiKey = newApiKey()
}
const db = getGlobalDB()
const id = generateDevInfoID(ctx.user._id)
const id = generateDevInfoID(userId)
let devInfo
try {
devInfo = await db.get(id)
} catch (err) {
devInfo = { _id: id, userId: ctx.user._id }
devInfo = { _id: id, userId }
}
devInfo.apiKey = await newApiKey()
devInfo.apiKey = await apiKey
await db.put(devInfo)
ctx.body = cleanupDevInfo(devInfo)
}

View File

@ -62,6 +62,7 @@ const env = {
// other
CHECKLIST_CACHE_TTL: parseIntSafe(process.env.CHECKLIST_CACHE_TTL) || 3600,
SESSION_UPDATE_PERIOD: process.env.SESSION_UPDATE_PERIOD,
ENCRYPTED_TEST_PUBLIC_API_KEY: process.env.ENCRYPTED_TEST_PUBLIC_API_KEY,
_set(key: any, value: any) {
process.env[key] = value
module.exports[key] = value

4
qa-core/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
.env
watchtower-hook.json
dist/

52
qa-core/package.json Normal file
View File

@ -0,0 +1,52 @@
{
"name": "@budibase/qa-core",
"email": "hi@budibase.com",
"version": "0.0.1",
"main": "index.js",
"description": "Budibase Integration Test Suite",
"repository": {
"type": "git",
"url": "https://github.com/Budibase/budibase.git"
},
"scripts": {
"test": "jest --runInBand",
"test:watch": "jest --watch",
"test:debug": "DEBUG=1 jest",
"api:server:setup": "ts-node ../packages/builder/cypress/ts/setup.ts",
"api:server:setup:ci": "node ../packages/builder/cypress/setup.js",
"api:test:ci": "start-server-and-test api:server:setup:ci http://localhost:4100/builder test",
"api:test": "start-server-and-test api:server:setup http://localhost:4100/builder test"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
"moduleNameMapper": {
"@budibase/backend-core/(.*)": "<rootDir>/../packages/backend-core/$1",
"@budibase/backend-core": "<rootDir>/../packages/backend-core/src",
"@budibase/types": "<rootDir>/../packages/types/src"
},
"setupFiles": [
"./scripts/jestSetup.js"
],
"setupFilesAfterEnv": [
"./src/jest.extends.ts"
]
},
"devDependencies": {
"@budibase/types": "^1.3.4",
"@types/jest": "^29.0.0",
"@types/node-fetch": "^2.6.2",
"chance": "^1.1.8",
"jest": "^28.0.2",
"prettier": "^2.7.1",
"start-server-and-test": "^1.14.0",
"timekeeper": "^2.2.0",
"ts-jest": "28.0.8",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.0",
"typescript": "^4.8.2"
},
"dependencies": {
"node-fetch": "2"
}
}

View File

@ -0,0 +1,15 @@
const env = require("../src/environment")
env._set("BUDIBASE_SERVER_URL", "http://localhost:4100")
env._set("BUDIBASE_PUBLIC_API_KEY", "a65722f06bee5caeadc5d7ca2f543a43-d610e627344210c643bb726f")
// mock all dates to 2020-01-01T00:00:00.000Z
// use tk.reset() to use real dates in individual tests
const MOCK_DATE = new Date("2020-01-01T00:00:00.000Z")
const MOCK_DATE_TIMESTAMP = 1577836800000
const tk = require("timekeeper")
tk.freeze(MOCK_DATE)
if (!process.env.DEBUG) {
global.console.log = jest.fn() // console.log are ignored in tests
}

View File

@ -0,0 +1,8 @@
export = {
BUDIBASE_SERVER_URL: process.env.BUDIBASE_SERVER_URL,
BUDIBASE_PUBLIC_API_KEY: process.env.BUDIBASE_PUBLIC_API_KEY,
_set(key: any, value: any) {
process.env[key] = value
module.exports[key] = value
},
}

View File

@ -0,0 +1,22 @@
// boilerplate to allow TS updates to the global scope
export {};
declare global {
namespace jest {
interface Matchers<R> {
toHaveStatusCode(code: number): R;
}
}
}
// Expect extensions
expect.extend({
toHaveStatusCode(received, code) {
const pass = received.status === code
return {
message: () =>
`expected ${received.status} to match status code ${code}`,
pass,
}
},
})

View File

@ -0,0 +1,58 @@
import env from "../../environment"
import fetch from "node-fetch"
interface HeaderOptions {
headers?: object;
body?: object;
json?: boolean;
}
type APIMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
class PublicAPIClient {
host: string
apiKey: string
appId?: string
constructor(appId?: string) {
if (!env.BUDIBASE_PUBLIC_API_KEY || !env.BUDIBASE_SERVER_URL) {
throw new Error("Must set BUDIBASE_PUBLIC_API_KEY and BUDIBASE_SERVER_URL env vars")
}
this.host = `${env.BUDIBASE_SERVER_URL}/api/public/v1`
this.apiKey = env.BUDIBASE_PUBLIC_API_KEY
this.appId = appId
}
apiCall =
(method: APIMethod) =>
async (url = "", options: HeaderOptions = {}) => {
const requestOptions = {
method: method,
body: JSON.stringify(options.body),
headers: {
"x-budibase-api-key": this.apiKey,
"x-budibase-app-id": this.appId,
"Content-Type": "application/json",
Accept: "application/json",
...options.headers,
},
// TODO: See if this is necessary
credentials: "include",
}
// @ts-ignore
const response = await fetch(`${this.host}${url}`, requestOptions)
if (response.status !== 200) {
console.error(response)
}
return response
}
post = this.apiCall("POST")
get = this.apiCall("GET")
patch = this.apiCall("PATCH")
del = this.apiCall("DELETE")
put = this.apiCall("PUT")
}
export default PublicAPIClient

View File

@ -0,0 +1,39 @@
import PublicAPIClient from "./PublicAPIClient";
import generateApp from "./applications/fixtures/generate"
class TestConfiguration {
testContext: Record<string, any>;
apiClient: PublicAPIClient;
constructor() {
this.testContext = {}
this.apiClient = new PublicAPIClient()
}
async beforeAll() {
}
async afterAll() {
}
async seedTable(appId: string) {
const response = await this.apiClient.post("/tables", {
body: require("./tables/fixtures/seed.json"),
headers: {
"x-budibase-app-id": appId
}
})
const json = await response.json()
return json.data
}
async seedApp() {
const response = await this.apiClient.post("/applications", {
body: generateApp()
})
return response.json()
}
}
export default TestConfiguration

View File

@ -0,0 +1,47 @@
import TestConfiguration from "../TestConfiguration"
import PublicAPIClient from "../PublicAPIClient"
import generateApp from "./fixtures/generate"
describe("Public API - /applications endpoints", () => {
const api = new PublicAPIClient()
const config = new TestConfiguration()
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
it("POST - Create a application", async () => {
const response = await api.post(`/applications`, {
body: generateApp()
})
const json = await response.json()
config.testContext.application = json.data
expect(response).toHaveStatusCode(200)
})
it("POST - Search applications", async () => {
const response = await api.post(`/applications/search`, {
body: {
name: config.testContext.application.name
}
})
expect(response).toHaveStatusCode(200)
})
it("GET - Retrieve a application", async () => {
const response = await api.get(`/applications/${config.testContext.application._id}`)
expect(response).toHaveStatusCode(200)
})
it("PUT - update a application", async () => {
const response = await api.put(`/applications/${config.testContext.application._id}`, {
body: require("./fixtures/update_application.json")
})
expect(response).toHaveStatusCode(200)
})
})

View File

@ -0,0 +1,4 @@
{
"name": "TestApp",
"url": "/testapp"
}

View File

@ -0,0 +1,9 @@
import generator from "../../generator"
const generate = (overrides = {}) => ({
name: generator.word(),
url: `/${generator.word()}`,
...overrides
})
export default generate

View File

@ -0,0 +1,4 @@
{
"name": "SeedApp",
"url": "/seedapp"
}

View File

@ -0,0 +1,4 @@
{
"name": "UpdatedTestApp",
"url": "/updatedtestapp"
}

View File

@ -0,0 +1,3 @@
const Chance = require("chance")
export default new Chance()

View File

@ -0,0 +1,8 @@
{
"type": "row",
"tableId": "seed_table",
"sasa": "Mike",
"relationship": [
"ro_ta_"
]
}

View File

@ -0,0 +1,94 @@
{
"name": "test",
"primaryDisplay": "sasa",
"schema": {
"Auto ID": {
"autocolumn": true,
"constraints": {
"numericality": {
"greaterThanOrEqualTo": "",
"lessThanOrEqualTo": ""
},
"presence": false,
"type": "number"
},
"icon": "ri-magic-line",
"name": "Auto ID",
"subtype": "autoID",
"type": "number"
},
"Created At": {
"autocolumn": true,
"constraints": {
"datetime": {
"earliest": "",
"latest": ""
},
"length": {},
"presence": false,
"type": "string"
},
"icon": "ri-magic-line",
"name": "Created At",
"subtype": "createdAt",
"type": "datetime"
},
"Created By": {
"autocolumn": true,
"constraints": {
"presence": false,
"type": "array"
},
"fieldName": "test12-Created By",
"icon": "ri-magic-line",
"name": "Created By",
"relationshipType": "many-to-many",
"subtype": "createdBy",
"tableId": "ta_users",
"type": "link"
},
"sasa": {
"constraints": {
"length": {
"maximum": null
},
"presence": {
"allowEmpty": false
},
"type": "string"
},
"name": "sasa",
"type": "string"
},
"Updated At": {
"autocolumn": true,
"constraints": {
"datetime": {
"earliest": "",
"latest": ""
},
"length": {},
"presence": false,
"type": "string"
},
"icon": "ri-magic-line",
"name": "Updated At",
"subtype": "updatedAt",
"type": "datetime"
},
"Updated By": {
"autocolumn": true,
"constraints": {
"presence": false,
"type": "array"
},
"fieldName": "test12-Updated By",
"icon": "ri-magic-line",
"name": "Updated By",
"relationshipType": "many-to-many",
"subtype": "updatedBy",
"tableId": "ta_users",
"type": "link"
}
}
}

View File

@ -0,0 +1,94 @@
{
"name": "test",
"primaryDisplay": "sasa",
"schema": {
"Auto ID": {
"autocolumn": true,
"constraints": {
"numericality": {
"greaterThanOrEqualTo": "",
"lessThanOrEqualTo": ""
},
"presence": false,
"type": "number"
},
"icon": "ri-magic-line",
"name": "Auto ID",
"subtype": "autoID",
"type": "number"
},
"Created At": {
"autocolumn": true,
"constraints": {
"datetime": {
"earliest": "",
"latest": ""
},
"length": {},
"presence": false,
"type": "string"
},
"icon": "ri-magic-line",
"name": "Created At",
"subtype": "createdAt",
"type": "datetime"
},
"Created By": {
"autocolumn": true,
"constraints": {
"presence": false,
"type": "array"
},
"fieldName": "test12-Created By",
"icon": "ri-magic-line",
"name": "Created By",
"relationshipType": "many-to-many",
"subtype": "createdBy",
"tableId": "ta_users",
"type": "link"
},
"sasa": {
"constraints": {
"length": {
"maximum": null
},
"presence": {
"allowEmpty": false
},
"type": "string"
},
"name": "sasa",
"type": "string"
},
"Updated At": {
"autocolumn": true,
"constraints": {
"datetime": {
"earliest": "",
"latest": ""
},
"length": {},
"presence": false,
"type": "string"
},
"icon": "ri-magic-line",
"name": "Updated At",
"subtype": "updatedAt",
"type": "datetime"
},
"Updated By": {
"autocolumn": true,
"constraints": {
"presence": false,
"type": "array"
},
"fieldName": "test12-Updated By",
"icon": "ri-magic-line",
"name": "Updated By",
"relationshipType": "many-to-many",
"subtype": "updatedBy",
"tableId": "ta_users",
"type": "link"
}
}
}

View File

@ -0,0 +1,8 @@
{
"type": "row",
"tableId": "seed_table",
"sasa": "MikeIsTheBest",
"relationship": [
"ro_ta_..."
]
}

View File

@ -0,0 +1,94 @@
{
"name": "test123",
"primaryDisplay": "sasa",
"schema": {
"Auto ID": {
"autocolumn": true,
"constraints": {
"numericality": {
"greaterThanOrEqualTo": "",
"lessThanOrEqualTo": ""
},
"presence": false,
"type": "number"
},
"icon": "ri-magic-line",
"name": "Auto ID",
"subtype": "autoID",
"type": "number"
},
"Created At": {
"autocolumn": true,
"constraints": {
"datetime": {
"earliest": "",
"latest": ""
},
"length": {},
"presence": false,
"type": "string"
},
"icon": "ri-magic-line",
"name": "Created At",
"subtype": "createdAt",
"type": "datetime"
},
"Created By": {
"autocolumn": true,
"constraints": {
"presence": false,
"type": "array"
},
"fieldName": "test12-Created By",
"icon": "ri-magic-line",
"name": "Created By",
"relationshipType": "many-to-many",
"subtype": "createdBy",
"tableId": "ta_users",
"type": "link"
},
"sasa": {
"constraints": {
"length": {
"maximum": null
},
"presence": {
"allowEmpty": false
},
"type": "string"
},
"name": "sasa",
"type": "string"
},
"Updated At": {
"autocolumn": true,
"constraints": {
"datetime": {
"earliest": "",
"latest": ""
},
"length": {},
"presence": false,
"type": "string"
},
"icon": "ri-magic-line",
"name": "Updated At",
"subtype": "updatedAt",
"type": "datetime"
},
"Updated By": {
"autocolumn": true,
"constraints": {
"presence": false,
"type": "array"
},
"fieldName": "test12-Updated By",
"icon": "ri-magic-line",
"name": "Updated By",
"relationshipType": "many-to-many",
"subtype": "updatedBy",
"tableId": "ta_users",
"type": "link"
}
}
}

View File

@ -0,0 +1,51 @@
import TestConfiguration from "../TestConfiguration"
import PublicAPIClient from "../PublicAPIClient"
describe("Public API - /rows endpoints", () => {
let api: PublicAPIClient
const config = new TestConfiguration()
beforeAll(async () => {
await config.beforeAll()
const app = await config.seedApp()
config.testContext.table = await config.seedTable(app.data._id)
api = new PublicAPIClient(app.data._id)
})
afterAll(async () => {
await config.afterAll()
})
it("POST - Create a row", async () => {
const response = await api.post(`/tables/${config.testContext.table._id}/rows`, {
body: require("./fixtures/row.json")
})
const json = await response.json()
config.testContext.row = json.data
expect(response).toHaveStatusCode(200)
})
it("POST - Search rows", async () => {
const response = await api.post(`/tables/${config.testContext.table._id}/rows/search`, {
body: {
name: config.testContext.row.name
}
})
expect(response).toHaveStatusCode(200)
})
it("GET - Retrieve a row", async () => {
const response = await api.get(`/tables/${config.testContext.table._id}/rows/${config.testContext.row._id}`)
expect(response).toHaveStatusCode(200)
})
it("PUT - update a row", async () => {
const response = await api.put(`/tables/${config.testContext.table._id}/rows/${config.testContext.row._id}`, {
body: require("./fixtures/update_row.json")
})
expect(response).toHaveStatusCode(200)
})
})

View File

@ -0,0 +1,48 @@
import TestConfiguration from "../TestConfiguration"
import PublicAPIClient from "../PublicAPIClient"
describe("Public API - /tables endpoints", () => {
let api: PublicAPIClient
const config = new TestConfiguration()
beforeAll(async () => {
await config.beforeAll()
const app = await config.seedApp()
api = new PublicAPIClient(app.data._id)
})
afterAll(async () => {
await config.afterAll()
})
it("POST - Create a table", async () => {
const response = await api.post(`/tables`, {
body: require("./fixtures/table.json")
})
const json = await response.json()
config.testContext.table = json.data
expect(response).toHaveStatusCode(200)
})
it("POST - Search tables", async () => {
const response = await api.post(`/tables/search`, {
body: {
name: config.testContext.table.name
}
})
expect(response).toHaveStatusCode(200)
})
it("GET - Retrieve a table", async () => {
const response = await api.get(`/tables/${config.testContext.table._id}`)
expect(response).toHaveStatusCode(200)
})
it("PUT - update a table", async () => {
const response = await api.put(`/tables/${config.testContext.table._id}`, {
body: require("./fixtures/update_table.json")
})
expect(response).toHaveStatusCode(200)
})
})

View File

@ -0,0 +1,22 @@
import generator from "../../generator"
import { User } from "@budibase/types"
const generate = (overrides = {}): User => ({
tenantId: generator.word(),
email: generator.email(),
roles: {
[generator.string({ length: 32, alpha: true, numeric: true })]: generator.word(),
},
password: generator.word(),
status: "active",
forceResetPassword: generator.bool(),
builder: {
global: generator.bool()
},
admin: {
global: generator.bool()
},
...overrides
})
export default generate

View File

@ -0,0 +1,18 @@
{
"email": "test@budibase.com",
"roles": {
"sed_6d7": "sit ea amet",
"cupidatat_e16": "fugiat proident sed"
},
"password": "cupidatat Lorem ad",
"status": "active",
"firstName": "QA",
"lastName": "Updated",
"forceResetPassword": true,
"builder": {
"global": true
},
"admin": {
"global": false
}
}

View File

@ -0,0 +1,18 @@
{
"email": "test@budibase.com",
"roles": {
"sed_6d7": "sit ea amet",
"cupidatat_e16": "fugiat proident sed"
},
"password": "cupidatat Lorem ad",
"status": "active",
"firstName": "QA",
"lastName": "Test",
"forceResetPassword": true,
"builder": {
"global": true
},
"admin": {
"global": false
}
}

View File

@ -0,0 +1,46 @@
import TestConfiguration from "../TestConfiguration"
import PublicAPIClient from "../PublicAPIClient"
describe("Public API - /users endpoints", () => {
const api = new PublicAPIClient()
const config = new TestConfiguration()
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
it("POST - Create a user", async () => {
const response = await api.post(`/users`, {
body: require("./fixtures/user.json")
})
const json = await response.json()
config.testContext.user = json.data
expect(response).toHaveStatusCode(200)
})
it("POST - Search users", async () => {
const response = await api.post(`/users/search`, {
body: {
name: config.testContext.user.email
}
})
expect(response).toHaveStatusCode(200)
})
it("GET - Retrieve a user", async () => {
const response = await api.get(`/users/${config.testContext.user._id}`)
expect(response).toHaveStatusCode(200)
})
it("PUT - update a user", async () => {
const response = await api.put(`/users/${config.testContext.user._id}`, {
body: require("./fixtures/update_user.json")
})
expect(response).toHaveStatusCode(200)
})
})

36
qa-core/tsconfig.json Normal file
View File

@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"lib": ["es2020"],
"allowJs": true,
"strict": true,
"noImplicitAny": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"incremental": true,
"types": ["node", "jest"],
"outDir": "dist",
"skipLibCheck": true,
"paths": {
"@budibase/types": ["../packages/types/src"],
"@budibase/backend-core": ["../packages/backend-core/src"],
"@budibase/backend-core/*": ["../packages/backend-core/*"]
}
},
"ts-node": {
"require": ["tsconfig-paths/register"]
},
"references": [
{ "path": "../packages/types" },
{ "path": "../packages/backend-core" },
],
"include": [
"src/**/*",
"package.json"
],
"exclude": [
"node_modules",
"dist"
]
}

2658
qa-core/yarn.lock Normal file

File diff suppressed because it is too large Load Diff