mirror of
https://github.com/Unleash/unleash.git
synced 2026-10-12 15:39:06 +00:00
Add Tags and tag types
- First iteration of api for tags and tag-types - Documentation in place - Adds three new tables - tag_types - tags - feature_tag - Tagging a feature is adding a row in the feature_tag join table * #665 Co-authored-by: Simen Bekkhus <sbekkhus91@gmail.com> Co-authored-by: Ivar Conradi Østhus <ivarconr@gmail.com>
This commit is contained in:
@@ -259,7 +259,8 @@ test.serial('creates new feature toggle without type', async t => {
|
||||
enabled: false,
|
||||
strategies: [{ name: 'default' }],
|
||||
});
|
||||
await request.get('/api/admin/features/com.test.noType').expect(res => {
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
return request.get('/api/admin/features/com.test.noType').expect(res => {
|
||||
t.is(res.body.type, 'release');
|
||||
});
|
||||
});
|
||||
@@ -273,7 +274,90 @@ test.serial('creates new feature toggle with type', async t => {
|
||||
enabled: false,
|
||||
strategies: [{ name: 'default' }],
|
||||
});
|
||||
await request.get('/api/admin/features/com.test.withType').expect(res => {
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
return request.get('/api/admin/features/com.test.withType').expect(res => {
|
||||
t.is(res.body.type, 'killswitch');
|
||||
});
|
||||
});
|
||||
|
||||
test.serial('tags feature with new tag', async t => {
|
||||
t.plan(1);
|
||||
const request = await setupApp(stores);
|
||||
await request.post('/api/admin/features').send({
|
||||
name: 'test.feature',
|
||||
type: 'killswitch',
|
||||
enabled: true,
|
||||
strategies: [{ name: 'default' }],
|
||||
});
|
||||
await request
|
||||
.post('/api/admin/features/test.feature/tags')
|
||||
.send({
|
||||
value: 'TeamGreen',
|
||||
type: 'simple',
|
||||
})
|
||||
.set('Content-Type', 'application/json');
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
return request.get('/api/admin/features/test.feature/tags').expect(res => {
|
||||
t.is(res.body.tags[0].value, 'TeamGreen');
|
||||
});
|
||||
});
|
||||
|
||||
test.serial(
|
||||
'tagging a feature with an already existing tag should be a noop',
|
||||
async t => {
|
||||
t.plan(1);
|
||||
const request = await setupApp(stores);
|
||||
await request.post('/api/admin/features').send({
|
||||
name: 'test.feature',
|
||||
type: 'killswitch',
|
||||
enabled: true,
|
||||
strategies: [{ name: 'default' }],
|
||||
});
|
||||
await request.post('/api/admin/features/test.feature/tags').send({
|
||||
value: 'TeamGreen',
|
||||
type: 'simple',
|
||||
});
|
||||
await request.post('/api/admin/features/test.feature/tags').send({
|
||||
value: 'TeamGreen',
|
||||
type: 'simple',
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
return request
|
||||
.get('/api/admin/features/test.feature/tags')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
t.is(res.body.tags.length, 1);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.serial('can untag feature', async t => {
|
||||
t.plan(1);
|
||||
const request = await setupApp(stores);
|
||||
await request.post('/api/admin/features').send({
|
||||
name: 'test.feature',
|
||||
type: 'killswitch',
|
||||
enabled: true,
|
||||
strategies: [{ name: 'default' }],
|
||||
});
|
||||
const tag = { value: 'TeamGreen', type: 'simple' };
|
||||
await request
|
||||
.post('/api/admin/features/test.feature/tags')
|
||||
.send(tag)
|
||||
.expect(201);
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
await request
|
||||
.delete(
|
||||
`/api/admin/features/test.feature/tags/${tag.type}/${tag.value}`,
|
||||
)
|
||||
.expect(200);
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
return request
|
||||
.get('/api/admin/features/test.feature/tags')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
t.is(res.body.tags.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('ava');
|
||||
const dbInit = require('../../helpers/database-init');
|
||||
const { setupApp } = require('../../helpers/test-helper');
|
||||
const getLogger = require('../../../fixtures/no-logger');
|
||||
|
||||
let stores;
|
||||
|
||||
test.before(async () => {
|
||||
const db = await dbInit('tag_types_api_serial', getLogger);
|
||||
stores = db.stores;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
await stores.db.destroy();
|
||||
});
|
||||
|
||||
test.serial('returns list of tag-types', async t => {
|
||||
t.plan(1);
|
||||
const request = await setupApp(stores);
|
||||
return request
|
||||
.get('/api/admin/tag-types')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
t.is(res.body.tagTypes.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test.serial('gets a tag-type by name', async t => {
|
||||
t.plan(1);
|
||||
const request = await setupApp(stores);
|
||||
return request
|
||||
.get('/api/admin/tag-types/simple')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
t.is(res.body.tagType.name, 'simple');
|
||||
});
|
||||
});
|
||||
|
||||
test.serial('querying a tag-type that does not exist yields 404', async t => {
|
||||
t.plan(0);
|
||||
const request = await setupApp(stores);
|
||||
return request.get('/api/admin/tag-types/non-existent').expect(404);
|
||||
});
|
||||
|
||||
test.serial('Can create a new tag type', async t => {
|
||||
const request = await setupApp(stores);
|
||||
await request.post('/api/admin/tag-types').send({
|
||||
name: 'slack',
|
||||
description:
|
||||
'Tag your feature toggles with slack channel to post updates for toggle to',
|
||||
icon:
|
||||
'http://icons.iconarchive.com/icons/papirus-team/papirus-apps/32/slack-icon.png',
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
return request
|
||||
.get('/api/admin/tag-types/slack')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
t.is(
|
||||
res.body.tagType.icon,
|
||||
'http://icons.iconarchive.com/icons/papirus-team/papirus-apps/32/slack-icon.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.serial('Invalid tag types gets rejected', async t => {
|
||||
const request = await setupApp(stores);
|
||||
await request
|
||||
.post('/api/admin/tag-types')
|
||||
.send({
|
||||
name: 'Something url unsafe',
|
||||
description: 'A fancy description',
|
||||
icon: 'NoIcon',
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(400)
|
||||
.expect(res => {
|
||||
t.is(res.body.details[0].message, '"name" must be URL friendly');
|
||||
});
|
||||
});
|
||||
|
||||
test.serial('Can update a tag types description and icon', async t => {
|
||||
const request = await setupApp(stores);
|
||||
await request.get('/api/admin/tag-types/simple').expect(200);
|
||||
await request
|
||||
.put('/api/admin/tag-types/simple')
|
||||
.send({
|
||||
description: 'new description',
|
||||
icon: '$',
|
||||
})
|
||||
.expect(200);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
return request
|
||||
.get('/api/admin/tag-types/simple')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
t.is(res.body.tagType.icon, '$');
|
||||
});
|
||||
});
|
||||
test.serial('Invalid updates gets rejected', async t => {
|
||||
const request = await setupApp(stores);
|
||||
await request.get('/api/admin/tag-types/simple').expect(200);
|
||||
await request
|
||||
.put('/api/admin/tag-types/simple')
|
||||
.send({
|
||||
description: 15125,
|
||||
icon: 125,
|
||||
})
|
||||
.expect(400)
|
||||
.expect(res => {
|
||||
t.is(res.body.details[0].message, '"description" must be a string');
|
||||
t.is(res.body.details[1].message, '"icon" must be a string');
|
||||
});
|
||||
});
|
||||
|
||||
test.serial(
|
||||
'Validation of tag-types returns 200 for valid tag-types',
|
||||
async t => {
|
||||
const request = await setupApp(stores);
|
||||
await request
|
||||
.post('/api/admin/tag-types/validate')
|
||||
.send({
|
||||
name: 'something',
|
||||
description: 'A fancy description',
|
||||
icon: 'NoIcon',
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
t.is(res.body.valid, true);
|
||||
});
|
||||
},
|
||||
);
|
||||
test.serial('Invalid tag-types get refused by validator', async t => {
|
||||
const request = await setupApp(stores);
|
||||
await request
|
||||
.post('/api/admin/tag-types/validate')
|
||||
.send({
|
||||
name: 'Something url unsafe',
|
||||
description: 'A fancy description',
|
||||
icon: 'NoIcon',
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(400)
|
||||
.expect(res => {
|
||||
t.is(res.body.details[0].message, '"name" must be URL friendly');
|
||||
});
|
||||
});
|
||||
|
||||
test.serial('Can delete tag type', async t => {
|
||||
t.plan(0);
|
||||
const request = await setupApp(stores);
|
||||
await request
|
||||
.delete('/api/admin/tag-types/simple')
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
return request.get('/api/admin/tag-types/simple').expect(404);
|
||||
});
|
||||
|
||||
test.serial('Non unique tag-types gets rejected', async t => {
|
||||
const request = await setupApp(stores);
|
||||
await request
|
||||
.post('/api/admin/tag-types')
|
||||
.send({
|
||||
name: 'my-tag-type',
|
||||
description: 'Will be accepted first time',
|
||||
icon: 'T',
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(201);
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
return request
|
||||
.post('/api/admin/tag-types')
|
||||
.send({
|
||||
name: 'my-tag-type',
|
||||
description: 'Will not be accepted this time',
|
||||
icon: 'T',
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(res => {
|
||||
t.is(res.status, 409);
|
||||
});
|
||||
});
|
||||
|
||||
test.serial('Only required argument should be name', async t => {
|
||||
const request = await setupApp(stores);
|
||||
const name = 'some-tag-type';
|
||||
return request
|
||||
.post('/api/admin/tag-types')
|
||||
.send({ name, description: '' })
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(201)
|
||||
.expect(res => {
|
||||
t.is(res.body.name, name);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('ava');
|
||||
const dbInit = require('../../helpers/database-init');
|
||||
const { setupApp } = require('../../helpers/test-helper');
|
||||
const getLogger = require('../../../fixtures/no-logger');
|
||||
|
||||
let stores;
|
||||
|
||||
test.before(async () => {
|
||||
const db = await dbInit('tag_api_serial', getLogger);
|
||||
stores = db.stores;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
await stores.db.destroy();
|
||||
});
|
||||
|
||||
test.serial('returns list of tags', async t => {
|
||||
const request = await setupApp(stores);
|
||||
request
|
||||
.post('/api/admin/tags')
|
||||
.send({
|
||||
value: 'Tester',
|
||||
type: 'simple',
|
||||
})
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
return request
|
||||
.get('/api/admin/tags')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
t.is(res.body.tags.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test.serial('gets a tag by type and value', async t => {
|
||||
const request = await setupApp(stores);
|
||||
request
|
||||
.post('/api/admin/tags')
|
||||
.send({
|
||||
value: 'Tester',
|
||||
type: 'simple',
|
||||
})
|
||||
.set('Content-Type', 'application/json');
|
||||
return request
|
||||
.get('/api/admin/tags/simple/Tester')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
t.is(res.body.tag.value, 'Tester');
|
||||
});
|
||||
});
|
||||
|
||||
test.serial('cannot get tag that does not exist', async t => {
|
||||
t.plan(1);
|
||||
const request = await setupApp(stores);
|
||||
return request.get('/api/admin/tags/simple/12158091').expect(res => {
|
||||
t.is(res.status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
test.serial('Can create a tag', async t => {
|
||||
const request = await setupApp(stores);
|
||||
return request
|
||||
.post('/api/admin/tags')
|
||||
.send({
|
||||
id: 1,
|
||||
value: 'TeamRed',
|
||||
type: 'simple',
|
||||
})
|
||||
.expect(res => {
|
||||
t.is(res.status, 201);
|
||||
});
|
||||
});
|
||||
test.serial('Can validate a tag', async t => {
|
||||
const request = await setupApp(stores);
|
||||
return request
|
||||
.post('/api/admin/tags')
|
||||
.send({
|
||||
value: 124,
|
||||
type: 'not url friendly',
|
||||
})
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(400)
|
||||
.expect(res => {
|
||||
t.is(res.body.details.length, 2);
|
||||
t.is(res.body.details[0].message, '"value" must be a string');
|
||||
t.is(res.body.details[1].message, '"type" must be URL friendly');
|
||||
});
|
||||
});
|
||||
test.serial('Can delete a tag', async t => {
|
||||
const request = await setupApp(stores);
|
||||
await request
|
||||
.delete('/api/admin/tags/simple/Tester')
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
return request
|
||||
.get('/api/admin/tags')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
t.is(
|
||||
res.body.tags.indexOf(
|
||||
tag => tag.value === 'Tester' && tag.type === 'simple',
|
||||
),
|
||||
-1,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,8 @@ async function resetDatabase(stores) {
|
||||
stores.db('context_fields').del(),
|
||||
stores.db('users').del(),
|
||||
stores.db('projects').del(),
|
||||
stores.db('tags').del(),
|
||||
stores.db('tag_types').del(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -52,16 +54,29 @@ function createFeatures(store) {
|
||||
return dbState.features.map(f => store._createFeature(f));
|
||||
}
|
||||
|
||||
async function setupDatabase(stores) {
|
||||
const updates = [];
|
||||
updates.push(...createStrategies(stores.strategyStore));
|
||||
updates.push(...createContextFields(stores.contextFieldStore));
|
||||
updates.push(...createFeatures(stores.featureToggleStore));
|
||||
updates.push(...createClientInstance(stores.clientInstanceStore));
|
||||
updates.push(...createApplications(stores.clientApplicationsStore));
|
||||
updates.push(...createProjects(stores.projectStore));
|
||||
function tagFeatures(store) {
|
||||
return dbState.features.map(f =>
|
||||
store.tagFeature({
|
||||
featureName: f.name,
|
||||
value: 'Tester',
|
||||
type: 'simple',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(updates);
|
||||
function createTagTypes(store) {
|
||||
return dbState.tag_types.map(t => store._createTagType(t));
|
||||
}
|
||||
|
||||
async function setupDatabase(stores) {
|
||||
await Promise.all(createStrategies(stores.strategyStore));
|
||||
await Promise.all(createContextFields(stores.contextFieldStore));
|
||||
await Promise.all(createFeatures(stores.featureToggleStore));
|
||||
await Promise.all(createClientInstance(stores.clientInstanceStore));
|
||||
await Promise.all(createApplications(stores.clientApplicationsStore));
|
||||
await Promise.all(createProjects(stores.projectStore));
|
||||
await Promise.all(createTagTypes(stores.tagTypeStore));
|
||||
await Promise.all(tagFeatures(stores.featureTagStore));
|
||||
}
|
||||
|
||||
module.exports = async function init(databaseSchema = 'test', getLogger) {
|
||||
|
||||
@@ -155,5 +155,12 @@
|
||||
"id": "default",
|
||||
"name": "Default"
|
||||
}
|
||||
],
|
||||
"tag_types": [
|
||||
{
|
||||
"name": "simple",
|
||||
"description": "Arbitrary tags. Used to simplify filtering of features",
|
||||
"icon": "#"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
'use strict';
|
||||
|
||||
const NotFoundError = require('../../lib/error/notfound-error');
|
||||
|
||||
module.exports = () => {
|
||||
const _tags = [];
|
||||
const _featureTags = {};
|
||||
return {
|
||||
getAllOfType: type => {
|
||||
const tags = _tags.filter(t => t.type === type);
|
||||
return Promise.resolve(tags);
|
||||
},
|
||||
addTag: tag => {
|
||||
_tags.push({ value: tag.value, type: tag.type });
|
||||
},
|
||||
removeTag: tag => {
|
||||
_tags.splice(
|
||||
_tags.indexOf(
|
||||
t => t.value === tag.value && t.type === tag.type,
|
||||
),
|
||||
1,
|
||||
);
|
||||
},
|
||||
getTags: () => Promise.resolve(_tags),
|
||||
getTagByTypeAndValue: (type, value) => {
|
||||
const tag = _tags.find(t => t.type === type && t.value === value);
|
||||
if (tag) {
|
||||
return Promise.resolve(tag);
|
||||
}
|
||||
return Promise.reject(new NotFoundError('Could not find tag'));
|
||||
},
|
||||
tagFeature: event => {
|
||||
_featureTags[event.featureName] =
|
||||
_featureTags[event.featureName] || [];
|
||||
const tag = {
|
||||
value: event.value,
|
||||
type: event.type,
|
||||
};
|
||||
_featureTags[event.featureName].push(tag);
|
||||
return tag;
|
||||
},
|
||||
untagFeature: event => {
|
||||
const tags = _featureTags[event.featureName];
|
||||
_featureTags[event.featureName] = tags.splice(
|
||||
tags.indexOf(
|
||||
t => t.type === event.type && t.value === event.value,
|
||||
),
|
||||
1,
|
||||
);
|
||||
},
|
||||
getAllTagsForFeature: featureName => {
|
||||
return _featureTags[featureName] || [];
|
||||
},
|
||||
};
|
||||
};
|
||||
Vendored
+2
@@ -4,6 +4,7 @@ const ClientMetricsStore = require('./fake-metrics-store');
|
||||
const clientInstanceStore = require('./fake-client-instance-store');
|
||||
const clientApplicationsStore = require('./fake-client-applications-store');
|
||||
const featureToggleStore = require('./fake-feature-toggle-store');
|
||||
const featureTagStore = require('./fake-feature-tag-store');
|
||||
const eventStore = require('./fake-event-store');
|
||||
const strategyStore = require('./fake-strategies-store');
|
||||
const contextFieldStore = require('./fake-context-store');
|
||||
@@ -23,6 +24,7 @@ module.exports = {
|
||||
clientMetricsStore: new ClientMetricsStore(),
|
||||
clientInstanceStore: clientInstanceStore(),
|
||||
featureToggleStore: featureToggleStore(),
|
||||
featureTagStore: featureTagStore(),
|
||||
eventStore: eventStore(),
|
||||
strategyStore: strategyStore(),
|
||||
contextFieldStore: contextFieldStore(),
|
||||
|
||||
Reference in New Issue
Block a user