mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-14 01:16:17 +02:00
feat: bulk update tags (#3274)
This commit is contained in:
parent
c42e3f2348
commit
a52dd10cf8
@ -48,6 +48,7 @@ export interface IFlags {
|
|||||||
proPlanAutoCharge?: boolean;
|
proPlanAutoCharge?: boolean;
|
||||||
notifications?: boolean;
|
notifications?: boolean;
|
||||||
loginHistory?: boolean;
|
loginHistory?: boolean;
|
||||||
|
bulkOperations?: boolean;
|
||||||
projectScopedSegments?: boolean;
|
projectScopedSegments?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,6 +69,7 @@ exports[`should create default config 1`] = `
|
|||||||
"flags": {
|
"flags": {
|
||||||
"ENABLE_DARK_MODE_SUPPORT": false,
|
"ENABLE_DARK_MODE_SUPPORT": false,
|
||||||
"anonymiseEventLog": false,
|
"anonymiseEventLog": false,
|
||||||
|
"bulkOperations": false,
|
||||||
"caseInsensitiveInOperators": false,
|
"caseInsensitiveInOperators": false,
|
||||||
"crOnVariants": false,
|
"crOnVariants": false,
|
||||||
"embedProxy": true,
|
"embedProxy": true,
|
||||||
@ -92,6 +93,7 @@ exports[`should create default config 1`] = `
|
|||||||
"experiments": {
|
"experiments": {
|
||||||
"ENABLE_DARK_MODE_SUPPORT": false,
|
"ENABLE_DARK_MODE_SUPPORT": false,
|
||||||
"anonymiseEventLog": false,
|
"anonymiseEventLog": false,
|
||||||
|
"bulkOperations": false,
|
||||||
"caseInsensitiveInOperators": false,
|
"caseInsensitiveInOperators": false,
|
||||||
"crOnVariants": false,
|
"crOnVariants": false,
|
||||||
"embedProxy": true,
|
"embedProxy": true,
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
import { EventEmitter } from 'stream';
|
|
||||||
import { Logger, LogProvider } from '../logger';
|
import { Logger, LogProvider } from '../logger';
|
||||||
import { ITag } from '../types/model';
|
import { ITag } from '../types';
|
||||||
|
import EventEmitter from 'events';
|
||||||
import metricsHelper from '../util/metrics-helper';
|
import metricsHelper from '../util/metrics-helper';
|
||||||
import { DB_TIME } from '../metric-events';
|
import { DB_TIME } from '../metric-events';
|
||||||
import { UNIQUE_CONSTRAINT_VIOLATION } from '../error/db-error';
|
|
||||||
import FeatureHasTagError from '../error/feature-has-tag-error';
|
|
||||||
import {
|
import {
|
||||||
IFeatureAndTag,
|
IFeatureAndTag,
|
||||||
IFeatureTag,
|
IFeatureTag,
|
||||||
@ -123,34 +121,22 @@ class FeatureTagStore implements IFeatureTagStore {
|
|||||||
const stopTimer = this.timer('tagFeature');
|
const stopTimer = this.timer('tagFeature');
|
||||||
await this.db<FeatureTagTable>(TABLE)
|
await this.db<FeatureTagTable>(TABLE)
|
||||||
.insert(this.featureAndTagToRow(featureName, tag))
|
.insert(this.featureAndTagToRow(featureName, tag))
|
||||||
.catch((err) => {
|
.onConflict(COLUMNS)
|
||||||
if (err.code === UNIQUE_CONSTRAINT_VIOLATION) {
|
.merge();
|
||||||
throw new FeatureHasTagError(
|
|
||||||
`${featureName} already has the tag: [${tag.type}:${tag.value}]`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
stopTimer();
|
stopTimer();
|
||||||
return tag;
|
return tag;
|
||||||
}
|
}
|
||||||
|
|
||||||
async tagFeatures(featureNames: string[], tag: ITag): Promise<ITag> {
|
async untagFeatures(featureTags: IFeatureTag[]): Promise<void> {
|
||||||
const stopTimer = this.timer('tagFeatures');
|
const stopTimer = this.timer('untagFeatures');
|
||||||
await this.db<FeatureTagTable>(TABLE)
|
try {
|
||||||
.insert(this.featuresAndTagToRow(featureNames, tag))
|
await this.db(TABLE)
|
||||||
.catch((err) => {
|
.whereIn(COLUMNS, featureTags.map(this.featureTagArray))
|
||||||
if (err.code === UNIQUE_CONSTRAINT_VIOLATION) {
|
.delete();
|
||||||
throw new FeatureHasTagError(
|
} catch (err) {
|
||||||
`Some of the features already have the tag: [${tag.type}:${tag.value}]`,
|
this.logger.error(err);
|
||||||
);
|
}
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
stopTimer();
|
stopTimer();
|
||||||
return tag;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -176,11 +162,9 @@ class FeatureTagStore implements IFeatureTagStore {
|
|||||||
stopTimer();
|
stopTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
async importFeatureTags(
|
async tagFeatures(featureTags: IFeatureTag[]): Promise<IFeatureAndTag[]> {
|
||||||
featureTags: IFeatureTag[],
|
|
||||||
): Promise<IFeatureAndTag[]> {
|
|
||||||
const rows = await this.db(TABLE)
|
const rows = await this.db(TABLE)
|
||||||
.insert(featureTags.map(this.importToRow))
|
.insert(featureTags.map(this.featureTagToRow))
|
||||||
.returning(COLUMNS)
|
.returning(COLUMNS)
|
||||||
.onConflict(COLUMNS)
|
.onConflict(COLUMNS)
|
||||||
.ignore();
|
.ignore();
|
||||||
@ -222,7 +206,7 @@ class FeatureTagStore implements IFeatureTagStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
importToRow({
|
featureTagToRow({
|
||||||
featureName,
|
featureName,
|
||||||
tagType,
|
tagType,
|
||||||
tagValue,
|
tagValue,
|
||||||
@ -234,6 +218,10 @@ class FeatureTagStore implements IFeatureTagStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
featureTagArray({ featureName, tagType, tagValue }: IFeatureTag): string[] {
|
||||||
|
return [featureName, tagType, tagValue];
|
||||||
|
}
|
||||||
|
|
||||||
featureAndTagToRow(
|
featureAndTagToRow(
|
||||||
featureName: string,
|
featureName: string,
|
||||||
{ type, value }: ITag,
|
{ type, value }: ITag,
|
||||||
@ -244,17 +232,6 @@ class FeatureTagStore implements IFeatureTagStore {
|
|||||||
tag_value: value,
|
tag_value: value,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
featuresAndTagToRow(
|
|
||||||
featureNames: string[],
|
|
||||||
{ type, value }: ITag,
|
|
||||||
): FeatureTagTable[] {
|
|
||||||
return featureNames.map((featureName) => ({
|
|
||||||
feature_name: featureName,
|
|
||||||
tag_type: type,
|
|
||||||
tag_value: value,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = FeatureTagStore;
|
module.exports = FeatureTagStore;
|
||||||
|
@ -4,9 +4,19 @@ import { TagsBulkAddSchema } from './tags-bulk-add-schema';
|
|||||||
test('tagsBulkAddSchema', () => {
|
test('tagsBulkAddSchema', () => {
|
||||||
const data: TagsBulkAddSchema = {
|
const data: TagsBulkAddSchema = {
|
||||||
features: ['my-feature'],
|
features: ['my-feature'],
|
||||||
tag: {
|
tags: {
|
||||||
type: 'simple',
|
addedTags: [
|
||||||
value: 'besttag',
|
{
|
||||||
|
type: 'simple',
|
||||||
|
value: 'besttag',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
removedTags: [
|
||||||
|
{
|
||||||
|
type: 'simple2',
|
||||||
|
value: 'besttag2',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { FromSchema } from 'json-schema-to-ts';
|
import { FromSchema } from 'json-schema-to-ts';
|
||||||
|
import { updateTagsSchema } from './update-tags-schema';
|
||||||
import { tagSchema } from './tag-schema';
|
import { tagSchema } from './tag-schema';
|
||||||
|
|
||||||
export const tagsBulkAddSchema = {
|
export const tagsBulkAddSchema = {
|
||||||
$id: '#/components/schemas/tagsBulkAddSchema',
|
$id: '#/components/schemas/tagsBulkAddSchema',
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
required: ['features', 'tag'],
|
required: ['features', 'tags'],
|
||||||
properties: {
|
properties: {
|
||||||
features: {
|
features: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
@ -14,12 +15,13 @@ export const tagsBulkAddSchema = {
|
|||||||
minLength: 1,
|
minLength: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tag: {
|
tags: {
|
||||||
$ref: '#/components/schemas/tagSchema',
|
$ref: '#/components/schemas/updateTagsSchema',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
updateTagsSchema,
|
||||||
tagSchema,
|
tagSchema,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
23
src/lib/openapi/spec/update-tags-schema.test.ts
Normal file
23
src/lib/openapi/spec/update-tags-schema.test.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { validateSchema } from '../validate';
|
||||||
|
import { UpdateTagsSchema } from './update-tags-schema';
|
||||||
|
|
||||||
|
test('updateTagsSchema', () => {
|
||||||
|
const data: UpdateTagsSchema = {
|
||||||
|
addedTags: [
|
||||||
|
{
|
||||||
|
type: 'simple',
|
||||||
|
value: 'besttag',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
removedTags: [
|
||||||
|
{
|
||||||
|
type: 'simple2',
|
||||||
|
value: 'besttag2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateSchema('#/components/schemas/updateTagsSchema', data),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
@ -24,6 +24,8 @@ import {
|
|||||||
import { emptyResponse } from '../../openapi/util/standard-responses';
|
import { emptyResponse } from '../../openapi/util/standard-responses';
|
||||||
import FeatureTagService from 'lib/services/feature-tag-service';
|
import FeatureTagService from 'lib/services/feature-tag-service';
|
||||||
import { TagsBulkAddSchema } from '../../openapi/spec/tags-bulk-add-schema';
|
import { TagsBulkAddSchema } from '../../openapi/spec/tags-bulk-add-schema';
|
||||||
|
import NotFoundError from '../../error/notfound-error';
|
||||||
|
import { IFlagResolver } from '../../types';
|
||||||
|
|
||||||
const version = 1;
|
const version = 1;
|
||||||
|
|
||||||
@ -36,6 +38,8 @@ class TagController extends Controller {
|
|||||||
|
|
||||||
private openApiService: OpenApiService;
|
private openApiService: OpenApiService;
|
||||||
|
|
||||||
|
private flagResolver: IFlagResolver;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: IUnleashConfig,
|
config: IUnleashConfig,
|
||||||
{
|
{
|
||||||
@ -52,6 +56,7 @@ class TagController extends Controller {
|
|||||||
this.openApiService = openApiService;
|
this.openApiService = openApiService;
|
||||||
this.featureTagService = featureTagService;
|
this.featureTagService = featureTagService;
|
||||||
this.logger = config.getLogger('/admin-api/tag.js');
|
this.logger = config.getLogger('/admin-api/tag.js');
|
||||||
|
this.flagResolver = config.flagResolver;
|
||||||
|
|
||||||
this.route({
|
this.route({
|
||||||
method: 'get',
|
method: 'get',
|
||||||
@ -85,18 +90,16 @@ class TagController extends Controller {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
this.route({
|
this.route({
|
||||||
method: 'post',
|
method: 'put',
|
||||||
path: '/features',
|
path: '/features',
|
||||||
handler: this.addTagToFeatures,
|
handler: this.updateFeaturesTags,
|
||||||
permission: UPDATE_FEATURE,
|
permission: UPDATE_FEATURE,
|
||||||
middleware: [
|
middleware: [
|
||||||
openApiService.validPath({
|
openApiService.validPath({
|
||||||
tags: ['Tags'],
|
tags: ['Tags'],
|
||||||
operationId: 'addTagToFeatures',
|
operationId: 'addTagToFeatures',
|
||||||
requestBody: createRequestSchema('tagsBulkAddSchema'),
|
requestBody: createRequestSchema('tagsBulkAddSchema'),
|
||||||
responses: {
|
responses: { 200: emptyResponse },
|
||||||
201: resourceCreatedResponseSchema('tagSchema'),
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@ -207,18 +210,22 @@ class TagController extends Controller {
|
|||||||
res.status(200).end();
|
res.status(200).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
async addTagToFeatures(
|
async updateFeaturesTags(
|
||||||
req: IAuthRequest<void, void, TagsBulkAddSchema>,
|
req: IAuthRequest<void, void, TagsBulkAddSchema>,
|
||||||
res: Response<TagSchema>,
|
res: Response<TagSchema>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { features, tag } = req.body;
|
if (!this.flagResolver.isEnabled('bulkOperations')) {
|
||||||
|
throw new NotFoundError('Bulk operations are not enabled');
|
||||||
|
}
|
||||||
|
const { features, tags } = req.body;
|
||||||
const userName = extractUsername(req);
|
const userName = extractUsername(req);
|
||||||
const addedTag = await this.featureTagService.addTags(
|
await this.featureTagService.updateTags(
|
||||||
features,
|
features,
|
||||||
tag,
|
tags.addedTags,
|
||||||
|
tags.removedTags,
|
||||||
userName,
|
userName,
|
||||||
);
|
);
|
||||||
res.status(201).json(addedTag);
|
res.status(200).end();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export default TagController;
|
export default TagController;
|
||||||
|
@ -4,7 +4,10 @@ import { FEATURE_TAGGED, FEATURE_UNTAGGED, TAG_CREATED } from '../types/events';
|
|||||||
import { IUnleashConfig } from '../types/option';
|
import { IUnleashConfig } from '../types/option';
|
||||||
import { IFeatureToggleStore, IUnleashStores } from '../types/stores';
|
import { IFeatureToggleStore, IUnleashStores } from '../types/stores';
|
||||||
import { tagSchema } from './tag-schema';
|
import { tagSchema } from './tag-schema';
|
||||||
import { IFeatureTagStore } from '../types/stores/feature-tag-store';
|
import {
|
||||||
|
IFeatureTag,
|
||||||
|
IFeatureTagStore,
|
||||||
|
} from '../types/stores/feature-tag-store';
|
||||||
import { IEventStore } from '../types/stores/event-store';
|
import { IEventStore } from '../types/stores/event-store';
|
||||||
import { ITagStore } from '../types/stores/tag-store';
|
import { ITagStore } from '../types/stores/tag-store';
|
||||||
import { ITag } from '../types/model';
|
import { ITag } from '../types/model';
|
||||||
@ -64,30 +67,61 @@ class FeatureTagService {
|
|||||||
return validatedTag;
|
return validatedTag;
|
||||||
}
|
}
|
||||||
|
|
||||||
async addTags(
|
async updateTags(
|
||||||
featureNames: string[],
|
featureNames: string[],
|
||||||
tag: ITag,
|
addedTags: ITag[],
|
||||||
|
removedTags: ITag[],
|
||||||
userName: string,
|
userName: string,
|
||||||
): Promise<ITag> {
|
): Promise<void> {
|
||||||
const featureToggles = await this.featureToggleStore.getAllByNames(
|
const featureToggles = await this.featureToggleStore.getAllByNames(
|
||||||
featureNames,
|
featureNames,
|
||||||
);
|
);
|
||||||
const validatedTag = await tagSchema.validateAsync(tag);
|
|
||||||
await this.createTagIfNeeded(validatedTag, userName);
|
|
||||||
await this.featureTagStore.tagFeatures(featureNames, validatedTag);
|
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
featureToggles.map((featureToggle) =>
|
addedTags.map((tag) => this.createTagIfNeeded(tag, userName)),
|
||||||
this.eventStore.store({
|
|
||||||
type: FEATURE_TAGGED,
|
|
||||||
createdBy: userName,
|
|
||||||
featureName: featureToggle.name,
|
|
||||||
project: featureToggle.project,
|
|
||||||
data: validatedTag,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return validatedTag;
|
const createdFeatureTags: IFeatureTag[] = featureNames.flatMap(
|
||||||
|
(featureName) =>
|
||||||
|
addedTags.map((addedTag) => ({
|
||||||
|
featureName,
|
||||||
|
tagType: addedTag.type,
|
||||||
|
tagValue: addedTag.value,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.featureTagStore.tagFeatures(createdFeatureTags);
|
||||||
|
|
||||||
|
const removedFeatureTags: IFeatureTag[] = featureNames.flatMap(
|
||||||
|
(featureName) =>
|
||||||
|
removedTags.map((addedTag) => ({
|
||||||
|
featureName,
|
||||||
|
tagType: addedTag.type,
|
||||||
|
tagValue: addedTag.value,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.featureTagStore.untagFeatures(removedFeatureTags);
|
||||||
|
|
||||||
|
const creationEvents = featureToggles.flatMap((featureToggle) =>
|
||||||
|
addedTags.map((addedTag) => ({
|
||||||
|
type: FEATURE_TAGGED,
|
||||||
|
createdBy: userName,
|
||||||
|
featureName: featureToggle.name,
|
||||||
|
project: featureToggle.project,
|
||||||
|
data: addedTag,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const removalEvents = featureToggles.flatMap((featureToggle) =>
|
||||||
|
removedTags.map((removedTag) => ({
|
||||||
|
type: FEATURE_UNTAGGED,
|
||||||
|
createdBy: userName,
|
||||||
|
featureName: featureToggle.name,
|
||||||
|
project: featureToggle.project,
|
||||||
|
data: removedTag,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.eventStore.batchStore([...creationEvents, ...removalEvents]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTagIfNeeded(tag: ITag, userName: string): Promise<void> {
|
async createTagIfNeeded(tag: ITag, userName: string): Promise<void> {
|
||||||
|
@ -600,10 +600,9 @@ export default class StateService {
|
|||||||
: true,
|
: true,
|
||||||
);
|
);
|
||||||
if (featureTagsToInsert.length > 0) {
|
if (featureTagsToInsert.length > 0) {
|
||||||
const importedFeatureTags =
|
const importedFeatureTags = await this.featureTagStore.tagFeatures(
|
||||||
await this.featureTagStore.importFeatureTags(
|
featureTagsToInsert,
|
||||||
featureTagsToInsert,
|
);
|
||||||
);
|
|
||||||
const importedFeatureTagEvents = importedFeatureTags.map((tag) => ({
|
const importedFeatureTagEvents = importedFeatureTags.map((tag) => ({
|
||||||
type: FEATURE_TAG_IMPORT,
|
type: FEATURE_TAG_IMPORT,
|
||||||
createdBy: userName,
|
createdBy: userName,
|
||||||
|
@ -64,6 +64,10 @@ const flags = {
|
|||||||
),
|
),
|
||||||
notifications: parseEnvVarBoolean(process.env.NOTIFICATIONS, false),
|
notifications: parseEnvVarBoolean(process.env.NOTIFICATIONS, false),
|
||||||
loginHistory: parseEnvVarBoolean(process.env.UNLEASH_LOGIN_HISTORY, false),
|
loginHistory: parseEnvVarBoolean(process.env.UNLEASH_LOGIN_HISTORY, false),
|
||||||
|
bulkOperations: parseEnvVarBoolean(
|
||||||
|
process.env.UNLEASH_BULK_OPERATIONS,
|
||||||
|
false,
|
||||||
|
),
|
||||||
projectScopedSegments: parseEnvVarBoolean(
|
projectScopedSegments: parseEnvVarBoolean(
|
||||||
process.env.PROJECT_SCOPED_SEGMENTS,
|
process.env.PROJECT_SCOPED_SEGMENTS,
|
||||||
false,
|
false,
|
||||||
|
@ -15,7 +15,7 @@ export interface IFeatureTagStore extends Store<IFeatureTag, IFeatureTag> {
|
|||||||
getAllTagsForFeature(featureName: string): Promise<ITag[]>;
|
getAllTagsForFeature(featureName: string): Promise<ITag[]>;
|
||||||
getAllByFeatures(features: string[]): Promise<IFeatureTag[]>;
|
getAllByFeatures(features: string[]): Promise<IFeatureTag[]>;
|
||||||
tagFeature(featureName: string, tag: ITag): Promise<ITag>;
|
tagFeature(featureName: string, tag: ITag): Promise<ITag>;
|
||||||
tagFeatures(featureNames: string[], tag: ITag): Promise<ITag>;
|
tagFeatures(featureTags: IFeatureTag[]): Promise<IFeatureAndTag[]>;
|
||||||
importFeatureTags(featureTags: IFeatureTag[]): Promise<IFeatureAndTag[]>;
|
|
||||||
untagFeature(featureName: string, tag: ITag): Promise<void>;
|
untagFeature(featureName: string, tag: ITag): Promise<void>;
|
||||||
|
untagFeatures(featureTags: IFeatureTag[]): Promise<void>;
|
||||||
}
|
}
|
||||||
|
@ -739,31 +739,6 @@ test('Querying with multiple filters ANDs the filters', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Tagging a feature with a tag it already has should return 409', async () => {
|
|
||||||
const feature1Name = `test.${randomId()}`;
|
|
||||||
await app.request.post('/api/admin/features').send({
|
|
||||||
name: feature1Name,
|
|
||||||
type: 'killswitch',
|
|
||||||
enabled: true,
|
|
||||||
strategies: [{ name: 'default' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const tag = { value: randomId(), type: 'simple' };
|
|
||||||
await app.request
|
|
||||||
.post(`/api/admin/features/${feature1Name}/tags`)
|
|
||||||
.send(tag)
|
|
||||||
.expect(201);
|
|
||||||
return app.request
|
|
||||||
.post(`/api/admin/features/${feature1Name}/tags`)
|
|
||||||
.send(tag)
|
|
||||||
.expect(409)
|
|
||||||
.expect((res) => {
|
|
||||||
expect(res.body.details[0].message).toBe(
|
|
||||||
`${feature1Name} already has the tag: [${tag.type}:${tag.value}]`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('marks feature toggle as stale', async () => {
|
test('marks feature toggle as stale', async () => {
|
||||||
expect.assertions(1);
|
expect.assertions(1);
|
||||||
await app.request
|
await app.request
|
||||||
|
@ -11,6 +11,7 @@ beforeAll(async () => {
|
|||||||
experimental: {
|
experimental: {
|
||||||
flags: {
|
flags: {
|
||||||
strictSchemaValidation: true,
|
strictSchemaValidation: true,
|
||||||
|
bulkOperations: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -112,16 +113,30 @@ test('Can delete a tag', async () => {
|
|||||||
test('Can tag features', async () => {
|
test('Can tag features', async () => {
|
||||||
const featureName = 'test.feature';
|
const featureName = 'test.feature';
|
||||||
const featureName2 = 'test.feature2';
|
const featureName2 = 'test.feature2';
|
||||||
const tag = {
|
const addedTag = {
|
||||||
value: 'TeamRed',
|
value: 'TeamRed',
|
||||||
type: 'simple',
|
type: 'simple',
|
||||||
};
|
};
|
||||||
|
const removedTag = {
|
||||||
|
value: 'remove_me',
|
||||||
|
type: 'simple',
|
||||||
|
};
|
||||||
await app.request.post('/api/admin/features').send({
|
await app.request.post('/api/admin/features').send({
|
||||||
name: featureName,
|
name: featureName,
|
||||||
type: 'killswitch',
|
type: 'killswitch',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
strategies: [{ name: 'default' }],
|
strategies: [{ name: 'default' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await db.stores.tagStore.createTag(removedTag);
|
||||||
|
await db.stores.featureTagStore.tagFeature(featureName, removedTag);
|
||||||
|
|
||||||
|
const initialTagState = await app.request.get(
|
||||||
|
`/api/admin/features/${featureName}/tags`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(initialTagState.body).toMatchObject({ tags: [removedTag] });
|
||||||
|
|
||||||
await app.request.post('/api/admin/features').send({
|
await app.request.post('/api/admin/features').send({
|
||||||
name: featureName2,
|
name: featureName2,
|
||||||
type: 'killswitch',
|
type: 'killswitch',
|
||||||
@ -129,9 +144,12 @@ test('Can tag features', async () => {
|
|||||||
strategies: [{ name: 'default' }],
|
strategies: [{ name: 'default' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.request.post('/api/admin/tags/features').send({
|
await app.request.put('/api/admin/tags/features').send({
|
||||||
features: [featureName, featureName2],
|
features: [featureName, featureName2],
|
||||||
tag: tag,
|
tags: {
|
||||||
|
addedTags: [addedTag],
|
||||||
|
removedTags: [removedTag],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const res = await app.request.get(
|
const res = await app.request.get(
|
||||||
`/api/admin/features/${featureName}/tags`,
|
`/api/admin/features/${featureName}/tags`,
|
||||||
@ -141,6 +159,6 @@ test('Can tag features', async () => {
|
|||||||
`/api/admin/features/${featureName2}/tags`,
|
`/api/admin/features/${featureName2}/tags`,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.body).toMatchObject({ tags: [tag] });
|
expect(res.body).toMatchObject({ tags: [addedTag] });
|
||||||
expect(res2.body).toMatchObject({ tags: [tag] });
|
expect(res2.body).toMatchObject({ tags: [addedTag] });
|
||||||
});
|
});
|
||||||
|
@ -3652,13 +3652,13 @@ Stats are divided into current and previous **windows**.
|
|||||||
},
|
},
|
||||||
"type": "array",
|
"type": "array",
|
||||||
},
|
},
|
||||||
"tag": {
|
"tags": {
|
||||||
"$ref": "#/components/schemas/tagSchema",
|
"$ref": "#/components/schemas/updateTagsSchema",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"features",
|
"features",
|
||||||
"tag",
|
"tags",
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
},
|
},
|
||||||
@ -7960,7 +7960,7 @@ If the provided project does not exist, the list of events will be empty.",
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"/api/admin/tags/features": {
|
"/api/admin/tags/features": {
|
||||||
"post": {
|
"put": {
|
||||||
"operationId": "addTagToFeatures",
|
"operationId": "addTagToFeatures",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"content": {
|
"content": {
|
||||||
@ -7974,24 +7974,8 @@ If the provided project does not exist, the list of events will be empty.",
|
|||||||
"required": true,
|
"required": true,
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"201": {
|
"200": {
|
||||||
"content": {
|
"description": "This response has no body.",
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/tagSchema",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"description": "The resource was successfully created.",
|
|
||||||
"headers": {
|
|
||||||
"location": {
|
|
||||||
"description": "The location of the newly created resource.",
|
|
||||||
"schema": {
|
|
||||||
"format": "uri",
|
|
||||||
"type": "string",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"tags": [
|
"tags": [
|
||||||
|
@ -71,16 +71,6 @@ test('should untag feature', async () => {
|
|||||||
expect(featureTags).toHaveLength(0);
|
expect(featureTags).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should throw if feature have tag', async () => {
|
|
||||||
expect.assertions(1);
|
|
||||||
await featureTagStore.tagFeature(featureName, tag);
|
|
||||||
try {
|
|
||||||
await featureTagStore.tagFeature(featureName, tag);
|
|
||||||
} catch (e) {
|
|
||||||
expect(e.message).toContain('already has the tag');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('get all feature tags', async () => {
|
test('get all feature tags', async () => {
|
||||||
await featureTagStore.tagFeature(featureName, tag);
|
await featureTagStore.tagFeature(featureName, tag);
|
||||||
await featureToggleStore.create('default', {
|
await featureToggleStore.create('default', {
|
||||||
@ -95,7 +85,7 @@ test('should import feature tags', async () => {
|
|||||||
await featureToggleStore.create('default', {
|
await featureToggleStore.create('default', {
|
||||||
name: 'some-other-toggle-import',
|
name: 'some-other-toggle-import',
|
||||||
});
|
});
|
||||||
await featureTagStore.importFeatureTags([
|
await featureTagStore.tagFeatures([
|
||||||
{ featureName, tagType: tag.type, tagValue: tag.value },
|
{ featureName, tagType: tag.type, tagValue: tag.value },
|
||||||
{
|
{
|
||||||
featureName: 'some-other-toggle-import',
|
featureName: 'some-other-toggle-import',
|
||||||
|
15
src/test/fixtures/fake-feature-tag-store.ts
vendored
15
src/test/fixtures/fake-feature-tag-store.ts
vendored
@ -57,9 +57,7 @@ export default class FakeFeatureTagStore implements IFeatureTagStore {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
async importFeatureTags(
|
async tagFeatures(featureTags: IFeatureTag[]): Promise<IFeatureAndTag[]> {
|
||||||
featureTags: IFeatureTag[],
|
|
||||||
): Promise<IFeatureAndTag[]> {
|
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
featureTags.map(async (fT) => {
|
featureTags.map(async (fT) => {
|
||||||
const saved = await this.tagFeature(fT.featureName, {
|
const saved = await this.tagFeature(fT.featureName, {
|
||||||
@ -92,14 +90,9 @@ export default class FakeFeatureTagStore implements IFeatureTagStore {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async tagFeatures(featureNames: string[], tag: ITag): Promise<ITag> {
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const featureTags = featureNames.map((featureName) => ({
|
untagFeatures(featureTags: IFeatureTag[]): Promise<void> {
|
||||||
featureName,
|
throw new Error('Method not implemented.');
|
||||||
tagType: tag.type,
|
|
||||||
tagValue: tag.value,
|
|
||||||
}));
|
|
||||||
this.featureTags.push(...featureTags);
|
|
||||||
return Promise.resolve(tag);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user