1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-10 01:16:39 +02:00

fix: import duplicate features (#4550)

This commit is contained in:
Mateusz Kwasniewski 2023-08-23 11:11:16 +02:00 committed by GitHub
parent 1fbd8b6ef8
commit 65e62c64b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 77 additions and 21 deletions

View File

@ -108,6 +108,10 @@ const useAPI = ({
const response = await res.json(); const response = await res.json();
if (response?.details?.length > 0 && propagateErrors) { if (response?.details?.length > 0 && propagateErrors) {
const error = response.details[0]; const error = response.details[0];
setErrors(prev => ({
...prev,
unknown: error,
}));
if (propagateErrors) { if (propagateErrors) {
throw new Error(error.message || error.msg); throw new Error(error.message || error.msg);
} }

View File

@ -29,6 +29,11 @@ export const useImportApi = () => {
}); });
return res; return res;
} catch (e) { } catch (e) {
trackEvent('export_import', {
props: {
eventType: `features import failed`,
},
});
throw e; throw e;
} }
}; };

View File

@ -8,6 +8,7 @@ import { FeatureToggle, FeatureToggleDTO, IVariant } from '../types/model';
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store'; import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
import { Db } from './db'; import { Db } from './db';
import { LastSeenInput } from '../services/client-metrics/last-seen-service'; import { LastSeenInput } from '../services/client-metrics/last-seen-service';
import { NameExistsError } from '../error';
export type EnvironmentFeatureNames = { [key: string]: string[] }; export type EnvironmentFeatureNames = { [key: string]: string[] };
@ -289,8 +290,16 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
return this.rowToFeature(row[0]); return this.rowToFeature(row[0]);
} catch (err) { } catch (err) {
this.logger.error('Could not insert feature, error: ', err); this.logger.error('Could not insert feature, error: ', err);
if (
typeof err.detail === 'string' &&
err.detail.includes('already exists')
) {
throw new NameExistsError(
`Feature ${data.name} already exists`,
);
}
throw err;
} }
return undefined;
} }
async update( async update(

View File

@ -205,7 +205,7 @@ export default class ExportImportService {
), ),
this.verifyFeatures(dto), this.verifyFeatures(dto),
]); ]);
await this.createToggles(cleanedDto, user); await this.createOrUpdateToggles(cleanedDto, user);
await this.importToggleVariants(dto, user); await this.importToggleVariants(dto, user);
await this.importTagTypes(cleanedDto, user); await this.importTagTypes(cleanedDto, user);
await this.importTags(cleanedDto, user); await this.importTags(cleanedDto, user);
@ -348,10 +348,21 @@ export default class ExportImportService {
); );
} }
private async createToggles(dto: ImportTogglesSchema, user: User) { private async createOrUpdateToggles(dto: ImportTogglesSchema, user: User) {
const existingFeatures = await this.getExistingProjectFeatures(dto);
const username = extractUsernameFromUser(user);
await Promise.all( await Promise.all(
dto.data.features.map((feature) => dto.data.features.map((feature) => {
this.featureToggleService if (existingFeatures.includes(feature.name)) {
const { archivedAt, createdAt, ...rest } = feature;
return this.featureToggleService.updateFeatureToggle(
dto.project,
rest as FeatureToggleDTO,
username,
feature.name,
);
}
return this.featureToggleService
.validateName(feature.name) .validateName(feature.name)
.then(() => { .then(() => {
const { archivedAt, createdAt, ...rest } = feature; const { archivedAt, createdAt, ...rest } = feature;
@ -360,9 +371,8 @@ export default class ExportImportService {
rest as FeatureToggleDTO, rest as FeatureToggleDTO,
extractUsernameFromUser(user), extractUsernameFromUser(user),
); );
}) });
.catch(() => {}), }),
),
); );
} }
@ -533,12 +543,10 @@ export default class ExportImportService {
} }
private async getExistingProjectFeatures(dto: ImportTogglesSchema) { private async getExistingProjectFeatures(dto: ImportTogglesSchema) {
const existingProjectsFeatures = return this.importTogglesStore.getFeaturesInProject(
await this.importTogglesStore.getFeaturesInProject(
dto.data.features.map((feature) => feature.name), dto.data.features.map((feature) => feature.name),
dto.project, dto.project,
); );
return existingProjectsFeatures;
} }
private async getNewTagTypes(dto: ImportTogglesSchema) { private async getNewTagTypes(dto: ImportTogglesSchema) {

View File

@ -547,6 +547,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: 'first_feature',
type: 'release',
}; };
const anotherExportedFeature: ImportTogglesSchema['data']['features'][0] = { const anotherExportedFeature: ImportTogglesSchema['data']['features'][0] = {
project: 'old_project', project: 'old_project',
@ -723,15 +724,22 @@ test('import multiple features with same tag', async () => {
}); });
}); });
test('importing same JSON should work multiple times in a row', async () => { test('can update toggles on subsequent import', async () => {
await createProjects(); await createProjects();
await app.importToggles(defaultImportPayload); await app.importToggles(defaultImportPayload);
await app.importToggles(defaultImportPayload); await app.importToggles({
...defaultImportPayload,
data: {
...defaultImportPayload.data,
features: [{ ...exportedFeature, type: 'operational' }],
},
});
const { body: importedFeature } = await getFeature(defaultFeature); const { body: importedFeature } = await getFeature(defaultFeature);
expect(importedFeature).toMatchObject({ expect(importedFeature).toMatchObject({
name: 'first_feature', name: 'first_feature',
project: DEFAULT_PROJECT, project: DEFAULT_PROJECT,
type: 'operational',
variants, variants,
}); });
@ -803,6 +811,26 @@ test('reject import with unsupported strategies', async () => {
expect(body.details[0].description).toMatch(/\bcustomStrategy\b/); expect(body.details[0].description).toMatch(/\bcustomStrategy\b/);
}); });
test('reject import with duplicate features', async () => {
await createProjects();
const importPayloadWithContextFields: ImportTogglesSchema = {
...defaultImportPayload,
data: {
...defaultImportPayload.data,
features: [exportedFeature, exportedFeature],
},
};
const { body } = await app.importToggles(
importPayloadWithContextFields,
409,
);
expect(body.details[0].description).toBe(
'Feature first_feature already exists',
);
});
test('validate import data', async () => { test('validate import data', async () => {
await createProjects(); await createProjects();
const contextField: IContextFieldDto = { const contextField: IContextFieldDto = {

View File

@ -10,7 +10,9 @@ export const isValidField = (
if (!matchingExistingField) { if (!matchingExistingField) {
return true; return true;
} }
return importedField.legalValues.every((value) => return (importedField.legalValues || []).every((value) =>
matchingExistingField.legalValues.find((v) => v.value === value.value), (matchingExistingField.legalValues || []).find(
(v) => v.value === value.value,
),
); );
}; };

View File

@ -2,11 +2,11 @@ import { Store } from './store';
export interface IContextFieldDto { export interface IContextFieldDto {
name: string; name: string;
description?: string; description?: string | null;
stickiness?: boolean; stickiness?: boolean;
sortOrder?: number; sortOrder?: number;
usedInProjects?: number; usedInProjects?: number | null;
usedInFeatures?: number; usedInFeatures?: number | null;
legalValues?: ILegalValue[]; legalValues?: ILegalValue[];
} }