From 289cf85a3cce464028d41fe8b4e00c50db8015dc Mon Sep 17 00:00:00 2001 From: Christopher Kolstad Date: Fri, 12 Mar 2021 11:08:10 +0100 Subject: [PATCH] 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 --- docs/deploy/import-export.md | 8 +- src/lib/db/feature-toggle-store.js | 49 ++++ src/lib/db/project-store.js | 16 ++ src/lib/db/tag-store.js | 14 + src/lib/db/tag-type-store.js | 18 ++ src/lib/event-type.js | 8 + src/lib/routes/admin-api/state.js | 34 ++- src/lib/services/feature-schema.js | 13 +- src/lib/services/state-schema.js | 22 +- src/lib/services/state-service.js | 245 +++++++++++++++++- src/lib/services/state-service.test.js | 221 +++++++++++++++- src/lib/services/state-util.js | 10 +- src/test/fixtures/fake-event-store.js | 8 + .../fixtures/fake-feature-toggle-store.js | 61 +++-- src/test/fixtures/fake-project-store.js | 35 +++ src/test/fixtures/fake-tag-store.js | 8 + src/test/fixtures/fake-tag-type-store.js | 15 +- src/test/fixtures/store.js | 2 + 18 files changed, 731 insertions(+), 56 deletions(-) create mode 100644 src/test/fixtures/fake-project-store.js diff --git a/docs/deploy/import-export.md b/docs/deploy/import-export.md index 445bd7ddd1..0163e90769 100644 --- a/docs/deploy/import-export.md +++ b/docs/deploy/import-export.md @@ -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 diff --git a/src/lib/db/feature-toggle-store.js b/src/lib/db/feature-toggle-store.js index d15c19ba02..59d7327eb0 100644 --- a/src/lib/db/feature-toggle-store.js +++ b/src/lib/db/feature-toggle-store.js @@ -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, diff --git a/src/lib/db/project-store.js b/src/lib/db/project-store.js index 1f36abf2cf..c0ec669ad8 100644 --- a/src/lib/db/project-store.js +++ b/src/lib/db/project-store.js @@ -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) diff --git a/src/lib/db/tag-store.js b/src/lib/db/tag-store.js index c128ff65d5..d84544ffbe 100644 --- a/src/lib/db/tag-store.js +++ b/src/lib/db/tag-store.js @@ -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, diff --git a/src/lib/db/tag-type-store.js b/src/lib/db/tag-type-store.js index d98a93c6c8..972f838dbb 100644 --- a/src/lib/db/tag-type-store.js +++ b/src/lib/db/tag-type-store.js @@ -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) diff --git a/src/lib/event-type.js b/src/lib/event-type.js index b3bb215d20..7d1c0aeafd 100644 --- a/src/lib/event-type.js +++ b/src/lib/event-type.js @@ -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', diff --git a/src/lib/routes/admin-api/state.js b/src/lib/routes/admin-api/state.js index 5ecac1ddaf..81fdf147fb 100644 --- a/src/lib/routes/admin-api/state.js +++ b/src/lib/routes/admin-api/state.js @@ -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') { diff --git a/src/lib/services/feature-schema.js b/src/lib/services/feature-schema.js index 7f6c7d9273..c4c3115aa6 100644 --- a/src/lib/services/feature-schema.js +++ b/src/lib/services/feature-schema.js @@ -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, +}; diff --git a/src/lib/services/state-schema.js b/src/lib/services/state-schema.js index 02d3180d0f..d189373c0e 100644 --- a/src/lib/services/state-schema.js +++ b/src/lib/services/state-schema.js @@ -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 = { diff --git a/src/lib/services/state-service.js b/src/lib/services/state-service.js index bdf6589557..cd211c3fd8 100644 --- a/src/lib/services/state-service.js +++ b/src/lib/services/state-service.js @@ -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, + }), + ); } } diff --git a/src/lib/services/state-service.test.js b/src/lib/services/state-service.test.js index aafee2d8a2..a360bdbf8a 100644 --- a/src/lib/services/state-service.test.js +++ b/src/lib/services/state-service.test.js @@ -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'); +}); diff --git a/src/lib/services/state-util.js b/src/lib/services/state-util.js index f410b8db3c..d1b534a6b6 100644 --- a/src/lib/services/state-util.js +++ b/src/lib/services/state-util.js @@ -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, }; diff --git a/src/test/fixtures/fake-event-store.js b/src/test/fixtures/fake-event-store.js index a5153960b3..b547acd51d 100644 --- a/src/test/fixtures/fake-event-store.js +++ b/src/test/fixtures/fake-event-store.js @@ -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); } diff --git a/src/test/fixtures/fake-feature-toggle-store.js b/src/test/fixtures/fake-feature-toggle-store.js index 81fbd295c0..b1fcac9d79 100644 --- a/src/test/fixtures/fake-feature-toggle-store.js +++ b/src/test/fixtures/fake-feature-toggle-store.js @@ -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(); }, }; }; diff --git a/src/test/fixtures/fake-project-store.js b/src/test/fixtures/fake-project-store.js new file mode 100644 index 0000000000..7fd8e94be6 --- /dev/null +++ b/src/test/fixtures/fake-project-store.js @@ -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}`), + ); + }, + }; +}; diff --git a/src/test/fixtures/fake-tag-store.js b/src/test/fixtures/fake-tag-store.js index c646ce4061..d821f8a6b3 100644 --- a/src/test/fixtures/fake-tag-store.js +++ b/src/test/fixtures/fake-tag-store.js @@ -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(); + }, }; }; diff --git a/src/test/fixtures/fake-tag-type-store.js b/src/test/fixtures/fake-tag-type-store.js index 3ce592fd48..920034d47a 100644 --- a/src/test/fixtures/fake-tag-type-store.js +++ b/src/test/fixtures/fake-tag-type-store.js @@ -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(); }, }; }; diff --git a/src/test/fixtures/store.js b/src/test/fixtures/store.js index 051d78a233..6960c53feb 100644 --- a/src/test/fixtures/store.js +++ b/src/test/fixtures/store.js @@ -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), }; }, };