diff --git a/src/lib/features/project-environments/environment-service.test.ts b/src/lib/features/project-environments/environment-service.test.ts index 5273b95a7a..6d2d9c32db 100644 --- a/src/lib/features/project-environments/environment-service.test.ts +++ b/src/lib/features/project-environments/environment-service.test.ts @@ -3,6 +3,7 @@ import { createTestConfig } from '../../../test/config/test-config'; import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init'; import NotFoundError from '../../error/notfound-error'; import { + type IExperimentalOptions, type IUnleashStores, SYSTEM_USER, SYSTEM_USER_AUDIT, @@ -17,9 +18,15 @@ let service: EnvironmentService; let eventService: EventService; beforeAll(async () => { - const config = createTestConfig(); + const flags: Partial = { + flags: { globalChangeRequestConfig: true }, + }; + const config = createTestConfig({ + experimental: flags, + }); db = await dbInit('environment_service_serial', config.getLogger, { dbInitMethod: 'legacy' as const, + experimental: flags, }); stores = db.stores; eventService = createEventsService(db.rawDatabase, config); @@ -49,6 +56,34 @@ test('Can get all', async () => { expect(environments).toHaveLength(3); // the one we created plus 'default' }); +test('Can manage required approvals', async () => { + const created = await db.stores.environmentStore.create({ + name: 'approval_env', + type: 'production', + requiredApprovals: 1, + }); + + const retrieved = await service.get('approval_env'); + + await db.stores.environmentStore.update( + { + type: 'production', + protected: false, + requiredApprovals: 2, + }, + 'approval_env', + ); + + const updated = await service.get('approval_env'); + const groupRetrieved = (await service.getAll()).find( + (env) => env.name === 'approval_env', + ); + + expect(retrieved).toEqual(created); + expect(updated).toEqual({ ...created, requiredApprovals: 2 }); + expect(groupRetrieved).toMatchObject({ ...created, requiredApprovals: 2 }); +}); + test('Can connect environment to project', async () => { await db.stores.environmentStore.create({ name: 'test-connection', diff --git a/src/lib/features/project-environments/environment-store-type.ts b/src/lib/features/project-environments/environment-store-type.ts index 16aa710934..1baa00a8f1 100644 --- a/src/lib/features/project-environments/environment-store-type.ts +++ b/src/lib/features/project-environments/environment-store-type.ts @@ -9,7 +9,7 @@ export interface IEnvironmentStore extends Store { exists(name: string): Promise; create(env: IEnvironmentCreate): Promise; update( - env: Pick, + env: Pick, name: string, ): Promise; updateProperty( diff --git a/src/lib/features/project-environments/environment-store.ts b/src/lib/features/project-environments/environment-store.ts index 196f046a2f..4559fcec7d 100644 --- a/src/lib/features/project-environments/environment-store.ts +++ b/src/lib/features/project-environments/environment-store.ts @@ -12,7 +12,7 @@ import NotFoundError from '../../error/notfound-error'; import type { IEnvironmentStore } from './environment-store-type'; import { snakeCaseKeys } from '../../util/snakeCase'; import type { CreateFeatureStrategySchema } from '../../openapi'; -import type { IUnleashConfig } from '../../types'; +import type { IFlagResolver, IUnleashConfig } from '../../types'; interface IEnvironmentsTable { name: string; @@ -21,6 +21,7 @@ interface IEnvironmentsTable { sort_order: number; enabled: boolean; protected: boolean; + required_approvals?: number; } interface IEnvironmentsWithCountsTable extends IEnvironmentsTable { @@ -51,6 +52,7 @@ function mapRow(row: IEnvironmentsTable): IEnvironment { sortOrder: row.sort_order, enabled: row.enabled, protected: row.protected, + requiredApprovals: row.required_approvals, }; } @@ -95,6 +97,7 @@ function fieldToRow(env: IEnvironment): IEnvironmentsTable { sort_order: env.sortOrder, enabled: env.enabled, protected: env.protected, + required_approvals: env.requiredApprovals, }; } @@ -103,6 +106,8 @@ const TABLE = 'environments'; export default class EnvironmentStore implements IEnvironmentStore { private logger: Logger; + private flagResolver: IFlagResolver; + private db: Db; private isOss: boolean; @@ -112,11 +117,16 @@ export default class EnvironmentStore implements IEnvironmentStore { constructor( db: Db, eventBus: EventEmitter, - { getLogger, isOss }: Pick, + { + getLogger, + isOss, + flagResolver, + }: Pick, ) { this.db = db; this.logger = getLogger('db/environment-store.ts'); this.isOss = isOss; + this.flagResolver = flagResolver; this.timer = (action) => metricsHelper.wrapTimer(eventBus, DB_TIME, { store: 'environment', @@ -124,12 +134,18 @@ export default class EnvironmentStore implements IEnvironmentStore { }); } + private allColumns() { + return this.flagResolver.isEnabled('globalChangeRequestConfig') + ? [...COLUMNS, 'required_approvals'] + : COLUMNS; + } + async importEnvironments( environments: IEnvironment[], ): Promise { const rows = await this.db(TABLE) .insert(environments.map(fieldToRow)) - .returning(COLUMNS) + .returning(this.allColumns()) .onConflict('name') .ignore(); @@ -318,13 +334,13 @@ export default class EnvironmentStore implements IEnvironmentStore { } async update( - env: Pick, + env: Pick, name: string, ): Promise { const updatedEnv = await this.db(TABLE) .update(snakeCaseKeys(env)) .where({ name, protected: false }) - .returning(COLUMNS); + .returning(this.allColumns()); return mapRow(updatedEnv[0]); } @@ -332,7 +348,7 @@ export default class EnvironmentStore implements IEnvironmentStore { async create(env: IEnvironmentCreate): Promise { const row = await this.db(TABLE) .insert(snakeCaseKeys(env)) - .returning(COLUMNS); + .returning(this.allColumns()); return mapRow(row[0]); } diff --git a/src/lib/features/project-environments/fake-environment-store.ts b/src/lib/features/project-environments/fake-environment-store.ts index a7349b56a1..ee581459dc 100644 --- a/src/lib/features/project-environments/fake-environment-store.ts +++ b/src/lib/features/project-environments/fake-environment-store.ts @@ -60,7 +60,7 @@ export default class FakeEnvironmentStore implements IEnvironmentStore { } async update( - env: Pick, + env: Pick, name: string, ): Promise { const found = this.environments.find( diff --git a/src/lib/openapi/spec/environment-schema.ts b/src/lib/openapi/spec/environment-schema.ts index 8275a200c5..7d465b8804 100644 --- a/src/lib/openapi/spec/environment-schema.ts +++ b/src/lib/openapi/spec/environment-schema.ts @@ -58,6 +58,14 @@ export const environmentSchema = { description: 'The number of enabled toggles for the project environment', }, + requiredApprovals: { + type: 'integer', + nullable: true, + description: + 'Experimental field. The number of approvals required before a change request can be applied in this environment.', + minimum: 1, + example: 3, + }, }, components: {}, } as const; diff --git a/src/test/e2e/api/admin/environment.test.ts b/src/test/e2e/api/admin/environment.test.ts index 3d1230dad2..cc469a12db 100644 --- a/src/test/e2e/api/admin/environment.test.ts +++ b/src/test/e2e/api/admin/environment.test.ts @@ -44,6 +44,7 @@ test('Can list all existing environments', async () => { sortOrder: 1, type: 'production', protected: true, + requiredApprovals: null, projectCount: 1, apiTokenCount: 0, enabledToggleCount: 0,