mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-22 19:07:54 +01:00
feat: add algorithm to detect what strategy changes would be overwritten by applying a CR (#5963)
This change adds an algorithm with tests for detecting what changes would be overwritten by applying a CR. Test cases: - It compares strategies regardless of order of keys in the objects. This ensures that two strategies with the same content but different order of keys are compared correctly. - It treats `undefined` or missing segments in old config as equal to `[]` in change - It treats `undefined` or missing strategy variants in old config and change as equal to `[]` - It lists changes in a sorted list with the correct values - It ignores object order on nested objects. Similar to the first point, this does order-insensitive comparison for nested objects (such as params and constraints).
This commit is contained in:
parent
b00909db3f
commit
c69137a1ee
@ -2,7 +2,10 @@
|
||||
"name": "unleash-frontend-local",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"files": ["index.js", "build"],
|
||||
"files": [
|
||||
"index.js",
|
||||
"build"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@ -50,6 +53,7 @@
|
||||
"@types/deep-diff": "1.0.5",
|
||||
"@types/jest": "29.5.11",
|
||||
"@types/lodash.clonedeep": "4.5.9",
|
||||
"@types/lodash.isequal": "^4.5.8",
|
||||
"@types/lodash.mapvalues": "^4.6.9",
|
||||
"@types/lodash.omit": "4.5.9",
|
||||
"@types/node": "18.19.6",
|
||||
@ -83,6 +87,7 @@
|
||||
"immer": "9.0.21",
|
||||
"jsdom": "23.1.0",
|
||||
"lodash.clonedeep": "4.5.0",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"lodash.mapvalues": "^4.6.0",
|
||||
"lodash.omit": "4.5.0",
|
||||
"mermaid": "^9.3.0",
|
||||
@ -138,7 +143,11 @@
|
||||
}
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [">0.2%", "not dead", "not op_mini all"],
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
|
@ -0,0 +1,290 @@
|
||||
import { IChangeRequestUpdateStrategy } from 'component/changeRequest/changeRequest.types';
|
||||
import { IFeatureStrategy } from 'interfaces/strategy';
|
||||
import { getChangesThatWouldBeOverwritten } from './strategy-change-diff-calculation';
|
||||
|
||||
describe('Strategy change conflict detection', () => {
|
||||
const existingStrategy: IFeatureStrategy = {
|
||||
name: 'flexibleRollout',
|
||||
constraints: [],
|
||||
variants: [],
|
||||
parameters: {
|
||||
groupId: 'aaa',
|
||||
rollout: '71',
|
||||
stickiness: 'default',
|
||||
},
|
||||
sortOrder: 0,
|
||||
id: '31572930-2db7-461f-813b-3eedc200cb33',
|
||||
title: '',
|
||||
disabled: false,
|
||||
segments: [],
|
||||
};
|
||||
|
||||
const snapshot: IFeatureStrategy = {
|
||||
id: '31572930-2db7-461f-813b-3eedc200cb33',
|
||||
name: 'flexibleRollout',
|
||||
title: '',
|
||||
disabled: false,
|
||||
segments: [],
|
||||
variants: [],
|
||||
sortOrder: 0,
|
||||
parameters: {
|
||||
groupId: 'aaa',
|
||||
rollout: '71',
|
||||
stickiness: 'default',
|
||||
},
|
||||
constraints: [],
|
||||
};
|
||||
|
||||
const change: IChangeRequestUpdateStrategy = {
|
||||
id: 39,
|
||||
action: 'updateStrategy' as const,
|
||||
payload: {
|
||||
id: '31572930-2db7-461f-813b-3eedc200cb33',
|
||||
name: 'flexibleRollout',
|
||||
title: '',
|
||||
disabled: false,
|
||||
segments: [],
|
||||
snapshot,
|
||||
variants: [],
|
||||
parameters: {
|
||||
groupId: 'aaa',
|
||||
rollout: '38',
|
||||
stickiness: 'default',
|
||||
},
|
||||
constraints: [],
|
||||
},
|
||||
createdAt: new Date('2024-01-18T07:58:36.314Z'),
|
||||
createdBy: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
imageUrl:
|
||||
'https://gravatar.com/avatar/8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918?s=42&d=retro&r=g',
|
||||
},
|
||||
};
|
||||
|
||||
test('It compares strategies regardless of order of keys in the objects', () => {
|
||||
const result = getChangesThatWouldBeOverwritten(
|
||||
existingStrategy,
|
||||
change,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('It treats `undefined` or missing segments in old config as equal to `[]` in change', () => {
|
||||
const resultUndefined = getChangesThatWouldBeOverwritten(
|
||||
{
|
||||
...existingStrategy,
|
||||
segments: undefined,
|
||||
},
|
||||
change,
|
||||
);
|
||||
|
||||
expect(resultUndefined).toBeNull();
|
||||
|
||||
const { segments, ...withoutSegments } = existingStrategy;
|
||||
const resultMissing = getChangesThatWouldBeOverwritten(
|
||||
withoutSegments,
|
||||
change,
|
||||
);
|
||||
|
||||
expect(resultMissing).toBeNull();
|
||||
});
|
||||
|
||||
test('It treats `undefined` or missing strategy variants in old config and change as equal to `[]`', () => {
|
||||
const undefinedVariantsExistingStrategy = {
|
||||
...existingStrategy,
|
||||
variants: undefined,
|
||||
};
|
||||
const { variants: _variants, ...missingVariantsExistingStrategy } =
|
||||
existingStrategy;
|
||||
|
||||
const { variants: _snapshotVariants, ...snapshot } =
|
||||
change.payload.snapshot!;
|
||||
|
||||
const undefinedVariantsInSnapshot = {
|
||||
...change,
|
||||
payload: {
|
||||
...change.payload,
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
variants: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const missingVariantsInSnapshot = {
|
||||
...change,
|
||||
payload: {
|
||||
...change.payload,
|
||||
snapshot: snapshot,
|
||||
},
|
||||
};
|
||||
|
||||
// for all combinations, check that there are no changes
|
||||
const cases = [
|
||||
undefinedVariantsExistingStrategy,
|
||||
missingVariantsExistingStrategy,
|
||||
].flatMap((existing) =>
|
||||
[undefinedVariantsInSnapshot, missingVariantsInSnapshot].map(
|
||||
(changeValue) =>
|
||||
getChangesThatWouldBeOverwritten(existing, changeValue),
|
||||
),
|
||||
);
|
||||
|
||||
expect(cases.every((result) => result === null)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('It lists changes in a sorted list with the correct values', () => {
|
||||
const withChanges: IFeatureStrategy = {
|
||||
name: 'flexibleRollout',
|
||||
title: 'custom title',
|
||||
constraints: [
|
||||
{
|
||||
values: ['blah'],
|
||||
inverted: false,
|
||||
operator: 'IN' as const,
|
||||
contextName: 'appName',
|
||||
caseInsensitive: false,
|
||||
},
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
name: 'variant1',
|
||||
weight: 1000,
|
||||
payload: {
|
||||
type: 'string',
|
||||
value: 'beaty',
|
||||
},
|
||||
stickiness: 'userId',
|
||||
weightType: 'variable' as const,
|
||||
},
|
||||
],
|
||||
parameters: {
|
||||
groupId: 'aab',
|
||||
rollout: '39',
|
||||
stickiness: 'userId',
|
||||
},
|
||||
sortOrder: 1,
|
||||
id: '31572930-2db7-461f-813b-3eedc200cb33',
|
||||
disabled: true,
|
||||
segments: [3],
|
||||
};
|
||||
|
||||
const result = getChangesThatWouldBeOverwritten(withChanges, change);
|
||||
|
||||
const { id, name, ...changedProperties } = withChanges;
|
||||
|
||||
const expectedOutput = Object.entries(changedProperties).map(
|
||||
([property, oldValue]) => ({
|
||||
property,
|
||||
oldValue,
|
||||
newValue:
|
||||
change.payload.snapshot![
|
||||
property as keyof IFeatureStrategy
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expectedOutput.sort((a, b) => a.property.localeCompare(b.property));
|
||||
expect(result).toStrictEqual(expectedOutput);
|
||||
});
|
||||
|
||||
test('it ignores object order on nested objects', () => {
|
||||
const existingStrategyMod: IFeatureStrategy = {
|
||||
...existingStrategy,
|
||||
constraints: [
|
||||
{
|
||||
values: ['blah'],
|
||||
inverted: false,
|
||||
operator: 'IN' as const,
|
||||
contextName: 'appName',
|
||||
caseInsensitive: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const constraintChange: IChangeRequestUpdateStrategy = {
|
||||
...change,
|
||||
payload: {
|
||||
...change.payload,
|
||||
// @ts-expect-error Some of the properties that may be
|
||||
// undefined, we know exist in the value
|
||||
snapshot: {
|
||||
...change.payload.snapshot,
|
||||
constraints: [
|
||||
{
|
||||
caseInsensitive: false,
|
||||
contextName: 'appName',
|
||||
inverted: false,
|
||||
operator: 'IN' as const,
|
||||
values: ['blah'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
getChangesThatWouldBeOverwritten(
|
||||
existingStrategyMod,
|
||||
constraintChange,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('Any properties in the existing strategy that do not exist in the snapshot are also detected', () => {
|
||||
const { variants: _snapshotVariants, ...snapshot } =
|
||||
change.payload.snapshot!;
|
||||
|
||||
const existingStrategyWithVariants = {
|
||||
...existingStrategy,
|
||||
variants: [
|
||||
{
|
||||
name: 'variant1',
|
||||
weight: 1000,
|
||||
payload: {
|
||||
type: 'string',
|
||||
value: 'beaty',
|
||||
},
|
||||
stickiness: 'userId',
|
||||
weightType: 'variable' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getChangesThatWouldBeOverwritten(
|
||||
existingStrategyWithVariants,
|
||||
{
|
||||
...change,
|
||||
payload: {
|
||||
...change.payload,
|
||||
snapshot,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toStrictEqual([
|
||||
{
|
||||
property: 'variants',
|
||||
oldValue: existingStrategyWithVariants.variants,
|
||||
newValue: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('it returns null if the existing strategy is undefined', () => {
|
||||
const result = getChangesThatWouldBeOverwritten(undefined, change);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
test('it returns null if the snapshot is missing', () => {
|
||||
const { snapshot, ...payload } = change.payload;
|
||||
const result = getChangesThatWouldBeOverwritten(existingStrategy, {
|
||||
...change,
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
@ -0,0 +1,79 @@
|
||||
import { IChangeRequestUpdateStrategy } from 'component/changeRequest/changeRequest.types';
|
||||
import { IFeatureStrategy } from 'interfaces/strategy';
|
||||
import isEqual from 'lodash.isequal';
|
||||
|
||||
const hasJsonDiff = (object: unknown, objectToCompare: unknown) =>
|
||||
JSON.stringify(object) !== JSON.stringify(objectToCompare);
|
||||
|
||||
type DataToOverwrite<Prop extends keyof IFeatureStrategy> = {
|
||||
property: Prop;
|
||||
oldValue: IFeatureStrategy[Prop];
|
||||
newValue: IFeatureStrategy[Prop];
|
||||
};
|
||||
type ChangesThatWouldBeOverwritten = DataToOverwrite<keyof IFeatureStrategy>[];
|
||||
|
||||
export const getChangesThatWouldBeOverwritten = (
|
||||
currentStrategyConfig: IFeatureStrategy | undefined,
|
||||
change: IChangeRequestUpdateStrategy,
|
||||
): ChangesThatWouldBeOverwritten | null => {
|
||||
const { snapshot } = change.payload;
|
||||
if (snapshot && currentStrategyConfig) {
|
||||
const hasChanged = (a: unknown, b: unknown) => {
|
||||
if (typeof a === 'object') {
|
||||
return !isEqual(a, b);
|
||||
}
|
||||
return hasJsonDiff(a, b);
|
||||
};
|
||||
|
||||
// compare each property in the snapshot. The property order
|
||||
// might differ, so using JSON.stringify to compare them
|
||||
// doesn't work.
|
||||
const changes: ChangesThatWouldBeOverwritten = Object.entries(
|
||||
currentStrategyConfig,
|
||||
)
|
||||
.map(([key, currentValue]: [string, unknown]) => {
|
||||
const snapshotValue = snapshot[key as keyof IFeatureStrategy];
|
||||
|
||||
// compare, assuming that order never changes
|
||||
if (key === 'segments') {
|
||||
// segments can be undefined on the original
|
||||
// object, but that doesn't mean it has changed
|
||||
if (hasJsonDiff(snapshotValue, currentValue ?? [])) {
|
||||
return {
|
||||
property: key as keyof IFeatureStrategy,
|
||||
oldValue: currentValue,
|
||||
newValue: snapshotValue,
|
||||
};
|
||||
}
|
||||
} else if (key === 'variants') {
|
||||
// strategy variants might not be defined, so use
|
||||
// fallback values
|
||||
if (hasJsonDiff(snapshotValue ?? [], currentValue ?? [])) {
|
||||
return {
|
||||
property: key as keyof IFeatureStrategy,
|
||||
oldValue: currentValue,
|
||||
newValue: snapshotValue,
|
||||
};
|
||||
}
|
||||
} else if (hasChanged(snapshotValue, currentValue)) {
|
||||
return {
|
||||
property: key as keyof IFeatureStrategy,
|
||||
oldValue: currentValue,
|
||||
newValue: snapshotValue,
|
||||
};
|
||||
}
|
||||
})
|
||||
.filter(
|
||||
(change): change is DataToOverwrite<keyof IFeatureStrategy> =>
|
||||
Boolean(change),
|
||||
);
|
||||
|
||||
if (changes.length) {
|
||||
// we have changes that would be overwritten
|
||||
changes.sort((a, b) => a.property.localeCompare(b.property));
|
||||
return changes;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
@ -228,6 +228,7 @@ export type ChangeRequestAddStrategy = Pick<
|
||||
|
||||
export type ChangeRequestEditStrategy = ChangeRequestAddStrategy & {
|
||||
id: string;
|
||||
snapshot?: IFeatureStrategy;
|
||||
};
|
||||
|
||||
type ChangeRequestDeleteStrategy = {
|
||||
|
@ -2127,6 +2127,13 @@
|
||||
dependencies:
|
||||
"@types/lodash" "*"
|
||||
|
||||
"@types/lodash.isequal@^4.5.8":
|
||||
version "4.5.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash.isequal/-/lodash.isequal-4.5.8.tgz#b30bb6ff6a5f6c19b3daf389d649ac7f7a250499"
|
||||
integrity sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==
|
||||
dependencies:
|
||||
"@types/lodash" "*"
|
||||
|
||||
"@types/lodash.mapvalues@^4.6.9":
|
||||
version "4.6.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash.mapvalues/-/lodash.mapvalues-4.6.9.tgz#1edb4b1d299db332166b474221b06058b34030a7"
|
||||
@ -5289,6 +5296,11 @@ lodash.isempty@^4.4.0:
|
||||
resolved "https://registry.yarnpkg.com/lodash.isempty/-/lodash.isempty-4.4.0.tgz#6f86cbedd8be4ec987be9aaf33c9684db1b31e7e"
|
||||
integrity sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==
|
||||
|
||||
lodash.isequal@^4.5.0:
|
||||
version "4.5.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
|
||||
integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==
|
||||
|
||||
lodash.mapvalues@^4.6.0:
|
||||
version "4.6.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c"
|
||||
|
Loading…
Reference in New Issue
Block a user