1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-04 01:18:20 +02:00

Merge pull request #712 from Unleash/fix-711-conflict-on-existing-tag

Make feature-toggle-store return 409
This commit is contained in:
Christopher Kolstad 2021-02-09 10:54:05 +01:00 committed by GitHub
commit 8c6bc9f118
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 124 additions and 31 deletions

View File

@ -3,6 +3,8 @@
const metricsHelper = require('../metrics-helper'); const metricsHelper = require('../metrics-helper');
const { DB_TIME } = require('../events'); const { DB_TIME } = require('../events');
const NotFoundError = require('../error/notfound-error'); const NotFoundError = require('../error/notfound-error');
const FeatureHasTagError = require('../error/feature-has-tag-error');
const { UNIQUE_CONSTRAINT_VIOLATION } = require('../error/db-error');
const FEATURE_COLUMNS = [ const FEATURE_COLUMNS = [
'name', 'name',
@ -240,8 +242,15 @@ class FeatureToggleStore {
const stopTimer = this.timer('tagFeature'); const stopTimer = this.timer('tagFeature');
await this.db(FEATURE_TAG_TABLE) await this.db(FEATURE_TAG_TABLE)
.insert(this.featureAndTagToRow(featureName, tag)) .insert(this.featureAndTagToRow(featureName, tag))
.onConflict(['feature_name', 'tag_type', 'tag_value']) .catch(err => {
.ignore(); if (err.code === UNIQUE_CONSTRAINT_VIOLATION) {
throw new FeatureHasTagError(
`${featureName} already had the tag: [${tag.type}:${tag.value}]`,
);
} else {
throw err;
}
});
stopTimer(); stopTimer();
return tag; return tag;
} }

3
lib/error/db-error.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
UNIQUE_CONSTRAINT_VIOLATION: '23505',
};

View File

@ -0,0 +1,26 @@
'use strict';
class FeatureHasTagError extends Error {
constructor(message) {
super();
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
this.message = message;
}
toJSON() {
const obj = {
isJoi: true,
name: this.constructor.name,
details: [
{
message: this.message,
},
],
};
return obj;
}
}
module.exports = FeatureHasTagError;

View File

@ -42,6 +42,11 @@ const handleErrors = (res, logger, error) => {
.status(400) .status(400)
.json(error) .json(error)
.end(); .end();
case 'FeatureHasTagError':
return res
.status(409)
.json(error)
.end();
default: default:
logger.error('Server failed executing request', error); logger.error('Server failed executing request', error);
return res.status(500).end(); return res.status(500).end();

View File

@ -101,6 +101,7 @@
"eslint-config-prettier": "^6.10.1", "eslint-config-prettier": "^6.10.1",
"eslint-plugin-import": "^2.20.2", "eslint-plugin-import": "^2.20.2",
"eslint-plugin-prettier": "^3.1.3", "eslint-plugin-prettier": "^3.1.3",
"faker": "^5.3.1",
"fetch-mock": "^9.11.0", "fetch-mock": "^9.11.0",
"husky": "^4.2.3", "husky": "^4.2.3",
"lint-staged": "^10.0.7", "lint-staged": "^10.0.7",

View File

@ -1,6 +1,7 @@
'use strict'; 'use strict';
const test = require('ava'); const test = require('ava');
const faker = require('faker');
const dbInit = require('../../helpers/database-init'); const dbInit = require('../../helpers/database-init');
const { setupApp } = require('../../helpers/test-helper'); const { setupApp } = require('../../helpers/test-helper');
const getLogger = require('../../../fixtures/no-logger'); const getLogger = require('../../../fixtures/no-logger');
@ -338,24 +339,25 @@ test.serial(
test.serial('can untag feature', async t => { test.serial('can untag feature', async t => {
t.plan(1); t.plan(1);
const request = await setupApp(stores); const request = await setupApp(stores);
const feature1Name = faker.helpers.slugify(faker.lorem.words(3));
await request.post('/api/admin/features').send({ await request.post('/api/admin/features').send({
name: 'test.feature', name: feature1Name,
type: 'killswitch', type: 'killswitch',
enabled: true, enabled: true,
strategies: [{ name: 'default' }], strategies: [{ name: 'default' }],
}); });
const tag = { value: 'TeamGreen', type: 'simple' }; const tag = { value: faker.lorem.word(), type: 'simple' };
await request await request
.post('/api/admin/features/test.feature/tags') .post(`/api/admin/features/${feature1Name}/tags`)
.send(tag) .send(tag)
.expect(201); .expect(201);
await request await request
.delete( .delete(
`/api/admin/features/test.feature/tags/${tag.type}/${tag.value}`, `/api/admin/features/${feature1Name}/tags/${tag.type}/${tag.value}`,
) )
.expect(200); .expect(200);
return request return request
.get('/api/admin/features/test.feature/tags') .get(`/api/admin/features/${feature1Name}/tags`)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect(res => { .expect(res => {
@ -366,76 +368,86 @@ test.serial('can untag feature', async t => {
test.serial('Can get features tagged by tag', async t => { test.serial('Can get features tagged by tag', async t => {
t.plan(2); t.plan(2);
const request = await setupApp(stores); const request = await setupApp(stores);
const feature1Name = faker.helpers.slugify(faker.lorem.words(3));
const feature2Name = faker.helpers.slugify(faker.lorem.words(3));
await request.post('/api/admin/features').send({ await request.post('/api/admin/features').send({
name: 'test.feature', name: feature1Name,
type: 'killswitch', type: 'killswitch',
enabled: true, enabled: true,
strategies: [{ name: 'default' }], strategies: [{ name: 'default' }],
}); });
await request.post('/api/admin/features').send({ await request.post('/api/admin/features').send({
name: 'test.feature2', name: feature2Name,
type: 'killswitch', type: 'killswitch',
enabled: true, enabled: true,
strategies: [{ name: 'default' }], strategies: [{ name: 'default' }],
}); });
const tag = { value: 'Crazy', type: 'simple' }; const tag = { value: faker.lorem.word(), type: 'simple' };
await request await request
.post('/api/admin/features/test.feature/tags') .post(`/api/admin/features/${feature1Name}/tags`)
.send(tag) .send(tag)
.expect(201); .expect(201);
return request return request
.get('/api/admin/features?tag=simple:Crazy') .get(`/api/admin/features?tag=${tag.type}:${tag.value}`)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect(res => { .expect(res => {
t.is(res.body.features.length, 1); t.is(res.body.features.length, 1);
t.is(res.body.features[0].name, 'test.feature'); t.is(res.body.features[0].name, feature1Name);
}); });
}); });
test.serial('Can query for multiple tags using OR', async t => { test.serial('Can query for multiple tags using OR', async t => {
t.plan(2); t.plan(3);
const request = await setupApp(stores); const request = await setupApp(stores);
const feature1Name = faker.helpers.slugify(faker.lorem.words(3));
const feature2Name = faker.helpers.slugify(faker.lorem.words(3));
await request.post('/api/admin/features').send({ await request.post('/api/admin/features').send({
name: 'test.feature', name: feature1Name,
type: 'killswitch', type: 'killswitch',
enabled: true, enabled: true,
strategies: [{ name: 'default' }], strategies: [{ name: 'default' }],
}); });
await request.post('/api/admin/features').send({ await request.post('/api/admin/features').send({
name: 'test.feature2', name: feature2Name,
type: 'killswitch', type: 'killswitch',
enabled: true, enabled: true,
strategies: [{ name: 'default' }], strategies: [{ name: 'default' }],
}); });
const tag = { value: 'Crazy', type: 'simple' }; const tag = { value: faker.name.firstName(), type: 'simple' };
const tag2 = { value: 'tagb', type: 'simple' }; const tag2 = { value: faker.name.firstName(), type: 'simple' };
await request await request
.post('/api/admin/features/test.feature/tags') .post(`/api/admin/features/${feature1Name}/tags`)
.send(tag) .send(tag)
.expect(201); .expect(201);
await request await request
.post('/api/admin/features/test.feature2/tags') .post(`/api/admin/features/${feature2Name}/tags`)
.send(tag2) .send(tag2)
.expect(201); .expect(201);
return request return request
.get('/api/admin/features?tag[]=simple:Crazy&tag[]=simple:tagb') .get(
`/api/admin/features?tag[]=${tag.type}:${tag.value}&tag[]=${tag2.type}:${tag2.value}`,
)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect(res => { .expect(res => {
t.is(res.body.features.length, 2); t.is(res.body.features.length, 2);
t.is(res.body.features[0].name, 'test.feature'); t.true(res.body.features.some(f => f.name === feature1Name));
t.true(res.body.features.some(f => f.name === feature2Name));
}); });
}); });
test.serial('Querying with multiple filters ANDs the filters', async t => { test.serial('Querying with multiple filters ANDs the filters', async t => {
const request = await setupApp(stores); const request = await setupApp(stores);
const feature1Name = `test.${faker.helpers.slugify(faker.hacker.phrase())}`;
const feature2Name = faker.helpers.slugify(faker.lorem.words());
await request.post('/api/admin/features').send({ await request.post('/api/admin/features').send({
name: 'test.feature', name: feature1Name,
type: 'killswitch', type: 'killswitch',
enabled: true, enabled: true,
strategies: [{ name: 'default' }], strategies: [{ name: 'default' }],
}); });
await request.post('/api/admin/features').send({ await request.post('/api/admin/features').send({
name: 'test.feature2', name: feature2Name,
type: 'killswitch', type: 'killswitch',
enabled: true, enabled: true,
strategies: [{ name: 'default' }], strategies: [{ name: 'default' }],
@ -446,14 +458,14 @@ test.serial('Querying with multiple filters ANDs the filters', async t => {
enabled: true, enabled: true,
strategies: [{ name: 'default' }], strategies: [{ name: 'default' }],
}); });
const tag = { value: 'Crazy', type: 'simple' }; const tag = { value: faker.lorem.word(), type: 'simple' };
const tag2 = { value: 'tagb', type: 'simple' }; const tag2 = { value: faker.name.firstName(), type: 'simple' };
await request await request
.post('/api/admin/features/test.feature/tags') .post(`/api/admin/features/${feature1Name}/tags`)
.send(tag) .send(tag)
.expect(201); .expect(201);
await request await request
.post('/api/admin/features/test.feature2/tags') .post(`/api/admin/features/${feature2Name}/tags`)
.send(tag2) .send(tag2)
.expect(201); .expect(201);
await request await request
@ -461,16 +473,48 @@ test.serial('Querying with multiple filters ANDs the filters', async t => {
.send(tag) .send(tag)
.expect(201); .expect(201);
await request await request
.get('/api/admin/features?tag=simple:Crazy') .get(`/api/admin/features?tag=${tag.type}:${tag.value}`)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect(res => t.is(res.body.features.length, 2)); .expect(res => t.is(res.body.features.length, 2));
await request await request
.get('/api/admin/features?namePrefix=test&tag=simple:Crazy') .get(`/api/admin/features?namePrefix=test&tag=${tag.type}:${tag.value}`)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect(res => { .expect(res => {
t.is(res.body.features.length, 1); t.is(res.body.features.length, 1);
t.is(res.body.features[0].name, 'test.feature'); t.is(res.body.features[0].name, feature1Name);
}); });
}); });
test.serial(
'Tagging a feature with a tag it already has should return 409',
async t => {
const request = await setupApp(stores);
const feature1Name = `test.${faker.helpers.slugify(
faker.lorem.words(3),
)}`;
await request.post('/api/admin/features').send({
name: feature1Name,
type: 'killswitch',
enabled: true,
strategies: [{ name: 'default' }],
});
const tag = { value: faker.lorem.word(), type: 'simple' };
await request
.post(`/api/admin/features/${feature1Name}/tags`)
.send(tag)
.expect(201);
return request
.post(`/api/admin/features/${feature1Name}/tags`)
.send(tag)
.expect(409)
.expect(res => {
t.is(
res.body.details[0].message,
`${feature1Name} already had the tag: [${tag.type}:${tag.value}]`,
);
});
},
);

View File

@ -2284,6 +2284,11 @@ eyes@0.1.x:
resolved "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz" resolved "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz"
integrity sha1-Ys8SAjTGg3hdkCNIqADvPgzCC8A= integrity sha1-Ys8SAjTGg3hdkCNIqADvPgzCC8A=
faker@^5.3.1:
version "5.3.1"
resolved "https://registry.yarnpkg.com/faker/-/faker-5.3.1.tgz#67f8f5c170b97a76b875389e0e8b9155da7b4853"
integrity sha512-sVdoApX/awJHO9DZHZsHVaJBNFiJW0n3lPs0q/nFxp/Mtya1dr2sCMktST3mdxNMHjkvKTTMAW488E+jH1eSbg==
fast-deep-equal@^3.1.1: fast-deep-equal@^3.1.1:
version "3.1.3" version "3.1.3"
resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"