mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-24 01:18:01 +02:00
Refactored state-service, schemas, added e2e tests #395
This commit is contained in:
parent
9065c5ee88
commit
a06d2c04bb
@ -8,6 +8,8 @@
|
|||||||
"ecmaVersion": "2017"
|
"ecmaVersion": "2017"
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
|
"no-param-reassign": "error",
|
||||||
|
"no-return-await": "error",
|
||||||
"max-nested-callbacks": "off",
|
"max-nested-callbacks": "off",
|
||||||
"new-cap": [
|
"new-cap": [
|
||||||
"error",
|
"error",
|
||||||
|
87
docs/import-export.md
Normal file
87
docs/import-export.md
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
id: import_export
|
||||||
|
title: Import & Export
|
||||||
|
---
|
||||||
|
|
||||||
|
Unleash supports import and export of feature-toggles and strategies at startup and during runtime. The import mechanism will guarantee that all imported features will be non-archived, as well as updates to strategies and features are included in the event history.
|
||||||
|
|
||||||
|
All import mechanisms support a `drop` parameter which will clean the database before import (all strategies and features will be removed).\
|
||||||
|
**You should never use this in production environments.**
|
||||||
|
|
||||||
|
_since v3.3.0_
|
||||||
|
|
||||||
|
## Runtime import & export
|
||||||
|
|
||||||
|
### State Service
|
||||||
|
|
||||||
|
Unleash returns a StateService when started, you can use this to import and export data at any time.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const unleash = require('unleash-server');
|
||||||
|
|
||||||
|
unleash.start({...})
|
||||||
|
.then(async ({ stateService }) => {
|
||||||
|
const exportedData = await stateService.export({includeStrategies: false, includeFeatureToggles: true});
|
||||||
|
await stateService.import({data: exportedData, userName: 'import', dropBeforeImport: false});
|
||||||
|
await stateService.importFile({file: 'exported-data.yml', userName: 'import', dropBeforeImport: true})
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want the database to be cleaned before import (all strategies and features will be removed), set the `dropBeforeImport` parameter.\
|
||||||
|
**You should never use this in production environments.**
|
||||||
|
|
||||||
|
### API Export
|
||||||
|
|
||||||
|
The api endpoint `/api/admin/state/export` will export feature-toggles and strategies as json by default.\
|
||||||
|
You can customize the export with queryparameters:
|
||||||
|
|
||||||
|
| Parameter | Default | Description |
|
||||||
|
| -------------- | ------- | --------------------------------------------------- |
|
||||||
|
| format | `json` | Export format, either `json` or `yaml` |
|
||||||
|
| download | `false` | If the exported data should be downloaded as a file |
|
||||||
|
| featureToggles | `true` | Include feature-toggles in the exported data |
|
||||||
|
| strategies | `true` | Include strategies in the exported data |
|
||||||
|
|
||||||
|
For example if you want to download all feature-toggles as yaml:
|
||||||
|
|
||||||
|
```
|
||||||
|
/api/admin/state/export?format=yaml&featureToggles=1&download=1
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Import
|
||||||
|
|
||||||
|
You can import feature-toggles and strategies by POSTing to the `/api/admin/state/import` endpoint (keep in mind this will require authentication).\
|
||||||
|
You can either send the data as JSON in the POST-body or send a `file` parameter with `multipart/form-data` (YAML files are also accepted here).
|
||||||
|
|
||||||
|
If you want the database to be cleaned before import (all strategies and features will be removed), specify a `drop` query parameter.\
|
||||||
|
**You should never use this in production environments.**
|
||||||
|
|
||||||
|
Example usage:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/admin/state/import
|
||||||
|
{
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"name": "a-feature-toggle",
|
||||||
|
"enabled": true,
|
||||||
|
"description": "#1 feature-toggle"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Startup import
|
||||||
|
|
||||||
|
### Import files via config parameter
|
||||||
|
|
||||||
|
You can import a json or yaml file via the configuration option `importFile`.
|
||||||
|
|
||||||
|
Example usage: `unleash-server --databaseUrl ... --importFile export.yml`.
|
||||||
|
|
||||||
|
If you want the database to be cleaned before import (all strategies and features will be removed), specify the `dropBeforeImport` option.\
|
||||||
|
**You should never use this in production environments.**
|
||||||
|
|
||||||
|
Example usage: `unleash-server --databaseUrl ... --importFile export.yml --dropBeforeImport`.
|
||||||
|
|
||||||
|
These options can also be passed into the `unleash.start()` entrypoint.
|
@ -143,15 +143,13 @@ class FeatureToggleStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_importFeature(data) {
|
_importFeature(data) {
|
||||||
data = this.eventDataToRow(data);
|
const rowData = this.eventDataToRow(data);
|
||||||
return this.db
|
return this.db(TABLE)
|
||||||
.raw(`? ON CONFLICT (name) DO ?`, [
|
.where({ name: rowData.name })
|
||||||
this.db(TABLE).insert(data),
|
.update(rowData)
|
||||||
this.db
|
.then(result =>
|
||||||
.queryBuilder()
|
result === 0 ? this.db(TABLE).insert(rowData) : result
|
||||||
.update(data)
|
)
|
||||||
.update('archived', 0),
|
|
||||||
])
|
|
||||||
.catch(err =>
|
.catch(err =>
|
||||||
logger.error('Could not import feature, error was: ', err)
|
logger.error('Could not import feature, error was: ', err)
|
||||||
);
|
);
|
||||||
|
@ -38,6 +38,15 @@ class StrategyStore {
|
|||||||
.map(this.rowToStrategy);
|
.map(this.rowToStrategy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getEditableStrategies() {
|
||||||
|
return this.db
|
||||||
|
.select(STRATEGY_COLUMNS)
|
||||||
|
.from(TABLE)
|
||||||
|
.where({ built_in: 0 }) // eslint-disable-line
|
||||||
|
.orderBy('name', 'asc')
|
||||||
|
.map(this.rowToEditableStrategy);
|
||||||
|
}
|
||||||
|
|
||||||
getStrategy(name) {
|
getStrategy(name) {
|
||||||
return this.db
|
return this.db
|
||||||
.first(STRATEGY_COLUMNS)
|
.first(STRATEGY_COLUMNS)
|
||||||
@ -58,6 +67,17 @@ class StrategyStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rowToEditableStrategy(row) {
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundError('No strategy found');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
parameters: row.parameters,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
eventDataToRow(data) {
|
eventDataToRow(data) {
|
||||||
return {
|
return {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@ -93,12 +113,13 @@ class StrategyStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_importStrategy(data) {
|
_importStrategy(data) {
|
||||||
data = this.eventDataToRow(data);
|
const rowData = this.eventDataToRow(data);
|
||||||
return this.db
|
return this.db(TABLE)
|
||||||
.raw(`? ON CONFLICT (name) DO ?`, [
|
.where({ name: rowData.name, built_in: 0 }) // eslint-disable-line
|
||||||
this.db(TABLE).insert(data),
|
.update(rowData)
|
||||||
this.db.queryBuilder().update(data).where(`${TABLE}.built_in`, 0), // eslint-disable-line
|
.then(result =>
|
||||||
])
|
result === 0 ? this.db(TABLE).insert(rowData) : result
|
||||||
|
)
|
||||||
.catch(err =>
|
.catch(err =>
|
||||||
logger.error('Could not import strategy, error was: ', err)
|
logger.error('Could not import strategy, error was: ', err)
|
||||||
);
|
);
|
||||||
|
@ -40,7 +40,11 @@ const featureShema = joi
|
|||||||
.keys({
|
.keys({
|
||||||
name: nameType,
|
name: nameType,
|
||||||
enabled: joi.boolean().default(false),
|
enabled: joi.boolean().default(false),
|
||||||
description: joi.string(),
|
description: joi
|
||||||
|
.string()
|
||||||
|
.allow('')
|
||||||
|
.allow(null)
|
||||||
|
.optional(),
|
||||||
strategies: joi
|
strategies: joi
|
||||||
.array()
|
.array()
|
||||||
.required()
|
.required()
|
||||||
@ -48,10 +52,10 @@ const featureShema = joi
|
|||||||
.items(strategiesSchema),
|
.items(strategiesSchema),
|
||||||
variants: joi
|
variants: joi
|
||||||
.array()
|
.array()
|
||||||
|
.allow(null)
|
||||||
.unique((a, b) => a.name === b.name)
|
.unique((a, b) => a.name === b.name)
|
||||||
.optional()
|
.optional()
|
||||||
.items(variantsSchema)
|
.items(variantsSchema),
|
||||||
.allow(null),
|
|
||||||
})
|
})
|
||||||
.options({ allowUnknown: false, stripUnknown: true });
|
.options({ allowUnknown: false, stripUnknown: true });
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ const moment = require('moment');
|
|||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const upload = multer({ limits: { fileSize: 5242880 } });
|
const upload = multer({ limits: { fileSize: 5242880 } });
|
||||||
|
|
||||||
class ImportController extends Controller {
|
class StateController extends Controller {
|
||||||
constructor(config) {
|
constructor(config) {
|
||||||
super(config);
|
super(config);
|
||||||
this.fileupload('/import', upload.single('file'), this.import, ADMIN);
|
this.fileupload('/import', upload.single('file'), this.import, ADMIN);
|
||||||
@ -46,27 +46,29 @@ class ImportController extends Controller {
|
|||||||
async export(req, res) {
|
async export(req, res) {
|
||||||
const { format } = req.query;
|
const { format } = req.query;
|
||||||
|
|
||||||
let strategies = 'strategies' in req.query;
|
const downloadFile = Boolean(req.query.download);
|
||||||
let featureToggles = 'features' in req.query;
|
let includeStrategies = Boolean(req.query.strategies);
|
||||||
|
let includeFeatureToggles = Boolean(req.query.featureToggles);
|
||||||
|
|
||||||
if (!strategies && !featureToggles) {
|
// if neither is passed as query argument, export both
|
||||||
strategies = true;
|
if (!includeStrategies && !includeFeatureToggles) {
|
||||||
featureToggles = true;
|
includeStrategies = true;
|
||||||
|
includeFeatureToggles = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await this.config.stateService.export({
|
const data = await this.config.stateService.export({
|
||||||
strategies,
|
includeStrategies,
|
||||||
featureToggles,
|
includeFeatureToggles,
|
||||||
});
|
});
|
||||||
const timestamp = moment().format('YYYY-MM-DD_HH-mm-ss');
|
const timestamp = moment().format('YYYY-MM-DD_HH-mm-ss');
|
||||||
if (format === 'yaml') {
|
if (format === 'yaml') {
|
||||||
if ('download' in req.query) {
|
if (downloadFile) {
|
||||||
res.attachment(`export-${timestamp}.yml`);
|
res.attachment(`export-${timestamp}.yml`);
|
||||||
}
|
}
|
||||||
res.type('yaml').send(YAML.safeDump(data));
|
res.type('yaml').send(YAML.safeDump(data));
|
||||||
} else {
|
} else {
|
||||||
if ('download' in req.query) {
|
if (downloadFile) {
|
||||||
res.attachment(`export-${timestamp}.json`);
|
res.attachment(`export-${timestamp}.json`);
|
||||||
}
|
}
|
||||||
res.json(data);
|
res.json(data);
|
||||||
@ -77,4 +79,4 @@ class ImportController extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = ImportController;
|
module.exports = StateController;
|
||||||
|
@ -6,7 +6,11 @@ const { nameType } = require('./util');
|
|||||||
const strategySchema = joi.object().keys({
|
const strategySchema = joi.object().keys({
|
||||||
name: nameType,
|
name: nameType,
|
||||||
editable: joi.boolean().default(true),
|
editable: joi.boolean().default(true),
|
||||||
description: joi.string(),
|
description: joi
|
||||||
|
.string()
|
||||||
|
.allow(null)
|
||||||
|
.allow('')
|
||||||
|
.optional(),
|
||||||
parameters: joi
|
parameters: joi
|
||||||
.array()
|
.array()
|
||||||
.required()
|
.required()
|
||||||
@ -14,7 +18,11 @@ const strategySchema = joi.object().keys({
|
|||||||
joi.object().keys({
|
joi.object().keys({
|
||||||
name: joi.string().required(),
|
name: joi.string().required(),
|
||||||
type: joi.string().required(),
|
type: joi.string().required(),
|
||||||
description: joi.string().allow(''),
|
description: joi
|
||||||
|
.string()
|
||||||
|
.allow(null)
|
||||||
|
.allow('')
|
||||||
|
.optional(),
|
||||||
required: joi.boolean(),
|
required: joi.boolean(),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
@ -40,9 +40,9 @@ async function createApp(options) {
|
|||||||
config.stateService = stateService;
|
config.stateService = stateService;
|
||||||
if (config.importFile) {
|
if (config.importFile) {
|
||||||
await stateService.importFile({
|
await stateService.importFile({
|
||||||
importFile: config.importFile,
|
file: config.importFile,
|
||||||
dropBeforeImport: config.dropBeforeImport,
|
dropBeforeImport: config.dropBeforeImport,
|
||||||
userName: 'importer',
|
userName: 'import',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,7 +50,7 @@ async function createApp(options) {
|
|||||||
logger.info(`Unleash started on port ${server.address().port}`)
|
logger.info(`Unleash started on port ${server.address().port}`)
|
||||||
);
|
);
|
||||||
|
|
||||||
return await new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
server.on('listening', () =>
|
server.on('listening', () =>
|
||||||
resolve({
|
resolve({
|
||||||
app,
|
app,
|
||||||
|
@ -6,7 +6,7 @@ const mime = require('mime');
|
|||||||
const { featureShema } = require('./routes/admin-api/feature-schema');
|
const { featureShema } = require('./routes/admin-api/feature-schema');
|
||||||
const strategySchema = require('./routes/admin-api/strategy-schema');
|
const strategySchema = require('./routes/admin-api/strategy-schema');
|
||||||
const getLogger = require('./logger');
|
const getLogger = require('./logger');
|
||||||
const yaml = require('js-yaml');
|
const YAML = require('js-yaml');
|
||||||
const {
|
const {
|
||||||
FEATURE_IMPORT,
|
FEATURE_IMPORT,
|
||||||
DROP_FEATURES,
|
DROP_FEATURES,
|
||||||
@ -17,6 +17,7 @@ const {
|
|||||||
const logger = getLogger('state-service.js');
|
const logger = getLogger('state-service.js');
|
||||||
|
|
||||||
const dataSchema = joi.object().keys({
|
const dataSchema = joi.object().keys({
|
||||||
|
version: joi.number(),
|
||||||
features: joi
|
features: joi
|
||||||
.array()
|
.array()
|
||||||
.optional()
|
.optional()
|
||||||
@ -27,39 +28,36 @@ const dataSchema = joi.object().keys({
|
|||||||
.items(strategySchema),
|
.items(strategySchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function readFile(file) {
|
||||||
|
return new Promise((resolve, reject) =>
|
||||||
|
fs.readFile(file, (err, v) => (err ? reject(err) : resolve(v)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFile(file, data) {
|
||||||
|
return mime.lookup(file) === 'text/yaml'
|
||||||
|
? YAML.safeLoad(data)
|
||||||
|
: JSON.parse(data);
|
||||||
|
}
|
||||||
|
|
||||||
class StateService {
|
class StateService {
|
||||||
constructor(config) {
|
constructor(config) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
async importFile({ importFile, dropBeforeImport, userName }) {
|
importFile({ file, dropBeforeImport, userName }) {
|
||||||
let data = await new Promise((resolve, reject) =>
|
return readFile(file)
|
||||||
fs.readFile(importFile, (err, v) =>
|
.then(data => parseFile(file, data))
|
||||||
err ? reject(err) : resolve(v)
|
.then(data => this.import({ data, userName, dropBeforeImport }));
|
||||||
)
|
|
||||||
);
|
|
||||||
if (mime.lookup(importFile) === 'text/yaml') {
|
|
||||||
data = yaml.safeLoad(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.import({
|
|
||||||
data,
|
|
||||||
dropBeforeImport,
|
|
||||||
userName,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async import({ data, userName, dropBeforeImport }) {
|
async import({ data, userName, dropBeforeImport }) {
|
||||||
const { eventStore } = this.config.stores;
|
const { eventStore } = this.config.stores;
|
||||||
|
|
||||||
if (typeof data === 'string') {
|
const importData = await joi.validate(data, dataSchema);
|
||||||
data = JSON.parse(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
data = await joi.validate(data, dataSchema);
|
if (importData.features) {
|
||||||
|
logger.info(`Importing ${importData.features.length} features`);
|
||||||
if (data.features) {
|
|
||||||
logger.info(`Importing ${data.features.length} features`);
|
|
||||||
if (dropBeforeImport) {
|
if (dropBeforeImport) {
|
||||||
logger.info(`Dropping existing features`);
|
logger.info(`Dropping existing features`);
|
||||||
await eventStore.store({
|
await eventStore.store({
|
||||||
@ -68,17 +66,19 @@ class StateService {
|
|||||||
data: { name: 'all-features' },
|
data: { name: 'all-features' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const feature of data.features) {
|
await Promise.all(
|
||||||
await eventStore.store({
|
importData.features.map(feature =>
|
||||||
type: FEATURE_IMPORT,
|
eventStore.store({
|
||||||
createdBy: userName,
|
type: FEATURE_IMPORT,
|
||||||
data: feature,
|
createdBy: userName,
|
||||||
});
|
data: feature,
|
||||||
}
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.strategies) {
|
if (importData.strategies) {
|
||||||
logger.info(`Importing ${data.strategies.length} strategies`);
|
logger.info(`Importing ${importData.strategies.length} strategies`);
|
||||||
if (dropBeforeImport) {
|
if (dropBeforeImport) {
|
||||||
logger.info(`Dropping existing strategies`);
|
logger.info(`Dropping existing strategies`);
|
||||||
await eventStore.store({
|
await eventStore.store({
|
||||||
@ -87,35 +87,33 @@ class StateService {
|
|||||||
data: { name: 'all-strategies' },
|
data: { name: 'all-strategies' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const strategy of data.strategies) {
|
await Promise.all(
|
||||||
await eventStore.store({
|
importData.strategies.map(strategy =>
|
||||||
type: STRATEGY_IMPORT,
|
eventStore.store({
|
||||||
createdBy: userName,
|
type: STRATEGY_IMPORT,
|
||||||
data: strategy,
|
createdBy: userName,
|
||||||
});
|
data: strategy,
|
||||||
}
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async export({ strategies, featureToggles }) {
|
async export({ includeFeatureToggles = true, includeStrategies = true }) {
|
||||||
const { featureToggleStore, strategyStore } = this.config.stores;
|
const { featureToggleStore, strategyStore } = this.config.stores;
|
||||||
const result = {};
|
|
||||||
|
|
||||||
if (featureToggles) {
|
return Promise.all([
|
||||||
result.features = await featureToggleStore.getFeatures();
|
includeFeatureToggles
|
||||||
}
|
? featureToggleStore.getFeatures()
|
||||||
|
: Promise.resolve(),
|
||||||
if (strategies) {
|
includeStrategies
|
||||||
result.strategies = (await strategyStore.getStrategies())
|
? strategyStore.getEditableStrategies()
|
||||||
.filter(strat => strat.editable)
|
: Promise.resolve(),
|
||||||
.map(strat => {
|
]).then(([features, strategies]) => ({
|
||||||
strat = Object.assign({}, strat);
|
version: 1,
|
||||||
delete strat.editable;
|
features,
|
||||||
return strat;
|
strategies,
|
||||||
});
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -130,7 +130,7 @@ test('should export featureToggles', async t => {
|
|||||||
|
|
||||||
stores.featureToggleStore.addFeature({ name: 'a-feature' });
|
stores.featureToggleStore.addFeature({ name: 'a-feature' });
|
||||||
|
|
||||||
const data = await stateService.export({ featureToggles: true });
|
const data = await stateService.export({ includeFeatureToggles: true });
|
||||||
|
|
||||||
t.is(data.features.length, 1);
|
t.is(data.features.length, 1);
|
||||||
t.is(data.features[0].name, 'a-feature');
|
t.is(data.features[0].name, 'a-feature');
|
||||||
@ -141,7 +141,7 @@ test('should export strategies', async t => {
|
|||||||
|
|
||||||
stores.strategyStore.addStrategy({ name: 'a-strategy', editable: true });
|
stores.strategyStore.addStrategy({ name: 'a-strategy', editable: true });
|
||||||
|
|
||||||
const data = await stateService.export({ strategies: true });
|
const data = await stateService.export({ includeStrategies: true });
|
||||||
|
|
||||||
t.is(data.strategies.length, 1);
|
t.is(data.strategies.length, 1);
|
||||||
t.is(data.strategies[0].name, 'a-strategy');
|
t.is(data.strategies[0].name, 'a-strategy');
|
||||||
|
80
test/e2e/api/admin/state.e2e.test.js
Normal file
80
test/e2e/api/admin/state.e2e.test.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('ava');
|
||||||
|
const { setupApp } = require('./../../helpers/test-helper');
|
||||||
|
const importData = require('../../../examples/import.json');
|
||||||
|
|
||||||
|
test.serial('exports strategies and features as json by default', async t => {
|
||||||
|
t.plan(2);
|
||||||
|
const { request, destroy } = await setupApp('state_api_serial');
|
||||||
|
return request
|
||||||
|
.get('/api/admin/state/export')
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(200)
|
||||||
|
.expect(res => {
|
||||||
|
t.true('features' in res.body);
|
||||||
|
t.true('strategies' in res.body);
|
||||||
|
})
|
||||||
|
.then(destroy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('exports strategies and features as yaml', async t => {
|
||||||
|
t.plan(0);
|
||||||
|
const { request, destroy } = await setupApp('state_api_serial');
|
||||||
|
return request
|
||||||
|
.get('/api/admin/state/export?format=yaml')
|
||||||
|
.expect('Content-Type', /yaml/)
|
||||||
|
.expect(200)
|
||||||
|
.then(destroy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('exports strategies and features as attachment', async t => {
|
||||||
|
t.plan(0);
|
||||||
|
const { request, destroy } = await setupApp('state_api_serial');
|
||||||
|
return request
|
||||||
|
.get('/api/admin/state/export?download=1')
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect('Content-Disposition', /attachment/)
|
||||||
|
.expect(200)
|
||||||
|
.then(destroy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('imports strategies and features', async t => {
|
||||||
|
t.plan(0);
|
||||||
|
const { request, destroy } = await setupApp('state_api_serial');
|
||||||
|
return request
|
||||||
|
.post('/api/admin/state/import')
|
||||||
|
.send(importData)
|
||||||
|
.expect(202)
|
||||||
|
.then(destroy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('does not not accept gibberish', async t => {
|
||||||
|
t.plan(0);
|
||||||
|
const { request, destroy } = await setupApp('state_api_serial');
|
||||||
|
return request
|
||||||
|
.post('/api/admin/state/import')
|
||||||
|
.send({ features: 'nonsense' })
|
||||||
|
.expect(400)
|
||||||
|
.then(destroy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('imports strategies and features from json file', async t => {
|
||||||
|
t.plan(0);
|
||||||
|
const { request, destroy } = await setupApp('state_api_serial');
|
||||||
|
return request
|
||||||
|
.post('/api/admin/state/import')
|
||||||
|
.attach('file', 'test/examples/import.json')
|
||||||
|
.expect(202)
|
||||||
|
.then(destroy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('imports strategies and features from yaml file', async t => {
|
||||||
|
t.plan(0);
|
||||||
|
const { request, destroy } = await setupApp('state_api_serial');
|
||||||
|
return request
|
||||||
|
.post('/api/admin/state/import')
|
||||||
|
.attach('file', 'test/examples/import.yml')
|
||||||
|
.expect(202)
|
||||||
|
.then(destroy);
|
||||||
|
});
|
@ -6,6 +6,7 @@ const supertest = require('supertest');
|
|||||||
|
|
||||||
const getApp = require('../../../lib/app');
|
const getApp = require('../../../lib/app');
|
||||||
const dbInit = require('./database-init');
|
const dbInit = require('./database-init');
|
||||||
|
const StateService = require('../../../lib/state-service');
|
||||||
|
|
||||||
const { EventEmitter } = require('events');
|
const { EventEmitter } = require('events');
|
||||||
const eventBus = new EventEmitter();
|
const eventBus = new EventEmitter();
|
||||||
@ -18,6 +19,7 @@ function createApp(stores, adminAuthentication = 'none', preHook) {
|
|||||||
adminAuthentication,
|
adminAuthentication,
|
||||||
secret: 'super-secret',
|
secret: 'super-secret',
|
||||||
sessionAge: 4000,
|
sessionAge: 4000,
|
||||||
|
stateService: new StateService({ stores }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
72
test/examples/import.json
Normal file
72
test/examples/import.json
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"strategies": [
|
||||||
|
{
|
||||||
|
"name": "usersWithEmail",
|
||||||
|
"description": "Active for users defined in the comma-separated emails-parameter.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "emails",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "country",
|
||||||
|
"description": "Active for country.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "countries",
|
||||||
|
"type": "list"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"name": "featureX",
|
||||||
|
"description": "the #1 feature",
|
||||||
|
"enabled": true,
|
||||||
|
"strategies": [
|
||||||
|
{
|
||||||
|
"name": "default",
|
||||||
|
"parameters": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "featureA",
|
||||||
|
"description": "soon to be the #1 feature",
|
||||||
|
"enabled": false,
|
||||||
|
"strategies": [
|
||||||
|
{
|
||||||
|
"name": "baz",
|
||||||
|
"parameters": {
|
||||||
|
"foo": "bar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "featureArchivedX",
|
||||||
|
"description": "the #1 feature",
|
||||||
|
"enabled": true,
|
||||||
|
"strategies": [
|
||||||
|
{
|
||||||
|
"name": "default",
|
||||||
|
"parameters": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "feature.with.variants",
|
||||||
|
"description": "A feature toggle with watiants",
|
||||||
|
"enabled": true,
|
||||||
|
"archived": false,
|
||||||
|
"strategies": [{ "name": "default" }],
|
||||||
|
"variants": [
|
||||||
|
{ "name": "control", "weight": 50 },
|
||||||
|
{ "name": "new", "weight": 50 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
42
test/examples/import.yml
Normal file
42
test/examples/import.yml
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
strategies:
|
||||||
|
- name: usersWithEmail
|
||||||
|
description: Active for users defined in the comma-separated emails-parameter.
|
||||||
|
parameters:
|
||||||
|
- name: emails
|
||||||
|
type: string
|
||||||
|
- name: country
|
||||||
|
description: Active for country.
|
||||||
|
parameters:
|
||||||
|
- name: countries
|
||||||
|
type: list
|
||||||
|
features:
|
||||||
|
- name: featureX
|
||||||
|
description: 'the #1 feature'
|
||||||
|
enabled: true
|
||||||
|
strategies:
|
||||||
|
- name: default
|
||||||
|
parameters: {}
|
||||||
|
- name: featureA
|
||||||
|
description: 'soon to be the #1 feature'
|
||||||
|
enabled: false
|
||||||
|
strategies:
|
||||||
|
- name: baz
|
||||||
|
parameters:
|
||||||
|
foo: bar
|
||||||
|
- name: featureArchivedX
|
||||||
|
description: 'the #1 feature'
|
||||||
|
enabled: true
|
||||||
|
strategies:
|
||||||
|
- name: default
|
||||||
|
parameters: {}
|
||||||
|
- name: feature.with.variants
|
||||||
|
description: A feature toggle with watiants
|
||||||
|
enabled: true
|
||||||
|
archived: false
|
||||||
|
strategies:
|
||||||
|
- name: default
|
||||||
|
variants:
|
||||||
|
- name: control
|
||||||
|
weight: 50
|
||||||
|
- name: new
|
||||||
|
weight: 50
|
2
test/fixtures/fake-strategies-store.js
vendored
2
test/fixtures/fake-strategies-store.js
vendored
@ -7,6 +7,8 @@ module.exports = () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
getStrategies: () => Promise.resolve(_strategies),
|
getStrategies: () => Promise.resolve(_strategies),
|
||||||
|
getEditableStrategies: () =>
|
||||||
|
Promise.resolve(_strategies.filter(s => s.editable)),
|
||||||
getStrategy: name => {
|
getStrategy: name => {
|
||||||
const strategy = _strategies.find(s => s.name === name);
|
const strategy = _strategies.find(s => s.name === name);
|
||||||
if (strategy) {
|
if (strategy) {
|
||||||
|
Loading…
Reference in New Issue
Block a user