mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-13 13:48:59 +02:00
fix: stickiness should be preserved on strategy updates
This commit is contained in:
parent
32996460df
commit
7456ae75f3
@ -343,4 +343,8 @@ export default class FakeFeatureStrategiesStore
|
|||||||
getCustomStrategiesInUseCount(): Promise<number> {
|
getCustomStrategiesInUseCount(): Promise<number> {
|
||||||
return Promise.resolve(3);
|
return Promise.resolve(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getDefaultStickiness(_projectId: string): Promise<string> {
|
||||||
|
return Promise.resolve('default');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -370,16 +370,6 @@ export class FeatureToggleService {
|
|||||||
'You can not change the featureName for an activation strategy.',
|
'You can not change the featureName for an activation strategy.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
existingStrategy.parameters &&
|
|
||||||
'stickiness' in existingStrategy.parameters &&
|
|
||||||
existingStrategy.parameters.stickiness === ''
|
|
||||||
) {
|
|
||||||
throw new InvalidOperationError(
|
|
||||||
'You can not have an empty string for stickiness.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateProjectCanAccessSegments(
|
async validateProjectCanAccessSegments(
|
||||||
@ -680,6 +670,59 @@ export class FeatureToggleService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async standardizeStrategyConfig(
|
||||||
|
projectId: string,
|
||||||
|
strategyConfig: Unsaved<IStrategyConfig>,
|
||||||
|
existing?: IFeatureStrategy,
|
||||||
|
): Promise<
|
||||||
|
{ strategyName: string } & Pick<
|
||||||
|
Partial<IFeatureStrategy>,
|
||||||
|
| 'title'
|
||||||
|
| 'disabled'
|
||||||
|
| 'variants'
|
||||||
|
| 'sortOrder'
|
||||||
|
| 'constraints'
|
||||||
|
| 'parameters'
|
||||||
|
>
|
||||||
|
> {
|
||||||
|
const { name, title, disabled, sortOrder } = strategyConfig;
|
||||||
|
let { constraints, parameters, variants } = strategyConfig;
|
||||||
|
if (constraints && constraints.length > 0) {
|
||||||
|
this.validateConstraintsLimit({
|
||||||
|
updated: constraints,
|
||||||
|
existing: existing?.constraints ?? [],
|
||||||
|
});
|
||||||
|
constraints = await this.validateConstraints(constraints);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
parameters &&
|
||||||
|
(!('stickiness' in parameters) ||
|
||||||
|
('stickiness' in parameters && parameters.stickiness === ''))
|
||||||
|
) {
|
||||||
|
parameters.stickiness =
|
||||||
|
existing?.parameters?.stickiness ||
|
||||||
|
(await this.featureStrategiesStore.getDefaultStickiness(
|
||||||
|
projectId,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variants && variants.length > 0) {
|
||||||
|
await variantsArraySchema.validateAsync(variants);
|
||||||
|
const fixedVariants = this.fixVariantWeights(variants);
|
||||||
|
variants = fixedVariants;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
strategyName: name,
|
||||||
|
title,
|
||||||
|
disabled,
|
||||||
|
sortOrder,
|
||||||
|
constraints,
|
||||||
|
variants,
|
||||||
|
parameters,
|
||||||
|
};
|
||||||
|
}
|
||||||
async unprotectedCreateStrategy(
|
async unprotectedCreateStrategy(
|
||||||
strategyConfig: Unsaved<IStrategyConfig>,
|
strategyConfig: Unsaved<IStrategyConfig>,
|
||||||
context: IFeatureStrategyContext,
|
context: IFeatureStrategyContext,
|
||||||
@ -694,34 +737,10 @@ export class FeatureToggleService {
|
|||||||
strategyConfig.segments,
|
strategyConfig.segments,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
const standardizedConfig = await this.standardizeStrategyConfig(
|
||||||
strategyConfig.constraints &&
|
projectId,
|
||||||
strategyConfig.constraints.length > 0
|
strategyConfig,
|
||||||
) {
|
);
|
||||||
this.validateConstraintsLimit({
|
|
||||||
updated: strategyConfig.constraints,
|
|
||||||
existing: [],
|
|
||||||
});
|
|
||||||
strategyConfig.constraints = await this.validateConstraints(
|
|
||||||
strategyConfig.constraints,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
strategyConfig.parameters &&
|
|
||||||
'stickiness' in strategyConfig.parameters &&
|
|
||||||
strategyConfig.parameters.stickiness === ''
|
|
||||||
) {
|
|
||||||
strategyConfig.parameters.stickiness = 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strategyConfig.variants && strategyConfig.variants.length > 0) {
|
|
||||||
await variantsArraySchema.validateAsync(strategyConfig.variants);
|
|
||||||
const fixedVariants = this.fixVariantWeights(
|
|
||||||
strategyConfig.variants,
|
|
||||||
);
|
|
||||||
strategyConfig.variants = fixedVariants;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.validateStrategyLimit({
|
await this.validateStrategyLimit({
|
||||||
featureName,
|
featureName,
|
||||||
@ -732,13 +751,10 @@ export class FeatureToggleService {
|
|||||||
try {
|
try {
|
||||||
const newFeatureStrategy =
|
const newFeatureStrategy =
|
||||||
await this.featureStrategiesStore.createStrategyFeatureEnv({
|
await this.featureStrategiesStore.createStrategyFeatureEnv({
|
||||||
strategyName: strategyConfig.name,
|
...standardizedConfig,
|
||||||
title: strategyConfig.title,
|
constraints: standardizedConfig.constraints || [],
|
||||||
disabled: strategyConfig.disabled,
|
variants: standardizedConfig.variants || [],
|
||||||
constraints: strategyConfig.constraints || [],
|
parameters: standardizedConfig.parameters || {},
|
||||||
variants: strategyConfig.variants || [],
|
|
||||||
parameters: strategyConfig.parameters || {},
|
|
||||||
sortOrder: strategyConfig.sortOrder,
|
|
||||||
projectId,
|
projectId,
|
||||||
featureName,
|
featureName,
|
||||||
environment,
|
environment,
|
||||||
@ -864,25 +880,14 @@ export class FeatureToggleService {
|
|||||||
const existingSegments = await this.segmentService.getByStrategy(id);
|
const existingSegments = await this.segmentService.getByStrategy(id);
|
||||||
|
|
||||||
if (existingStrategy.id === id) {
|
if (existingStrategy.id === id) {
|
||||||
if (updates.constraints && updates.constraints.length > 0) {
|
const standardizedUpdates = await this.standardizeStrategyConfig(
|
||||||
this.validateConstraintsLimit({
|
projectId,
|
||||||
updated: updates.constraints,
|
{ ...updates, name: updates.name! },
|
||||||
existing: existingStrategy.constraints,
|
existingStrategy,
|
||||||
});
|
);
|
||||||
updates.constraints = await this.validateConstraints(
|
|
||||||
updates.constraints,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.variants && updates.variants.length > 0) {
|
|
||||||
await variantsArraySchema.validateAsync(updates.variants);
|
|
||||||
const fixedVariants = this.fixVariantWeights(updates.variants);
|
|
||||||
updates.variants = fixedVariants;
|
|
||||||
}
|
|
||||||
|
|
||||||
const strategy = await this.featureStrategiesStore.updateStrategy(
|
const strategy = await this.featureStrategiesStore.updateStrategy(
|
||||||
id,
|
id,
|
||||||
updates,
|
standardizedUpdates,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (updates.segments && Array.isArray(updates.segments)) {
|
if (updates.segments && Array.isArray(updates.segments)) {
|
||||||
|
@ -248,7 +248,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
|||||||
});
|
});
|
||||||
return Number.isInteger(max) ? max + 1 : 0;
|
return Number.isInteger(max) ? max + 1 : 0;
|
||||||
}
|
}
|
||||||
private async getDefaultStickiness(projectName: string): Promise<string> {
|
async getDefaultStickiness(projectName: string): Promise<string> {
|
||||||
const defaultFromDb = await this.db(T.projectSettings)
|
const defaultFromDb = await this.db(T.projectSettings)
|
||||||
.select('default_stickiness')
|
.select('default_stickiness')
|
||||||
.where('project', projectName)
|
.where('project', projectName)
|
||||||
@ -264,9 +264,9 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
|||||||
strategyConfig.featureName,
|
strategyConfig.featureName,
|
||||||
strategyConfig.environment,
|
strategyConfig.environment,
|
||||||
));
|
));
|
||||||
const stickiness = await this.getDefaultStickiness(
|
const stickiness =
|
||||||
strategyConfig.projectId,
|
strategyConfig.parameters?.stickiness ??
|
||||||
);
|
(await this.getDefaultStickiness(strategyConfig.projectId));
|
||||||
strategyConfig.parameters = parametersWithDefaults(
|
strategyConfig.parameters = parametersWithDefaults(
|
||||||
strategyConfig,
|
strategyConfig,
|
||||||
stickiness,
|
stickiness,
|
||||||
|
@ -141,7 +141,11 @@ test('Should be able to update existing strategy configuration', async () => {
|
|||||||
TEST_AUDIT_USER,
|
TEST_AUDIT_USER,
|
||||||
);
|
);
|
||||||
expect(createdConfig.id).toEqual(updatedConfig.id);
|
expect(createdConfig.id).toEqual(updatedConfig.id);
|
||||||
expect(updatedConfig.parameters).toEqual({ b2b: 'true' });
|
expect(updatedConfig.parameters).toEqual({
|
||||||
|
b2b: 'true',
|
||||||
|
// make sure stickiness is preserved
|
||||||
|
stickiness: createdConfig.parameters?.stickiness,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Should be able to get strategy by id', async () => {
|
test('Should be able to get strategy by id', async () => {
|
||||||
@ -758,34 +762,79 @@ test('Should return last seen at per environment', async () => {
|
|||||||
expect(featureToggle.lastSeenAt).toEqual(new Date(lastSeenAtStoreDate));
|
expect(featureToggle.lastSeenAt).toEqual(new Date(lastSeenAtStoreDate));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Should return "default" for stickiness when creating a flexibleRollout strategy with empty stickiness', async () => {
|
test.each([
|
||||||
const strategy = {
|
['empty stickiness', { rollout: '100', stickiness: '' }],
|
||||||
name: 'flexibleRollout',
|
['undefined stickiness', { rollout: '100' }],
|
||||||
parameters: {
|
['undefined parameters', undefined],
|
||||||
rollout: '100',
|
])(
|
||||||
stickiness: '',
|
'Should set project default stickiness when creating a flexibleRollout strategy with %s',
|
||||||
},
|
async (description, parameters) => {
|
||||||
constraints: [],
|
const strategy = {
|
||||||
};
|
name: 'flexibleRollout',
|
||||||
const feature = {
|
parameters,
|
||||||
name: 'test-feature-stickiness-1',
|
constraints: [],
|
||||||
description: 'the #1 feature',
|
};
|
||||||
};
|
const feature = {
|
||||||
const projectId = 'default';
|
name: `test-feature-create-${description.replaceAll(' ', '-')}`,
|
||||||
|
};
|
||||||
|
const projectId = 'default';
|
||||||
|
const defaultStickiness = `not-default-${description.replaceAll(' ', '-')}`;
|
||||||
|
const project = await stores.projectStore.update({
|
||||||
|
id: projectId,
|
||||||
|
name: 'stickiness-project-test',
|
||||||
|
defaultStickiness,
|
||||||
|
});
|
||||||
|
const context = {
|
||||||
|
projectId,
|
||||||
|
featureName: feature.name,
|
||||||
|
environment: DEFAULT_ENV,
|
||||||
|
};
|
||||||
|
|
||||||
await service.createFeatureToggle(projectId, feature, TEST_AUDIT_USER);
|
await service.createFeatureToggle(projectId, feature, TEST_AUDIT_USER);
|
||||||
await service.createStrategy(
|
const createdStrategy = await service.createStrategy(
|
||||||
strategy,
|
strategy,
|
||||||
{ projectId, featureName: feature.name, environment: DEFAULT_ENV },
|
context,
|
||||||
TEST_AUDIT_USER,
|
TEST_AUDIT_USER,
|
||||||
);
|
);
|
||||||
|
|
||||||
const featureDB = await service.getFeature({ featureName: feature.name });
|
const featureDB = await service.getFeature({
|
||||||
|
featureName: feature.name,
|
||||||
|
});
|
||||||
|
|
||||||
expect(featureDB.environments[0]).toMatchObject({
|
expect(featureDB.environments[0]).toMatchObject({
|
||||||
strategies: [{ parameters: { stickiness: 'default' } }],
|
strategies: [
|
||||||
});
|
{
|
||||||
});
|
parameters: {
|
||||||
|
...parameters,
|
||||||
|
stickiness: defaultStickiness,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify that updating the strategy with same data is idempotent
|
||||||
|
await service.updateStrategy(
|
||||||
|
createdStrategy.id,
|
||||||
|
strategy,
|
||||||
|
context,
|
||||||
|
TEST_AUDIT_USER,
|
||||||
|
);
|
||||||
|
const featureDBAfterUpdate = await service.getFeature({
|
||||||
|
featureName: feature.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(featureDBAfterUpdate.environments[0]).toMatchObject({
|
||||||
|
strategies: [
|
||||||
|
{
|
||||||
|
parameters: {
|
||||||
|
...parameters,
|
||||||
|
stickiness: defaultStickiness,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('Should not allow to add flags to archived projects', async () => {
|
test('Should not allow to add flags to archived projects', async () => {
|
||||||
const project = await stores.projectStore.create({
|
const project = await stores.projectStore.create({
|
||||||
|
@ -124,4 +124,6 @@ export interface IFeatureStrategiesStore
|
|||||||
): Promise<IFeatureStrategy[]>;
|
): Promise<IFeatureStrategy[]>;
|
||||||
|
|
||||||
getCustomStrategiesInUseCount(): Promise<number>;
|
getCustomStrategiesInUseCount(): Promise<number>;
|
||||||
|
|
||||||
|
getDefaultStickiness(projectId: string): Promise<string>;
|
||||||
}
|
}
|
||||||
|
@ -13,25 +13,6 @@ import type {
|
|||||||
import type { Store } from '../../types/stores/store.js';
|
import type { Store } from '../../types/stores/store.js';
|
||||||
import type { CreateFeatureStrategySchema } from '../../openapi/index.js';
|
import type { CreateFeatureStrategySchema } from '../../openapi/index.js';
|
||||||
|
|
||||||
export interface IProjectInsert {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
updatedAt?: Date;
|
|
||||||
changeRequestsEnabled?: boolean;
|
|
||||||
mode?: ProjectMode;
|
|
||||||
featureLimit?: number;
|
|
||||||
featureNaming?: IFeatureNaming;
|
|
||||||
linkTemplates?: IProjectLinkTemplate[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IProjectEnterpriseSettingsUpdate {
|
|
||||||
id: string;
|
|
||||||
mode?: ProjectMode;
|
|
||||||
featureNaming?: IFeatureNaming;
|
|
||||||
linkTemplates?: IProjectLinkTemplate[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IProjectSettings {
|
export interface IProjectSettings {
|
||||||
mode: ProjectMode;
|
mode: ProjectMode;
|
||||||
defaultStickiness: string;
|
defaultStickiness: string;
|
||||||
@ -42,6 +23,22 @@ export interface IProjectSettings {
|
|||||||
linkTemplates?: IProjectLinkTemplate[];
|
linkTemplates?: IProjectLinkTemplate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IProjectInsert extends Partial<IProjectSettings> {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
updatedAt?: Date;
|
||||||
|
changeRequestsEnabled?: boolean;
|
||||||
|
featureNaming?: IFeatureNaming;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProjectEnterpriseSettingsUpdate {
|
||||||
|
id: string;
|
||||||
|
mode?: ProjectMode;
|
||||||
|
featureNaming?: IFeatureNaming;
|
||||||
|
linkTemplates?: IProjectLinkTemplate[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface IProjectHealthUpdate {
|
export interface IProjectHealthUpdate {
|
||||||
id: string;
|
id: string;
|
||||||
health: number;
|
health: number;
|
||||||
|
Loading…
Reference in New Issue
Block a user