1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

feat: import service validate duplicates (#4558)

This commit is contained in:
Mateusz Kwasniewski 2023-08-24 10:05:21 +02:00 committed by GitHub
parent 0fb078d4c5
commit 2c3514954c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 183 additions and 106 deletions

View File

@ -42,8 +42,9 @@ import {
} from '../../services';
import { isValidField } from './import-context-validation';
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 { findDuplicates } from '../../util/findDuplicates';
export default class ExportImportService {
private logger: Logger;
@ -144,6 +145,7 @@ export default class ExportImportService {
async validate(
dto: ImportTogglesSchema,
user: User,
mode = 'regular' as Mode,
): Promise<ImportTogglesValidateSchema> {
const [
unsupportedStrategies,
@ -153,6 +155,7 @@ export default class ExportImportService {
otherProjectFeatures,
existingProjectFeatures,
missingPermissions,
duplicateFeatures,
] = await Promise.all([
this.getUnsupportedStrategies(dto),
this.getUsedCustomStrategies(dto),
@ -163,23 +166,23 @@ export default class ExportImportService {
this.importPermissionsService.getMissingPermissions(
dto,
user,
'regular',
mode,
),
this.getDuplicateFeatures(dto),
]);
const errors = ImportValidationMessages.compileErrors(
dto.project,
unsupportedStrategies,
unsupportedContextFields || [],
[],
const errors = ImportValidationMessages.compileErrors({
projectName: dto.project,
strategies: unsupportedStrategies,
contextFields: unsupportedContextFields || [],
otherProjectFeatures,
false,
);
const warnings = ImportValidationMessages.compileWarnings(
usedCustomStrategies,
duplicateFeatures,
});
const warnings = ImportValidationMessages.compileWarnings({
archivedFeatures,
existingProjectFeatures,
);
existingFeatures: existingProjectFeatures,
usedCustomStrategies,
});
const permissions =
ImportValidationMessages.compilePermissionErrors(
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> {
const cleanedDto = await this.cleanData(dto);
await Promise.all([
this.verifyStrategies(cleanedDto),
this.verifyContextFields(cleanedDto),
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.importVerify(cleanedDto, user);
await this.importToggleLevelInfo(cleanedDto, user);
await this.importDefault(cleanedDto, user);
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.importStrategies(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 featureTags =
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) {
const existingTagTypes = (await this.tagTypeService.getAll()).map(
(tagType) => tagType.name,

View File

@ -51,6 +51,8 @@ const defaultContext: ContextFieldSchema = {
],
};
const defaultFeatureName = 'first_feature';
const createToggle = async (
toggle: FeatureToggleDTO,
strategy: Omit<IStrategyConfig, 'id'> = defaultStrategy,
@ -195,7 +197,7 @@ describe('import-export for project-specific segments', () => {
};
await createToggle(
{
name: 'first_feature',
name: defaultFeatureName,
description: 'the #1 feature',
},
strategy,
@ -205,7 +207,7 @@ describe('import-export for project-specific segments', () => {
const { body } = await app.request
.post('/api/admin/features-batch/export')
.send({
features: ['first_feature'],
features: [defaultFeatureName],
environment: 'default',
})
.set('Content-Type', 'application/json')
@ -215,7 +217,7 @@ describe('import-export for project-specific segments', () => {
expect(body).toMatchObject({
features: [
{
name: 'first_feature',
name: defaultFeatureName,
},
],
featureStrategies: [resultStrategy],
@ -223,7 +225,7 @@ describe('import-export for project-specific segments', () => {
{
enabled: false,
environment: 'default',
featureName: 'first_feature',
featureName: defaultFeatureName,
},
],
segments: [
@ -254,7 +256,7 @@ test('exports features', async () => {
};
await createToggle(
{
name: 'first_feature',
name: defaultFeatureName,
description: 'the #1 feature',
},
strategy,
@ -269,7 +271,7 @@ test('exports features', async () => {
const { body } = await app.request
.post('/api/admin/features-batch/export')
.send({
features: ['first_feature'],
features: [defaultFeatureName],
environment: 'default',
})
.set('Content-Type', 'application/json')
@ -279,7 +281,7 @@ test('exports features', async () => {
expect(body).toMatchObject({
features: [
{
name: 'first_feature',
name: defaultFeatureName,
},
],
featureStrategies: [resultStrategy],
@ -287,7 +289,7 @@ test('exports features', async () => {
{
enabled: false,
environment: 'default',
featureName: 'first_feature',
featureName: defaultFeatureName,
},
],
segments: [
@ -314,7 +316,7 @@ test('exports features by tag', async () => {
};
await createToggle(
{
name: 'first_feature',
name: defaultFeatureName,
description: 'the #1 feature',
},
strategy,
@ -341,7 +343,7 @@ test('exports features by tag', async () => {
expect(body).toMatchObject({
features: [
{
name: 'first_feature',
name: defaultFeatureName,
},
],
featureStrategies: [resultStrategy],
@ -349,7 +351,7 @@ test('exports features by tag', async () => {
{
enabled: false,
environment: 'default',
featureName: 'first_feature',
featureName: defaultFeatureName,
},
],
});
@ -387,7 +389,7 @@ test('should export custom context fields from strategies and variants', async (
};
await createToggle(
{
name: 'first_feature',
name: defaultFeatureName,
description: 'the #1 feature',
},
strategy,
@ -408,7 +410,7 @@ test('should export custom context fields from strategies and variants', async (
};
await createContext(variantStickinessContext);
await createContext(variantOverridesContext);
await createVariants('first_feature', [
await createVariants(defaultFeatureName, [
{
name: 'irrelevant',
weight: 1000,
@ -426,7 +428,7 @@ test('should export custom context fields from strategies and variants', async (
const { body } = await app.request
.post('/api/admin/features-batch/export')
.send({
features: ['first_feature'],
features: [defaultFeatureName],
environment: 'default',
})
.set('Content-Type', 'application/json')
@ -436,7 +438,7 @@ test('should export custom context fields from strategies and variants', async (
expect(body).toMatchObject({
features: [
{
name: 'first_feature',
name: defaultFeatureName,
},
],
featureStrategies: [resultStrategy],
@ -444,7 +446,7 @@ test('should export custom context fields from strategies and variants', async (
{
enabled: false,
environment: 'default',
featureName: 'first_feature',
featureName: defaultFeatureName,
},
],
contextFields: [
@ -457,7 +459,7 @@ test('should export custom context fields from strategies and variants', async (
});
test('should export tags', async () => {
const featureName = 'first_feature';
const featureName = defaultFeatureName;
await createProjects();
await createToggle(
{
@ -471,7 +473,7 @@ test('should export tags', async () => {
const { body } = await app.request
.post('/api/admin/features-batch/export')
.send({
features: ['first_feature'],
features: [defaultFeatureName],
environment: 'default',
})
.set('Content-Type', 'application/json')
@ -481,7 +483,7 @@ test('should export tags', async () => {
expect(body).toMatchObject({
features: [
{
name: 'first_feature',
name: defaultFeatureName,
},
],
featureStrategies: [resultStrategy],
@ -489,7 +491,7 @@ test('should export tags', async () => {
{
enabled: false,
environment: 'default',
featureName: 'first_feature',
featureName: defaultFeatureName,
},
],
featureTags: [{ featureName, tagValue: 'tag1' }],
@ -499,7 +501,7 @@ test('should export tags', async () => {
test('returns no features, when no feature was requested', async () => {
await createProjects();
await createToggle({
name: 'first_feature',
name: defaultFeatureName,
description: 'the #1 feature',
});
await createToggle({
@ -518,8 +520,6 @@ test('returns no features, when no feature was requested', async () => {
expect(body.features).toHaveLength(0);
});
const defaultFeature = 'first_feature';
const variants: VariantsSchema = [
{
name: 'variantA',
@ -546,7 +546,7 @@ const variants: VariantsSchema = [
];
const exportedFeature: ImportTogglesSchema['data']['features'][0] = {
project: 'old_project',
name: 'first_feature',
name: defaultFeatureName,
type: 'release',
};
const anotherExportedFeature: ImportTogglesSchema['data']['features'][0] = {
@ -564,7 +564,7 @@ const constraints: ImportTogglesSchema['data']['featureStrategies'][0]['constrai
},
];
const exportedStrategy: ImportTogglesSchema['data']['featureStrategies'][0] = {
featureName: defaultFeature,
featureName: defaultFeatureName,
id: '798cb25a-2abd-47bd-8a95-40ec13472309',
name: 'default',
parameters: {},
@ -573,17 +573,17 @@ const exportedStrategy: ImportTogglesSchema['data']['featureStrategies'][0] = {
const tags = [
{
featureName: defaultFeature,
featureName: defaultFeatureName,
tagType: 'simple',
tagValue: 'tag1',
},
{
featureName: defaultFeature,
featureName: defaultFeatureName,
tagType: 'simple',
tagValue: 'tag2',
},
{
featureName: defaultFeature,
featureName: defaultFeatureName,
tagType: 'special_tag',
tagValue: 'feature_tagged',
},
@ -609,8 +609,8 @@ const defaultImportPayload: ImportTogglesSchema = {
{
enabled: true,
environment: 'irrelevant',
featureName: defaultFeature,
name: defaultFeature,
featureName: defaultFeatureName,
name: defaultFeatureName,
variants,
},
],
@ -675,23 +675,23 @@ test('import features to existing project and environment', async () => {
await app.importToggles(defaultImportPayload);
const { body: importedFeature } = await getFeature(defaultFeature);
const { body: importedFeature } = await getFeature(defaultFeatureName);
expect(importedFeature).toMatchObject({
name: 'first_feature',
name: defaultFeatureName,
project: DEFAULT_PROJECT,
variants,
});
const { body: importedFeatureEnvironment } = await getFeatureEnvironment(
defaultFeature,
defaultFeatureName,
);
expect(importedFeatureEnvironment).toMatchObject({
name: defaultFeature,
name: defaultFeatureName,
environment: DEFAULT_ENV,
enabled: true,
strategies: [
{
featureName: defaultFeature,
featureName: defaultFeatureName,
parameters: {},
constraints,
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({
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({
name: 'first_feature',
name: defaultFeatureName,
project: DEFAULT_PROJECT,
type: 'operational',
variants,
});
const { body: importedFeatureEnvironment } = await getFeatureEnvironment(
defaultFeature,
defaultFeatureName,
);
expect(importedFeatureEnvironment).toMatchObject({
name: defaultFeature,
name: defaultFeatureName,
environment: DEFAULT_ENV,
enabled: true,
strategies: [
{
featureName: defaultFeature,
featureName: defaultFeatureName,
parameters: {},
constraints,
sortOrder: 0,
@ -843,14 +843,15 @@ test('validate import data', async () => {
legalValues: [{ value: 'new_value' }],
};
await app.createFeature(defaultFeature);
await app.archiveFeature(defaultFeature);
await app.createFeature(defaultFeatureName);
await app.archiveFeature(defaultFeatureName);
await app.createContextField(contextField);
const importPayloadWithContextFields: ImportTogglesSchema = {
...defaultImportPayload,
data: {
...defaultImportPayload.data,
features: [exportedFeature, exportedFeature],
featureStrategies: [{ name: 'customStrategy' }],
segments: [{ id: 1, name: 'customSegment' }],
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:',
affectedItems: [contextField.name],
},
{
message:
'We detected the following features are duplicate in your import data:',
affectedItems: [defaultFeatureName],
},
],
warnings: [
{
message:
'The following features will not be imported as they are currently archived. To import them, please unarchive them first:',
affectedItems: [defaultFeature],
affectedItems: [defaultFeatureName],
},
],
permissions: [],
@ -913,7 +919,7 @@ test('should not import archived features tags', async () => {
await createProjects();
await app.importToggles(defaultImportPayload);
await app.archiveFeature(defaultFeature);
await app.archiveFeature(defaultFeatureName);
await app.importToggles({
...defaultImportPayload,
@ -921,16 +927,16 @@ test('should not import archived features tags', async () => {
...defaultImportPayload.data,
featureTags: [
{
featureName: defaultFeature,
featureName: defaultFeatureName,
tagType: 'simple',
tagValue: 'tag2',
},
],
},
});
await unArchiveFeature(defaultFeature);
await unArchiveFeature(defaultFeatureName);
const { body: importedTags } = await getTags(defaultFeature);
const { body: importedTags } = await getTags(defaultFeatureName);
expect(importedTags).toMatchObject({
tags: resultTags,
});

View File

@ -14,7 +14,7 @@ import {
} from '../../types';
import { PermissionError } from '../../error';
type Mode = 'regular' | 'change_request';
export type Mode = 'regular' | 'change_request';
export class ImportPermissionsService {
private importTogglesStore: IImportTogglesStore;

View File

@ -4,6 +4,20 @@ import {
} from '../../openapi';
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 {
static compilePermissionErrors(
missingPermissions: string[],
@ -20,14 +34,13 @@ export class ImportValidationMessages {
return errors;
}
static compileErrors(
projectName: string,
strategies: FeatureStrategySchema[],
contextFields: IContextFieldDto[],
segments: string[],
otherProjectFeatures: string[],
changeRequestExists: boolean,
): ImportTogglesValidateItemSchema[] {
static compileErrors({
projectName,
strategies,
contextFields,
otherProjectFeatures,
duplicateFeatures,
}: IErrorsParams): ImportTogglesValidateItemSchema[] {
const errors: ImportTogglesValidateItemSchema[] = [];
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) {
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}:`,
affectedItems: otherProjectFeatures,
});
}
if (duplicateFeatures.length > 0) {
errors.push({
message:
'We detected the following features are duplicate in your import data:',
affectedItems: duplicateFeatures,
});
}
return errors;
}
static compileWarnings(
usedCustomStrategies: string[],
archivedFeatures: string[],
existingFeatures: string[],
): ImportTogglesValidateItemSchema[] {
static compileWarnings({
usedCustomStrategies,
existingFeatures,
archivedFeatures,
}: IWarningParams): ImportTogglesValidateItemSchema[] {
const warnings: ImportTogglesValidateItemSchema[] = [];
if (usedCustomStrategies.length > 0) {
warnings.push({

View 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]),
);
});

View 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];
};