mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-17 13:46:47 +02:00
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 <chriswk@getunleash.ai> Co-authored-by: Christopher Kolstad <chriswk@getunleash.ai>
This commit is contained in:
parent
1b1bb97715
commit
26c9d53b89
@ -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<IEnvironment[]> {
|
||||
const rows = await this.db<IEnvironmentsTable>(TABLE).select('*');
|
||||
const rows = await this.db<IEnvironmentsTable>(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<IEnvironment> {
|
||||
async updateProperty(
|
||||
id: string,
|
||||
field: string,
|
||||
value: string | number,
|
||||
): Promise<void> {
|
||||
await this.db<IEnvironmentsTable>(TABLE)
|
||||
.insert(mapInput(env))
|
||||
.onConflict('name')
|
||||
.merge();
|
||||
return env;
|
||||
.update({
|
||||
[field]: value,
|
||||
})
|
||||
.where({ name: id, protected: false });
|
||||
}
|
||||
|
||||
async updateSortOrder(id: string, value: number): Promise<void> {
|
||||
await this.db<IEnvironmentsTable>(TABLE)
|
||||
.update({
|
||||
sort_order: value,
|
||||
})
|
||||
.where({ name: id });
|
||||
}
|
||||
|
||||
async update(
|
||||
env: Pick<IEnvironment, 'displayName' | 'type' | 'protected'>,
|
||||
name: string,
|
||||
): Promise<IEnvironment> {
|
||||
const updatedEnv = await this.db<IEnvironmentsTable>(TABLE)
|
||||
.update(snakeCaseKeys(env))
|
||||
.where({ name, protected: false })
|
||||
.returning<IEnvironmentsTable>(COLUMNS);
|
||||
|
||||
return mapRow(updatedEnv[0]);
|
||||
}
|
||||
|
||||
async create(env: IEnvironmentCreate): Promise<IEnvironment> {
|
||||
const row = await this.db<IEnvironmentsTable>(TABLE)
|
||||
.insert(snakeCaseKeys(env))
|
||||
.returning<IEnvironmentsTable>(COLUMNS);
|
||||
|
||||
return mapRow(row[0]);
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<void> {
|
||||
await this.db(TABLE).where({ name }).del();
|
||||
await this.db(TABLE).where({ name, protected: false }).del();
|
||||
}
|
||||
|
||||
destroy(): void {}
|
||||
|
@ -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<void> {
|
||||
@ -35,12 +35,30 @@ export class EnvironmentsController extends Controller {
|
||||
res.status(200).json({ version: 1, environments });
|
||||
}
|
||||
|
||||
async createEnv(
|
||||
req: Request<any, any, IEnvironment, any>,
|
||||
async updateSortOrder(
|
||||
req: Request<any, any, ISortOrder, any>,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
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<EnvironmentParam, any, any, any>,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const { name } = req.params;
|
||||
await this.service.toggleEnvironment(name, true);
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
async toggleEnvironmentOff(
|
||||
req: Request<EnvironmentParam, any, any, any>,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
const { name } = req.params;
|
||||
|
||||
const env = await this.service.get(name);
|
||||
res.status(200).json(env);
|
||||
}
|
||||
|
||||
async updateEnv(
|
||||
req: Request<EnvironmentParam, any, IEnvironment, any>,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const { name } = req.params;
|
||||
const env = await this.service.update(name, req.body);
|
||||
res.status(200).json(env);
|
||||
}
|
||||
|
||||
async deleteEnv(
|
||||
req: Request<EnvironmentParam, any, any, any>,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const { name } = req.params;
|
||||
await this.service.delete(name);
|
||||
res.status(200).end();
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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<void> {
|
||||
return this.environmentStore.delete(name);
|
||||
async updateSortOrder(sortOrder: ISortOrder): Promise<void> {
|
||||
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<IEnvironment> {
|
||||
await environmentSchema.validateAsync(env);
|
||||
return this.environmentStore.upsert(env);
|
||||
}
|
||||
|
||||
async update(
|
||||
name: string,
|
||||
env: Pick<IEnvironment, 'displayName'>,
|
||||
): Promise<IEnvironment> {
|
||||
async toggleEnvironment(name: string, value: boolean): Promise<void> {
|
||||
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}`);
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -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',
|
||||
|
@ -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 {
|
||||
|
@ -1,6 +1,17 @@
|
||||
import { IEnvironment } from '../model';
|
||||
import { IEnvironment, IEnvironmentCreate } from '../model';
|
||||
import { Store } from './store';
|
||||
|
||||
export interface IEnvironmentStore extends Store<IEnvironment, string> {
|
||||
upsert(env: IEnvironment): Promise<IEnvironment>;
|
||||
exists(name: string): Promise<boolean>;
|
||||
create(env: IEnvironmentCreate): Promise<IEnvironment>;
|
||||
update(
|
||||
env: Pick<IEnvironment, 'displayName' | 'type' | 'protected'>,
|
||||
name: string,
|
||||
): Promise<IEnvironment>;
|
||||
updateProperty(
|
||||
id: string,
|
||||
field: string,
|
||||
value: string | number | boolean,
|
||||
): Promise<void>;
|
||||
updateSortOrder(id: string, value: number): Promise<void>;
|
||||
}
|
||||
|
25
src/lib/util/snakeCase.test.ts
Normal file
25
src/lib/util/snakeCase.test.ts
Normal file
@ -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);
|
||||
});
|
27
src/lib/util/snakeCase.ts
Normal file
27
src/lib/util/snakeCase.ts
Normal file
@ -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;
|
||||
};
|
@ -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,
|
||||
};
|
21
src/migrations/20210908100701-add-enabled-to-environments.js
Normal file
21
src/migrations/20210908100701-add-enabled-to-environments.js
Normal file
@ -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,
|
||||
};
|
@ -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,
|
||||
};
|
@ -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);
|
||||
});
|
||||
|
@ -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 })
|
||||
|
@ -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')
|
||||
|
@ -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(
|
||||
|
@ -58,7 +58,7 @@ async function connectProject(store: IFeatureEnvironmentStore): Promise<void> {
|
||||
}
|
||||
|
||||
async function createEnvironments(store: EnvironmentStore): Promise<void> {
|
||||
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) {
|
||||
|
@ -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": [
|
||||
|
@ -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}`),
|
||||
);
|
||||
});
|
||||
|
@ -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');
|
||||
|
56
src/test/fixtures/fake-environment-store.ts
vendored
56
src/test/fixtures/fake-environment-store.ts
vendored
@ -23,7 +23,7 @@ export default class FakeEnvironmentStore implements IEnvironmentStore {
|
||||
);
|
||||
}
|
||||
|
||||
async upsert(env: IEnvironment): Promise<IEnvironment> {
|
||||
async create(env: IEnvironment): Promise<IEnvironment> {
|
||||
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<IEnvironment, 'displayName' | 'type' | 'protected'>,
|
||||
name: string,
|
||||
): Promise<IEnvironment> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
return Promise.reject(new Error('Not implemented'));
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<void> {
|
||||
this.environments = this.environments.filter((e) => e.name !== name);
|
||||
return Promise.resolve();
|
||||
|
Loading…
Reference in New Issue
Block a user