mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-24 01:18:01 +02:00
feat: import service validate duplicates (#4558)
This commit is contained in:
parent
0fb078d4c5
commit
2c3514954c
@ -42,8 +42,9 @@ import {
|
|||||||
} from '../../services';
|
} from '../../services';
|
||||||
import { isValidField } from './import-context-validation';
|
import { isValidField } from './import-context-validation';
|
||||||
import { IImportTogglesStore } from './import-toggles-store-type';
|
import { IImportTogglesStore } from './import-toggles-store-type';
|
||||||
import { ImportPermissionsService } from './import-permissions-service';
|
import { ImportPermissionsService, Mode } from './import-permissions-service';
|
||||||
import { ImportValidationMessages } from './import-validation-messages';
|
import { ImportValidationMessages } from './import-validation-messages';
|
||||||
|
import { findDuplicates } from '../../util/findDuplicates';
|
||||||
|
|
||||||
export default class ExportImportService {
|
export default class ExportImportService {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
@ -144,6 +145,7 @@ export default class ExportImportService {
|
|||||||
async validate(
|
async validate(
|
||||||
dto: ImportTogglesSchema,
|
dto: ImportTogglesSchema,
|
||||||
user: User,
|
user: User,
|
||||||
|
mode = 'regular' as Mode,
|
||||||
): Promise<ImportTogglesValidateSchema> {
|
): Promise<ImportTogglesValidateSchema> {
|
||||||
const [
|
const [
|
||||||
unsupportedStrategies,
|
unsupportedStrategies,
|
||||||
@ -153,6 +155,7 @@ export default class ExportImportService {
|
|||||||
otherProjectFeatures,
|
otherProjectFeatures,
|
||||||
existingProjectFeatures,
|
existingProjectFeatures,
|
||||||
missingPermissions,
|
missingPermissions,
|
||||||
|
duplicateFeatures,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.getUnsupportedStrategies(dto),
|
this.getUnsupportedStrategies(dto),
|
||||||
this.getUsedCustomStrategies(dto),
|
this.getUsedCustomStrategies(dto),
|
||||||
@ -163,23 +166,23 @@ export default class ExportImportService {
|
|||||||
this.importPermissionsService.getMissingPermissions(
|
this.importPermissionsService.getMissingPermissions(
|
||||||
dto,
|
dto,
|
||||||
user,
|
user,
|
||||||
'regular',
|
mode,
|
||||||
),
|
),
|
||||||
|
this.getDuplicateFeatures(dto),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const errors = ImportValidationMessages.compileErrors(
|
const errors = ImportValidationMessages.compileErrors({
|
||||||
dto.project,
|
projectName: dto.project,
|
||||||
unsupportedStrategies,
|
strategies: unsupportedStrategies,
|
||||||
unsupportedContextFields || [],
|
contextFields: unsupportedContextFields || [],
|
||||||
[],
|
|
||||||
otherProjectFeatures,
|
otherProjectFeatures,
|
||||||
false,
|
duplicateFeatures,
|
||||||
);
|
});
|
||||||
const warnings = ImportValidationMessages.compileWarnings(
|
const warnings = ImportValidationMessages.compileWarnings({
|
||||||
usedCustomStrategies,
|
|
||||||
archivedFeatures,
|
archivedFeatures,
|
||||||
existingProjectFeatures,
|
existingFeatures: existingProjectFeatures,
|
||||||
);
|
usedCustomStrategies,
|
||||||
|
});
|
||||||
const permissions =
|
const permissions =
|
||||||
ImportValidationMessages.compilePermissionErrors(
|
ImportValidationMessages.compilePermissionErrors(
|
||||||
missingPermissions,
|
missingPermissions,
|
||||||
@ -192,24 +195,36 @@ export default class ExportImportService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async importVerify(
|
||||||
|
dto: ImportTogglesSchema,
|
||||||
|
user: User,
|
||||||
|
mode = 'regular' as Mode,
|
||||||
|
): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
this.verifyStrategies(dto),
|
||||||
|
this.verifyContextFields(dto),
|
||||||
|
this.importPermissionsService.verifyPermissions(dto, user, mode),
|
||||||
|
this.verifyFeatures(dto),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async importToggleLevelInfo(
|
||||||
|
dto: ImportTogglesSchema,
|
||||||
|
user: User,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.createOrUpdateToggles(dto, user);
|
||||||
|
await this.importToggleVariants(dto, user);
|
||||||
|
await this.importTagTypes(dto, user);
|
||||||
|
await this.importTags(dto, user);
|
||||||
|
await this.importContextFields(dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
async import(dto: ImportTogglesSchema, user: User): Promise<void> {
|
async import(dto: ImportTogglesSchema, user: User): Promise<void> {
|
||||||
const cleanedDto = await this.cleanData(dto);
|
const cleanedDto = await this.cleanData(dto);
|
||||||
|
|
||||||
await Promise.all([
|
await this.importVerify(cleanedDto, user);
|
||||||
this.verifyStrategies(cleanedDto),
|
|
||||||
this.verifyContextFields(cleanedDto),
|
await this.importToggleLevelInfo(cleanedDto, user);
|
||||||
this.importPermissionsService.verifyPermissions(
|
|
||||||
dto,
|
|
||||||
user,
|
|
||||||
'regular',
|
|
||||||
),
|
|
||||||
this.verifyFeatures(dto),
|
|
||||||
]);
|
|
||||||
await this.createOrUpdateToggles(cleanedDto, user);
|
|
||||||
await this.importToggleVariants(dto, user);
|
|
||||||
await this.importTagTypes(cleanedDto, user);
|
|
||||||
await this.importTags(cleanedDto, user);
|
|
||||||
await this.importContextFields(dto, user);
|
|
||||||
|
|
||||||
await this.importDefault(cleanedDto, user);
|
await this.importDefault(cleanedDto, user);
|
||||||
await this.eventStore.store({
|
await this.eventStore.store({
|
||||||
@ -220,7 +235,7 @@ export default class ExportImportService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async importDefault(dto: ImportTogglesSchema, user: User) {
|
async importDefault(dto: ImportTogglesSchema, user: User): Promise<void> {
|
||||||
await this.deleteStrategies(dto);
|
await this.deleteStrategies(dto);
|
||||||
await this.importStrategies(dto, user);
|
await this.importStrategies(dto, user);
|
||||||
await this.importToggleStatuses(dto, user);
|
await this.importToggleStatuses(dto, user);
|
||||||
@ -429,7 +444,9 @@ export default class ExportImportService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async removeArchivedFeatures(dto: ImportTogglesSchema) {
|
async removeArchivedFeatures(
|
||||||
|
dto: ImportTogglesSchema,
|
||||||
|
): Promise<ImportTogglesSchema> {
|
||||||
const archivedFeatures = await this.getArchivedFeatures(dto);
|
const archivedFeatures = await this.getArchivedFeatures(dto);
|
||||||
const featureTags =
|
const featureTags =
|
||||||
dto.data.featureTags?.filter(
|
dto.data.featureTags?.filter(
|
||||||
@ -549,6 +566,10 @@ export default class ExportImportService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getDuplicateFeatures(dto: ImportTogglesSchema) {
|
||||||
|
return findDuplicates(dto.data.features.map((feature) => feature.name));
|
||||||
|
}
|
||||||
|
|
||||||
private async getNewTagTypes(dto: ImportTogglesSchema) {
|
private async getNewTagTypes(dto: ImportTogglesSchema) {
|
||||||
const existingTagTypes = (await this.tagTypeService.getAll()).map(
|
const existingTagTypes = (await this.tagTypeService.getAll()).map(
|
||||||
(tagType) => tagType.name,
|
(tagType) => tagType.name,
|
||||||
|
@ -51,6 +51,8 @@ const defaultContext: ContextFieldSchema = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultFeatureName = 'first_feature';
|
||||||
|
|
||||||
const createToggle = async (
|
const createToggle = async (
|
||||||
toggle: FeatureToggleDTO,
|
toggle: FeatureToggleDTO,
|
||||||
strategy: Omit<IStrategyConfig, 'id'> = defaultStrategy,
|
strategy: Omit<IStrategyConfig, 'id'> = defaultStrategy,
|
||||||
@ -195,7 +197,7 @@ describe('import-export for project-specific segments', () => {
|
|||||||
};
|
};
|
||||||
await createToggle(
|
await createToggle(
|
||||||
{
|
{
|
||||||
name: 'first_feature',
|
name: defaultFeatureName,
|
||||||
description: 'the #1 feature',
|
description: 'the #1 feature',
|
||||||
},
|
},
|
||||||
strategy,
|
strategy,
|
||||||
@ -205,7 +207,7 @@ describe('import-export for project-specific segments', () => {
|
|||||||
const { body } = await app.request
|
const { body } = await app.request
|
||||||
.post('/api/admin/features-batch/export')
|
.post('/api/admin/features-batch/export')
|
||||||
.send({
|
.send({
|
||||||
features: ['first_feature'],
|
features: [defaultFeatureName],
|
||||||
environment: 'default',
|
environment: 'default',
|
||||||
})
|
})
|
||||||
.set('Content-Type', 'application/json')
|
.set('Content-Type', 'application/json')
|
||||||
@ -215,7 +217,7 @@ describe('import-export for project-specific segments', () => {
|
|||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
features: [
|
features: [
|
||||||
{
|
{
|
||||||
name: 'first_feature',
|
name: defaultFeatureName,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
featureStrategies: [resultStrategy],
|
featureStrategies: [resultStrategy],
|
||||||
@ -223,7 +225,7 @@ describe('import-export for project-specific segments', () => {
|
|||||||
{
|
{
|
||||||
enabled: false,
|
enabled: false,
|
||||||
environment: 'default',
|
environment: 'default',
|
||||||
featureName: 'first_feature',
|
featureName: defaultFeatureName,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
segments: [
|
segments: [
|
||||||
@ -254,7 +256,7 @@ test('exports features', async () => {
|
|||||||
};
|
};
|
||||||
await createToggle(
|
await createToggle(
|
||||||
{
|
{
|
||||||
name: 'first_feature',
|
name: defaultFeatureName,
|
||||||
description: 'the #1 feature',
|
description: 'the #1 feature',
|
||||||
},
|
},
|
||||||
strategy,
|
strategy,
|
||||||
@ -269,7 +271,7 @@ test('exports features', async () => {
|
|||||||
const { body } = await app.request
|
const { body } = await app.request
|
||||||
.post('/api/admin/features-batch/export')
|
.post('/api/admin/features-batch/export')
|
||||||
.send({
|
.send({
|
||||||
features: ['first_feature'],
|
features: [defaultFeatureName],
|
||||||
environment: 'default',
|
environment: 'default',
|
||||||
})
|
})
|
||||||
.set('Content-Type', 'application/json')
|
.set('Content-Type', 'application/json')
|
||||||
@ -279,7 +281,7 @@ test('exports features', async () => {
|
|||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
features: [
|
features: [
|
||||||
{
|
{
|
||||||
name: 'first_feature',
|
name: defaultFeatureName,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
featureStrategies: [resultStrategy],
|
featureStrategies: [resultStrategy],
|
||||||
@ -287,7 +289,7 @@ test('exports features', async () => {
|
|||||||
{
|
{
|
||||||
enabled: false,
|
enabled: false,
|
||||||
environment: 'default',
|
environment: 'default',
|
||||||
featureName: 'first_feature',
|
featureName: defaultFeatureName,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
segments: [
|
segments: [
|
||||||
@ -314,7 +316,7 @@ test('exports features by tag', async () => {
|
|||||||
};
|
};
|
||||||
await createToggle(
|
await createToggle(
|
||||||
{
|
{
|
||||||
name: 'first_feature',
|
name: defaultFeatureName,
|
||||||
description: 'the #1 feature',
|
description: 'the #1 feature',
|
||||||
},
|
},
|
||||||
strategy,
|
strategy,
|
||||||
@ -341,7 +343,7 @@ test('exports features by tag', async () => {
|
|||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
features: [
|
features: [
|
||||||
{
|
{
|
||||||
name: 'first_feature',
|
name: defaultFeatureName,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
featureStrategies: [resultStrategy],
|
featureStrategies: [resultStrategy],
|
||||||
@ -349,7 +351,7 @@ test('exports features by tag', async () => {
|
|||||||
{
|
{
|
||||||
enabled: false,
|
enabled: false,
|
||||||
environment: 'default',
|
environment: 'default',
|
||||||
featureName: 'first_feature',
|
featureName: defaultFeatureName,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@ -387,7 +389,7 @@ test('should export custom context fields from strategies and variants', async (
|
|||||||
};
|
};
|
||||||
await createToggle(
|
await createToggle(
|
||||||
{
|
{
|
||||||
name: 'first_feature',
|
name: defaultFeatureName,
|
||||||
description: 'the #1 feature',
|
description: 'the #1 feature',
|
||||||
},
|
},
|
||||||
strategy,
|
strategy,
|
||||||
@ -408,7 +410,7 @@ test('should export custom context fields from strategies and variants', async (
|
|||||||
};
|
};
|
||||||
await createContext(variantStickinessContext);
|
await createContext(variantStickinessContext);
|
||||||
await createContext(variantOverridesContext);
|
await createContext(variantOverridesContext);
|
||||||
await createVariants('first_feature', [
|
await createVariants(defaultFeatureName, [
|
||||||
{
|
{
|
||||||
name: 'irrelevant',
|
name: 'irrelevant',
|
||||||
weight: 1000,
|
weight: 1000,
|
||||||
@ -426,7 +428,7 @@ test('should export custom context fields from strategies and variants', async (
|
|||||||
const { body } = await app.request
|
const { body } = await app.request
|
||||||
.post('/api/admin/features-batch/export')
|
.post('/api/admin/features-batch/export')
|
||||||
.send({
|
.send({
|
||||||
features: ['first_feature'],
|
features: [defaultFeatureName],
|
||||||
environment: 'default',
|
environment: 'default',
|
||||||
})
|
})
|
||||||
.set('Content-Type', 'application/json')
|
.set('Content-Type', 'application/json')
|
||||||
@ -436,7 +438,7 @@ test('should export custom context fields from strategies and variants', async (
|
|||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
features: [
|
features: [
|
||||||
{
|
{
|
||||||
name: 'first_feature',
|
name: defaultFeatureName,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
featureStrategies: [resultStrategy],
|
featureStrategies: [resultStrategy],
|
||||||
@ -444,7 +446,7 @@ test('should export custom context fields from strategies and variants', async (
|
|||||||
{
|
{
|
||||||
enabled: false,
|
enabled: false,
|
||||||
environment: 'default',
|
environment: 'default',
|
||||||
featureName: 'first_feature',
|
featureName: defaultFeatureName,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
contextFields: [
|
contextFields: [
|
||||||
@ -457,7 +459,7 @@ test('should export custom context fields from strategies and variants', async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should export tags', async () => {
|
test('should export tags', async () => {
|
||||||
const featureName = 'first_feature';
|
const featureName = defaultFeatureName;
|
||||||
await createProjects();
|
await createProjects();
|
||||||
await createToggle(
|
await createToggle(
|
||||||
{
|
{
|
||||||
@ -471,7 +473,7 @@ test('should export tags', async () => {
|
|||||||
const { body } = await app.request
|
const { body } = await app.request
|
||||||
.post('/api/admin/features-batch/export')
|
.post('/api/admin/features-batch/export')
|
||||||
.send({
|
.send({
|
||||||
features: ['first_feature'],
|
features: [defaultFeatureName],
|
||||||
environment: 'default',
|
environment: 'default',
|
||||||
})
|
})
|
||||||
.set('Content-Type', 'application/json')
|
.set('Content-Type', 'application/json')
|
||||||
@ -481,7 +483,7 @@ test('should export tags', async () => {
|
|||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
features: [
|
features: [
|
||||||
{
|
{
|
||||||
name: 'first_feature',
|
name: defaultFeatureName,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
featureStrategies: [resultStrategy],
|
featureStrategies: [resultStrategy],
|
||||||
@ -489,7 +491,7 @@ test('should export tags', async () => {
|
|||||||
{
|
{
|
||||||
enabled: false,
|
enabled: false,
|
||||||
environment: 'default',
|
environment: 'default',
|
||||||
featureName: 'first_feature',
|
featureName: defaultFeatureName,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
featureTags: [{ featureName, tagValue: 'tag1' }],
|
featureTags: [{ featureName, tagValue: 'tag1' }],
|
||||||
@ -499,7 +501,7 @@ test('should export tags', async () => {
|
|||||||
test('returns no features, when no feature was requested', async () => {
|
test('returns no features, when no feature was requested', async () => {
|
||||||
await createProjects();
|
await createProjects();
|
||||||
await createToggle({
|
await createToggle({
|
||||||
name: 'first_feature',
|
name: defaultFeatureName,
|
||||||
description: 'the #1 feature',
|
description: 'the #1 feature',
|
||||||
});
|
});
|
||||||
await createToggle({
|
await createToggle({
|
||||||
@ -518,8 +520,6 @@ test('returns no features, when no feature was requested', async () => {
|
|||||||
expect(body.features).toHaveLength(0);
|
expect(body.features).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultFeature = 'first_feature';
|
|
||||||
|
|
||||||
const variants: VariantsSchema = [
|
const variants: VariantsSchema = [
|
||||||
{
|
{
|
||||||
name: 'variantA',
|
name: 'variantA',
|
||||||
@ -546,7 +546,7 @@ const variants: VariantsSchema = [
|
|||||||
];
|
];
|
||||||
const exportedFeature: ImportTogglesSchema['data']['features'][0] = {
|
const exportedFeature: ImportTogglesSchema['data']['features'][0] = {
|
||||||
project: 'old_project',
|
project: 'old_project',
|
||||||
name: 'first_feature',
|
name: defaultFeatureName,
|
||||||
type: 'release',
|
type: 'release',
|
||||||
};
|
};
|
||||||
const anotherExportedFeature: ImportTogglesSchema['data']['features'][0] = {
|
const anotherExportedFeature: ImportTogglesSchema['data']['features'][0] = {
|
||||||
@ -564,7 +564,7 @@ const constraints: ImportTogglesSchema['data']['featureStrategies'][0]['constrai
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
const exportedStrategy: ImportTogglesSchema['data']['featureStrategies'][0] = {
|
const exportedStrategy: ImportTogglesSchema['data']['featureStrategies'][0] = {
|
||||||
featureName: defaultFeature,
|
featureName: defaultFeatureName,
|
||||||
id: '798cb25a-2abd-47bd-8a95-40ec13472309',
|
id: '798cb25a-2abd-47bd-8a95-40ec13472309',
|
||||||
name: 'default',
|
name: 'default',
|
||||||
parameters: {},
|
parameters: {},
|
||||||
@ -573,17 +573,17 @@ const exportedStrategy: ImportTogglesSchema['data']['featureStrategies'][0] = {
|
|||||||
|
|
||||||
const tags = [
|
const tags = [
|
||||||
{
|
{
|
||||||
featureName: defaultFeature,
|
featureName: defaultFeatureName,
|
||||||
tagType: 'simple',
|
tagType: 'simple',
|
||||||
tagValue: 'tag1',
|
tagValue: 'tag1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
featureName: defaultFeature,
|
featureName: defaultFeatureName,
|
||||||
tagType: 'simple',
|
tagType: 'simple',
|
||||||
tagValue: 'tag2',
|
tagValue: 'tag2',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
featureName: defaultFeature,
|
featureName: defaultFeatureName,
|
||||||
tagType: 'special_tag',
|
tagType: 'special_tag',
|
||||||
tagValue: 'feature_tagged',
|
tagValue: 'feature_tagged',
|
||||||
},
|
},
|
||||||
@ -609,8 +609,8 @@ const defaultImportPayload: ImportTogglesSchema = {
|
|||||||
{
|
{
|
||||||
enabled: true,
|
enabled: true,
|
||||||
environment: 'irrelevant',
|
environment: 'irrelevant',
|
||||||
featureName: defaultFeature,
|
featureName: defaultFeatureName,
|
||||||
name: defaultFeature,
|
name: defaultFeatureName,
|
||||||
variants,
|
variants,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -675,23 +675,23 @@ test('import features to existing project and environment', async () => {
|
|||||||
|
|
||||||
await app.importToggles(defaultImportPayload);
|
await app.importToggles(defaultImportPayload);
|
||||||
|
|
||||||
const { body: importedFeature } = await getFeature(defaultFeature);
|
const { body: importedFeature } = await getFeature(defaultFeatureName);
|
||||||
expect(importedFeature).toMatchObject({
|
expect(importedFeature).toMatchObject({
|
||||||
name: 'first_feature',
|
name: defaultFeatureName,
|
||||||
project: DEFAULT_PROJECT,
|
project: DEFAULT_PROJECT,
|
||||||
variants,
|
variants,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { body: importedFeatureEnvironment } = await getFeatureEnvironment(
|
const { body: importedFeatureEnvironment } = await getFeatureEnvironment(
|
||||||
defaultFeature,
|
defaultFeatureName,
|
||||||
);
|
);
|
||||||
expect(importedFeatureEnvironment).toMatchObject({
|
expect(importedFeatureEnvironment).toMatchObject({
|
||||||
name: defaultFeature,
|
name: defaultFeatureName,
|
||||||
environment: DEFAULT_ENV,
|
environment: DEFAULT_ENV,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
strategies: [
|
strategies: [
|
||||||
{
|
{
|
||||||
featureName: defaultFeature,
|
featureName: defaultFeatureName,
|
||||||
parameters: {},
|
parameters: {},
|
||||||
constraints,
|
constraints,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
@ -700,7 +700,7 @@ test('import features to existing project and environment', async () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { body: importedTags } = await getTags(defaultFeature);
|
const { body: importedTags } = await getTags(defaultFeatureName);
|
||||||
expect(importedTags).toMatchObject({
|
expect(importedTags).toMatchObject({
|
||||||
tags: resultTags,
|
tags: resultTags,
|
||||||
});
|
});
|
||||||
@ -735,25 +735,25 @@ test('can update toggles on subsequent import', async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { body: importedFeature } = await getFeature(defaultFeature);
|
const { body: importedFeature } = await getFeature(defaultFeatureName);
|
||||||
expect(importedFeature).toMatchObject({
|
expect(importedFeature).toMatchObject({
|
||||||
name: 'first_feature',
|
name: defaultFeatureName,
|
||||||
project: DEFAULT_PROJECT,
|
project: DEFAULT_PROJECT,
|
||||||
type: 'operational',
|
type: 'operational',
|
||||||
variants,
|
variants,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { body: importedFeatureEnvironment } = await getFeatureEnvironment(
|
const { body: importedFeatureEnvironment } = await getFeatureEnvironment(
|
||||||
defaultFeature,
|
defaultFeatureName,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(importedFeatureEnvironment).toMatchObject({
|
expect(importedFeatureEnvironment).toMatchObject({
|
||||||
name: defaultFeature,
|
name: defaultFeatureName,
|
||||||
environment: DEFAULT_ENV,
|
environment: DEFAULT_ENV,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
strategies: [
|
strategies: [
|
||||||
{
|
{
|
||||||
featureName: defaultFeature,
|
featureName: defaultFeatureName,
|
||||||
parameters: {},
|
parameters: {},
|
||||||
constraints,
|
constraints,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
@ -843,14 +843,15 @@ test('validate import data', async () => {
|
|||||||
legalValues: [{ value: 'new_value' }],
|
legalValues: [{ value: 'new_value' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
await app.createFeature(defaultFeature);
|
await app.createFeature(defaultFeatureName);
|
||||||
await app.archiveFeature(defaultFeature);
|
await app.archiveFeature(defaultFeatureName);
|
||||||
|
|
||||||
await app.createContextField(contextField);
|
await app.createContextField(contextField);
|
||||||
const importPayloadWithContextFields: ImportTogglesSchema = {
|
const importPayloadWithContextFields: ImportTogglesSchema = {
|
||||||
...defaultImportPayload,
|
...defaultImportPayload,
|
||||||
data: {
|
data: {
|
||||||
...defaultImportPayload.data,
|
...defaultImportPayload.data,
|
||||||
|
features: [exportedFeature, exportedFeature],
|
||||||
featureStrategies: [{ name: 'customStrategy' }],
|
featureStrategies: [{ name: 'customStrategy' }],
|
||||||
segments: [{ id: 1, name: 'customSegment' }],
|
segments: [{ id: 1, name: 'customSegment' }],
|
||||||
contextFields: [
|
contextFields: [
|
||||||
@ -877,12 +878,17 @@ test('validate import data', async () => {
|
|||||||
'We detected the following context fields that do not have matching legal values with the imported ones:',
|
'We detected the following context fields that do not have matching legal values with the imported ones:',
|
||||||
affectedItems: [contextField.name],
|
affectedItems: [contextField.name],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
'We detected the following features are duplicate in your import data:',
|
||||||
|
affectedItems: [defaultFeatureName],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
warnings: [
|
warnings: [
|
||||||
{
|
{
|
||||||
message:
|
message:
|
||||||
'The following features will not be imported as they are currently archived. To import them, please unarchive them first:',
|
'The following features will not be imported as they are currently archived. To import them, please unarchive them first:',
|
||||||
affectedItems: [defaultFeature],
|
affectedItems: [defaultFeatureName],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
permissions: [],
|
permissions: [],
|
||||||
@ -913,7 +919,7 @@ test('should not import archived features tags', async () => {
|
|||||||
await createProjects();
|
await createProjects();
|
||||||
await app.importToggles(defaultImportPayload);
|
await app.importToggles(defaultImportPayload);
|
||||||
|
|
||||||
await app.archiveFeature(defaultFeature);
|
await app.archiveFeature(defaultFeatureName);
|
||||||
|
|
||||||
await app.importToggles({
|
await app.importToggles({
|
||||||
...defaultImportPayload,
|
...defaultImportPayload,
|
||||||
@ -921,16 +927,16 @@ test('should not import archived features tags', async () => {
|
|||||||
...defaultImportPayload.data,
|
...defaultImportPayload.data,
|
||||||
featureTags: [
|
featureTags: [
|
||||||
{
|
{
|
||||||
featureName: defaultFeature,
|
featureName: defaultFeatureName,
|
||||||
tagType: 'simple',
|
tagType: 'simple',
|
||||||
tagValue: 'tag2',
|
tagValue: 'tag2',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await unArchiveFeature(defaultFeature);
|
await unArchiveFeature(defaultFeatureName);
|
||||||
|
|
||||||
const { body: importedTags } = await getTags(defaultFeature);
|
const { body: importedTags } = await getTags(defaultFeatureName);
|
||||||
expect(importedTags).toMatchObject({
|
expect(importedTags).toMatchObject({
|
||||||
tags: resultTags,
|
tags: resultTags,
|
||||||
});
|
});
|
||||||
|
@ -14,7 +14,7 @@ import {
|
|||||||
} from '../../types';
|
} from '../../types';
|
||||||
import { PermissionError } from '../../error';
|
import { PermissionError } from '../../error';
|
||||||
|
|
||||||
type Mode = 'regular' | 'change_request';
|
export type Mode = 'regular' | 'change_request';
|
||||||
|
|
||||||
export class ImportPermissionsService {
|
export class ImportPermissionsService {
|
||||||
private importTogglesStore: IImportTogglesStore;
|
private importTogglesStore: IImportTogglesStore;
|
||||||
|
@ -4,6 +4,20 @@ import {
|
|||||||
} from '../../openapi';
|
} from '../../openapi';
|
||||||
import { IContextFieldDto } from '../../types/stores/context-field-store';
|
import { IContextFieldDto } from '../../types/stores/context-field-store';
|
||||||
|
|
||||||
|
export interface IErrorsParams {
|
||||||
|
projectName: string;
|
||||||
|
strategies: FeatureStrategySchema[];
|
||||||
|
contextFields: IContextFieldDto[];
|
||||||
|
otherProjectFeatures: string[];
|
||||||
|
duplicateFeatures: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWarningParams {
|
||||||
|
usedCustomStrategies: string[];
|
||||||
|
archivedFeatures: string[];
|
||||||
|
existingFeatures: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export class ImportValidationMessages {
|
export class ImportValidationMessages {
|
||||||
static compilePermissionErrors(
|
static compilePermissionErrors(
|
||||||
missingPermissions: string[],
|
missingPermissions: string[],
|
||||||
@ -20,14 +34,13 @@ export class ImportValidationMessages {
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
static compileErrors(
|
static compileErrors({
|
||||||
projectName: string,
|
projectName,
|
||||||
strategies: FeatureStrategySchema[],
|
strategies,
|
||||||
contextFields: IContextFieldDto[],
|
contextFields,
|
||||||
segments: string[],
|
otherProjectFeatures,
|
||||||
otherProjectFeatures: string[],
|
duplicateFeatures,
|
||||||
changeRequestExists: boolean,
|
}: IErrorsParams): ImportTogglesValidateItemSchema[] {
|
||||||
): ImportTogglesValidateItemSchema[] {
|
|
||||||
const errors: ImportTogglesValidateItemSchema[] = [];
|
const errors: ImportTogglesValidateItemSchema[] = [];
|
||||||
|
|
||||||
if (strategies.length > 0) {
|
if (strategies.length > 0) {
|
||||||
@ -46,35 +59,28 @@ export class ImportValidationMessages {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (segments.length > 0) {
|
|
||||||
errors.push({
|
|
||||||
message:
|
|
||||||
'We detected the following segments in the import file that need to be created first:',
|
|
||||||
affectedItems: segments,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (changeRequestExists) {
|
|
||||||
errors.push({
|
|
||||||
message:
|
|
||||||
'Before importing any data, please resolve your pending change request in this project and environment as it is preventing you from importing at this time',
|
|
||||||
affectedItems: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (otherProjectFeatures.length > 0) {
|
if (otherProjectFeatures.length > 0) {
|
||||||
errors.push({
|
errors.push({
|
||||||
message: `You cannot import a features that already exist in other projects. You already have the following features defined outside of project ${projectName}:`,
|
message: `You cannot import a features that already exist in other projects. You already have the following features defined outside of project ${projectName}:`,
|
||||||
affectedItems: otherProjectFeatures,
|
affectedItems: otherProjectFeatures,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (duplicateFeatures.length > 0) {
|
||||||
|
errors.push({
|
||||||
|
message:
|
||||||
|
'We detected the following features are duplicate in your import data:',
|
||||||
|
affectedItems: duplicateFeatures,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
static compileWarnings(
|
static compileWarnings({
|
||||||
usedCustomStrategies: string[],
|
usedCustomStrategies,
|
||||||
archivedFeatures: string[],
|
existingFeatures,
|
||||||
existingFeatures: string[],
|
archivedFeatures,
|
||||||
): ImportTogglesValidateItemSchema[] {
|
}: IWarningParams): ImportTogglesValidateItemSchema[] {
|
||||||
const warnings: ImportTogglesValidateItemSchema[] = [];
|
const warnings: ImportTogglesValidateItemSchema[] = [];
|
||||||
if (usedCustomStrategies.length > 0) {
|
if (usedCustomStrategies.length > 0) {
|
||||||
warnings.push({
|
warnings.push({
|
||||||
|
30
src/lib/util/findDuplicates.test.ts
Normal file
30
src/lib/util/findDuplicates.test.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { findDuplicates } from './findDuplicates';
|
||||||
|
|
||||||
|
test('should find single duplicates', () => {
|
||||||
|
expect(findDuplicates([1, 2, 3, 4, 1])).toEqual([1]);
|
||||||
|
expect(findDuplicates(['a', 'b', 'a', 'a'])).toEqual(['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return an empty array for unique elements', () => {
|
||||||
|
expect(findDuplicates(['a', 'b', 'c', 'd'])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle arrays with all identical elements', () => {
|
||||||
|
expect(findDuplicates([1, 1, 1, 1])).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle multiple duplicates', () => {
|
||||||
|
expect(findDuplicates([1, 2, 2, 1])).toEqual(
|
||||||
|
expect.arrayContaining([1, 2]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle an empty array', () => {
|
||||||
|
expect(findDuplicates([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle arrays with boolean values', () => {
|
||||||
|
expect(findDuplicates([true, true, false, false, true])).toEqual(
|
||||||
|
expect.arrayContaining([true, false]),
|
||||||
|
);
|
||||||
|
});
|
14
src/lib/util/findDuplicates.ts
Normal file
14
src/lib/util/findDuplicates.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export const findDuplicates = <T>(arr: T[]): T[] => {
|
||||||
|
const seen: Set<T> = new Set();
|
||||||
|
const duplicates: Set<T> = new Set();
|
||||||
|
|
||||||
|
for (let item of arr) {
|
||||||
|
if (seen.has(item)) {
|
||||||
|
duplicates.add(item);
|
||||||
|
} else {
|
||||||
|
seen.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...duplicates];
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user