diff --git a/src/IFeatureManager.ts b/src/IFeatureManager.ts new file mode 100644 index 0000000..f7975f1 --- /dev/null +++ b/src/IFeatureManager.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { ITargetingContext } from "./common/ITargetingContext"; +import { Variant } from "./variant/Variant"; + +export interface IFeatureManager { + /** + * Get the list of feature names. + */ + listFeatureNames(): Promise; + + /** + * Check if a feature is enabled. + * @param featureName name of the feature. + * @param context an object providing information that can be used to evaluate whether a feature should be on or off. + */ + isEnabled(featureName: string, context?: unknown): Promise; + + /** + * Get the allocated variant of a feature given the targeting context. + * @param featureName name of the feature. + * @param context a targeting context object used to evaluate which variant the user will be assigned. + */ + getVariant(featureName: string, context: ITargetingContext): Promise; +} diff --git a/src/common/ITargetingContext.ts b/src/common/ITargetingContext.ts new file mode 100644 index 0000000..758ec52 --- /dev/null +++ b/src/common/ITargetingContext.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export interface ITargetingContext { + userId?: string; + groups?: string[]; +} + diff --git a/src/common/targetingEvaluator.ts b/src/common/targetingEvaluator.ts new file mode 100644 index 0000000..a048f32 --- /dev/null +++ b/src/common/targetingEvaluator.ts @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { createHash } from "crypto"; + +/** + * Determines if the user is part of the audience, based on the user id and the percentage range. + * + * @param userId user id from app context + * @param hint hint string to be included in the context id + * @param from percentage range start + * @param to percentage range end + * @returns true if the user is part of the audience, false otherwise + */ +export function isTargetedPercentile(userId: string | undefined, hint: string, from: number, to: number): boolean { + if (from < 0 || from > 100) { + throw new Error("The 'from' value must be between 0 and 100."); + } + if (to < 0 || to > 100) { + throw new Error("The 'to' value must be between 0 and 100."); + } + if (from > to) { + throw new Error("The 'from' value cannot be larger than the 'to' value."); + } + + const audienceContextId = constructAudienceContextId(userId, hint); + + // Cryptographic hashing algorithms ensure adequate entropy across hash values. + const contextMarker = stringToUint32(audienceContextId); + const contextPercentage = (contextMarker / 0xFFFFFFFF) * 100; + + // Handle edge case of exact 100 bucket + if (to === 100) { + return contextPercentage >= from; + } + + return contextPercentage >= from && contextPercentage < to; +} + +/** + * Determines if the user is part of the audience, based on the groups they belong to. + * + * @param sourceGroups user groups from app context + * @param targetedGroups targeted groups from feature configuration + * @returns true if the user is part of the audience, false otherwise + */ +export function isTargetedGroup(sourceGroups: string[] | undefined, targetedGroups: string[]): boolean { + if (sourceGroups === undefined) { + return false; + } + + return sourceGroups.some(group => targetedGroups.includes(group)); +} + +/** + * Determines if the user is part of the audience, based on the user id. + * @param userId user id from app context + * @param users targeted users from feature configuration + * @returns true if the user is part of the audience, false otherwise + */ +export function isTargetedUser(userId: string | undefined, users: string[]): boolean { + if (userId === undefined) { + return false; + } + + return users.includes(userId); +} + +/** + * Constructs the context id for the audience. + * The context id is used to determine if the user is part of the audience for a feature. + * + * @param userId userId from app context + * @param hint hint string to be included in the context id + * @returns a string that represents the context id for the audience + */ +function constructAudienceContextId(userId: string | undefined, hint: string): string { + return `${userId ?? ""}\n${hint}`; +} + +/** + * Converts a string to a uint32 in little-endian encoding. + * @param str the string to convert. + * @returns a uint32 value. + */ +function stringToUint32(str: string): number { + // Create a SHA-256 hash of the string + const hash = createHash("sha256").update(str).digest(); + + // Get the first 4 bytes of the hash + const first4Bytes = hash.subarray(0, 4); + + // Convert the 4 bytes to a uint32 with little-endian encoding + const uint32 = first4Bytes.readUInt32LE(0); + return uint32; +} diff --git a/src/featureManager.ts b/src/featureManager.ts index bce1325..395ed78 100644 --- a/src/featureManager.ts +++ b/src/featureManager.ts @@ -3,11 +3,15 @@ import { TimeWindowFilter } from "./filter/TimeWindowFilter"; import { IFeatureFilter } from "./filter/FeatureFilter"; -import { RequirementType } from "./model"; +import { FeatureFlag, RequirementType, VariantDefinition } from "./model"; import { IFeatureFlagProvider } from "./featureProvider"; import { TargetingFilter } from "./filter/TargetingFilter"; +import { Variant } from "./variant/Variant"; +import { IFeatureManager } from "./IFeatureManager"; +import { ITargetingContext } from "./common/ITargetingContext"; +import { isTargetedGroup, isTargetedPercentile, isTargetedUser } from "./common/targetingEvaluator"; -export class FeatureManager { +export class FeatureManager implements IFeatureManager { #provider: IFeatureFlagProvider; #featureFilters: Map = new Map(); @@ -30,15 +34,48 @@ export class FeatureManager { // If multiple feature flags are found, the first one takes precedence. async isEnabled(featureName: string, context?: unknown): Promise { - const featureFlag = await this.#provider.getFeatureFlag(featureName); - if (featureFlag === undefined) { - // If the feature is not found, then it is disabled. - return false; + const result = await this.#evaluateFeature(featureName, context); + return result.enabled; + } + + async getVariant(featureName: string, context?: ITargetingContext): Promise { + const result = await this.#evaluateFeature(featureName, context); + return result.variant; + } + + async #assignVariant(featureFlag: FeatureFlag, context: ITargetingContext): Promise { + // user allocation + if (featureFlag.allocation?.user !== undefined) { + for (const userAllocation of featureFlag.allocation.user) { + if (isTargetedUser(context.userId, userAllocation.users)) { + return getVariantAssignment(featureFlag, userAllocation.variant, VariantAssignmentReason.User); + } + } } - // Ensure that the feature flag is in the correct format. Feature providers should validate the feature flags, but we do it here as a safeguard. - validateFeatureFlagFormat(featureFlag); + // group allocation + if (featureFlag.allocation?.group !== undefined) { + for (const groupAllocation of featureFlag.allocation.group) { + if (isTargetedGroup(context.groups, groupAllocation.groups)) { + return getVariantAssignment(featureFlag, groupAllocation.variant, VariantAssignmentReason.Group); + } + } + } + // percentile allocation + if (featureFlag.allocation?.percentile !== undefined) { + for (const percentileAllocation of featureFlag.allocation.percentile) { + const hint = featureFlag.allocation.seed ?? `allocation\n${featureFlag.id}`; + if (isTargetedPercentile(context.userId, hint, percentileAllocation.from, percentileAllocation.to)) { + return getVariantAssignment(featureFlag, percentileAllocation.variant, VariantAssignmentReason.Percentile); + } + } + } + + return { variant: undefined, reason: VariantAssignmentReason.None }; + } + + async #isEnabled(featureFlag: FeatureFlag, context?: unknown): Promise { if (featureFlag.enabled !== true) { // If the feature is not explicitly enabled, then it is disabled by default. return false; @@ -61,7 +98,7 @@ export class FeatureManager { for (const clientFilter of clientFilters) { const matchedFeatureFilter = this.#featureFilters.get(clientFilter.name); - const contextWithFeatureName = { featureName, parameters: clientFilter.parameters }; + const contextWithFeatureName = { featureName: featureFlag.id, parameters: clientFilter.parameters }; if (matchedFeatureFilter === undefined) { console.warn(`Feature filter ${clientFilter.name} is not found.`); return false; @@ -75,14 +112,166 @@ export class FeatureManager { return !shortCircuitEvaluationResult; } + async #evaluateFeature(featureName: string, context: unknown): Promise { + const featureFlag = await this.#provider.getFeatureFlag(featureName); + const result = new EvaluationResult(featureFlag); + + if (featureFlag === undefined) { + return result; + } + + // Ensure that the feature flag is in the correct format. Feature providers should validate the feature flags, but we do it here as a safeguard. + // TODO: move to the feature flag provider implementation. + validateFeatureFlagFormat(featureFlag); + + // Evaluate if the feature is enabled. + result.enabled = await this.#isEnabled(featureFlag, context); + + // Determine Variant + let variantDef: VariantDefinition | undefined; + let reason: VariantAssignmentReason = VariantAssignmentReason.None; + + // featureFlag.variant not empty + if (featureFlag.variants !== undefined && featureFlag.variants.length > 0) { + if (!result.enabled) { + // not enabled, assign default if specified + if (featureFlag.allocation?.default_when_disabled !== undefined) { + variantDef = featureFlag.variants.find(v => v.name == featureFlag.allocation?.default_when_disabled); + reason = VariantAssignmentReason.DefaultWhenDisabled; + } else { + // no default specified + variantDef = undefined; + reason = VariantAssignmentReason.DefaultWhenDisabled; + } + } else { + // enabled, assign based on allocation + if (context !== undefined && featureFlag.allocation !== undefined) { + const variantAndReason = await this.#assignVariant(featureFlag, context as ITargetingContext); + variantDef = variantAndReason.variant; + reason = variantAndReason.reason; + } + + // allocation failed, assign default if specified + if (variantDef === undefined && reason === VariantAssignmentReason.None) { + if (featureFlag.allocation?.default_when_enabled !== undefined) { + variantDef = featureFlag.variants.find(v => v.name == featureFlag.allocation?.default_when_enabled); + reason = VariantAssignmentReason.DefaultWhenEnabled; + } else { + variantDef = undefined; + reason = VariantAssignmentReason.DefaultWhenEnabled; + } + } + } + } + + // TODO: send telemetry for variant assignment reason in the future. + console.log(`Variant assignment for feature ${featureName}: ${variantDef?.name ?? "default"} (${reason})`); + + if (variantDef?.configuration_reference !== undefined) { + console.warn("Configuration reference is not supported yet."); + } + + result.variant = variantDef !== undefined ? new Variant(variantDef.name, variantDef.configuration_value) : undefined; + result.variantAssignmentReason = reason; + + // Status override for isEnabled + if (variantDef !== undefined && featureFlag.enabled) { + if (variantDef.status_override === "Enabled") { + result.enabled = true; + } else if (variantDef.status_override === "Disabled") { + result.enabled = false; + } + } + + return result; + } } interface FeatureManagerOptions { customFilters?: IFeatureFilter[]; } +/** + * Validates the format of the feature flag definition. + * + * FeatureFlag data objects are from IFeatureFlagProvider, depending on the implementation. + * Thus the properties are not guaranteed to have the expected types. + * + * @param featureFlag The feature flag definition to validate. + */ function validateFeatureFlagFormat(featureFlag: any): void { if (featureFlag.enabled !== undefined && typeof featureFlag.enabled !== "boolean") { throw new Error(`Feature flag ${featureFlag.id} has an invalid 'enabled' value.`); } + // TODO: add more validations. + // TODO: should be moved to the feature flag provider. +} + +/** + * Try to get the variant assignment for the given variant name. If the variant is not found, override the reason with VariantAssignmentReason.None. + * + * @param featureFlag feature flag definition + * @param variantName variant name + * @param reason variant assignment reason + * @returns variant assignment containing the variant definition and the reason + */ +function getVariantAssignment(featureFlag: FeatureFlag, variantName: string, reason: VariantAssignmentReason): VariantAssignment { + const variant = featureFlag.variants?.find(v => v.name == variantName); + if (variant !== undefined) { + return { variant, reason }; + } else { + console.warn(`Variant ${variantName} not found for feature ${featureFlag.id}.`); + return { variant: undefined, reason: VariantAssignmentReason.None }; + } +} + +type VariantAssignment = { + variant: VariantDefinition | undefined; + reason: VariantAssignmentReason; +}; + +enum VariantAssignmentReason { + /** + * Variant allocation did not happen. No variant is assigned. + */ + None, + + /** + * The default variant is assigned when a feature flag is disabled. + */ + DefaultWhenDisabled, + + /** + * The default variant is assigned because of no applicable user/group/percentile allocation when a feature flag is enabled. + */ + DefaultWhenEnabled, + + /** + * The variant is assigned because of the user allocation when a feature flag is enabled. + */ + User, + + /** + * The variant is assigned because of the group allocation when a feature flag is enabled. + */ + Group, + + /** + * The variant is assigned because of the percentile allocation when a feature flag is enabled. + */ + Percentile +} + +class EvaluationResult { + constructor( + // feature flag definition + public readonly feature: FeatureFlag | undefined, + + // enabled state + public enabled: boolean = false, + + // variant assignment + public variant: Variant | undefined = undefined, + public variantAssignmentReason: VariantAssignmentReason = VariantAssignmentReason.None + ) { } } diff --git a/src/filter/TargetingFilter.ts b/src/filter/TargetingFilter.ts index 1f55901..276ebaa 100644 --- a/src/filter/TargetingFilter.ts +++ b/src/filter/TargetingFilter.ts @@ -2,7 +2,8 @@ // Licensed under the MIT license. import { IFeatureFilter } from "./FeatureFilter"; -import { createHash } from "crypto"; +import { isTargetedPercentile } from "../common/targetingEvaluator"; +import { ITargetingContext } from "../common/ITargetingContext"; type TargetingFilterParameters = { Audience: { @@ -24,15 +25,10 @@ type TargetingFilterEvaluationContext = { parameters: TargetingFilterParameters; } -type TargetingFilterAppContext = { - userId?: string; - groups?: string[]; -} - export class TargetingFilter implements IFeatureFilter { name: string = "Microsoft.Targeting"; - evaluate(context: TargetingFilterEvaluationContext, appContext?: TargetingFilterAppContext): boolean { + evaluate(context: TargetingFilterEvaluationContext, appContext?: ITargetingContext): boolean { const { featureName, parameters } = context; TargetingFilter.#validateParameters(parameters); @@ -70,9 +66,8 @@ export class TargetingFilter implements IFeatureFilter { parameters.Audience.Groups !== undefined) { for (const group of parameters.Audience.Groups) { if (appContext.groups.includes(group.Name)) { - const audienceContextId = constructAudienceContextId(featureName, appContext.userId, group.Name); - const rolloutPercentage = group.RolloutPercentage; - if (TargetingFilter.#isTargeted(audienceContextId, rolloutPercentage)) { + const hint = `${featureName}\n${group.Name}`; + if (isTargetedPercentile(appContext.userId, hint, 0, group.RolloutPercentage)) { return true; } } @@ -80,18 +75,8 @@ export class TargetingFilter implements IFeatureFilter { } // check if the user is being targeted by a default rollout percentage - const defaultContextId = constructAudienceContextId(featureName, appContext?.userId); - return TargetingFilter.#isTargeted(defaultContextId, parameters.Audience.DefaultRolloutPercentage); - } - - static #isTargeted(audienceContextId: string, rolloutPercentage: number): boolean { - if (rolloutPercentage === 100) { - return true; - } - // Cryptographic hashing algorithms ensure adequate entropy across hash values. - const contextMarker = stringToUint32(audienceContextId); - const contextPercentage = (contextMarker / 0xFFFFFFFF) * 100; - return contextPercentage < rolloutPercentage; + const hint = featureName; + return isTargetedPercentile(appContext?.userId, hint, 0, parameters.Audience.DefaultRolloutPercentage); } static #validateParameters(parameters: TargetingFilterParameters): void { @@ -108,36 +93,3 @@ export class TargetingFilter implements IFeatureFilter { } } } - -/** - * Constructs the context id for the audience. - * The context id is used to determine if the user is part of the audience for a feature. - * If groupName is provided, the context id is constructed as follows: - * userId + "\n" + featureName + "\n" + groupName - * Otherwise, the context id is constructed as follows: - * userId + "\n" + featureName - * - * @param featureName name of the feature - * @param userId userId from app context - * @param groupName group name from app context - * @returns a string that represents the context id for the audience - */ -function constructAudienceContextId(featureName: string, userId: string | undefined, groupName?: string) { - let contextId = `${userId ?? ""}\n${featureName}`; - if (groupName !== undefined) { - contextId += `\n${groupName}`; - } - return contextId -} - -function stringToUint32(str: string): number { - // Create a SHA-256 hash of the string - const hash = createHash("sha256").update(str).digest(); - - // Get the first 4 bytes of the hash - const first4Bytes = hash.subarray(0, 4); - - // Convert the 4 bytes to a uint32 with little-endian encoding - const uint32 = first4Bytes.readUInt32LE(0); - return uint32; -} diff --git a/src/model.ts b/src/model.ts index fc6fa19..b707a5a 100644 --- a/src/model.ts +++ b/src/model.ts @@ -31,7 +31,7 @@ export interface FeatureFlag { /** * The list of variants defined for this feature. A variant represents a configuration value of a feature flag that can be a string, a number, a boolean, or a JSON object. */ - variants?: Variant[]; + variants?: VariantDefinition[]; /** * Determines how variants should be allocated for the feature to various users. */ @@ -69,7 +69,7 @@ interface ClientFilter { parameters?: Record; } -interface Variant { +export interface VariantDefinition { /** * The name used to refer to a feature variant. */ diff --git a/src/variant/Variant.ts b/src/variant/Variant.ts new file mode 100644 index 0000000..2daf288 --- /dev/null +++ b/src/variant/Variant.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export class Variant { + constructor( + public name: string, + public configuration: unknown + ){} +} diff --git a/test/sampleFeatureFlags.ts b/test/sampleFeatureFlags.ts new file mode 100644 index 0000000..f99ace0 --- /dev/null +++ b/test/sampleFeatureFlags.ts @@ -0,0 +1,536 @@ +export enum Features { + VariantFeatureDefaultDisabled = "VariantFeatureDefaultDisabled", + VariantFeatureDefaultEnabled = "VariantFeatureDefaultEnabled", + VariantFeaturePercentileOn = "VariantFeaturePercentileOn", + VariantFeaturePercentileOff = "VariantFeaturePercentileOff", + VariantFeatureAlwaysOff = "VariantFeatureAlwaysOff", + VariantFeatureUser = "VariantFeatureUser", + VariantFeatureGroup = "VariantFeatureGroup", + VariantFeatureNoVariants = "VariantFeatureNoVariants", + VariantFeatureNoAllocation = "VariantFeatureNoAllocation", + VariantFeatureAlwaysOffNoAllocation = "VariantFeatureAlwaysOffNoAllocation", + VariantFeatureBothConfigurations = "VariantFeatureBothConfigurations", + VariantFeatureInvalidStatusOverride = "VariantFeatureInvalidStatusOverride", + VariantFeatureInvalidFromTo = "VariantFeatureInvalidFromTo", + VariantImplementationFeature = "VariantImplementationFeature", +} + +export const featureFlagsConfigurationObject = { + "feature_management": { + "feature_flags": [ + { + "id": "OnTestFeature", + "enabled": true + }, + { + "id": "OffTestFeature", + "enabled": false + }, + { + "id": "ConditionalFeature", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Test", + "parameters": { + "P1": "V1" + } + } + ] + } + }, + { + "id": "ContextualFeature", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "ContextualTest", + "parameters": { + "AllowedAccounts": [ + "abc" + ] + } + } + ] + } + }, + { + "id": "AnyFilterFeature", + "enabled": true, + "conditions": { + "requirement_type": "Any", + "client_filters": [ + { + "name": "Test", + "parameters": { + "Id": "1" + } + }, + { + "name": "Test", + "parameters": { + "Id": "2" + } + } + ] + } + }, + { + "id": "AllFilterFeature", + "enabled": true, + "conditions": { + "requirement_type": "All", + "client_filters": [ + { + "name": "Test", + "parameters": { + "Id": "1" + } + }, + { + "name": "Test", + "parameters": { + "Id": "2" + } + } + ] + } + }, + { + "id": "FeatureUsesFiltersWithDuplicatedAlias", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "DuplicatedFilterName" + }, + { + "name": "Percentage", + "parameters": { + "Value": 100 + } + } + ] + } + }, + { + "id": "TargetingTestFeature", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Targeting", + "parameters": { + "Audience": { + "Users": [ + "Jeff", + "Alicia" + ], + "Groups": [ + { + "Name": "Ring0", + "RolloutPercentage": 100 + }, + { + "Name": "Ring1", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 20 + } + } + } + ] + } + }, + { + "id": "TargetingTestFeatureWithExclusion", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Targeting", + "parameters": { + "Audience": { + "Users": [ + "Jeff", + "Alicia" + ], + "Groups": [ + { + "Name": "Ring0", + "RolloutPercentage": 100 + }, + { + "Name": "Ring1", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 20, + "Exclusion": { + "Users": [ + "Jeff" + ], + "Groups": [ + "Ring0", + "Ring2" + ] + } + } + } + } + ] + } + }, + { + "id": "CustomFilterFeature", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "CustomTargetingFilter", + "parameters": { + "Audience": { + "Users": [ + "Jeff" + ] + } + } + } + ] + } + }, + { + "id": "VariantFeaturePercentileOn", + "enabled": true, + "variants": [ + { + "name": "Big", + "configuration_reference": "ShoppingCart:Big", + "status_override": "Disabled" + } + ], + "allocation": { + "percentile": [ + { + "variant": "Big", + "from": 0, + "to": 50 + } + ], + "seed": 1234 + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeaturePercentileOff", + "enabled": true, + "variants": [ + { + "name": "Big", + "configuration_reference": "ShoppingCart:Big" + } + ], + "allocation": { + "percentile": [ + { + "variant": "Big", + "from": 0, + "to": 50 + } + ], + "seed": 12345 + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureAlwaysOff", + "enabled": false, + "variants": [ + { + "name": "Big", + "configuration_reference": "ShoppingCart:Big" + } + ], + "allocation": { + "percentile": [ + { + "variant": "Big", + "from": 0, + "to": 100 + } + ], + "seed": 12345 + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureDefaultDisabled", + "enabled": false, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "default_when_disabled": "Small" + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureDefaultEnabled", + "enabled": true, + "variants": [ + { + "name": "Medium", + "configuration_value": { + "Size": "450px", + "Color": "Purple" + } + }, + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "default_when_enabled": "Medium", + "user": [ + { + "variant": "Small", + "users": [ + "Jeff" + ] + } + ] + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureUser", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "user": [ + { + "variant": "Small", + "users": [ + "Marsha" + ] + } + ] + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureGroup", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "group": [ + { + "variant": "Small", + "groups": [ + "Group1" + ] + } + ] + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureNoVariants", + "enabled": true, + "variants": [], + "allocation": { + "user": [ + { + "variant": "Small", + "users": [ + "Marsha" + ] + } + ] + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureNoAllocation", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureAlwaysOffNoAllocation", + "enabled": false, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureBothConfigurations", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "600px", + "configuration_reference": "ShoppingCart:Small" + } + ], + "allocation": { + "default_when_enabled": "Small" + } + }, + { + "id": "VariantFeatureInvalidStatusOverride", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px", + "status_override": "InvalidValue" + } + ], + "allocation": { + "default_when_enabled": "Small" + } + }, + { + "id": "VariantFeatureInvalidFromTo", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "percentile": [ + { + "variant": "Small", + "from": "Invalid", + "to": "Invalid" + } + ] + } + }, + { + "id": "VariantImplementationFeature", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Targeting", + "parameters": { + "Audience": { + "Users": [ + "UserOmega", + "UserSigma", + "UserBeta" + ] + } + } + } + ] + }, + "variants": [ + { + "name": "AlgorithmBeta" + }, + { + "name": "Sigma", + "configuration_value": "AlgorithmSigma" + }, + { + "name": "Omega" + } + ], + "allocation": { + "user": [ + { + "variant": "AlgorithmBeta", + "users": [ + "UserBeta" + ] + }, + { + "variant": "Omega", + "users": [ + "UserOmega" + ] + }, + { + "variant": "Sigma", + "users": [ + "UserSigma" + ] + } + ] + } + }, + { + "id": "OnTelemetryTestFeature", + "enabled": true, + "telemetry": { + "enabled": true, + "metadata": { + "Tags.Tag1": "Tag1Value", + "Tags.Tag2": "Tag2Value", + "Etag": "EtagValue", + "Label": "LabelValue" + } + } + }, + { + "id": "OffTelemetryTestFeature", + "enabled": false, + "telemetry": { + "enabled": true + } + } + ] + } +}; + diff --git a/test/variant.test.ts b/test/variant.test.ts new file mode 100644 index 0000000..758d741 --- /dev/null +++ b/test/variant.test.ts @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; +chai.use(chaiAsPromised); +const expect = chai.expect; + +import { FeatureManager, ConfigurationObjectFeatureFlagProvider } from "./exportedApi"; +import { Features, featureFlagsConfigurationObject } from "./sampleFeatureFlags"; + + +describe("feature variant", () => { + + let featureManager: FeatureManager; + + before(() => { + const provider = new ConfigurationObjectFeatureFlagProvider(featureFlagsConfigurationObject); + featureManager = new FeatureManager(provider); + }); + + describe("valid scenarios", () => { + const context = { userId: "Marsha", groups: ["Group1"] }; + + it("default allocation with disabled feature", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureDefaultDisabled, context); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Small"); + expect(variant?.configuration).eq("300px"); + }); + + it("default allocation with enabled feature", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureDefaultEnabled, context); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Medium"); + expect(variant?.configuration).deep.eq({ Size: "450px", Color: "Purple" }); + }); + + + it("user allocation", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureUser, context); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Small"); + expect(variant?.configuration).eq("300px"); + }); + + it("group allocation", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureGroup, context); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Small"); + expect(variant?.configuration).eq("300px"); + }); + + it("percentile allocation with seed", async () => { + const variant = await featureManager.getVariant(Features.VariantFeaturePercentileOn, context); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Big"); + + const variant2 = await featureManager.getVariant(Features.VariantFeaturePercentileOff, context); + expect(variant2).to.be.undefined; + }); + + it("overwrite enabled status", async () => { + const enabledStatus = await featureManager.isEnabled(Features.VariantFeaturePercentileOn, context); + expect(enabledStatus).to.be.false; // featureFlag.enabled = true, overridden to false by variant `Big`. + }); + + }); + + describe("invalid scenarios", () => { + const context = { userId: "Jeff" }; + + it("return undefined when no variants are specified", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureNoVariants, context); + expect(variant).to.be.undefined; + }); + + it("return undefined when no allocation is specified", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureNoAllocation, context); + expect(variant).to.be.undefined; + }); + + it("only support configuration value", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureBothConfigurations, context); + expect(variant).not.to.be.undefined; + expect(variant?.configuration).eq("600px"); + }); + + // requires IFeatureFlagProvider to throw an exception on validation + it("throw exception for invalid StatusOverride value"); + + // requires IFeatureFlagProvider to throw an exception on validation + it("throw exception for invalid doubles From and To in the Percentile section"); + + }); + +});