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:
Christopher Kolstad
2021-01-04 10:29:33 +01:00
parent a26dea661d
commit 43801f1f13
32 changed files with 2662 additions and 932 deletions
+86 -2
View File
@@ -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);
});
});
+203
View File
@@ -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);
});
});
+112
View File
@@ -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,
);
});
});