diff --git a/lib/optimizely.rb b/lib/optimizely.rb index f2e1dd82..4c4beafa 100644 --- a/lib/optimizely.rb +++ b/lib/optimizely.rb @@ -43,11 +43,17 @@ require_relative 'optimizely/odp/odp_manager' require_relative 'optimizely/helpers/sdk_settings' require_relative 'optimizely/user_profile_tracker' +require_relative 'optimizely/cmab/cmab_client' +require_relative 'optimizely/cmab/cmab_service' module Optimizely class Project include Optimizely::Decide + # CMAB Constants + DEFAULT_CMAB_CACHE_TIMEOUT = (30 * 60 * 1000) + DEFAULT_CMAB_CACHE_SIZE = 1000 + attr_reader :notification_center # @api no-doc attr_reader :config_manager, :decision_service, :error_handler, :event_dispatcher, @@ -131,7 +137,19 @@ def initialize( setup_odp!(@config_manager.sdk_key) - @decision_service = DecisionService.new(@logger, @user_profile_service) + # Initialize CMAB components + @cmab_client = DefaultCmabClient.new( + retry_config: CmabRetryConfig.new, + logger: @logger + ) + @cmab_cache = LRUCache.new(DEFAULT_CMAB_CACHE_SIZE, DEFAULT_CMAB_CACHE_TIMEOUT) + @cmab_service = DefaultCmabService.new( + @cmab_cache, + @cmab_client, + @logger + ) + + @decision_service = DecisionService.new(@logger, @cmab_service, @user_profile_service) @event_processor = if event_processor.respond_to?(:process) event_processor @@ -337,7 +355,7 @@ def decide_for_keys(user_context, keys, decide_options = [], ignore_default_opti # If the feature flag is nil, create a default OptimizelyDecision and move to the next key if feature_flag.nil? - decisions[key] = OptimizelyDecision.new(nil, false, nil, nil, key, user_context, []) + decisions[key] = OptimizelyDecision.new(variation_key: nil, enabled: false, variables: nil, rule_key: nil, flag_key: key, user_context: user_context, reasons: []) next end valid_keys.push(key) @@ -358,9 +376,17 @@ def decide_for_keys(user_context, keys, decide_options = [], ignore_default_opti decision_list = @decision_service.get_variations_for_feature_list(config, flags_without_forced_decision, user_context, decide_options) flags_without_forced_decision.each_with_index do |flag, i| - decision = decision_list[i][0] - reasons = decision_list[i][1] + decision = decision_list[i].decision + reasons = decision_list[i].reasons + error = decision_list[i].error flag_key = flag['key'] + # store error decision against key and remove key from valid keys + if error + optimizely_decision = OptimizelyDecision.new_error_decision(flag_key, user_context, reasons) + decisions[flag_key] = optimizely_decision + valid_keys.delete(flag_key) if valid_keys.include?(flag_key) + next + end flag_decisions[flag_key] = decision decision_reasons_dict[flag_key] ||= [] decision_reasons_dict[flag_key].push(*reasons) @@ -599,8 +625,8 @@ def is_feature_enabled(feature_flag_key, user_id, attributes = nil) end user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false) - decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_context) - + decision_result = @decision_service.get_variation_for_feature(config, feature_flag, user_context) + decision = decision_result.decision feature_enabled = false source_string = Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] if decision.is_a?(Optimizely::DecisionService::Decision) @@ -839,7 +865,8 @@ def get_all_feature_variables(feature_flag_key, user_id, attributes = nil) end user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false) - decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_context) + decision_result = @decision_service.get_variation_for_feature(config, feature_flag, user_context) + decision = decision_result.decision variation = decision ? decision['variation'] : nil feature_enabled = variation ? variation['featureEnabled'] : false all_variables = {} @@ -1029,7 +1056,8 @@ def get_variation_with_config(experiment_key, user_id, attributes, config) user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false) user_profile_tracker = UserProfileTracker.new(user_id, @user_profile_service, @logger) user_profile_tracker.load_user_profile - variation_id, = @decision_service.get_variation(config, experiment_id, user_context, user_profile_tracker) + variation_result = @decision_service.get_variation(config, experiment_id, user_context, user_profile_tracker) + variation_id = variation_result.variation_id user_profile_tracker.save_user_profile variation = config.get_variation_from_id(experiment_key, variation_id) unless variation_id.nil? variation_key = variation['key'] if variation @@ -1097,7 +1125,8 @@ def get_feature_variable_for_type(feature_flag_key, variable_key, variable_type, end user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false) - decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_context) + decision_result = @decision_service.get_variation_for_feature(config, feature_flag, user_context) + decision = decision_result.decision variation = decision ? decision['variation'] : nil feature_enabled = variation ? variation['featureEnabled'] : false diff --git a/lib/optimizely/bucketer.rb b/lib/optimizely/bucketer.rb index 15f711cb..d62e088d 100644 --- a/lib/optimizely/bucketer.rb +++ b/lib/optimizely/bucketer.rb @@ -44,6 +44,25 @@ def bucket(project_config, experiment, bucketing_id, user_id) # user_id - String ID for user. # # Returns variation in which visitor with ID user_id has been placed. Nil if no variation. + + variation_id, decide_reasons = bucket_to_entity_id(project_config, experiment, bucketing_id, user_id) + if variation_id && variation_id != '' + experiment_id = experiment['id'] + variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id) + return variation, decide_reasons + end + + # Handle the case when the traffic range is empty due to sticky bucketing + if variation_id == '' + message = 'Bucketed into an empty traffic range. Returning nil.' + @logger.log(Logger::DEBUG, message) + decide_reasons.push(message) + end + + [nil, decide_reasons] + end + + def bucket_to_entity_id(project_config, experiment, bucketing_id, user_id) return nil, [] if experiment.nil? decide_reasons = [] @@ -84,22 +103,18 @@ def bucket(project_config, experiment, bucketing_id, user_id) end traffic_allocations = experiment['trafficAllocation'] + if experiment['cmab'] + traffic_allocations = [ + { + 'entityId' => '$', + 'endOfRange' => experiment['cmab']['trafficAllocation'] + } + ] + end variation_id, find_bucket_reasons = find_bucket(bucketing_id, user_id, experiment_id, traffic_allocations) decide_reasons.push(*find_bucket_reasons) - if variation_id && variation_id != '' - variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id) - return variation, decide_reasons - end - - # Handle the case when the traffic range is empty due to sticky bucketing - if variation_id == '' - message = 'Bucketed into an empty traffic range. Returning nil.' - @logger.log(Logger::DEBUG, message) - decide_reasons.push(message) - end - - [nil, decide_reasons] + [variation_id, decide_reasons] end def find_bucket(bucketing_id, user_id, parent_id, traffic_allocations) diff --git a/lib/optimizely/decide/optimizely_decision.rb b/lib/optimizely/decide/optimizely_decision.rb index 06b109b3..ea1964d3 100644 --- a/lib/optimizely/decide/optimizely_decision.rb +++ b/lib/optimizely/decide/optimizely_decision.rb @@ -55,6 +55,25 @@ def as_json def to_json(*args) as_json.to_json(*args) end + + # Create a new OptimizelyDecision representing an error state. + # + # @param key [String] The flag key + # @param user [OptimizelyUserContext] The user context + # @param reasons [Array] List of reasons explaining the error + # + # @return [OptimizelyDecision] OptimizelyDecision with error state values + def self.new_error_decision(key, user, reasons = []) + new( + variation_key: nil, + enabled: false, + variables: {}, + rule_key: nil, + flag_key: key, + user_context: user, + reasons: reasons + ) + end end end end diff --git a/lib/optimizely/decision_service.rb b/lib/optimizely/decision_service.rb index 3303907d..976c742f 100644 --- a/lib/optimizely/decision_service.rb +++ b/lib/optimizely/decision_service.rb @@ -29,7 +29,8 @@ class DecisionService # 3. Check whitelisting # 4. Check user profile service for past bucketing decisions (sticky bucketing) # 5. Check audience targeting - # 6. Use Murmurhash3 to bucket the user + # 6. Check cmab service + # 7. Use Murmurhash3 to bucket the user attr_reader :bucketer @@ -37,7 +38,10 @@ class DecisionService # This contains all the forced variations set by the user by calling setForcedVariation. attr_reader :forced_variation_map - Decision = Struct.new(:experiment, :variation, :source) + Decision = Struct.new(:experiment, :variation, :source, :cmab_uuid) + CmabDecisionResult = Struct.new(:error, :result, :reasons) + VariationResult = Struct.new(:cmab_uuid, :error, :reasons, :variation_id) + DecisionResult = Struct.new(:decision, :error, :reasons) DECISION_SOURCES = { 'EXPERIMENT' => 'experiment', @@ -45,11 +49,12 @@ class DecisionService 'ROLLOUT' => 'rollout' }.freeze - def initialize(logger, user_profile_service = nil) + def initialize(logger, cmab_service, user_profile_service = nil) @logger = logger @user_profile_service = user_profile_service @bucketer = Bucketer.new(logger) @forced_variation_map = {} + @cmab_service = cmab_service end def get_variation(project_config, experiment_id, user_context, user_profile_tracker = nil, decide_options = [], reasons = []) @@ -61,8 +66,7 @@ def get_variation(project_config, experiment_id, user_context, user_profile_trac # user_profile_tracker: Tracker for reading and updating user profile of the user. # reasons: Decision reasons. # - # Returns variation ID where visitor will be bucketed - # (nil if experiment is inactive or user does not meet audience conditions) + # Returns VariationResult struct user_profile_tracker = UserProfileTracker.new(user_context.user_id, @user_profile_service, @logger) unless user_profile_tracker.is_a?(Optimizely::UserProfileTracker) decide_reasons = [] decide_reasons.push(*reasons) @@ -73,25 +77,25 @@ def get_variation(project_config, experiment_id, user_context, user_profile_trac decide_reasons.push(*bucketing_id_reasons) # Check to make sure experiment is active experiment = project_config.get_experiment_from_id(experiment_id) - return nil, decide_reasons if experiment.nil? + return VariationResult.new(nil, false, decide_reasons, nil) if experiment.nil? experiment_key = experiment['key'] unless project_config.experiment_running?(experiment) message = "Experiment '#{experiment_key}' is not running." @logger.log(Logger::INFO, message) decide_reasons.push(message) - return nil, decide_reasons + return VariationResult.new(nil, false, decide_reasons, nil) end # Check if a forced variation is set for the user forced_variation, reasons_received = get_forced_variation(project_config, experiment['key'], user_id) decide_reasons.push(*reasons_received) - return forced_variation['id'], decide_reasons if forced_variation + return VariationResult.new(nil, false, decide_reasons, forced_variation['id']) if forced_variation # Check if user is in a white-listed variation whitelisted_variation_id, reasons_received = get_whitelisted_variation_id(project_config, experiment_id, user_id) decide_reasons.push(*reasons_received) - return whitelisted_variation_id, decide_reasons if whitelisted_variation_id + return VariationResult.new(nil, false, decide_reasons, whitelisted_variation_id) if whitelisted_variation_id should_ignore_user_profile_service = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE # Check for saved bucketing decisions if decide_options do not include ignoreUserProfileService @@ -102,7 +106,7 @@ def get_variation(project_config, experiment_id, user_context, user_profile_trac message = "Returning previously activated variation ID #{saved_variation_id} of experiment '#{experiment_key}' for user '#{user_id}' from user profile." @logger.log(Logger::INFO, message) decide_reasons.push(message) - return saved_variation_id, decide_reasons + return VariationResult.new(nil, false, decide_reasons, saved_variation_id) end end @@ -113,27 +117,45 @@ def get_variation(project_config, experiment_id, user_context, user_profile_trac message = "User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'." @logger.log(Logger::INFO, message) decide_reasons.push(message) - return nil, decide_reasons + return VariationResult.new(nil, false, decide_reasons, nil) end - # Bucket normally - variation, bucket_reasons = @bucketer.bucket(project_config, experiment, bucketing_id, user_id) - decide_reasons.push(*bucket_reasons) - variation_id = variation ? variation['id'] : nil + # Check if this is a CMAB experiment + # If so, handle CMAB-specific traffic allocation and decision logic. + # Otherwise, proceed with standard bucketing logic for non-CMAB experiments. + if experiment.key?('cmab') + cmab_decision_result = get_decision_for_cmab_experiment(project_config, experiment, user_context, bucketing_id, decide_options) + decide_reasons.push(*cmab_decision_result.reasons) + if cmab_decision_result.error + # CMAB decision failed, return error + return VariationResult.new(nil, true, decide_reasons, nil) + end - message = '' - if variation_id - variation_key = variation['key'] - message = "User '#{user_id}' is in variation '#{variation_key}' of experiment '#{experiment_id}'." + cmab_decision = cmab_decision_result.result + variation_id = cmab_decision&.variation_id + cmab_uuid = cmab_decision&.cmab_uuid + variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id) else - message = "User '#{user_id}' is in no variation." + # Bucket normally + variation, bucket_reasons = @bucketer.bucket(project_config, experiment, bucketing_id, user_id) + decide_reasons.push(*bucket_reasons) + variation_id = variation ? variation['id'] : nil + cmab_uuid = nil end + + variation_key = variation['key'] if variation + message = if variation_id + "User '#{user_id}' is in variation '#{variation_key}' of experiment '#{experiment_id}'." + else + "User '#{user_id}' is in no variation." + end + @logger.log(Logger::INFO, message) - decide_reasons.push(message) + decide_reasons.push(message) if message # Persist bucketing decision user_profile_tracker.update_user_profile(experiment_id, variation_id) unless should_ignore_user_profile_service && user_profile_tracker - [variation_id, decide_reasons] + VariationResult.new(cmab_uuid, false, decide_reasons, variation_id) end def get_variation_for_feature(project_config, feature_flag, user_context, decide_options = []) @@ -143,7 +165,7 @@ def get_variation_for_feature(project_config, feature_flag, user_context, decide # feature_flag - The feature flag the user wants to access # user_context - Optimizely user context instance # - # Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature) + # Returns DecisionResult struct. get_variations_for_feature_list(project_config, [feature_flag], user_context, decide_options).first end @@ -157,7 +179,7 @@ def get_variations_for_feature_list(project_config, feature_flags, user_context, # decide_options: Decide options. # # Returns: - # Array of Decision struct. + # Array of DecisionResult struct. ignore_ups = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE user_profile_tracker = nil unless ignore_ups && @user_profile_service @@ -166,18 +188,15 @@ def get_variations_for_feature_list(project_config, feature_flags, user_context, end decisions = [] feature_flags.each do |feature_flag| - decide_reasons = [] # check if the feature is being experiment on and whether the user is bucketed into the experiment - decision, reasons_received = get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options) - decide_reasons.push(*reasons_received) - if decision - decisions << [decision, decide_reasons] - else - # Proceed to rollout if the decision is nil - rollout_decision, reasons_received = get_variation_for_feature_rollout(project_config, feature_flag, user_context) - decide_reasons.push(*reasons_received) - decisions << [rollout_decision, decide_reasons] + decision_result = get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options) + # Only process rollout if no experiment decision was found and no error + if decision_result.decision.nil? && !decision_result.error + decision_result_rollout = get_variation_for_feature_rollout(project_config, feature_flag, user_context) unless decision_result.decision + decision_result.decision = decision_result_rollout.decision + decision_result.reasons.push(*decision_result_rollout.reasons) end + decisions << decision_result end user_profile_tracker&.save_user_profile decisions @@ -190,8 +209,8 @@ def get_variation_for_feature_experiment(project_config, feature_flag, user_cont # feature_flag - The feature flag the user wants to access # user_context - Optimizely user context instance # - # Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature) - # or nil if the user is not bucketed into any of the experiments on the feature + # Returns a DecisionResult containing the decision (or nil if not bucketed), + # an error flag, and an array of decision reasons. decide_reasons = [] user_id = user_context.user_id feature_flag_key = feature_flag['key'] @@ -199,7 +218,7 @@ def get_variation_for_feature_experiment(project_config, feature_flag, user_cont message = "The feature flag '#{feature_flag_key}' is not used in any experiments." @logger.log(Logger::DEBUG, message) decide_reasons.push(message) - return nil, decide_reasons + return DecisionResult.new(nil, false, decide_reasons) end # Evaluate each experiment and return the first bucketed experiment variation @@ -209,26 +228,30 @@ def get_variation_for_feature_experiment(project_config, feature_flag, user_cont message = "Feature flag experiment with ID '#{experiment_id}' is not in the datafile." @logger.log(Logger::DEBUG, message) decide_reasons.push(message) - return nil, decide_reasons + return DecisionResult.new(nil, false, decide_reasons) end experiment_id = experiment['id'] - variation_id, reasons_received = get_variation_from_experiment_rule(project_config, feature_flag_key, experiment, user_context, user_profile_tracker, decide_options) + variation_result = get_variation_from_experiment_rule(project_config, feature_flag_key, experiment, user_context, user_profile_tracker, decide_options) + error = variation_result.error + reasons_received = variation_result.reasons + variation_id = variation_result.variation_id + cmab_uuid = variation_result.cmab_uuid decide_reasons.push(*reasons_received) - next unless variation_id variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id) variation = project_config.get_variation_from_flag(feature_flag['key'], variation_id, 'id') if variation.nil? - return Decision.new(experiment, variation, DECISION_SOURCES['FEATURE_TEST']), decide_reasons + decision = Decision.new(experiment, variation, DECISION_SOURCES['FEATURE_TEST'], cmab_uuid) + return DecisionResult.new(decision, error, decide_reasons) end message = "The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'." @logger.log(Logger::INFO, message) decide_reasons.push(message) - [nil, decide_reasons] + DecisionResult.new(nil, false, decide_reasons) end def get_variation_for_feature_rollout(project_config, feature_flag, user_context) @@ -239,7 +262,8 @@ def get_variation_for_feature_rollout(project_config, feature_flag, user_context # feature_flag - The feature flag the user wants to access # user_context - Optimizely user context instance # - # Returns the Decision struct or nil if not bucketed into any of the targeting rules + # Returns a DecisionResult containing the decision (or nil if not bucketed), + # an error flag, and an array of decision reasons. decide_reasons = [] rollout_id = feature_flag['rolloutId'] @@ -248,7 +272,7 @@ def get_variation_for_feature_rollout(project_config, feature_flag, user_context message = "Feature flag '#{feature_flag_key}' is not used in a rollout." @logger.log(Logger::DEBUG, message) decide_reasons.push(message) - return nil, decide_reasons + return DecisionResult.new(nil, false, decide_reasons) end rollout = project_config.get_rollout_from_id(rollout_id) @@ -256,10 +280,10 @@ def get_variation_for_feature_rollout(project_config, feature_flag, user_context message = "Rollout with ID '#{rollout_id}' is not in the datafile '#{feature_flag['key']}'" @logger.log(Logger::DEBUG, message) decide_reasons.push(message) - return nil, decide_reasons + return DecisionResult.new(nil, false, decide_reasons) end - return nil, decide_reasons if rollout['experiments'].empty? + return DecisionResult.new(nil, false, decide_reasons) if rollout['experiments'].empty? index = 0 rollout_rules = rollout['experiments'] @@ -268,14 +292,14 @@ def get_variation_for_feature_rollout(project_config, feature_flag, user_context decide_reasons.push(*reasons_received) if variation rule = rollout_rules[index] - feature_decision = Decision.new(rule, variation, DECISION_SOURCES['ROLLOUT']) - return [feature_decision, decide_reasons] + feature_decision = Decision.new(rule, variation, DECISION_SOURCES['ROLLOUT'], nil) + return DecisionResult.new(feature_decision, false, decide_reasons) end index = skip_to_everyone_else ? (rollout_rules.length - 1) : (index + 1) end - [nil, decide_reasons] + DecisionResult.new(nil, false, decide_reasons) end def get_variation_from_experiment_rule(project_config, flag_key, rule, user, user_profile_tracker, options = []) @@ -293,13 +317,11 @@ def get_variation_from_experiment_rule(project_config, flag_key, rule, user, use context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(flag_key, rule['key']) variation, forced_reasons = validated_forced_decision(project_config, context, user) reasons.push(*forced_reasons) + return VariationResult.new(nil, false, reasons, variation['id']) if variation - return [variation['id'], reasons] if variation - - variation_id, response_reasons = get_variation(project_config, rule['id'], user, user_profile_tracker, options) - reasons.push(*response_reasons) - - [variation_id, reasons] + variation_result = get_variation(project_config, rule['id'], user, user_profile_tracker, options) + variation_result.reasons = reasons + variation_result.reasons + variation_result end def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index, user_context) @@ -467,6 +489,50 @@ def validated_forced_decision(project_config, context, user_context) private + def get_decision_for_cmab_experiment(project_config, experiment, user_context, bucketing_id, decide_options = []) + # Determines the CMAB (Contextual Multi-Armed Bandit) decision for a given experiment and user context. + # + # This method first checks if the user is bucketed into the CMAB experiment based on traffic allocation. + # If the user is not bucketed, it returns a CmabDecisionResult indicating exclusion. + # If the user is bucketed, it attempts to fetch a CMAB decision using the CMAB service. + # In case of errors during CMAB decision retrieval, it logs the error and returns a result indicating failure. + # + # @param project_config [ProjectConfig] The current project configuration. + # @param experiment [Hash] The experiment configuration hash. + # @param user_context [OptimizelyUserContext] The user context object containing user information. + # @param bucketing_id [String] The bucketing ID used for traffic allocation. + # @param decide_options [Array] Optional array of decision options. + # + # @return [CmabDecisionResult] The result of the CMAB decision process, including decision error status, decision data, and reasons. + decide_reasons = [] + user_id = user_context.user_id + + # Check if user is in CMAB traffic allocation + bucketed_entity_id, bucket_reasons = @bucketer.bucket_to_entity_id( + project_config, experiment, user_id, bucketing_id + ) + decide_reasons.push(*bucket_reasons) + unless bucketed_entity_id + message = "User \"#{user_context.user_id}\" not in CMAB experiment \"#{experiment['key']}\" due to traffic allocation." + @logger.log(Logger::INFO, message) + decide_reasons.push(message) + return CmabDecisionResult.new(false, nil, decide_reasons) + end + + # User is in CMAB allocation, proceed to CMAB decision + begin + cmab_decision = @cmab_service.get_decision( + project_config, user_context, experiment['id'], decide_options + ) + CmabDecisionResult.new(false, cmab_decision, decide_reasons) + rescue StandardError => e + error_message = "Failed to fetch CMAB decision for experiment '#{experiment['key']}'" + decide_reasons.push(error_message) + @logger&.log(Logger::ERROR, "#{error_message} #{e}") + CmabDecisionResult.new(true, nil, decide_reasons) + end + end + def get_whitelisted_variation_id(project_config, experiment_id, user_id) # Determine if a user is whitelisted into a variation for the given experiment and return the ID of that variation # diff --git a/spec/decision_service_spec.rb b/spec/decision_service_spec.rb index af22b18b..e524203e 100644 --- a/spec/decision_service_spec.rb +++ b/spec/decision_service_spec.rb @@ -26,8 +26,9 @@ let(:error_handler) { Optimizely::NoOpErrorHandler.new } let(:spy_logger) { spy('logger') } let(:spy_user_profile_service) { spy('user_profile_service') } + let(:spy_cmab_service) { spy('cmab_service') } let(:config) { Optimizely::DatafileProjectConfig.new(config_body_JSON, spy_logger, error_handler) } - let(:decision_service) { Optimizely::DecisionService.new(spy_logger, spy_user_profile_service) } + let(:decision_service) { Optimizely::DecisionService.new(spy_logger, spy_cmab_service, spy_user_profile_service) } let(:project_instance) { Optimizely::Project.new(datafile: config_body_JSON, logger: spy_logger, error_handler: error_handler) } let(:user_context) { project_instance.create_user_context('some-user', {}) } after(:example) { project_instance.close } @@ -46,9 +47,9 @@ it 'should return the correct variation ID for a given user for whom a variation has been forced' do decision_service.set_forced_variation(config, 'test_experiment', 'test_user', 'variation') user_context = project_instance.create_user_context('test_user') - variation_received, reasons = decision_service.get_variation(config, '111127', user_context) - expect(variation_received).to eq('111129') - expect(reasons).to eq(["Variation 'variation' is mapped to experiment '111127' and user 'test_user' in the forced variation map"]) + variation_result = decision_service.get_variation(config, '111127', user_context) + expect(variation_result.variation_id).to eq('111129') + expect(variation_result.reasons).to eq(["Variation 'variation' is mapped to experiment '111127' and user 'test_user' in the forced variation map"]) # Setting forced variation should short circuit whitelist check, bucketing and audience evaluation expect(decision_service).not_to have_received(:get_whitelisted_variation_id) expect(decision_service.bucketer).not_to have_received(:bucket) @@ -62,9 +63,9 @@ } decision_service.set_forced_variation(config, 'test_experiment_with_audience', 'test_user', 'control_with_audience') user_context = project_instance.create_user_context('test_user', user_attributes) - variation_received, reasons = decision_service.get_variation(config, '122227', user_context) - expect(variation_received).to eq('122228') - expect(reasons).to eq(["Variation 'control_with_audience' is mapped to experiment '122227' and user 'test_user' in the forced variation map"]) + variation_result = decision_service.get_variation(config, '122227', user_context) + expect(variation_result.variation_id).to eq('122228') + expect(variation_result.reasons).to eq(["Variation 'control_with_audience' is mapped to experiment '122227' and user 'test_user' in the forced variation map"]) # Setting forced variation should short circuit whitelist check, bucketing and audience evaluation expect(decision_service).not_to have_received(:get_whitelisted_variation_id) expect(decision_service.bucketer).not_to have_received(:bucket) @@ -74,13 +75,13 @@ it 'should return the correct variation ID for a given user ID and key of a running experiment' do user_context = project_instance.create_user_context('test_user') user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id) - variation_received, reasons = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) - expect(variation_received).to eq('111128') + variation_result = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) + expect(variation_result.variation_id).to eq('111128') - expect(reasons).to eq([ - "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", - "User 'test_user' is in variation 'control' of experiment '111127'." - ]) + expect(variation_result.reasons).to eq([ + "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", + "User 'test_user' is in variation 'control' of experiment '111127'." + ]) expect(spy_logger).to have_received(:log) .once.with(Logger::INFO, "User 'test_user' is in variation 'control' of experiment '111127'.") @@ -92,12 +93,12 @@ allow(decision_service.bucketer).to receive(:bucket).and_return(nil) user_context = project_instance.create_user_context('test_user') user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id) - variation_received, reasons = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) - expect(variation_received).to eq(nil) - expect(reasons).to eq([ - "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", - "User 'test_user' is in no variation." - ]) + variation_result = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) + expect(variation_result.variation_id).to eq(nil) + expect(variation_result.reasons).to eq([ + "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", + "User 'test_user' is in no variation." + ]) expect(spy_logger).to have_received(:log) .once.with(Logger::INFO, "User 'test_user' is in no variation.") @@ -105,20 +106,20 @@ it 'should return correct variation ID if user ID is in whitelisted Variations and variation is valid' do user_context = project_instance.create_user_context('forced_user1') - variation_received, reasons = decision_service.get_variation(config, '111127', user_context) - expect(variation_received).to eq('111128') - expect(reasons).to eq([ - "User 'forced_user1' is whitelisted into variation 'control' of experiment '111127'." - ]) + variation_result = decision_service.get_variation(config, '111127', user_context) + expect(variation_result.variation_id).to eq('111128') + expect(variation_result.reasons).to eq([ + "User 'forced_user1' is whitelisted into variation 'control' of experiment '111127'." + ]) expect(spy_logger).to have_received(:log) .once.with(Logger::INFO, "User 'forced_user1' is whitelisted into variation 'control' of experiment '111127'.") user_context = project_instance.create_user_context('forced_user2') - variation_received, reasons = decision_service.get_variation(config, '111127', user_context) - expect(variation_received).to eq('111129') - expect(reasons).to eq([ - "User 'forced_user2' is whitelisted into variation 'variation' of experiment '111127'." - ]) + variation_result = decision_service.get_variation(config, '111127', user_context) + expect(variation_result.variation_id).to eq('111129') + expect(variation_result.reasons).to eq([ + "User 'forced_user2' is whitelisted into variation 'variation' of experiment '111127'." + ]) expect(spy_logger).to have_received(:log) .once.with(Logger::INFO, "User 'forced_user2' is whitelisted into variation 'variation' of experiment '111127'.") @@ -135,20 +136,20 @@ } user_context = project_instance.create_user_context('forced_user1', user_attributes) - variation_received, reasons = decision_service.get_variation(config, '111127', user_context) - expect(variation_received).to eq('111128') - expect(reasons).to eq([ - "User 'forced_user1' is whitelisted into variation 'control' of experiment '111127'." - ]) + variation_result = decision_service.get_variation(config, '111127', user_context) + expect(variation_result.variation_id).to eq('111128') + expect(variation_result.reasons).to eq([ + "User 'forced_user1' is whitelisted into variation 'control' of experiment '111127'." + ]) expect(spy_logger).to have_received(:log) .once.with(Logger::INFO, "User 'forced_user1' is whitelisted into variation 'control' of experiment '111127'.") user_context = project_instance.create_user_context('forced_user2', user_attributes) - variation_received, reasons = decision_service.get_variation(config, '111127', user_context) - expect(variation_received).to eq('111129') - expect(reasons).to eq([ - "User 'forced_user2' is whitelisted into variation 'variation' of experiment '111127'." - ]) + variation_result = decision_service.get_variation(config, '111127', user_context) + expect(variation_result.variation_id).to eq('111129') + expect(variation_result.reasons).to eq([ + "User 'forced_user2' is whitelisted into variation 'variation' of experiment '111127'." + ]) expect(spy_logger).to have_received(:log) .once.with(Logger::INFO, "User 'forced_user2' is whitelisted into variation 'variation' of experiment '111127'.") @@ -161,11 +162,11 @@ it 'should return the correct variation ID for a user in a whitelisted variation (even when audience conditions do not match)' do user_attributes = {'browser_type' => 'wrong_browser'} user_context = project_instance.create_user_context('forced_audience_user', user_attributes) - variation_received, reasons = decision_service.get_variation(config, '122227', user_context) - expect(variation_received).to eq('122229') - expect(reasons).to eq([ - "User 'forced_audience_user' is whitelisted into variation 'variation_with_audience' of experiment '122227'." - ]) + variation_result = decision_service.get_variation(config, '122227', user_context) + expect(variation_result.variation_id).to eq('122229') + expect(variation_result.reasons).to eq([ + "User 'forced_audience_user' is whitelisted into variation 'variation_with_audience' of experiment '122227'." + ]) expect(spy_logger).to have_received(:log) .once.with( Logger::INFO, @@ -180,9 +181,9 @@ it 'should return nil if the experiment key is invalid' do user_context = project_instance.create_user_context('test_user', {}) - variation_received, reasons = decision_service.get_variation(config, 'totally_invalid_experiment', user_context) - expect(variation_received).to eq(nil) - expect(reasons).to eq([]) + variation_result = decision_service.get_variation(config, 'totally_invalid_experiment', user_context) + expect(variation_result.variation_id).to eq(nil) + expect(variation_result.reasons).to eq([]) expect(spy_logger).to have_received(:log) .once.with(Logger::ERROR, "Experiment id 'totally_invalid_experiment' is not in datafile.") @@ -192,14 +193,14 @@ user_attributes = {'browser_type' => 'chrome'} user_context = project_instance.create_user_context('test_user', user_attributes) user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id) - variation_received, reasons = decision_service.get_variation(config, '122227', user_context, user_profile_tracker) - expect(variation_received).to eq(nil) - expect(reasons).to eq([ - "Starting to evaluate audience '11154' with conditions: [\"and\", [\"or\", [\"or\", {\"name\": \"browser_type\", \"type\": \"custom_attribute\", \"value\": \"firefox\"}]]].", - "Audience '11154' evaluated to FALSE.", - "Audiences for experiment 'test_experiment_with_audience' collectively evaluated to FALSE.", - "User 'test_user' does not meet the conditions to be in experiment 'test_experiment_with_audience'." - ]) + variation_result = decision_service.get_variation(config, '122227', user_context, user_profile_tracker) + expect(variation_result.variation_id).to eq(nil) + expect(variation_result.reasons).to eq([ + "Starting to evaluate audience '11154' with conditions: [\"and\", [\"or\", [\"or\", {\"name\": \"browser_type\", \"type\": \"custom_attribute\", \"value\": \"firefox\"}]]].", + "Audience '11154' evaluated to FALSE.", + "Audiences for experiment 'test_experiment_with_audience' collectively evaluated to FALSE.", + "User 'test_user' does not meet the conditions to be in experiment 'test_experiment_with_audience'." + ]) expect(spy_logger).to have_received(:log) .once.with(Logger::INFO, "User 'test_user' does not meet the conditions to be in experiment 'test_experiment_with_audience'.") @@ -211,9 +212,9 @@ it 'should return nil if the given experiment is not running' do user_context = project_instance.create_user_context('test_user') - variation_received, reasons = decision_service.get_variation(config, '100027', user_context) - expect(variation_received).to eq(nil) - expect(reasons).to eq(["Experiment 'test_experiment_not_started' is not running."]) + variation_result = decision_service.get_variation(config, '100027', user_context) + expect(variation_result.variation_id).to eq(nil) + expect(variation_result.reasons).to eq(["Experiment 'test_experiment_not_started' is not running."]) expect(spy_logger).to have_received(:log) .once.with(Logger::INFO, "Experiment 'test_experiment_not_started' is not running.") @@ -227,11 +228,11 @@ it 'should respect forced variations within mutually exclusive grouped experiments' do user_context = project_instance.create_user_context('forced_group_user1') - variation_received, reasons = decision_service.get_variation(config, '133332', user_context) - expect(variation_received).to eq('130004') - expect(reasons).to eq([ - "User 'forced_group_user1' is whitelisted into variation 'g1_e2_v2' of experiment '133332'." - ]) + variation_result = decision_service.get_variation(config, '133332', user_context) + expect(variation_result.variation_id).to eq('130004') + expect(variation_result.reasons).to eq([ + "User 'forced_group_user1' is whitelisted into variation 'g1_e2_v2' of experiment '133332'." + ]) expect(spy_logger).to have_received(:log) .once.with(Logger::INFO, "User 'forced_group_user1' is whitelisted into variation 'g1_e2_v2' of experiment '133332'.") @@ -244,13 +245,13 @@ it 'should bucket normally if user is whitelisted into a forced variation that is not in the datafile' do user_context = project_instance.create_user_context('forced_user_with_invalid_variation') user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id) - variation_received, reasons = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) - expect(variation_received).to eq('111128') - expect(reasons).to eq([ - "User 'forced_user_with_invalid_variation' is whitelisted into variation 'invalid_variation', which is not in the datafile.", - "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", - "User 'forced_user_with_invalid_variation' is in variation 'control' of experiment '111127'." - ]) + variation_result = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) + expect(variation_result.variation_id).to eq('111128') + expect(variation_result.reasons).to eq([ + "User 'forced_user_with_invalid_variation' is whitelisted into variation 'invalid_variation', which is not in the datafile.", + "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", + "User 'forced_user_with_invalid_variation' is in variation 'control' of experiment '111127'." + ]) expect(spy_logger).to have_received(:log) .once.with( Logger::INFO, @@ -270,12 +271,12 @@ } user_context = project_instance.create_user_context('test_user', user_attributes) user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id, spy_user_profile_service, spy_logger) - variation_received, reasons = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) - expect(variation_received).to eq('111129') - expect(reasons).to eq([ - "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", - "User 'test_user' is in variation 'variation' of experiment '111127'." - ]) + variation_result = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) + expect(variation_result.variation_id).to eq('111129') + expect(variation_result.reasons).to eq([ + "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", + "User 'test_user' is in variation 'variation' of experiment '111127'." + ]) # bucketing should have occurred expect(decision_service.bucketer).to have_received(:bucket).once @@ -296,11 +297,11 @@ user_context = project_instance.create_user_context('test_user') user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id, spy_user_profile_service, spy_logger) user_profile_tracker.load_user_profile - variation_received, reasons = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) - expect(variation_received).to eq('111129') - expect(reasons).to eq([ - "Returning previously activated variation ID 111129 of experiment 'test_experiment' for user 'test_user' from user profile." - ]) + variation_result = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) + expect(variation_result.variation_id).to eq('111129') + expect(variation_result.reasons).to eq([ + "Returning previously activated variation ID 111129 of experiment 'test_experiment' for user 'test_user' from user profile." + ]) expect(spy_logger).to have_received(:log).once .with(Logger::INFO, "Returning previously activated variation ID 111129 of experiment 'test_experiment' for user 'test_user' from user profile.") @@ -328,12 +329,12 @@ user_context = project_instance.create_user_context('test_user') user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id, spy_user_profile_service, spy_logger) user_profile_tracker.load_user_profile - variation_received, reasons = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) - expect(variation_received).to eq('111128') - expect(reasons).to eq([ - "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", - "User 'test_user' is in variation 'control' of experiment '111127'." - ]) + variation_result = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) + expect(variation_result.variation_id).to eq('111128') + expect(variation_result.reasons).to eq([ + "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", + "User 'test_user' is in variation 'control' of experiment '111127'." + ]) # bucketing should have occurred expect(decision_service.bucketer).to have_received(:bucket).once @@ -355,13 +356,13 @@ user_context = project_instance.create_user_context('test_user') user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id, spy_user_profile_service, spy_logger) user_profile_tracker.load_user_profile - variation_received, reasons = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) - expect(variation_received).to eq('111128') - expect(reasons).to eq([ - "User 'test_user' was previously bucketed into variation ID '111111' for experiment '111127', but no matching variation was found. Re-bucketing user.", - "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", - "User 'test_user' is in variation 'control' of experiment '111127'." - ]) + variation_result = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) + expect(variation_result.variation_id).to eq('111128') + expect(variation_result.reasons).to eq([ + "User 'test_user' was previously bucketed into variation ID '111111' for experiment '111127', but no matching variation was found. Re-bucketing user.", + "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", + "User 'test_user' is in variation 'control' of experiment '111127'." + ]) # bucketing should have occurred expect(decision_service.bucketer).to have_received(:bucket).once @@ -373,13 +374,13 @@ user_context = project_instance.create_user_context('test_user') user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id, spy_user_profile_service, spy_logger) user_profile_tracker.load_user_profile - variation_received, reasons = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) + variation_result = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) user_profile_tracker.save_user_profile - expect(variation_received).to eq('111128') - expect(reasons).to eq([ - "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", - "User 'test_user' is in variation 'control' of experiment '111127'." - ]) + expect(variation_result.variation_id).to eq('111128') + expect(variation_result.reasons).to eq([ + "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", + "User 'test_user' is in variation 'control' of experiment '111127'." + ]) expect(spy_logger).to have_received(:log).once .with(Logger::ERROR, "Error while looking up user profile for user ID 'test_user': uncaught throw :LookupError.") @@ -395,12 +396,12 @@ user_context = project_instance.create_user_context('test_user', nil) user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id, spy_user_profile_service, spy_logger) user_profile_tracker.load_user_profile - variation_received, reasons = decision_service.get_variation(config, '111127', user_context, user_profile_tracker, [Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE]) - expect(variation_received).to eq('111128') - expect(reasons).to eq([ - "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", - "User 'test_user' is in variation 'control' of experiment '111127'." - ]) + variation_result = decision_service.get_variation(config, '111127', user_context, user_profile_tracker, [Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE]) + expect(variation_result.variation_id).to eq('111128') + expect(variation_result.reasons).to eq([ + "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", + "User 'test_user' is in variation 'control' of experiment '111127'." + ]) expect(decision_service.bucketer).to have_received(:bucket) expect(Optimizely::Audience).to have_received(:user_meets_audience_conditions?) @@ -417,9 +418,9 @@ describe 'when the feature flag\'s experiment ids array is empty' do it 'should return nil and log a message' do feature_flag = config.feature_flag_key_map['empty_feature'] - variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context, user_profile_tracker) - expect(variation_received).to eq(nil) - expect(reasons).to eq(["The feature flag 'empty_feature' is not used in any experiments."]) + decision_result = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context, user_profile_tracker) + expect(decision_result.decision).to eq(nil) + expect(decision_result.reasons).to eq(["The feature flag 'empty_feature' is not used in any experiments."]) expect(spy_logger).to have_received(:log).once .with(Logger::DEBUG, "The feature flag 'empty_feature' is not used in any experiments.") @@ -432,9 +433,9 @@ # any string that is not an experiment id in the data file feature_flag['experimentIds'] = ['1333333337'] user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id) - variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context, user_profile_tracker) - expect(variation_received).to eq(nil) - expect(reasons).to eq(["Feature flag experiment with ID '1333333337' is not in the datafile."]) + decision_result = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context, user_profile_tracker) + expect(decision_result.decision).to eq(nil) + expect(decision_result.reasons).to eq(["Feature flag experiment with ID '1333333337' is not in the datafile."]) expect(spy_logger).to have_received(:log).once .with(Logger::DEBUG, "Feature flag experiment with ID '1333333337' is not in the datafile.") end @@ -448,14 +449,14 @@ # make sure the user is not bucketed into the feature experiment allow(decision_service).to receive(:get_variation) .with(config, multivariate_experiment['id'], user_context, user_profile_tracker, []) - .and_return([nil, nil]) + .and_return(Optimizely::DecisionService::VariationResult.new(nil, false, [], nil)) end it 'should return nil and log a message' do feature_flag = config.feature_flag_key_map['multi_variate_feature'] - variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context, user_profile_tracker, []) - expect(variation_received).to eq(nil) - expect(reasons).to eq(["The user 'user_1' is not bucketed into any of the experiments on the feature 'multi_variate_feature'."]) + decision_result = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context, user_profile_tracker, []) + expect(decision_result.decision).to eq(nil) + expect(decision_result.reasons).to eq(["The user 'user_1' is not bucketed into any of the experiments on the feature 'multi_variate_feature'."]) expect(spy_logger).to have_received(:log).once .with(Logger::INFO, "The user 'user_1' is not bucketed into any of the experiments on the feature 'multi_variate_feature'.") @@ -465,7 +466,7 @@ describe 'and the user is bucketed into a variation for the experiment on the feature flag' do before(:each) do # mock and return the first variation of the `test_experiment_multivariate` experiment, which is attached to the `multi_variate_feature` - allow(decision_service).to receive(:get_variation).and_return('122231') + allow(decision_service).to receive(:get_variation).and_return(Optimizely::DecisionService::VariationResult.new(nil, false, [], '122231')) end it 'should return the variation' do @@ -475,10 +476,15 @@ config.variation_id_map['test_experiment_multivariate']['122231'], Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] ) + expected_decision_result = Optimizely::DecisionService::DecisionResult.new( + expected_decision, + false, + [] + ) user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id) - variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context, user_profile_tracker) - expect(variation_received).to eq(expected_decision) - expect(reasons).to eq([]) + decision_result = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context, user_profile_tracker) + expect(decision_result).to eq(expected_decision_result) + expect(decision_result.reasons).to eq([]) end end end @@ -496,15 +502,15 @@ Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] ) allow(decision_service).to receive(:get_variation) - .and_return(variation['id']) + .and_return(Optimizely::DecisionService::VariationResult.new(nil, false, [], variation['id'])) end it 'should return the variation the user is bucketed into' do feature_flag = config.feature_flag_key_map['mutex_group_feature'] user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id) - variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context, user_profile_tracker) - expect(variation_received).to eq(expected_decision) - expect(reasons).to eq([]) + decision_result = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context, user_profile_tracker) + expect(decision_result.decision).to eq(expected_decision) + expect(decision_result.reasons).to eq([]) end end @@ -515,17 +521,17 @@ mutex_exp2 = config.experiment_key_map['group1_exp2'] allow(decision_service).to receive(:get_variation) .with(config, mutex_exp['id'], user_context, user_profile_tracker, []) - .and_return([nil, nil]) + .and_return(Optimizely::DecisionService::VariationResult.new(nil, false, [], nil)) allow(decision_service).to receive(:get_variation) .with(config, mutex_exp2['id'], user_context, user_profile_tracker, []) - .and_return([nil, nil]) + .and_return(Optimizely::DecisionService::VariationResult.new(nil, false, [], nil)) end it 'should return nil and log a message' do feature_flag = config.feature_flag_key_map['mutex_group_feature'] - variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context, user_profile_tracker) - expect(variation_received).to eq(nil) - expect(reasons).to eq(["The user 'user_1' is not bucketed into any of the experiments on the feature 'mutex_group_feature'."]) + decision_result = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context, user_profile_tracker) + expect(decision_result.decision).to eq(nil) + expect(decision_result.reasons).to eq(["The user 'user_1' is not bucketed into any of the experiments on the feature 'mutex_group_feature'."]) expect(spy_logger).to have_received(:log).once .with(Logger::INFO, "The user 'user_1' is not bucketed into any of the experiments on the feature 'mutex_group_feature'.") @@ -543,9 +549,9 @@ describe 'when the feature flag is not associated with a rollout' do it 'should log a message and return nil' do feature_flag = config.feature_flag_key_map['boolean_feature'] - variation_received, reasons = decision_service.get_variation_for_feature_rollout(config, feature_flag, user_context) - expect(variation_received).to eq(nil) - expect(reasons).to eq(["Feature flag '#{feature_flag['key']}' is not used in a rollout."]) + decision_result = decision_service.get_variation_for_feature_rollout(config, feature_flag, user_context) + expect(decision_result.decision).to eq(nil) + expect(decision_result.reasons).to eq(["Feature flag '#{feature_flag['key']}' is not used in a rollout."]) expect(spy_logger).to have_received(:log).once .with(Logger::DEBUG, "Feature flag '#{feature_flag['key']}' is not used in a rollout.") end @@ -555,9 +561,9 @@ it 'should log a message and return nil' do feature_flag = config.feature_flag_key_map['boolean_feature'].dup feature_flag['rolloutId'] = 'invalid_rollout_id' - variation_received, reasons = decision_service.get_variation_for_feature_rollout(config, feature_flag, user_context) - expect(variation_received).to eq(nil) - expect(reasons).to eq(["Rollout with ID 'invalid_rollout_id' is not in the datafile 'boolean_feature'"]) + decision_result = decision_service.get_variation_for_feature_rollout(config, feature_flag, user_context) + expect(decision_result.decision).to eq(nil) + expect(decision_result.reasons).to eq(["Rollout with ID 'invalid_rollout_id' is not in the datafile 'boolean_feature'"]) expect(spy_logger).to have_received(:log).once .with(Logger::ERROR, "Rollout with ID 'invalid_rollout_id' is not in the datafile.") @@ -570,9 +576,9 @@ experimentless_rollout['experiments'] = [] allow(config).to receive(:get_rollout_from_id).and_return(experimentless_rollout) feature_flag = config.feature_flag_key_map['boolean_single_variable_feature'] - variation_received, reasons = decision_service.get_variation_for_feature_rollout(config, feature_flag, user_context) - expect(variation_received).to eq(nil) - expect(reasons).to eq([]) + decision_result = decision_service.get_variation_for_feature_rollout(config, feature_flag, user_context) + expect(decision_result.decision).to eq(nil) + expect(decision_result.reasons).to eq([]) end end @@ -587,10 +593,10 @@ allow(decision_service.bucketer).to receive(:bucket) .with(config, rollout_experiment, user_id, user_id) .and_return(variation) - variation_received, reasons = decision_service.get_variation_for_feature_rollout(config, feature_flag, user_context) - expect(variation_received).to eq(expected_decision) - expect(reasons).to eq(["User 'user_1' meets the audience conditions for targeting rule '1'.", - "User 'user_1' is in the traffic group of targeting rule '1'."]) + decision_result = decision_service.get_variation_for_feature_rollout(config, feature_flag, user_context) + expect(decision_result.decision).to eq(expected_decision) + expect(decision_result.reasons).to eq(["User 'user_1' meets the audience conditions for targeting rule '1'.", + "User 'user_1' is in the traffic group of targeting rule '1'."]) end end @@ -609,13 +615,13 @@ .with(config, everyone_else_experiment, user_id, user_id) .and_return(nil) - variation_received, reasons = decision_service.get_variation_for_feature_rollout(config, feature_flag, user_context) - expect(variation_received).to eq(nil) - expect(reasons).to eq([ - "User 'user_1' meets the audience conditions for targeting rule '1'.", - "User 'user_1' is not in the traffic group for targeting rule '1'.", - "User 'user_1' meets the audience conditions for targeting rule 'Everyone Else'." - ]) + decision_result = decision_service.get_variation_for_feature_rollout(config, feature_flag, user_context) + expect(decision_result.decision).to eq(nil) + expect(decision_result.reasons).to eq([ + "User 'user_1' meets the audience conditions for targeting rule '1'.", + "User 'user_1' is not in the traffic group for targeting rule '1'.", + "User 'user_1' meets the audience conditions for targeting rule 'Everyone Else'." + ]) # make sure we only checked the audience for the first rule expect(Optimizely::Audience).to have_received(:user_meets_audience_conditions?).once @@ -640,14 +646,14 @@ .with(config, everyone_else_experiment, user_id, user_id) .and_return(variation) - variation_received, reasons = decision_service.get_variation_for_feature_rollout(config, feature_flag, user_context) - expect(variation_received).to eq(expected_decision) - expect(reasons).to eq([ - "User 'user_1' meets the audience conditions for targeting rule '1'.", - "User 'user_1' is not in the traffic group for targeting rule '1'.", - "User 'user_1' meets the audience conditions for targeting rule 'Everyone Else'.", - "User 'user_1' is in the traffic group of targeting rule 'Everyone Else'." - ]) + decision_result = decision_service.get_variation_for_feature_rollout(config, feature_flag, user_context) + expect(decision_result.decision).to eq(expected_decision) + expect(decision_result.reasons).to eq([ + "User 'user_1' meets the audience conditions for targeting rule '1'.", + "User 'user_1' is not in the traffic group for targeting rule '1'.", + "User 'user_1' meets the audience conditions for targeting rule 'Everyone Else'.", + "User 'user_1' is in the traffic group of targeting rule 'Everyone Else'." + ]) # make sure we only checked the audience for the first rule expect(Optimizely::Audience).to have_received(:user_meets_audience_conditions?).once @@ -675,14 +681,14 @@ .with(config, everyone_else_experiment, user_id, user_id) .and_return(variation) - variation_received, reasons = decision_service.get_variation_for_feature_rollout(config, feature_flag, user_context) - expect(variation_received).to eq(expected_decision) - expect(reasons).to eq([ - "User 'user_1' does not meet the conditions for targeting rule '1'.", - "User 'user_1' does not meet the conditions for targeting rule '2'.", - "User 'user_1' meets the audience conditions for targeting rule 'Everyone Else'.", - "User 'user_1' is in the traffic group of targeting rule 'Everyone Else'." - ]) + decision_result = decision_service.get_variation_for_feature_rollout(config, feature_flag, user_context) + expect(decision_result.decision).to eq(expected_decision) + expect(decision_result.reasons).to eq([ + "User 'user_1' does not meet the conditions for targeting rule '1'.", + "User 'user_1' does not meet the conditions for targeting rule '2'.", + "User 'user_1' meets the audience conditions for targeting rule 'Everyone Else'.", + "User 'user_1' is in the traffic group of targeting rule 'Everyone Else'." + ]) # verify we tried to bucket in all targeting rules and the everyone else rule expect(Optimizely::Audience).to have_received(:user_meets_audience_conditions?).exactly(3).times @@ -705,13 +711,13 @@ expect(decision_service.bucketer).not_to receive(:bucket) .with(config, everyone_else_experiment, user_id, user_id) - variation_received, reasons = decision_service.get_variation_for_feature_rollout(config, feature_flag, user_context) - expect(variation_received).to eq(nil) - expect(reasons).to eq([ - "User 'user_1' does not meet the conditions for targeting rule '1'.", - "User 'user_1' does not meet the conditions for targeting rule '2'.", - "User 'user_1' does not meet the conditions for targeting rule 'Everyone Else'." - ]) + decision_result = decision_service.get_variation_for_feature_rollout(config, feature_flag, user_context) + expect(decision_result.decision).to eq(nil) + expect(decision_result.reasons).to eq([ + "User 'user_1' does not meet the conditions for targeting rule '1'.", + "User 'user_1' does not meet the conditions for targeting rule '2'.", + "User 'user_1' does not meet the conditions for targeting rule 'Everyone Else'." + ]) # verify we tried to bucket in all targeting rules and the everyone else rule expect(Optimizely::Audience).to have_received(:user_meets_audience_conditions?).once @@ -745,11 +751,11 @@ 'experiment' => expected_experiment, 'variation' => expected_variation } - allow(decision_service).to receive(:get_variation_for_feature_experiment).and_return([expected_decision, nil]) + allow(decision_service).to receive(:get_variation_for_feature_experiment).and_return(Optimizely::DecisionService::DecisionResult.new(expected_decision, false, [])) - decision_received, reasons = decision_service.get_variation_for_feature(config, feature_flag, user_context) - expect(decision_received).to eq(expected_decision) - expect(reasons).to eq([]) + decision_result = decision_service.get_variation_for_feature(config, feature_flag, user_context) + expect(decision_result.decision).to eq(expected_decision) + expect(decision_result.reasons).to eq([]) end end @@ -764,24 +770,24 @@ variation, Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] ) - allow(decision_service).to receive(:get_variation_for_feature_experiment).and_return([nil, nil]) - allow(decision_service).to receive(:get_variation_for_feature_rollout).and_return([expected_decision, nil]) + allow(decision_service).to receive(:get_variation_for_feature_experiment).and_return(Optimizely::DecisionService::DecisionResult.new(nil, false, [])) + allow(decision_service).to receive(:get_variation_for_feature_rollout).and_return(Optimizely::DecisionService::DecisionResult.new(expected_decision, false, [])) - decision_received, reasons = decision_service.get_variation_for_feature(config, feature_flag, user_context) - expect(decision_received).to eq(expected_decision) - expect(reasons).to eq([]) + decision_result = decision_service.get_variation_for_feature(config, feature_flag, user_context) + expect(decision_result.decision).to eq(expected_decision) + expect(decision_result.reasons).to eq([]) end end describe 'and the user is not bucketed into the feature rollout' do it 'should log a message and return nil' do feature_flag = config.feature_flag_key_map['string_single_variable_feature'] - allow(decision_service).to receive(:get_variation_for_feature_experiment).and_return([nil, nil]) - allow(decision_service).to receive(:get_variation_for_feature_rollout).and_return([nil, nil]) + allow(decision_service).to receive(:get_variation_for_feature_experiment).and_return(Optimizely::DecisionService::DecisionResult.new(nil, false, [])) + allow(decision_service).to receive(:get_variation_for_feature_rollout).and_return(Optimizely::DecisionService::DecisionResult.new(nil, false, [])) - decision_received, reasons = decision_service.get_variation_for_feature(config, feature_flag, user_context) - expect(decision_received).to eq(nil) - expect(reasons).to eq([]) + decision_result = decision_service.get_variation_for_feature(config, feature_flag, user_context) + expect(decision_result.decision).to eq(nil) + expect(decision_result.reasons).to eq([]) end end end @@ -931,4 +937,234 @@ expect(reasons).to eq(["Variation 'control' is mapped to experiment '111127' and user 'test_user_2' in the forced variation map"]) end end + describe 'CMAB experiments' do + describe 'when user is in traffic allocation' do + it 'should return correct variation and CMAB UUID from CMAB service' do + # Create a CMAB experiment configuration + cmab_experiment = { + 'id' => '111150', + 'key' => 'cmab_experiment', + 'status' => 'Running', + 'layerId' => '111150', + 'audienceIds' => [], + 'forcedVariations' => {}, + 'variations' => [ + {'id' => '111151', 'key' => 'variation_1'}, + {'id' => '111152', 'key' => 'variation_2'} + ], + 'trafficAllocation' => [ + {'entityId' => '111151', 'endOfRange' => 5000}, + {'entityId' => '111152', 'endOfRange' => 10_000} + ], + 'cmab' => {'trafficAllocation' => 5000} + } + user_context = project_instance.create_user_context('test_user', {}) + + # Mock experiment lookup to return our CMAB experiment + allow(config).to receive(:get_experiment_from_id).with('111150').and_return(cmab_experiment) + allow(config).to receive(:experiment_running?).with(cmab_experiment).and_return(true) + + # Mock audience evaluation to pass + allow(Optimizely::Audience).to receive(:user_meets_audience_conditions?).and_return([true, []]) + + # Mock bucketer to return a valid entity ID (user is in traffic allocation) + allow(decision_service.bucketer).to receive(:bucket_to_entity_id) + .with(config, cmab_experiment, 'test_user', 'test_user') + .and_return(['$', []]) + + # Mock CMAB service to return a decision + allow(spy_cmab_service).to receive(:get_decision) + .with(config, user_context, '111150', []) + .and_return(Optimizely::CmabDecision.new(variation_id: '111151', cmab_uuid: 'test-cmab-uuid-123')) + + # Mock variation lookup + allow(config).to receive(:get_variation_from_id_by_experiment_id) + .with('111150', '111151') + .and_return({'id' => '111151', 'key' => 'variation_1'}) + + variation_result = decision_service.get_variation(config, '111150', user_context) + + expect(variation_result.variation_id).to eq('111151') + expect(variation_result.cmab_uuid).to eq('test-cmab-uuid-123') + expect(variation_result.error).to eq(false) + expect(variation_result.reasons).to include( + "User 'test_user' is in variation 'variation_1' of experiment '111150'." + ) + + # Verify CMAB service was called + expect(spy_cmab_service).to have_received(:get_decision).once + end + end + + describe 'when user is not in traffic allocation' do + it 'should return nil variation and log traffic allocation message' do + cmab_experiment = { + 'id' => '111150', + 'key' => 'cmab_experiment', + 'status' => 'Running', + 'layerId' => '111150', + 'audienceIds' => [], + 'forcedVariations' => {}, + 'variations' => [ + {'id' => '111151', 'key' => 'variation_1'} + ], + 'trafficAllocation' => [ + {'entityId' => '111151', 'endOfRange' => 10_000} + ], + 'cmab' => {'trafficAllocation' => 1000} + } + user_context = project_instance.create_user_context('test_user', {}) + + # Mock experiment lookup to return our CMAB experiment + allow(config).to receive(:get_experiment_from_id).with('111150').and_return(cmab_experiment) + allow(config).to receive(:experiment_running?).with(cmab_experiment).and_return(true) + + # Mock audience evaluation to pass + allow(Optimizely::Audience).to receive(:user_meets_audience_conditions?).and_return([true, []]) + + variation_result = decision_service.get_variation(config, '111150', user_context) + + expect(variation_result.variation_id).to eq(nil) + expect(variation_result.cmab_uuid).to eq(nil) + expect(variation_result.error).to eq(false) + expect(variation_result.reasons).to include( + 'User "test_user" not in CMAB experiment "cmab_experiment" due to traffic allocation.' + ) + + # Verify CMAB service was not called since user is not in traffic allocation + expect(spy_cmab_service).not_to have_received(:get_decision) + end + end + + describe 'when CMAB service returns an error' do + it 'should return nil variation and include error in reasons' do + cmab_experiment = { + 'id' => '111150', + 'key' => 'cmab_experiment', + 'status' => 'Running', + 'layerId' => '111150', + 'audienceIds' => [], + 'forcedVariations' => {}, + 'variations' => [ + {'id' => '111151', 'key' => 'variation_1'} + ], + 'trafficAllocation' => [ + {'entityId' => '111151', 'endOfRange' => 10_000} + ], + 'cmab' => {'trafficAllocation' => 5000} + } + user_context = project_instance.create_user_context('test_user', {}) + + # Mock experiment lookup to return our CMAB experiment + allow(config).to receive(:get_experiment_from_id).with('111150').and_return(cmab_experiment) + allow(config).to receive(:experiment_running?).with(cmab_experiment).and_return(true) + + # Mock audience evaluation to pass + allow(Optimizely::Audience).to receive(:user_meets_audience_conditions?).and_return([true, []]) + + # Mock bucketer to return a valid entity ID (user is in traffic allocation) + allow(decision_service.bucketer).to receive(:bucket_to_entity_id) + .with(config, cmab_experiment, 'test_user', 'test_user') + .and_return(['$', []]) + + # Mock CMAB service to return an error + allow(spy_cmab_service).to receive(:get_decision) + .with(config, user_context, '111150', []) + .and_raise(StandardError.new('CMAB service error')) + + variation_result = decision_service.get_variation(config, '111150', user_context) + + expect(variation_result.variation_id).to be_nil + expect(variation_result.cmab_uuid).to be_nil + expect(variation_result.error).to eq(true) + expect(variation_result.reasons).to include( + "Failed to fetch CMAB decision for experiment 'cmab_experiment'" + ) + + # Verify CMAB service was called but errored + expect(spy_cmab_service).to have_received(:get_decision).once + end + end + + describe 'when user has forced variation' do + it 'should return forced variation and skip CMAB service call' do + # Use a real experiment from the datafile and modify it to be a CMAB experiment + real_experiment = config.get_experiment_from_key('test_experiment') + cmab_experiment = real_experiment.dup + cmab_experiment['cmab'] = {'trafficAllocation' => 5000} + + user_context = project_instance.create_user_context('test_user', {}) + + # Set up forced variation first (using real experiment that exists in datafile) + decision_service.set_forced_variation(config, 'test_experiment', 'test_user', 'variation') + + # Mock the experiment to be a CMAB experiment after setting forced variation + allow(config).to receive(:get_experiment_from_id).with('111127').and_return(cmab_experiment) + allow(config).to receive(:experiment_running?).with(cmab_experiment).and_return(true) + + # Add spy for bucket_to_entity_id method + allow(decision_service.bucketer).to receive(:bucket_to_entity_id).and_call_original + + variation_result = decision_service.get_variation(config, '111127', user_context) + + expect(variation_result.variation_id).to eq('111129') + expect(variation_result.cmab_uuid).to be_nil + expect(variation_result.error).to eq(false) + expect(variation_result.reasons).to include( + "Variation 'variation' is mapped to experiment '111127' and user 'test_user' in the forced variation map" + ) + + # Verify CMAB service was not called since user has forced variation + expect(spy_cmab_service).not_to have_received(:get_decision) + # Verify bucketer was not called since forced variations short-circuit bucketing + expect(decision_service.bucketer).not_to have_received(:bucket_to_entity_id) + end + end + + describe 'when user has whitelisted variation' do + it 'should return whitelisted variation and skip CMAB service call' do + # Create a CMAB experiment with whitelisted users + cmab_experiment = { + 'id' => '111150', + 'key' => 'cmab_experiment', + 'status' => 'Running', + 'layerId' => '111150', + 'audienceIds' => [], + 'forcedVariations' => { + 'whitelisted_user' => '111151' # User is whitelisted to variation_1 + }, + 'variations' => [ + {'id' => '111151', 'key' => 'variation_1'}, + {'id' => '111152', 'key' => 'variation_2'} + ], + 'trafficAllocation' => [ + {'entityId' => '111151', 'endOfRange' => 5000}, + {'entityId' => '111152', 'endOfRange' => 10_000} + ], + 'cmab' => {'trafficAllocation' => 5000} + } + user_context = project_instance.create_user_context('whitelisted_user', {}) + + # Mock experiment lookup to return our CMAB experiment + allow(config).to receive(:get_experiment_from_id).with('111150').and_return(cmab_experiment) + allow(config).to receive(:experiment_running?).with(cmab_experiment).and_return(true) + + # Mock the get_whitelisted_variation_id method directly + allow(decision_service).to receive(:get_whitelisted_variation_id) + .with(config, '111150', 'whitelisted_user') + .and_return(['111151', "User 'whitelisted_user' is whitelisted into variation 'variation_1' of experiment '111150'."]) + + variation_result = decision_service.get_variation(config, '111150', user_context) + + expect(variation_result.variation_id).to eq('111151') + expect(variation_result.cmab_uuid).to be_nil + expect(variation_result.error).to eq(false) + expect(variation_result.reasons).to include( + "User 'whitelisted_user' is whitelisted into variation 'variation_1' of experiment '111150'." + ) + # Verify CMAB service was not called since user is whitelisted + expect(spy_cmab_service).not_to have_received(:get_decision) + end + end + end end diff --git a/spec/project_spec.rb b/spec/project_spec.rb index f857a5ce..28437a16 100644 --- a/spec/project_spec.rb +++ b/spec/project_spec.rb @@ -1681,7 +1681,7 @@ def callback(_args); end it 'should return false and send an impression when the user is not bucketed into any variation' do allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(nil) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(Optimizely::DecisionService::DecisionResult.new(nil, false, [])) expect(project_instance.is_feature_enabled('multi_variate_feature', 'test_user')).to be(false) @@ -1703,7 +1703,7 @@ def callback(_args); end ) allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, [])) expect(project_instance.is_feature_enabled('boolean_single_variable_feature', 'test_user')).to be true @@ -1723,7 +1723,7 @@ def callback(_args); end Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] ) allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, [])) expect(variation_to_return['featureEnabled']).to be false expect(project_instance.is_feature_enabled('boolean_single_variable_feature', 'test_user')).to be false @@ -1744,7 +1744,7 @@ def callback(_args); end Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] ) allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, [])) expect(variation_to_return['featureEnabled']).to be true expect(project_instance.is_feature_enabled('boolean_single_variable_feature', 'test_user')).to be true @@ -1841,7 +1841,7 @@ def callback(_args); end Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION], any_args ).ordered - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, [])) expect(project_instance.is_feature_enabled('multi_variate_feature', 'test_user')).to be true @@ -1862,7 +1862,7 @@ def callback(_args); end Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] ) expect(variation_to_return['featureEnabled']).to be false - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, [])) expect(project_instance.is_feature_enabled('multi_variate_feature', 'test_user')).to be false @@ -1888,7 +1888,7 @@ def callback(_args); end Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] ) - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, [])) # Activate listener expect(project_instance.notification_center).to receive(:send_notifications).once.with( @@ -1925,7 +1925,7 @@ def callback(_args); end Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] ) - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, [])) expect(project_instance.notification_center).to receive(:send_notifications).once.with( Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args @@ -1959,7 +1959,7 @@ def callback(_args); end variation_to_return, Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] ) - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, [])) # DECISION listener called when the user is in rollout with variation feature true. expect(variation_to_return['featureEnabled']).to be true @@ -1983,8 +1983,21 @@ def callback(_args); end end it 'should call decision listener when user is bucketed into rollout with featureEnabled property is false' do - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(Optimizely::DecisionService::Decision) + experiment_to_return = config_body['rollouts'][0]['experiments'][1] + variation_to_return = experiment_to_return['variations'][0] + decision_to_return = Optimizely::DecisionService::Decision.new( + experiment_to_return, + variation_to_return, + Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] + ) + # Ensure featureEnabled is false for this test + expect(variation_to_return['featureEnabled']).to be false + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, [])) + + expect(project_instance.notification_center).to receive(:send_notifications).once.with( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args + ).ordered # DECISION listener called when the user is in rollout with variation feature off. expect(project_instance.notification_center).to receive(:send_notifications).once.with( Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION], @@ -1999,7 +2012,7 @@ def callback(_args); end end it 'call decision listener when the user is not bucketed into any experiment or rollout' do - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(nil) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(Optimizely::DecisionService::DecisionResult.new(nil, false, [])) expect(project_instance.notification_center).to receive(:send_notifications).once.with( Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args ).ordered @@ -2113,26 +2126,42 @@ def callback(_args); end rollout_to_return = config_body['rollouts'][0]['experiments'][0] allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return( - Optimizely::DecisionService::Decision.new( - experiment_to_return, - experiment_to_return['variations'][0], - Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] + Optimizely::DecisionService::DecisionResult.new( + Optimizely::DecisionService::Decision.new( + experiment_to_return, + experiment_to_return['variations'][0], + Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] + ), false, [] ), - nil, - Optimizely::DecisionService::Decision.new( - rollout_to_return, - rollout_to_return['variations'][0], - Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] + Optimizely::DecisionService::DecisionResult.new( + nil, false, [] ), - Optimizely::DecisionService::Decision.new( - experiment_to_return, - experiment_to_return['variations'][1], - Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] + Optimizely::DecisionService::DecisionResult.new( + Optimizely::DecisionService::Decision.new( + rollout_to_return, + rollout_to_return['variations'][0], + Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] + ), false, [] + ), + Optimizely::DecisionService::DecisionResult.new( + Optimizely::DecisionService::Decision.new( + experiment_to_return, + experiment_to_return['variations'][1], + Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] + ), false, [] ), - nil, - nil, - nil, - nil + Optimizely::DecisionService::DecisionResult.new( + nil, false, [] + ), + Optimizely::DecisionService::DecisionResult.new( + nil, false, [] + ), + Optimizely::DecisionService::DecisionResult.new( + nil, false, [] + ), + Optimizely::DecisionService::DecisionResult.new( + nil, false, [] + ) ) expect(project_instance.notification_center).to receive(:send_notifications).exactly(10).times.with( @@ -2274,7 +2303,8 @@ def callback(_args); end 'experiment' => nil, 'variation' => variation_to_return } - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_result_to_return) expect(project_instance.get_feature_variable_string('string_single_variable_feature', 'string_variable', user_id, user_attributes)) .to eq('wingardium leviosa') @@ -2294,7 +2324,8 @@ def callback(_args); end 'experiment' => nil, 'variation' => variation_to_return } - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_result_to_return) expect(project_instance.get_feature_variable_string('boolean_single_variable_feature', 'boolean_variable', user_id, user_attributes)) .to eq(nil) @@ -2315,7 +2346,8 @@ def callback(_args); end 'experiment' => experiment_to_return, 'variation' => variation_to_return } - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_result_to_return) expect(project_instance.get_feature_variable_string('integer_single_variable_feature', 'integer_variable', user_id, user_attributes)) .to eq(nil) @@ -2334,7 +2366,8 @@ def callback(_args); end 'experiment' => experiment_to_return, 'variation' => variation_to_return } - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_result_to_return) expect(project_instance.get_feature_variable_string('string_single_variable_feature', 'string_variable', user_id, user_attributes)) .to eq('cta_1') @@ -2351,7 +2384,7 @@ def callback(_args); end describe 'when the feature flag is not enabled for the user' do it 'should return the default variable value' do - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(nil) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(Optimizely::DecisionService::DecisionResult.new(nil, false, [])) expect(project_instance.get_feature_variable_string('string_single_variable_feature', 'string_variable', user_id, user_attributes)) .to eq('wingardium leviosa') @@ -2424,7 +2457,8 @@ def callback(_args); end 'experiment' => nil, 'variation' => variation_to_return } - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_result_to_return) expect(project_instance.notification_center).to receive(:send_notifications).once.with( Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION], @@ -2496,7 +2530,8 @@ def callback(_args); end 'experiment' => experiment_to_return, 'variation' => variation_to_return } - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_result_to_return) expect(project_instance.notification_center).to receive(:send_notifications).once.with( Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION], @@ -2525,7 +2560,7 @@ def callback(_args); end describe 'when the feature flag is not enabled for the user' do it 'should return the default variable value' do - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(nil) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(Optimizely::DecisionService::DecisionResult.new(nil, false, [])) expect(project_instance.notification_center).to receive(:send_notifications).once.with( Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION], @@ -2608,7 +2643,8 @@ def callback(_args); end 'experiment' => nil, 'variation' => variation_to_return } - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_result_to_return) expect(project_instance.get_feature_variable_boolean('boolean_single_variable_feature', 'boolean_variable', user_id, user_attributes)) .to eq(true) @@ -2652,8 +2688,8 @@ def callback(_args); end 'experiment' => experiment_to_return, 'variation' => variation_to_return } - - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_result_to_return) expect(project_instance.get_feature_variable_double('double_single_variable_feature', 'double_variable', user_id, user_attributes)) .to eq(42.42) @@ -2698,8 +2734,8 @@ def callback(_args); end 'experiment' => experiment_to_return, 'variation' => variation_to_return } - - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_result_to_return) expect(project_instance.get_feature_variable_integer('integer_single_variable_feature', 'integer_variable', user_id, user_attributes)) .to eq(42) @@ -2741,7 +2777,8 @@ def callback(_args); end Decision = Struct.new(:experiment, :variation, :source) # rubocop:disable Lint/ConstantDefinitionInBlock variation_to_return = project_config.rollout_id_map['166661']['experiments'][0]['variations'][0] decision_to_return = Decision.new({'key' => 'test-exp'}, variation_to_return, 'feature-test') - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decisiont_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decisiont_result_to_return) expect(project_instance.notification_center).to receive(:send_notifications).once.with( Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION], @@ -2814,7 +2851,8 @@ def callback(_args); end 'experiment' => experiment_to_return, 'variation' => variation_to_return } - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decisiont_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decisiont_result_to_return) allow(project_config).to receive(:variation_id_to_variable_usage_map).and_return(variation_id_to_variable_usage_map) expect(project_instance.notification_center).to receive(:send_notifications).once.with( @@ -2873,7 +2911,7 @@ def callback(_args); end describe 'when the feature flag is not enabled for the user' do it 'should return the default variable value' do - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(nil) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(Optimizely::DecisionService::DecisionResult.new(nil, false, [])) expect(project_instance.notification_center).to receive(:send_notifications).once.with( Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION], @@ -2976,7 +3014,8 @@ def callback(_args); end 'experiment' => nil, 'variation' => variation_to_return } - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_result_to_return) expect(project_instance.get_feature_variable('string_single_variable_feature', 'string_variable', user_id, user_attributes)) .to eq('wingardium leviosa') @@ -2996,7 +3035,8 @@ def callback(_args); end 'experiment' => experiment_to_return, 'variation' => variation_to_return } - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_result_to_return) expect(project_instance.get_feature_variable('string_single_variable_feature', 'string_variable', user_id, user_attributes)) .to eq('cta_1') @@ -3017,7 +3057,8 @@ def callback(_args); end 'experiment' => nil, 'variation' => variation_to_return } - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_result_to_return) expect(project_instance.get_feature_variable('boolean_single_variable_feature', 'boolean_variable', user_id, user_attributes)) .to eq(true) @@ -3038,8 +3079,8 @@ def callback(_args); end 'experiment' => experiment_to_return, 'variation' => variation_to_return } - - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_result_to_return) expect(project_instance.get_feature_variable('double_single_variable_feature', 'double_variable', user_id, user_attributes)) .to eq(42.42) @@ -3060,8 +3101,8 @@ def callback(_args); end 'experiment' => experiment_to_return, 'variation' => variation_to_return } - - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_result_to_return) expect(project_instance.get_feature_variable('integer_single_variable_feature', 'integer_variable', user_id, user_attributes)) .to eq(42) @@ -3078,7 +3119,7 @@ def callback(_args); end describe 'when the feature flag is not enabled for the user' do it 'should return the default variable value' do - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(nil) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(Optimizely::DecisionService::DecisionResult.new(nil, false, [])) expect(project_instance.get_feature_variable('string_single_variable_feature', 'string_variable', user_id, user_attributes)) .to eq('wingardium leviosa') @@ -3243,8 +3284,8 @@ def callback(_args); end variation_to_return, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] ) - - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_result_to_return) # DECISION listener called when the user is in experiment with variation feature off. expect(project_instance.notification_center).to receive(:send_notifications).once.with( @@ -3287,8 +3328,8 @@ def callback(_args); end variation_to_return, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] ) - - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_result_to_return) # DECISION listener called when the user is in experiment with variation feature on. expect(project_instance.notification_center).to receive(:send_notifications).once.with( @@ -3325,8 +3366,8 @@ def callback(_args); end variation_to_return, Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] ) - - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_result_to_return) # DECISION listener called when the user is in rollout with variation feature on. expect(variation_to_return['featureEnabled']).to be true @@ -3360,7 +3401,8 @@ def callback(_args); end variation_to_return, Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] ) - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_result_to_return) # DECISION listener called when the user is in rollout with variation feature on. expect(variation_to_return['featureEnabled']).to be false @@ -3392,7 +3434,7 @@ def callback(_args); end end it 'should call listener with default variable type and value, when user neither in experiment nor in rollout' do - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(nil) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(Optimizely::DecisionService::DecisionResult.new(nil, false, [])) expect(project_instance.notification_center).to receive(:send_notifications).once.with( Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION], @@ -3768,8 +3810,9 @@ def callback(_args); end variation_to_return, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] ) + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) decision_list_to_be_returned = [] - decision_list_to_be_returned << [decision_to_return, []] + decision_list_to_be_returned << decision_result_to_return allow(project_instance.decision_service).to receive(:get_variations_for_feature_list).and_return(decision_list_to_be_returned) user_context = project_instance.create_user_context('user1') decision = project_instance.decide(user_context, 'multi_variate_feature') @@ -3813,8 +3856,9 @@ def callback(_args); end variation_to_return, Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] ) + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) decision_list_to_be_returned = [] - decision_list_to_be_returned << [decision_to_return, []] + decision_list_to_be_returned << decision_result_to_return allow(project_instance.decision_service).to receive(:get_variations_for_feature_list).and_return(decision_list_to_be_returned) user_context = project_instance.create_user_context('user1') decision = project_instance.decide(user_context, 'multi_variate_feature') @@ -3898,7 +3942,8 @@ def callback(_args); end variation_to_return, Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] ) - decision_list_to_return = [[decision_to_return, []]] + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + decision_list_to_return = [decision_result_to_return] allow(project_instance.decision_service).to receive(:get_variations_for_feature_list).and_return(decision_list_to_return) user_context = project_instance.create_user_context('user1') decision = project_instance.decide(user_context, 'multi_variate_feature') @@ -4070,7 +4115,8 @@ def callback(_args); end variation_to_return, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] ) - decision_list_to_be_returned = [[decision_to_return, []]] + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + decision_list_to_be_returned = [decision_result_to_return] allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) allow(project_instance.decision_service).to receive(:get_variations_for_feature_list).and_return(decision_list_to_be_returned) user_context = project_instance.create_user_context('user1') @@ -4094,7 +4140,8 @@ def callback(_args); end variation_to_return, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] ) - decision_list_to_return = [[decision_to_return, []]] + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + decision_list_to_return = [decision_result_to_return] allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) allow(project_instance.decision_service).to receive(:get_variations_for_feature_list).and_return(decision_list_to_return) user_context = project_instance.create_user_context('user1') @@ -4201,7 +4248,8 @@ def callback(_args); end variation_to_return, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] ) - decision_list_to_return = [[decision_to_return, []]] + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + decision_list_to_return = [decision_result_to_return] allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) allow(project_instance.decision_service).to receive(:get_variations_for_feature_list).and_return(decision_list_to_return) user_context = project_instance.create_user_context('user1') @@ -4233,6 +4281,33 @@ def callback(_args); end ]) end end + describe 'when decision service fails with CMAB error' do + it 'should return error decision when CMAB decision service fails' do + # Add the HTTP stub to prevent real requests + stub_request(:post, 'https://logx.optimizely.com/v1/events') + .to_return(status: 200, body: '', headers: {}) + + feature_flag_key = 'boolean_single_variable_feature' + + # Mock the decision service to return an error result + error_decision_result = double('DecisionResult') + allow(error_decision_result).to receive(:decision).and_return(nil) + allow(error_decision_result).to receive(:error).and_return(true) + allow(error_decision_result).to receive(:reasons).and_return(['CMAB service failed to fetch decision']) + + # Mock get_variations_for_feature_list instead of get_variation_for_feature + allow(project_instance.decision_service).to receive(:get_variations_for_feature_list) + .and_return([error_decision_result]) + + user_context = project_instance.create_user_context('test_user') + decision = user_context.decide(feature_flag_key) + + expect(decision.enabled).to eq(false) + expect(decision.variation_key).to be_nil + expect(decision.flag_key).to eq(feature_flag_key) + expect(decision.reasons).to include('CMAB service failed to fetch decision') + end + end end describe '#decide_all' do @@ -4421,7 +4496,8 @@ def callback(_args); end variation_to_return, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] ) - decision_list_to_return = [[decision_to_return, []]] + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + decision_list_to_return = [decision_result_to_return] allow(custom_project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) allow(custom_project_instance.decision_service).to receive(:get_variations_for_feature_list).and_return(decision_list_to_return) user_context = custom_project_instance.create_user_context('user1') @@ -4450,7 +4526,8 @@ def callback(_args); end variation_to_return, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] ) - decision_list_to_return = [[decision_to_return, []]] + decision_result_to_return = Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []) + decision_list_to_return = [decision_result_to_return] allow(custom_project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) allow(custom_project_instance.decision_service).to receive(:get_variations_for_feature_list).and_return(decision_list_to_return) user_context = custom_project_instance.create_user_context('user1')