From 26c9d53b892c4a45d6904c4497aae596d0632532 Mon Sep 17 00:00:00 2001 From: Fredrik Strand Oseberg Date: Mon, 13 Sep 2021 15:57:38 +0200 Subject: [PATCH] feat: Move environments to enterprise (#935) - Adding, updating and renaming environments are meant to be enterprise only features, as such, this PR moves these operations out of this server - We still keep sortOrder updating, toggling on/off and getting one, getting all, so we can still work with environments in the OSS version as well. Co-authored-by: Christopher Kolstad Co-authored-by: Christopher Kolstad --- src/lib/db/environment-store.ts | 76 ++++++-- .../admin-api/environments-controller.ts | 53 +++--- src/lib/server-impl.ts | 3 +- src/lib/services/environment-service.ts | 26 ++- src/lib/services/state-schema.ts | 9 + src/lib/services/state-service.test.ts | 18 +- src/lib/types/model.ts | 14 ++ src/lib/types/stores/environment-store.ts | 15 +- src/lib/util/snakeCase.test.ts | 25 +++ src/lib/util/snakeCase.ts | 27 +++ ...31072631-add-sort-order-and-type-to-env.js | 21 +++ ...10908100701-add-enabled-to-environments.js | 21 +++ ...651-add-protected-field-to-environments.js | 22 +++ src/test/e2e/api/admin/environment.test.ts | 172 +++++++++++------- .../admin/project/environments.e2e.test.ts | 27 +-- .../project/feature.strategy.e2e.test.ts | 150 ++++++--------- src/test/e2e/api/client/feature.e2e.test.ts | 3 +- src/test/e2e/helpers/database-init.ts | 2 +- src/test/e2e/helpers/database.json | 6 +- .../e2e/services/environment-service.test.ts | 59 +++--- src/test/e2e/stores/project-store.e2e.test.ts | 6 +- src/test/fixtures/fake-environment-store.ts | 56 +++++- 22 files changed, 547 insertions(+), 264 deletions(-) create mode 100644 src/lib/util/snakeCase.test.ts create mode 100644 src/lib/util/snakeCase.ts create mode 100644 src/migrations/20210831072631-add-sort-order-and-type-to-env.js create mode 100644 src/migrations/20210908100701-add-enabled-to-environments.js create mode 100644 src/migrations/20210909085651-add-protected-field-to-environments.js diff --git a/src/lib/db/environment-store.ts b/src/lib/db/environment-store.ts index 51d712e5e6..2027636678 100644 --- a/src/lib/db/environment-store.ts +++ b/src/lib/db/environment-store.ts @@ -3,27 +3,39 @@ import { Knex } from 'knex'; import { Logger, LogProvider } from '../logger'; import metricsHelper from '../util/metrics-helper'; import { DB_TIME } from '../metric-events'; -import { IEnvironment } from '../types/model'; +import { IEnvironment, IEnvironmentCreate } from '../types/model'; import NotFoundError from '../error/notfound-error'; import { IEnvironmentStore } from '../types/stores/environment-store'; +import { snakeCaseKeys } from '../util/snakeCase'; interface IEnvironmentsTable { name: string; display_name: string; created_at?: Date; + type: string; + sort_order: number; + enabled: boolean; + protected: boolean; } +const COLUMNS = [ + 'type', + 'display_name', + 'name', + 'created_at', + 'sort_order', + 'enabled', + 'protected', +]; + function mapRow(row: IEnvironmentsTable): IEnvironment { return { name: row.name, displayName: row.display_name, - }; -} - -function mapInput(input: IEnvironment): IEnvironmentsTable { - return { - name: input.name, - display_name: input.displayName, + type: row.type, + sortOrder: row.sort_order, + enabled: row.enabled, + protected: row.protected, }; } @@ -61,7 +73,9 @@ export default class EnvironmentStore implements IEnvironmentStore { } async getAll(): Promise { - const rows = await this.db(TABLE).select('*'); + const rows = await this.db(TABLE) + .select('*') + .orderBy('sort_order', 'created_at'); return rows.map(mapRow); } @@ -86,16 +100,48 @@ export default class EnvironmentStore implements IEnvironmentStore { return mapRow(row); } - async upsert(env: IEnvironment): Promise { + async updateProperty( + id: string, + field: string, + value: string | number, + ): Promise { await this.db(TABLE) - .insert(mapInput(env)) - .onConflict('name') - .merge(); - return env; + .update({ + [field]: value, + }) + .where({ name: id, protected: false }); + } + + async updateSortOrder(id: string, value: number): Promise { + await this.db(TABLE) + .update({ + sort_order: value, + }) + .where({ name: id }); + } + + async update( + env: Pick, + name: string, + ): Promise { + const updatedEnv = await this.db(TABLE) + .update(snakeCaseKeys(env)) + .where({ name, protected: false }) + .returning(COLUMNS); + + return mapRow(updatedEnv[0]); + } + + async create(env: IEnvironmentCreate): Promise { + const row = await this.db(TABLE) + .insert(snakeCaseKeys(env)) + .returning(COLUMNS); + + return mapRow(row[0]); } async delete(name: string): Promise { - await this.db(TABLE).where({ name }).del(); + await this.db(TABLE).where({ name, protected: false }).del(); } destroy(): void {} diff --git a/src/lib/routes/admin-api/environments-controller.ts b/src/lib/routes/admin-api/environments-controller.ts index 55860c904a..9f5085677b 100644 --- a/src/lib/routes/admin-api/environments-controller.ts +++ b/src/lib/routes/admin-api/environments-controller.ts @@ -2,7 +2,7 @@ import { Request, Response } from 'express'; import Controller from '../controller'; import { IUnleashServices } from '../../types/services'; import { IUnleashConfig } from '../../types/option'; -import { IEnvironment } from '../../types/model'; +import { ISortOrder } from '../../types/model'; import EnvironmentService from '../../services/environment-service'; import { Logger } from '../../logger'; import { ADMIN } from '../../types/permissions'; @@ -24,10 +24,10 @@ export class EnvironmentsController extends Controller { this.logger = config.getLogger('admin-api/environments-controller.ts'); this.service = environmentService; this.get('/', this.getAll); - this.post('/', this.createEnv, ADMIN); + this.put('/sort-order', this.updateSortOrder, ADMIN); this.get('/:name', this.getEnv); - this.put('/:name', this.updateEnv, ADMIN); - this.delete('/:name', this.deleteEnv, ADMIN); + this.post('/:name/on', this.toggleEnvironmentOn, ADMIN); + this.post('/:name/off', this.toggleEnvironmentOff, ADMIN); } async getAll(req: Request, res: Response): Promise { @@ -35,12 +35,30 @@ export class EnvironmentsController extends Controller { res.status(200).json({ version: 1, environments }); } - async createEnv( - req: Request, + async updateSortOrder( + req: Request, res: Response, ): Promise { - const environment = await this.service.create(req.body); - res.status(201).json(environment); + await this.service.updateSortOrder(req.body); + res.status(200).end(); + } + + async toggleEnvironmentOn( + req: Request, + res: Response, + ): Promise { + const { name } = req.params; + await this.service.toggleEnvironment(name, true); + res.status(204).end(); + } + + async toggleEnvironmentOff( + req: Request, + res: Response, + ): Promise { + const { name } = req.params; + await this.service.toggleEnvironment(name, false); + res.status(204).end(); } async getEnv( @@ -48,25 +66,8 @@ export class EnvironmentsController extends Controller { res: Response, ): Promise { const { name } = req.params; + const env = await this.service.get(name); res.status(200).json(env); } - - async updateEnv( - req: Request, - res: Response, - ): Promise { - const { name } = req.params; - const env = await this.service.update(name, req.body); - res.status(200).json(env); - } - - async deleteEnv( - req: Request, - res: Response, - ): Promise { - const { name } = req.params; - await this.service.delete(name); - res.status(200).end(); - } } diff --git a/src/lib/server-impl.ts b/src/lib/server-impl.ts index 5f081292b2..688ca9400c 100644 --- a/src/lib/server-impl.ts +++ b/src/lib/server-impl.ts @@ -14,7 +14,7 @@ import { createDb } from './db/db-pool'; import sessionDb from './middleware/session-db'; // Types import { IUnleash } from './types/core'; -import { IUnleashConfig, IUnleashOptions } from './types/option'; +import { IUnleashConfig, IUnleashOptions, IAuthType } from './types/option'; import { IUnleashServices } from './types/services'; import User, { IUser } from './types/user'; import { Logger, LogLevel } from './logger'; @@ -160,6 +160,7 @@ export { User, LogLevel, RoleName, + IAuthType, }; export default { diff --git a/src/lib/services/environment-service.ts b/src/lib/services/environment-service.ts index 7e16ce594b..9a42a1e9ba 100644 --- a/src/lib/services/environment-service.ts +++ b/src/lib/services/environment-service.ts @@ -1,10 +1,10 @@ import { IUnleashStores } from '../types/stores'; import { IUnleashConfig } from '../types/option'; import { Logger } from '../logger'; -import { IEnvironment } from '../types/model'; +import { IEnvironment, ISortOrder } from '../types/model'; import { UNIQUE_CONSTRAINT_VIOLATION } from '../error/db-error'; import NameExistsError from '../error/name-exists-error'; -import { environmentSchema } from './state-schema'; +import { sortOrderSchema } from './state-schema'; import NotFoundError from '../error/notfound-error'; import { IEnvironmentStore } from '../types/stores/environment-store'; import { IFeatureStrategiesStore } from '../types/stores/feature-strategies-store'; @@ -46,22 +46,20 @@ export default class EnvironmentService { return this.environmentStore.get(name); } - async delete(name: string): Promise { - return this.environmentStore.delete(name); + async updateSortOrder(sortOrder: ISortOrder): Promise { + await sortOrderSchema.validateAsync(sortOrder); + await Promise.all( + Object.keys(sortOrder).map((key) => { + const value = sortOrder[key]; + return this.environmentStore.updateSortOrder(key, value); + }), + ); } - async create(env: IEnvironment): Promise { - await environmentSchema.validateAsync(env); - return this.environmentStore.upsert(env); - } - - async update( - name: string, - env: Pick, - ): Promise { + async toggleEnvironment(name: string, value: boolean): Promise { const exists = await this.environmentStore.exists(name); if (exists) { - return this.environmentStore.upsert({ ...env, name }); + return this.environmentStore.updateProperty(name, 'enabled', value); } throw new NotFoundError(`Could not find environment ${name}`); } diff --git a/src/lib/services/state-schema.ts b/src/lib/services/state-schema.ts index 15a03372c7..54fd050dd8 100644 --- a/src/lib/services/state-schema.ts +++ b/src/lib/services/state-schema.ts @@ -28,8 +28,17 @@ export const featureEnvironmentsSchema = joi.object().keys({ export const environmentSchema = joi.object().keys({ name: nameType.allow(':global:'), displayName: joi.string().optional().allow(''), + type: joi.string().required(), }); +export const updateEnvironmentSchema = joi.object().keys({ + displayName: joi.string().optional().allow(''), + type: joi.string().optional(), + sortOrder: joi.number().optional(), +}); + +export const sortOrderSchema = joi.object().pattern(/^/, joi.number()); + export const stateSchema = joi.object().keys({ version: joi.number(), features: joi.array().optional().items(featureSchema), diff --git a/src/lib/services/state-service.test.ts b/src/lib/services/state-service.test.ts index 511afcd278..41a8107d79 100644 --- a/src/lib/services/state-service.test.ts +++ b/src/lib/services/state-service.test.ts @@ -479,13 +479,15 @@ test('exporting to new format works', async () => { name: 'extra', description: 'No surprises here', }); - await stores.environmentStore.upsert({ + await stores.environmentStore.create({ name: 'dev', displayName: 'Development', + type: 'development', }); - await stores.environmentStore.upsert({ + await stores.environmentStore.create({ name: 'prod', displayName: 'Production', + type: 'production', }); await stores.featureToggleStore.create('fancy', { name: 'Some-feature', @@ -519,13 +521,15 @@ test('featureStrategies can keep existing', async () => { name: 'extra', description: 'No surprises here', }); - await stores.environmentStore.upsert({ + await stores.environmentStore.create({ name: 'dev', displayName: 'Development', + type: 'development', }); - await stores.environmentStore.upsert({ + await stores.environmentStore.create({ name: 'prod', displayName: 'Production', + type: 'production', }); await stores.featureToggleStore.create('fancy', { name: 'Some-feature', @@ -565,13 +569,15 @@ test('featureStrategies should not keep existing if dropBeforeImport', async () name: 'extra', description: 'No surprises here', }); - await stores.environmentStore.upsert({ + await stores.environmentStore.create({ name: 'dev', displayName: 'Development', + type: 'development', }); - await stores.environmentStore.upsert({ + await stores.environmentStore.create({ name: 'prod', displayName: 'Production', + type: 'production', }); await stores.featureToggleStore.create('fancy', { name: 'Some-feature', diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 70f958dbf2..f1d3d2daa5 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -74,6 +74,10 @@ export interface IEnvironmentDetail extends IEnvironmentOverview { strategies: IStrategyConfig[]; } +export interface ISortOrder { + [index: string]: number; +} + export interface IFeatureEnvironment { environment: string; featureName: string; @@ -98,6 +102,16 @@ export interface IVariant { export interface IEnvironment { name: string; displayName: string; + type: string; + sortOrder: number; + enabled: boolean; + protected: boolean; +} + +export interface IEnvironmentCreate { + name: string; + displayName: string; + type: string; } export interface IEnvironmentOverview { diff --git a/src/lib/types/stores/environment-store.ts b/src/lib/types/stores/environment-store.ts index fe2988db74..9eb7dc6b12 100644 --- a/src/lib/types/stores/environment-store.ts +++ b/src/lib/types/stores/environment-store.ts @@ -1,6 +1,17 @@ -import { IEnvironment } from '../model'; +import { IEnvironment, IEnvironmentCreate } from '../model'; import { Store } from './store'; export interface IEnvironmentStore extends Store { - upsert(env: IEnvironment): Promise; + exists(name: string): Promise; + create(env: IEnvironmentCreate): Promise; + update( + env: Pick, + name: string, + ): Promise; + updateProperty( + id: string, + field: string, + value: string | number | boolean, + ): Promise; + updateSortOrder(id: string, value: number): Promise; } diff --git a/src/lib/util/snakeCase.test.ts b/src/lib/util/snakeCase.test.ts new file mode 100644 index 0000000000..3c726c870d --- /dev/null +++ b/src/lib/util/snakeCase.test.ts @@ -0,0 +1,25 @@ +import { snakeCase, snakeCaseKeys } from './snakeCase'; + +test('should return snake case from camelCase', () => { + const resultOne = snakeCase('camelCase'); + const resultTwo = snakeCase('SnaejKase'); + + expect(resultOne).toBe('camel_case'); + expect(resultTwo).toBe('snaej_kase'); +}); + +test('should return object with snake case keys', () => { + const input = { + sortOrder: 1, + type: 'production', + displayName: 'dev', + enabled: true, + }; + + const output = snakeCaseKeys(input); + + expect(output.sort_order).toBe(1); + expect(output.type).toBe('production'); + expect(output.display_name).toBe('dev'); + expect(output.enabled).toBe(true); +}); diff --git a/src/lib/util/snakeCase.ts b/src/lib/util/snakeCase.ts new file mode 100644 index 0000000000..bc5f85ea0b --- /dev/null +++ b/src/lib/util/snakeCase.ts @@ -0,0 +1,27 @@ +export const snakeCase = (input: string): string => { + const result = []; + const splitString = input.split(''); + for (let i = 0; i < splitString.length; i++) { + const char = splitString[i]; + if (i !== 0 && char.toLocaleUpperCase() === char) { + result.push('_', char.toLocaleLowerCase()); + } else { + result.push(char.toLocaleLowerCase()); + } + } + return result.join(''); +}; + +export const snakeCaseKeys = (obj: { + [index: string]: any; +}): { [index: string]: any } => { + const objResult: { [index: string]: any } = {}; + + Object.keys(obj).forEach((key) => { + const snakeCaseKey = snakeCase(key); + + objResult[snakeCaseKey] = obj[key]; + }); + + return objResult; +}; diff --git a/src/migrations/20210831072631-add-sort-order-and-type-to-env.js b/src/migrations/20210831072631-add-sort-order-and-type-to-env.js new file mode 100644 index 0000000000..dbe12a07e5 --- /dev/null +++ b/src/migrations/20210831072631-add-sort-order-and-type-to-env.js @@ -0,0 +1,21 @@ +exports.up = function (db, cb) { + db.runSql( + ` + ALTER TABLE environments ADD COLUMN sort_order integer DEFAULT 9999, ADD COLUMN type text; + `, + cb, + ); +}; + +exports.down = function (db, cb) { + db.runSql( + ` + ALTER TABLE environments DROP COLUMN sort_order, DROP COLUMN type; + `, + cb, + ); +}; + +exports._meta = { + version: 1, +}; diff --git a/src/migrations/20210908100701-add-enabled-to-environments.js b/src/migrations/20210908100701-add-enabled-to-environments.js new file mode 100644 index 0000000000..bb026ef2b4 --- /dev/null +++ b/src/migrations/20210908100701-add-enabled-to-environments.js @@ -0,0 +1,21 @@ +exports.up = function (db, cb) { + db.runSql( + ` + ALTER TABLE environments ADD COLUMN enabled BOOLEAN DEFAULT true; + `, + cb, + ); +}; + +exports.down = function (db, cb) { + db.runSql( + ` + ALTER TABLE environments DROP COLUMN enabled; + `, + cb, + ); +}; + +exports._meta = { + version: 1, +}; diff --git a/src/migrations/20210909085651-add-protected-field-to-environments.js b/src/migrations/20210909085651-add-protected-field-to-environments.js new file mode 100644 index 0000000000..e1aa3370a5 --- /dev/null +++ b/src/migrations/20210909085651-add-protected-field-to-environments.js @@ -0,0 +1,22 @@ +exports.up = function (db, cb) { + db.runSql( + ` + ALTER TABLE environments ADD COLUMN protected BOOLEAN DEFAULT false; + UPDATE environments SET protected = true WHERE name = ':global:'; + `, + cb, + ); +}; + +exports.down = function (db, cb) { + db.runSql( + ` + ALTER TABLE environments DROP COLUMN protected; + `, + cb, + ); +}; + +exports._meta = { + version: 1, +}; diff --git a/src/test/e2e/api/admin/environment.test.ts b/src/test/e2e/api/admin/environment.test.ts index 91d802ef06..a901b7c9f6 100644 --- a/src/test/e2e/api/admin/environment.test.ts +++ b/src/test/e2e/api/admin/environment.test.ts @@ -1,9 +1,9 @@ -import dbInit from '../../helpers/database-init'; +import dbInit, { ITestDb } from '../../helpers/database-init'; import getLogger from '../../../fixtures/no-logger'; -import { setupApp } from '../../helpers/test-helper'; +import { IUnleashTest, setupApp } from '../../helpers/test-helper'; -let app; -let db; +let app: IUnleashTest; +let db: ITestDb; beforeAll(async () => { db = await dbInit('environment_api_serial', getLogger); @@ -15,32 +15,6 @@ afterAll(async () => { await db.destroy(); }); -test('Should be able to create an environment', async () => { - const envName = 'environment-info'; - // Create environment - await app.request - .post('/api/admin/environments') - .send({ - name: envName, - displayName: 'Enable feature for environment', - }) - .set('Content-Type', 'application/json') - .expect(201); -}); - -test('Environment names must be URL safe', async () => { - const envName = 'Something not url safe **/ */21312'; - // Create environment - await app.request - .post('/api/admin/environments') - .send({ - name: envName, - displayName: 'Enable feature for environment', - }) - .set('Content-Type', 'application/json') - .expect(400); -}); - test('Can list all existing environments', async () => { await app.request .get('/api/admin/environments') @@ -51,53 +25,123 @@ test('Can list all existing environments', async () => { expect(res.body.environments[0]).toStrictEqual({ displayName: 'Across all environments', name: ':global:', + enabled: true, + sortOrder: 1, + type: 'production', + protected: true, }); }); }); -test('Can delete environment', async () => { - const envName = 'deletable-info'; - // Create environment +test('Can update sort order', async () => { + const envName = 'update-sort-order'; + await db.stores.environmentStore.create({ + name: envName, + displayName: 'Enable feature for environment', + type: 'production', + }); await app.request - .post('/api/admin/environments') + .put('/api/admin/environments/sort-order') .send({ - name: envName, - displayName: 'Enable feature for environment', + ':global:': 2, + [envName]: 1, }) - .set('Content-Type', 'application/json') - .expect(201); - await app.request.get(`/api/admin/environments/${envName}`).expect(200); - await app.request.delete(`/api/admin/environments/${envName}`).expect(200); - await app.request.get(`/api/admin/environments/${envName}`).expect(404); -}); - -test('Can update environment', async () => { - const envName = 'update-env'; - // Create environment - await app.request - .post('/api/admin/environments') - .send({ - name: envName, - displayName: 'Enable feature for environment', - }) - .set('Content-Type', 'application/json') - .expect(201); - await app.request.get(`/api/admin/environments/${envName}`).expect(200); - await app.request - .put(`/api/admin/environments/${envName}`) - .send({ displayName: 'Update this' }) .expect(200); + await app.request - .get(`/api/admin/environments/${envName}`) + .get('/api/admin/environments') + .expect(200) + .expect('Content-Type', /json/) .expect((res) => { - expect(res.body.displayName).toBe('Update this'); + const updatedSort = res.body.environments.find( + (t) => t.name === envName, + ); + const global = res.body.environments.find( + (t) => t.name === ':global:', + ); + expect(updatedSort.sortOrder).toBe(1); + expect(global.sortOrder).toBe(2); }); }); -test('Updating a non existing environment yields 404', async () => { - const envName = 'non-existing-env'; +test('Sort order will fail on wrong data format', async () => { + const envName = 'sort-order-env'; + await app.request - .put(`/api/admin/environments/${envName}`) - .send({ displayName: 'Update this' }) + .put('/api/admin/environments/sort-order') + .send({ + ':global:': 'test', + [envName]: 1, + }) + .expect(400); +}); + +test('Can update environment enabled status', async () => { + const envName = 'enable-environment'; + await db.stores.environmentStore.create({ + name: envName, + displayName: 'Enable feature for environment', + type: 'production', + }); + await app.request + .post(`/api/admin/environments/${envName}/on`) + .set('Content-Type', 'application/json') + .expect(204); +}); + +test('Can update environment disabled status', async () => { + const envName = 'disable-environment'; + + await db.stores.environmentStore.create({ + name: envName, + displayName: 'Enable feature for environment', + type: 'production', + }); + + await app.request + .post(`/api/admin/environments/${envName}/off`) + .set('Content-Type', 'application/json') + .expect(204); +}); + +test('Can not update non-existing environment enabled status', async () => { + const envName = 'non-existing-env'; + + await app.request + .post(`/api/admin/environments/${envName}/on`) + .set('Content-Type', 'application/json') + .expect(404); +}); + +test('Can not update non-existing environment disabled status', async () => { + const envName = 'non-existing-env'; + + await app.request + .post(`/api/admin/environments/${envName}/off`) + .set('Content-Type', 'application/json') + .expect(404); +}); + +test('Can get specific environment', async () => { + const envName = 'get-specific'; + await db.stores.environmentStore.create({ + name: envName, + type: 'production', + displayName: 'Fun!', + }); + await app.request + .get(`/api/admin/environments/${envName}`) + .expect(200) + .expect('Content-Type', /json/) + .expect((res) => { + const { body } = res; + expect(body.name).toBe(envName); + expect(body.type).toBe('production'); + }); +}); + +test('Getting a non existing environment yields 404', async () => { + await app.request + .get('/api/admin/environments/this-does-not-exist') .expect(404); }); diff --git a/src/test/e2e/api/admin/project/environments.e2e.test.ts b/src/test/e2e/api/admin/project/environments.e2e.test.ts index 0bc8a46997..d52a8bac8c 100644 --- a/src/test/e2e/api/admin/project/environments.e2e.test.ts +++ b/src/test/e2e/api/admin/project/environments.e2e.test.ts @@ -32,11 +32,12 @@ afterAll(async () => { }); test('Should add environment to project', async () => { - await app.request - .post('/api/admin/environments') - .send({ name: 'test', displayName: 'Test Env' }) - .set('Content-Type', 'application/json') - .expect(201); + // Endpoint to create env does not exists anymore + await db.stores.environmentStore.create({ + name: 'test', + displayName: 'Test Env', + type: 'test', + }); await app.request .post('/api/admin/projects/default/environments') .send({ environment: 'test' }) @@ -59,13 +60,17 @@ test('Should validate environment', async () => { .expect(400); }); -test('Should remove environment to project', async () => { +test('Should remove environment from project', async () => { const name = 'test-delete'; - await app.request - .post('/api/admin/environments') - .send({ name, displayName: 'Test Env' }) - .set('Content-Type', 'application/json') - .expect(201); + // Endpoint to create env does not exists anymore + + await db.stores.environmentStore.create({ + name, + displayName: 'Test Env', + type: 'test', + }); + + // Endpoint to delete project does not exist anymore await app.request .post('/api/admin/projects/default/environments') .send({ environment: name }) diff --git a/src/test/e2e/api/admin/project/feature.strategy.e2e.test.ts b/src/test/e2e/api/admin/project/feature.strategy.e2e.test.ts index c19162cff0..c7f76f3241 100644 --- a/src/test/e2e/api/admin/project/feature.strategy.e2e.test.ts +++ b/src/test/e2e/api/admin/project/feature.strategy.e2e.test.ts @@ -126,11 +126,11 @@ test('Project overview includes environment connected to feature', async () => { expect(res.body.name).toBe('com.test.environment'); expect(res.body.createdAt).toBeTruthy(); }); - await app.request - .post('/api/admin/environments') - .send({ name: 'project-overview', displayName: 'Project Overview' }) - .set('Content-Type', 'application/json') - .expect(201); + await db.stores.environmentStore.create({ + name: 'project-overview', + displayName: 'Project Overview', + type: 'production', + }); await app.request .post('/api/admin/projects/default/environments') .send({ environment: 'project-overview' }) @@ -160,11 +160,11 @@ test('Disconnecting environment from project, removes environment from features expect(res.body.name).toBe('com.test.disconnect.environment'); expect(res.body.createdAt).toBeTruthy(); }); - await app.request - .post('/api/admin/environments') - .send({ name: 'dis-project-overview', displayName: 'Project Overview' }) - .set('Content-Type', 'application/json') - .expect(201); + await db.stores.environmentStore.create({ + name: 'dis-project-overview', + displayName: 'Project Overview', + type: 'production', + }); await app.request .post('/api/admin/projects/default/environments') .send({ environment: 'dis-project-overview' }) @@ -188,14 +188,11 @@ test('Can enable/disable environment for feature with strategies', async () => { const envName = 'enable-feature-environment'; const featureName = 'com.test.enable.environment'; // Create environment - await app.request - .post('/api/admin/environments') - .send({ - name: envName, - displayName: 'Enable feature for environment', - }) - .set('Content-Type', 'application/json') - .expect(201); + await db.stores.environmentStore.create({ + name: envName, + displayName: 'Enable feature for environment', + type: 'production', + }); // Connect environment to project await app.request .post('/api/admin/projects/default/environments') @@ -341,14 +338,11 @@ test('Trying to create toggle under project that does not exist should fail', as test('Can get environment info for feature toggle', async () => { const envName = 'environment-info'; // Create environment - await app.request - .post('/api/admin/environments') - .send({ - name: envName, - displayName: 'Enable feature for environment', - }) - .set('Content-Type', 'application/json') - .expect(201); + await db.stores.environmentStore.create({ + name: envName, + displayName: 'Enable feature for environment', + type: 'production', + }); // Connect environment to project await app.request .post('/api/admin/projects/default/environments') @@ -508,14 +502,11 @@ test('Can add strategy to feature toggle to default env', async () => { const envName = 'default'; const featureName = 'feature.strategy.toggle'; // Create environment - await app.request - .post('/api/admin/environments') - .send({ - name: envName, - displayName: 'Enable feature for environment', - }) - .set('Content-Type', 'application/json') - .expect(201); + await db.stores.environmentStore.create({ + name: envName, + displayName: 'Enable feature for environment', + type: 'production', + }); // Connect environment to project await app.request .post('/api/admin/projects/default/environments') @@ -550,14 +541,11 @@ test('Can add strategy to feature toggle to default env', async () => { test('Can get strategies for feature and environment', async () => { const envName = 'get-strategy'; // Create environment - await app.request - .post('/api/admin/environments') - .send({ - name: envName, - displayName: 'Enable feature for environment', - }) - .set('Content-Type', 'application/json') - .expect(201); + await db.stores.environmentStore.create({ + name: envName, + displayName: 'Enable feature for environment', + type: 'production', + }); // Connect environment to project await app.request .post('/api/admin/projects/default/environments') @@ -608,14 +596,11 @@ test('Getting strategies for environment that does not exist yields 404', async test('Can update a strategy based on id', async () => { const envName = 'feature.update.strategies'; // Create environment - await app.request - .post('/api/admin/environments') - .send({ - name: envName, - displayName: 'Enable feature for environment', - }) - .set('Content-Type', 'application/json') - .expect(201); + await db.stores.environmentStore.create({ + name: envName, + displayName: 'Enable feature for environment', + type: 'production', + }); // Connect environment to project await app.request .post('/api/admin/projects/default/environments') @@ -664,14 +649,11 @@ test('Can update a strategy based on id', async () => { test('Trying to update a non existing feature strategy should yield 404', async () => { const envName = 'feature.non.existing.strategy'; // Create environment - await app.request - .post('/api/admin/environments') - .send({ - name: envName, - displayName: 'Enable feature for environment', - }) - .set('Content-Type', 'application/json') - .expect(201); + await db.stores.environmentStore.create({ + name: envName, + displayName: 'Enable feature for environment', + type: 'production', + }); // Connect environment to project await app.request .post('/api/admin/projects/default/environments') @@ -698,14 +680,11 @@ test('Can patch a strategy based on id', async () => { const featureName = 'feature.patch.strategies'; // Create environment - await app.request - .post('/api/admin/environments') - .send({ - name: envName, - displayName: 'Enable feature for environment', - }) - .set('Content-Type', 'application/json') - .expect(201); + await db.stores.environmentStore.create({ + name: envName, + displayName: 'Enable feature for environment', + type: 'test', + }); // Connect environment to project await app.request .post(`${BASE_URI}/environments`) @@ -754,14 +733,11 @@ test('Can patch a strategy based on id', async () => { test('Trying to get a non existing feature strategy should yield 404', async () => { const envName = 'feature.non.existing.strategy.get'; // Create environment - await app.request - .post('/api/admin/environments') - .send({ - name: envName, - displayName: 'Enable feature for environment', - }) - .set('Content-Type', 'application/json') - .expect(201); + await db.stores.environmentStore.create({ + name: envName, + displayName: 'Enable feature for environment', + type: 'production', + }); // Connect environment to project await app.request .post('/api/admin/projects/default/environments') @@ -786,14 +762,11 @@ test('Can not enable environment for feature without strategies', async () => { const featureName = 'com.test.enable.environment.disabled'; // Create environment - await app.request - .post('/api/admin/environments') - .send({ - name: environment, - displayName: 'Enable feature for environment', - }) - .set('Content-Type', 'application/json') - .expect(201); + await db.stores.environmentStore.create({ + name: environment, + displayName: 'Enable feature for environment', + type: 'test', + }); // Connect environment to project await app.request .post('/api/admin/projects/default/environments') @@ -830,14 +803,11 @@ test('Can delete strategy from feature toggle', async () => { const envName = 'del-strategy'; const featureName = 'feature.strategy.toggle.delete.strategy'; // Create environment - await app.request - .post('/api/admin/environments') - .send({ - name: envName, - displayName: 'Enable feature for environment', - }) - .set('Content-Type', 'application/json') - .expect(201); + await db.stores.environmentStore.create({ + name: envName, + displayName: 'Enable feature for environment', + type: 'test', + }); // Connect environment to project await app.request .post('/api/admin/projects/default/environments') diff --git a/src/test/e2e/api/client/feature.e2e.test.ts b/src/test/e2e/api/client/feature.e2e.test.ts index bad7a2b62d..ada2a5ee72 100644 --- a/src/test/e2e/api/client/feature.e2e.test.ts +++ b/src/test/e2e/api/client/feature.e2e.test.ts @@ -172,9 +172,10 @@ test('Can get strategies for specific environment', async () => { // create new env - await db.stores.environmentStore.upsert({ + await db.stores.environmentStore.create({ name: 'testing', displayName: 'simple test', + type: 'test', }); await app.services.environmentService.addEnvironmentToProject( diff --git a/src/test/e2e/helpers/database-init.ts b/src/test/e2e/helpers/database-init.ts index 50bb93a234..4bc7e143dc 100644 --- a/src/test/e2e/helpers/database-init.ts +++ b/src/test/e2e/helpers/database-init.ts @@ -58,7 +58,7 @@ async function connectProject(store: IFeatureEnvironmentStore): Promise { } async function createEnvironments(store: EnvironmentStore): Promise { - await Promise.all(dbState.environments.map(async (e) => store.upsert(e))); + await Promise.all(dbState.environments.map(async (e) => store.create(e))); } async function setupDatabase(stores) { diff --git a/src/test/e2e/helpers/database.json b/src/test/e2e/helpers/database.json index 8776ad2e67..32f18a10ab 100644 --- a/src/test/e2e/helpers/database.json +++ b/src/test/e2e/helpers/database.json @@ -30,7 +30,11 @@ "environments": [ { "name": ":global:", - "displayName": "Across all environments" + "displayName": "Across all environments", + "type": "production", + "sortOrder": 1, + "enabled": true, + "protected": true } ], "tag_types": [ diff --git a/src/test/e2e/services/environment-service.test.ts b/src/test/e2e/services/environment-service.test.ts index c2c636bbbd..8950f2dd78 100644 --- a/src/test/e2e/services/environment-service.test.ts +++ b/src/test/e2e/services/environment-service.test.ts @@ -19,50 +19,34 @@ afterAll(async () => { await db.destroy(); }); -test('Can create and get environment', async () => { - const created = await service.create({ +test('Can get environment', async () => { + const created = await db.stores.environmentStore.create({ name: 'testenv', displayName: 'Environment for testing', + type: 'production', }); const retrieved = await service.get('testenv'); expect(retrieved).toEqual(created); }); -test('Can delete environment', async () => { - await service.create({ - name: 'testenv', - displayName: 'Environment for testing', - }); - await service.delete('testenv'); - return expect(async () => service.get('testenv')).rejects.toThrow( - NotFoundError, - ); -}); - test('Can get all', async () => { - await service.create({ - name: 'testenv', + await db.stores.environmentStore.create({ + name: 'testenv2', displayName: 'Environment for testing', + type: 'production', }); const environments = await service.getAll(); - expect(environments).toHaveLength(2); // the one we created plus ':global:' -}); - -test('Can update display name', async () => { - await service.create({ - name: 'testenv', - displayName: 'Environment for testing', - }); - - await service.update('testenv', { displayName: 'Different name' }); - const updated = await service.get('testenv'); - expect(updated.displayName).toEqual('Different name'); + expect(environments).toHaveLength(3); // the one we created plus ':global:' }); test('Can connect environment to project', async () => { - await service.create({ name: 'test-connection', displayName: '' }); + await db.stores.environmentStore.create({ + name: 'test-connection', + displayName: '', + type: 'production', + }); await stores.featureToggleStore.create('default', { name: 'test-connection', type: 'release', @@ -87,7 +71,11 @@ test('Can connect environment to project', async () => { }); test('Can remove environment from project', async () => { - await service.create({ name: 'removal-test', displayName: '' }); + await db.stores.environmentStore.create({ + name: 'removal-test', + displayName: '', + type: 'production', + }); await stores.featureToggleStore.create('default', { name: 'removal-test', }); @@ -119,7 +107,11 @@ test('Can remove environment from project', async () => { }); test('Adding same environment twice should throw a NameExistsError', async () => { - await service.create({ name: 'uniqueness-test', displayName: '' }); + await db.stores.environmentStore.create({ + name: 'uniqueness-test', + displayName: '', + type: 'production', + }); await service.removeEnvironmentFromProject('test-connection', 'default'); await service.removeEnvironmentFromProject('removal-test', 'default'); @@ -140,3 +132,10 @@ test('Removing environment not connected to project should be a noop', async () 'default', ), ).resolves); + +test('Trying to get an environment that does not exist throws NotFoundError', async () => { + const envName = 'this-should-not-exist'; + await expect(async () => service.get(envName)).rejects.toThrow( + new NotFoundError(`Could not find environment with name: ${envName}`), + ); +}); diff --git a/src/test/e2e/stores/project-store.e2e.test.ts b/src/test/e2e/stores/project-store.e2e.test.ts index 6ee6c5be0f..aed8b4e7c3 100644 --- a/src/test/e2e/stores/project-store.e2e.test.ts +++ b/src/test/e2e/stores/project-store.e2e.test.ts @@ -121,7 +121,11 @@ test('should add environment to project', async () => { description: 'Blah', }; - await environmentStore.upsert({ name: 'test', displayName: 'Test Env' }); + await environmentStore.create({ + name: 'test', + displayName: 'Test Env', + type: 'production', + }); await projectStore.create(project); await projectStore.addEnvironmentToProject(project.id, 'test'); diff --git a/src/test/fixtures/fake-environment-store.ts b/src/test/fixtures/fake-environment-store.ts index a14d7b6faa..f5422fa20c 100644 --- a/src/test/fixtures/fake-environment-store.ts +++ b/src/test/fixtures/fake-environment-store.ts @@ -23,7 +23,7 @@ export default class FakeEnvironmentStore implements IEnvironmentStore { ); } - async upsert(env: IEnvironment): Promise { + async create(env: IEnvironment): Promise { this.environments = this.environments.filter( (e) => e.name !== env.name, ); @@ -31,6 +31,60 @@ export default class FakeEnvironmentStore implements IEnvironmentStore { return Promise.resolve(env); } + async update( + env: Pick, + name: string, + ): Promise { + const found = this.environments.find( + (en: IEnvironment) => en.name === name, + ); + const idx = this.environments.findIndex( + (en: IEnvironment) => en.name === name, + ); + const updated = { ...found, env }; + + this.environments[idx] = updated; + return Promise.resolve(updated); + } + + async updateSortOrder(id: string, value: number): Promise { + const environment = this.environments.find( + (env: IEnvironment) => env.name === id, + ); + environment.sortOrder = value; + return Promise.resolve(); + } + + async updateProperty( + id: string, + field: string, + value: string | number, + ): Promise { + const environment = this.environments.find( + (env: IEnvironment) => env.name === id, + ); + environment[field] = value; + return Promise.resolve(); + } + + async connectProject( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + environment: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + projectId: string, + ): Promise { + return Promise.reject(new Error('Not implemented')); + } + + async connectFeatures( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + environment: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + projectId: string, + ): Promise { + return Promise.reject(new Error('Not implemented')); + } + async delete(name: string): Promise { this.environments = this.environments.filter((e) => e.name !== name); return Promise.resolve();