From 11804f6ddd5318c80a80ac06a11368f89ad4d284 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Mon, 7 Oct 2024 18:18:04 +0100 Subject: [PATCH] Create a feature flag helper for tests. --- .../backend-core/src/features/features.ts | 300 ++++++++++++++++++ packages/backend-core/src/features/index.ts | 283 +---------------- .../backend-core/src/features/tests/utils.ts | 64 ++++ .../src/api/routes/tests/application.spec.ts | 11 +- .../server/src/api/routes/tests/row.spec.ts | 30 +- .../src/api/routes/tests/search.spec.ts | 18 +- .../src/api/routes/tests/templates.spec.ts | 73 +++-- .../src/api/routes/tests/viewV2.spec.ts | 24 +- .../tests/20240604153647_initial_sqs.spec.ts | 5 +- .../sdk/app/rows/search/tests/search.spec.ts | 15 +- .../tests/outputProcessing.spec.ts | 8 +- .../api/routes/global/tests/auditLogs.spec.ts | 10 +- 12 files changed, 458 insertions(+), 383 deletions(-) create mode 100644 packages/backend-core/src/features/features.ts create mode 100644 packages/backend-core/src/features/tests/utils.ts diff --git a/packages/backend-core/src/features/features.ts b/packages/backend-core/src/features/features.ts new file mode 100644 index 0000000000..90a395d52a --- /dev/null +++ b/packages/backend-core/src/features/features.ts @@ -0,0 +1,300 @@ +import env from "../environment" +import * as context from "../context" +import { PostHog, PostHogOptions } from "posthog-node" +import { FeatureFlag, IdentityType, UserCtx } from "@budibase/types" +import tracer from "dd-trace" +import { Duration } from "../utils" + +let posthog: PostHog | undefined +export function init(opts?: PostHogOptions) { + if ( + env.POSTHOG_TOKEN && + env.POSTHOG_API_HOST && + !env.SELF_HOSTED && + env.POSTHOG_FEATURE_FLAGS_ENABLED + ) { + console.log("initializing posthog client...") + posthog = new PostHog(env.POSTHOG_TOKEN, { + host: env.POSTHOG_API_HOST, + personalApiKey: env.POSTHOG_PERSONAL_TOKEN, + featureFlagsPollingInterval: Duration.fromMinutes(3).toMs(), + ...opts, + }) + } else { + console.log("posthog disabled") + } +} + +export function shutdown() { + posthog?.shutdown() +} + +export abstract class Flag { + static boolean(defaultValue: boolean): Flag { + return new BooleanFlag(defaultValue) + } + + static string(defaultValue: string): Flag { + return new StringFlag(defaultValue) + } + + static number(defaultValue: number): Flag { + return new NumberFlag(defaultValue) + } + + protected constructor(public defaultValue: T) {} + + abstract parse(value: any): T +} + +type UnwrapFlag = F extends Flag ? U : never + +export type FlagValues = { + [K in keyof T]: UnwrapFlag +} + +type KeysOfType = { + [K in keyof T]: T[K] extends Flag ? K : never +}[keyof T] + +class BooleanFlag extends Flag { + parse(value: any) { + if (typeof value === "string") { + return ["true", "t", "1"].includes(value.toLowerCase()) + } + + if (typeof value === "boolean") { + return value + } + + throw new Error(`could not parse value "${value}" as boolean`) + } +} + +class StringFlag extends Flag { + parse(value: any) { + if (typeof value === "string") { + return value + } + throw new Error(`could not parse value "${value}" as string`) + } +} + +class NumberFlag extends Flag { + parse(value: any) { + if (typeof value === "number") { + return value + } + + if (typeof value === "string") { + const parsed = parseFloat(value) + if (!isNaN(parsed)) { + return parsed + } + } + + throw new Error(`could not parse value "${value}" as number`) + } +} + +export interface EnvFlagEntry { + tenantId: string + key: string + value: boolean +} + +export function parseEnvFlags(flags: string): EnvFlagEntry[] { + const split = flags.split(",").map(x => x.split(":")) + const result: EnvFlagEntry[] = [] + for (const [tenantId, ...features] of split) { + for (let feature of features) { + let value = true + if (feature.startsWith("!")) { + feature = feature.slice(1) + value = false + } + result.push({ tenantId, key: feature, value }) + } + } + return result +} + +export class FlagSet, T extends { [key: string]: V }> { + // This is used to safely cache flags sets in the current request context. + // Because multiple sets could theoretically exist, we don't want the cache of + // one to leak into another. + private readonly setId: string + + constructor(private readonly flagSchema: T) { + this.setId = crypto.randomUUID() + } + + defaults(): FlagValues { + return Object.keys(this.flagSchema).reduce((acc, key) => { + const typedKey = key as keyof T + acc[typedKey] = this.flagSchema[key].defaultValue + return acc + }, {} as FlagValues) + } + + isFlagName(name: string | number | symbol): name is keyof T { + return this.flagSchema[name as keyof T] !== undefined + } + + async get( + key: K, + ctx?: UserCtx + ): Promise[K]> { + const flags = await this.fetch(ctx) + return flags[key] + } + + async isEnabled>( + key: K, + ctx?: UserCtx + ): Promise { + const flags = await this.fetch(ctx) + return flags[key] + } + + async fetch(ctx?: UserCtx): Promise> { + return await tracer.trace("features.fetch", async span => { + const cachedFlags = context.getFeatureFlags>(this.setId) + if (cachedFlags) { + span?.addTags({ fromCache: true }) + return cachedFlags + } + + const tags: Record = {} + const flagValues = this.defaults() + const currentTenantId = context.getTenantId() + const specificallySetFalse = new Set() + + for (const { tenantId, key, value } of parseEnvFlags( + env.TENANT_FEATURE_FLAGS || "" + )) { + if (!tenantId || (tenantId !== "*" && tenantId !== currentTenantId)) { + continue + } + + tags[`readFromEnvironmentVars`] = true + + if (value === false) { + specificallySetFalse.add(key) + } + + // ignore unknown flags + if (!this.isFlagName(key)) { + continue + } + + if (typeof flagValues[key] !== "boolean") { + throw new Error(`Feature: ${key} is not a boolean`) + } + + // @ts-expect-error - TS does not like you writing into a generic type, + // but we know that it's okay in this case because it's just an object. + flagValues[key as keyof FlagValues] = value + tags[`flags.${key}.source`] = "environment" + } + + const license = ctx?.user?.license + if (license) { + tags[`readFromLicense`] = true + + for (const feature of license.features) { + if (!this.isFlagName(feature)) { + continue + } + + if ( + flagValues[feature] === true || + specificallySetFalse.has(feature) + ) { + // If the flag is already set to through environment variables, we + // don't want to override it back to false here. + continue + } + + // @ts-expect-error - TS does not like you writing into a generic type, + // but we know that it's okay in this case because it's just an object. + flagValues[feature] = true + tags[`flags.${feature}.source`] = "license" + } + } + + const identity = context.getIdentity() + tags[`identity.type`] = identity?.type + tags[`identity.tenantId`] = identity?.tenantId + tags[`identity._id`] = identity?._id + + if (posthog && identity?.type === IdentityType.USER) { + tags[`readFromPostHog`] = true + + const personProperties: Record = {} + if (identity.tenantId) { + personProperties.tenantId = identity.tenantId + } + + const posthogFlags = await posthog.getAllFlagsAndPayloads( + identity._id, + { + personProperties, + } + ) + + for (const [name, value] of Object.entries(posthogFlags.featureFlags)) { + if (!this.isFlagName(name)) { + // We don't want an unexpected PostHog flag to break the app, so we + // just log it and continue. + console.warn(`Unexpected posthog flag "${name}": ${value}`) + continue + } + + if (flagValues[name] === true || specificallySetFalse.has(name)) { + // If the flag is already set to through environment variables, we + // don't want to override it back to false here. + continue + } + + const payload = posthogFlags.featureFlagPayloads?.[name] + const flag = this.flagSchema[name] + try { + // @ts-expect-error - TS does not like you writing into a generic + // type, but we know that it's okay in this case because it's just + // an object. + flagValues[name] = flag.parse(payload || value) + tags[`flags.${name}.source`] = "posthog" + } catch (err) { + // We don't want an invalid PostHog flag to break the app, so we just + // log it and continue. + console.warn(`Error parsing posthog flag "${name}": ${value}`, err) + } + } + } + + context.setFeatureFlags(this.setId, flagValues) + for (const [key, value] of Object.entries(flagValues)) { + tags[`flags.${key}.value`] = value + } + span?.addTags(tags) + + return flagValues + }) + } +} + +// This is the primary source of truth for feature flags. If you want to add a +// new flag, add it here and use the `fetch` and `get` functions to access it. +// All of the machinery in this file is to make sure that flags have their +// default values set correctly and their types flow through the system. +export const flags = new FlagSet({ + DEFAULT_VALUES: Flag.boolean(env.isDev()), + AUTOMATION_BRANCHING: Flag.boolean(env.isDev()), + SQS: Flag.boolean(env.isDev()), + [FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(env.isDev()), + [FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(env.isDev()), +}) + +type UnwrapPromise = T extends Promise ? U : T +export type FeatureFlags = UnwrapPromise> diff --git a/packages/backend-core/src/features/index.ts b/packages/backend-core/src/features/index.ts index 2b915e5689..f77a62fd4d 100644 --- a/packages/backend-core/src/features/index.ts +++ b/packages/backend-core/src/features/index.ts @@ -1,281 +1,2 @@ -import env from "../environment" -import * as context from "../context" -import { PostHog, PostHogOptions } from "posthog-node" -import { FeatureFlag, IdentityType, UserCtx } from "@budibase/types" -import tracer from "dd-trace" -import { Duration } from "../utils" - -let posthog: PostHog | undefined -export function init(opts?: PostHogOptions) { - if ( - env.POSTHOG_TOKEN && - env.POSTHOG_API_HOST && - !env.SELF_HOSTED && - env.POSTHOG_FEATURE_FLAGS_ENABLED - ) { - console.log("initializing posthog client...") - posthog = new PostHog(env.POSTHOG_TOKEN, { - host: env.POSTHOG_API_HOST, - personalApiKey: env.POSTHOG_PERSONAL_TOKEN, - featureFlagsPollingInterval: Duration.fromMinutes(3).toMs(), - ...opts, - }) - } else { - console.log("posthog disabled") - } -} - -export function shutdown() { - posthog?.shutdown() -} - -export abstract class Flag { - static boolean(defaultValue: boolean): Flag { - return new BooleanFlag(defaultValue) - } - - static string(defaultValue: string): Flag { - return new StringFlag(defaultValue) - } - - static number(defaultValue: number): Flag { - return new NumberFlag(defaultValue) - } - - protected constructor(public defaultValue: T) {} - - abstract parse(value: any): T -} - -type UnwrapFlag = F extends Flag ? U : never - -export type FlagValues = { - [K in keyof T]: UnwrapFlag -} - -type KeysOfType = { - [K in keyof T]: T[K] extends Flag ? K : never -}[keyof T] - -class BooleanFlag extends Flag { - parse(value: any) { - if (typeof value === "string") { - return ["true", "t", "1"].includes(value.toLowerCase()) - } - - if (typeof value === "boolean") { - return value - } - - throw new Error(`could not parse value "${value}" as boolean`) - } -} - -class StringFlag extends Flag { - parse(value: any) { - if (typeof value === "string") { - return value - } - throw new Error(`could not parse value "${value}" as string`) - } -} - -class NumberFlag extends Flag { - parse(value: any) { - if (typeof value === "number") { - return value - } - - if (typeof value === "string") { - const parsed = parseFloat(value) - if (!isNaN(parsed)) { - return parsed - } - } - - throw new Error(`could not parse value "${value}" as number`) - } -} - -export class FlagSet, T extends { [key: string]: V }> { - // This is used to safely cache flags sets in the current request context. - // Because multiple sets could theoretically exist, we don't want the cache of - // one to leak into another. - private readonly setId: string - - constructor(private readonly flagSchema: T) { - this.setId = crypto.randomUUID() - } - - defaults(): FlagValues { - return Object.keys(this.flagSchema).reduce((acc, key) => { - const typedKey = key as keyof T - acc[typedKey] = this.flagSchema[key].defaultValue - return acc - }, {} as FlagValues) - } - - isFlagName(name: string | number | symbol): name is keyof T { - return this.flagSchema[name as keyof T] !== undefined - } - - async get( - key: K, - ctx?: UserCtx - ): Promise[K]> { - const flags = await this.fetch(ctx) - return flags[key] - } - - async isEnabled>( - key: K, - ctx?: UserCtx - ): Promise { - const flags = await this.fetch(ctx) - return flags[key] - } - - async fetch(ctx?: UserCtx): Promise> { - return await tracer.trace("features.fetch", async span => { - const cachedFlags = context.getFeatureFlags>(this.setId) - if (cachedFlags) { - span?.addTags({ fromCache: true }) - return cachedFlags - } - - const tags: Record = {} - const flagValues = this.defaults() - const currentTenantId = context.getTenantId() - const specificallySetFalse = new Set() - - const split = (env.TENANT_FEATURE_FLAGS || "") - .split(",") - .map(x => x.split(":")) - for (const [tenantId, ...features] of split) { - if (!tenantId || (tenantId !== "*" && tenantId !== currentTenantId)) { - continue - } - - tags[`readFromEnvironmentVars`] = true - - for (let feature of features) { - let value = true - if (feature.startsWith("!")) { - feature = feature.slice(1) - value = false - specificallySetFalse.add(feature) - } - - // ignore unknown flags - if (!this.isFlagName(feature)) { - continue - } - - if (typeof flagValues[feature] !== "boolean") { - throw new Error(`Feature: ${feature} is not a boolean`) - } - - // @ts-expect-error - TS does not like you writing into a generic type, - // but we know that it's okay in this case because it's just an object. - flagValues[feature as keyof FlagValues] = value - tags[`flags.${feature}.source`] = "environment" - } - } - - const license = ctx?.user?.license - if (license) { - tags[`readFromLicense`] = true - - for (const feature of license.features) { - if (!this.isFlagName(feature)) { - continue - } - - if ( - flagValues[feature] === true || - specificallySetFalse.has(feature) - ) { - // If the flag is already set to through environment variables, we - // don't want to override it back to false here. - continue - } - - // @ts-expect-error - TS does not like you writing into a generic type, - // but we know that it's okay in this case because it's just an object. - flagValues[feature] = true - tags[`flags.${feature}.source`] = "license" - } - } - - const identity = context.getIdentity() - tags[`identity.type`] = identity?.type - tags[`identity.tenantId`] = identity?.tenantId - tags[`identity._id`] = identity?._id - - if (posthog && identity?.type === IdentityType.USER) { - tags[`readFromPostHog`] = true - - const personProperties: Record = {} - if (identity.tenantId) { - personProperties.tenantId = identity.tenantId - } - - const posthogFlags = await posthog.getAllFlagsAndPayloads( - identity._id, - { - personProperties, - } - ) - - for (const [name, value] of Object.entries(posthogFlags.featureFlags)) { - if (!this.isFlagName(name)) { - // We don't want an unexpected PostHog flag to break the app, so we - // just log it and continue. - console.warn(`Unexpected posthog flag "${name}": ${value}`) - continue - } - - if (flagValues[name] === true || specificallySetFalse.has(name)) { - // If the flag is already set to through environment variables, we - // don't want to override it back to false here. - continue - } - - const payload = posthogFlags.featureFlagPayloads?.[name] - const flag = this.flagSchema[name] - try { - // @ts-expect-error - TS does not like you writing into a generic - // type, but we know that it's okay in this case because it's just - // an object. - flagValues[name] = flag.parse(payload || value) - tags[`flags.${name}.source`] = "posthog" - } catch (err) { - // We don't want an invalid PostHog flag to break the app, so we just - // log it and continue. - console.warn(`Error parsing posthog flag "${name}": ${value}`, err) - } - } - } - - context.setFeatureFlags(this.setId, flagValues) - for (const [key, value] of Object.entries(flagValues)) { - tags[`flags.${key}.value`] = value - } - span?.addTags(tags) - - return flagValues - }) - } -} - -// This is the primary source of truth for feature flags. If you want to add a -// new flag, add it here and use the `fetch` and `get` functions to access it. -// All of the machinery in this file is to make sure that flags have their -// default values set correctly and their types flow through the system. -export const flags = new FlagSet({ - DEFAULT_VALUES: Flag.boolean(env.isDev()), - AUTOMATION_BRANCHING: Flag.boolean(env.isDev()), - SQS: Flag.boolean(env.isDev()), - [FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(env.isDev()), - [FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(env.isDev()), -}) +export * from "./features" +export * as testutils from "./tests/utils" diff --git a/packages/backend-core/src/features/tests/utils.ts b/packages/backend-core/src/features/tests/utils.ts new file mode 100644 index 0000000000..cc633c083d --- /dev/null +++ b/packages/backend-core/src/features/tests/utils.ts @@ -0,0 +1,64 @@ +import { FeatureFlags, parseEnvFlags } from ".." +import { setEnv } from "../../environment" + +function getCurrentFlags(): Record> { + const result: Record> = {} + for (const { tenantId, key, value } of parseEnvFlags( + process.env.TENANT_FEATURE_FLAGS || "" + )) { + const tenantFlags = result[tenantId] || {} + // Don't allow overwriting specifically false flags, to match the beheaviour + // of FlagSet. + if (tenantFlags[key] === false) { + continue + } + tenantFlags[key] = value + result[tenantId] = tenantFlags + } + return result +} + +function buildFlagString( + flags: Record> +): string { + const parts: string[] = [] + for (const [tenantId, tenantFlags] of Object.entries(flags)) { + for (const [key, value] of Object.entries(tenantFlags)) { + if (value === false) { + parts.push(`${tenantId}:!${key}`) + } else { + parts.push(`${tenantId}:${key}`) + } + } + } + return parts.join(",") +} + +export function setFeatureFlags( + tenantId: string, + flags: Partial +): () => void { + const current = getCurrentFlags() + for (const [key, value] of Object.entries(flags)) { + const tenantFlags = current[tenantId] || {} + tenantFlags[key] = value + current[tenantId] = tenantFlags + } + const flagString = buildFlagString(current) + return setEnv({ TENANT_FEATURE_FLAGS: flagString }) +} + +export function withFeatureFlags( + tenantId: string, + flags: Partial, + f: () => T +) { + const cleanup = setFeatureFlags(tenantId, flags) + const result = f() + if (result instanceof Promise) { + return result.finally(cleanup) + } else { + cleanup() + return result + } +} diff --git a/packages/server/src/api/routes/tests/application.spec.ts b/packages/server/src/api/routes/tests/application.spec.ts index 40ed828dce..6e41c8b4ec 100644 --- a/packages/server/src/api/routes/tests/application.spec.ts +++ b/packages/server/src/api/routes/tests/application.spec.ts @@ -19,6 +19,7 @@ import { utils, context, withEnv as withCoreEnv, + features, } from "@budibase/backend-core" import env from "../../../environment" import { type App } from "@budibase/types" @@ -358,9 +359,13 @@ describe("/applications", () => { .delete(`/api/global/roles/${prodAppId}`) .reply(200, {}) - await withCoreEnv({ TENANT_FEATURE_FLAGS: "*:SQS" }, async () => { - await config.api.application.delete(app.appId) - }) + await features.testutils.withFeatureFlags( + "*", + { SQS: true }, + async () => { + await config.api.application.delete(app.appId) + } + ) }) }) diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 5222069460..3fc140e8e5 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -15,6 +15,7 @@ import { tenancy, withEnv as withCoreEnv, setEnv as setCoreEnv, + features, } from "@budibase/backend-core" import { quotas } from "@budibase/pro" import { @@ -98,12 +99,12 @@ describe.each([ let envCleanup: (() => void) | undefined beforeAll(async () => { - await withCoreEnv({ TENANT_FEATURE_FLAGS: "*:SQS" }, () => config.init()) - if (isLucene) { - envCleanup = setCoreEnv({ TENANT_FEATURE_FLAGS: "*:!SQS" }) - } else if (isSqs) { - envCleanup = setCoreEnv({ TENANT_FEATURE_FLAGS: "*:SQS" }) - } + await features.testutils.withFeatureFlags("*", { SQS: true }, () => + config.init() + ) + envCleanup = features.testutils.setFeatureFlags("*", { + SQS: isSqs, + }) if (dsProvider) { const rawDatasource = await dsProvider @@ -2517,15 +2518,9 @@ describe.each([ let flagCleanup: (() => void) | undefined beforeAll(async () => { - const env = { - TENANT_FEATURE_FLAGS: `*:${FeatureFlag.ENRICHED_RELATIONSHIPS}`, - } - if (isSqs) { - env.TENANT_FEATURE_FLAGS = `${env.TENANT_FEATURE_FLAGS},*:SQS` - } else { - env.TENANT_FEATURE_FLAGS = `${env.TENANT_FEATURE_FLAGS},*:!SQS` - } - flagCleanup = setCoreEnv(env) + flagCleanup = features.testutils.setFeatureFlags("*", { + ENRICHED_RELATIONSHIPS: true, + }) const aux2Table = await config.api.table.save(saveTableRequest()) const aux2Data = await config.api.row.save(aux2Table._id!, {}) @@ -2752,9 +2747,10 @@ describe.each([ it.each(testScenarios)( "does not enrich relationships when not enabled (via %s)", async (__, retrieveDelegate) => { - await withCoreEnv( + await features.testutils.withFeatureFlags( + "*", { - TENANT_FEATURE_FLAGS: `*:!${FeatureFlag.ENRICHED_RELATIONSHIPS}`, + ENRICHED_RELATIONSHIPS: false, }, async () => { const otherRows = _.sampleSize(auxData, 5) diff --git a/packages/server/src/api/routes/tests/search.spec.ts b/packages/server/src/api/routes/tests/search.spec.ts index 110899e292..51965b5574 100644 --- a/packages/server/src/api/routes/tests/search.spec.ts +++ b/packages/server/src/api/routes/tests/search.spec.ts @@ -7,9 +7,9 @@ import { import { context, db as dbCore, + features, MAX_VALID_DATE, MIN_VALID_DATE, - setEnv as setCoreEnv, SQLITE_DESIGN_DOC_ID, utils, withEnv as withCoreEnv, @@ -94,16 +94,12 @@ describe.each([ } beforeAll(async () => { - await withCoreEnv({ TENANT_FEATURE_FLAGS: "*:SQS" }, () => config.init()) - if (isLucene) { - envCleanup = setCoreEnv({ - TENANT_FEATURE_FLAGS: "*:!SQS", - }) - } else if (isSqs) { - envCleanup = setCoreEnv({ - TENANT_FEATURE_FLAGS: "*:SQS", - }) - } + await features.testutils.withFeatureFlags("*", { SQS: true }, () => + config.init() + ) + envCleanup = features.testutils.setFeatureFlags("*", { + SQS: isSqs, + }) if (config.app?.appId) { config.app = await config.api.application.update(config.app?.appId, { diff --git a/packages/server/src/api/routes/tests/templates.spec.ts b/packages/server/src/api/routes/tests/templates.spec.ts index 6f4d468a68..a4f9318720 100644 --- a/packages/server/src/api/routes/tests/templates.spec.ts +++ b/packages/server/src/api/routes/tests/templates.spec.ts @@ -2,7 +2,11 @@ import * as setup from "./utilities" import path from "path" import nock from "nock" import { generator } from "@budibase/backend-core/tests" -import { withEnv as withCoreEnv, env as coreEnv } from "@budibase/backend-core" +import { + withEnv as withCoreEnv, + env as coreEnv, + features, +} from "@budibase/backend-core" interface App { background: string @@ -85,41 +89,44 @@ describe("/templates", () => { it.each(["sqs", "lucene"])( `should be able to create an app from a template (%s)`, async source => { - const env: Partial = { - TENANT_FEATURE_FLAGS: source === "sqs" ? "*:SQS" : "", - } + await features.testutils.withFeatureFlags( + "*", + { SQS: source === "sqs" }, + async () => { + const name = generator.guid().replaceAll("-", "") + const url = `/${name}` - await withCoreEnv(env, async () => { - const name = generator.guid().replaceAll("-", "") - const url = `/${name}` - - const app = await config.api.application.create({ - name, - url, - useTemplate: "true", - templateName: "Agency Client Portal", - templateKey: "app/agency-client-portal", - }) - expect(app.name).toBe(name) - expect(app.url).toBe(url) - - await config.withApp(app, async () => { - const tables = await config.api.table.fetch() - expect(tables).toHaveLength(2) - - tables.sort((a, b) => a.name.localeCompare(b.name)) - const [agencyProjects, users] = tables - expect(agencyProjects.name).toBe("Agency Projects") - expect(users.name).toBe("Users") - - const { rows } = await config.api.row.search(agencyProjects._id!, { - tableId: agencyProjects._id!, - query: {}, + const app = await config.api.application.create({ + name, + url, + useTemplate: "true", + templateName: "Agency Client Portal", + templateKey: "app/agency-client-portal", }) + expect(app.name).toBe(name) + expect(app.url).toBe(url) - expect(rows).toHaveLength(3) - }) - }) + await config.withApp(app, async () => { + const tables = await config.api.table.fetch() + expect(tables).toHaveLength(2) + + tables.sort((a, b) => a.name.localeCompare(b.name)) + const [agencyProjects, users] = tables + expect(agencyProjects.name).toBe("Agency Projects") + expect(users.name).toBe("Users") + + const { rows } = await config.api.row.search( + agencyProjects._id!, + { + tableId: agencyProjects._id!, + query: {}, + } + ) + + expect(rows).toHaveLength(3) + }) + } + ) } ) }) diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts index 04e51fc212..ca4805864b 100644 --- a/packages/server/src/api/routes/tests/viewV2.spec.ts +++ b/packages/server/src/api/routes/tests/viewV2.spec.ts @@ -37,6 +37,7 @@ import { withEnv as withCoreEnv, setEnv as setCoreEnv, env, + features, } from "@budibase/backend-core" describe.each([ @@ -102,18 +103,13 @@ describe.each([ } beforeAll(async () => { - await withCoreEnv({ TENANT_FEATURE_FLAGS: isSqs ? "*:SQS" : "" }, () => + await features.testutils.withFeatureFlags("*", { SQS: isSqs }, () => config.init() ) - if (isLucene) { - envCleanup = setCoreEnv({ - TENANT_FEATURE_FLAGS: "*:!SQS", - }) - } else if (isSqs) { - envCleanup = setCoreEnv({ - TENANT_FEATURE_FLAGS: "*:SQS", - }) - } + + envCleanup = features.testutils.setFeatureFlags("*", { + SQS: isSqs, + }) if (dsProvider) { datasource = await config.createDatasource({ @@ -2490,12 +2486,8 @@ describe.each([ describe("foreign relationship columns", () => { let envCleanup: () => void beforeAll(() => { - const flags = [`*:${FeatureFlag.ENRICHED_RELATIONSHIPS}`] - if (env.TENANT_FEATURE_FLAGS) { - flags.push(...env.TENANT_FEATURE_FLAGS.split(",")) - } - envCleanup = setCoreEnv({ - TENANT_FEATURE_FLAGS: flags.join(","), + envCleanup = features.testutils.setFeatureFlags("*", { + ENRICHED_RELATIONSHIPS: true, }) }) diff --git a/packages/server/src/appMigrations/migrations/tests/20240604153647_initial_sqs.spec.ts b/packages/server/src/appMigrations/migrations/tests/20240604153647_initial_sqs.spec.ts index b4f708edb5..759665f1c2 100644 --- a/packages/server/src/appMigrations/migrations/tests/20240604153647_initial_sqs.spec.ts +++ b/packages/server/src/appMigrations/migrations/tests/20240604153647_initial_sqs.spec.ts @@ -2,6 +2,7 @@ import * as setup from "../../../api/routes/tests/utilities" import { basicTable } from "../../../tests/utilities/structures" import { db as dbCore, + features, SQLITE_DESIGN_DOC_ID, withEnv as withCoreEnv, } from "@budibase/backend-core" @@ -71,11 +72,11 @@ function oldLinkDocument(): Omit { } async function sqsDisabled(cb: () => Promise) { - await withCoreEnv({ TENANT_FEATURE_FLAGS: "*:!SQS" }, cb) + await features.testutils.withFeatureFlags("*", { SQS: false }, cb) } async function sqsEnabled(cb: () => Promise) { - await withCoreEnv({ TENANT_FEATURE_FLAGS: "*:SQS" }, cb) + await features.testutils.withFeatureFlags("*", { SQS: true }, cb) } describe("SQS migration", () => { diff --git a/packages/server/src/sdk/app/rows/search/tests/search.spec.ts b/packages/server/src/sdk/app/rows/search/tests/search.spec.ts index e7fd095865..fce1e14094 100644 --- a/packages/server/src/sdk/app/rows/search/tests/search.spec.ts +++ b/packages/server/src/sdk/app/rows/search/tests/search.spec.ts @@ -13,6 +13,7 @@ import { generator } from "@budibase/backend-core/tests" import { withEnv as withCoreEnv, setEnv as setCoreEnv, + features, } from "@budibase/backend-core" import { DatabaseName, @@ -41,19 +42,13 @@ describe.each([ let table: Table beforeAll(async () => { - await withCoreEnv({ TENANT_FEATURE_FLAGS: isSqs ? "*:SQS" : "" }, () => + await features.testutils.withFeatureFlags("*", { SQS: isSqs }, () => config.init() ) - if (isLucene) { - envCleanup = setCoreEnv({ - TENANT_FEATURE_FLAGS: "*:!SQS", - }) - } else if (isSqs) { - envCleanup = setCoreEnv({ - TENANT_FEATURE_FLAGS: "*:SQS", - }) - } + envCleanup = features.testutils.setFeatureFlags("*", { + SQS: isSqs, + }) if (dsProvider) { datasource = await config.createDatasource({ diff --git a/packages/server/src/utilities/rowProcessor/tests/outputProcessing.spec.ts b/packages/server/src/utilities/rowProcessor/tests/outputProcessing.spec.ts index b6c2cdb11c..8cbe585d90 100644 --- a/packages/server/src/utilities/rowProcessor/tests/outputProcessing.spec.ts +++ b/packages/server/src/utilities/rowProcessor/tests/outputProcessing.spec.ts @@ -8,7 +8,7 @@ import { } from "@budibase/types" import { outputProcessing } from ".." import { generator, structures } from "@budibase/backend-core/tests" -import { setEnv as setCoreEnv } from "@budibase/backend-core" +import { features } from "@budibase/backend-core" import * as bbReferenceProcessor from "../bbReferenceProcessor" import TestConfiguration from "../../../tests/utilities/TestConfiguration" @@ -21,7 +21,7 @@ jest.mock("../bbReferenceProcessor", (): typeof bbReferenceProcessor => ({ describe("rowProcessor - outputProcessing", () => { const config = new TestConfiguration() - let cleanupEnv: () => void = () => {} + let cleanupFlags: () => void = () => {} beforeAll(async () => { await config.init() @@ -33,11 +33,11 @@ describe("rowProcessor - outputProcessing", () => { beforeEach(() => { jest.resetAllMocks() - cleanupEnv = setCoreEnv({ TENANT_FEATURE_FLAGS: "*SQS" }) + cleanupFlags = features.testutils.setFeatureFlags("*", { SQS: true }) }) afterEach(() => { - cleanupEnv() + cleanupFlags() }) const processOutputBBReferenceMock = diff --git a/packages/worker/src/api/routes/global/tests/auditLogs.spec.ts b/packages/worker/src/api/routes/global/tests/auditLogs.spec.ts index b09c27762d..b540836583 100644 --- a/packages/worker/src/api/routes/global/tests/auditLogs.spec.ts +++ b/packages/worker/src/api/routes/global/tests/auditLogs.spec.ts @@ -1,5 +1,5 @@ import { mocks, structures } from "@budibase/backend-core/tests" -import { context, events, setEnv as setCoreEnv } from "@budibase/backend-core" +import { context, events, features } from "@budibase/backend-core" import { Event, IdentityType } from "@budibase/types" import { TestConfiguration } from "../../../../tests" @@ -17,11 +17,9 @@ describe.each(["lucene", "sql"])("/api/global/auditlogs (%s)", method => { let envCleanup: (() => void) | undefined beforeAll(async () => { - if (method === "lucene") { - envCleanup = setCoreEnv({ TENANT_FEATURE_FLAGS: "*:!SQS" }) - } else if (method === "sql") { - envCleanup = setCoreEnv({ TENANT_FEATURE_FLAGS: "*:SQS" }) - } + envCleanup = features.testutils.setFeatureFlags("*", { + SQS: method === "sql", + }) await config.beforeAll() })