mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	Add import/export for tags and projects (#754)
* Add import/export for tags and projects Tags includes (tags, tag-types and feature-tags) fixes: #752
This commit is contained in:
		
							parent
							
								
									e1fbe9d013
								
							
						
					
					
						commit
						289cf85a3c
					
				| @ -23,7 +23,7 @@ const unleash = require('unleash-server'); | ||||
| const { services } = await unleash.start({...}); | ||||
| const { stateService } = services; | ||||
| 
 | ||||
| const exportedData = await stateService.export({includeStrategies: false, includeFeatureToggles: true}); | ||||
| const exportedData = await stateService.export({includeStrategies: false, includeFeatureToggles: true, includeTags: true, includeProjects: true}); | ||||
| 
 | ||||
| await stateService.import({data: exportedData, userName: 'import', dropBeforeImport: false}); | ||||
| 
 | ||||
| @ -45,11 +45,13 @@ You can customize the export with query parameters: | ||||
| | download | `false` | If the exported data should be downloaded as a file | | ||||
| | featureToggles | `true` | Include feature-toggles in the exported data | | ||||
| | strategies | `true` | Include strategies in the exported data | | ||||
| | tags | `true` | Include tagtypes, tags and feature_tags in the exported data | | ||||
| | projects | `true` | Include projects in the exported data | | ||||
| 
 | ||||
| For example if you want to download all feature-toggles as yaml: | ||||
| For example if you want to download just feature-toggles as yaml: | ||||
| 
 | ||||
| ``` | ||||
| /api/admin/state/export?format=yaml&featureToggles=1&download=1 | ||||
| /api/admin/state/export?format=yaml&featureToggles=1&strategies=0&tags=0&projects=0&download=1 | ||||
| ``` | ||||
| 
 | ||||
| ### API Import | ||||
|  | ||||
| @ -268,6 +268,37 @@ class FeatureToggleStore { | ||||
|         return tag; | ||||
|     } | ||||
| 
 | ||||
|     async getAllFeatureTags() { | ||||
|         const rows = await this.db(FEATURE_TAG_TABLE).select( | ||||
|             FEATURE_TAG_COLUMNS, | ||||
|         ); | ||||
|         return rows.map(row => { | ||||
|             return { | ||||
|                 featureName: row.feature_name, | ||||
|                 tagType: row.tag_type, | ||||
|                 tagValue: row.tag_value, | ||||
|             }; | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     async dropFeatureTags() { | ||||
|         const stopTimer = this.timer('dropFeatureTags'); | ||||
|         await this.db(FEATURE_TAG_TABLE).del(); | ||||
|         stopTimer(); | ||||
|     } | ||||
| 
 | ||||
|     async importFeatureTags(featureTags) { | ||||
|         const rows = await this.db(FEATURE_TAG_TABLE) | ||||
|             .insert(featureTags.map(this.importToRow)) | ||||
|             .returning(FEATURE_TAG_COLUMNS) | ||||
|             .onConflict(FEATURE_TAG_COLUMNS) | ||||
|             .ignore(); | ||||
|         if (rows) { | ||||
|             return rows.map(this.rowToFeatureAndTag); | ||||
|         } | ||||
|         return []; | ||||
|     } | ||||
| 
 | ||||
|     async untagFeature(featureName, tag) { | ||||
|         const stopTimer = this.timer('untagFeature'); | ||||
|         try { | ||||
| @ -300,6 +331,24 @@ class FeatureToggleStore { | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     rowToFeatureAndTag(row) { | ||||
|         return { | ||||
|             featureName: row.feature_name, | ||||
|             tag: { | ||||
|                 type: row.tag_type, | ||||
|                 value: row.tag_value, | ||||
|             }, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     importToRow({ featureName, tagType, tagValue }) { | ||||
|         return { | ||||
|             feature_name: featureName, | ||||
|             tag_type: tagType, | ||||
|             tag_value: tagValue, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     featureAndTagToRow(featureName, { type, value }) { | ||||
|         return { | ||||
|             feature_name: featureName, | ||||
|  | ||||
| @ -67,6 +67,22 @@ class ProjectStore { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     async importProjects(projects) { | ||||
|         const rows = await this.db(TABLE) | ||||
|             .insert(projects.map(this.fieldToRow)) | ||||
|             .returning(COLUMNS) | ||||
|             .onConflict('id') | ||||
|             .ignore(); | ||||
|         if (rows.length > 0) { | ||||
|             return rows.map(this.mapRow); | ||||
|         } | ||||
|         return []; | ||||
|     } | ||||
| 
 | ||||
|     async dropProjects() { | ||||
|         await this.db(TABLE).del(); | ||||
|     } | ||||
| 
 | ||||
|     async delete(id) { | ||||
|         try { | ||||
|             await this.db(TABLE) | ||||
|  | ||||
| @ -63,6 +63,20 @@ class TagStore { | ||||
|         stopTimer(); | ||||
|     } | ||||
| 
 | ||||
|     async dropTags() { | ||||
|         const stopTimer = this.timer('dropTags'); | ||||
|         await this.db(TABLE).del(); | ||||
|         stopTimer(); | ||||
|     } | ||||
| 
 | ||||
|     async bulkImport(tags) { | ||||
|         return this.db(TABLE) | ||||
|             .insert(tags) | ||||
|             .returning(COLUMNS) | ||||
|             .onConflict(['type', 'value']) | ||||
|             .ignore(); | ||||
|     } | ||||
| 
 | ||||
|     rowToTag(row) { | ||||
|         return { | ||||
|             type: row.type, | ||||
|  | ||||
| @ -65,6 +65,24 @@ class TagTypeStore { | ||||
|         stopTimer(); | ||||
|     } | ||||
| 
 | ||||
|     async dropTagTypes() { | ||||
|         const stopTimer = this.timer('dropTagTypes'); | ||||
|         await this.db(TABLE).del(); | ||||
|         stopTimer(); | ||||
|     } | ||||
| 
 | ||||
|     async bulkImport(tagTypes) { | ||||
|         const rows = await this.db(TABLE) | ||||
|             .insert(tagTypes) | ||||
|             .returning(COLUMNS) | ||||
|             .onConflict('name') | ||||
|             .ignore(); | ||||
|         if (rows.length > 0) { | ||||
|             return rows; | ||||
|         } | ||||
|         return []; | ||||
|     } | ||||
| 
 | ||||
|     async updateTagType({ name, description, icon }) { | ||||
|         const stopTimer = this.timer('updateTagType'); | ||||
|         await this.db(TABLE) | ||||
|  | ||||
| @ -8,6 +8,8 @@ module.exports = { | ||||
|     FEATURE_REVIVED: 'feature-revived', | ||||
|     FEATURE_IMPORT: 'feature-import', | ||||
|     FEATURE_TAGGED: 'feature-tagged', | ||||
|     FEATURE_TAG_IMPORT: 'feature-tag-import', | ||||
|     DROP_FEATURE_TAGS: 'drop-feature-tags', | ||||
|     FEATURE_UNTAGGED: 'feature-untagged', | ||||
|     FEATURE_STALE_ON: 'feature-stale-on', | ||||
|     FEATURE_STALE_OFF: 'feature-stale-off', | ||||
| @ -25,11 +27,17 @@ module.exports = { | ||||
|     PROJECT_CREATED: 'project-created', | ||||
|     PROJECT_UPDATED: 'project-updated', | ||||
|     PROJECT_DELETED: 'project-deleted', | ||||
|     PROJECT_IMPORT: 'project-import', | ||||
|     DROP_PROJECTS: 'drop-projects', | ||||
|     TAG_CREATED: 'tag-created', | ||||
|     TAG_DELETED: 'tag-deleted', | ||||
|     TAG_IMPORT: 'tag-import', | ||||
|     DROP_TAGS: 'drop-tags', | ||||
|     TAG_TYPE_CREATED: 'tag-type-created', | ||||
|     TAG_TYPE_DELETED: 'tag-type-deleted', | ||||
|     TAG_TYPE_UPDATED: 'tag-type-updated', | ||||
|     TAG_TYPE_IMPORT: 'tag-type-import', | ||||
|     DROP_TAG_TYPES: 'drop-tag-types', | ||||
|     ADDON_CONFIG_CREATED: 'addon-config-created', | ||||
|     ADDON_CONFIG_UPDATED: 'addon-config-updated', | ||||
|     ADDON_CONFIG_DELETED: 'addon-config-deleted', | ||||
|  | ||||
| @ -10,7 +10,16 @@ const extractUser = require('../../extract-user'); | ||||
| const { handleErrors } = require('./util'); | ||||
| 
 | ||||
| const upload = multer({ limits: { fileSize: 5242880 } }); | ||||
| 
 | ||||
| const paramToBool = (param, def) => { | ||||
|     if (param === null || param === undefined) { | ||||
|         return def; | ||||
|     } | ||||
|     const nu = Number.parseInt(param, 10); | ||||
|     if (Number.isNaN(nu)) { | ||||
|         return param.toLowerCase() === 'true'; | ||||
|     } | ||||
|     return Boolean(nu); | ||||
| }; | ||||
| class StateController extends Controller { | ||||
|     constructor(config, services) { | ||||
|         super(config); | ||||
| @ -39,8 +48,8 @@ class StateController extends Controller { | ||||
|             await this.stateService.import({ | ||||
|                 data, | ||||
|                 userName, | ||||
|                 dropBeforeImport: drop, | ||||
|                 keepExisting: keep, | ||||
|                 dropBeforeImport: paramToBool(drop), | ||||
|                 keepExisting: paramToBool(keep), | ||||
|             }); | ||||
|             res.sendStatus(202); | ||||
|         } catch (err) { | ||||
| @ -51,20 +60,21 @@ class StateController extends Controller { | ||||
|     async export(req, res) { | ||||
|         const { format } = req.query; | ||||
| 
 | ||||
|         const downloadFile = Boolean(req.query.download); | ||||
|         let includeStrategies = Boolean(req.query.strategies); | ||||
|         let includeFeatureToggles = Boolean(req.query.featureToggles); | ||||
| 
 | ||||
|         // if neither is passed as query argument, export both
 | ||||
|         if (!includeStrategies && !includeFeatureToggles) { | ||||
|             includeStrategies = true; | ||||
|             includeFeatureToggles = true; | ||||
|         } | ||||
|         const downloadFile = paramToBool(req.query.download, false); | ||||
|         const includeStrategies = paramToBool(req.query.strategies, true); | ||||
|         const includeFeatureToggles = paramToBool( | ||||
|             req.query.featureToggles, | ||||
|             true, | ||||
|         ); | ||||
|         const includeProjects = paramToBool(req.query.projects, true); | ||||
|         const includeTags = paramToBool(req.query.tags, true); | ||||
| 
 | ||||
|         try { | ||||
|             const data = await this.stateService.export({ | ||||
|                 includeStrategies, | ||||
|                 includeFeatureToggles, | ||||
|                 includeProjects, | ||||
|                 includeTags, | ||||
|             }); | ||||
|             const timestamp = moment().format('YYYY-MM-DD_HH-mm-ss'); | ||||
|             if (format === 'yaml') { | ||||
|  | ||||
| @ -109,4 +109,15 @@ const querySchema = joi | ||||
|     }) | ||||
|     .options({ allowUnknown: false, stripUnknown: true, abortEarly: false }); | ||||
| 
 | ||||
| module.exports = { featureSchema, strategiesSchema, nameSchema, querySchema }; | ||||
| const featureTagSchema = joi.object().keys({ | ||||
|     featureName: nameType, | ||||
|     tagType: nameType, | ||||
|     tagValue: joi.string(), | ||||
| }); | ||||
| module.exports = { | ||||
|     featureSchema, | ||||
|     strategiesSchema, | ||||
|     nameSchema, | ||||
|     querySchema, | ||||
|     featureTagSchema, | ||||
| }; | ||||
|  | ||||
| @ -1,8 +1,10 @@ | ||||
| const joi = require('joi'); | ||||
| const { featureSchema } = require('./feature-schema'); | ||||
| const { featureSchema, featureTagSchema } = require('./feature-schema'); | ||||
| const strategySchema = require('./strategy-schema'); | ||||
| const { tagSchema } = require('./tag-schema'); | ||||
| const { tagTypeSchema } = require('./tag-type-schema'); | ||||
| const projectSchema = require('./project-schema'); | ||||
| 
 | ||||
| // TODO: Extract to seperate file
 | ||||
| const stateSchema = joi.object().keys({ | ||||
|     version: joi.number(), | ||||
|     features: joi | ||||
| @ -13,6 +15,22 @@ const stateSchema = joi.object().keys({ | ||||
|         .array() | ||||
|         .optional() | ||||
|         .items(strategySchema), | ||||
|     tags: joi | ||||
|         .array() | ||||
|         .optional() | ||||
|         .items(tagSchema), | ||||
|     tagTypes: joi | ||||
|         .array() | ||||
|         .optional() | ||||
|         .items(tagTypeSchema), | ||||
|     featureTags: joi | ||||
|         .array() | ||||
|         .optional() | ||||
|         .items(featureTagSchema), | ||||
|     projects: joi | ||||
|         .array() | ||||
|         .optional() | ||||
|         .items(projectSchema), | ||||
| }); | ||||
| 
 | ||||
| module.exports = { | ||||
|  | ||||
| @ -4,12 +4,20 @@ const { | ||||
|     DROP_FEATURES, | ||||
|     STRATEGY_IMPORT, | ||||
|     DROP_STRATEGIES, | ||||
|     TAG_IMPORT, | ||||
|     DROP_TAGS, | ||||
|     FEATURE_TAG_IMPORT, | ||||
|     DROP_FEATURE_TAGS, | ||||
|     TAG_TYPE_IMPORT, | ||||
|     DROP_TAG_TYPES, | ||||
|     PROJECT_IMPORT, | ||||
|     DROP_PROJECTS, | ||||
| } = require('../event-type'); | ||||
| 
 | ||||
| const { | ||||
|     readFile, | ||||
|     parseFile, | ||||
|     filterExisitng, | ||||
|     filterExisting, | ||||
|     filterEqual, | ||||
| } = require('./state-util'); | ||||
| 
 | ||||
| @ -18,6 +26,9 @@ class StateService { | ||||
|         this.eventStore = stores.eventStore; | ||||
|         this.toggleStore = stores.featureToggleStore; | ||||
|         this.strategyStore = stores.strategyStore; | ||||
|         this.tagStore = stores.tagStore; | ||||
|         this.tagTypeStore = stores.tagTypeStore; | ||||
|         this.projectStore = stores.projectStore; | ||||
|         this.logger = getLogger('services/state-service.js'); | ||||
|     } | ||||
| 
 | ||||
| @ -49,6 +60,25 @@ class StateService { | ||||
|                 keepExisting, | ||||
|             }); | ||||
|         } | ||||
| 
 | ||||
|         if (importData.tagTypes && importData.tags) { | ||||
|             await this.importTagData({ | ||||
|                 tagTypes: data.tagTypes, | ||||
|                 tags: data.tags, | ||||
|                 featureTags: data.featureTags || [], | ||||
|                 userName, | ||||
|                 dropBeforeImport, | ||||
|                 keepExisting, | ||||
|             }); | ||||
|         } | ||||
|         if (importData.projects) { | ||||
|             await this.importProjects({ | ||||
|                 projects: data.projects, | ||||
|                 userName, | ||||
|                 dropBeforeImport, | ||||
|                 keepExisting, | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     async importFeatures({ | ||||
| @ -74,7 +104,7 @@ class StateService { | ||||
| 
 | ||||
|         await Promise.all( | ||||
|             features | ||||
|                 .filter(filterExisitng(keepExisting, oldToggles)) | ||||
|                 .filter(filterExisting(keepExisting, oldToggles)) | ||||
|                 .filter(filterEqual(oldToggles)) | ||||
|                 .map(feature => | ||||
|                     this.toggleStore.importFeature(feature).then(() => | ||||
| @ -111,7 +141,7 @@ class StateService { | ||||
| 
 | ||||
|         await Promise.all( | ||||
|             strategies | ||||
|                 .filter(filterExisitng(keepExisting, oldStrategies)) | ||||
|                 .filter(filterExisting(keepExisting, oldStrategies)) | ||||
|                 .filter(filterEqual(oldStrategies)) | ||||
|                 .map(strategy => | ||||
|                     this.strategyStore.importStrategy(strategy).then(() => { | ||||
| @ -125,7 +155,183 @@ class StateService { | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     async export({ includeFeatureToggles = true, includeStrategies = true }) { | ||||
|     async importProjects({ | ||||
|         projects, | ||||
|         userName, | ||||
|         dropBeforeImport, | ||||
|         keepExisting, | ||||
|     }) { | ||||
|         this.logger.info(`Import ${projects.length} projects`); | ||||
|         const oldProjects = dropBeforeImport | ||||
|             ? [] | ||||
|             : await this.projectStore.getAll(); | ||||
|         if (dropBeforeImport) { | ||||
|             this.logger.info('Dropping existing projects'); | ||||
|             await this.projectStore.dropProjects(); | ||||
|             await this.eventStore.store({ | ||||
|                 type: DROP_PROJECTS, | ||||
|                 createdBy: userName, | ||||
|                 data: { name: 'all-projects' }, | ||||
|             }); | ||||
|         } | ||||
|         const projectsToImport = projects.filter(project => | ||||
|             keepExisting | ||||
|                 ? !oldProjects.some(old => old.id === project.id) | ||||
|                 : true, | ||||
|         ); | ||||
|         if (projectsToImport.length > 0) { | ||||
|             const importedProjects = await this.projectStore.importProjects( | ||||
|                 projectsToImport, | ||||
|             ); | ||||
|             const importedProjectEvents = importedProjects.map(project => { | ||||
|                 return { | ||||
|                     type: PROJECT_IMPORT, | ||||
|                     createdBy: userName, | ||||
|                     data: project, | ||||
|                 }; | ||||
|             }); | ||||
|             await this.eventStore.batchStore(importedProjectEvents); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     async importTagData({ | ||||
|         tagTypes, | ||||
|         tags, | ||||
|         featureTags, | ||||
|         userName, | ||||
|         dropBeforeImport, | ||||
|         keepExisting, | ||||
|     }) { | ||||
|         this.logger.info( | ||||
|             `Importing ${tagTypes.length} tagtypes, ${tags.length} tags and ${featureTags.length} feature tags`, | ||||
|         ); | ||||
|         const oldTagTypes = dropBeforeImport | ||||
|             ? [] | ||||
|             : await this.tagTypeStore.getAll(); | ||||
|         const oldTags = dropBeforeImport ? [] : await this.tagStore.getAll(); | ||||
|         const oldFeatureTags = dropBeforeImport | ||||
|             ? [] | ||||
|             : await this.toggleStore.getAllFeatureTags(); | ||||
|         if (dropBeforeImport) { | ||||
|             this.logger.info( | ||||
|                 'Dropping all existing featuretags, tags and tagtypes', | ||||
|             ); | ||||
|             await this.toggleStore.dropFeatureTags(); | ||||
|             await this.tagStore.dropTags(); | ||||
|             await this.tagTypeStore.dropTagTypes(); | ||||
|             await this.eventStore.batchStore([ | ||||
|                 { | ||||
|                     type: DROP_FEATURE_TAGS, | ||||
|                     createdBy: userName, | ||||
|                     data: { name: 'all-feature-tags' }, | ||||
|                 }, | ||||
|                 { | ||||
|                     type: DROP_TAGS, | ||||
|                     createdBy: userName, | ||||
|                     data: { name: 'all-tags' }, | ||||
|                 }, | ||||
|                 { | ||||
|                     type: DROP_TAG_TYPES, | ||||
|                     createdBy: userName, | ||||
|                     data: { name: 'all-tag-types' }, | ||||
|                 }, | ||||
|             ]); | ||||
|         } | ||||
|         await this.importTagTypes( | ||||
|             tagTypes, | ||||
|             keepExisting, | ||||
|             oldTagTypes, | ||||
|             userName, | ||||
|         ); | ||||
|         await this.importTags(tags, keepExisting, oldTags, userName); | ||||
|         await this.importFeatureTags( | ||||
|             featureTags, | ||||
|             keepExisting, | ||||
|             oldFeatureTags, | ||||
|             userName, | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     compareFeatureTags = (old, tag) => | ||||
|         old.featureName === tag.featureName && | ||||
|         old.tagValue === tag.tagValue && | ||||
|         old.tagType === tag.tagType; | ||||
| 
 | ||||
|     async importFeatureTags( | ||||
|         featureTags, | ||||
|         keepExisting, | ||||
|         oldFeatureTags, | ||||
|         userName, | ||||
|     ) { | ||||
|         const featureTagsToInsert = featureTags.filter(tag => | ||||
|             keepExisting | ||||
|                 ? !oldFeatureTags.some(old => this.compareFeatureTags(old, tag)) | ||||
|                 : true, | ||||
|         ); | ||||
|         if (featureTagsToInsert.length > 0) { | ||||
|             const importedFeatureTags = await this.toggleStore.importFeatureTags( | ||||
|                 featureTagsToInsert, | ||||
|             ); | ||||
|             const importedFeatureTagEvents = importedFeatureTags.map(tag => { | ||||
|                 return { | ||||
|                     type: FEATURE_TAG_IMPORT, | ||||
|                     createdBy: userName, | ||||
|                     data: tag, | ||||
|                 }; | ||||
|             }); | ||||
|             await this.eventStore.batchStore(importedFeatureTagEvents); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     compareTags = (old, tag) => | ||||
|         old.type === tag.type && old.value === tag.value; | ||||
| 
 | ||||
|     async importTags(tags, keepExisting, oldTags, userName) { | ||||
|         const tagsToInsert = tags.filter(tag => | ||||
|             keepExisting | ||||
|                 ? !oldTags.some(old => this.compareTags(old, tag)) | ||||
|                 : true, | ||||
|         ); | ||||
|         if (tagsToInsert.length > 0) { | ||||
|             const importedTags = await this.tagStore.bulkImport(tagsToInsert); | ||||
|             const importedTagEvents = importedTags.map(tag => { | ||||
|                 return { | ||||
|                     type: TAG_IMPORT, | ||||
|                     createdBy: userName, | ||||
|                     data: tag, | ||||
|                 }; | ||||
|             }); | ||||
|             await this.eventStore.batchStore(importedTagEvents); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     async importTagTypes(tagTypes, keepExisting, oldTagTypes = [], userName) { | ||||
|         const tagTypesToInsert = tagTypes.filter(tagType => | ||||
|             keepExisting | ||||
|                 ? !oldTagTypes.some(t => t.name === tagType.name) | ||||
|                 : true, | ||||
|         ); | ||||
|         if (tagTypesToInsert.length > 0) { | ||||
|             const importedTagTypes = await this.tagTypeStore.bulkImport( | ||||
|                 tagTypesToInsert, | ||||
|             ); | ||||
|             const importedTagTypeEvents = importedTagTypes.map(tagType => { | ||||
|                 return { | ||||
|                     type: TAG_TYPE_IMPORT, | ||||
|                     createdBy: userName, | ||||
|                     data: tagType, | ||||
|                 }; | ||||
|             }); | ||||
|             await this.eventStore.batchStore(importedTagTypeEvents); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     async export({ | ||||
|         includeFeatureToggles = true, | ||||
|         includeStrategies = true, | ||||
|         includeProjects = true, | ||||
|         includeTags = true, | ||||
|     }) { | ||||
|         return Promise.all([ | ||||
|             includeFeatureToggles | ||||
|                 ? this.toggleStore.getFeatures() | ||||
| @ -133,11 +339,32 @@ class StateService { | ||||
|             includeStrategies | ||||
|                 ? this.strategyStore.getEditableStrategies() | ||||
|                 : Promise.resolve(), | ||||
|         ]).then(([features, strategies]) => ({ | ||||
|             version: 1, | ||||
|             features, | ||||
|             strategies, | ||||
|         })); | ||||
|             this.projectStore && includeProjects | ||||
|                 ? this.projectStore.getAll() | ||||
|                 : Promise.resolve(), | ||||
|             includeTags ? this.tagTypeStore.getAll() : Promise.resolve(), | ||||
|             includeTags ? this.tagStore.getAll() : Promise.resolve(), | ||||
|             includeTags | ||||
|                 ? this.toggleStore.getAllFeatureTags() | ||||
|                 : Promise.resolve(), | ||||
|         ]).then( | ||||
|             ([ | ||||
|                 features, | ||||
|                 strategies, | ||||
|                 projects, | ||||
|                 tagTypes, | ||||
|                 tags, | ||||
|                 featureTags, | ||||
|             ]) => ({ | ||||
|                 version: 1, | ||||
|                 features, | ||||
|                 strategies, | ||||
|                 projects, | ||||
|                 tagTypes, | ||||
|                 tags, | ||||
|                 featureTags, | ||||
|             }), | ||||
|         ); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -6,11 +6,16 @@ const store = require('../../test/fixtures/store'); | ||||
| const getLogger = require('../../test/fixtures/no-logger'); | ||||
| 
 | ||||
| const StateService = require('./state-service'); | ||||
| const NotFoundError = require('../error/notfound-error'); | ||||
| const { | ||||
|     FEATURE_IMPORT, | ||||
|     DROP_FEATURES, | ||||
|     STRATEGY_IMPORT, | ||||
|     DROP_STRATEGIES, | ||||
|     TAG_TYPE_IMPORT, | ||||
|     TAG_IMPORT, | ||||
|     FEATURE_TAG_IMPORT, | ||||
|     PROJECT_IMPORT, | ||||
| } = require('../event-type'); | ||||
| 
 | ||||
| function getSetup() { | ||||
| @ -132,7 +137,7 @@ test('should import a strategy', async t => { | ||||
|     t.is(events[0].data.name, 'new-strategy'); | ||||
| }); | ||||
| 
 | ||||
| test('should not import an exiting strategy', async t => { | ||||
| test('should not import an existing strategy', async t => { | ||||
|     const { stateService, stores } = getSetup(); | ||||
| 
 | ||||
|     const data = { | ||||
| @ -219,3 +224,217 @@ test('should export strategies', async t => { | ||||
|     t.is(data.strategies.length, 1); | ||||
|     t.is(data.strategies[0].name, 'a-strategy'); | ||||
| }); | ||||
| 
 | ||||
| test('should import a tag and tag type', async t => { | ||||
|     const { stateService, stores } = getSetup(); | ||||
|     const data = { | ||||
|         tagTypes: [ | ||||
|             { name: 'simple', description: 'some description', icon: '#' }, | ||||
|         ], | ||||
|         tags: [{ type: 'simple', value: 'test' }], | ||||
|         featureTags: [ | ||||
|             { | ||||
|                 featureName: 'demo-feature', | ||||
|                 tagType: 'simple', | ||||
|                 tagValue: 'test', | ||||
|             }, | ||||
|         ], | ||||
|     }; | ||||
|     await stateService.import({ data }); | ||||
|     const events = await stores.eventStore.getEvents(); | ||||
|     t.is(events.length, 3); | ||||
|     t.is(events[0].type, TAG_TYPE_IMPORT); | ||||
|     t.is(events[0].data.name, 'simple'); | ||||
|     t.is(events[1].type, TAG_IMPORT); | ||||
|     t.is(events[1].data.value, 'test'); | ||||
|     t.is(events[2].type, FEATURE_TAG_IMPORT); | ||||
|     t.is(events[2].data.featureName, 'demo-feature'); | ||||
| }); | ||||
| 
 | ||||
| test('Should not import an existing tag', async t => { | ||||
|     const { stateService, stores } = getSetup(); | ||||
|     const data = { | ||||
|         tagTypes: [ | ||||
|             { name: 'simple', description: 'some description', icon: '#' }, | ||||
|         ], | ||||
|         tags: [{ type: 'simple', value: 'test' }], | ||||
|         featureTags: [ | ||||
|             { | ||||
|                 featureName: 'demo-feature', | ||||
|                 tagType: 'simple', | ||||
|                 tagValue: 'test', | ||||
|             }, | ||||
|         ], | ||||
|     }; | ||||
|     await stores.tagTypeStore.createTagType(data.tagTypes[0]); | ||||
|     await stores.tagStore.createTag(data.tags[0]); | ||||
|     await stores.featureToggleStore.tagFeature( | ||||
|         data.featureTags[0].featureName, | ||||
|         { | ||||
|             type: data.featureTags[0].tagType, | ||||
|             value: data.featureTags[0].tagValue, | ||||
|         }, | ||||
|     ); | ||||
|     await stateService.import({ data, keepExisting: true }); | ||||
|     const events = await stores.eventStore.getEvents(); | ||||
|     t.is(events.length, 0); | ||||
| }); | ||||
| 
 | ||||
| test('Should not keep existing tags if drop-before-import', async t => { | ||||
|     const { stateService, stores } = getSetup(); | ||||
|     const notSoSimple = { | ||||
|         name: 'notsosimple', | ||||
|         description: 'some other description', | ||||
|         icon: '#', | ||||
|     }; | ||||
|     const slack = { | ||||
|         name: 'slack', | ||||
|         description: 'slack tags', | ||||
|         icon: '#', | ||||
|     }; | ||||
| 
 | ||||
|     await stores.tagTypeStore.createTagType(notSoSimple); | ||||
|     await stores.tagTypeStore.createTagType(slack); | ||||
|     const data = { | ||||
|         tagTypes: [ | ||||
|             { name: 'simple', description: 'some description', icon: '#' }, | ||||
|         ], | ||||
|         tags: [{ type: 'simple', value: 'test' }], | ||||
|         featureTags: [ | ||||
|             { | ||||
|                 featureName: 'demo-feature', | ||||
|                 tagType: 'simple', | ||||
|                 tagValue: 'test', | ||||
|             }, | ||||
|         ], | ||||
|     }; | ||||
|     await stateService.import({ data, dropBeforeImport: true }); | ||||
|     const tagTypes = await stores.tagTypeStore.getAll(); | ||||
|     t.is(tagTypes.length, 1); | ||||
| }); | ||||
| 
 | ||||
| test('should export tag, tagtypes and feature tags', async t => { | ||||
|     const { stateService, stores } = getSetup(); | ||||
| 
 | ||||
|     const data = { | ||||
|         tagTypes: [ | ||||
|             { name: 'simple', description: 'some description', icon: '#' }, | ||||
|         ], | ||||
|         tags: [{ type: 'simple', value: 'test' }], | ||||
|         featureTags: [ | ||||
|             { | ||||
|                 featureName: 'demo-feature', | ||||
|                 tagType: 'simple', | ||||
|                 tagValue: 'test', | ||||
|             }, | ||||
|         ], | ||||
|     }; | ||||
|     await stores.tagTypeStore.createTagType(data.tagTypes[0]); | ||||
|     await stores.tagStore.createTag(data.tags[0]); | ||||
|     await stores.featureToggleStore.tagFeature( | ||||
|         data.featureTags[0].featureName, | ||||
|         { | ||||
|             type: data.featureTags[0].tagType, | ||||
|             value: data.featureTags[0].tagValue, | ||||
|         }, | ||||
|     ); | ||||
| 
 | ||||
|     const exported = await stateService.export({ | ||||
|         includeFeatureToggles: false, | ||||
|         includeStrategies: false, | ||||
|         includeTags: true, | ||||
|         includeProjects: false, | ||||
|     }); | ||||
|     t.is(exported.tags.length, 1); | ||||
|     t.is(exported.tags[0].type, data.tags[0].type); | ||||
|     t.is(exported.tags[0].value, data.tags[0].value); | ||||
|     t.is(exported.tagTypes.length, 1); | ||||
|     t.is(exported.tagTypes[0].name, data.tagTypes[0].name); | ||||
|     t.is(exported.featureTags.length, 1); | ||||
|     t.is(exported.featureTags[0].featureName, data.featureTags[0].featureName); | ||||
|     t.is(exported.featureTags[0].tagType, data.featureTags[0].tagType); | ||||
|     t.is(exported.featureTags[0].tagValue, data.featureTags[0].tagValue); | ||||
| }); | ||||
| 
 | ||||
| test('should import a project', async t => { | ||||
|     const { stateService, stores } = getSetup(); | ||||
| 
 | ||||
|     const data = { | ||||
|         projects: [ | ||||
|             { | ||||
|                 id: 'default', | ||||
|                 name: 'default', | ||||
|                 description: 'Some fancy description for project', | ||||
|             }, | ||||
|         ], | ||||
|     }; | ||||
| 
 | ||||
|     await stateService.import({ data }); | ||||
| 
 | ||||
|     const events = await stores.eventStore.getEvents(); | ||||
|     t.is(events.length, 1); | ||||
|     t.is(events[0].type, PROJECT_IMPORT); | ||||
|     t.is(events[0].data.name, 'default'); | ||||
| }); | ||||
| 
 | ||||
| test('Should not import an existing project', async t => { | ||||
|     const { stateService, stores } = getSetup(); | ||||
| 
 | ||||
|     const data = { | ||||
|         projects: [ | ||||
|             { | ||||
|                 id: 'default', | ||||
|                 name: 'default', | ||||
|                 description: 'Some fancy description for project', | ||||
|             }, | ||||
|         ], | ||||
|     }; | ||||
|     await stores.projectStore.create(data.projects[0]); | ||||
| 
 | ||||
|     await stateService.import({ data, keepExisting: true }); | ||||
|     const events = await stores.eventStore.getEvents(); | ||||
|     t.is(events.length, 0); | ||||
| 
 | ||||
|     await stateService.import({ data }); | ||||
| }); | ||||
| 
 | ||||
| test('Should drop projects before import if specified', async t => { | ||||
|     const { stateService, stores } = getSetup(); | ||||
| 
 | ||||
|     const data = { | ||||
|         projects: [ | ||||
|             { | ||||
|                 id: 'default', | ||||
|                 name: 'default', | ||||
|                 description: 'Some fancy description for project', | ||||
|             }, | ||||
|         ], | ||||
|     }; | ||||
|     await stores.projectStore.create({ | ||||
|         id: 'fancy', | ||||
|         name: 'extra', | ||||
|         description: 'Not expected to be seen after import', | ||||
|     }); | ||||
|     await stateService.import({ data, dropBeforeImport: true }); | ||||
|     return t.throwsAsync(async () => stores.projectStore.hasProject('fancy'), { | ||||
|         instanceOf: NotFoundError, | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| test('Should export projects', async t => { | ||||
|     const { stateService, stores } = getSetup(); | ||||
|     await stores.projectStore.create({ | ||||
|         id: 'fancy', | ||||
|         name: 'extra', | ||||
|         description: 'No surprises here', | ||||
|     }); | ||||
|     const exported = await stateService.export({ | ||||
|         includeFeatureToggles: false, | ||||
|         includeStrategies: false, | ||||
|         includeTags: false, | ||||
|         includeProjects: true, | ||||
|     }); | ||||
|     t.is(exported.projects[0].id, 'fancy'); | ||||
|     t.is(exported.projects[0].name, 'extra'); | ||||
|     t.is(exported.projects[0].description, 'No surprises here'); | ||||
| }); | ||||
|  | ||||
| @ -14,19 +14,19 @@ const parseFile = (file, data) => { | ||||
|         : JSON.parse(data); | ||||
| }; | ||||
| 
 | ||||
| const filterExisitng = (keepExisting, exitingArray) => { | ||||
| const filterExisting = (keepExisting, existingArray = []) => { | ||||
|     return item => { | ||||
|         if (keepExisting) { | ||||
|             const found = exitingArray.find(t => t.name === item.name); | ||||
|             const found = existingArray.find(t => t.name === item.name); | ||||
|             return !found; | ||||
|         } | ||||
|         return true; | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
| const filterEqual = exitingArray => { | ||||
| const filterEqual = (existingArray = []) => { | ||||
|     return item => { | ||||
|         const toggle = exitingArray.find(t => t.name === item.name); | ||||
|         const toggle = existingArray.find(t => t.name === item.name); | ||||
|         if (toggle) { | ||||
|             return JSON.stringify(toggle) !== JSON.stringify(item); | ||||
|         } | ||||
| @ -37,6 +37,6 @@ const filterEqual = exitingArray => { | ||||
| module.exports = { | ||||
|     readFile, | ||||
|     parseFile, | ||||
|     filterExisitng, | ||||
|     filterExisting, | ||||
|     filterEqual, | ||||
| }; | ||||
|  | ||||
							
								
								
									
										8
									
								
								src/test/fixtures/fake-event-store.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								src/test/fixtures/fake-event-store.js
									
									
									
									
										vendored
									
									
								
							| @ -15,6 +15,14 @@ class EventStore extends EventEmitter { | ||||
|         return Promise.resolve(); | ||||
|     } | ||||
| 
 | ||||
|     batchStore(events) { | ||||
|         events.forEach(event => { | ||||
|             this.events.push(event); | ||||
|             this.emit(event.type, event); | ||||
|         }); | ||||
|         return Promise.resolve(); | ||||
|     } | ||||
| 
 | ||||
|     getEvents() { | ||||
|         return Promise.resolve(this.events); | ||||
|     } | ||||
|  | ||||
							
								
								
									
										61
									
								
								src/test/fixtures/fake-feature-toggle-store.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										61
									
								
								src/test/fixtures/fake-feature-toggle-store.js
									
									
									
									
										vendored
									
									
								
							| @ -3,7 +3,7 @@ | ||||
| module.exports = (databaseIsUp = true) => { | ||||
|     const _features = []; | ||||
|     const _archive = []; | ||||
|     const _featureTags = {}; | ||||
|     const _featureTags = []; | ||||
| 
 | ||||
|     return { | ||||
|         getFeature: name => { | ||||
| @ -79,16 +79,14 @@ module.exports = (databaseIsUp = true) => { | ||||
|                             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(tagQuery => { | ||||
|                                 return _featureTags | ||||
|                                     .filter(t => t.featureName === feature.name) | ||||
|                                     .some( | ||||
|                                         tag => | ||||
|                                             tag.tagType === tagQuery[0] && | ||||
|                                             tag.tagValue === tagQuery[1], | ||||
|                                     ); | ||||
|                             }); | ||||
|                         } | ||||
|                         return query[key].some(v => v === feature[key]); | ||||
| @ -99,20 +97,43 @@ module.exports = (databaseIsUp = true) => { | ||||
|             return Promise.resolve(_features); | ||||
|         }, | ||||
|         tagFeature: (featureName, tag) => { | ||||
|             _featureTags[featureName] = _featureTags[featureName] || []; | ||||
|             _featureTags[featureName].push(tag); | ||||
|             _featureTags.push({ | ||||
|                 featureName, | ||||
|                 tagType: tag.type, | ||||
|                 tagValue: tag.value, | ||||
|             }); | ||||
|         }, | ||||
|         untagFeature: event => { | ||||
|             const tags = _featureTags[event.featureName]; | ||||
|             _featureTags[event.featureName] = tags.splice( | ||||
|                 tags.indexOf( | ||||
|                     t => t.type === event.type && t.value === event.value, | ||||
|                 ), | ||||
|                 1, | ||||
|             const index = _featureTags.findIndex( | ||||
|                 f => | ||||
|                     f.featureName === event.featureName && | ||||
|                     f.tagType === event.type && | ||||
|                     f.tagValue === event.value, | ||||
|             ); | ||||
|             _featureTags.splice(index, 1); | ||||
|         }, | ||||
|         getAllTagsForFeature: featureName => { | ||||
|             return _featureTags[featureName] || []; | ||||
|             return Promise.resolve( | ||||
|                 _featureTags | ||||
|                     .filter(f => f.featureName === featureName) | ||||
|                     .map(t => { | ||||
|                         return { | ||||
|                             type: t.tagType, | ||||
|                             value: t.tagValue, | ||||
|                         }; | ||||
|                     }), | ||||
|             ); | ||||
|         }, | ||||
|         getAllFeatureTags: () => Promise.resolve(_featureTags), | ||||
|         importFeatureTags: tags => { | ||||
|             tags.forEach(tag => { | ||||
|                 _featureTags.push(tag); | ||||
|             }); | ||||
|             return Promise.resolve(_featureTags); | ||||
|         }, | ||||
|         dropFeatureTags: () => { | ||||
|             _featureTags.splice(0, _featureTags.length); | ||||
|             return Promise.resolve(); | ||||
|         }, | ||||
|     }; | ||||
| }; | ||||
|  | ||||
							
								
								
									
										35
									
								
								src/test/fixtures/fake-project-store.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/test/fixtures/fake-project-store.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | ||||
| const NotFoundError = require('../../lib/error/notfound-error'); | ||||
| 
 | ||||
| module.exports = (databaseIsUp = true) => { | ||||
|     const _projects = []; | ||||
|     return { | ||||
|         create: project => { | ||||
|             _projects.push(project); | ||||
|             return Promise.resolve(); | ||||
|         }, | ||||
|         getAll: () => { | ||||
|             if (databaseIsUp) { | ||||
|                 return Promise.resolve(_projects); | ||||
|             } | ||||
|             return Promise.reject(new Error('Database is down')); | ||||
|         }, | ||||
|         importProjects: projects => { | ||||
|             projects.forEach(project => { | ||||
|                 _projects.push(project); | ||||
|             }); | ||||
|             return Promise.resolve(_projects); | ||||
|         }, | ||||
|         dropProjects: () => { | ||||
|             _projects.splice(0, _projects.length); | ||||
|         }, | ||||
|         hasProject: id => { | ||||
|             const project = _projects.find(p => p.id === id); | ||||
|             if (project) { | ||||
|                 return Promise.resolve(project); | ||||
|             } | ||||
|             return Promise.reject( | ||||
|                 new NotFoundError(`Could not find project with id ${id}`), | ||||
|             ); | ||||
|         }, | ||||
|     }; | ||||
| }; | ||||
							
								
								
									
										8
									
								
								src/test/fixtures/fake-tag-store.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								src/test/fixtures/fake-tag-store.js
									
									
									
									
										vendored
									
									
								
							| @ -34,5 +34,13 @@ module.exports = (databaseIsUp = true) => { | ||||
|             } | ||||
|             return Promise.reject(new NotFoundError('Could not find tag')); | ||||
|         }, | ||||
|         bulkImport: tags => { | ||||
|             tags.forEach(tag => _tags.push(tag)); | ||||
|             return Promise.resolve(_tags); | ||||
|         }, | ||||
|         dropTags: () => { | ||||
|             _tags.splice(0, _tags.length); | ||||
|             return Promise.resolve(); | ||||
|         }, | ||||
|     }; | ||||
| }; | ||||
|  | ||||
							
								
								
									
										15
									
								
								src/test/fixtures/fake-tag-type-store.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										15
									
								
								src/test/fixtures/fake-tag-type-store.js
									
									
									
									
										vendored
									
									
								
							| @ -1,17 +1,26 @@ | ||||
| const NotFoundError = require('../../lib/error/notfound-error'); | ||||
| 
 | ||||
| module.exports = () => { | ||||
|     const _tagTypes = {}; | ||||
|     const _tagTypes = []; | ||||
|     return { | ||||
|         getTagType: async name => { | ||||
|             const tag = _tagTypes[name]; | ||||
|             const tag = _tagTypes.find(t => t.name === name); | ||||
|             if (tag) { | ||||
|                 return Promise.resolve(tag); | ||||
|             } | ||||
|             return Promise.reject(new NotFoundError('Could not find tag type')); | ||||
|         }, | ||||
|         createTagType: async tag => { | ||||
|             _tagTypes[tag.name] = tag; | ||||
|             _tagTypes.push(tag); | ||||
|         }, | ||||
|         getAll: () => Promise.resolve(_tagTypes), | ||||
|         bulkImport: tagTypes => { | ||||
|             tagTypes.forEach(tagType => _tagTypes.push(tagType)); | ||||
|             return Promise.resolve(_tagTypes); | ||||
|         }, | ||||
|         dropTagTypes: () => { | ||||
|             _tagTypes.splice(0, _tagTypes.length); | ||||
|             return Promise.resolve(); | ||||
|         }, | ||||
|     }; | ||||
| }; | ||||
|  | ||||
							
								
								
									
										2
									
								
								src/test/fixtures/store.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								src/test/fixtures/store.js
									
									
									
									
										vendored
									
									
								
							| @ -11,6 +11,7 @@ const strategyStore = require('./fake-strategies-store'); | ||||
| const contextFieldStore = require('./fake-context-store'); | ||||
| const settingStore = require('./fake-setting-store'); | ||||
| const addonStore = require('./fake-addon-store'); | ||||
| const projectStore = require('./fake-project-store'); | ||||
| 
 | ||||
| module.exports = { | ||||
|     createStores: (databaseIsUp = true) => { | ||||
| @ -33,6 +34,7 @@ module.exports = { | ||||
|             contextFieldStore: contextFieldStore(databaseIsUp), | ||||
|             settingStore: settingStore(databaseIsUp), | ||||
|             addonStore: addonStore(databaseIsUp), | ||||
|             projectStore: projectStore(databaseIsUp), | ||||
|         }; | ||||
|     }, | ||||
| }; | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user