diff --git a/lib/flags/flags.d.ts b/lib/flags/flags.d.ts new file mode 100644 index 0000000..8302667 --- /dev/null +++ b/lib/flags/flags.d.ts @@ -0,0 +1,50 @@ +/** + * TypeScript type definitions for Base Feature Flags Provider + */ + +import { CustomLogger } from '../mixpanel-node'; +import { SelectedVariant, FlagContext } from './types'; + +/** + * Configuration for feature flags API requests + */ +export interface FeatureFlagsConfig { + token: string; + api_host: string; + request_timeout_in_seconds: number; +} + +/** + * Base Feature Flags Provider + * Contains common methods for feature flag evaluation + */ +export class FeatureFlagsProvider { + providerConfig: FeatureFlagsConfig; + endpoint: string; + logger: CustomLogger | null; + + /** + * @param config - Common configuration for feature flag providers + * @param endpoint - API endpoint path (i.e., '/flags' or '/flags/definitions') + * @param logger - Logger instance + */ + constructor(config: FeatureFlagsConfig, endpoint: string, logger: CustomLogger | null); + + /** + * Common HTTP request handler for flags API endpoints + * @param additionalParams - Additional query parameters to append + * @returns Parsed JSON response + */ + callFlagsEndpoint(additionalParams?: Record | null): Promise; + + /** + * Manually tracks a feature flag exposure event to Mixpanel + * This provides flexibility for reporting individual exposure events when using getAllVariants + * If using getVariantValue or getVariant, exposure events are tracked automatically by default. + * @param {string} flagKey - The key of the feature flag + * @param {SelectedVariant} variant - The selected variant for the feature flag + * @param {FlagContext} context - The user context used to evaluate the feature flag + * @param {number|null} latencyMs - Optionally included latency in milliseconds that assignment took. + */ + trackExposureEvent(flagKey: string, variant: SelectedVariant, context: FlagContext, latencyMs?: number | null): void; +} diff --git a/lib/flags/flags.js b/lib/flags/flags.js new file mode 100644 index 0000000..3cb1a62 --- /dev/null +++ b/lib/flags/flags.js @@ -0,0 +1,147 @@ +/** + * Base Feature Flags Provider + * Contains common methods for feature flag evaluation + */ + +const https = require('https'); +const packageInfo = require('../../package.json'); +const { prepareCommonQueryParams, generateTraceparent, EXPOSURE_EVENT, REQUEST_HEADERS } = require('./utils'); + +/** + * @typedef {import('./types').SelectedVariant} SelectedVariant + * @typedef {import('./types').FlagContext} FlagContext + */ +class FeatureFlagsProvider { + /** + * @param {Object} providerConfig - Configuration object with token, api_host, request_timeout_in_seconds + * @param {string} endpoint - API endpoint path (e.g., '/flags' or '/flags/definitions') + * @param {Function} tracker - Function to track events (signature: track(distinct_id, event, properties, callback)) + * @param {string} evaluationMode - The feature flag evaluation mode + * @param {CustomLogger} logger - Logger instance + */ + constructor(providerConfig, endpoint, tracker, evaluationMode, logger) { + this.providerConfig = providerConfig; + this.endpoint = endpoint; + this.tracker = tracker; + this.evaluationMode = evaluationMode; + this.logger = logger; + } + + /** + * Common HTTP request handler for flags API endpoints + * @param {Object} additionalParams - Additional query parameters to append + * @returns {Promise} - Parsed JSON response + */ + async callFlagsEndpoint(additionalParams = null) { + return new Promise((resolve, reject) => { + const commonParams = prepareCommonQueryParams(this.providerConfig.token, packageInfo.version); + const params = new URLSearchParams(commonParams); + + if (additionalParams) { + for (const [key, value] of Object.entries(additionalParams)) { + params.append(key, value); + } + } + + const path = `${this.endpoint}?${params.toString()}`; + + const requestOptions = { + host: this.providerConfig.api_host, + port: 443, + path: path, + method: 'GET', + headers: { + ...REQUEST_HEADERS, + 'Authorization': 'Basic ' + Buffer.from(this.providerConfig.token + ':').toString('base64'), + 'traceparent': generateTraceparent(), + }, + timeout: this.providerConfig.request_timeout_in_seconds * 1000, + }; + + const request = https.request(requestOptions, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode !== 200) { + this.logger?.error(`HTTP ${res.statusCode} error calling flags endpoint: ${data}`); + return reject(new Error(`HTTP ${res.statusCode}: ${data}`)); + } + + try { + const result = JSON.parse(data); + resolve(result); + } catch (parseErr) { + this.logger?.error(`Failed to parse JSON response: ${parseErr.message}`); + reject(parseErr); + } + }); + }); + + request.on('error', (err) => { + this.logger?.error(`Network error calling flags endpoint: ${err.message}`); + reject(err); + }); + + request.on('timeout', () => { + this.logger?.error(`Request timeout calling flags endpoint`); + request.destroy(); + reject(new Error('Request timeout')); + }); + + request.end(); + }); + } + + /** + * Manually tracks a feature flag exposure event to Mixpanel + * This provides flexibility for reporting individual exposure events when using getAllVariants + * If using getVariantValue or getVariant, exposure events are tracked automatically by default. + * @param {string} flagKey - The key of the feature flag + * @param {SelectedVariant} variant - The selected variant for the feature flag + * @param {FlagContext} context - The user context used to evaluate the feature flag + * @param {number|null} latencyMs - Optionally included latency in milliseconds that assignment took. + */ + trackExposureEvent(flagKey, selectedVariant, context, latencyMs=null) { + if (!context.distinct_id) { + this.logger?.error('Cannot track exposure event without a distinct_id in the context'); + return; + } + + const properties = { + 'distinct_id': context.distinct_id, + 'Experiment name': flagKey, + 'Variant name': selectedVariant.variant_key, + '$experiment_type': 'feature_flag', + 'Flag evaluation mode': this.evaluationMode + }; + + if (latencyMs !== null && latencyMs !== undefined) { + properties['Variant fetch latency (ms)'] = latencyMs; + } + + if (selectedVariant.experiment_id !== undefined) { + properties['$experiment_id'] = selectedVariant.experiment_id; + } + + if (selectedVariant.is_experiment_active !== undefined) { + properties['$is_experiment_active'] = selectedVariant.is_experiment_active; + } + + if (selectedVariant.is_qa_tester !== undefined) { + properties['$is_qa_tester'] = selectedVariant.is_qa_tester; + } + + // Use the tracker function provided (bound to the main mixpanel instance) + this.tracker(EXPOSURE_EVENT, properties, (err) => { + if (err) { + this.logger?.error(`[flags]Failed to track exposure event for flag '${flagKey}': ${err.message}`); + } + }); + } +} + +module.exports = FeatureFlagsProvider; diff --git a/lib/flags/index.js b/lib/flags/index.js new file mode 100644 index 0000000..f6dfd3a --- /dev/null +++ b/lib/flags/index.js @@ -0,0 +1,12 @@ +/** + * Mixpanel Feature Flags + * Exports for local and remote feature flag evaluation + */ + +const LocalFeatureFlagsProvider = require('./local_flags'); +const RemoteFeatureFlagsProvider = require('./remote_flags'); + +module.exports = { + LocalFeatureFlagsProvider, + RemoteFeatureFlagsProvider, +}; diff --git a/lib/flags/local_flags.d.ts b/lib/flags/local_flags.d.ts new file mode 100644 index 0000000..728e45a --- /dev/null +++ b/lib/flags/local_flags.d.ts @@ -0,0 +1,80 @@ +/** + * TypeScript definitions for Local Feature Flags Provider + */ + +import { LocalFlagsConfig, FlagContext, SelectedVariant } from './types'; +import { CustomLogger } from '../mixpanel-node'; + +/** + * Local Feature Flags Provider + * Evaluates feature flags client-side using locally cached definitions + */ +export default class LocalFeatureFlagsProvider { + constructor( + token: string, + config: LocalFlagsConfig, + tracker: (distinct_id: string, event: string, properties: object, callback: (err?: Error) => void) => void, + logger: CustomLogger + ); + + /** + * Start polling for flag definitions + * Fetches immediately and then at regular intervals if polling is enabled + */ + startPollingForDefinitions(): Promise; + + /** + * Stop polling for flag definitions + */ + stopPollingForDefinitions(): void; + + /** + * Get the variant value for a feature flag + * @param flagKey - Feature flag key + * @param fallbackValue - Value to return if flag evaluation fails + * @param context - Evaluation context (must include distinct_id) + * @param reportExposure - Whether to track exposure event (default: true) + */ + getVariantValue( + flagKey: string, + fallbackValue: T, + context: FlagContext, + reportExposure?: boolean + ): T; + + /** + * Get the complete variant information for a feature flag + * @param flagKey - Feature flag key + * @param fallbackVariant - Variant to return if flag evaluation fails + * @param context - Evaluation context (must include distinct_id) + * @param reportExposure - Whether to track exposure event (default: true) + */ + getVariant( + flagKey: string, + fallbackVariant: SelectedVariant, + context: FlagContext, + reportExposure?: boolean + ): SelectedVariant; + + /** + * Check if a feature flag is enabled + * This method is intended only for flags defined as Mixpanel Feature Gates (boolean flags) + * This checks that the variant value of a selected variant is concretely the boolean 'true' + * It does not coerce other truthy values. + * @param flagKey - Feature flag key + * @param context - Evaluation context (must include distinct_id) + */ + isEnabled( + flagKey: string, + context: FlagContext + ): boolean; + + /** + * Get all feature flag variants for the current user context + * Exposure events are not automatically tracked when this method is used + * @param context - Evaluation context (must include distinct_id) + */ + getAllVariants( + context: FlagContext + ): {[key: string]: SelectedVariant}; +} diff --git a/lib/flags/local_flags.js b/lib/flags/local_flags.js new file mode 100644 index 0000000..1b35788 --- /dev/null +++ b/lib/flags/local_flags.js @@ -0,0 +1,319 @@ +/** + * Local Feature Flags Provider + * Evaluates feature flags client-side using locally cached definitions + */ + +/** + * @typedef {import('./types').SelectedVariant} SelectedVariant + * @typedef {import('./types').FlagContext} FlagContext + * @typedef {import('./types').LocalFlagsConfig} LocalFlagsConfig + * @typedef {import('./types').ExperimentationFlag} ExperimentationFlag + * @typedef {import('./types').LocalFlagsResponse} LocalFlagsResponse + * */ + +const FeatureFlagsProvider = require('./flags'); +const { normalizedHash } = require('./utils'); + +class LocalFeatureFlagsProvider extends FeatureFlagsProvider { + /** + * @param {string} token - Mixpanel project token + * @param {LocalFlagsConfig} config - Local flags configuration + * @param {Function} tracker - Function to track events (signature: track(distinct_id, event, properties, callback)) + * @param {CustomLogger} logger - Logger + */ + constructor(token, config, tracker, logger) { + const mergedConfig = { + api_host: 'api.mixpanel.com', + request_timeout_in_seconds: 10, + enable_polling: true, + polling_interval_in_seconds: 60, + ...config, + }; + + const providerConfig = { + token: token, + api_host: mergedConfig.api_host, + request_timeout_in_seconds: mergedConfig.request_timeout_in_seconds, + }; + + super(providerConfig, '/flags/definitions', tracker, 'local', logger); + + this.config = mergedConfig; + this.flagDefinitions = new Map(); + this.pollingInterval = null; + } + + /** + * Start polling for flag definitions. + * Fetches immediately and then at regular intervals if polling is enabled + * @returns {Promise} + */ + async startPollingForDefinitions() { + try { + await this._fetchFlagDefinitions(); + + if (this.config.enable_polling && !this.pollingInterval) { + this.pollingInterval = setInterval(async () => { + try { + await this._fetchFlagDefinitions(); + } catch (err) { + this.logger?.error(`Error polling for flag definition: ${err.message}`); + } + }, this.config.polling_interval_in_seconds * 1000); + } + } catch (err) { + this.logger?.error(`Initial flag definitions fetch failed: ${err.message}`); + } + } + + /** + * Stop polling for flag definitions + */ + stopPollingForDefinitions() { + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + this.pollingInterval = null; + } else { + this.logger?.warn('stopPollingForDefinitions called but polling was not active'); + } + } + + /** + * Check if a feature flag is enabled + * This method is intended only for flags defined as Mixpanel Feature Gates (boolean flags) + * This checks that the variant value of a selected variant is concretely the boolean 'true' + * It does not coerce other truthy values. + * @param {string} flagKey - Feature flag key + * @param {FlagContext} context - Evaluation context (must include distinct_id) + * @returns {boolean} + */ + isEnabled(flagKey, context) { + const value = this.getVariantValue(flagKey, false, context); + return value === true; + } + + /** + * Get the variant value for a feature flag + * @param {string} flagKey - Feature flag key + * @param {*} fallbackValue - Value to return if the user context is not in the rollout for a flag or if evaluation fails + * @param {FlagContext} context - Evaluation context + * @param {boolean} [reportExposure=true] - Whether to track exposure event + * @returns {*} The variant value + */ + getVariantValue(flagKey, fallbackValue, context, reportExposure = true) { + const result = this.getVariant(flagKey, { variant_value: fallbackValue }, context, reportExposure); + return result.variant_value; + } + + /** + * Get the complete variant information for a feature flag + * @param {string} flagKey - Feature flag key + * @param {SelectedVariant} fallbackVariant - Variant to return if flag evaluation fails + * @param {FlagContext} context - Evaluation context (must include distinct_id) + * @param {boolean} [reportExposure=true] - Whether to track exposure event + * @returns {SelectedVariant} + */ + getVariant(flagKey, fallbackVariant, context, reportExposure = true) { + const flag = this.flagDefinitions.get(flagKey); + + if (!flag) { + this.logger?.warn(`Cannot find flag definition for key: '${flagKey}`); + return fallbackVariant; + } + + if (!Object.hasOwn(context, flag.context)) { + this.logger?.warn( + `The variant assignment key, '${flag.context}' for flag, '${flagKey}' is not present in the supplied user context dictionary` + ); + return fallbackVariant; + } + + const contextValue = context[flag.context]; + + let selectedVariant = null; + + const testUserVariant = this._getVariantOverrideForTestUser(flag, context); + if (testUserVariant) { + selectedVariant = testUserVariant; + } else { + const rollout = this._getAssignedRollout(flag, contextValue, context); + if (rollout) { + selectedVariant = this._getAssignedVariant(flag, contextValue, flagKey, rollout); + } + } + + if (selectedVariant) { + if (reportExposure) { + this.trackExposureEvent(flagKey, selectedVariant, context); + } + return selectedVariant; + } + + return fallbackVariant; + } + + /** + * Get all feature flag variants for the current user context + * Exposure events are not automatically tracked when this method is used + * @param {FlagContext} context - Evaluation context (must include distinct_id) + * @returns {{[key: string]: SelectedVariant}} + */ + getAllVariants(context) { + const variants = {}; + + for (const flagKey of this.flagDefinitions.keys()) { + const variant = this.getVariant(flagKey, null, context, false); + if (variant !== null) { + variants[flagKey] = variant; + } + } + + return variants; + } + + /** + * Fetch flag definitions from API. + * @returns {Promise} + */ + async _fetchFlagDefinitions() { + const response = await this.callFlagsEndpoint(); + + const newDefinitions = new Map(); + response.flags.forEach(flag => { + newDefinitions.set(flag.key, flag); + }); + + this.flagDefinitions = newDefinitions; + + return response; + } + + /** + * Find a variant by key (case-insensitive) and return complete SelectedVariant + * @param {string} variantKey - Variant key to find + * @param {ExperimentationFlag} flag - Flag definition + * @returns {SelectedVariant|null} + */ + _getMatchingVariant(variantKey, flag) { + for (const variant of flag.ruleset.variants) { + if (variantKey.toLowerCase() === variant.key.toLowerCase()) { + return { + variant_key: variant.key, + variant_value: variant.value, + experiment_id: flag.experiment_id, + is_experiment_active: flag.is_experiment_active, + }; + } + } + return null; + } + + _getVariantOverrideForTestUser(flag, context) { + if (!flag.ruleset.test?.users) { + return null; + } + + const distinctId = context.distinct_id; + if (!distinctId) { + return null; + } + + const variantKey = flag.ruleset.test.users[distinctId]; + if (!variantKey) { + return null; + } + + let selected_variant = this._getMatchingVariant(variantKey, flag); + if (selected_variant) { + selected_variant.is_qa_tester = true; + } + return selected_variant; + } + + _getAssignedRollout(flag, contextValue, context) { + for (let index = 0; index < flag.ruleset.rollout.length; index++) { + const rollout = flag.ruleset.rollout[index]; + + let salt; + if (flag.hash_salt !== null && flag.hash_salt !== undefined) { + salt = flag.key + flag.hash_salt + index.toString(); + } else { + salt = flag.key + "rollout"; + } + + const rolloutHash = normalizedHash(String(contextValue), salt); + + if (rolloutHash < rollout.rollout_percentage && + this._isRuntimeEvaluationSatisfied(rollout, context)) { + return rollout; + } + } + + return null; + } + + _getAssignedVariant(flag, contextValue, flagKey, rollout) { + if (rollout.variant_override) { + const variant = this._getMatchingVariant(rollout.variant_override.key, flag); + if (variant) { + return { ...variant, is_qa_tester: false }; + } + } + + const storedSalt = flag.hash_salt !== null && flag.hash_salt !== undefined ? flag.hash_salt : ""; + const salt = flagKey + storedSalt + "variant"; + const variantHash = normalizedHash(String(contextValue), salt); + + const variants = flag.ruleset.variants.map(v => ({ ...v })); + if (rollout.variant_splits) { + for (const variant of variants) { + if (variant.key in rollout.variant_splits) { + variant.split = rollout.variant_splits[variant.key]; + } + } + } + + let selected = variants[0]; + let cumulative = 0.0; + for (const variant of variants) { + selected = variant; + cumulative += variant.split || 0.0; + if (variantHash < cumulative) { + break; + } + } + + return { + variant_key: selected.key, + variant_value: selected.value, + experiment_id: flag.experiment_id, + is_experiment_active: flag.is_experiment_active, + is_qa_tester: false, + }; + } + + _isRuntimeEvaluationSatisfied(rollout, context) { + if (!rollout.runtime_evaluation_definition) { + return true; + } + + const customProperties = context.custom_properties; + if (!customProperties || typeof customProperties !== 'object') { + return false; + } + + for (const [key, expectedValue] of Object.entries(rollout.runtime_evaluation_definition)) { + if (!(key in customProperties)) { + return false; + } + + const actualValue = customProperties[key]; + if (String(actualValue).toLowerCase() !== String(expectedValue).toLowerCase()) { + return false; + } + } + return true; + } +} + +module.exports = LocalFeatureFlagsProvider; diff --git a/lib/flags/remote_flags.d.ts b/lib/flags/remote_flags.d.ts new file mode 100644 index 0000000..5652736 --- /dev/null +++ b/lib/flags/remote_flags.d.ts @@ -0,0 +1,72 @@ +/** + * TypeScript definitions for Remote Feature Flags Provider + */ + +import { CustomLogger } from '../mixpanel-node' +import { RemoteFlagsConfig, FlagContext, SelectedVariant } from './types'; + +/** + * Remote Feature Flags Provider + * Evaluates feature flags via server-side API requests + */ +export default class RemoteFeatureFlagsProvider { + constructor( + token: string, + config: RemoteFlagsConfig, + logger: CustomLogger, + tracker: (distinct_id: string, event: string, properties: object, callback: (err?: Error) => void) => void + ); + + /** + * Get the variant value for a feature flag + * @param flagKey - Feature flag key + * @param fallbackValue - Value to return if flag evaluation fails + * @param context - Evaluation context (must include distinct_id) + * @param reportExposure - Whether to track exposure event (default: true) + * @returns Promise resolving to variant value + */ + getVariantValue( + flagKey: string, + fallbackValue: T, + context: FlagContext, + reportExposure?: boolean + ): Promise; + + /** + * Get the complete variant information for a feature flag + * @param flagKey - Feature flag key + * @param fallbackVariant - Variant to return if flag evaluation fails + * @param context - Evaluation context (must include distinct_id) + * @param reportExposure - Whether to track exposure event (default: true) + * @returns Promise resolving to selected variant + */ + getVariant( + flagKey: string, + fallbackVariant: SelectedVariant, + context: FlagContext, + reportExposure?: boolean + ): Promise; + + /** + * Check if a feature flag is enabled. + * This checks that the variant value of a selected variant is concretely the boolean 'true', which will be the case for flags setup as FeatureGates + * It does not coerce other truthy values. + * @param flagKey - Feature flag key + * @param context - Evaluation context (must include distinct_id) + * @returns Promise resolving to whether the flag is enabled + */ + isEnabled( + flagKey: string, + context: FlagContext + ): Promise; + + /** + * Get all feature flag variants for the current user context from remote server + * Exposure events are not automatically tracked when this method is used + * @param context - Evaluation context (must include distinct_id) + * @returns Promise resolving to dictionary mapping flag keys to variants, or null if the call fails + */ + getAllVariants( + context: FlagContext + ): Promise<{[key: string]: SelectedVariant} | null>; +} diff --git a/lib/flags/remote_flags.js b/lib/flags/remote_flags.js new file mode 100644 index 0000000..d4c3e36 --- /dev/null +++ b/lib/flags/remote_flags.js @@ -0,0 +1,144 @@ +/** + * Remote Feature Flags Provider + * Evaluates feature flags via server-side API requests + */ + +/** + * @typedef {import('./types').SelectedVariant} SelectedVariant + * @typedef {import('./types').FlagContext} FlagContext + * @typedef {import('./types').RemoteFlagsConfig} RemoteFlagsConfig + * @typedef {import('./types').RemoteFlagsResponse} RemoteFlagsResponse + */ + +const FeatureFlagsProvider = require('./flags'); + +class RemoteFeatureFlagsProvider extends FeatureFlagsProvider { + /** + * @param {string} token - Mixpanel project token + * @param {RemoteFlagsConfig} config - Remote flags configuration + * @param {Function} tracker - Function to track events (signature: track(distinct_id, event, properties, callback)) + * @param {CustomLogger} logger - Logger instance + */ + constructor(token, config, tracker, logger) { + const mergedConfig = { + api_host: 'api.mixpanel.com', + request_timeout_in_seconds: 10, + ...config, + }; + + const providerConfig = { + token: token, + api_host: mergedConfig.api_host, + request_timeout_in_seconds: mergedConfig.request_timeout_in_seconds, + }; + + super(providerConfig, '/flags', tracker, 'remote', logger); + } + + /** + * Get the variant value for a feature flag + * If the user context is eligible for the rollout, one of the flag variants will be selected and an exposure event will be tracked to Mixpanel. + * If the user context is not eligible, the fallback value is returned. + * @param {string} flagKey - Feature flag key + * @param {*} fallbackValue - Value to return if flag evaluation fails + * @param {FlagContext} context - Evaluation context + * @param {boolean} reportExposure - Whether to track exposure event + * @returns {Promise<*>} - Variant value + */ + async getVariantValue(flagKey, fallbackValue, context, reportExposure = true) { + try { + const selectedVariant = await this.getVariant(flagKey, { variant_value: fallbackValue }, context, reportExposure); + return selectedVariant.variant_value; + } catch (err) { + this.logger?.error(`Failed to get variant value for flag '${flagKey}': ${err.message}`); + return fallbackValue; + } + } + + /** + * Get the complete variant information for a feature flag + * If the user context is eligible for the rollout, one of the flag variants will be selected and an exposure event will be tracked to Mixpanel. + * If the user context is not eligible, the fallback value is returned. + * @param {string} flagKey - Feature flag key + * @param {SelectedVariant} fallbackVariant - Variant to return if flag evaluation fails + * @param {FlagContext} context - Evaluation context + * @param {boolean} reportExposure - Whether to track exposure event in the event that the user context is eligible for the rollout. + * @returns {Promise} - Selected variant + */ + async getVariant(flagKey, fallbackVariant, context, reportExposure = true) { + try { + const startTime = Date.now(); + const response = await this._fetchFlags(context, flagKey); + const latencyMs = Date.now() - startTime; + + const flags = response.flags || {}; + const selectedVariant = flags[flagKey]; + if (!selectedVariant) { + return fallbackVariant; + } + + if (reportExposure) { + this.trackExposureEvent(flagKey, selectedVariant, context, latencyMs); + } + + return selectedVariant; + } catch (err) { + this.logger?.error(`Failed to get variant for flag '${flagKey}': ${err.message}`); + return fallbackVariant; + } + } + + /** + * This method is intended only for flags defined as Mixpanel Feature Gates (boolean flags) + * This checks that the variant value of a selected variant is concretely the boolean 'true' + * It does not coerce other truthy values. + * @param {string} flagKey - Feature flag key + * @param {FlagContext} context - User's evaluation context + * @returns {Promise} - Whether the flag is enabled + */ + async isEnabled(flagKey, context) { + try { + const value = await this.getVariantValue(flagKey, false, context); + return value === true; + } catch (err) { + this.logger?.error(`Failed to check if flag '${flagKey}' is enabled: ${err.message}`); + return false; + } + } + + /** + * Get all feature flag variants for the current user context from remote server + * Exposure events are not automatically tracked when this method is used + * @param {FlagContext} context - User's evaluation context + * @returns {Promise<{[key: string]: SelectedVariant}|null>} - Dictionary mapping flag keys to variants, or null if the call fails + */ + async getAllVariants(context) { + try { + const response = await this._fetchFlags(context); + return response.flags || {}; + } catch (err) { + this.logger?.error(`Failed to get all remote variants: ${err.message}`); + return null; + } + } + + /** + * Fetch flags from remote flags evaluation API + * @param {FlagContext} context - Evaluation context + * @param {string} [flagKey] - Optional flag key (if omitted, fetches all flags) + * @returns {Promise} - API response containing flags dictionary + */ + _fetchFlags(context, flagKey = null) { + const additionalParams = { + context: JSON.stringify(context), + }; + + if (flagKey !== null) { + additionalParams.flag_key = flagKey; + } + + return this.callFlagsEndpoint(additionalParams); + } +} + +module.exports = RemoteFeatureFlagsProvider; diff --git a/lib/flags/types.d.ts b/lib/flags/types.d.ts new file mode 100644 index 0000000..0e0b549 --- /dev/null +++ b/lib/flags/types.d.ts @@ -0,0 +1,132 @@ +/** + * TypeScript type definitions for Mixpanel feature flags + */ + +/** + * Base configuration for feature flags + */ +export interface FlagsConfig { + /** API host for Mixpanel (default: 'api.mixpanel.com') */ + api_host?: string; + /** Request timeout in seconds (default: 10) */ + request_timeout_in_seconds?: number; +} + +/** + * Configuration for local feature flags (client-side evaluation) + */ +export interface LocalFlagsConfig extends FlagsConfig { + /** Enable automatic polling for flag definition updates (default: true) */ + enable_polling?: boolean; + /** Polling interval in seconds (default: 60) */ + polling_interval_in_seconds?: number; +} + +/** + * Configuration for remote feature flags (server-side evaluation) + */ +export interface RemoteFlagsConfig extends FlagsConfig {} + +/** + * Represents a variant in a feature flag + */ +export interface Variant { + /** Variant key/name */ + key: string; + /** Variant value (can be any type) */ + value: any; + /** Whether this is the control variant */ + is_control: boolean; + /** Percentage split for this variant (0.0-1.0) */ + split?: number; +} + +/** + * Variant override configuration + */ +export interface VariantOverride { + /** Key of the variant to override to */ + key: string; +} + +/** + * Rollout configuration for a feature flag + */ +export interface Rollout { + /** Percentage of users to include in this rollout (0.0-1.0) */ + rollout_percentage: number; + /** Runtime evaluation conditions (property-based targeting) */ + runtime_evaluation_definition?: Record; + /** Variant override for this rollout */ + variant_override?: VariantOverride; + /** Variant split percentages (variant_key -> percentage) */ + variant_splits?: Record; +} + +/** + * Test users configuration for a feature flag + */ +export interface FlagTestUsers { + /** Map of distinct_id to variant_key */ + users: Record; +} + +/** + * Rule set for a feature flag + */ +export interface RuleSet { + /** Available variants for this flag */ + variants: Variant[]; + /** Rollout configurations */ + rollout: Rollout[]; + /** Test users configuration */ + test?: FlagTestUsers; +} + +/** + * Complete feature flag definition + */ +export interface ExperimentationFlag { + /** Flag ID */ + id: string; + /** Flag name */ + name: string; + /** Flag key (used for lookups) */ + key: string; + /** Flag status */ + status: string; + /** Project ID */ + project_id: number; + /** Rule set for this flag */ + ruleset: RuleSet; + /** Context type (e.g., 'user', 'group') */ + context: string; + /** Associated experiment ID */ + experiment_id?: string; + /** Whether the associated experiment is active */ + is_experiment_active?: boolean; + /** Hash salt for variant assignment */ + hash_salt?: string; +} + +export interface SelectedVariant { + variant_key?: string | null; + variant_value: any; + experiment_id?: string; + is_experiment_active?: boolean; + is_qa_tester?: boolean; +} + +export interface RemoteFlagsResponse { + code: number; + flags: Record; +} + +export interface LocalFlagsResponse { + flags: ExperimentationFlag[]; +} + +export interface FlagContext { + distinct_id: string; + [key: string]: any; +} diff --git a/lib/flags/utils.js b/lib/flags/utils.js new file mode 100644 index 0000000..2f9f6c9 --- /dev/null +++ b/lib/flags/utils.js @@ -0,0 +1,79 @@ +/** + * Utility functions for Mixpanel feature flags + */ +const crypto = require('crypto'); + +// Constants +const EXPOSURE_EVENT = '$experiment_started'; + +const REQUEST_HEADERS = { + 'Content-Type': 'application/json', +}; + +/** + * FNV-1a 64-bit hash function used for consistent variant assignment + * https://www.ietf.org/archive/id/draft-eastlake-fnv-21.html#section-6.1.2 + * @param {Buffer} data - Data to hash + * @returns {BigInt} - Hash value as BigInt + */ +function _fnv1a64(data) { + const FNV_PRIME = BigInt('0x100000001B3'); + let hash = BigInt('0xCBF29CE484222325'); + + for (let i = 0; i < data.length; i++) { + hash ^= BigInt(data[i]); + hash *= FNV_PRIME; + hash &= BigInt('0xFFFFFFFFFFFFFFFF'); + } + + return hash; +} + +/** + * Normalized hash function that returns a value between 0.0 and 1.0 + * Used for variant assignment based on rollout percentages + * @param {string} key - The key to hash (usually distinct_id or other identifier) + * @param {string} salt - Salt value (usually flag-specific hash_salt) + * @returns {number} - Hash value normalized to the non-inclusive range, [0.0, 1.0) + */ +function normalizedHash(key, salt) { + const combined = Buffer.from(key + salt, 'utf-8'); + const hashValue = _fnv1a64(combined); + return Number(hashValue % BigInt(100)) / 100.0; +} + +/** + * Prepare common query parameters for feature flags API requests + * @param {string} token - Mixpanel project token + * @param {string} sdkVersion - SDK version string + * @returns {Object} - Query parameters object + */ +function prepareCommonQueryParams(token, sdkVersion) { + return { + mp_lib: 'node', + $lib_version: sdkVersion, + token: token, + }; +} + +/** + * Generate W3C traceparent header for distributed tracing + * Format: 00-{trace-id}-{parent-id}-{trace-flags} + * @returns {string} - traceparent header value + */ +function generateTraceparent() { + const version = '00'; + const traceId = crypto.randomBytes(16).toString('hex'); + const parentId = crypto.randomBytes(8).toString('hex'); + const traceFlags = '01'; // sampled + + return `${version}-${traceId}-${parentId}-${traceFlags}`; +} + +module.exports = { + EXPOSURE_EVENT, + REQUEST_HEADERS, + normalizedHash, + prepareCommonQueryParams, + generateTraceparent, +}; diff --git a/lib/mixpanel-node.d.ts b/lib/mixpanel-node.d.ts index 4346c88..164d4c1 100644 --- a/lib/mixpanel-node.d.ts +++ b/lib/mixpanel-node.d.ts @@ -1,3 +1,7 @@ +import LocalFeatureFlagsProvider from './flags/local_flags'; +import RemoteFeatureFlagsProvider from './flags/remote_flags'; +import { LocalFlagsConfig, RemoteFlagsConfig } from './flags/types'; + declare const mixpanel: mixpanel.Mixpanel; declare namespace mixpanel { @@ -25,6 +29,8 @@ declare namespace mixpanel { keepAlive: boolean; geolocate: boolean; logger: CustomLogger; + local_flags_config?: LocalFlagsConfig; + remote_flags_config?: RemoteFlagsConfig; } export interface PropertyDict { @@ -84,6 +90,10 @@ declare namespace mixpanel { people: People; groups: Groups; + + local_flags?: LocalFeatureFlagsProvider; + + remote_flags?: RemoteFeatureFlagsProvider; } interface People { @@ -152,6 +162,11 @@ declare namespace mixpanel { delete_group(groupKey: string, groupId: string, modifiers?: Modifiers, callback?: Callback): void; delete_group(groupKey: string, groupId: string, callback: Callback): void; } + + // Export feature flags types for convenience + export { LocalFlagsConfig, RemoteFlagsConfig, FlagContext, SelectedVariant } from './flags/types'; + export { default as LocalFeatureFlagsProvider } from './flags/local_flags'; + export { default as RemoteFeatureFlagsProvider } from './flags/remote_flags'; } export = mixpanel; diff --git a/lib/mixpanel-node.js b/lib/mixpanel-node.js index 5d67c43..aaa169b 100644 --- a/lib/mixpanel-node.js +++ b/lib/mixpanel-node.js @@ -18,6 +18,7 @@ const packageInfo = require('../package.json') const {async_all, ensure_timestamp, assert_logger} = require('./utils'); const {MixpanelGroups} = require('./groups'); const {MixpanelPeople} = require('./people'); +const {LocalFeatureFlagsProvider, RemoteFeatureFlagsProvider} = require('./flags'); const DEFAULT_CONFIG = { test: false, @@ -465,6 +466,25 @@ var create_client = function(token, config) { metrics.set_config(config); } + // Initialize feature flags providers if configs are provided + if (config && config.local_flags_config) { + metrics.local_flags = new LocalFeatureFlagsProvider( + token, + config.local_flags_config, + metrics.track.bind(metrics), + config.logger + ); + } + + if (config && config.remote_flags_config) { + metrics.remote_flags = new RemoteFeatureFlagsProvider( + token, + config.remote_flags_config, + metrics.track.bind(metrics), + config.logger + ); + } + return metrics; }; diff --git a/package-lock.json b/package-lock.json index 1794322..203d021 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ }, "devDependencies": { "@vitest/coverage-v8": "^4.0.8", + "nock": "^14.0.10", "proxyquire": "^2.1.3", "vitest": "^4.0.8" }, @@ -550,6 +551,49 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.8.tgz", + "integrity": "sha512-2+BzZbjRO7Ct61k8fMNHEtoKjeWI9pIlHFTqBwZ5icHpqszIgEZbjb1MW5Z0+bITTCTl3gk4PDBxs9tA/csXvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", @@ -1275,6 +1319,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-object": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.1.tgz", @@ -1342,6 +1393,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1417,6 +1475,28 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nock": { + "version": "14.0.10", + "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.10.tgz", + "integrity": "sha512-Q7HjkpyPeLa0ZVZC5qpxBt5EyLczFJ91MEewQiIi9taWuA0KB/MDJlUWtON+7dGouVdADTQsf9RA7TZk6D8VMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mswjs/interceptors": "^0.39.5", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">=18.20.0 <20 || >=20.12.1" + } + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -1480,6 +1560,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/proxyquire": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz", @@ -1599,6 +1689,13 @@ "dev": true, "license": "MIT" }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -2079,6 +2176,42 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "@mswjs/interceptors": { + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.8.tgz", + "integrity": "sha512-2+BzZbjRO7Ct61k8fMNHEtoKjeWI9pIlHFTqBwZ5icHpqszIgEZbjb1MW5Z0+bITTCTl3gk4PDBxs9tA/csXvA==", + "dev": true, + "requires": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + } + }, + "@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true + }, + "@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "requires": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true + }, "@rollup/rollup-android-arm-eabi": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", @@ -2511,6 +2644,12 @@ "hasown": "^2.0.2" } }, + "is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true + }, "is-object": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.1.tgz", @@ -2561,6 +2700,12 @@ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", "dev": true }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true + }, "magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2613,6 +2758,23 @@ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true }, + "nock": { + "version": "14.0.10", + "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.10.tgz", + "integrity": "sha512-Q7HjkpyPeLa0ZVZC5qpxBt5EyLczFJ91MEewQiIi9taWuA0KB/MDJlUWtON+7dGouVdADTQsf9RA7TZk6D8VMw==", + "dev": true, + "requires": { + "@mswjs/interceptors": "^0.39.5", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + } + }, + "outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true + }, "path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -2648,6 +2810,12 @@ "source-map-js": "^1.2.1" } }, + "propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true + }, "proxyquire": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz", @@ -2732,6 +2900,12 @@ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true }, + "strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true + }, "supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index 549eff9..38a732e 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "types": "./lib/mixpanel-node.d.ts", "devDependencies": { "@vitest/coverage-v8": "^4.0.8", + "nock": "^14.0.10", "proxyquire": "^2.1.3", "vitest": "^4.0.8" }, diff --git a/test/flags/local_flags.js b/test/flags/local_flags.js new file mode 100644 index 0000000..a9c62a5 --- /dev/null +++ b/test/flags/local_flags.js @@ -0,0 +1,502 @@ +const nock = require('nock'); +const LocalFeatureFlagsProvider = require('../../lib/flags/local_flags'); + +const mockFlagDefinitionsResponse = (flags) => { + const response = { + code: 200, + flags: flags, + }; + + nock('https://localhost') + .get('/flags/definitions') + .query(true) + .reply(200, response); +}; + +const mockFailedFlagDefinitionsResponse = (statusCode) => { + nock('https://localhost') + .get('/flags/definitions') + .query(true) + .reply(statusCode); +}; + +const createTestFlag = ({ + flagKey = 'test_flag', + context = 'distinct_id', + variants = null, + variantOverride = null, + rolloutPercentage = 100.0, + runtimeEvaluation = null, + testUsers = null, + experimentId = null, + isExperimentActive = null, + variantSplits = null, + hashSalt = null +} = {}) => { + const defaultVariants = [ + { key: 'control', value: 'control', is_control: true, split: 50.0 }, + { key: 'treatment', value: 'treatment', is_control: false, split: 50.0 } + ]; + + const rollout = [{ + rollout_percentage: rolloutPercentage, + runtime_evaluation_definition: runtimeEvaluation, + variant_override: variantOverride, + variant_splits: variantSplits + }]; + + const testConfig = testUsers ? { users: testUsers } : null; + + return { + id: 'test-id', + name: 'Test Flag', + key: flagKey, + status: 'active', + project_id: 123, + context: context, + experiment_id: experimentId, + is_experiment_active: isExperimentActive, + hash_salt: hashSalt, + ruleset: { + variants: variants || defaultVariants, + rollout: rollout, + test: testConfig + } + }; +}; + +describe('LocalFeatureFlagsProvider', () => { + const TEST_TOKEN = 'test-token'; + const TEST_CONTEXT = { + distinct_id: 'test-user', + }; + + let mockTracker; + let mockLogger; + + beforeEach(() => { + mockTracker = vi.fn(); + + mockLogger = { + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn() + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + nock.cleanAll(); + }); + + describe('getVariant', () => { + let provider; + + beforeEach(() => { + const config = { + api_host: 'localhost', + enable_polling: false, + }; + + provider = new LocalFeatureFlagsProvider(TEST_TOKEN, config, mockTracker, mockLogger); + }); + + afterEach(() => { + provider.stopPollingForDefinitions(); + }); + + it('should return fallback when no flag definitions', async () => { + mockFlagDefinitionsResponse([]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant('nonexistent_flag', { variant_value: 'control' }, TEST_CONTEXT); + expect(result.variant_value).toBe('control'); + expect(mockTracker).not.toHaveBeenCalled(); + }); + + it('should return fallback if flag definition call fails', async () => { + mockFailedFlagDefinitionsResponse(500); + + await provider.startPollingForDefinitions(); + const result = provider.getVariant('nonexistent_flag', { variant_value: 'control' }, TEST_CONTEXT); + expect(result.variant_value).toBe('control'); + }); + + it('should return fallback when flag does not exist', async () => { + const otherFlag = createTestFlag({ flagKey: 'other_flag' }); + mockFlagDefinitionsResponse([otherFlag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant('nonexistent_flag', { variant_value: 'control' }, TEST_CONTEXT); + expect(result.variant_value).toBe('control'); + }); + + it('should return fallback when no context', async () => { + const flag = createTestFlag({ context: 'distinct_id' }); + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant('test_flag', { variant_value: 'fallback' }, {}); + expect(result.variant_value).toBe('fallback'); + }); + + it('should return fallback when wrong context key', async () => { + const flag = createTestFlag({ context: 'user_id' }); + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant('test_flag', { variant_value: 'fallback' }, { distinct_id: 'user123' }); + expect(result.variant_value).toBe('fallback'); + }); + + it('should return test user variant when configured', async () => { + const variants = [ + { key: 'control', value: 'false', is_control: true, split: 50.0 }, + { key: 'treatment', value: 'true', is_control: false, split: 50.0 } + ]; + const flag = createTestFlag({ + variants: variants, + testUsers: { 'test_user': 'treatment' } + }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant('test_flag', { variant_value: 'control' }, { distinct_id: 'test_user' }); + expect(result.variant_value).toBe('true'); + }); + + it('should return correct variant when test user variant not configured', async () => { + const variants = [ + { key: 'control', value: 'false', is_control: true, split: 50.0 }, + { key: 'treatment', value: 'true', is_control: false, split: 50.0 } + ]; + const flag = createTestFlag({ + variants: variants, + testUsers: { 'test_user': 'nonexistent_variant' } + }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant('test_flag', { variant_value: 'fallback' }, { distinct_id: 'test_user' }); + expect(['false', 'true']).toContain(result.variant_value); + }); + + it('should return fallback when rollout percentage zero', async () => { + const flag = createTestFlag({ rolloutPercentage: 0.0 }); + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant('test_flag', { variant_value: 'fallback' }, TEST_CONTEXT); + expect(result.variant_value).toBe('fallback'); + }); + + it('should return variant when rollout percentage hundred', async () => { + const flag = createTestFlag({ rolloutPercentage: 100.0 }); + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant('test_flag', { variant_value: 'fallback' }, TEST_CONTEXT); + expect(result.variant_value).not.toBe('fallback'); + expect(['control', 'treatment']).toContain(result.variant_value); + }); + + it('should respect runtime evaluation when satisfied', async () => { + const runtimeEval = { plan: 'premium', region: 'US' }; + const flag = createTestFlag({ runtimeEvaluation: runtimeEval }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const context = { + distinct_id: 'user123', + custom_properties: { + plan: 'premium', + region: 'US' + } + }; + + const result = provider.getVariant('test_flag', { variant_value: 'fallback' }, context); + expect(result.variant_value).not.toBe('fallback'); + }); + + it('should return fallback when runtime evaluation not satisfied', async () => { + const runtimeEval = { plan: 'premium', region: 'US' }; + const flag = createTestFlag({ runtimeEvaluation: runtimeEval }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const context = { + distinct_id: 'user123', + custom_properties: { + plan: 'basic', + region: 'US' + } + }; + + const result = provider.getVariant('test_flag', { variant_value: 'fallback' }, context); + expect(result.variant_value).toBe('fallback'); + }); + + it('should pick correct variant with hundred percent split', async () => { + const variants = [ + { key: 'A', value: 'variant_a', is_control: false, split: 100.0 }, + { key: 'B', value: 'variant_b', is_control: false, split: 0.0 }, + { key: 'C', value: 'variant_c', is_control: false, split: 0.0 } + ]; + const flag = createTestFlag({ variants: variants, rolloutPercentage: 100.0 }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant('test_flag', { variant_value: 'fallback' }, TEST_CONTEXT); + expect(result.variant_value).toBe('variant_a'); + }); + + it('should pick correct variant with half migrated group splits', async () => { + const variants = [ + { key: 'A', value: 'variant_a', is_control: false, split: 100.0 }, + { key: 'B', value: 'variant_b', is_control: false, split: 0.0 }, + { key: 'C', value: 'variant_c', is_control: false, split: 0.0 } + ]; + const variantSplits = { A: 0.0, B: 100.0, C: 0.0 }; + const flag = createTestFlag({ + variants: variants, + rolloutPercentage: 100.0, + variantSplits: variantSplits + }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant('test_flag', { variant_value: 'fallback' }, TEST_CONTEXT); + expect(result.variant_value).toBe('variant_b'); + }); + + it('should pick correct variant with full migrated group splits', async () => { + const variants = [ + { key: 'A', value: 'variant_a', is_control: false }, + { key: 'B', value: 'variant_b', is_control: false }, + { key: 'C', value: 'variant_c', is_control: false } + ]; + const variantSplits = { A: 0.0, B: 0.0, C: 100.0 }; + const flag = createTestFlag({ + variants: variants, + rolloutPercentage: 100.0, + variantSplits: variantSplits + }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant('test_flag', { variant_value: 'fallback' }, TEST_CONTEXT); + expect(result.variant_value).toBe('variant_c'); + }); + + it('should pick overridden variant', async () => { + const variants = [ + { key: 'A', value: 'variant_a', is_control: false, split: 100.0 }, + { key: 'B', value: 'variant_b', is_control: false, split: 0.0 } + ]; + const flag = createTestFlag({ + variants: variants, + variantOverride: { key: 'B' } + }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant('test_flag', { variant_value: 'control' }, TEST_CONTEXT); + expect(result.variant_value).toBe('variant_b'); + }); + + it('should track exposure when variant selected', async () => { + const flag = createTestFlag(); + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + provider.getVariant('test_flag', { variant_value: 'fallback' }, TEST_CONTEXT); + expect(mockTracker).toHaveBeenCalledTimes(1); + }); + + it('should track exposure with correct properties', async () => { + const flag = createTestFlag({ + experimentId: 'exp-123', + isExperimentActive: true, + testUsers: { 'qa_user': 'treatment' } + }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + provider.getVariant('test_flag', { variant_value: 'fallback' }, { distinct_id: 'qa_user' }); + + expect(mockTracker).toHaveBeenCalledTimes(1); + + const call = mockTracker.mock.calls[0]; + const properties = call[1]; + + expect(properties['$experiment_id']).toBe('exp-123'); + expect(properties['$is_experiment_active']).toBe(true); + expect(properties['$is_qa_tester']).toBe(true); + }); + + it('should not track exposure on fallback', async () => { + mockFlagDefinitionsResponse([]); + await provider.startPollingForDefinitions(); + + provider.getVariant('nonexistent_flag', { variant_value: 'fallback' }, TEST_CONTEXT); + expect(mockTracker).not.toHaveBeenCalled(); + }); + + it('should not track exposure without distinct_id', async () => { + const flag = createTestFlag({ context: 'company' }); + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + provider.getVariant('test_flag', { variant_value: 'fallback' }, { company_id: 'company123' }); + expect(mockTracker).not.toHaveBeenCalled(); + }); + }); + + describe('getAllVariants', () => { + let provider; + + beforeEach(() => { + const config = { + api_host: 'localhost', + enable_polling: false, + }; + + provider = new LocalFeatureFlagsProvider(TEST_TOKEN, config, mockTracker, mockLogger); + }); + + afterEach(() => { + provider.stopPollingForDefinitions(); + }); + + it('should return empty object when no flag definitions', async () => { + mockFlagDefinitionsResponse([]); + await provider.startPollingForDefinitions(); + + const result = provider.getAllVariants(TEST_CONTEXT); + + expect(result).toEqual({}); + }); + + it('should return all variants when two flags have 100% rollout', async () => { + const flag1 = createTestFlag({ flagKey: 'flag1', rolloutPercentage: 100.0 }); + const flag2 = createTestFlag({ flagKey: 'flag2', rolloutPercentage: 100.0 }); + + mockFlagDefinitionsResponse([flag1, flag2]); + await provider.startPollingForDefinitions(); + + const result = provider.getAllVariants(TEST_CONTEXT); + + expect(Object.keys(result).length).toBe(2); + expect(result).toHaveProperty('flag1'); + expect(result).toHaveProperty('flag2'); + }); + + it('should return partial results when one flag has 0% rollout', async () => { + const flag1 = createTestFlag({ flagKey: 'flag1', rolloutPercentage: 100.0 }); + const flag2 = createTestFlag({ flagKey: 'flag2', rolloutPercentage: 0.0 }); + + mockFlagDefinitionsResponse([flag1, flag2]); + await provider.startPollingForDefinitions(); + + const result = provider.getAllVariants(TEST_CONTEXT); + + expect(Object.keys(result).length).toBe(1); + expect(result).toHaveProperty('flag1'); + expect(result).not.toHaveProperty('flag2'); + }); + }); + + describe('getVariantValue', () => { + let provider; + + beforeEach(() => { + const config = { + api_host: 'localhost', + enable_polling: false, + }; + + provider = new LocalFeatureFlagsProvider(TEST_TOKEN, config, mockTracker, mockLogger); + }); + + afterEach(() => { + provider.stopPollingForDefinitions(); + }); + + it('should return variant value when flag exists', async () => { + const variants = [ + { key: 'treatment', value: 'blue', is_control: false, split: 100.0 } + ]; + const flag = createTestFlag({ variants: variants, rolloutPercentage: 100.0 }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariantValue('test_flag', 'default', TEST_CONTEXT); + + expect(result).toBe('blue'); + }); + + it('should return fallback value when flag doesn\'t exist', async () => { + mockFlagDefinitionsResponse([]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariantValue('nonexistent_flag', 'default_value', TEST_CONTEXT); + + expect(result).toBe('default_value'); + }); + }); + + describe('isEnabled', () => { + let provider; + + beforeEach(() => { + const config = { + api_host: 'localhost', + enable_polling: false, + }; + + provider = new LocalFeatureFlagsProvider(TEST_TOKEN, config, mockTracker, mockLogger); + }); + + afterEach(() => { + provider.stopPollingForDefinitions(); + }); + + it('should return true when variant value is boolean true', async () => { + const variants = [ + { key: 'treatment', value: true, is_control: false, split: 100.0 } + ]; + const flag = createTestFlag({ variants: variants, rolloutPercentage: 100.0 }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.isEnabled('test_flag', TEST_CONTEXT); + + expect(result).toBe(true); + }); + + it('should return false when variant value is boolean false', async () => { + const variants = [ + { key: 'control', value: false, is_control: true, split: 100.0 } + ]; + const flag = createTestFlag({ variants: variants, rolloutPercentage: 100.0 }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.isEnabled('test_flag', TEST_CONTEXT); + + expect(result).toBe(false); + }); + }); +}); diff --git a/test/flags/remote_flags.js b/test/flags/remote_flags.js new file mode 100644 index 0000000..32acb1e --- /dev/null +++ b/test/flags/remote_flags.js @@ -0,0 +1,443 @@ +const nock = require('nock'); +const RemoteFeatureFlagsProvider = require('../../lib/flags/remote_flags'); + +const mockSuccessResponse = (flags_with_selected_variant) => { + const remote_response = { + code: 200, + flags: flags_with_selected_variant, + }; + + nock('https://localhost') + .get('/flags') + .query(true) + .reply(200, remote_response); +}; + +describe('RemoteFeatureFlagProvider', () => { + const flagsEndpointHostName = "localhost"; + const TEST_TOKEN = 'test-token'; + + const TEST_CONTEXT = { + distinct_id: 'test-user', + }; + + let provider; + let mockTracker; + + beforeEach(() => { + mockTracker = vi.fn(); + + let mockLogger = { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn() + }; + + let config = { + api_host: flagsEndpointHostName, + }; + + provider = new RemoteFeatureFlagsProvider(TEST_TOKEN, config, mockTracker, mockLogger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + nock.cleanAll(); + }); + + describe('getVariant', () => { + it('should return variant when served', async () => { + mockSuccessResponse({ + 'new-feature': { + variant_key: 'on', + variant_value: true, + } + }); + + const expectedVariant = { + variant_key: 'on', + variant_value: true, + } + + const result = await provider.getVariant('new-feature', null, TEST_CONTEXT); + + expect(result).toEqual(expectedVariant); + }); + + it('should select fallback variant when no flags are served', async () => { + nock('https://localhost') + .get('/flags') + .query(true) + .reply(200, { code: 200, flags: {} }); + + const fallbackVariant = { + variant_key: 'control', + variant_value: false, + }; + + const result = await provider.getVariant('any-flag', fallbackVariant, TEST_CONTEXT); + + expect(result).toEqual(fallbackVariant); + expect(mockTracker).not.toHaveBeenCalled(); + }); + + it('should select fallback variant if flag does not exist in served flags', async () => { + mockSuccessResponse({ + 'different-flag': { + variant_key: 'on', + variant_value: true, + } + }); + + const fallbackVariant = { + variant_key: 'control', + variant_value: false, + }; + + const result = await provider.getVariant('missing-flag', fallbackVariant, TEST_CONTEXT); + + expect(result).toEqual(fallbackVariant); + expect(mockTracker).not.toHaveBeenCalled(); + }); + + it('No exposure events are tracked when fallback variant is selected', async () => { + nock('https://localhost') + .get('/flags') + .query(true) + .reply(200, { code: 200, flags: {} }); + + const fallbackVariant = { + variant_key: 'control', + variant_value: false, + }; + + await provider.getVariant('any-flag', fallbackVariant, TEST_CONTEXT); + + expect(mockTracker).not.toHaveBeenCalled(); + }); + + it('Exposure event is tracked when a variant is selected', async () => { + mockSuccessResponse({ + 'test-flag': { + variant_key: 'treatment', + variant_value: true, + } + }); + + const fallbackVariant = { + variant_key: 'control', + variant_value: false, + }; + + const result = await provider.getVariant('test-flag', fallbackVariant, TEST_CONTEXT); + + expect(result).toEqual({ + variant_key: 'treatment', + variant_value: true, + }); + + expect(mockTracker).toHaveBeenCalledTimes(1); + + expect(mockTracker).toHaveBeenCalledWith( + '$experiment_started', + expect.objectContaining({ + 'distinct_id': 'test-user', + 'Experiment name': 'test-flag', + 'Variant name': 'treatment', + '$experiment_type': 'feature_flag', + 'Flag evaluation mode': 'remote' + }), + expect.any(Function) + ); + }); + }); + + describe('getVariantValue', () => { + it('should return variant value when flag exists', async () => { + mockSuccessResponse({ + 'test-flag': { + variant_key: 'treatment', + variant_value: 'blue', + } + }); + + const result = await provider.getVariantValue('test-flag', 'default', TEST_CONTEXT); + + expect(result).toEqual('blue'); + }); + + it('should return fallback value when flag doesn\'t exist', async () => { + mockSuccessResponse({ + 'different-flag': { + variant_key: 'on', + variant_value: true, + } + }); + + const result = await provider.getVariantValue('missing-flag', 'default-value', TEST_CONTEXT); + + expect(result).toEqual('default-value'); + }); + + it('should track exposure event by default', async () => { + mockSuccessResponse({ + 'test-flag': { + variant_key: 'treatment', + variant_value: 'value', + } + }); + + await provider.getVariantValue('test-flag', 'default', TEST_CONTEXT); + + expect(mockTracker).toHaveBeenCalledTimes(1); + expect(mockTracker).toHaveBeenCalledWith( + '$experiment_started', + expect.objectContaining({ + 'Experiment name': 'test-flag', + 'Variant name': 'treatment', + }), + expect.any(Function) + ); + }); + + it('should NOT track exposure event when reportExposure is false', async () => { + mockSuccessResponse({ + 'test-flag': { + variant_key: 'treatment', + variant_value: 'value', + } + }); + + await provider.getVariantValue('test-flag', 'default', TEST_CONTEXT, false); + + expect(mockTracker).not.toHaveBeenCalled(); + }); + + it('should handle different variant value types', async () => { + // Test string + mockSuccessResponse({ + 'string-flag': { + variant_key: 'treatment', + variant_value: 'text-value', + } + }); + let result = await provider.getVariantValue('string-flag', 'default', TEST_CONTEXT); + expect(result).toEqual('text-value'); + + // Test number + nock.cleanAll(); + mockSuccessResponse({ + 'number-flag': { + variant_key: 'treatment', + variant_value: 42, + } + }); + result = await provider.getVariantValue('number-flag', 0, TEST_CONTEXT); + expect(result).toEqual(42); + + // Test object + nock.cleanAll(); + mockSuccessResponse({ + 'object-flag': { + variant_key: 'treatment', + variant_value: { key: 'value' }, + } + }); + result = await provider.getVariantValue('object-flag', {}, TEST_CONTEXT); + expect(result).toEqual({ key: 'value' }); + }); + + it('should return fallback on network error', async () => { + nock('https://localhost') + .get('/flags') + .query(true) + .replyWithError('Network error'); + + const result = await provider.getVariantValue('test-flag', 'fallback', TEST_CONTEXT); + + expect(result).toEqual('fallback'); + }); + + it('should return fallback when no flags are served', async () => { + nock('https://localhost') + .get('/flags') + .query(true) + .reply(200, { code: 200, flags: {} }); + + const result = await provider.getVariantValue('test-flag', 'fallback', TEST_CONTEXT); + + expect(result).toEqual('fallback'); + }); + + it('should NOT track exposure when fallback is returned', async () => { + nock('https://localhost') + .get('/flags') + .query(true) + .reply(200, { code: 200, flags: {} }); + + await provider.getVariantValue('test-flag', 'fallback', TEST_CONTEXT); + + expect(mockTracker).not.toHaveBeenCalled(); + }); + }); + + describe('getAllVariants', () => { + it('should return all variants from API', async () => { + mockSuccessResponse({ + 'flag-1': { + variant_key: 'treatment', + variant_value: true, + }, + 'flag-2': { + variant_key: 'control', + variant_value: false, + }, + 'flag-3': { + variant_key: 'blue', + variant_value: 'blue-theme', + } + }); + + const result = await provider.getAllVariants(TEST_CONTEXT); + + expect(result).toEqual({ + 'flag-1': { + variant_key: 'treatment', + variant_value: true, + }, + 'flag-2': { + variant_key: 'control', + variant_value: false, + }, + 'flag-3': { + variant_key: 'blue', + variant_value: 'blue-theme', + } + }); + }); + + it('should return empty object when no flags served', async () => { + nock('https://localhost') + .get('/flags') + .query(true) + .reply(200, { code: 200, flags: {} }); + + const result = await provider.getAllVariants(TEST_CONTEXT); + + expect(result).toEqual({}); + }); + + it('should NOT track any exposure events', async () => { + mockSuccessResponse({ + 'flag-1': { + variant_key: 'treatment', + variant_value: true, + }, + 'flag-2': { + variant_key: 'control', + variant_value: false, + } + }); + + await provider.getAllVariants(TEST_CONTEXT); + + expect(mockTracker).not.toHaveBeenCalled(); + }); + }); + + describe('isEnabled', () => { + it('should return true when variant value is boolean true', async () => { + mockSuccessResponse({ + 'test-flag': { + variant_key: 'on', + variant_value: true, + } + }); + + const result = await provider.isEnabled('test-flag', TEST_CONTEXT); + + expect(result).toBe(true); + }); + + it('should return false when variant value is boolean false', async () => { + mockSuccessResponse({ + 'test-flag': { + variant_key: 'off', + variant_value: false, + } + }); + + const result = await provider.isEnabled('test-flag', TEST_CONTEXT); + + expect(result).toBe(false); + }); + + it('should return false for truthy non-boolean values', async () => { + // Test string "true" + mockSuccessResponse({ + 'string-flag': { + variant_key: 'treatment', + variant_value: 'true', + } + }); + let result = await provider.isEnabled('string-flag', TEST_CONTEXT); + expect(result).toBe(false); + + // Test number 1 + nock.cleanAll(); + mockSuccessResponse({ + 'number-flag': { + variant_key: 'treatment', + variant_value: 1, + } + }); + result = await provider.isEnabled('number-flag', TEST_CONTEXT); + expect(result).toBe(false); + }); + + it('should return false when flag doesn\'t exist', async () => { + mockSuccessResponse({ + 'different-flag': { + variant_key: 'on', + variant_value: true, + } + }); + + const result = await provider.isEnabled('missing-flag', TEST_CONTEXT); + + expect(result).toBe(false); + }); + + it('should track exposure event', async () => { + mockSuccessResponse({ + 'test-flag': { + variant_key: 'on', + variant_value: true, + } + }); + + await provider.isEnabled('test-flag', TEST_CONTEXT); + + expect(mockTracker).toHaveBeenCalledTimes(1); + expect(mockTracker).toHaveBeenCalledWith( + '$experiment_started', + expect.objectContaining({ + 'Experiment name': 'test-flag', + 'Variant name': 'on', + }), + expect.any(Function) + ); + }); + + it('should return false on network error', async () => { + nock('https://localhost') + .get('/flags') + .query(true) + .replyWithError('Network error'); + + const result = await provider.isEnabled('test-flag', TEST_CONTEXT); + + expect(result).toBe(false); + }); + }); +}); diff --git a/test/flags/utils.js b/test/flags/utils.js new file mode 100644 index 0000000..222c0bc --- /dev/null +++ b/test/flags/utils.js @@ -0,0 +1,107 @@ +const { + generateTraceparent, + normalizedHash +} = require('../../lib/flags/utils'); + +describe('Utils', function() { + describe('generateTraceparent', function() { + it('should generate traceparent in W3C format', function() { + const traceparent = generateTraceparent(); + // W3C format: 00-{32 hex chars}-{16 hex chars}-01 + const pattern = /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/; + expect(traceparent).to.match(pattern); + }); + }); + + describe('normalizedHash', function() { + const expectValidHash = (hash) => { + expect(hash).to.be.a('number'); + expect(hash).to.be.at.least(0); + expect(hash).to.be.at.most(1); + }; + + it('should match known test vectors', function() { + const hash1 = normalizedHash("abc", "variant"); + expect(hash1).equals(0.72) + + const hash2 = normalizedHash("def", "variant"); + expect(hash2).equals(0.21, 0.01); + }); + + it('should produce consistent results', function() { + const hash1 = normalizedHash("test_key", "salt"); + const hash2 = normalizedHash("test_key", "salt"); + const hash3 = normalizedHash("test_key", "salt"); + + expect(hash1).equals(hash2); + expect(hash2).equals(hash3); + }); + + it('should produce different hashes when salt is changed', function() { + const hash1 = normalizedHash("same_key", "salt1"); + const hash2 = normalizedHash("same_key", "salt2"); + const hash3 = normalizedHash("same_key", "different_salt"); + + expect(hash1).to.not.equal(hash2); + expect(hash1).to.not.equal(hash3); + expect(hash2).to.not.equal(hash3); + }); + + it('should produce different hashes when order is changed', function() { + const hash1 = normalizedHash("abc", "salt"); + const hash2 = normalizedHash("bac", "salt"); + const hash3 = normalizedHash("cba", "salt"); + + expect(hash1).to.not.equal(hash2); + expect(hash1).to.not.equal(hash3); + expect(hash2).to.not.equal(hash3); + }); + + describe('should handle edge cases with empty strings', function() { + const testCases = [ + { key: "", salt: "salt", description: "empty key" }, + { key: "key", salt: "", description: "empty salt" }, + { key: "", salt: "", description: "both empty" } + ]; + + testCases.forEach(({ key, salt, description }) => { + it(`should return valid hash for ${description}`, function() { + const hash = normalizedHash(key, salt); + expectValidHash(hash); + }); + }); + + it('empty strings in different positions should produce different results', function() { + const hash1 = normalizedHash("", "salt"); + const hash2 = normalizedHash("key", ""); + expect(hash1).to.not.equal(hash2); + }); + }); + + describe('should handle special characters', function() { + const testCases = [ + { key: "🎉", description: "emoji" }, + { key: "beyoncé", description: "accented characters" }, + { key: "key@#$%^&*()", description: "special symbols" }, + { key: "key with spaces", description: "spaces" } + ]; + + testCases.forEach(({ key, description }) => { + it(`should return valid hash for ${description}`, function() { + const hash = normalizedHash(key, "salt"); + expectValidHash(hash); + }); + }); + + it('produces different results for different special characters', function() { + const hashes = testCases.map(tc => normalizedHash(tc.key, "salt")); + + for (let i = 0; i < hashes.length; i++) { + for (let j = i + 1; j < hashes.length; j++) { + expect(hashes[i]).to.not.equal(hashes[j]); + } + } + }); + }); + }); +}); diff --git a/vitest.config.mjs b/vitest.config.mjs index c8df25d..389bc88 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -4,7 +4,7 @@ export default defineConfig({ test: { globals: true, include: [ - 'test/**.js' + 'test/**/*.js' ], coverage: { exclude: [