1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-31 01:16:01 +02:00

Refactor routes setup, move test files, cleanup legacy

This commit is contained in:
sveisvei 2017-06-28 10:20:22 +02:00
parent 5771bcb1bd
commit be4852f63a
59 changed files with 6614 additions and 1186 deletions

View File

@ -68,14 +68,11 @@ module.exports = function(config) {
}
// Setup API routes
const apiRouter = express.Router(); // eslint-disable-line new-cap
routes.createAPI(apiRouter, config);
app.use(`${baseUriPath}/api/`, apiRouter);
// Setup deprecated routes
const router = express.Router(); // eslint-disable-line new-cap
routes.createLegacy(router, config);
app.use(baseUriPath, router);
const middleware = routes.router(config);
if (!middleware) {
throw new Error('Routes invalid');
}
app.use(`${baseUriPath}/`, middleware);
if (process.env.NODE_ENV !== 'production') {
app.use(errorHandler());

View File

@ -1,11 +1,11 @@
'use strict';
const test = require('ava');
const { test } = require('ava');
const express = require('express');
const proxyquire = require('proxyquire');
const getApp = proxyquire('./app', {
'./routes': {
createAPI: () => {},
createLegacy: () => {},
router: () => express.Router(),
},
});

View File

View File

@ -0,0 +1,39 @@
'use strict';
const { test } = require('ava');
const store = require('./../../../test/fixtures/store');
const supertest = require('supertest');
const logger = require('../../logger');
const getApp = require('../../app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
test.beforeEach(() => {
logger.setLevel('FATAL');
});
function getSetup() {
const stores = store.createStores();
const app = getApp({
baseUriPath: '',
stores,
eventBus,
});
return {
request: supertest(app),
stores,
};
}
test('should return list of client applications', t => {
t.plan(1);
const { request } = getSetup();
return request
.get('/api/admin/metrics/applications')
.expect(200)
.expect(res => {
t.true(res.body.applications.length === 0);
});
});

View File

@ -0,0 +1,46 @@
'use strict';
const { Router } = require('express');
const logger = require('../../logger');
const { FEATURE_REVIVED } = require('../../event-type');
const ValidationError = require('../../error/validation-error');
const validateRequest = require('../../error/validate-request');
const handleErrors = (req, res, error) => {
switch (error.constructor) {
case ValidationError:
return res.status(400).json(req.validationErrors()).end();
default:
logger.error('Server failed executing request', error);
return res.status(500).end();
}
};
module.exports.router = function(config) {
const { featureToggleStore, eventStore } = config.stores;
const router = Router();
router.get('/features', (req, res) => {
featureToggleStore.getArchivedFeatures().then(archivedFeatures => {
res.json({ features: archivedFeatures });
});
});
router.post('/revive/:name', (req, res) => {
req.checkParams('name', 'Name is required').notEmpty();
validateRequest(req)
.then(() =>
eventStore.store({
type: FEATURE_REVIVED,
createdBy: req.connection.remoteAddress,
data: { name: req.params.name },
})
)
.then(() => res.status(200).end())
.catch(error => handleErrors(req, res, error));
});
return router;
};

View File

@ -0,0 +1,5 @@
'use strict';
const { test } = require('ava');
test.todo('should unit test archive');

View File

@ -1,19 +1,22 @@
'use strict';
const eventDiffer = require('../event-differ');
const { Router } = require('express');
const eventDiffer = require('../../event-differ');
const version = 1;
module.exports = function (app, config) {
module.exports.router = function(config) {
const { eventStore } = config.stores;
const router = Router();
app.get('/events', (req, res) => {
router.get('/', (req, res) => {
eventStore.getEvents().then(events => {
eventDiffer.addDiffs(events);
res.json({ version, events });
});
});
app.get('/events/:name', (req, res) => {
router.get('/:name', (req, res) => {
const toggleName = req.params.name;
eventStore.getEventsFilterByName(toggleName).then(events => {
if (events) {
@ -27,4 +30,6 @@ module.exports = function (app, config) {
}
});
});
return router;
};

View File

@ -0,0 +1,39 @@
'use strict';
const { test } = require('ava');
const store = require('./../../../test/fixtures/store');
const supertest = require('supertest');
const logger = require('../../logger');
const getApp = require('../../app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`;
const stores = store.createStores();
const app = getApp({
baseUriPath: base,
stores,
eventBus,
});
return { base, eventStore: stores.eventStore, request: supertest(app) };
}
test.beforeEach(() => {
logger.setLevel('FATAL');
});
test('should get empty events list via admin', t => {
t.plan(1);
const { request, base } = getSetup();
return request
.get(`${base}/api/admin/events`)
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.true(res.body.events.length === 0);
});
});

View File

@ -0,0 +1,196 @@
'use strict';
const { Router } = require('express');
const joi = require('joi');
const logger = require('../../logger');
const {
FEATURE_CREATED,
FEATURE_UPDATED,
FEATURE_ARCHIVED,
} = require('../../event-type');
const NameExistsError = require('../../error/name-exists-error');
const NotFoundError = require('../../error/notfound-error');
const ValidationError = require('../../error/validation-error.js');
const validateRequest = require('../../error/validate-request');
const extractUser = require('../../extract-user');
const handleErrors = (req, res, error) => {
logger.warn('Error creating or updating feature', error);
switch (error.constructor) {
case NotFoundError:
return res.status(404).end();
case NameExistsError:
return res
.status(403)
.json([
{
msg:
'A feature with this name already exists. Try re-activating it from the archive.',
},
])
.end();
case ValidationError:
return res.status(400).json(req.validationErrors()).end();
default:
logger.error('Server failed executing request', error);
return res.status(500).end();
}
};
const strategiesSchema = joi.object().keys({
name: joi.string().regex(/^[a-zA-Z0-9\\.\\-]{3,100}$/).required(),
parameters: joi.object(),
});
function validateStrategy(featureToggle) {
return new Promise((resolve, reject) => {
if (
!featureToggle.strategies ||
featureToggle.strategies.length === 0
) {
return reject(
new ValidationError('You must define at least one strategy')
);
}
featureToggle.strategies = featureToggle.strategies.map(
strategyConfig => {
const result = joi.validate(strategyConfig, strategiesSchema);
if (result.error) {
throw result.error;
}
return result.value;
}
);
return resolve(featureToggle);
});
}
const version = 1;
module.exports.router = function(config) {
const { featureToggleStore, eventStore } = config.stores;
const router = Router();
router.get('/', (req, res) => {
featureToggleStore
.getFeatures()
.then(features => res.json({ version, features }));
});
router.get('/:featureName', (req, res) => {
featureToggleStore
.getFeature(req.params.featureName)
.then(feature => res.json(feature).end())
.catch(() =>
res.status(404).json({ error: 'Could not find feature' })
);
});
function validateUniqueName(req) {
return new Promise((resolve, reject) => {
featureToggleStore
.getFeature(req.body.name)
.then(() =>
reject(new NameExistsError('Feature name already exist'))
)
.catch(() => resolve(req));
});
}
router.post('/validate', (req, res) => {
req.checkBody('name', 'Name is required').notEmpty();
req
.checkBody('name', 'Name must match format ^[0-9a-zA-Z\\.\\-]+$')
.matches(/^[0-9a-zA-Z\\.\\-]+$/i);
validateRequest(req)
.then(validateUniqueName)
.then(() => res.status(201).end())
.catch(error => handleErrors(req, res, error));
});
router.post('/', (req, res) => {
req.checkBody('name', 'Name is required').notEmpty();
req
.checkBody('name', 'Name must match format ^[0-9a-zA-Z\\.\\-]+$')
.matches(/^[0-9a-zA-Z\\.\\-]+$/i);
const userName = extractUser(req);
validateRequest(req)
.then(validateUniqueName)
.then(_req => _req.body)
.then(validateStrategy)
.then(featureToggle =>
eventStore.store({
type: FEATURE_CREATED,
createdBy: userName,
data: featureToggle,
})
)
.then(() => res.status(201).end())
.catch(error => handleErrors(req, res, error));
});
router.put('/:featureName', (req, res) => {
const featureName = req.params.featureName;
const userName = extractUser(req);
const updatedFeature = req.body;
updatedFeature.name = featureName;
featureToggleStore
.getFeature(featureName)
.then(() => validateStrategy(updatedFeature))
.then(() =>
eventStore.store({
type: FEATURE_UPDATED,
createdBy: userName,
data: updatedFeature,
})
)
.then(() => res.status(200).end())
.catch(error => handleErrors(req, res, error));
});
router.post('/:featureName/toggle', (req, res) => {
const featureName = req.params.featureName;
const userName = extractUser(req);
featureToggleStore
.getFeature(featureName)
.then(feature => {
feature.enabled = !feature.enabled;
return eventStore.store({
type: FEATURE_UPDATED,
createdBy: userName,
data: feature,
});
})
.then(() => res.status(200).end())
.catch(error => handleErrors(req, res, error));
});
router.delete('/:featureName', (req, res) => {
const featureName = req.params.featureName;
const userName = extractUser(req);
featureToggleStore
.getFeature(featureName)
.then(() =>
eventStore.store({
type: FEATURE_ARCHIVED,
createdBy: userName,
data: {
name: featureName,
},
})
)
.then(() => res.status(200).end())
.catch(error => handleErrors(req, res, error));
});
return router;
};

View File

@ -1,19 +1,19 @@
'use strict';
const test = require('ava');
const store = require('./fixtures/store');
const { test } = require('ava');
const store = require('./../../../test/fixtures/store');
const supertest = require('supertest');
const logger = require('../../../lib/logger');
const getApp = require('../../../lib/app');
const logger = require('../../logger');
const getApp = require('../../app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
test.beforeEach(() => {
test.beforeEach(() => {
logger.setLevel('FATAL');
});
function getSetup () {
function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`;
const stores = store.createStores();
const app = getApp({
@ -29,60 +29,74 @@ function getSetup () {
};
}
test('should get empty getFeatures', t => {
test('should get empty getFeatures via admin', t => {
t.plan(1);
const { request, base } = getSetup();
return request
.get(`${base}/features`)
.get(`${base}/api/admin/features`)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
.expect(res => {
t.true(res.body.features.length === 0);
});
});
test('should get one getFeature', t => {
t.plan(1);
const { request, featureToggleStore, base } = getSetup();
featureToggleStore.addFeature({ name: 'test_', strategies: [{ name: 'default_' }] });
featureToggleStore.addFeature({
name: 'test_',
strategies: [{ name: 'default_' }],
});
return request
.get(`${base}/features`)
.get(`${base}/api/admin/features`)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
.expect(res => {
t.true(res.body.features.length === 1);
});
});
test('should add version numbers for /features', t => {
t.plan(1);
const { request, featureToggleStore, base } = getSetup();
featureToggleStore.addFeature({ name: 'test2', strategies: [{ name: 'default' }] });
featureToggleStore.addFeature({
name: 'test2',
strategies: [{ name: 'default' }],
});
return request
.get(`${base}/features`)
.get(`${base}/api/admin/features`)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
.expect(res => {
t.true(res.body.version === 1);
});
});
test('should require at least one strategy when creating a feature toggle', t => {
t.plan(0);
const { request, base } = getSetup();
return request
.post(`${base}/features`)
.post(`${base}/api/admin/features`)
.send({ name: 'sample.missing.strategy' })
.set('Content-Type', 'application/json')
.expect(400)
.expect(400);
});
test('should require at least one strategy when updating a feature toggle', t => {
t.plan(0);
const { request, featureToggleStore, base } = getSetup();
featureToggleStore.addFeature({ name: 'ts', strategies: [{ name: 'default' }] });
featureToggleStore.addFeature({
name: 'ts',
strategies: [{ name: 'default' }],
});
return request
.put(`${base}/features/ts`)
.put(`${base}/api/admin/features/ts`)
.send({ name: 'ts' })
.set('Content-Type', 'application/json')
.expect(400)
.expect(400);
});

View File

@ -0,0 +1,38 @@
'use strict';
const { Router } = require('express');
const features = require('./feature.js');
const featureArchive = require('./archive.js');
const events = require('./event.js');
const strategies = require('./strategy');
const metrics = require('./metrics');
const apiDef = {
version: 2,
links: {
'feature-toggles': { uri: '/api/admin/features' },
'feature-archive': { uri: '/api/admin/archive' },
strategies: { uri: '/api/admin/strategies' },
events: { uri: '/api/admin/events' },
metrics: { uri: '/api/admin/metrics' },
},
};
exports.apiDef = apiDef;
exports.router = config => {
const router = Router();
router.get('/', (req, res) => {
res.json(apiDef);
});
router.use('/features', features.router(config));
router.use('/archive', featureArchive.router(config));
router.use('/strategies', strategies.router(config));
router.use('/events', events.router(config));
router.use('/metrics', metrics.router(config));
return router;
};

View File

@ -0,0 +1,137 @@
'use strict';
const { Router } = require('express');
const logger = require('../../logger');
const ClientMetrics = require('../../client-metrics');
const { catchLogAndSendErrorResponse } = require('./route-utils');
exports.router = function(config) {
const {
clientMetricsStore,
clientInstanceStore,
clientApplicationsStore,
strategyStore,
featureToggleStore,
} = config.stores;
const metrics = new ClientMetrics(clientMetricsStore);
const router = Router();
router.get('/seen-toggles', (req, res) => {
const seenAppToggles = metrics.getAppsWithToggles();
res.json(seenAppToggles);
});
router.get('/seen-apps', (req, res) => {
const seenApps = metrics.getSeenAppsPerToggle();
clientApplicationsStore
.getApplications()
.then(toLookup)
.then(metaData => {
Object.keys(seenApps).forEach(key => {
seenApps[key] = seenApps[key].map(entry => {
if (metaData[entry.appName]) {
return Object.assign(
{},
entry,
metaData[entry.appName]
);
}
return entry;
});
});
res.json(seenApps);
});
});
router.get('/feature-toggles', (req, res) => {
res.json(metrics.getTogglesMetrics());
});
router.get('/feature-toggles/:name', (req, res) => {
const name = req.params.name;
const data = metrics.getTogglesMetrics();
const lastHour = data.lastHour[name] || {};
const lastMinute = data.lastMinute[name] || {};
res.json({
lastHour,
lastMinute,
});
});
router.post('/applications/:appName', (req, res) => {
const input = Object.assign({}, req.body, {
appName: req.params.appName,
});
clientApplicationsStore
.upsert(input)
.then(() => res.status(202).end())
.catch(e => {
logger.error(e);
res.status(500).end();
});
});
function toLookup(metaData) {
return metaData.reduce((result, entry) => {
result[entry.appName] = entry;
return result;
}, {});
}
router.get('/applications/', (req, res) => {
clientApplicationsStore
.getApplications(req.query)
.then(applications => res.json({ applications }))
.catch(err => catchLogAndSendErrorResponse(err, res));
});
router.get('/applications/:appName', (req, res) => {
const appName = req.params.appName;
const seenToggles = metrics.getSeenTogglesByAppName(appName);
Promise.all([
clientApplicationsStore.getApplication(appName),
clientInstanceStore.getByAppName(appName),
strategyStore.getStrategies(),
featureToggleStore.getFeatures(),
])
.then(([application, instances, strategies, features]) => {
const appDetails = {
appName: application.appName,
createdAt: application.createdAt,
description: application.description,
url: application.url,
color: application.color,
icon: application.icon,
strategies: application.strategies.map(name => {
const found = strategies.find(
feature => feature.name === name
);
if (found) {
return found;
}
return { name, notFound: true };
}),
instances,
seenToggles: seenToggles.map(name => {
const found = features.find(
feature => feature.name === name
);
if (found) {
return found;
}
return { name, notFound: true };
}),
links: {
self: `/api/applications/${application.appName}`,
},
};
res.json(appDetails);
})
.catch(err => catchLogAndSendErrorResponse(err, res));
});
return router;
};

View File

@ -0,0 +1,100 @@
'use strict';
const { test } = require('ava');
const store = require('./../../../test/fixtures/store');
const supertest = require('supertest');
const logger = require('../../logger');
const getApp = require('../../app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
test.beforeEach(() => {
logger.setLevel('FATAL');
});
function getSetup() {
const stores = store.createStores();
const app = getApp({
baseUriPath: '',
stores,
eventBus,
});
return {
request: supertest(app),
stores,
};
}
test('should return seen toggles even when there is nothing', t => {
t.plan(1);
const { request } = getSetup();
return request
.get('/api/admin/metrics/seen-toggles')
.expect(200)
.expect(res => {
t.true(res.body.length === 0);
});
});
test('should return list of seen-toggles per app', t => {
t.plan(3);
const { request, stores } = getSetup();
const appName = 'asd!23';
stores.clientMetricsStore.emit('metrics', {
appName,
instanceId: 'instanceId',
bucket: {
start: new Date(),
stop: new Date(),
toggles: {
toggleX: { yes: 123, no: 0 },
toggleY: { yes: 123, no: 0 },
},
},
});
return request
.get('/api/admin/metrics/seen-toggles')
.expect(200)
.expect(res => {
const seenAppsWithToggles = res.body;
t.true(seenAppsWithToggles.length === 1);
t.true(seenAppsWithToggles[0].appName === appName);
t.true(seenAppsWithToggles[0].seenToggles.length === 2);
});
});
test('should return feature-toggles metrics even when there is nothing', t => {
t.plan(0);
const { request } = getSetup();
return request.get('/api/admin/metrics/feature-toggles').expect(200);
});
test('should return metrics for all toggles', t => {
t.plan(2);
const { request, stores } = getSetup();
const appName = 'asd!23';
stores.clientMetricsStore.emit('metrics', {
appName,
instanceId: 'instanceId',
bucket: {
start: new Date(),
stop: new Date(),
toggles: {
toggleX: { yes: 123, no: 0 },
toggleY: { yes: 123, no: 0 },
},
},
});
return request
.get('/api/admin/metrics/feature-toggles')
.expect(200)
.expect(res => {
const metrics = res.body;
t.true(metrics.lastHour !== undefined);
t.true(metrics.lastMinute !== undefined);
});
});

View File

@ -1,6 +1,6 @@
'use strict';
const logger = require('../logger');
const logger = require('../../logger');
const catchLogAndSendErrorResponse = (err, res) => {
logger.error(err);

View File

@ -3,18 +3,16 @@
const joi = require('joi');
const strategySchema = joi.object().keys({
name: joi.string()
.regex(/^[a-zA-Z0-9\\.\\-]{3,100}$/)
.required(),
name: joi.string().regex(/^[a-zA-Z0-9\\.\\-]{3,100}$/).required(),
description: joi.string(),
parameters: joi.array()
.required()
.items(joi.object().keys({
parameters: joi.array().required().items(
joi.object().keys({
name: joi.string().required(),
type: joi.string().required(),
description: joi.string().allow(''),
required: joi.boolean(),
})),
})
),
});
module.exports = strategySchema;

View File

@ -0,0 +1,131 @@
'use strict';
const { Router } = require('express');
const joi = require('joi');
const eventType = require('../../event-type');
const logger = require('../../logger');
const NameExistsError = require('../../error/name-exists-error');
const extractUser = require('../../extract-user');
const strategySchema = require('./strategy-schema');
const version = 1;
const handleError = (req, res, error) => {
logger.warn('Error creating or updating strategy', error);
switch (error.name) {
case 'NotFoundError':
return res.status(404).end();
case 'NameExistsError':
return res
.status(403)
.json([
{
msg: `A strategy named '${req.body
.name}' already exists.`,
},
])
.end();
case 'ValidationError':
return res.status(400).json(error).end();
default:
logger.error('Could perfom operation', error);
return res.status(500).end();
}
};
exports.router = function(config) {
const { strategyStore, eventStore } = config.stores;
const router = Router();
router.get('/', (req, res) => {
strategyStore.getStrategies().then(strategies => {
res.json({ version, strategies });
});
});
router.get('/:name', (req, res) => {
strategyStore
.getStrategy(req.params.name)
.then(strategy => res.json(strategy).end())
.catch(() =>
res.status(404).json({ error: 'Could not find strategy' })
);
});
router.delete('/:name', (req, res) => {
const strategyName = req.params.name;
strategyStore
.getStrategy(strategyName)
.then(() =>
eventStore.store({
type: eventType.STRATEGY_DELETED,
createdBy: extractUser(req),
data: {
name: strategyName,
},
})
)
.then(() => res.status(200).end())
.catch(error => handleError(req, res, error));
});
router.post('/', (req, res) => {
const data = req.body;
validateInput(data)
.then(validateStrategyName)
.then(newStrategy =>
eventStore.store({
type: eventType.STRATEGY_CREATED,
createdBy: extractUser(req),
data: newStrategy,
})
)
.then(() => res.status(201).end())
.catch(error => handleError(req, res, error));
});
router.put('/:strategyName', (req, res) => {
const strategyName = req.params.strategyName;
const updatedStrategy = req.body;
updatedStrategy.name = strategyName;
strategyStore
.getStrategy(strategyName)
.then(() => validateInput(updatedStrategy))
.then(() =>
eventStore.store({
type: eventType.STRATEGY_UPDATED,
createdBy: extractUser(req),
data: updatedStrategy,
})
)
.then(() => res.status(200).end())
.catch(error => handleError(req, res, error));
});
function validateStrategyName(data) {
return new Promise((resolve, reject) => {
strategyStore
.getStrategy(data.name)
.then(() =>
reject(new NameExistsError('Feature name already exist'))
)
.catch(() => resolve(data));
});
}
function validateInput(data) {
return new Promise((resolve, reject) => {
joi.validate(data, strategySchema, (err, cleaned) => {
if (err) {
return reject(err);
}
return resolve(cleaned);
});
});
}
return router;
};

View File

@ -1,14 +1,15 @@
'use strict';
const test = require('ava');
const store = require('./fixtures/store');
const { test } = require('ava');
const store = require('./../../../test/fixtures/store');
const supertest = require('supertest');
const getApp = require('../../../lib/app');
const getApp = require('../../app');
const logger = require('../../logger');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
function getSetup () {
function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`;
const stores = store.createStores();
const app = getApp({
@ -24,89 +25,101 @@ function getSetup () {
};
}
test.beforeEach(() => {
logger.setLevel('FATAL');
});
test('should add version numbers for /stategies', t => {
t.plan(1);
const { request, base } = getSetup();
return request
.get(`${base}/api/strategies`)
.get(`${base}/api/admin/strategies`)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
.expect(res => {
t.true(res.body.version === 1);
});
});
test('should require a name when creating a new stratey', t => {
t.plan(1);
const { request, base } = getSetup();
return request
.post(`${base}/api/strategies`)
.post(`${base}/api/admin/strategies`)
.send({})
.expect(400)
.expect((res) => {
.expect(res => {
t.true(res.body.name === 'ValidationError');
});
});
test('should require parameters array when creating a new stratey', t => {
t.plan(1);
const { request, base } = getSetup();
return request
.post(`${base}/api/strategies`)
.post(`${base}/api/admin/strategies`)
.send({ name: 'TestStrat' })
.expect(400)
.expect((res) => {
.expect(res => {
t.true(res.body.name === 'ValidationError');
});
});
test('should create a new stratey with empty parameters', () => {
test('should create a new stratey with empty parameters', t => {
t.plan(0);
const { request, base } = getSetup();
return request
.post(`${base}/api/strategies`)
.post(`${base}/api/admin/strategies`)
.send({ name: 'TestStrat', parameters: [] })
.expect(201);
});
test('should not be possible to override name', () => {
test('should not be possible to override name', t => {
t.plan(0);
const { request, base, strategyStore } = getSetup();
strategyStore.addStrategy({ name: 'Testing', parameters: [] });
return request
.post(`${base}/api/strategies`)
.post(`${base}/api/admin/strategies`)
.send({ name: 'Testing', parameters: [] })
.expect(403);
});
test('should update strategy', () => {
test('should update strategy', t => {
t.plan(0);
const name = 'AnotherStrat';
const { request, base, strategyStore } = getSetup();
strategyStore.addStrategy({ name, parameters: [] });
return request
.put(`${base}/api/strategies/${name}`)
.put(`${base}/api/admin/strategies/${name}`)
.send({ name, parameters: [], description: 'added' })
.expect(200);
});
test('should not update uknown strategy', () => {
test('should not update uknown strategy', t => {
t.plan(0);
const name = 'UnknownStrat';
const { request, base } = getSetup();
return request
.put(`${base}/api/strategies/${name}`)
.put(`${base}/api/admin/strategies/${name}`)
.send({ name, parameters: [], description: 'added' })
.expect(404);
});
test('should validate format when updating strategy', () => {
test('should validate format when updating strategy', t => {
t.plan(0);
const name = 'AnotherStrat';
const { request, base, strategyStore } = getSetup();
strategyStore.addStrategy({ name, parameters: [] });
return request
.put(`${base}/api/strategies/${name}`)
.send({ })
.put(`${base}/api/admin/strategies/${name}`)
.send({})
.expect(400);
});

View File

@ -1,19 +0,0 @@
'use strict';
const apiDef = {
version: 1,
links: {
'feature-toggles': { uri: '/api/features' },
'strategies': { uri: '/api/strategies' },
'events': { uri: '/api/events' },
'client-register': { uri: '/api/client/register' },
'client-metrics': { uri: '/api/client/register' },
'seen-toggles': { uri: '/api/client/seen-toggles' },
},
};
module.exports = (app) => {
app.get('/', (req, res) => {
res.json(apiDef);
});
};

View File

@ -1,12 +1,17 @@
'use strict';
const { Router } = require('express');
const prometheusRegister = require('prom-client/lib/register');
module.exports = function (app, config) {
exports.router = config => {
const router = Router();
if (config.serverMetrics) {
app.get('/internal-backstage/prometheus', (req, res) => {
router.get('/prometheus', (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(prometheusRegister.metrics());
});
}
return router;
};

View File

@ -1,19 +1,20 @@
'use strict';
const test = require('ava');
const store = require('./fixtures/store');
const { test } = require('ava');
const store = require('./../../test/fixtures/store');
const supertest = require('supertest');
const logger = require('../../../lib/logger');
const getApp = require('../../../lib/app');
const logger = require('../logger');
const getApp = require('../app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
test.beforeEach(() => {
test.beforeEach(() => {
logger.setLevel('FATAL');
});
test('should use enable prometheus', t => {
t.plan(0);
const stores = store.createStores();
const app = getApp({
baseUriPath: '',
@ -27,5 +28,5 @@ test('should use enable prometheus', t => {
return request
.get('/internal-backstage/prometheus')
.expect('Content-Type', /text/)
.expect(200)
.expect(200);
});

View File

@ -0,0 +1,18 @@
'use strict';
const { Router } = require('express');
const version = 1;
exports.router = config => {
const router = Router();
const { featureToggleStore } = config.stores;
router.get('/', (req, res) => {
featureToggleStore
.getFeatures()
.then(features => res.json({ version, features }));
});
return router;
};

View File

@ -1,10 +1,10 @@
'use strict';
const test = require('ava');
const store = require('./fixtures/store');
const { test } = require('ava');
const store = require('./../../../test/fixtures/store');
const supertest = require('supertest');
const logger = require('../../../lib/logger');
const getApp = require('../../../lib/app');
const logger = require('../../logger');
const getApp = require('../../app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
@ -13,7 +13,7 @@ test.beforeEach(() => {
logger.setLevel('FATAL');
});
function getSetup () {
function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`;
const stores = store.createStores();
const app = getApp({
@ -29,13 +29,14 @@ function getSetup () {
};
}
test('should get api defintion', t => {
test('should get empty getFeatures via client', t => {
t.plan(1);
const { request, base } = getSetup();
return request
.get(`${base}/api/`)
.get(`${base}/api/client/features`)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
t.true(res.body.links['feature-toggles'].uri === '/api/features');
.expect(res => {
t.true(res.body.features.length === 0);
});
});

View File

@ -0,0 +1,30 @@
'use strict';
const { Router } = require('express');
const features = require('./feature.js');
const metrics = require('./metrics.js');
const register = require('./register.js');
const apiDef = {
version: 2,
links: {
'feature-toggles': { uri: '/api/client/features' },
register: { uri: '/api/client/register' },
metrics: { uri: '/api/client/metrics' },
},
};
exports.apiDef = apiDef;
exports.router = config => {
const router = Router();
router.get('/', (req, res) => {
res.json(apiDef);
});
router.use('/features', features.router(config));
router.use('/metrics', metrics.router(config));
router.use('/register', register.router(config));
return router;
};

View File

@ -5,8 +5,7 @@ const joi = require('joi');
const clientMetricsSchema = joi.object().keys({
appName: joi.string().required(),
instanceId: joi.string().required(),
bucket: joi.object().required()
.keys({
bucket: joi.object().required().keys({
start: joi.date().required(),
stop: joi.date().required(),
toggles: joi.object(),
@ -16,9 +15,7 @@ const clientMetricsSchema = joi.object().keys({
const clientRegisterSchema = joi.object().keys({
appName: joi.string().required(),
instanceId: joi.string().required(),
strategies: joi.array()
.required()
.items(joi.string(), joi.any().strip()),
strategies: joi.array().required().items(joi.string(), joi.any().strip()),
started: joi.date().required(),
interval: joi.number().required(),
});

View File

@ -0,0 +1,39 @@
'use strict';
const { Router } = require('express');
const joi = require('joi');
const logger = require('../../logger');
const { clientMetricsSchema } = require('./metrics-schema');
exports.router = config => {
const { clientMetricsStore, clientInstanceStore } = config.stores;
const router = Router();
router.post('/', (req, res) => {
const data = req.body;
const clientIp = req.ip;
joi.validate(data, clientMetricsSchema, (err, cleaned) => {
if (err) {
logger.warn('Invalid metrics posted', err);
return res.status(400).json(err);
}
clientMetricsStore
.insert(cleaned)
.then(() =>
clientInstanceStore.insert({
appName: cleaned.appName,
instanceId: cleaned.instanceId,
clientIp,
})
)
.catch(err => logger.error('failed to store metrics', err));
res.status(202).end();
});
});
return router;
};

View File

@ -0,0 +1,54 @@
'use strict';
const { test } = require('ava');
const store = require('./../../../test/fixtures/store');
const supertest = require('supertest');
const logger = require('../../logger');
const getApp = require('../../app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
test.beforeEach(() => {
logger.setLevel('FATAL');
});
function getSetup() {
const stores = store.createStores();
const app = getApp({
baseUriPath: '',
stores,
eventBus,
});
return {
request: supertest(app),
stores,
};
}
test('should validate client metrics', t => {
t.plan(0);
const { request } = getSetup();
return request
.post('/api/client/metrics')
.send({ random: 'blush' })
.expect(400);
});
test('should accept client metrics', t => {
t.plan(0);
const { request } = getSetup();
return request
.post('/api/client/metrics')
.send({
appName: 'demo',
instanceId: '1',
bucket: {
start: Date.now(),
stop: Date.now(),
toggles: {},
},
})
.expect(202);
});

View File

@ -0,0 +1,38 @@
'use strict';
const { Router } = require('express');
const joi = require('joi');
const logger = require('../../logger');
const { clientRegisterSchema } = require('./metrics-schema');
exports.router = config => {
const { clientInstanceStore, clientApplicationsStore } = config.stores;
const router = Router();
router.post('/', (req, res) => {
const data = req.body;
joi.validate(data, clientRegisterSchema, (err, clientRegistration) => {
if (err) {
logger.warn('Invalid client data posted', err);
return res.status(400).json(err);
}
clientRegistration.clientIp = req.ip;
clientApplicationsStore
.upsert(clientRegistration)
.then(() => clientInstanceStore.insert(clientRegistration))
.then(() =>
logger.info(`New client registered with
appName=${clientRegistration.appName} and instanceId=${clientRegistration.instanceId}`)
)
.catch(err => logger.error('failed to register client', err));
res.status(202).end();
});
});
return router;
};

View File

@ -0,0 +1,64 @@
'use strict';
const { test } = require('ava');
const store = require('./../../../test/fixtures/store');
const supertest = require('supertest');
const logger = require('../../logger');
const getApp = require('../../app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
test.beforeEach(() => {
logger.setLevel('FATAL');
});
function getSetup() {
const stores = store.createStores();
const app = getApp({
baseUriPath: '',
stores,
eventBus,
});
return {
request: supertest(app),
stores,
};
}
test('should register client', t => {
t.plan(0);
const { request } = getSetup();
return request
.post('/api/client/register')
.send({
appName: 'demo',
instanceId: 'test',
strategies: ['default'],
started: Date.now(),
interval: 10,
})
.expect(202);
});
test('should require appName field', t => {
t.plan(0);
const { request } = getSetup();
return request.post('/api/client/register').expect(400);
});
test('should require strategies field', t => {
t.plan(0);
const { request } = getSetup();
return request
.post('/api/client/register')
.send({
appName: 'demo',
instanceId: 'test',
// strategies: ['default'],
started: Date.now(),
interval: 10,
})
.expect(400);
});

View File

@ -1,44 +0,0 @@
'use strict';
const logger = require('../logger');
const { FEATURE_REVIVED } = require('../event-type');
const ValidationError = require('../error/validation-error');
const validateRequest = require('../error/validate-request');
const handleErrors = (req, res, error) => {
switch (error.constructor) {
case ValidationError:
return res
.status(400)
.json(req.validationErrors())
.end();
default:
logger.error('Server failed executing request', error);
return res
.status(500)
.end();
}
};
module.exports = function (app, config) {
const { featureToggleStore, eventStore } = config.stores;
app.get('/archive/features', (req, res) => {
featureToggleStore.getArchivedFeatures().then(archivedFeatures => {
res.json({ features: archivedFeatures });
});
});
app.post('/archive/revive/:name', (req, res) => {
req.checkParams('name', 'Name is required').notEmpty();
validateRequest(req)
.then(() => eventStore.store({
type: FEATURE_REVIVED,
createdBy: req.connection.remoteAddress,
data: { name: req.params.name },
}))
.then(() => res.status(200).end())
.catch(error => handleErrors(req, res, error));
});
};

View File

@ -1,178 +0,0 @@
'use strict';
const joi = require('joi');
const logger = require('../logger');
const { FEATURE_CREATED, FEATURE_UPDATED, FEATURE_ARCHIVED } = require('../event-type');
const NameExistsError = require('../error/name-exists-error');
const NotFoundError = require('../error/notfound-error');
const ValidationError = require('../error/validation-error.js');
const validateRequest = require('../error/validate-request');
const extractUser = require('../extract-user');
const legacyFeatureMapper = require('../data-helper/legacy-feature-mapper');
const version = 1;
const handleErrors = (req, res, error) => {
logger.warn('Error creating or updating feature', error);
switch (error.constructor) {
case NotFoundError:
return res
.status(404)
.end();
case NameExistsError:
return res
.status(403)
.json([{ msg: 'A feature with this name already exists. Try re-activating it from the archive.' }])
.end();
case ValidationError:
return res
.status(400)
.json(req.validationErrors())
.end();
default:
logger.error('Server failed executing request', error);
return res
.status(500)
.end();
}
};
module.exports = function (app, config) {
const { featureToggleStore, eventStore } = config.stores;
app.get('/features', (req, res) => {
featureToggleStore.getFeatures()
.then((features) => features.map(legacyFeatureMapper.addOldFields))
.then(features => res.json({ version, features }));
});
app.get('/features/:featureName', (req, res) => {
featureToggleStore.getFeature(req.params.featureName)
.then(legacyFeatureMapper.addOldFields)
.then(feature => res.json(feature).end())
.catch(() => res.status(404).json({ error: 'Could not find feature' }));
});
app.post('/features-validate', (req, res) => {
req.checkBody('name', 'Name is required').notEmpty();
req.checkBody('name', 'Name must match format ^[0-9a-zA-Z\\.\\-]+$').matches(/^[0-9a-zA-Z\\.\\-]+$/i);
validateRequest(req)
.then(validateFormat)
.then(validateUniqueName)
.then(() => res.status(201).end())
.catch(error => handleErrors(req, res, error));
});
app.post('/features', (req, res) => {
req.checkBody('name', 'Name is required').notEmpty();
req.checkBody('name', 'Name must match format ^[0-9a-zA-Z\\.\\-]+$').matches(/^[0-9a-zA-Z\\.\\-]+$/i);
const userName = extractUser(req);
validateRequest(req)
.then(validateFormat)
.then(validateUniqueName)
.then((_req) => legacyFeatureMapper.toNewFormat(_req.body))
.then(validateStrategy)
.then((featureToggle) => eventStore.store({
type: FEATURE_CREATED,
createdBy: userName,
data: featureToggle,
}))
.then(() => res.status(201).end())
.catch(error => handleErrors(req, res, error));
});
app.put('/features/:featureName', (req, res) => {
const featureName = req.params.featureName;
const userName = extractUser(req);
const updatedFeature = legacyFeatureMapper.toNewFormat(req.body);
updatedFeature.name = featureName;
featureToggleStore.getFeature(featureName)
.then(() => validateStrategy(updatedFeature))
.then(() => eventStore.store({
type: FEATURE_UPDATED,
createdBy: userName,
data: updatedFeature,
}))
.then(() => res.status(200).end())
.catch(error => handleErrors(req, res, error));
});
app.post('/features/:featureName/toggle', (req, res) => {
const featureName = req.params.featureName;
const userName = extractUser(req);
featureToggleStore.getFeature(featureName)
.then((feature) => {
feature.enabled = !feature.enabled;
return eventStore.store({
type: FEATURE_UPDATED,
createdBy: userName,
data: feature,
});
})
.then(() => res.status(200).end())
.catch(error => handleErrors(req, res, error));
});
app.delete('/features/:featureName', (req, res) => {
const featureName = req.params.featureName;
const userName = extractUser(req);
featureToggleStore.getFeature(featureName)
.then(() => eventStore.store({
type: FEATURE_ARCHIVED,
createdBy: userName,
data: {
name: featureName,
},
}))
.then(() => res.status(200).end())
.catch(error => handleErrors(req, res, error));
});
function validateUniqueName (req) {
return new Promise((resolve, reject) => {
featureToggleStore.getFeature(req.body.name)
.then(() => reject(new NameExistsError('Feature name already exist')))
.catch(() => resolve(req));
});
}
function validateFormat (req) {
if (req.body.strategy && req.body.strategies) {
return Promise.reject(new ValidationError('Cannot use both "strategy" and "strategies".'));
}
return Promise.resolve(req);
}
const strategiesSchema = joi.object().keys({
name: joi.string()
.regex(/^[a-zA-Z0-9\\.\\-]{3,100}$/)
.required(),
parameters: joi.object(),
});
function validateStrategy (featureToggle) {
return new Promise((resolve, reject) => {
if (!featureToggle.strategies || featureToggle.strategies.length === 0) {
return reject(new ValidationError('You must define at least one strategy'));
}
featureToggle.strategies = featureToggle.strategies.map((strategyConfig) => {
const result = joi.validate(strategyConfig, strategiesSchema);
if (result.error) {
throw result.error;
}
return result.value;
});
return resolve(featureToggle);
});
}
};

View File

@ -1,15 +1,24 @@
'use strict';
const logger = require('../logger');
const { Router } = require('express');
module.exports = function (app, config) {
app.get('/health', (req, res) => {
config.stores.db.select(1)
exports.router = function(config) {
const router = Router();
router.get('/', (req, res) => {
config.stores.db
.select(1)
.from('features')
.then(() => res.json({ health: 'GOOD' }))
.catch(err => {
logger.error('Could not select from features, error was: ', err);
logger.error(
'Could not select from features, error was: ',
err
);
res.status(500).json({ health: 'BAD' });
});
});
return router;
};

View File

@ -1,20 +1,19 @@
'use strict';
const test = require('ava');
const store = require('./fixtures/store');
const { test } = require('ava');
const store = require('./../../test/fixtures/store');
const supertest = require('supertest');
const logger = require('../../../lib/logger');
const getApp = require('../../../lib/app');
const logger = require('../logger');
const getApp = require('../app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
test.beforeEach(() => {
test.beforeEach(() => {
logger.setLevel('FATAL');
});
function getSetup () {
function getSetup() {
const stores = store.createStores();
const db = stores.db;
const app = getApp({
@ -30,34 +29,28 @@ function getSetup () {
}
test('should give 500 when db is failing', t => {
t.plan(2);
const { request, db } = getSetup();
db.select = () => ({
from: () => Promise.reject(new Error('db error')),
});
return request
.get('/health')
.expect(500)
.expect((res) => {
t.true(res.status === 500);
t.true(res.body.health === 'BAD');
});
return request.get('/health').expect(500).expect(res => {
t.true(res.status === 500);
t.true(res.body.health === 'BAD');
});
});
test('should give 200 when db is not failing', () => {
test('should give 200 when db is not failing', t => {
t.plan(0);
const { request } = getSetup();
return request
.get('/health')
.expect(200);
return request.get('/health').expect(200);
});
test('should give health=GOOD when db is not failing', t => {
t.plan(2);
const { request } = getSetup();
return request
.get('/health')
.expect(200)
.expect((res) => {
t.true(res.status === 200);
t.true(res.body.health === 'GOOD');
});
return request.get('/health').expect(200).expect(res => {
t.true(res.status === 200);
t.true(res.body.health === 'GOOD');
});
});

View File

@ -1,18 +1,44 @@
'use strict';
const { Router } = require('express');
exports.createAPI = function (router, config) {
require('./api')(router, config);
require('./event')(router, config);
require('./feature')(router, config);
require('./feature-archive')(router, config);
require('./strategy')(router, config);
require('./health-check')(router, config);
require('./metrics')(router, config);
};
const adminApi = require('./admin-api');
const clientApi = require('./client-api');
const clientFeatures = require('./client-api/feature.js');
exports.createLegacy = function (router, config) {
require('./feature')(router, config);
require('./health-check')(router, config);
require('./backstage')(router, config);
const health = require('./health-check');
const backstage = require('./backstage.js');
exports.router = function(config) {
const router = Router();
router.use('/health', health.router(config));
router.use('/internal-backstage', backstage.router(config));
router.get('/api', (req, res) => {
res.json({
version: 2,
links: {
admin: {
uri: '/api/admin',
links: adminApi.apiDef.links,
},
client: {
uri: '/api/client',
links: clientApi.apiDef.links,
},
},
});
});
router.use('/api/admin', adminApi.router(config));
router.use('/api/client', clientApi.router(config));
// legacy support
// $root/features
// $root/client/register
// $root/client/metrics
router.use('/api/features', clientFeatures.router(config));
return router;
};

91
lib/routes/index.test.js Normal file
View File

@ -0,0 +1,91 @@
'use strict';
const { test } = require('ava');
const store = require('./../../test/fixtures/store');
const supertest = require('supertest');
const logger = require('../logger');
const getApp = require('../app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
test.beforeEach(() => {
logger.setLevel('FATAL');
});
function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`;
const stores = store.createStores();
const app = getApp({
baseUriPath: base,
stores,
eventBus,
});
return {
base,
featureToggleStore: stores.featureToggleStore,
request: supertest(app),
};
}
test('api defintion', t => {
t.plan(5);
const { request, base } = getSetup();
return request
.get(`${base}/api/`)
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.truthy(res.body);
const { admin, client } = res.body.links;
t.true(admin.uri === '/api/admin');
t.true(client.uri === '/api/client');
t.true(
admin.links['feature-toggles'].uri === '/api/admin/features'
);
t.true(client.links.metrics.uri === '/api/client/metrics');
});
});
test('admin api defintion', t => {
t.plan(2);
const { request, base } = getSetup();
return request
.get(`${base}/api/admin`)
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.truthy(res.body);
t.true(
res.body.links['feature-toggles'].uri === '/api/admin/features'
);
});
});
test('client api defintion', t => {
t.plan(2);
const { request, base } = getSetup();
return request
.get(`${base}/api/client`)
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.truthy(res.body);
t.true(res.body.links.metrics.uri === '/api/client/metrics');
});
});
test('client legacy features uri', t => {
t.plan(3);
const { request, base } = getSetup();
return request
.get(`${base}/api/features`)
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.truthy(res.body);
t.true(res.body.version === 1);
t.deepEqual(res.body.features, []);
});
});

View File

@ -1,170 +0,0 @@
'use strict';
const logger = require('../logger');
const ClientMetrics = require('../client-metrics');
const joi = require('joi');
const { clientMetricsSchema, clientRegisterSchema } = require('./metrics-schema');
const { catchLogAndSendErrorResponse } = require('./route-utils');
module.exports = function (app, config) {
const {
clientMetricsStore,
clientInstanceStore,
clientApplicationsStore,
strategyStore,
featureToggleStore,
} = config.stores;
const metrics = new ClientMetrics(clientMetricsStore);
app.get('/client/seen-toggles', (req, res) => {
const seenAppToggles = metrics.getAppsWithToggles();
res.json(seenAppToggles);
});
app.get('/client/seen-apps', (req, res) => {
const seenApps = metrics.getSeenAppsPerToggle();
clientApplicationsStore.getApplications()
.then(toLookup)
.then(metaData => {
Object.keys(seenApps).forEach(key => {
seenApps[key] = seenApps[key].map(entry => {
if (metaData[entry.appName]) {
return Object.assign({}, entry, metaData[entry.appName]);
}
return entry;
});
});
res.json(seenApps);
});
});
app.get('/client/metrics/feature-toggles', (req, res) => {
res.json(metrics.getTogglesMetrics());
});
app.get('/client/metrics/feature-toggles/:name', (req, res) => {
const name = req.params.name;
const data = metrics.getTogglesMetrics();
const lastHour = data.lastHour[name] || {};
const lastMinute = data.lastMinute[name] || {};
res.json({
lastHour,
lastMinute,
});
});
app.post('/client/metrics', (req, res) => {
const data = req.body;
const clientIp = req.ip;
joi.validate(data, clientMetricsSchema, (err, cleaned) => {
if (err) {
logger.warn('Invalid metrics posted', err);
return res.status(400).json(err);
}
clientMetricsStore
.insert(cleaned)
.then(() => clientInstanceStore.insert({
appName: cleaned.appName,
instanceId: cleaned.instanceId,
clientIp,
}))
.catch(err => logger.error('failed to store metrics', err));
res.status(202).end();
});
});
app.post('/client/register', (req, res) => {
const data = req.body;
joi.validate(data, clientRegisterSchema, (err, clientRegistration) => {
if (err) {
logger.warn('Invalid client data posted', err);
return res.status(400).json(err);
}
clientRegistration.clientIp = req.ip;
clientApplicationsStore
.upsert(clientRegistration)
.then(() => clientInstanceStore.insert(clientRegistration))
.then(() => logger.info(`New client registered with
appName=${clientRegistration.appName} and instanceId=${clientRegistration.instanceId}`))
.catch(err => logger.error('failed to register client', err));
res.status(202).end();
});
});
app.post('/client/applications/:appName', (req, res) => {
const input = Object.assign({}, req.body, {
appName: req.params.appName,
});
clientApplicationsStore
.upsert(input)
.then(() => res.status(202).end())
.catch((e) => {
logger.error(e);
res.status(500).end();
});
});
function toLookup (metaData) {
return metaData.reduce((result, entry) => {
result[entry.appName] = entry;
return result;
}, {});
}
app.get('/client/applications/', (req, res) => {
clientApplicationsStore
.getApplications(req.query)
.then(applications => res.json({ applications }))
.catch(err => catchLogAndSendErrorResponse(err, res));
});
app.get('/client/applications/:appName', (req, res) => {
const appName = req.params.appName;
const seenToggles = metrics.getSeenTogglesByAppName(appName);
Promise.all([
clientApplicationsStore.getApplication(appName),
clientInstanceStore.getByAppName(appName),
strategyStore.getStrategies(),
featureToggleStore.getFeatures(),
])
.then(([application, instances, strategies, features]) => {
const appDetails = {
appName: application.appName,
createdAt: application.createdAt,
description: application.description,
url: application.url,
color: application.color,
icon: application.icon,
strategies: application.strategies.map(name => {
const found = strategies.find((feature) => feature.name === name);
if (found) {
return found;
}
return { name, notFound: true };
}),
instances,
seenToggles: seenToggles.map(name => {
const found = features.find((feature) => feature.name === name);
if (found) {
return found;
}
return { name, notFound: true };
}),
links: {
self: `/api/client/applications/${application.appName}`,
},
};
res.json(appDetails);
})
.catch(err => catchLogAndSendErrorResponse(err, res));
});
};

View File

@ -1,114 +0,0 @@
'use strict';
const joi = require('joi');
const eventType = require('../event-type');
const logger = require('../logger');
const NameExistsError = require('../error/name-exists-error');
const extractUser = require('../extract-user');
const strategySchema = require('./strategy-schema');
const version = 1;
const handleError = (req, res, error) => {
logger.warn('Error creating or updating strategy', error);
switch (error.name) {
case 'NotFoundError':
return res
.status(404)
.end();
case 'NameExistsError':
return res
.status(403)
.json([{ msg: `A strategy named '${req.body.name}' already exists.` }])
.end();
case 'ValidationError':
return res
.status(400)
.json(error)
.end();
default:
logger.error('Could perfom operation', error);
return res
.status(500)
.end();
}
};
module.exports = function (app, config) {
const { strategyStore, eventStore } = config.stores;
app.get('/strategies', (req, res) => {
strategyStore.getStrategies().then(strategies => {
res.json({ version, strategies });
});
});
app.get('/strategies/:name', (req, res) => {
strategyStore.getStrategy(req.params.name)
.then(strategy => res.json(strategy).end())
.catch(() => res.status(404).json({ error: 'Could not find strategy' }));
});
app.delete('/strategies/:name', (req, res) => {
const strategyName = req.params.name;
strategyStore.getStrategy(strategyName)
.then(() => eventStore.store({
type: eventType.STRATEGY_DELETED,
createdBy: extractUser(req),
data: {
name: strategyName,
},
}))
.then(() => res.status(200).end())
.catch(error => handleError(req, res, error));
});
app.post('/strategies', (req, res) => {
const data = req.body;
validateInput(data)
.then(validateStrategyName)
.then((newStrategy) => eventStore.store({
type: eventType.STRATEGY_CREATED,
createdBy: extractUser(req),
data: newStrategy,
}))
.then(() => res.status(201).end())
.catch(error => handleError(req, res, error));
});
app.put('/strategies/:strategyName', (req, res) => {
const strategyName = req.params.strategyName;
const updatedStrategy = req.body;
updatedStrategy.name = strategyName;
strategyStore.getStrategy(strategyName)
.then(() => validateInput(updatedStrategy))
.then(() => eventStore.store({
type: eventType.STRATEGY_UPDATED,
createdBy: extractUser(req),
data: updatedStrategy,
}))
.then(() => res.status(200).end())
.catch(error => handleError(req, res, error));
});
function validateStrategyName (data) {
return new Promise((resolve, reject) => {
strategyStore.getStrategy(data.name)
.then(() => reject(new NameExistsError('Feature name already exist')))
.catch(() => resolve(data));
});
}
function validateInput (data) {
return new Promise((resolve, reject) => {
joi.validate(data, strategySchema, (err, cleaned) => {
if (err) {
return reject(err);
}
return resolve(cleaned);
});
});
}
};

View File

@ -2,11 +2,11 @@
const { test } = require('ava');
const proxyquire = require('proxyquire');
const express = require('express');
const getApp = proxyquire('./app', {
'./routes': {
createAPI: () => {},
createLegacy: () => {},
router: () => express.Router(),
},
});
@ -35,6 +35,7 @@ const serverImpl = proxyquire('./server-impl', {
test('should call preHook', async t => {
let called = 0;
await serverImpl.start({
port: 0,
preHook: () => {
called++;
},
@ -44,8 +45,11 @@ test('should call preHook', async t => {
test('should call preRouterHook', async t => {
let called = 0;
await serverImpl.start({ preRouterHook: () => {
called++;
} });
await serverImpl.start({
port: 0,
preRouterHook: () => {
called++;
},
});
t.true(called === 1);
});

View File

@ -39,7 +39,7 @@
"db-migrate": "db-migrate",
"lint": "eslint lib",
"pretest": "npm run lint",
"test": "PORT=4243 ava test lib/*.test.js lib/**/*.test.js",
"test": "PORT=4243 ava lib/*.test.js lib/**/*.test.js lib/**/**/*.test.js lib/**/**/**/*.test.js test",
"test:docker": "./scripts/docker-postgres.sh",
"test:watch": "npm run test -- --watch",
"test:pg-virtualenv": "pg_virtualenv npm run test:pg-virtualenv-chai",
@ -81,11 +81,13 @@
},
"devDependencies": {
"@types/node": "^7.0.5",
"ava": "^0.18.2",
"ava": "^0.19.1",
"coveralls": "^2.11.16",
"eslint": "^3.16.1",
"eslint-config-finn": "^1.0.0-beta.1",
"eslint": "^4.0.0",
"eslint-config-finn": "^1.0.2",
"eslint-config-finn-prettier": "^2.0.0",
"nyc": "^10.1.2",
"prettier": "^1.4.4",
"proxyquire": "^1.7.11",
"sinon": "^1.17.7",
"superagent": "^3.5.0",

View File

@ -1,26 +1,28 @@
'use strict';
const test = require('ava');
const { setupApp } = require('./helpers/test-helper');
const logger = require('../../lib/logger');
const { test } = require('ava');
const { setupApp } = require('./../../helpers/test-helper');
const logger = require('../../../../lib/logger');
test.beforeEach(() => {
test.beforeEach(() => {
logger.setLevel('FATAL');
});
test.serial('returns events', async (t) => {
test.serial('returns events', async t => {
t.plan(0);
const { request, destroy } = await setupApp('event_api_serial');
return request
.get('/api/events')
.get('/api/admin/events')
.expect('Content-Type', /json/)
.expect(200)
.then(destroy);
});
test.serial('returns events given a name', async (t) => {
test.serial('returns events given a name', async t => {
t.plan(0);
const { request, destroy } = await setupApp('event_api_serial');
return request
.get('/api/events/myname')
.get('/api/admin/events/myname')
.expect('Content-Type', /json/)
.expect(200)
.then(destroy);

View File

@ -1,38 +1,38 @@
'use strict';
const test = require('ava');
const { setupApp } = require('./helpers/test-helper');
const logger = require('../../lib/logger');
const { test } = require('ava');
const { setupApp } = require('./../../helpers/test-helper');
const logger = require('../../../../lib/logger');
test.beforeEach(() => {
test.beforeEach(() => {
logger.setLevel('FATAL');
});
test.serial('returns three archived toggles', async t => {
t.plan(1);
const { request, destroy } = await setupApp('archive_serial');
return request
.get('/api/archive/features')
.get('/api/admin/archive/features')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
.expect(res => {
t.true(res.body.features.length === 3);
})
.then(destroy);
});
test.serial('revives a feature by name', async t => {
const { request, destroy } = await setupApp('archive_serial');
t.plan(0);
const { request, destroy } = await setupApp('archive_serial');
return request
.post('/api/archive/revive/featureArchivedX')
.post('/api/admin/archive/revive/featureArchivedX')
.set('Content-Type', 'application/json')
.expect(200)
.then(destroy);
});
test.serial('must set name when reviving toggle', async t => {
const { request, destroy } = await setupApp('archive_serial');
return request
.post('/api/archive/revive/')
.expect(404)
.then(destroy);
t.plan(0);
const { request, destroy } = await setupApp('archive_serial');
return request.post('/api/admin/archive/revive/').expect(404).then(destroy);
});

View File

@ -0,0 +1,200 @@
'use strict';
const { test } = require('ava');
const { setupApp } = require('./../../helpers/test-helper');
const logger = require('../../../../lib/logger');
test.beforeEach(() => {
logger.setLevel('FATAL');
});
test.serial('returns three feature toggles', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
return request
.get('/api/admin/features')
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.true(res.body.features.length === 3);
})
.then(destroy);
});
test.serial('gets a feature by name', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.get('/api/admin/features/featureX')
.expect('Content-Type', /json/)
.expect(200)
.then(destroy);
});
test.serial('cant get feature that dose not exist', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.get('/api/admin/features/myfeature')
.expect('Content-Type', /json/)
.expect(404)
.then(destroy);
});
test.serial('creates new feature toggle', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.post('/api/admin/features')
.send({
name: 'com.test.feature',
enabled: false,
strategies: [{ name: 'default' }],
})
.set('Content-Type', 'application/json')
.expect(201)
.then(destroy);
});
test.serial('creates new feature toggle with createdBy', async t => {
t.plan(1);
const { request, destroy } = await setupApp('feature_api_serial');
await request
.post('/api/admin/features')
.send({
name: 'com.test.Username',
enabled: false,
strategies: [{ name: 'default' }],
})
.set('Cookie', ['username=ivaosthu'])
.set('Content-Type', 'application/json');
await request
.get('/api/admin/events')
.expect(res => {
t.true(res.body.events[0].createdBy === 'ivaosthu');
})
.then(destroy);
});
test.serial('require new feature toggle to have a name', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.post('/api/admin/features')
.send({ name: '' })
.set('Content-Type', 'application/json')
.expect(400)
.then(destroy);
});
test.serial(
'can not change status of feature toggle that does not exist',
async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.put('/api/admin/features/should-not-exist')
.send({ name: 'should-not-exist', enabled: false })
.set('Content-Type', 'application/json')
.expect(404)
.then(destroy);
}
);
test.serial('can change status of feature toggle that does exist', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.put('/api/admin/features/featureY')
.send({
name: 'featureY',
enabled: true,
strategies: [{ name: 'default' }],
})
.set('Content-Type', 'application/json')
.expect(200)
.then(destroy);
});
test.serial('can not toggle of feature that does not exist', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.post('/api/admin/features/should-not-exist/toggle')
.set('Content-Type', 'application/json')
.expect(404)
.then(destroy);
});
test.serial('can toggle a feature that does exist', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.post('/api/admin/features/featureY/toggle')
.set('Content-Type', 'application/json')
.expect(200)
.then(destroy);
});
test.serial('archives a feature by name', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.delete('/api/admin/features/featureX')
.expect(200)
.then(destroy);
});
test.serial('can not archive unknown feature', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.delete('/api/admin/features/featureUnknown')
.expect(404)
.then(destroy);
});
test.serial('refuses to create a feature with an existing name', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.post('/api/admin/features')
.send({ name: 'featureX' })
.set('Content-Type', 'application/json')
.expect(403)
.then(destroy);
});
test.serial('refuses to validate a feature with an existing name', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.post('/api/admin/features/validate')
.send({ name: 'featureX' })
.set('Content-Type', 'application/json')
.expect(403)
.then(destroy);
});
test.serial(
'new strategies api can add two strategies to a feature toggle',
async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.put('/api/admin/features/featureY')
.send({
name: 'featureY',
description: 'soon to be the #14 feature',
enabled: false,
strategies: [
{
name: 'baz',
parameters: { foo: 'bar' },
},
],
})
.set('Content-Type', 'application/json')
.expect(200)
.then(destroy);
}
);

View File

@ -1,14 +1,16 @@
'use strict';
const test = require('ava');
const { setupApp } = require('./helpers/test-helper');
const logger = require('../../lib/logger');
test.beforeEach(() => {
const { test } = require('ava');
const { setupApp } = require('./../../helpers/test-helper');
const logger = require('../../../../lib/logger');
test.beforeEach(() => {
logger.setLevel('FATAL');
});
test.serial('should register client', async (t) => {
const { request, destroy } = await setupApp('metrics_serial');
test.serial('should register client', async t => {
t.plan(0);
const { request, destroy } = await setupApp('metrics_serial');
return request
.post('/api/client/register')
.send({
@ -16,35 +18,39 @@ test.serial('should register client', async (t) => {
instanceId: 'test',
strategies: ['default'],
started: Date.now(),
interval: 10
interval: 10,
})
.expect(202)
.then(destroy);
});
test.serial('should allow client to register multiple times', async (t) => {
const { request, destroy } = await setupApp('metrics_serial');
test.serial('should allow client to register multiple times', async t => {
t.plan(0);
const { request, destroy } = await setupApp('metrics_serial');
const clientRegistration = {
appName: 'multipleRegistration',
instanceId: 'test',
strategies: ['default', 'another'],
started: Date.now(),
interval: 10
appName: 'multipleRegistration',
instanceId: 'test',
strategies: ['default', 'another'],
started: Date.now(),
interval: 10,
};
return request
.post('/api/client/register')
.send(clientRegistration)
.expect(202)
.then(() => request
.post('/api/client/register')
.send(clientRegistration)
.expect(202))
.then(() =>
request
.post('/api/client/register')
.send(clientRegistration)
.expect(202)
)
.then(destroy);
});
test.serial('should accept client metrics', async t => {
const { request, destroy } = await setupApp('metrics_serial');
t.plan(0);
const { request, destroy } = await setupApp('metrics_serial');
return request
.post('/api/client/metrics')
.send({
@ -53,19 +59,20 @@ test.serial('should accept client metrics', async t => {
bucket: {
start: Date.now(),
stop: Date.now(),
toggles: {}
}
toggles: {},
},
})
.expect(202)
.then(destroy);
});
test.serial('should get application details', async t => {
const { request, destroy } = await setupApp('metrics_serial');
t.plan(3);
const { request, destroy } = await setupApp('metrics_serial');
return request
.get('/api/client/applications/demo-app-1')
.get('/api/admin/metrics/applications/demo-app-1')
.expect('Content-Type', /json/)
.expect((res) => {
.expect(res => {
t.true(res.status === 200);
t.true(res.body.appName === 'demo-app-1');
t.true(res.body.instances.length === 1);
@ -74,11 +81,12 @@ test.serial('should get application details', async t => {
});
test.serial('should get list of applications', async t => {
const { request, destroy } = await setupApp('metrics_serial');
t.plan(2);
const { request, destroy } = await setupApp('metrics_serial');
return request
.get('/api/client/applications')
.get('/api/admin/metrics/applications')
.expect('Content-Type', /json/)
.expect((res) => {
.expect(res => {
t.true(res.status === 200);
t.true(res.body.applications.length === 2);
})

View File

@ -1,102 +1,124 @@
'use strict';
const test = require('ava');
const { setupApp } = require('./helpers/test-helper');
const logger = require('../../lib/logger');
const { test } = require('ava');
const { setupApp } = require('./../../helpers/test-helper');
const logger = require('../../../../lib/logger');
test.beforeEach(() => {
test.beforeEach(() => {
logger.setLevel('FATAL');
});
test.serial('gets all strategies', async (t) => {
test.serial('gets all strategies', async t => {
t.plan(1);
const { request, destroy } = await setupApp('strategy_api_serial');
return request
.get('/api/strategies')
.get('/api/admin/strategies')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
t.true(res.body.strategies.length === 2, 'expected to have two strategies');
.expect(res => {
t.true(
res.body.strategies.length === 2,
'expected to have two strategies'
);
})
.then(destroy);
});
test.serial('gets a strategy by name', async (t) => {
test.serial('gets a strategy by name', async t => {
t.plan(0);
const { request, destroy } = await setupApp('strategy_api_serial');
return request
.get('/api/strategies/default')
.get('/api/admin/strategies/default')
.expect('Content-Type', /json/)
.expect(200)
.then(destroy);
});
test.serial('cant get a strategy by name that dose not exist', async (t) => {
test.serial('cant get a strategy by name that dose not exist', async t => {
t.plan(0);
const { request, destroy } = await setupApp('strategy_api_serial');
return request
.get('/api/strategies/mystrategy')
.get('/api/admin/strategies/mystrategy')
.expect('Content-Type', /json/)
.expect(404)
.then(destroy);
});
test.serial('creates a new strategy', async (t) => {
test.serial('creates a new strategy', async t => {
t.plan(0);
const { request, destroy } = await setupApp('strategy_api_serial');
return request
.post('/api/strategies')
.send({ name: 'myCustomStrategy', description: 'Best strategy ever.', parameters: [] })
.post('/api/admin/strategies')
.send({
name: 'myCustomStrategy',
description: 'Best strategy ever.',
parameters: [],
})
.set('Content-Type', 'application/json')
.expect(201)
.then(destroy);
});
test.serial('requires new strategies to have a name', async (t) => {
test.serial('requires new strategies to have a name', async t => {
t.plan(0);
const { request, destroy } = await setupApp('strategy_api_serial');
return request
.post('/api/strategies')
.post('/api/admin/strategies')
.send({ name: '' })
.set('Content-Type', 'application/json')
.expect(400)
.then(destroy);
});
test.serial('refuses to create a strategy with an existing name', async (t) => {
test.serial('refuses to create a strategy with an existing name', async t => {
t.plan(0);
const { request, destroy } = await setupApp('strategy_api_serial');
return request
.post('/api/strategies')
.post('/api/admin/strategies')
.send({ name: 'default', parameters: [] })
.set('Content-Type', 'application/json')
.expect(403)
.then(destroy);
});
test.serial('deletes a new strategy', async (t) => {
test.serial('deletes a new strategy', async t => {
t.plan(0);
const { request, destroy } = await setupApp('strategy_api_serial');
return request
.delete('/api/strategies/usersWithEmail')
.delete('/api/admin/strategies/usersWithEmail')
.expect(200)
.then(destroy);
});
test.serial('can\'t delete a strategy that dose not exist', async (t) => {
test.serial("can't delete a strategy that dose not exist", async t => {
t.plan(0);
const { request, destroy } = await setupApp('strategy_api_serial', false);
return request
.delete('/api/strategies/unknown')
.expect(404);
.delete('/api/admin/strategies/unknown')
.expect(404)
.then(destroy);
});
test.serial('updates a exiting strategy', async (t) => {
test.serial('updates a exiting strategy', async t => {
t.plan(0);
const { request, destroy } = await setupApp('strategy_api_serial');
return request
.put('/api/strategies/default')
.send({ name: 'default', description: 'Default is the best!', parameters: [] })
.put('/api/admin/strategies/default')
.send({
name: 'default',
description: 'Default is the best!',
parameters: [],
})
.set('Content-Type', 'application/json')
.expect(200)
.then(destroy);
});
test.serial('cant update a unknown strategy', async (t) => {
test.serial('cant update a unknown strategy', async t => {
t.plan(0);
const { request, destroy } = await setupApp('strategy_api_serial');
return request
.put('/api/strategies/unknown')
.put('/api/admin/strategies/unknown')
.send({ name: 'unkown', parameters: [] })
.set('Content-Type', 'application/json')
.expect(404)

View File

@ -0,0 +1,5 @@
'use strict';
const { test } = require('ava');
test.todo('e2e client feature');

View File

@ -0,0 +1,5 @@
'use strict';
const { test } = require('ava');
test.todo('e2e client metrics');

View File

@ -1,208 +0,0 @@
'use strict';
const { test } = require('ava');
const { setupApp } = require('./helpers/test-helper');
const logger = require('../../lib/logger');
test.beforeEach(() => {
logger.setLevel('FATAL');
});
test.serial('returns three feature toggles', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
return request
.get('/features')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
t.true(res.body.features.length === 3);
})
.then(destroy);
});
test.serial('gets a feature by name', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
return request
.get('/features/featureX')
.expect('Content-Type', /json/)
.expect(200)
.then(destroy);
});
test.serial('cant get feature that dose not exist', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
logger.setLevel('FATAL');
return request
.get('/features/myfeature')
.expect('Content-Type', /json/)
.expect(404)
.then(destroy);
});
test.serial('creates new feature toggle', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
return request
.post('/features')
.send({ name: 'com.test.feature', enabled: false, strategies: [{name: 'default'}] })
.set('Content-Type', 'application/json')
.expect(201)
.then(destroy);
});
test.serial('creates new feature toggle with createdBy', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
logger.setLevel('FATAL');
request
.post('/features')
.send({ name: 'com.test.Username', enabled: false, strategies: [{name: 'default'}] })
.set('Cookie', ['username=ivaosthu'])
.set('Content-Type', 'application/json')
.end(() => {
return request
.get('/api/events')
.expect((res) => {
t.true(res.body.events[0].createdBy === 'ivaosthu');
})
.then(destroy);
});
});
test.serial('require new feature toggle to have a name', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
logger.setLevel('FATAL');
return request
.post('/features')
.send({ name: '' })
.set('Content-Type', 'application/json')
.expect(400)
.then(destroy);
});
test.serial('can not change status of feature toggle that does not exist', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
logger.setLevel('FATAL');
return request
.put('/features/should-not-exist')
.send({ name: 'should-not-exist', enabled: false })
.set('Content-Type', 'application/json')
.expect(404).then(destroy);
});
test.serial('can change status of feature toggle that does exist', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
logger.setLevel('FATAL');
return request
.put('/features/featureY')
.send({ name: 'featureY', enabled: true, strategies: [{name: 'default'}] })
.set('Content-Type', 'application/json')
.expect(200).then(destroy);
});
test.serial('can not toggle of feature that does not exist', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
logger.setLevel('FATAL');
return request
.post('/features/should-not-exist/toggle')
.set('Content-Type', 'application/json')
.expect(404).then(destroy);
});
test.serial('can toggle a feature that does exist', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
logger.setLevel('FATAL');
return request
.post('/features/featureY/toggle')
.set('Content-Type', 'application/json')
.expect(200).then(destroy);
});
test.serial('archives a feature by name', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
return request
.delete('/features/featureX')
.expect(200).then(destroy);
});
test.serial('can not archive unknown feature', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
return request
.delete('/features/featureUnknown')
.expect(404).then(destroy);
});
test.serial('refuses to create a feature with an existing name', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
return request
.post('/features')
.send({ name: 'featureX' })
.set('Content-Type', 'application/json')
.expect(403).then(destroy);
});
test.serial('refuses to validate a feature with an existing name', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
return request
.post('/features-validate')
.send({ name: 'featureX' })
.set('Content-Type', 'application/json')
.expect(403).then(destroy);
});
test.serial('new strategies api automatically map existing strategy to strategies array', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
t.plan(3);
return request
.get('/features/featureY')
.expect('Content-Type', /json/)
.expect((res) => {
t.true(res.body.strategies.length === 1, 'expected strategy added to strategies');
t.true(res.body.strategy === res.body.strategies[0].name);
t.deepEqual(res.body.parameters, res.body.strategies[0].parameters);
})
.then(destroy);
});
test.serial('new strategies api can add two strategies to a feature toggle', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
return request
.put('/features/featureY')
.send({
name: 'featureY',
description: 'soon to be the #14 feature',
enabled: false,
strategies: [
{
name: 'baz',
parameters: { foo: 'bar' },
},
],
})
.set('Content-Type', 'application/json')
.expect(200)
.then(destroy);
});
test.serial('new strategies api should not be allowed to post both strategy and strategies', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
logger.setLevel('FATAL');
return request
.post('/features')
.send({
name: 'featureConfusing',
description: 'soon to be the #14 feature',
enabled: false,
strategy: 'baz',
parameters: {},
strategies: [
{
name: 'baz',
parameters: { foo: 'bar' },
},
],
})
.set('Content-Type', 'application/json')
.expect(400)
.then(destroy);
});

View File

@ -1,16 +1,18 @@
'use strict';
const test = require('ava');
const { test } = require('ava');
const { setupApp } = require('./helpers/test-helper');
const logger = require('../../lib/logger');
test.beforeEach(() => {
test.beforeEach(() => {
logger.setLevel('FATAL');
});
test('returns health good', async (t) => {
test('returns health good', async t => {
t.plan(0);
const { request, destroy } = await setupApp('health');
return request.get('/health')
return request
.get('/health')
.expect('Content-Type', /json/)
.expect(200)
.expect('{"health":"GOOD"}')

View File

@ -1,6 +1,6 @@
'use strict';
function getDatabaseUrl () {
function getDatabaseUrl() {
if (process.env.TEST_DATABASE_URL) {
return process.env.TEST_DATABASE_URL;
} else {

View File

@ -18,16 +18,21 @@ delete process.env.DATABASE_URL;
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
function createApp (databaseSchema = 'test') {
function createApp(databaseSchema = 'test') {
const options = {
databaseUrl: require('./database-config').getDatabaseUrl(),
databaseSchema,
minPool: 0,
maxPool: 0,
};
const db = createDb({ databaseUrl: options.databaseUrl, minPool: 0, maxPool: 0 });
const db = createDb({
databaseUrl: options.databaseUrl,
minPool: 0,
maxPool: 0,
});
return db.raw(`CREATE SCHEMA IF NOT EXISTS ${options.databaseSchema}`)
return db
.raw(`CREATE SCHEMA IF NOT EXISTS ${options.databaseSchema}`)
.then(() => migrator(options))
.then(() => {
db.destroy();
@ -36,14 +41,14 @@ function createApp (databaseSchema = 'test') {
return {
stores,
request: supertest(app),
destroy () {
destroy() {
return stores.db.destroy();
},
};
});
}
function createStrategies (stores) {
function createStrategies(stores) {
return [
{
name: 'default',
@ -52,15 +57,14 @@ function createStrategies (stores) {
},
{
name: 'usersWithEmail',
description: 'Active for users defined in the comma-separated emails-parameter.',
parameters: [
{ name: 'emails', type: 'string' },
],
description:
'Active for users defined in the comma-separated emails-parameter.',
parameters: [{ name: 'emails', type: 'string' }],
},
].map(strategy => stores.strategyStore._createStrategy(strategy));
}
function createApplications (stores) {
function createApplications(stores) {
return [
{
appName: 'demo-app-1',
@ -74,7 +78,7 @@ function createApplications (stores) {
].map(client => stores.clientApplicationsStore.upsert(client));
}
function createClientInstance (stores) {
function createClientInstance(stores) {
return [
{
appName: 'demo-app-1',
@ -93,7 +97,7 @@ function createClientInstance (stores) {
].map(client => stores.clientInstanceStore.insert(client));
}
function createFeatures (stores) {
function createFeatures(stores) {
return [
{
name: 'featureX',
@ -105,23 +109,27 @@ function createFeatures (stores) {
name: 'featureY',
description: 'soon to be the #1 feature',
enabled: false,
strategies: [{
name: 'baz',
parameters: {
foo: 'bar',
strategies: [
{
name: 'baz',
parameters: {
foo: 'bar',
},
},
}],
],
},
{
name: 'featureZ',
description: 'terrible feature',
enabled: true,
strategies: [{
name: 'baz',
parameters: {
foo: 'rab',
strategies: [
{
name: 'baz',
parameters: {
foo: 'rab',
},
},
}],
],
},
{
name: 'featureArchivedX',
@ -135,29 +143,33 @@ function createFeatures (stores) {
description: 'soon to be the #1 feature',
enabled: false,
archived: true,
strategies: [{
name: 'baz',
parameters: {
foo: 'bar',
strategies: [
{
name: 'baz',
parameters: {
foo: 'bar',
},
},
}],
],
},
{
name: 'featureArchivedZ',
description: 'terrible feature',
enabled: true,
archived: true,
strategies: [{
name: 'baz',
parameters: {
foo: 'rab',
strategies: [
{
name: 'baz',
parameters: {
foo: 'rab',
},
},
}],
],
},
].map(feature => stores.featureToggleStore._createFeature(feature));
}
function resetDatabase (stores) {
function resetDatabase(stores) {
return Promise.all([
stores.db('strategies').del(),
stores.db('features').del(),
@ -166,20 +178,22 @@ function resetDatabase (stores) {
]);
}
function setupDatabase (stores) {
function setupDatabase(stores) {
return Promise.all(
createStrategies(stores)
.concat(createFeatures(stores)
.concat(createClientInstance(stores))
.concat(createApplications(stores))));
createStrategies(stores).concat(
createFeatures(stores)
.concat(createClientInstance(stores))
.concat(createApplications(stores))
)
);
}
module.exports = {
setupApp (name) {
return createApp(name).then((app) => {
return resetDatabase(app.stores)
setupApp(name) {
return createApp(name).then(app =>
resetDatabase(app.stores)
.then(() => setupDatabase(app.stores))
.then(() => app);
});
.then(() => app)
);
},
};

6
test/fixtures/fake-event-store.js vendored Normal file
View File

@ -0,0 +1,6 @@
'use strict';
module.exports = () => ({
store: () => Promise.resolve(),
getEvents: () => Promise.resolve([]),
});

View File

@ -1,10 +1,9 @@
'use strict';
module.exports = () => {
module.exports = () => {
const _features = [];
return {
getFeature: (name) => {
getFeature: name => {
const toggle = _features.find(f => f.name === name);
if (toggle) {
return Promise.resolve(toggle);
@ -13,6 +12,6 @@ module.exports = () => {
}
},
getFeatures: () => Promise.resolve(_features),
addFeature: (feature) => _features.push(feature),
addFeature: feature => _features.push(feature),
};
};

View File

@ -3,12 +3,12 @@
const { EventEmitter } = require('events');
class FakeMetricsStore extends EventEmitter {
getMetricsLastHour () {
getMetricsLastHour() {
return Promise.resolve([]);
}
insert () {
insert() {
return Promise.resolve();
}
}
}
module.exports = FakeMetricsStore;
module.exports = FakeMetricsStore;

View File

@ -1,15 +1,13 @@
'use strict';
const NotFoundError = require('../../../../lib/error/notfound-error');
const NotFoundError = require('../../lib/error/notfound-error');
module.exports = () => {
const _strategies = [{ name: 'default', parameters: {} }];
return {
getStrategies: () => Promise.resolve(_strategies),
getStrategy: (name) => {
getStrategy: name => {
const strategy = _strategies.find(s => s.name === name);
if (strategy) {
return Promise.resolve(strategy);
@ -17,6 +15,6 @@ module.exports = () => {
return Promise.reject(new NotFoundError('Not found!'));
}
},
addStrategy: (strat) => _strategies.push(strat),
addStrategy: strat => _strategies.push(strat),
};
};

View File

@ -7,8 +7,6 @@ const featureToggleStore = require('./fake-feature-toggle-store');
const eventStore = require('./fake-event-store');
const strategyStore = require('./fake-strategies-store');
module.exports = {
createStores: () => {
const db = {

View File

@ -1,7 +0,0 @@
'use strict';
module.exports = () => {
return {
store: () => Promise.resolve(),
};
};

View File

@ -1,168 +0,0 @@
'use strict';
const test = require('ava');
const store = require('./fixtures/store');
const supertest = require('supertest');
const logger = require('../../../lib/logger');
const getApp = require('../../../lib/app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
test.beforeEach(() => {
logger.setLevel('FATAL');
});
function getSetup () {
const stores = store.createStores();
const app = getApp({
baseUriPath: '',
stores,
eventBus,
});
return {
request: supertest(app),
stores,
};
}
test('should register client', () => {
const { request } = getSetup();
return request
.post('/api/client/register')
.send({
appName: 'demo',
instanceId: 'test',
strategies: ['default'],
started: Date.now(),
interval: 10,
})
.expect(202);
});
test('should require appName field', () => {
const { request } = getSetup();
return request
.post('/api/client/register')
.expect(400);
});
test('should require strategies field', () => {
const { request } = getSetup();
return request
.post('/api/client/register')
.send({
appName: 'demo',
instanceId: 'test',
// strategies: ['default'],
started: Date.now(),
interval: 10,
})
.expect(400);
});
test('should validate client metrics', () => {
const { request } = getSetup();
return request
.post('/api/client/metrics')
.send({ random: 'blush' })
.expect(400);
});
test('should accept client metrics', () => {
const { request } = getSetup();
return request
.post('/api/client/metrics')
.send({
appName: 'demo',
instanceId: '1',
bucket: {
start: Date.now(),
stop: Date.now(),
toggles: {},
},
})
.expect(202);
});
test('should return seen toggles even when there is nothing', t => {
const { request } = getSetup();
return request
.get('/api/client/seen-toggles')
.expect(200)
.expect((res) => {
t.true(res.body.length === 0);
});
});
test('should return list of seen-toggles per app', t => {
const { request, stores } = getSetup();
const appName = 'asd!23';
stores.clientMetricsStore.emit('metrics', {
appName,
instanceId: 'instanceId',
bucket: {
start: new Date(),
stop: new Date(),
toggles: {
toggleX: { yes: 123, no: 0 },
toggleY: { yes: 123, no: 0 }
},
},
});
return request
.get('/api/client/seen-toggles')
.expect(200)
.expect((res) => {
const seenAppsWithToggles = res.body;
t.true(seenAppsWithToggles.length === 1);
t.true(seenAppsWithToggles[0].appName === appName);
t.true(seenAppsWithToggles[0].seenToggles.length === 2);
});
});
test('should return feature-toggles metrics even when there is nothing', t => {
const { request } = getSetup();
return request
.get('/api/client/metrics/feature-toggles')
.expect(200);
});
test('should return metrics for all toggles', t => {
const { request, stores } = getSetup();
const appName = 'asd!23';
stores.clientMetricsStore.emit('metrics', {
appName,
instanceId: 'instanceId',
bucket: {
start: new Date(),
stop: new Date(),
toggles: {
toggleX: { yes: 123, no: 0 },
toggleY: { yes: 123, no: 0 },
},
},
});
return request
.get('/api/client/metrics/feature-toggles')
.expect(200)
.expect((res) => {
const metrics = res.body;
t.true(metrics.lastHour !== undefined);
t.true(metrics.lastMinute !== undefined);
});
});
test('should return list of client applications', t => {
const { request } = getSetup();
return request
.get('/api/client/applications')
.expect(200)
.expect((res) => {
t.true(res.body.applications.length === 0);
});
});

4947
yarn.lock Normal file

File diff suppressed because it is too large Load Diff