mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	Merge pull request #693 from Unleash/feat-690-toggle-filtering
feat: #690 toggle filtering
This commit is contained in:
		
						commit
						00c7d634d2
					
				| @ -1,5 +1,9 @@ | |||||||
| # Changelog | # Changelog | ||||||
| 
 | 
 | ||||||
|  | ## 3.11 | ||||||
|  | 
 | ||||||
|  | - feat: Add support for filtering toggles on tags, projects or namePrefix (#690) | ||||||
|  | 
 | ||||||
| ## 3.10.1 | ## 3.10.1 | ||||||
| 
 | 
 | ||||||
| - fix: remove fields from /api/client/features respnse (#692) | - fix: remove fields from /api/client/features respnse (#692) | ||||||
|  | |||||||
| @ -73,6 +73,28 @@ This endpoint is the one all admin ui should use to fetch all available feature | |||||||
| } | } | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
|  | #### Filter feature toggles | ||||||
|  | 
 | ||||||
|  | Supports three params for now | ||||||
|  | 
 | ||||||
|  | - `tag` - filters for features tagged with tag | ||||||
|  | - `project` - filters for features belonging to project | ||||||
|  | - `namePrefix` - filters for features beginning with prefix | ||||||
|  | 
 | ||||||
|  | For `tag` and `project` performs OR filtering if multiple arguments | ||||||
|  | 
 | ||||||
|  | To filter for any feature tagged with a `simple` tag with value `taga` or a `simple` tag with value `tagb` use | ||||||
|  | 
 | ||||||
|  | `GET https://unleash.host.com/api/admin/features?tag[]=simple:taga&tag[]simple:tagb` | ||||||
|  | 
 | ||||||
|  | To filter for any feature belonging to project `myproject` use | ||||||
|  | 
 | ||||||
|  | `GET https://unleash.host.com/api/admin/features?project=myproject` | ||||||
|  | 
 | ||||||
|  | Response format is the same as `api/admin/features` | ||||||
|  | 
 | ||||||
|  | ### Fetch specific feature toggle | ||||||
|  | 
 | ||||||
| `GET: http://unleash.host.com/api/admin/features/:featureName` | `GET: http://unleash.host.com/api/admin/features/:featureName` | ||||||
| 
 | 
 | ||||||
| Used to fetch details about a specific featureToggle. This is mostly provded to make it easy to debug the API and should not be used by the client implementations. | Used to fetch details about a specific featureToggle. This is mostly provded to make it easy to debug the API and should not be used by the client implementations. | ||||||
|  | |||||||
| @ -68,7 +68,27 @@ This endpoint should never return anything besides a valid _20X or 304-response_ | |||||||
| } | } | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
| You may limit the response by sending a `namePrefix` query-parameter. | #### Filter feature toggles | ||||||
|  | 
 | ||||||
|  | Supports three params for now | ||||||
|  | 
 | ||||||
|  | - `tag` - filters for features tagged with tag | ||||||
|  | - `project` - filters for features belonging to project | ||||||
|  | - `namePrefix` - filters for features beginning with prefix | ||||||
|  | 
 | ||||||
|  | For `tag` and `project` performs OR filtering if multiple arguments | ||||||
|  | 
 | ||||||
|  | To filter for any feature tagged with a `simple` tag with value `taga` or a `simple` tag with value `tagb` use | ||||||
|  | 
 | ||||||
|  | `GET https://unleash.host.com/api/client/features?tag[]=simple:taga&tag[]simple:tagb` | ||||||
|  | 
 | ||||||
|  | To filter for any feature belonging to project `myproject` use | ||||||
|  | 
 | ||||||
|  | `GET https://unleash.host.com/api/client/features?project=myproject` | ||||||
|  | 
 | ||||||
|  | Response format is the same as `api/client/features` | ||||||
|  | 
 | ||||||
|  | ### Get specific feature toggle | ||||||
| 
 | 
 | ||||||
| `GET: http://unleash.host.com/api/client/features/:featureName` | `GET: http://unleash.host.com/api/client/features/:featureName` | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,82 +0,0 @@ | |||||||
| 'use strict'; |  | ||||||
| 
 |  | ||||||
| const metricsHelper = require('../metrics-helper'); |  | ||||||
| const { DB_TIME } = require('../events'); |  | ||||||
| 
 |  | ||||||
| const FEATURE_TAG_COLUMNS = ['feature_name', 'tag_type', 'tag_value']; |  | ||||||
| const FEATURE_TAG_TABLE = 'feature_tag'; |  | ||||||
| 
 |  | ||||||
| class FeatureTagStore { |  | ||||||
|     constructor(db, eventBus, getLogger) { |  | ||||||
|         this.db = db; |  | ||||||
|         this.logger = getLogger('feature-tag-store.js'); |  | ||||||
| 
 |  | ||||||
|         this.timer = action => |  | ||||||
|             metricsHelper.wrapTimer(eventBus, DB_TIME, { |  | ||||||
|                 store: 'tag', |  | ||||||
|                 action, |  | ||||||
|             }); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     async getAllTagsForFeature(featureName) { |  | ||||||
|         const stopTimer = this.timer('getAllForFeature'); |  | ||||||
|         const rows = await this.db |  | ||||||
|             .select(FEATURE_TAG_COLUMNS) |  | ||||||
|             .from(FEATURE_TAG_TABLE) |  | ||||||
|             .where({ feature_name: featureName }); |  | ||||||
|         stopTimer(); |  | ||||||
|         return rows.map(this.featureTagRowToTag); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     async tagFeature(featureName, tag) { |  | ||||||
|         const stopTimer = this.timer('tagFeature'); |  | ||||||
|         await this.db(FEATURE_TAG_TABLE) |  | ||||||
|             .insert(this.featureAndTagToRow(featureName, tag)) |  | ||||||
|             .onConflict(['feature_name', 'tag_type', 'tag_value']) |  | ||||||
|             .ignore(); |  | ||||||
|         stopTimer(); |  | ||||||
|         return tag; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     async untagFeature(featureName, tag) { |  | ||||||
|         const stopTimer = this.timer('untagFeature'); |  | ||||||
|         try { |  | ||||||
|             await this.db(FEATURE_TAG_TABLE) |  | ||||||
|                 .where(this.featureAndTagToRow(featureName, tag)) |  | ||||||
|                 .delete(); |  | ||||||
|         } catch (err) { |  | ||||||
|             this.logger.error(err); |  | ||||||
|         } |  | ||||||
|         stopTimer(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     rowToTag(row) { |  | ||||||
|         if (row) { |  | ||||||
|             return { |  | ||||||
|                 value: row.value, |  | ||||||
|                 type: row.type, |  | ||||||
|             }; |  | ||||||
|         } |  | ||||||
|         return null; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     featureTagRowToTag(row) { |  | ||||||
|         if (row) { |  | ||||||
|             return { |  | ||||||
|                 value: row.tag_value, |  | ||||||
|                 type: row.tag_type, |  | ||||||
|             }; |  | ||||||
|         } |  | ||||||
|         return null; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     featureAndTagToRow(featureName, { type, value }) { |  | ||||||
|         return { |  | ||||||
|             feature_name: featureName, |  | ||||||
|             tag_type: type, |  | ||||||
|             tag_value: value, |  | ||||||
|         }; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| module.exports = FeatureTagStore; |  | ||||||
| @ -16,16 +16,15 @@ const FEATURE_COLUMNS = [ | |||||||
|     'created_at', |     'created_at', | ||||||
|     'last_seen_at', |     'last_seen_at', | ||||||
| ]; | ]; | ||||||
| const TABLE = 'features'; |  | ||||||
| 
 | 
 | ||||||
| const FEATURE_COLUMNS_CLIENT = [ | const mapperToColumnNames = { | ||||||
|     'name', |     createdAt: 'created_at', | ||||||
|     'type', |     lastSeenAt: 'last_seen_at', | ||||||
|     'enabled', | }; | ||||||
|     'stale', | const TABLE = 'features'; | ||||||
|     'strategies', | const FEATURE_TAG_COLUMNS = ['feature_name', 'tag_type', 'tag_value']; | ||||||
|     'variants', | const FEATURE_TAG_FILTER_COLUMNS = ['tag_type', 'tag_value']; | ||||||
| ]; | const FEATURE_TAG_TABLE = 'feature_tag'; | ||||||
| 
 | 
 | ||||||
| class FeatureToggleStore { | class FeatureToggleStore { | ||||||
|     constructor(db, eventBus, getLogger) { |     constructor(db, eventBus, getLogger) { | ||||||
| @ -38,43 +37,36 @@ class FeatureToggleStore { | |||||||
|             }); |             }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async getFeatures() { |     async getFeatures(query = {}, fields = FEATURE_COLUMNS) { | ||||||
|         const stopTimer = this.timer('getAll'); |         const stopTimer = this.timer('getAll'); | ||||||
| 
 |         const queryFields = fields.map(f => | ||||||
|         const rows = await this.db |             mapperToColumnNames[f] ? mapperToColumnNames[f] : f, | ||||||
|             .select(FEATURE_COLUMNS) |         ); | ||||||
|  |         let baseQuery = this.db | ||||||
|  |             .select(queryFields) | ||||||
|             .from(TABLE) |             .from(TABLE) | ||||||
|             .where({ archived: 0 }) |             .where({ archived: 0 }) | ||||||
|             .orderBy('name', 'asc'); |             .orderBy('name', 'asc'); | ||||||
| 
 |         if (query) { | ||||||
|         stopTimer(); |             if (query.tag) { | ||||||
| 
 |                 const tagQuery = this.db | ||||||
|         return rows.map(this.rowToFeature); |                     .from('feature_tag') | ||||||
|     } |                     .select('feature_name') | ||||||
| 
 |                     .whereIn(FEATURE_TAG_FILTER_COLUMNS, query.tag); | ||||||
|     async getFeaturesClient() { |                 baseQuery = baseQuery.whereIn('name', tagQuery); | ||||||
|         const stopTimer = this.timer('getAllClient'); |             } | ||||||
| 
 |             if (query.project) { | ||||||
|         const rows = await this.db |                 baseQuery = baseQuery.whereIn('project', query.project); | ||||||
|             .select(FEATURE_COLUMNS_CLIENT) |             } | ||||||
|             .from(TABLE) |             if (query.namePrefix) { | ||||||
|             .where({ archived: 0 }) |                 baseQuery = baseQuery.where( | ||||||
|             .orderBy('name', 'asc'); |                     'name', | ||||||
| 
 |                     'like', | ||||||
|         stopTimer(); |                     `${query.namePrefix}%`, | ||||||
| 
 |                 ); | ||||||
|         return rows.map(this.rowToFeature); |             } | ||||||
|     } |         } | ||||||
| 
 |         const rows = await baseQuery; | ||||||
|     async getClientFeatures() { |  | ||||||
|         const stopTimer = this.timer('getAll'); |  | ||||||
| 
 |  | ||||||
|         const rows = await this.db |  | ||||||
|             .select(FEATURE_COLUMNS) |  | ||||||
|             .from(TABLE) |  | ||||||
|             .where({ archived: 0 }) |  | ||||||
|             .orderBy('name', 'asc'); |  | ||||||
| 
 |  | ||||||
|         stopTimer(); |         stopTimer(); | ||||||
| 
 | 
 | ||||||
|         return rows.map(this.rowToFeature); |         return rows.map(this.rowToFeature); | ||||||
| @ -233,6 +225,66 @@ class FeatureToggleStore { | |||||||
|             this.logger.error('Could not drop features, error: ', err); |             this.logger.error('Could not drop features, error: ', err); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     async getAllTagsForFeature(featureName) { | ||||||
|  |         const stopTimer = this.timer('getAllForFeature'); | ||||||
|  |         const rows = await this.db | ||||||
|  |             .select(FEATURE_TAG_COLUMNS) | ||||||
|  |             .from(FEATURE_TAG_TABLE) | ||||||
|  |             .where({ feature_name: featureName }); | ||||||
|  |         stopTimer(); | ||||||
|  |         return rows.map(this.featureTagRowToTag); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     async tagFeature(featureName, tag) { | ||||||
|  |         const stopTimer = this.timer('tagFeature'); | ||||||
|  |         await this.db(FEATURE_TAG_TABLE) | ||||||
|  |             .insert(this.featureAndTagToRow(featureName, tag)) | ||||||
|  |             .onConflict(['feature_name', 'tag_type', 'tag_value']) | ||||||
|  |             .ignore(); | ||||||
|  |         stopTimer(); | ||||||
|  |         return tag; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     async untagFeature(featureName, tag) { | ||||||
|  |         const stopTimer = this.timer('untagFeature'); | ||||||
|  |         try { | ||||||
|  |             await this.db(FEATURE_TAG_TABLE) | ||||||
|  |                 .where(this.featureAndTagToRow(featureName, tag)) | ||||||
|  |                 .delete(); | ||||||
|  |         } catch (err) { | ||||||
|  |             this.logger.error(err); | ||||||
|  |         } | ||||||
|  |         stopTimer(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     rowToTag(row) { | ||||||
|  |         if (row) { | ||||||
|  |             return { | ||||||
|  |                 value: row.value, | ||||||
|  |                 type: row.type, | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  |         return null; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     featureTagRowToTag(row) { | ||||||
|  |         if (row) { | ||||||
|  |             return { | ||||||
|  |                 value: row.tag_value, | ||||||
|  |                 type: row.tag_type, | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  |         return null; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     featureAndTagToRow(featureName, { type, value }) { | ||||||
|  |         return { | ||||||
|  |             feature_name: featureName, | ||||||
|  |             tag_type: type, | ||||||
|  |             tag_value: value, | ||||||
|  |         }; | ||||||
|  |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| module.exports = FeatureToggleStore; | module.exports = FeatureToggleStore; | ||||||
|  | |||||||
| @ -14,7 +14,6 @@ const SettingStore = require('./setting-store'); | |||||||
| const UserStore = require('./user-store'); | const UserStore = require('./user-store'); | ||||||
| const ProjectStore = require('./project-store'); | const ProjectStore = require('./project-store'); | ||||||
| const TagStore = require('./tag-store'); | const TagStore = require('./tag-store'); | ||||||
| const FeatureTagStore = require('./feature-tag-store'); |  | ||||||
| const TagTypeStore = require('./tag-type-store'); | const TagTypeStore = require('./tag-type-store'); | ||||||
| 
 | 
 | ||||||
| module.exports.createStores = (config, eventBus) => { | module.exports.createStores = (config, eventBus) => { | ||||||
| @ -51,6 +50,5 @@ module.exports.createStores = (config, eventBus) => { | |||||||
|         projectStore: new ProjectStore(db, getLogger), |         projectStore: new ProjectStore(db, getLogger), | ||||||
|         tagStore: new TagStore(db, eventBus, getLogger), |         tagStore: new TagStore(db, eventBus, getLogger), | ||||||
|         tagTypeStore: new TagTypeStore(db, eventBus, getLogger), |         tagTypeStore: new TagTypeStore(db, eventBus, getLogger), | ||||||
|         featureTagStore: new FeatureTagStore(db, eventBus, getLogger), |  | ||||||
|     }; |     }; | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -9,6 +9,18 @@ const { | |||||||
| } = require('../../permissions'); | } = require('../../permissions'); | ||||||
| 
 | 
 | ||||||
| const version = 1; | const version = 1; | ||||||
|  | const fields = [ | ||||||
|  |     'name', | ||||||
|  |     'description', | ||||||
|  |     'type', | ||||||
|  |     'project', | ||||||
|  |     'enabled', | ||||||
|  |     'stale', | ||||||
|  |     'strategies', | ||||||
|  |     'variants', | ||||||
|  |     'createdAt', | ||||||
|  |     'lastSeenAt', | ||||||
|  | ]; | ||||||
| 
 | 
 | ||||||
| class FeatureController extends Controller { | class FeatureController extends Controller { | ||||||
|     constructor(config, { featureToggleService }) { |     constructor(config, { featureToggleService }) { | ||||||
| @ -37,7 +49,10 @@ class FeatureController extends Controller { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async getAllToggles(req, res) { |     async getAllToggles(req, res) { | ||||||
|         const features = await this.featureService.getFeatures(); |         const features = await this.featureService.getFeatures( | ||||||
|  |             req.query, | ||||||
|  |             fields, | ||||||
|  |         ); | ||||||
|         res.json({ version, features }); |         res.json({ version, features }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -31,7 +31,6 @@ function getSetup() { | |||||||
|         base, |         base, | ||||||
|         perms, |         perms, | ||||||
|         featureToggleStore: stores.featureToggleStore, |         featureToggleStore: stores.featureToggleStore, | ||||||
|         featureTagStore: stores.featureTagStore, |  | ||||||
|         request: supertest(app), |         request: supertest(app), | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
| @ -383,13 +382,7 @@ test('should be able to add tag for feature', t => { | |||||||
| }); | }); | ||||||
| test('should be able to get tags for feature', t => { | test('should be able to get tags for feature', t => { | ||||||
|     t.plan(1); |     t.plan(1); | ||||||
|     const { |     const { request, featureToggleStore, base, perms } = getSetup(); | ||||||
|         request, |  | ||||||
|         featureToggleStore, |  | ||||||
|         featureTagStore, |  | ||||||
|         base, |  | ||||||
|         perms, |  | ||||||
|     } = getSetup(); |  | ||||||
|     perms.withPermissions(UPDATE_FEATURE); |     perms.withPermissions(UPDATE_FEATURE); | ||||||
| 
 | 
 | ||||||
|     featureToggleStore.createFeature({ |     featureToggleStore.createFeature({ | ||||||
| @ -398,7 +391,7 @@ test('should be able to get tags for feature', t => { | |||||||
|         strategies: [{ name: 'default' }], |         strategies: [{ name: 'default' }], | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     featureTagStore.tagFeature('toggle.disabled', { |     featureToggleStore.tagFeature('toggle.disabled', { | ||||||
|         value: 'TeamGreen', |         value: 'TeamGreen', | ||||||
|         type: 'simple', |         type: 'simple', | ||||||
|     }); |     }); | ||||||
| @ -434,3 +427,99 @@ test('Invalid tag for feature should be rejected', t => { | |||||||
|             t.is(res.body.details[0].message, '"type" must be URL friendly'); |             t.is(res.body.details[0].message, '"type" must be URL friendly'); | ||||||
|         }); |         }); | ||||||
| }); | }); | ||||||
|  | 
 | ||||||
|  | test('Should be able to filter on tag', t => { | ||||||
|  |     t.plan(2); | ||||||
|  |     const { request, featureToggleStore, base, perms } = getSetup(); | ||||||
|  |     perms.withPermissions(UPDATE_FEATURE); | ||||||
|  | 
 | ||||||
|  |     featureToggleStore.createFeature({ | ||||||
|  |         name: 'toggle.tagged', | ||||||
|  |         enabled: false, | ||||||
|  |         strategies: [{ name: 'default' }], | ||||||
|  |     }); | ||||||
|  |     featureToggleStore.createFeature({ | ||||||
|  |         name: 'toggle.untagged', | ||||||
|  |         enabled: false, | ||||||
|  |         strategies: [{ name: 'default' }], | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     featureToggleStore.tagFeature('toggle.tagged', { | ||||||
|  |         type: 'simple', | ||||||
|  |         value: 'mytag', | ||||||
|  |     }); | ||||||
|  |     return request | ||||||
|  |         .get(`${base}/api/admin/features?tag=simple:mytag`) | ||||||
|  |         .expect(200) | ||||||
|  |         .expect('Content-Type', /json/) | ||||||
|  |         .expect(res => { | ||||||
|  |             t.is(res.body.features.length, 1); | ||||||
|  |             t.is(res.body.features[0].name, 'toggle.tagged'); | ||||||
|  |         }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Should be able to filter on name prefix', t => { | ||||||
|  |     t.plan(3); | ||||||
|  |     const { request, featureToggleStore, base, perms } = getSetup(); | ||||||
|  |     perms.withPermissions(UPDATE_FEATURE); | ||||||
|  | 
 | ||||||
|  |     featureToggleStore.createFeature({ | ||||||
|  |         name: 'a_team.toggle', | ||||||
|  |         enabled: false, | ||||||
|  |         strategies: [{ name: 'default' }], | ||||||
|  |     }); | ||||||
|  |     featureToggleStore.createFeature({ | ||||||
|  |         name: 'a_tag.toggle', | ||||||
|  |         enabled: false, | ||||||
|  |         strategies: [{ name: 'default' }], | ||||||
|  |     }); | ||||||
|  |     featureToggleStore.createFeature({ | ||||||
|  |         name: 'b_tag.toggle', | ||||||
|  |         enabled: false, | ||||||
|  |         strategies: [{ name: 'default' }], | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     return request | ||||||
|  |         .get(`${base}/api/admin/features?namePrefix=a_`) | ||||||
|  |         .expect(200) | ||||||
|  |         .expect('Content-Type', /json/) | ||||||
|  |         .expect(res => { | ||||||
|  |             t.is(res.body.features.length, 2); | ||||||
|  |             t.is(res.body.features[0].name, 'a_team.toggle'); | ||||||
|  |             t.is(res.body.features[1].name, 'a_tag.toggle'); | ||||||
|  |         }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Should be able to filter on project', t => { | ||||||
|  |     t.plan(3); | ||||||
|  |     const { request, featureToggleStore, base, perms } = getSetup(); | ||||||
|  |     perms.withPermissions(UPDATE_FEATURE); | ||||||
|  | 
 | ||||||
|  |     featureToggleStore.createFeature({ | ||||||
|  |         name: 'a_team.toggle', | ||||||
|  |         enabled: false, | ||||||
|  |         strategies: [{ name: 'default' }], | ||||||
|  |         project: 'projecta', | ||||||
|  |     }); | ||||||
|  |     featureToggleStore.createFeature({ | ||||||
|  |         name: 'a_tag.toggle', | ||||||
|  |         enabled: false, | ||||||
|  |         strategies: [{ name: 'default' }], | ||||||
|  |         project: 'projecta', | ||||||
|  |     }); | ||||||
|  |     featureToggleStore.createFeature({ | ||||||
|  |         name: 'b_tag.toggle', | ||||||
|  |         enabled: false, | ||||||
|  |         strategies: [{ name: 'default' }], | ||||||
|  |         project: 'projectb', | ||||||
|  |     }); | ||||||
|  |     return request | ||||||
|  |         .get(`${base}/api/admin/features?project=projecta`) | ||||||
|  |         .expect(200) | ||||||
|  |         .expect('Content-Type', /json/) | ||||||
|  |         .expect(res => { | ||||||
|  |             t.is(res.body.features.length, 2); | ||||||
|  |             t.is(res.body.features[0].name, 'a_team.toggle'); | ||||||
|  |             t.is(res.body.features[1].name, 'a_tag.toggle'); | ||||||
|  |         }); | ||||||
|  | }); | ||||||
|  | |||||||
| @ -1,10 +1,18 @@ | |||||||
| 'use strict'; | 'use strict'; | ||||||
| 
 | 
 | ||||||
| const Controller = require('../controller'); | const Controller = require('../controller'); | ||||||
| const { filter } = require('./util'); |  | ||||||
| 
 | 
 | ||||||
| const version = 1; | const version = 1; | ||||||
| 
 | 
 | ||||||
|  | const FEATURE_COLUMNS_CLIENT = [ | ||||||
|  |     'name', | ||||||
|  |     'type', | ||||||
|  |     'enabled', | ||||||
|  |     'stale', | ||||||
|  |     'strategies', | ||||||
|  |     'variants', | ||||||
|  | ]; | ||||||
|  | 
 | ||||||
| class FeatureController extends Controller { | class FeatureController extends Controller { | ||||||
|     constructor({ featureToggleService }, getLogger) { |     constructor({ featureToggleService }, getLogger) { | ||||||
|         super(); |         super(); | ||||||
| @ -15,11 +23,10 @@ class FeatureController extends Controller { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async getAll(req, res) { |     async getAll(req, res) { | ||||||
|         const nameFilter = filter('name', req.query.namePrefix); |         const features = await this.toggleService.getFeatures( | ||||||
| 
 |             req.query, | ||||||
|         const allFeatureToggles = await this.toggleService.getFeaturesClient(); |             FEATURE_COLUMNS_CLIENT, | ||||||
|         const features = nameFilter(allFeatureToggles); |         ); | ||||||
| 
 |  | ||||||
|         res.json({ version, features }); |         res.json({ version, features }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -72,7 +72,22 @@ test('support name prefix', t => { | |||||||
|         .expect('Content-Type', /json/) |         .expect('Content-Type', /json/) | ||||||
|         .expect(200) |         .expect(200) | ||||||
|         .expect(res => { |         .expect(res => { | ||||||
|             t.true(res.body.features.length === 2); |             t.is(res.body.features.length, 2); | ||||||
|             t.true(res.body.features[1].name === 'b_test2'); |             t.is(res.body.features[1].name, 'b_test2'); | ||||||
|  |         }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('support filtering on project', t => { | ||||||
|  |     t.plan(2); | ||||||
|  |     const { request, featureToggleStore, base } = getSetup(); | ||||||
|  |     featureToggleStore.createFeature({ name: 'a_test1', project: 'projecta' }); | ||||||
|  |     featureToggleStore.createFeature({ name: 'b_test2', project: 'projectb' }); | ||||||
|  |     return request | ||||||
|  |         .get(`${base}/api/client/features?project=projecta`) | ||||||
|  |         .expect('Content-Type', /json/) | ||||||
|  |         .expect(200) | ||||||
|  |         .expect(res => { | ||||||
|  |             t.is(res.body.features.length, 1); | ||||||
|  |             t.is(res.body.features[0].name, 'a_test1'); | ||||||
|         }); |         }); | ||||||
| }); | }); | ||||||
|  | |||||||
| @ -1,10 +0,0 @@ | |||||||
| 'use strict'; |  | ||||||
| 
 |  | ||||||
| const filter = (key, value) => { |  | ||||||
|     if (!key || !value) return array => array; |  | ||||||
|     return array => array.filter(item => item[key].startsWith(value)); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| module.exports = { |  | ||||||
|     filter, |  | ||||||
| }; |  | ||||||
| @ -88,4 +88,24 @@ const featureSchema = joi | |||||||
|     }) |     }) | ||||||
|     .options({ allowUnknown: false, stripUnknown: true, abortEarly: false }); |     .options({ allowUnknown: false, stripUnknown: true, abortEarly: false }); | ||||||
| 
 | 
 | ||||||
| module.exports = { featureSchema, strategiesSchema, nameSchema }; | const querySchema = joi | ||||||
|  |     .object() | ||||||
|  |     .keys({ | ||||||
|  |         tag: joi | ||||||
|  |             .array() | ||||||
|  |             .allow(null) | ||||||
|  |             .items(joi.string().pattern(/\w+:.+/, { name: 'tag' })) | ||||||
|  |             .optional(), | ||||||
|  |         project: joi | ||||||
|  |             .array() | ||||||
|  |             .allow(null) | ||||||
|  |             .items(joi.string().alphanum()) | ||||||
|  |             .optional(), | ||||||
|  |         namePrefix: joi | ||||||
|  |             .string() | ||||||
|  |             .allow(null) | ||||||
|  |             .optional(), | ||||||
|  |     }) | ||||||
|  |     .options({ allowUnknown: false, stripUnknown: true, abortEarly: false }); | ||||||
|  | 
 | ||||||
|  | module.exports = { featureSchema, strategiesSchema, nameSchema, querySchema }; | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| 'use strict'; | 'use strict'; | ||||||
| 
 | 
 | ||||||
| const test = require('ava'); | const test = require('ava'); | ||||||
| const { featureSchema } = require('./feature-schema'); | const { featureSchema, querySchema } = require('./feature-schema'); | ||||||
| 
 | 
 | ||||||
| test('should require URL firendly name', t => { | test('should require URL firendly name', t => { | ||||||
|     const toggle = { |     const toggle = { | ||||||
| @ -227,3 +227,41 @@ test('should not accept empty list of constraint values', t => { | |||||||
|         '"strategies[0].constraints[0].values" must contain at least 1 items', |         '"strategies[0].constraints[0].values" must contain at least 1 items', | ||||||
|     ); |     ); | ||||||
| }); | }); | ||||||
|  | 
 | ||||||
|  | test('Filter queries should accept a list of tag values', t => { | ||||||
|  |     const query = { | ||||||
|  |         tag: ['simple:valuea', 'simple:valueb'], | ||||||
|  |     }; | ||||||
|  |     const { value } = querySchema.validate(query); | ||||||
|  |     t.deepEqual(value, { tag: ['simple:valuea', 'simple:valueb'] }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Filter queries should reject tag values with missing type prefix', t => { | ||||||
|  |     const query = { | ||||||
|  |         tag: ['simple', 'simple'], | ||||||
|  |     }; | ||||||
|  |     const { error } = querySchema.validate(query); | ||||||
|  |     t.deepEqual( | ||||||
|  |         error.details[0].message, | ||||||
|  |         '"tag[0]" with value "simple" fails to match the tag pattern', | ||||||
|  |     ); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Filter queries should allow project names', t => { | ||||||
|  |     const query = { | ||||||
|  |         project: ['projecta'], | ||||||
|  |     }; | ||||||
|  |     const { value } = querySchema.validate(query); | ||||||
|  |     t.deepEqual(value, { project: ['projecta'] }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('Filter queries should reject project names that are not alphanum', t => { | ||||||
|  |     const query = { | ||||||
|  |         project: ['project name with space'], | ||||||
|  |     }; | ||||||
|  |     const { error } = querySchema.validate(query); | ||||||
|  |     t.deepEqual( | ||||||
|  |         error.details[0].message, | ||||||
|  |         '"project[0]" must only contain alpha-numeric characters', | ||||||
|  |     ); | ||||||
|  | }); | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| const { FEATURE_TAGGED, FEATURE_UNTAGGED } = require('../event-type'); | const { FEATURE_TAGGED, FEATURE_UNTAGGED } = require('../event-type'); | ||||||
| const { featureSchema, nameSchema } = require('./feature-schema'); | const { featureSchema, nameSchema, querySchema } = require('./feature-schema'); | ||||||
| const { tagSchema } = require('./tag-schema'); | const { tagSchema } = require('./tag-schema'); | ||||||
| const NameExistsError = require('../error/name-exists-error'); | const NameExistsError = require('../error/name-exists-error'); | ||||||
| const NotFoundError = require('../error/notfound-error'); | const NotFoundError = require('../error/notfound-error'); | ||||||
| @ -12,23 +12,40 @@ const { | |||||||
| } = require('../event-type'); | } = require('../event-type'); | ||||||
| 
 | 
 | ||||||
| class FeatureToggleService { | class FeatureToggleService { | ||||||
|     constructor( |     constructor({ featureToggleStore, tagStore, eventStore }, { getLogger }) { | ||||||
|         { featureToggleStore, featureTagStore, tagStore, eventStore }, |  | ||||||
|         { getLogger }, |  | ||||||
|     ) { |  | ||||||
|         this.featureToggleStore = featureToggleStore; |         this.featureToggleStore = featureToggleStore; | ||||||
|         this.tagStore = tagStore; |         this.tagStore = tagStore; | ||||||
|         this.eventStore = eventStore; |         this.eventStore = eventStore; | ||||||
|         this.featureTagStore = featureTagStore; |  | ||||||
|         this.logger = getLogger('services/feature-toggle-service.js'); |         this.logger = getLogger('services/feature-toggle-service.js'); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async getFeatures() { |     async getFeatures(query, fields) { | ||||||
|         return this.featureToggleStore.getFeatures(); |         const preppedQuery = await this.prepQuery(query); | ||||||
|  |         return this.featureToggleStore.getFeatures(preppedQuery, fields); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async getFeaturesClient() { |     paramToArray(param) { | ||||||
|         return this.featureToggleStore.getFeaturesClient(); |         if (!param) { | ||||||
|  |             return param; | ||||||
|  |         } | ||||||
|  |         return Array.isArray(param) ? param : [param]; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     async prepQuery({ tag, project, namePrefix }) { | ||||||
|  |         if (!tag && !project && !namePrefix) { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |         const tagQuery = this.paramToArray(tag); | ||||||
|  |         const projectQuery = this.paramToArray(project); | ||||||
|  |         const query = await querySchema.validateAsync({ | ||||||
|  |             tag: tagQuery, | ||||||
|  |             project: projectQuery, | ||||||
|  |             namePrefix, | ||||||
|  |         }); | ||||||
|  |         if (query.tag) { | ||||||
|  |             query.tag = query.tag.map(q => q.split(':')); | ||||||
|  |         } | ||||||
|  |         return query; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async getArchivedFeatures() { |     async getArchivedFeatures() { | ||||||
| @ -92,14 +109,14 @@ class FeatureToggleService { | |||||||
| 
 | 
 | ||||||
|     /** Tag releated  */ |     /** Tag releated  */ | ||||||
|     async listTags(featureName) { |     async listTags(featureName) { | ||||||
|         return this.featureTagStore.getAllTagsForFeature(featureName); |         return this.featureToggleStore.getAllTagsForFeature(featureName); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async addTag(featureName, tag, userName) { |     async addTag(featureName, tag, userName) { | ||||||
|         await nameSchema.validateAsync({ name: featureName }); |         await nameSchema.validateAsync({ name: featureName }); | ||||||
|         const validatedTag = await tagSchema.validateAsync(tag); |         const validatedTag = await tagSchema.validateAsync(tag); | ||||||
|         await this.createTagIfNeeded(validatedTag, userName); |         await this.createTagIfNeeded(validatedTag, userName); | ||||||
|         await this.featureTagStore.tagFeature(featureName, validatedTag); |         await this.featureToggleStore.tagFeature(featureName, validatedTag); | ||||||
|         await this.eventStore.store({ |         await this.eventStore.store({ | ||||||
|             type: FEATURE_TAGGED, |             type: FEATURE_TAGGED, | ||||||
|             createdBy: userName, |             createdBy: userName, | ||||||
| @ -129,7 +146,7 @@ class FeatureToggleService { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async removeTag(featureName, tag, userName) { |     async removeTag(featureName, tag, userName) { | ||||||
|         await this.featureTagStore.untagFeature(featureName, tag); |         await this.featureToggleStore.untagFeature(featureName, tag); | ||||||
|         await this.eventStore.store({ |         await this.eventStore.store({ | ||||||
|             type: FEATURE_UNTAGGED, |             type: FEATURE_UNTAGGED, | ||||||
|             createdBy: userName, |             createdBy: userName, | ||||||
|  | |||||||
| @ -362,3 +362,115 @@ test.serial('can untag feature', async t => { | |||||||
|             t.is(res.body.tags.length, 0); |             t.is(res.body.tags.length, 0); | ||||||
|         }); |         }); | ||||||
| }); | }); | ||||||
|  | 
 | ||||||
|  | test.serial('Can get features tagged by tag', async t => { | ||||||
|  |     t.plan(2); | ||||||
|  |     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').send({ | ||||||
|  |         name: 'test.feature2', | ||||||
|  |         type: 'killswitch', | ||||||
|  |         enabled: true, | ||||||
|  |         strategies: [{ name: 'default' }], | ||||||
|  |     }); | ||||||
|  |     const tag = { value: 'Crazy', type: 'simple' }; | ||||||
|  |     await request | ||||||
|  |         .post('/api/admin/features/test.feature/tags') | ||||||
|  |         .send(tag) | ||||||
|  |         .expect(201); | ||||||
|  |     return request | ||||||
|  |         .get('/api/admin/features?tag=simple:Crazy') | ||||||
|  |         .expect('Content-Type', /json/) | ||||||
|  |         .expect(200) | ||||||
|  |         .expect(res => { | ||||||
|  |             t.is(res.body.features.length, 1); | ||||||
|  |             t.is(res.body.features[0].name, 'test.feature'); | ||||||
|  |         }); | ||||||
|  | }); | ||||||
|  | test.serial('Can query for multiple tags using OR', async t => { | ||||||
|  |     t.plan(2); | ||||||
|  |     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').send({ | ||||||
|  |         name: 'test.feature2', | ||||||
|  |         type: 'killswitch', | ||||||
|  |         enabled: true, | ||||||
|  |         strategies: [{ name: 'default' }], | ||||||
|  |     }); | ||||||
|  |     const tag = { value: 'Crazy', type: 'simple' }; | ||||||
|  |     const tag2 = { value: 'tagb', type: 'simple' }; | ||||||
|  |     await request | ||||||
|  |         .post('/api/admin/features/test.feature/tags') | ||||||
|  |         .send(tag) | ||||||
|  |         .expect(201); | ||||||
|  |     await request | ||||||
|  |         .post('/api/admin/features/test.feature2/tags') | ||||||
|  |         .send(tag2) | ||||||
|  |         .expect(201); | ||||||
|  |     return request | ||||||
|  |         .get('/api/admin/features?tag[]=simple:Crazy&tag[]=simple:tagb') | ||||||
|  |         .expect('Content-Type', /json/) | ||||||
|  |         .expect(200) | ||||||
|  |         .expect(res => { | ||||||
|  |             t.is(res.body.features.length, 2); | ||||||
|  |             t.is(res.body.features[0].name, 'test.feature'); | ||||||
|  |         }); | ||||||
|  | }); | ||||||
|  | test.serial('Querying with multiple filters ANDs the filters', async t => { | ||||||
|  |     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').send({ | ||||||
|  |         name: 'test.feature2', | ||||||
|  |         type: 'killswitch', | ||||||
|  |         enabled: true, | ||||||
|  |         strategies: [{ name: 'default' }], | ||||||
|  |     }); | ||||||
|  |     await request.post('/api/admin/features').send({ | ||||||
|  |         name: 'notestprefix.feature3', | ||||||
|  |         type: 'release', | ||||||
|  |         enabled: true, | ||||||
|  |         strategies: [{ name: 'default' }], | ||||||
|  |     }); | ||||||
|  |     const tag = { value: 'Crazy', type: 'simple' }; | ||||||
|  |     const tag2 = { value: 'tagb', type: 'simple' }; | ||||||
|  |     await request | ||||||
|  |         .post('/api/admin/features/test.feature/tags') | ||||||
|  |         .send(tag) | ||||||
|  |         .expect(201); | ||||||
|  |     await request | ||||||
|  |         .post('/api/admin/features/test.feature2/tags') | ||||||
|  |         .send(tag2) | ||||||
|  |         .expect(201); | ||||||
|  |     await request | ||||||
|  |         .post('/api/admin/features/notestprefix.feature3/tags') | ||||||
|  |         .send(tag) | ||||||
|  |         .expect(201); | ||||||
|  |     await request | ||||||
|  |         .get('/api/admin/features?tag=simple:Crazy') | ||||||
|  |         .expect('Content-Type', /json/) | ||||||
|  |         .expect(200) | ||||||
|  |         .expect(res => t.is(res.body.features.length, 2)); | ||||||
|  |     await request | ||||||
|  |         .get('/api/admin/features?namePrefix=test&tag=simple:Crazy') | ||||||
|  |         .expect('Content-Type', /json/) | ||||||
|  |         .expect(200) | ||||||
|  |         .expect(res => { | ||||||
|  |             t.is(res.body.features.length, 1); | ||||||
|  |             t.is(res.body.features[0].name, 'test.feature'); | ||||||
|  |         }); | ||||||
|  | }); | ||||||
|  | |||||||
| @ -47,7 +47,7 @@ test.serial('gets a feature by name', async t => { | |||||||
|         .expect(200); |         .expect(200); | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| test.serial('cant get feature that dose not exist', async t => { | test.serial('cant get feature that does not exist', async t => { | ||||||
|     t.plan(0); |     t.plan(0); | ||||||
|     const request = await setupApp(stores); |     const request = await setupApp(stores); | ||||||
|     return request |     return request | ||||||
| @ -55,3 +55,66 @@ test.serial('cant get feature that dose not exist', async t => { | |||||||
|         .expect('Content-Type', /json/) |         .expect('Content-Type', /json/) | ||||||
|         .expect(404); |         .expect(404); | ||||||
| }); | }); | ||||||
|  | 
 | ||||||
|  | test.serial('Can filter features by namePrefix', async t => { | ||||||
|  |     t.plan(2); | ||||||
|  |     const request = await setupApp(stores); | ||||||
|  |     return request | ||||||
|  |         .get('/api/client/features?namePrefix=feature.') | ||||||
|  |         .expect('Content-Type', /json/) | ||||||
|  |         .expect(200) | ||||||
|  |         .expect(res => { | ||||||
|  |             t.is(res.body.features.length, 1); | ||||||
|  |             t.is(res.body.features[0].name, 'feature.with.variants'); | ||||||
|  |         }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test.serial('Can use multiple filters', async t => { | ||||||
|  |     t.plan(3); | ||||||
|  |     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').send({ | ||||||
|  |         name: 'test.feature2', | ||||||
|  |         type: 'killswitch', | ||||||
|  |         enabled: true, | ||||||
|  |         strategies: [{ name: 'default' }], | ||||||
|  |     }); | ||||||
|  |     await request.post('/api/admin/features').send({ | ||||||
|  |         name: 'notestprefix.feature3', | ||||||
|  |         type: 'release', | ||||||
|  |         enabled: true, | ||||||
|  |         strategies: [{ name: 'default' }], | ||||||
|  |     }); | ||||||
|  |     const tag = { value: 'Crazy', type: 'simple' }; | ||||||
|  |     const tag2 = { value: 'tagb', type: 'simple' }; | ||||||
|  |     await request | ||||||
|  |         .post('/api/admin/features/test.feature/tags') | ||||||
|  |         .send(tag) | ||||||
|  |         .expect(201); | ||||||
|  |     await request | ||||||
|  |         .post('/api/admin/features/test.feature2/tags') | ||||||
|  |         .send(tag2) | ||||||
|  |         .expect(201); | ||||||
|  |     await request | ||||||
|  |         .post('/api/admin/features/notestprefix.feature3/tags') | ||||||
|  |         .send(tag) | ||||||
|  |         .expect(201); | ||||||
|  |     await request | ||||||
|  |         .get('/api/client/features?tag=simple:Crazy') | ||||||
|  |         .expect('Content-Type', /json/) | ||||||
|  |         .expect(200) | ||||||
|  |         .expect(res => t.is(res.body.features.length, 2)); | ||||||
|  |     await request | ||||||
|  |         .get('/api/client/features?namePrefix=test&tag=simple:Crazy') | ||||||
|  |         .expect('Content-Type', /json/) | ||||||
|  |         .expect(200) | ||||||
|  |         .expect(res => { | ||||||
|  |             t.is(res.body.features.length, 1); | ||||||
|  |             t.is(res.body.features[0].name, 'test.feature'); | ||||||
|  |         }); | ||||||
|  | }); | ||||||
|  | |||||||
| @ -76,7 +76,7 @@ async function setupDatabase(stores) { | |||||||
|     await Promise.all(createApplications(stores.clientApplicationsStore)); |     await Promise.all(createApplications(stores.clientApplicationsStore)); | ||||||
|     await Promise.all(createProjects(stores.projectStore)); |     await Promise.all(createProjects(stores.projectStore)); | ||||||
|     await Promise.all(createTagTypes(stores.tagTypeStore)); |     await Promise.all(createTagTypes(stores.tagTypeStore)); | ||||||
|     await tagFeatures(stores.tagStore, stores.featureTagStore); |     await tagFeatures(stores.tagStore, stores.featureToggleStore); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| module.exports = async function init(databaseSchema = 'test', getLogger) { | module.exports = async function init(databaseSchema = 'test', getLogger) { | ||||||
|  | |||||||
							
								
								
									
										23
									
								
								test/fixtures/fake-feature-tag-store.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										23
									
								
								test/fixtures/fake-feature-tag-store.js
									
									
									
									
										vendored
									
									
								
							| @ -1,23 +0,0 @@ | |||||||
| 'use strict'; |  | ||||||
| 
 |  | ||||||
| module.exports = () => { |  | ||||||
|     const _featureTags = {}; |  | ||||||
|     return { |  | ||||||
|         tagFeature: (featureName, tag) => { |  | ||||||
|             _featureTags[featureName] = _featureTags[featureName] || []; |  | ||||||
|             _featureTags[featureName].push(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] || []; |  | ||||||
|         }, |  | ||||||
|     }; |  | ||||||
| }; |  | ||||||
							
								
								
									
										50
									
								
								test/fixtures/fake-feature-toggle-store.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										50
									
								
								test/fixtures/fake-feature-toggle-store.js
									
									
									
									
										vendored
									
									
								
							| @ -3,6 +3,8 @@ | |||||||
| module.exports = () => { | module.exports = () => { | ||||||
|     const _features = []; |     const _features = []; | ||||||
|     const _archive = []; |     const _archive = []; | ||||||
|  |     const _featureTags = {}; | ||||||
|  | 
 | ||||||
|     return { |     return { | ||||||
|         getFeature: name => { |         getFeature: name => { | ||||||
|             const toggle = _features.find(f => f.name === name); |             const toggle = _features.find(f => f.name === name); | ||||||
| @ -29,8 +31,6 @@ module.exports = () => { | |||||||
|             ); |             ); | ||||||
|             _features.push(updatedFeature); |             _features.push(updatedFeature); | ||||||
|         }, |         }, | ||||||
|         getFeatures: () => Promise.resolve(_features), |  | ||||||
|         getFeaturesClient: () => Promise.resolve(_features), |  | ||||||
|         createFeature: feature => _features.push(feature), |         createFeature: feature => _features.push(feature), | ||||||
|         getArchivedFeatures: () => Promise.resolve(_archive), |         getArchivedFeatures: () => Promise.resolve(_archive), | ||||||
|         addArchivedFeature: feature => _archive.push(feature), |         addArchivedFeature: feature => _archive.push(feature), | ||||||
| @ -55,5 +55,51 @@ module.exports = () => { | |||||||
|             _archive.splice(0, _archive.length); |             _archive.splice(0, _archive.length); | ||||||
|         }, |         }, | ||||||
|         importFeature: feat => Promise.resolve(_features.push(feat)), |         importFeature: feat => Promise.resolve(_features.push(feat)), | ||||||
|  |         getFeatures: query => { | ||||||
|  |             if (query) { | ||||||
|  |                 const activeQueryKeys = Object.keys(query).filter( | ||||||
|  |                     t => query[t], | ||||||
|  |                 ); | ||||||
|  |                 const filtered = _features.filter(feature => { | ||||||
|  |                     return activeQueryKeys.every(key => { | ||||||
|  |                         if (key === 'namePrefix') { | ||||||
|  |                             return feature.name.indexOf(query[key]) > -1; | ||||||
|  |                         } | ||||||
|  |                         if (key === 'tag') { | ||||||
|  |                             return query[key].some(tag => { | ||||||
|  |                                 return ( | ||||||
|  |                                     _featureTags[feature.name] && | ||||||
|  |                                     _featureTags[feature.name].some(t => { | ||||||
|  |                                         return ( | ||||||
|  |                                             t.type === tag[0] && | ||||||
|  |                                             t.value === tag[1] | ||||||
|  |                                         ); | ||||||
|  |                                     }) | ||||||
|  |                                 ); | ||||||
|  |                             }); | ||||||
|  |                         } | ||||||
|  |                         return query[key].some(v => v === feature[key]); | ||||||
|  |                     }); | ||||||
|  |                 }); | ||||||
|  |                 return Promise.resolve(filtered); | ||||||
|  |             } | ||||||
|  |             return Promise.resolve(_features); | ||||||
|  |         }, | ||||||
|  |         tagFeature: (featureName, tag) => { | ||||||
|  |             _featureTags[featureName] = _featureTags[featureName] || []; | ||||||
|  |             _featureTags[featureName].push(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] || []; | ||||||
|  |         }, | ||||||
|     }; |     }; | ||||||
| }; | }; | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								test/fixtures/store.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								test/fixtures/store.js
									
									
									
									
										vendored
									
									
								
							| @ -4,7 +4,6 @@ const ClientMetricsStore = require('./fake-metrics-store'); | |||||||
| const clientInstanceStore = require('./fake-client-instance-store'); | const clientInstanceStore = require('./fake-client-instance-store'); | ||||||
| const clientApplicationsStore = require('./fake-client-applications-store'); | const clientApplicationsStore = require('./fake-client-applications-store'); | ||||||
| const featureToggleStore = require('./fake-feature-toggle-store'); | const featureToggleStore = require('./fake-feature-toggle-store'); | ||||||
| const featureTagStore = require('./fake-feature-tag-store'); |  | ||||||
| const tagStore = require('./fake-tag-store'); | const tagStore = require('./fake-tag-store'); | ||||||
| const eventStore = require('./fake-event-store'); | const eventStore = require('./fake-event-store'); | ||||||
| const strategyStore = require('./fake-strategies-store'); | const strategyStore = require('./fake-strategies-store'); | ||||||
| @ -25,7 +24,6 @@ module.exports = { | |||||||
|             clientMetricsStore: new ClientMetricsStore(), |             clientMetricsStore: new ClientMetricsStore(), | ||||||
|             clientInstanceStore: clientInstanceStore(), |             clientInstanceStore: clientInstanceStore(), | ||||||
|             featureToggleStore: featureToggleStore(), |             featureToggleStore: featureToggleStore(), | ||||||
|             featureTagStore: featureTagStore(), |  | ||||||
|             tagStore: tagStore(), |             tagStore: tagStore(), | ||||||
|             eventStore: eventStore(), |             eventStore: eventStore(), | ||||||
|             strategyStore: strategyStore(), |             strategyStore: strategyStore(), | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user