diff --git a/src/lib/db/feature-strategy-store.test.ts b/src/lib/db/feature-strategy-store.test.ts index 14c2b769e8..7ef1e4a95d 100644 --- a/src/lib/db/feature-strategy-store.test.ts +++ b/src/lib/db/feature-strategy-store.test.ts @@ -84,6 +84,7 @@ test('counts custom strategies in use', async () => { environment: 'default', parameters: {}, constraints: [], + variants: [], }); // Act diff --git a/src/lib/db/feature-strategy-store.ts b/src/lib/db/feature-strategy-store.ts index e05121b0a3..3cba3ac5e7 100644 --- a/src/lib/db/feature-strategy-store.ts +++ b/src/lib/db/feature-strategy-store.ts @@ -15,6 +15,7 @@ import { IFeatureToggleClient, IFlagResolver, IStrategyConfig, + IStrategyVariant, ITag, PartialDeep, PartialSome, @@ -34,6 +35,7 @@ const COLUMNS = [ 'title', 'parameters', 'constraints', + 'variants', 'created_at', 'disabled', ]; @@ -62,6 +64,7 @@ interface IFeatureStrategiesTable { strategy_name: string; parameters: object; constraints: string; + variants: string; sort_order: number; created_at?: Date; disabled?: boolean | null; @@ -84,6 +87,7 @@ function mapRow(row: IFeatureStrategiesTable): IFeatureStrategy { title: row.title, parameters: mapValues(row.parameters || {}, ensureStringValue), constraints: (row.constraints as unknown as IConstraint[]) || [], + variants: (row.variants as unknown as IStrategyVariant[]) || [], createdAt: row.created_at, sortOrder: row.sort_order, disabled: row.disabled, @@ -100,6 +104,7 @@ function mapInput(input: IFeatureStrategy): IFeatureStrategiesTable { title: input.title, parameters: input.parameters, constraints: JSON.stringify(input.constraints || []), + variants: JSON.stringify(input.variants || []), created_at: input.createdAt, sort_order: input.sortOrder, disabled: input.disabled, @@ -110,6 +115,7 @@ interface StrategyUpdate { strategy_name: string; parameters: object; constraints: string; + variants: string; title?: string; disabled?: boolean; } @@ -131,6 +137,7 @@ function mapStrategyUpdate( update.disabled = input.disabled; } update.constraints = JSON.stringify(input.constraints || []); + update.variants = JSON.stringify(input.variants || []); return update; } @@ -599,6 +606,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { const strategy = { name: r.strategy_name, constraints: r.constraints || [], + variants: r.strategy_variants || [], parameters: r.parameters, sortOrder: r.sort_order, id: r.strategy_id, diff --git a/src/lib/services/feature-toggle-service.ts b/src/lib/services/feature-toggle-service.ts index 3807e1b7e4..987542c13b 100644 --- a/src/lib/services/feature-toggle-service.ts +++ b/src/lib/services/feature-toggle-service.ts @@ -439,6 +439,14 @@ class FeatureToggleService { 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; + } + try { const newFeatureStrategy = await this.featureStrategiesStore.createStrategyFeatureEnv({ @@ -446,6 +454,7 @@ class FeatureToggleService { title: strategyConfig.title, disabled: strategyConfig.disabled, constraints: strategyConfig.constraints || [], + variants: strategyConfig.variants || [], parameters: strategyConfig.parameters || {}, sortOrder: strategyConfig.sortOrder, projectId, @@ -562,6 +571,12 @@ class FeatureToggleService { ); } + 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( id, updates, @@ -756,6 +771,7 @@ class FeatureToggleService { name: strat.strategyName, constraints: strat.constraints, parameters: strat.parameters, + variants: strat.variants, title: strat.title, disabled: strat.disabled, sortOrder: strat.sortOrder, diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 1dd3f051e0..3b819c113d 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -26,6 +26,7 @@ export interface IStrategyConfig { name: string; featureName?: string; constraints?: IConstraint[]; + variants?: IStrategyVariant[]; segments?: number[]; parameters?: { [key: string]: string }; sortOrder?: number; @@ -41,6 +42,7 @@ export interface IFeatureStrategy { parameters: { [key: string]: string }; sortOrder?: number; constraints: IConstraint[]; + variants?: IStrategyVariant[]; createdAt?: Date; segments?: number[]; title?: string | null; @@ -130,6 +132,8 @@ export interface IVariant { }[]; } +export type IStrategyVariant = Omit; + export interface IEnvironment { name: string; type: string; diff --git a/src/test/e2e/api/admin/project/features.e2e.test.ts b/src/test/e2e/api/admin/project/features.e2e.test.ts index f46f3f73a0..441c138a30 100644 --- a/src/test/e2e/api/admin/project/features.e2e.test.ts +++ b/src/test/e2e/api/admin/project/features.e2e.test.ts @@ -2350,6 +2350,80 @@ test('Can create toggle with impression data on different project', async () => }); }); +test('should handle strategy variants', async () => { + const feature = { name: uuidv4(), impressionData: false }; + await app.createFeature(feature.name); + + const strategyWithInvalidVariant = { + name: uuidv4(), + constraints: [], + variants: [ + { + name: 'invalidVariant', + weight: 1000, + stickiness: 'default', + weightType: 'fix', + }, + ], // it should be variable + }; + + const variant = { + name: 'variantName', + weight: 1, + weightType: 'variable', + stickiness: 'default', + }; + const updatedVariant1 = { + name: 'updatedVariant1', + weight: 500, + weightType: 'variable', + stickiness: 'default', + }; + const updatedVariant2 = { + name: 'updatedVariant2', + weight: 500, + weightType: 'variable', + stickiness: 'default', + }; + const strategyWithValidVariant = { + name: uuidv4(), + constraints: [], + variants: [variant], + }; + + const featureStrategiesPath = `/api/admin/projects/default/features/${feature.name}/environments/default/strategies`; + + await app.request + .post(featureStrategiesPath) + .send(strategyWithInvalidVariant) + .expect(400); + + await createStrategy(feature.name, strategyWithValidVariant); + + const { body: strategies } = await app.request.get(featureStrategiesPath); + + expect(strategies).toMatchObject([ + { + variants: [{ ...variant, weight: 1000 }], // weight was fixed + }, + ]); + + await updateStrategy(feature.name, strategies[0].id, { + ...strategies[0], + variants: [updatedVariant1, updatedVariant2], + }); + + const { body: updatedStrategies } = await app.request.get( + featureStrategiesPath, + ); + + expect(updatedStrategies).toMatchObject([ + { + variants: [updatedVariant1, updatedVariant2], + }, + ]); +}); + test('should reject invalid constraint values for multi-valued constraints', async () => { const project = await db.stores.projectStore.create({ id: uuidv4(),