diff --git a/lib/optimizely.rb b/lib/optimizely.rb index 98ebdd39..6ffd0d06 100644 --- a/lib/optimizely.rb +++ b/lib/optimizely.rb @@ -199,13 +199,20 @@ def decide(user_context, key, decide_options = []) experiment = nil decision_source = Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] - decision, reasons_received = @decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes, decide_options) + variation, reasons_received = user_context.find_validated_forced_decision(key, nil) reasons.push(*reasons_received) + if variation + decision = Optimizely::DecisionService::Decision.new(nil, variation, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST']) + else + decision, reasons_received = @decision_service.get_variation_for_feature(config, feature_flag, user_context, decide_options) + reasons.push(*reasons_received) + end + # Send impression event if Decision came from a feature test and decide options doesn't include disableDecisionEvent if decision.is_a?(Optimizely::DecisionService::Decision) experiment = decision.experiment - rule_key = experiment['key'] + rule_key = experiment ? experiment['key'] : nil variation = decision['variation'] variation_key = variation['key'] feature_enabled = variation['featureEnabled'] @@ -291,6 +298,10 @@ def decide_for_keys(user_context, keys, decide_options = []) decisions end + def get_flag_variation_by_key(flag_key, variation_key) + project_config.get_variation_from_flag(flag_key, variation_key) + end + # Buckets visitor and sends impression event to Optimizely. # # @param experiment_key - Experiment which needs to be activated. @@ -490,7 +501,8 @@ def is_feature_enabled(feature_flag_key, user_id, attributes = nil) return false end - decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes) + user_context = create_user_context(user_id, attributes) + decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_context) feature_enabled = false source_string = Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] @@ -739,7 +751,8 @@ def get_all_feature_variables(feature_flag_key, user_id, attributes = nil) return nil end - decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes) + user_context = create_user_context(user_id, attributes) + decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_context) variation = decision ? decision['variation'] : nil feature_enabled = variation ? variation['featureEnabled'] : false all_variables = {} @@ -881,7 +894,8 @@ def get_variation_with_config(experiment_key, user_id, attributes, config) return nil unless user_inputs_valid?(attributes) - variation_id, = @decision_service.get_variation(config, experiment_id, user_id, attributes) + user_context = create_user_context(user_id, attributes) + variation_id, = @decision_service.get_variation(config, experiment_id, user_context) variation = config.get_variation_from_id(experiment_key, variation_id) unless variation_id.nil? variation_key = variation['key'] if variation decision_notification_type = if config.feature_experiment?(experiment_id) @@ -947,7 +961,8 @@ def get_feature_variable_for_type(feature_flag_key, variable_key, variable_type, return nil end - decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes) + user_context = create_user_context(user_id, attributes) + decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_context) variation = decision ? decision['variation'] : nil feature_enabled = variation ? variation['featureEnabled'] : false @@ -1083,8 +1098,12 @@ def send_impression(config, experiment, variation_key, flag_key, rule_key, enabl experiment_id = experiment['id'] experiment_key = experiment['key'] - variation_id = '' - variation_id = config.get_variation_id_from_key_by_experiment_id(experiment_id, variation_key) if experiment_id != '' + if experiment_id != '' + variation_id = config.get_variation_id_from_key_by_experiment_id(experiment_id, variation_key) + else + varaition = get_flag_variation_by_key(flag_key, variation_key) + variation_id = varaition ? varaition['id'] : '' + end metadata = { flag_key: flag_key, diff --git a/lib/optimizely/config/datafile_project_config.rb b/lib/optimizely/config/datafile_project_config.rb index 0e5163a6..9d942c6a 100644 --- a/lib/optimizely/config/datafile_project_config.rb +++ b/lib/optimizely/config/datafile_project_config.rb @@ -60,6 +60,7 @@ class DatafileProjectConfig < ProjectConfig attr_reader :variation_key_map attr_reader :variation_id_map_by_experiment_id attr_reader :variation_key_map_by_experiment_id + attr_reader :flag_variation_map def initialize(datafile, logger, error_handler) # ProjectConfig init method to fetch and set project config data @@ -123,6 +124,8 @@ def initialize(datafile, logger, error_handler) @variation_key_map_by_experiment_id = {} @variation_id_to_variable_usage_map = {} @variation_id_to_experiment_map = {} + @flag_variation_map = {} + @experiment_id_map.each_value do |exp| # Excludes experiments from rollouts variations = exp.fetch('variations') @@ -138,6 +141,8 @@ def initialize(datafile, logger, error_handler) exps = rollout.fetch('experiments') @rollout_experiment_id_map = @rollout_experiment_id_map.merge(generate_key_map(exps, 'id')) end + + @flag_variation_map = generate_feature_variation_map(@feature_flags) @all_experiments = @experiment_id_map.merge(@rollout_experiment_id_map) @all_experiments.each do |id, exp| variations = exp.fetch('variations') @@ -165,6 +170,24 @@ def initialize(datafile, logger, error_handler) end end + def get_rules_for_flag(feature_flag) + # Retrieves rules for a given feature flag + # + # feature_flag - String key representing the feature_flag + # + # Returns rules in feature flag + rules = feature_flag['experimentIds'].map { |exp_id| @experiment_id_map[exp_id] } + rollout = feature_flag['rolloutId'].empty? ? nil : @rollout_id_map[feature_flag['rolloutId']] + + if rollout + rollout_experiments = rollout.fetch('experiments') + rollout_experiments.each do |exp| + rules.push(exp) + end + end + rules + end + def self.create(datafile, logger, error_handler, skip_json_validation) # Looks up and sets datafile and config based on response body. # @@ -279,6 +302,13 @@ def get_audience_from_id(audience_id) nil end + def get_variation_from_flag(flag_key, variation_key) + variations = @flag_variation_map[flag_key] + return variations.select { |variation| variation['key'] == variation_key }.first if variations + + nil + end + def get_variation_from_id(experiment_key, variation_id) # Get variation given experiment key and variation ID # @@ -494,6 +524,20 @@ def rollout_experiment?(experiment_id) private + def generate_feature_variation_map(feature_flags) + flag_variation_map = {} + feature_flags.each do |flag| + variations = [] + get_rules_for_flag(flag).each do |rule| + rule['variations'].each do |rule_variation| + variations.push(rule_variation) if variations.select { |variation| variation['id'] == rule_variation['id'] }.empty? + end + end + flag_variation_map[flag['key']] = variations + end + flag_variation_map + end + def generate_key_map(array, key) # Helper method to generate map from key to hash in array of hashes # diff --git a/lib/optimizely/decision_service.rb b/lib/optimizely/decision_service.rb index 49df5e32..9608f995 100644 --- a/lib/optimizely/decision_service.rb +++ b/lib/optimizely/decision_service.rb @@ -52,18 +52,19 @@ def initialize(logger, user_profile_service = nil) @forced_variation_map = {} end - def get_variation(project_config, experiment_id, user_id, attributes = nil, decide_options = []) + def get_variation(project_config, experiment_id, user_context, decide_options = []) # Determines variation into which user will be bucketed. # # project_config - project_config - Instance of ProjectConfig # experiment_id - Experiment for which visitor variation needs to be determined - # user_id - String ID for user - # attributes - Hash representing user attributes + # user_context - Optimizely user context instance # # Returns variation ID where visitor will be bucketed # (nil if experiment is inactive or user does not meet audience conditions) decide_reasons = [] + user_id = user_context.user_id + attributes = user_context.user_attributes # By default, the bucketing ID should be the user ID bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes) decide_reasons.push(*bucketing_id_reasons) @@ -134,40 +135,39 @@ def get_variation(project_config, experiment_id, user_id, attributes = nil, deci [variation_id, decide_reasons] end - def get_variation_for_feature(project_config, feature_flag, user_id, attributes = nil, decide_options = []) + def get_variation_for_feature(project_config, feature_flag, user_context, decide_options = []) # Get the variation the user is bucketed into for the given FeatureFlag. # # project_config - project_config - Instance of ProjectConfig # feature_flag - The feature flag the user wants to access - # user_id - String ID for the user - # attributes - Hash representing user attributes + # user_context - Optimizely user context instance # # Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature) 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_id, attributes, decide_options) + decision, reasons_received = get_variation_for_feature_experiment(project_config, feature_flag, user_context, decide_options) decide_reasons.push(*reasons_received) return decision, decide_reasons unless decision.nil? - decision, reasons_received = get_variation_for_feature_rollout(project_config, feature_flag, user_id, attributes) + decision, reasons_received = get_variation_for_feature_rollout(project_config, feature_flag, user_context) decide_reasons.push(*reasons_received) [decision, decide_reasons] end - def get_variation_for_feature_experiment(project_config, feature_flag, user_id, attributes = nil, decide_options = []) + def get_variation_for_feature_experiment(project_config, feature_flag, user_context, decide_options = []) # Gets the variation the user is bucketed into for the feature flag's experiment. # # project_config - project_config - Instance of ProjectConfig # feature_flag - The feature flag the user wants to access - # user_id - String ID for the user - # attributes - Hash representing user attributes + # 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 decide_reasons = [] + user_id = user_context.user_id feature_flag_key = feature_flag['key'] if feature_flag['experimentIds'].empty? message = "The feature flag '#{feature_flag_key}' is not used in any experiments." @@ -187,7 +187,7 @@ def get_variation_for_feature_experiment(project_config, feature_flag, user_id, end experiment_id = experiment['id'] - variation_id, reasons_received = get_variation(project_config, experiment_id, user_id, attributes, decide_options) + variation_id, reasons_received = get_variation_from_experiment_rule(project_config, feature_flag_key, experiment, user_context, decide_options) decide_reasons.push(*reasons_received) next unless variation_id @@ -204,22 +204,20 @@ def get_variation_for_feature_experiment(project_config, feature_flag, user_id, [nil, decide_reasons] end - def get_variation_for_feature_rollout(project_config, feature_flag, user_id, attributes = nil) + def get_variation_for_feature_rollout(project_config, feature_flag, user_context) # Determine which variation the user is in for a given rollout. # Returns the variation of the first experiment the user qualifies for. # # project_config - project_config - Instance of ProjectConfig # feature_flag - The feature flag the user wants to access - # user_id - String ID for the user - # attributes - Hash representing user attributes + # user_context - Optimizely user context instance # # Returns the Decision struct or nil if not bucketed into any of the targeting rules decide_reasons = [] - bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes) - decide_reasons.push(*bucketing_id_reasons) + rollout_id = feature_flag['rolloutId'] + feature_flag_key = feature_flag['key'] if rollout_id.nil? || rollout_id.empty? - feature_flag_key = feature_flag['key'] message = "Feature flag '#{feature_flag_key}' is not used in a rollout." @logger.log(Logger::DEBUG, message) decide_reasons.push(message) @@ -236,60 +234,100 @@ def get_variation_for_feature_rollout(project_config, feature_flag, user_id, att return nil, decide_reasons if rollout['experiments'].empty? + index = 0 rollout_rules = rollout['experiments'] - number_of_rules = rollout_rules.length - 1 - - # Go through each experiment in order and try to get the variation for the user - number_of_rules.times do |index| - rollout_rule = rollout_rules[index] - logging_key = index + 1 - - user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, rollout_rule, attributes, @logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', logging_key) + while index < rollout_rules.length + variation, skip_to_everyone_else, reasons_received = get_variation_from_delivery_rule(project_config, feature_flag_key, rollout_rules, index, user_context) decide_reasons.push(*reasons_received) - # Check that user meets audience conditions for targeting rule - unless user_meets_audience_conditions - message = "User '#{user_id}' does not meet the audience conditions for targeting rule '#{logging_key}'." - @logger.log(Logger::DEBUG, message) - decide_reasons.push(message) - # move onto the next targeting rule - next + if variation + rule = rollout_rules[index] + feature_decision = Decision.new(rule, variation, DECISION_SOURCES['ROLLOUT']) + return [feature_decision, decide_reasons] end - message = "User '#{user_id}' meets the audience conditions for targeting rule '#{logging_key}'." - @logger.log(Logger::DEBUG, message) - decide_reasons.push(message) + index = skip_to_everyone_else ? (rollout_rules.length - 1) : (index + 1) + end - # Evaluate if user satisfies the traffic allocation for this rollout rule - variation, bucket_reasons = @bucketer.bucket(project_config, rollout_rule, bucketing_id, user_id) - decide_reasons.push(*bucket_reasons) - return Decision.new(rollout_rule, variation, DECISION_SOURCES['ROLLOUT']), decide_reasons unless variation.nil? + [nil, decide_reasons] + end - break - end + def get_variation_from_experiment_rule(project_config, flag_key, rule, user, options = []) + # Determine which variation the user is in for a given rollout. + # Returns the variation from experiment rules. + # + # project_config - project_config - Instance of ProjectConfig + # flag_key - The feature flag the user wants to access + # rule - An experiment rule key + # user - Optimizely user context instance + # + # Returns variation_id and reasons + reasons = [] - # get last rule which is the everyone else rule - everyone_else_experiment = rollout_rules[number_of_rules] - logging_key = 'Everyone Else' + variation, forced_reasons = user.find_validated_forced_decision(flag_key, rule['key']) + reasons.push(*forced_reasons) - user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, everyone_else_experiment, attributes, @logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', logging_key) - decide_reasons.push(*reasons_received) - # Check that user meets audience conditions for last rule + return [variation['id'], reasons] if variation + + variation_id, response_reasons = get_variation(project_config, rule['id'], user, options) + reasons.push(*response_reasons) + + [variation_id, reasons] + end + + def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index, user) + # Determine which variation the user is in for a given rollout. + # Returns the variation from delivery rules. + # + # project_config - project_config - Instance of ProjectConfig + # flag_key - The feature flag the user wants to access + # rule - An experiment rule key + # user - Optimizely user context instance + # + # Returns variation, boolean to skip for eveyone else rule and reasons + reasons = [] + skip_to_everyone_else = false + rule = rules[rule_index] + variation, forced_reasons = user.find_validated_forced_decision(flag_key, rule['key']) + reasons.push(*forced_reasons) + + return [variation, skip_to_everyone_else, reasons] if variation + + user_id = user.user_id + attributes = user.user_attributes + bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes) + reasons.push(*bucketing_id_reasons) + + everyone_else = (rule_index == rules.length - 1) + + logging_key = everyone_else ? 'Everyone Else' : (rule_index + 1).to_s + + user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, rule, attributes, @logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', logging_key) + reasons.push(*reasons_received) unless user_meets_audience_conditions - message = "User '#{user_id}' does not meet the audience conditions for targeting rule '#{logging_key}'." + message = "User '#{user_id}' does not meet the conditions for targeting rule '#{logging_key}'." @logger.log(Logger::DEBUG, message) - decide_reasons.push(message) - return nil, decide_reasons + reasons.push(message) + return [nil, skip_to_everyone_else, reasons] end message = "User '#{user_id}' meets the audience conditions for targeting rule '#{logging_key}'." @logger.log(Logger::DEBUG, message) - decide_reasons.push(message) + reasons.push(message) + bucket_variation, bucket_reasons = @bucketer.bucket(project_config, rule, bucketing_id, user_id) - variation, bucket_reasons = @bucketer.bucket(project_config, everyone_else_experiment, bucketing_id, user_id) - decide_reasons.push(*bucket_reasons) - return Decision.new(everyone_else_experiment, variation, DECISION_SOURCES['ROLLOUT']), decide_reasons unless variation.nil? + reasons.push(*bucket_reasons) - [nil, decide_reasons] + if bucket_variation + message = "User '#{user_id}' is in the traffic group of targeting rule '#{logging_key}'." + @logger.log(Logger::DEBUG, message) + reasons.push(message) + elsif !everyone_else + message = "User '#{user_id}' is not in the traffic group for targeting rule '#{logging_key}'." + @logger.log(Logger::DEBUG, message) + reasons.push(message) + skip_to_everyone_else = true + end + [bucket_variation, skip_to_everyone_else, reasons] end def set_forced_variation(project_config, experiment_key, user_id, variation_key) diff --git a/lib/optimizely/optimizely_user_context.rb b/lib/optimizely/optimizely_user_context.rb index 928470a2..c7febfad 100644 --- a/lib/optimizely/optimizely_user_context.rb +++ b/lib/optimizely/optimizely_user_context.rb @@ -23,16 +23,23 @@ class OptimizelyUserContext # Representation of an Optimizely User Context using which APIs are to be called. attr_reader :user_id + attr_reader :forced_decisions + attr_reader :ForcedDecision + ForcedDecision = Struct.new(:flag_key, :rule_key) def initialize(optimizely_client, user_id, user_attributes) @attr_mutex = Mutex.new + @forced_decision_mutex = Mutex.new @optimizely_client = optimizely_client @user_id = user_id @user_attributes = user_attributes.nil? ? {} : user_attributes.clone + @forced_decisions = {} end def clone - OptimizelyUserContext.new(@optimizely_client, @user_id, user_attributes) + user_context = OptimizelyUserContext.new(@optimizely_client, @user_id, user_attributes) + @forced_decision_mutex.synchronize { user_context.instance_variable_set('@forced_decisions', @forced_decisions.dup) unless @forced_decisions.empty? } + user_context end def user_attributes @@ -85,6 +92,97 @@ def decide_all(options = nil) @optimizely_client&.decide_all(clone, options) end + # Sets the forced decision (variation key) for a given flag and an optional rule. + # + # @param flag_key - A flag key. + # @param rule_key - An experiment or delivery rule key (optional). + # @param variation_key - A variation key. + # + # @return - true if the forced decision has been set successfully. + + def set_forced_decision(flag_key, rule_key, variation_key) + return false if @optimizely_client&.get_optimizely_config.nil? + return false if flag_key.empty? || flag_key.nil? + + forced_decision_key = ForcedDecision.new(flag_key, rule_key) + @forced_decision_mutex.synchronize { @forced_decisions[forced_decision_key] = variation_key } + + true + end + + def find_forced_decision(flag_key, rule_key = nil) + return nil if @forced_decisions.empty? + + variation_key = nil + forced_decision_key = ForcedDecision.new(flag_key, rule_key) + @forced_decision_mutex.synchronize { variation_key = @forced_decisions[forced_decision_key] } + variation_key + end + + # Returns the forced decision for a given flag and an optional rule. + # + # @param flag_key - A flag key. + # @param rule_key - An experiment or delivery rule key (optional). + # + # @return - A variation key or nil if forced decisions are not set for the parameters. + + def get_forced_decision(flag_key, rule_key = nil) + return nil if @optimizely_client&.get_optimizely_config.nil? + + find_forced_decision(flag_key, rule_key) + end + + # Removes the forced decision for a given flag and an optional rule. + # + # @param flag_key - A flag key. + # @param rule_key - An experiment or delivery rule key (optional). + # + # @return - true if the forced decision has been removed successfully. + + def remove_forced_decision(flag_key, rule_key = nil) + return false if @optimizely_client&.get_optimizely_config.nil? + + forced_decision_key = ForcedDecision.new(flag_key, rule_key) + deleted = false + @forced_decision_mutex.synchronize do + if @forced_decisions.key?(forced_decision_key) + @forced_decisions.delete(forced_decision_key) + deleted = true + end + end + deleted + end + + # Removes all forced decisions bound to this user context. + # + # @return - true if forced decisions have been removed successfully. + + def remove_all_forced_decision + return false if @optimizely_client&.get_optimizely_config.nil? + + @forced_decision_mutex.synchronize { @forced_decisions.clear } + true + end + + def find_validated_forced_decision(flag_key, rule_key) + variation_key = find_forced_decision(flag_key, rule_key) + reasons = [] + target = rule_key ? "flag (#{flag_key}), rule (#{rule_key})" : "flag (#{flag_key})" + if variation_key + variation = @optimizely_client.get_flag_variation_by_key(flag_key, variation_key) + if variation + reason = "Variation (#{variation_key}) is mapped to #{target} and user (#{@user_id}) in the forced decision map." + reasons.push(reason) + return variation, reasons + else + reason = "Invalid variation is mapped to #{target} and user (#{@user_id}) in the forced decision map." + reasons.push(reason) + end + end + + [nil, reasons] + end + # Track an event # # @param event_key - Event key representing the event which needs to be recorded. diff --git a/optimizely-sdk-3.8.1.gem b/optimizely-sdk-3.8.1.gem new file mode 100644 index 00000000..c418c3f8 Binary files /dev/null and b/optimizely-sdk-3.8.1.gem differ diff --git a/spec/config/datafile_project_config_spec.rb b/spec/config/datafile_project_config_spec.rb index 82d8d221..5a5ab42d 100644 --- a/spec/config/datafile_project_config_spec.rb +++ b/spec/config/datafile_project_config_spec.rb @@ -23,6 +23,7 @@ describe Optimizely::DatafileProjectConfig do let(:config_body) { OptimizelySpec::VALID_CONFIG_BODY } let(:config_body_JSON) { OptimizelySpec::VALID_CONFIG_BODY_JSON } + let(:decision_JSON) { OptimizelySpec::DECIDE_FORCED_DECISION_JSON } let(:error_handler) { Optimizely::NoOpErrorHandler.new } let(:logger) { Optimizely::NoOpLogger.new } let(:config) { Optimizely::DatafileProjectConfig.new(config_body_JSON, logger, error_handler) } @@ -1073,4 +1074,56 @@ expect(config.rollout_experiment?('177771')).to eq(false) end end + + describe '#feature variation map' do + let(:config) { Optimizely::DatafileProjectConfig.new(decision_JSON, logger, error_handler) } + + it 'should return valid flag variation map without duplicates' do + # variation '3324490634' is repeated in datafile but should appear once in map + expected_feature_variation_map = { + 'feature_1' => [{ + 'variables' => [], + 'featureEnabled' => true, + 'id' => '10389729780', + 'key' => 'a' + }, { + 'variables' => [], + 'id' => '10416523121', + 'key' => 'b', + 'featureEnabled' => false + }, { + 'featureEnabled' => true, + 'id' => '3324490633', + 'key' => '3324490633', + 'variables' => [] + }, { + 'featureEnabled' => true, + 'id' => '3324490634', + 'key' => '3324490634', + 'variables' => [] + }, { + 'featureEnabled' => true, + 'id' => '3324490562', + 'key' => '3324490562', + 'variables' => [] + }, { + 'variables' => [], + 'id' => '18257766532', + 'key' => '18257766532', + 'featureEnabled' => true + }], 'feature_2' => [{ + 'variables' => [], + 'featureEnabled' => true, + 'id' => '10418551353', + 'key' => 'variation_with_traffic' + }, { + 'variables' => [], + 'featureEnabled' => false, + 'id' => '10418510624', + 'key' => 'variation_no_traffic' + }], 'feature_3' => [] + } + expect(config.send(:generate_feature_variation_map, config.feature_flags)).to eq(expected_feature_variation_map) + end + end end diff --git a/spec/decision_service_spec.rb b/spec/decision_service_spec.rb index c0ffb41b..33e1152d 100644 --- a/spec/decision_service_spec.rb +++ b/spec/decision_service_spec.rb @@ -28,6 +28,7 @@ let(:spy_user_profile_service) { spy('user_profile_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(:project_instance) { Optimizely::Project.new(config_body_JSON, nil, spy_logger, error_handler) } describe '#get_variation' do before(:example) do @@ -42,7 +43,8 @@ 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') - variation_received, reasons = decision_service.get_variation(config, '111127', 'test_user') + 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"]) # Setting forced variation should short circuit whitelist check, bucketing and audience evaluation @@ -57,7 +59,8 @@ Optimizely::Helpers::Constants::CONTROL_ATTRIBUTES['BUCKETING_ID'] => 'pid' } decision_service.set_forced_variation(config, 'test_experiment_with_audience', 'test_user', 'control_with_audience') - variation_received, reasons = decision_service.get_variation(config, '122227', 'test_user', user_attributes) + 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"]) # Setting forced variation should short circuit whitelist check, bucketing and audience evaluation @@ -67,7 +70,8 @@ end it 'should return the correct variation ID for a given user ID and key of a running experiment' do - variation_received, reasons = decision_service.get_variation(config, '111127', 'test_user') + 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('111128') expect(reasons).to eq([ @@ -83,7 +87,8 @@ it 'should return nil when user ID is not bucketed' do allow(decision_service.bucketer).to receive(:bucket).and_return(nil) - variation_received, reasons = decision_service.get_variation(config, '111127', 'test_user') + 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(nil) expect(reasons).to eq([ "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", @@ -95,7 +100,8 @@ end it 'should return correct variation ID if user ID is in whitelisted Variations and variation is valid' do - variation_received, reasons = decision_service.get_variation(config, '111127', 'forced_user1') + 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'." @@ -103,7 +109,8 @@ expect(spy_logger).to have_received(:log) .once.with(Logger::INFO, "User 'forced_user1' is whitelisted into variation 'control' of experiment '111127'.") - variation_received, reasons = decision_service.get_variation(config, '111127', 'forced_user2') + 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'." @@ -123,7 +130,8 @@ Optimizely::Helpers::Constants::CONTROL_ATTRIBUTES['BUCKETING_ID'] => 'pid' } - variation_received, reasons = decision_service.get_variation(config, '111127', 'forced_user1', user_attributes) + 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'." @@ -131,7 +139,8 @@ expect(spy_logger).to have_received(:log) .once.with(Logger::INFO, "User 'forced_user1' is whitelisted into variation 'control' of experiment '111127'.") - variation_received, reasons = decision_service.get_variation(config, '111127', 'forced_user2', user_attributes) + 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'." @@ -147,7 +156,8 @@ 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'} - variation_received, reasons = decision_service.get_variation(config, '122227', 'forced_audience_user', user_attributes) + 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'." @@ -165,7 +175,8 @@ end it 'should return nil if the experiment key is invalid' do - variation_received, reasons = decision_service.get_variation(config, 'totally_invalid_experiment', 'test_user', {}) + 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([]) @@ -175,7 +186,8 @@ it 'should return nil if the user does not meet the audience conditions for a given experiment' do user_attributes = {'browser_type' => 'chrome'} - variation_received, reasons = decision_service.get_variation(config, '122227', 'test_user', user_attributes) + 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(nil) expect(reasons).to eq([ "Starting to evaluate audience '11154' with conditions: [\"and\", [\"or\", [\"or\", {\"name\": \"browser_type\", \"type\": \"custom_attribute\", \"value\": \"firefox\"}]]].", @@ -193,7 +205,8 @@ end it 'should return nil if the given experiment is not running' do - variation_received, reasons = decision_service.get_variation(config, '100027', 'test_user') + 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."]) expect(spy_logger).to have_received(:log) @@ -208,7 +221,8 @@ end it 'should respect forced variations within mutually exclusive grouped experiments' do - variation_received, reasons = decision_service.get_variation(config, '133332', 'forced_group_user1') + 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'." @@ -223,7 +237,8 @@ end it 'should bucket normally if user is whitelisted into a forced variation that is not in the datafile' do - variation_received, reasons = decision_service.get_variation(config, '111127', 'forced_user_with_invalid_variation') + user_context = project_instance.create_user_context('forced_user_with_invalid_variation') + variation_received, reasons = decision_service.get_variation(config, '111127', user_context) 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.", @@ -253,7 +268,8 @@ } expect(spy_user_profile_service).to receive(:lookup).once.and_return(nil) - variation_received, reasons = decision_service.get_variation(config, '111127', 'test_user') + 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('111128') expect(reasons).to eq([ "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", @@ -283,7 +299,8 @@ } expect(spy_user_profile_service).to receive(:lookup).once.and_return(nil) - variation_received, reasons = decision_service.get_variation(config, '111127', 'test_user', user_attributes) + user_context = project_instance.create_user_context('test_user', user_attributes) + variation_received, reasons = decision_service.get_variation(config, '111127', user_context) expect(variation_received).to eq('111129') expect(reasons).to eq([ "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", @@ -310,7 +327,8 @@ expect(spy_user_profile_service).to receive(:lookup) .with('test_user').once.and_return(saved_user_profile) - variation_received, reasons = decision_service.get_variation(config, '111127', 'test_user') + 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([ "Returning previously activated variation ID 111129 of experiment 'test_experiment' for user 'test_user' from user profile." @@ -339,7 +357,8 @@ expect(spy_user_profile_service).to receive(:lookup) .once.with('test_user').and_return(saved_user_profile) - variation_received, reasons = decision_service.get_variation(config, '111127', 'test_user') + 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('111128') expect(reasons).to eq([ "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", @@ -377,7 +396,8 @@ expect(spy_user_profile_service).to receive(:lookup) .once.with('test_user').and_return(saved_user_profile) - variation_received, reasons = decision_service.get_variation(config, '111127', 'test_user') + 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('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.", @@ -403,7 +423,8 @@ it 'should bucket normally if the user profile service throws an error during lookup' do expect(spy_user_profile_service).to receive(:lookup).once.with('test_user').and_throw(:LookupError) - variation_received, reasons = decision_service.get_variation(config, '111127', 'test_user') + 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('111128') expect(reasons).to eq([ "Error while looking up user profile for user ID 'test_user': uncaught throw :LookupError.", @@ -420,7 +441,8 @@ it 'should log an error if the user profile service throws an error during save' do expect(spy_user_profile_service).to receive(:save).once.and_throw(:SaveError) - variation_received, reasons = decision_service.get_variation(config, '111127', 'test_user') + 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('111128') expect(reasons).to eq([ "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", @@ -436,7 +458,8 @@ allow(spy_user_profile_service).to receive(:lookup) .with('test_user').once.and_return(nil) - variation_received, reasons = decision_service.get_variation(config, '111127', 'test_user', nil, [Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE]) + user_context = project_instance.create_user_context('test_user', nil) + variation_received, reasons = decision_service.get_variation(config, '111127', user_context, [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.", @@ -453,7 +476,8 @@ allow(spy_user_profile_service).to receive(:lookup) .with('test_user').once.and_return(nil) - variation_received, reasons = decision_service.get_variation(config, '111127', 'test_user') + 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('111128') expect(reasons).to eq([ "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", @@ -470,13 +494,14 @@ end describe '#get_variation_for_feature_experiment' do - user_attributes = {} - user_id = 'user_1' + config_body_json = OptimizelySpec::VALID_CONFIG_BODY_JSON + project_instance = Optimizely::Project.new(config_body_json, nil, nil, nil) + user_context = project_instance.create_user_context('user_1', {}) 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_1', user_attributes) + variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context) expect(variation_received).to eq(nil) expect(reasons).to eq(["The feature flag 'empty_feature' is not used in any experiments."]) @@ -490,7 +515,7 @@ feature_flag = config.feature_flag_key_map['boolean_feature'].dup # any string that is not an experiment id in the data file feature_flag['experimentIds'] = ['1333333337'] - variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_id, user_attributes) + variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context) expect(variation_received).to eq(nil) expect(reasons).to eq(["Feature flag experiment with ID '1333333337' is not in the datafile."]) expect(spy_logger).to have_received(:log).once @@ -505,13 +530,13 @@ # make sure the user is not bucketed into the feature experiment allow(decision_service).to receive(:get_variation) - .with(config, multivariate_experiment['id'], 'user_1', user_attributes, []) + .with(config, multivariate_experiment['id'], user_context, []) .and_return([nil, 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_1', user_attributes, []) + variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context, []) 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'."]) @@ -527,14 +552,14 @@ end it 'should return the variation' do - user_attributes = {} feature_flag = config.feature_flag_key_map['multi_variate_feature'] expected_decision = Optimizely::DecisionService::Decision.new( config.experiment_key_map['test_experiment_multivariate'], config.variation_id_map['test_experiment_multivariate']['122231'], Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] ) - variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, 'user_1', user_attributes) + + variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context) expect(variation_received).to eq(expected_decision) expect(reasons).to eq([]) end @@ -559,7 +584,7 @@ it 'should return the variation the user is bucketed into' 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_id, user_attributes) + variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context) expect(variation_received).to eq(expected_decision) expect(reasons).to eq([]) end @@ -570,16 +595,16 @@ mutex_exp = config.experiment_key_map['group1_exp1'] mutex_exp2 = config.experiment_key_map['group1_exp2'] allow(decision_service).to receive(:get_variation) - .with(config, mutex_exp['id'], user_id, user_attributes, []) + .with(config, mutex_exp['id'], user_context, []) .and_return([nil, nil]) allow(decision_service).to receive(:get_variation) - .with(config, mutex_exp2['id'], user_id, user_attributes, []) + .with(config, mutex_exp2['id'], user_context, []) .and_return([nil, 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_id, user_attributes) + variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context) 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'."]) @@ -591,13 +616,16 @@ end describe '#get_variation_for_feature_rollout' do - user_attributes = {} + config_body_json = OptimizelySpec::VALID_CONFIG_BODY_JSON + project_instance = Optimizely::Project.new(config_body_json, nil, nil, nil) + user_context = project_instance.create_user_context('user_1', {}) user_id = 'user_1' + user_attributes = {} 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_id, user_attributes) + 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."]) expect(spy_logger).to have_received(:log).once @@ -609,7 +637,7 @@ 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_id, user_attributes) + 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'"]) @@ -624,7 +652,7 @@ 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_id, user_attributes) + 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([]) end @@ -641,9 +669,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_id, user_attributes) + 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'."]) + 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'."]) end end @@ -662,16 +691,17 @@ .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_id, user_attributes) + 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'." ]) # make sure we only checked the audience for the first rule expect(Optimizely::Audience).to have_received(:user_meets_audience_conditions?).once - .with(config, rollout['experiments'][0], user_attributes, spy_logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', 1) + .with(config, rollout['experiments'][0], user_attributes, spy_logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', '1') expect(Optimizely::Audience).not_to have_received(:user_meets_audience_conditions?) .with(config, rollout['experiments'][1], user_attributes, spy_logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', 2) end @@ -692,13 +722,18 @@ .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_id, user_attributes) + 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' meets the audience conditions for targeting rule 'Everyone Else'."]) + 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'." + ]) # make sure we only checked the audience for the first rule expect(Optimizely::Audience).to have_received(:user_meets_audience_conditions?).once - .with(config, rollout['experiments'][0], user_attributes, spy_logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', 1) + .with(config, rollout['experiments'][0], user_attributes, spy_logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', '1') expect(Optimizely::Audience).not_to have_received(:user_meets_audience_conditions?) .with(config, rollout['experiments'][1], user_attributes, spy_logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', 2) end @@ -722,22 +757,22 @@ .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_id, user_attributes) + 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 audience conditions for targeting rule '1'.", "User 'user_1' does not meet the audience conditions for targeting rule '2'.", "User 'user_1' meets the audience conditions for targeting rule 'Everyone Else'."]) + 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'." + ]) # verify we tried to bucket in all targeting rules and the everyone else rule - expect(Optimizely::Audience).to have_received(:user_meets_audience_conditions?) - .with(config, rollout['experiments'][0], user_attributes, spy_logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', 1) - expect(Optimizely::Audience).to have_received(:user_meets_audience_conditions?) - .with(config, rollout['experiments'][1], user_attributes, spy_logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', 2) - expect(Optimizely::Audience).to have_received(:user_meets_audience_conditions?) - .with(config, rollout['experiments'][2], user_attributes, spy_logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', 'Everyone Else') + expect(Optimizely::Audience).to have_received(:user_meets_audience_conditions?).exactly(3).times # verify log messages - expect(spy_logger).to have_received(:log).with(Logger::DEBUG, "User '#{user_id}' does not meet the audience conditions for targeting rule '1'.") + expect(spy_logger).to have_received(:log).with(Logger::DEBUG, "User '#{user_id}' does not meet the conditions for targeting rule '1'.") - expect(spy_logger).to have_received(:log).with(Logger::DEBUG, "User '#{user_id}' does not meet the audience conditions for targeting rule '2'.") + expect(spy_logger).to have_received(:log).with(Logger::DEBUG, "User '#{user_id}' does not meet the conditions for targeting rule '2'.") expect(spy_logger).to have_received(:log).with(Logger::DEBUG, "User '#{user_id}' meets the audience conditions for targeting rule 'Everyone Else'.") end @@ -752,31 +787,36 @@ 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_id, user_attributes) + 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 audience conditions for targeting rule '1'.", "User 'user_1' does not meet the audience conditions for targeting rule '2'.", "User 'user_1' does not meet the audience conditions for targeting rule 'Everyone Else'."]) + 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'." + ]) # 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 - .with(config, rollout['experiments'][0], user_attributes, spy_logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', 1) + .with(config, rollout['experiments'][0], user_attributes, spy_logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', '1') expect(Optimizely::Audience).to have_received(:user_meets_audience_conditions?) - .with(config, rollout['experiments'][1], user_attributes, spy_logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', 2) + .with(config, rollout['experiments'][1], user_attributes, spy_logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', '2') expect(Optimizely::Audience).to have_received(:user_meets_audience_conditions?) .with(config, rollout['experiments'][2], user_attributes, spy_logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', 'Everyone Else') # verify log messages - expect(spy_logger).to have_received(:log).with(Logger::DEBUG, "User '#{user_id}' does not meet the audience conditions for targeting rule '1'.") + expect(spy_logger).to have_received(:log).with(Logger::DEBUG, "User '#{user_id}' does not meet the conditions for targeting rule '1'.") - expect(spy_logger).to have_received(:log).with(Logger::DEBUG, "User '#{user_id}' does not meet the audience conditions for targeting rule '2'.") + expect(spy_logger).to have_received(:log).with(Logger::DEBUG, "User '#{user_id}' does not meet the conditions for targeting rule '2'.") - expect(spy_logger).to have_received(:log).with(Logger::DEBUG, "User '#{user_id}' does not meet the audience conditions for targeting rule 'Everyone Else'.") + expect(spy_logger).to have_received(:log).with(Logger::DEBUG, "User '#{user_id}' does not meet the conditions for targeting rule 'Everyone Else'.") end end end describe '#get_variation_for_feature' do - user_attributes = {} - user_id = 'user_1' + config_body_json = OptimizelySpec::VALID_CONFIG_BODY_JSON + project_instance = Optimizely::Project.new(config_body_json, nil, nil, nil) + user_context = project_instance.create_user_context('user_1', {}) describe 'when the user is bucketed into the feature experiment' do it 'should return the bucketed experiment and variation' do @@ -789,7 +829,7 @@ } allow(decision_service).to receive(:get_variation_for_feature_experiment).and_return([expected_decision, nil]) - decision_received, reasons = decision_service.get_variation_for_feature(config, feature_flag, user_id, user_attributes) + 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([]) end @@ -809,7 +849,7 @@ 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]) - decision_received, reasons = decision_service.get_variation_for_feature(config, feature_flag, user_id, user_attributes) + 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([]) end @@ -821,7 +861,7 @@ 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]) - decision_received, reasons = decision_service.get_variation_for_feature(config, feature_flag, user_id, user_attributes) + decision_received, reasons = decision_service.get_variation_for_feature(config, feature_flag, user_context) expect(decision_received).to eq(nil) expect(reasons).to eq([]) end diff --git a/spec/optimizely_user_context_spec.rb b/spec/optimizely_user_context_spec.rb index ed8b8bf6..8122931e 100644 --- a/spec/optimizely_user_context_spec.rb +++ b/spec/optimizely_user_context_spec.rb @@ -23,9 +23,12 @@ let(:config_body) { OptimizelySpec::VALID_CONFIG_BODY } let(:config_body_JSON) { OptimizelySpec::VALID_CONFIG_BODY_JSON } let(:config_body_invalid_JSON) { OptimizelySpec::INVALID_CONFIG_BODY_JSON } + let(:forced_decision_JSON) { OptimizelySpec::DECIDE_FORCED_DECISION_JSON } let(:error_handler) { Optimizely::RaiseErrorHandler.new } let(:spy_logger) { spy('logger') } let(:project_instance) { Optimizely::Project.new(config_body_JSON, nil, spy_logger, error_handler) } + let(:forced_decision_project_instance) { Optimizely::Project.new(forced_decision_JSON, nil, spy_logger, error_handler) } + let(:impression_log_url) { 'https://logx.optimizely.com/v1/events' } describe '#initialize' do it 'should set passed value as expected' do @@ -80,4 +83,554 @@ expect(original_attributes).to eq('browser' => 'firefox') end end + + describe '#forced_decisions' do + it 'should return invalid status for invalid datafile in forced decision calls' do + user_id = 'test_user' + original_attributes = {} + invalid_project_instance = Optimizely::Project.new('Invalid datafile', nil, spy_logger, error_handler) + user_context_obj = Optimizely::OptimizelyUserContext.new(invalid_project_instance, user_id, original_attributes) + status = user_context_obj.set_forced_decision('feature_1', nil, '3324490562') + expect(status).to be false + status = user_context_obj.get_forced_decision('feature_1') + expect(status).to be_nil + status = user_context_obj.remove_forced_decision('feature_1') + expect(status).to be false + status = user_context_obj.remove_all_forced_decision + expect(status).to be false + end + + it 'should return status for datafile in forced decision calls' do + user_id = 'test_user' + original_attributes = {} + user_context_obj = Optimizely::OptimizelyUserContext.new(project_instance, user_id, original_attributes) + status = user_context_obj.set_forced_decision('feature_1', nil, '3324490562') + expect(status).to be true + status = user_context_obj.get_forced_decision('feature_1') + expect(status).to eq('3324490562') + status = user_context_obj.remove_forced_decision('feature_1') + expect(status).to be true + status = user_context_obj.remove_all_forced_decision + expect(status).to be true + end + + it 'should set forced decision in decide' do + impression_log_url = 'https://logx.optimizely.com/v1/events' + time_now = Time.now + post_headers = {'Content-Type' => 'application/json'} + allow(Time).to receive(:now).and_return(time_now) + allow(SecureRandom).to receive(:uuid).and_return('a68cf1ad-0393-4e18-af87-efe8f01a7c9c') + allow(forced_decision_project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + user_id = 'tester' + feature_key = 'feature_1' + expected_params = { + account_id: '10367498574', + project_id: '10431130345', + revision: '241', + client_name: 'ruby-sdk', + client_version: '3.9.0', + anonymize_ip: true, + enrich_decisions: true, + visitors: [{ + snapshots: [{ + events: [{ + entity_id: '', + uuid: 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + key: 'campaign_activated', + timestamp: (time_now.to_f * 1000).to_i + }], + decisions: [{ + campaign_id: '', + experiment_id: '', + variation_id: '3324490562', + metadata: { + flag_key: 'feature_1', + rule_key: '', + rule_type: 'feature-test', + variation_key: '3324490562', + enabled: true + } + }] + }], + visitor_id: 'tester', + attributes: [{ + entity_id: '$opt_bot_filtering', + key: '$opt_bot_filtering', + type: 'custom', + value: true + }] + }] + } + stub_request(:post, impression_log_url) + expect(forced_decision_project_instance.notification_center).to receive(:send_notifications) + .once.with(Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args) + expect(forced_decision_project_instance.notification_center).to receive(:send_notifications) + .with( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION], + 'flag', + 'tester', + {}, + flag_key: 'feature_1', + enabled: true, + variables: { + 'b_true' => true, + 'd_4_2' => 4.2, + 'i_1' => 'invalid', + 'i_42' => 42, + 'j_1' => { + 'value' => 1 + }, + 's_foo' => 'foo' + + }, + variation_key: '3324490562', + rule_key: nil, + reasons: [], + decision_event_dispatched: true + ) + user_context_obj = forced_decision_project_instance.create_user_context(user_id) + user_context_obj.set_forced_decision(feature_key, nil, '3324490562') + decision = user_context_obj.decide(feature_key) + expect(forced_decision_project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, impression_log_url, expected_params, post_headers)) + expect(decision.variation_key).to eq('3324490562') + expect(decision.rule_key).to be_nil + expect(decision.enabled).to be true + expect(decision.flag_key).to eq(feature_key) + expect(decision.user_context.user_id).to eq(user_id) + expect(decision.user_context.user_attributes.length).to eq(0) + expect(decision.reasons).to eq([]) + expect(decision.user_context.forced_decisions.length).to eq(1) + expect(decision.user_context.forced_decisions).to eq(Optimizely::OptimizelyUserContext::ForcedDecision.new(feature_key, nil) => '3324490562') + end + + it 'should set experiment rule in forced decision using set forced decision' do + impression_log_url = 'https://logx.optimizely.com/v1/events' + time_now = Time.now + post_headers = {'Content-Type' => 'application/json'} + allow(Time).to receive(:now).and_return(time_now) + allow(SecureRandom).to receive(:uuid).and_return('a68cf1ad-0393-4e18-af87-efe8f01a7c9c') + allow(forced_decision_project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + user_id = 'tester' + feature_key = 'feature_1' + original_attributes = {} + stub_request(:post, impression_log_url) + expected_params = { + account_id: '10367498574', + project_id: '10431130345', + revision: '241', + client_name: 'ruby-sdk', + client_version: '3.9.0', + anonymize_ip: true, + enrich_decisions: true, + visitors: [{ + snapshots: [{ + events: [{ + entity_id: '10420273888', + uuid: 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + key: 'campaign_activated', + timestamp: (time_now.to_f * 1000).to_i + }], + decisions: [{ + campaign_id: '10420273888', + experiment_id: '10390977673', + variation_id: '10416523121', + metadata: { + flag_key: 'feature_1', + rule_key: 'exp_with_audience', + rule_type: 'feature-test', + variation_key: 'b', + enabled: false + } + }] + }], + visitor_id: 'tester', + attributes: [{ + entity_id: '$opt_bot_filtering', + key: '$opt_bot_filtering', + type: 'custom', + value: true + }] + }] + } + + expect(forced_decision_project_instance.notification_center).to receive(:send_notifications) + .with(Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args) + expect(forced_decision_project_instance.notification_center).to receive(:send_notifications) + .with( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION], + 'flag', + 'tester', + {}, + flag_key: 'feature_1', + enabled: false, + variables: { + 'b_true' => true, + 'd_4_2' => 4.2, + 'i_1' => 'invalid', + 'i_42' => 42, + 'j_1' => { + 'value' => 1 + }, + 's_foo' => 'foo' + }, + variation_key: 'b', + rule_key: 'exp_with_audience', + reasons: ['Variation (b) is mapped to flag (feature_1), rule (exp_with_audience) and user (tester) in the forced decision map.'], + decision_event_dispatched: true + ) + user_context_obj = Optimizely::OptimizelyUserContext.new(forced_decision_project_instance, user_id, original_attributes) + user_context_obj.set_forced_decision(feature_key, 'exp_with_audience', 'b') + decision = user_context_obj.decide(feature_key, [Optimizely::Decide::OptimizelyDecideOption::INCLUDE_REASONS]) + expect(forced_decision_project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, impression_log_url, expected_params, post_headers)) + expect(decision.variation_key).to eq('b') + expect(decision.rule_key).to eq('exp_with_audience') + expect(decision.enabled).to be false + expect(decision.flag_key).to eq(feature_key) + expect(decision.user_context.user_id).to eq(user_id) + expect(decision.user_context.user_attributes.length).to eq(0) + expect(decision.user_context.forced_decisions.length).to eq(1) + expect(decision.user_context.forced_decisions).to eq(Optimizely::OptimizelyUserContext::ForcedDecision.new(feature_key, 'exp_with_audience') => 'b') + expect(decision.reasons).to eq(['Variation (b) is mapped to flag (feature_1), rule (exp_with_audience) and user (tester) in the forced decision map.']) + end + + it 'should return correct variation if rule in forced decision is deleted' do + impression_log_url = 'https://logx.optimizely.com/v1/events' + time_now = Time.now + post_headers = {'Content-Type' => 'application/json'} + allow(Time).to receive(:now).and_return(time_now) + allow(SecureRandom).to receive(:uuid).and_return('a68cf1ad-0393-4e18-af87-efe8f01a7c9c') + allow(forced_decision_project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + user_id = 'tester' + feature_key = 'feature_1' + expected_params = { + account_id: '10367498574', + project_id: '10431130345', + revision: '241', + client_name: 'ruby-sdk', + client_version: '3.9.0', + anonymize_ip: true, + enrich_decisions: true, + visitors: [{ + snapshots: [{ + events: [{ + entity_id: '', + uuid: 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + key: 'campaign_activated', + timestamp: (time_now.to_f * 1000).to_i + }], + decisions: [{ + campaign_id: '', + experiment_id: '', + variation_id: '3324490562', + metadata: { + flag_key: 'feature_1', + rule_key: '', + rule_type: 'feature-test', + variation_key: '3324490562', + enabled: true + } + }] + }], + visitor_id: 'tester', + attributes: [{ + entity_id: '$opt_bot_filtering', + key: '$opt_bot_filtering', + type: 'custom', + value: true + }] + }] + } + stub_request(:post, impression_log_url) + expect(forced_decision_project_instance.notification_center).to receive(:send_notifications) + .once.with(Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args) + expect(forced_decision_project_instance.notification_center).to receive(:send_notifications) + .with( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION], + 'flag', + 'tester', + {}, + flag_key: 'feature_1', + enabled: true, + variables: { + 'b_true' => true, + 'd_4_2' => 4.2, + 'i_1' => 'invalid', + 'i_42' => 42, + 'j_1' => { + 'value' => 1 + }, + 's_foo' => 'foo' + }, + variation_key: '3324490562', + rule_key: nil, + reasons: [], + decision_event_dispatched: true + ) + user_context_obj = forced_decision_project_instance.create_user_context(user_id) + # set forced decision with flag + user_context_obj.set_forced_decision(feature_key, nil, '3324490562') + # set forced decision with flag and rule + user_context_obj.set_forced_decision(feature_key, 'exp_with_audience', 'b') + # remove rule forced decision with flag + user_context_obj.remove_forced_decision(feature_key, 'exp_with_audience') + # decision should be based on flag forced decision + decision = user_context_obj.decide(feature_key) + expect(forced_decision_project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, impression_log_url, expected_params, post_headers)) + expect(decision.variation_key).to eq('3324490562') + expect(decision.rule_key).to be_nil + expect(decision.enabled).to be true + expect(decision.flag_key).to eq(feature_key) + expect(decision.user_context.user_id).to eq(user_id) + expect(decision.user_context.user_attributes.length).to eq(0) + expect(decision.reasons).to eq([]) + expect(decision.user_context.forced_decisions.length).to eq(1) + expect(decision.user_context.forced_decisions).to eq(Optimizely::OptimizelyUserContext::ForcedDecision.new(feature_key, nil) => '3324490562') + end + + it 'should set delivery rule in forced decision using set forced decision' do + user_id = 'tester' + feature_key = 'feature_1' + original_attributes = {} + stub_request(:post, impression_log_url) + user_context_obj = Optimizely::OptimizelyUserContext.new(forced_decision_project_instance, user_id, original_attributes) + user_context_obj.set_forced_decision(feature_key, '3332020515', '3324490633') + decision = user_context_obj.decide(feature_key) + expect(decision.variation_key).to eq('3324490633') + expect(decision.rule_key).to eq('3332020515') + expect(decision.enabled).to be true + expect(decision.flag_key).to eq(feature_key) + expect(decision.user_context.user_id).to eq(user_id) + expect(decision.user_context.user_attributes.length).to eq(0) + expect(decision.reasons).to eq([]) + expect(decision.user_context.forced_decisions.length).to eq(1) + expect(decision.user_context.forced_decisions).to eq(Optimizely::OptimizelyUserContext::ForcedDecision.new(feature_key, '3332020515') => '3324490633') + + decision = user_context_obj.decide(feature_key, [Optimizely::Decide::OptimizelyDecideOption::INCLUDE_REASONS]) + expect(decision.reasons).to eq([ + "Starting to evaluate audience '13389141123' with conditions: [\"and\", [\"or\", [\"or\", {\"match\": \"exact\", \"name\": \"gender\", \"type\": \"custom_attribute\", \"value\": \"f\"}]]].", + "Audience '13389141123' evaluated to UNKNOWN.", + "Audiences for experiment 'exp_with_audience' collectively evaluated to FALSE.", + "User 'tester' does not meet the conditions to be in experiment 'exp_with_audience'.", + "The user 'tester' is not bucketed into any of the experiments on the feature 'feature_1'.", + 'Variation (3324490633) is mapped to flag (feature_1), rule (3332020515) and user (tester) in the forced decision map.' + ]) + end + + it 'should return proper valid result for invalid variation in forced decision' do + user_id = 'tester' + feature_key = 'feature_1' + original_attributes = {} + stub_request(:post, impression_log_url) + + # flag-to-decision + user_context_obj = Optimizely::OptimizelyUserContext.new(forced_decision_project_instance, user_id, original_attributes) + user_context_obj.set_forced_decision(feature_key, nil, 'invalid') + decision = user_context_obj.decide(feature_key, [Optimizely::Decide::OptimizelyDecideOption::INCLUDE_REASONS]) + expect(decision.variation_key).to eq('18257766532') + expect(decision.rule_key).to eq('18322080788') + expect(decision.reasons).to include('Invalid variation is mapped to flag (feature_1) and user (tester) in the forced decision map.') + + # experiment-rule-to-decision + user_context_obj = Optimizely::OptimizelyUserContext.new(forced_decision_project_instance, user_id, original_attributes) + user_context_obj.set_forced_decision(feature_key, 'exp_with_audience', 'invalid') + decision = user_context_obj.decide(feature_key, [Optimizely::Decide::OptimizelyDecideOption::INCLUDE_REASONS]) + expect(decision.variation_key).to eq('18257766532') + expect(decision.rule_key).to eq('18322080788') + expect(decision.reasons).to include('Invalid variation is mapped to flag (feature_1), rule (exp_with_audience) and user (tester) in the forced decision map.') + + # delivery-rule-to-decision + user_context_obj = Optimizely::OptimizelyUserContext.new(forced_decision_project_instance, user_id, original_attributes) + user_context_obj.set_forced_decision(feature_key, '3332020515', 'invalid') + decision = user_context_obj.decide(feature_key, [Optimizely::Decide::OptimizelyDecideOption::INCLUDE_REASONS]) + expect(decision.variation_key).to eq('18257766532') + expect(decision.rule_key).to eq('18322080788') + expect(decision.reasons).to include("Starting to evaluate audience '13389141123' with conditions: [\"and\", [\"or\", [\"or\", {\"match\": \"exact\", \"name\": \"gender\", \"type\": \"custom_attribute\", \"value\": \"f\"}]]].") + end + + it 'should return valid response with conflicts in forced decision' do + user_id = 'tester' + feature_key = 'feature_1' + original_attributes = {} + stub_request(:post, impression_log_url) + user_context_obj = Optimizely::OptimizelyUserContext.new(forced_decision_project_instance, user_id, original_attributes) + user_context_obj.set_forced_decision(feature_key, nil, '3324490562') + user_context_obj.set_forced_decision(feature_key, 'exp_with_audience', 'b') + decision = user_context_obj.decide(feature_key) + expect(decision.variation_key).to eq('3324490562') + expect(decision.rule_key).to be_nil + end + + it 'should get forced decision' do + user_id = 'tester' + feature_key = 'feature_1' + original_attributes = {} + stub_request(:post, impression_log_url) + user_context_obj = Optimizely::OptimizelyUserContext.new(forced_decision_project_instance, user_id, original_attributes) + user_context_obj.set_forced_decision(feature_key, nil, 'fv1') + expect(user_context_obj.get_forced_decision(feature_key)).to eq('fv1') + + user_context_obj.set_forced_decision(feature_key, nil, 'fv2') + expect(user_context_obj.get_forced_decision(feature_key)).to eq('fv2') + + user_context_obj.set_forced_decision(feature_key, 'r', 'ev1') + expect(user_context_obj.get_forced_decision(feature_key, 'r')).to eq('ev1') + + user_context_obj.set_forced_decision(feature_key, 'r', 'ev2') + expect(user_context_obj.get_forced_decision(feature_key, 'r')).to eq('ev2') + + expect(user_context_obj.get_forced_decision(feature_key)).to eq('fv2') + end + + it 'should remove forced decision' do + user_id = 'tester' + feature_key = 'feature_1' + original_attributes = {} + stub_request(:post, impression_log_url) + user_context_obj = Optimizely::OptimizelyUserContext.new(forced_decision_project_instance, user_id, original_attributes) + user_context_obj.set_forced_decision(feature_key, nil, 'fv1') + user_context_obj.set_forced_decision(feature_key, 'r', 'ev1') + + expect(user_context_obj.get_forced_decision(feature_key)).to eq('fv1') + expect(user_context_obj.get_forced_decision(feature_key, 'r')).to eq('ev1') + + status = user_context_obj.remove_forced_decision(feature_key) + expect(status).to be true + expect(user_context_obj.get_forced_decision(feature_key)).to be_nil + expect(user_context_obj.get_forced_decision(feature_key, 'r')).to eq('ev1') + + status = user_context_obj.remove_forced_decision(feature_key, 'r') + expect(status).to be true + expect(user_context_obj.get_forced_decision(feature_key)).to be_nil + expect(user_context_obj.get_forced_decision(feature_key, 'r')).to be_nil + + status = user_context_obj.remove_forced_decision(feature_key) + expect(status).to be false + end + + it 'should remove all forced decision' do + user_id = 'tester' + feature_key = 'feature_1' + original_attributes = {} + stub_request(:post, impression_log_url) + user_context_obj = Optimizely::OptimizelyUserContext.new(forced_decision_project_instance, user_id, original_attributes) + user_context_obj.set_forced_decision(feature_key, nil, 'fv1') + user_context_obj.set_forced_decision(feature_key, 'r', 'ev1') + + expect(user_context_obj.get_forced_decision(feature_key)).to eq('fv1') + expect(user_context_obj.get_forced_decision(feature_key, 'r')).to eq('ev1') + + user_context_obj.remove_all_forced_decision + expect(user_context_obj.get_forced_decision(feature_key)).to be_nil + expect(user_context_obj.get_forced_decision(feature_key, 'r')).to be_nil + + status = user_context_obj.remove_forced_decision(feature_key) + expect(status).to be false + end + + it 'should clone forced decision in user context' do + user_id = 'tester' + original_attributes = {'country' => 'us'} + user_context_obj = Optimizely::OptimizelyUserContext.new(forced_decision_project_instance, user_id, original_attributes) + user_clone_1 = user_context_obj.clone + + # with no forced decision + expect(user_clone_1.user_id).to eq(user_id) + expect(user_clone_1.user_attributes).to eq(original_attributes) + expect(user_clone_1.forced_decisions).to be_empty + + # with forced decisions + user_context_obj.set_forced_decision('a', nil, 'b') + user_context_obj.set_forced_decision('a', 'c', 'd') + user_context_obj.set_forced_decision('a', '', 'e') + + user_clone_2 = user_context_obj.clone + expect(user_clone_2.user_id).to eq(user_id) + expect(user_clone_2.user_attributes).to eq(original_attributes) + expect(user_clone_2.forced_decisions).not_to be_nil + + expect(user_clone_2.get_forced_decision('a')).to eq('b') + expect(user_clone_2.get_forced_decision('a', 'c')).to eq('d') + expect(user_clone_2.get_forced_decision('x')).to be_nil + expect(user_clone_2.get_forced_decision('a', '')).to eq('e') + + # forced decisions should be copied separately + user_context_obj.set_forced_decision('a', 'new-rk', 'new-vk') + expect(user_context_obj.get_forced_decision('a', 'new-rk')).to eq('new-vk') + expect(user_clone_2.get_forced_decision('a', 'new-rk')).to be_nil + end + + it 'should set, get, remove, remove all and clone in synchronize manner' do + user_id = 'tester' + original_attributes = {} + threads = [] + user_clone = nil + user_context_obj = Optimizely::OptimizelyUserContext.new(forced_decision_project_instance, user_id, original_attributes) + allow(user_context_obj).to receive(:clone) + allow(user_context_obj).to receive(:set_forced_decision) + allow(user_context_obj).to receive(:get_forced_decision) + allow(user_context_obj).to receive(:remove_forced_decision) + allow(user_context_obj).to receive(:remove_all_forced_decision) + + # clone + threads << Thread.new do + 100.times do + user_clone = user_context_obj.clone + end + end + + # set forced decision + threads << Thread.new do + 100.times do + user_context_obj.set_forced_decision('0', nil, 'var') + end + end + + threads << Thread.new do + 100.times do + user_context_obj.set_forced_decision('1', nil, 'var') + end + end + + # get forced decision + threads << Thread.new do + 100.times do + user_context_obj.get_forced_decision('0', nil) + end + end + + threads << Thread.new do + 100.times do + user_context_obj.get_forced_decision('1', nil) + end + end + + # remove forced decision + threads << Thread.new do + 100.times do + user_context_obj.remove_forced_decision('0', nil) + end + end + + threads << Thread.new do + 100.times do + user_context_obj.remove_forced_decision('1', nil) + end + end + + # remove all forced decision + threads << Thread.new do + user_context_obj.remove_all_forced_decision + end + + threads.each(&:join) + expect(user_context_obj).to have_received(:clone).exactly(100).times + expect(user_context_obj).to have_received(:set_forced_decision).with('0', nil, 'var').exactly(100).times + expect(user_context_obj).to have_received(:set_forced_decision).with('1', nil, 'var').exactly(100).times + expect(user_context_obj).to have_received(:get_forced_decision).with('0', nil).exactly(100).times + expect(user_context_obj).to have_received(:get_forced_decision).with('1', nil).exactly(100).times + expect(user_context_obj).to have_received(:remove_forced_decision).with('0', nil).exactly(100).times + expect(user_context_obj).to have_received(:remove_forced_decision).with('1', nil).exactly(100).times + expect(user_context_obj).to have_received(:remove_all_forced_decision).once + end + end end diff --git a/spec/project_spec.rb b/spec/project_spec.rb index 5a0d7c9f..2944a7fe 100644 --- a/spec/project_spec.rb +++ b/spec/project_spec.rb @@ -2147,7 +2147,7 @@ def callback(_args); end it 'should log an error message and return nil' do expect(project_instance.get_feature_variable_string('totally_invalid_feature_key', 'string_variable', user_id, user_attributes)) .to eq(nil) - expect(spy_logger).to have_received(:log).twice + expect(spy_logger).to have_received(:log).exactly(2).times expect(spy_logger).to have_received(:log).once .with( Logger::ERROR, @@ -3978,15 +3978,15 @@ def callback(_args); end user_context = project_instance.create_user_context('user1') expect(project_instance.decision_service).to receive(:get_variation_for_feature) - .with(anything, anything, anything, anything, []).once + .with(anything, anything, anything, []).once project_instance.decide(user_context, 'multi_variate_feature') expect(project_instance.decision_service).to receive(:get_variation_for_feature) - .with(anything, anything, anything, anything, [Optimizely::Decide::OptimizelyDecideOption::DISABLE_DECISION_EVENT]).once + .with(anything, anything, anything, [Optimizely::Decide::OptimizelyDecideOption::DISABLE_DECISION_EVENT]).once project_instance.decide(user_context, 'multi_variate_feature', [Optimizely::Decide::OptimizelyDecideOption::DISABLE_DECISION_EVENT]) expect(project_instance.decision_service).to receive(:get_variation_for_feature) - .with(anything, anything, anything, anything, [ + .with(anything, anything, anything, [ Optimizely::Decide::OptimizelyDecideOption::DISABLE_DECISION_EVENT, Optimizely::Decide::OptimizelyDecideOption::EXCLUDE_VARIABLES, Optimizely::Decide::OptimizelyDecideOption::ENABLED_FLAGS_ONLY, @@ -4044,6 +4044,7 @@ def callback(_args); end stub_request(:post, impression_log_url) user_context = project_instance.create_user_context('user1') decisions = project_instance.decide_all(user_context, [Optimizely::Decide::OptimizelyDecideOption::ENABLED_FLAGS_ONLY]) + expect(decisions.length).to eq(6) expect(decisions['boolean_single_variable_feature'].as_json).to eq( enabled: true, diff --git a/spec/spec_params.rb b/spec/spec_params.rb index 4647f4ea..588cde57 100644 --- a/spec/spec_params.rb +++ b/spec/spec_params.rb @@ -1445,6 +1445,282 @@ module OptimizelySpec 'sendFlagDecisions' => true }.freeze + DECIDE_FORCED_DECISION = { + 'version' => '4', 'sendFlagDecisions' => true, 'rollouts' => [{ + 'experiments' => [{ + 'audienceIds' => ['13389130056'], + 'forcedVariations' => {}, + 'id' => '3332020515', + 'key' => '3332020515', + 'layerId' => '3319450668', + 'status' => 'Running', + 'trafficAllocation' => [{ + 'endOfRange' => 9_000, + 'entityId' => '3324490633' + }, { + 'entityId' => '3324490634', + 'endOfRange' => 1_000 + }], + 'variations' => [{ + 'featureEnabled' => true, + 'id' => '3324490633', + 'key' => '3324490633', + 'variables' => [] + }, { + 'featureEnabled' => true, + 'id' => '3324490634', + 'key' => '3324490634', + 'variables' => [] + }] + }, { + 'audienceIds' => ['12208130097'], + 'forcedVariations' => {}, + 'id' => '3332020494', + 'key' => '3332020494', + 'layerId' => '3319450668', + 'status' => 'Running', + 'trafficAllocation' => [{ + 'endOfRange' => 0, + 'entityId' => '3324490562' + }, { + 'entityId' => '3324490634', + 'endOfRange' => 0 + }], + 'variations' => [{ + 'featureEnabled' => true, + 'id' => '3324490562', + 'key' => '3324490562', + 'variables' => [] + }, { + 'featureEnabled' => true, + 'id' => '3324490634', + 'key' => '3324490634', + 'variables' => [] + }] + }, { + 'status' => 'Running', + 'audienceIds' => [], + 'variations' => [{ + 'variables' => [], + 'id' => '18257766532', + 'key' => '18257766532', + 'featureEnabled' => true + }, { + 'featureEnabled' => true, + 'id' => '3324490634', + 'key' => '3324490634', + 'variables' => [] + }], + 'id' => '18322080788', + 'key' => '18322080788', + 'layerId' => '18263344648', + 'trafficAllocation' => [{ + 'entityId' => '18257766532', + 'endOfRange' => 9_000 + }, { + 'entityId' => '3324490634', + 'endOfRange' => 1_000 + }], + 'forcedVariations' => {} + }], + 'id' => '3319450668' + }], 'anonymizeIP' => true, 'botFiltering' => true, 'projectId' => '10431130345', 'variables' => [], 'featureFlags' => [{ + 'experimentIds' => ['10390977673'], + 'id' => '4482920077', + 'key' => 'feature_1', + 'rolloutId' => '3319450668', + 'variables' => [{ + 'defaultValue' => '42', + 'id' => '2687470095', + 'key' => 'i_42', + 'type' => 'integer' + }, { + 'defaultValue' => '4.2', + 'id' => '2689280165', + 'key' => 'd_4_2', + 'type' => 'double' + }, { + 'defaultValue' => 'true', + 'id' => '2689660112', + 'key' => 'b_true', + 'type' => 'boolean' + }, { + 'defaultValue' => 'foo', + 'id' => '2696150066', + 'key' => 's_foo', + 'type' => 'string' + }, { + 'defaultValue' => '{"value":1}', + 'id' => '2696150067', + 'key' => 'j_1', + 'type' => 'string', + 'subType' => 'json' + }, { + 'defaultValue' => 'invalid', + 'id' => '2696150068', + 'key' => 'i_1', + 'type' => 'invalid', + 'subType' => '' + }] + }, { + 'experimentIds' => ['10420810910'], + 'id' => '4482920078', + 'key' => 'feature_2', + 'rolloutId' => '', + 'variables' => [{ + 'defaultValue' => '42', + 'id' => '2687470095', + 'key' => 'i_42', + 'type' => 'integer' + }] + }, { + 'experimentIds' => [], + 'id' => '44829230000', + 'key' => 'feature_3', + 'rolloutId' => '', + 'variables' => [] + }], 'experiments' => [{ + 'status' => 'Running', + 'key' => 'exp_with_audience', + 'layerId' => '10420273888', + 'trafficAllocation' => [{ + 'entityId' => '10389729780', + 'endOfRange' => 10_000 + }], + 'audienceIds' => ['13389141123'], + 'variations' => [{ + 'variables' => [], + 'featureEnabled' => true, + 'id' => '10389729780', + 'key' => 'a' + }, { + 'variables' => [], + 'id' => '10416523121', + 'key' => 'b' + }], + 'forcedVariations' => {}, + 'id' => '10390977673' + }, { + 'status' => 'Running', + 'key' => 'exp_no_audience', + 'layerId' => '10417730432', + 'trafficAllocation' => [{ + 'entityId' => '10418551353', + 'endOfRange' => 10_000 + }], + 'audienceIds' => [], + 'variations' => [{ + 'variables' => [], + 'featureEnabled' => true, + 'id' => '10418551353', + 'key' => 'variation_with_traffic' + }, { + 'variables' => [], + 'featureEnabled' => false, + 'id' => '10418510624', + 'key' => 'variation_no_traffic' + }], + 'forcedVariations' => {}, + 'id' => '10420810910' + }], 'audiences' => [{ + 'id' => '13389141123', + 'conditions' => '["and", ["or", ["or", {"match": "exact", "name": "gender", "type": "custom_attribute", "value": "f"}]]]', + 'name' => 'gender' + }, { + 'id' => '13389130056', + 'conditions' => '["and", ["or", ["or", {"match": "exact", "name": "country", "type": "custom_attribute", "value": "US"}]]]', + 'name' => 'US' + }, { + 'id' => '12208130097', + 'conditions' => '["and", ["or", ["or", {"match": "exact", "name": "browser", "type": "custom_attribute", "value": "safari"}]]]', + 'name' => 'safari' + }, { + 'id' => 'age_18', + 'conditions' => '["and", ["or", ["or", {"match": "gt", "name": "age", "type": "custom_attribute", "value": 18}]]]', + 'name' => 'age_18' + }, { + 'id' => 'invalid_format', + 'conditions' => '[]', + 'name' => 'invalid_format' + }, { + 'id' => 'invalid_condition', + 'conditions' => '["and", ["or", ["or", {"match": "gt", "name": "age", "type": "custom_attribute", "value": "US"}]]]', + 'name' => 'invalid_condition' + }, { + 'id' => 'invalid_type', + 'conditions' => '["and", ["or", ["or", {"match": "gt", "name": "age", "type": "invalid", "value": 18}]]]', + 'name' => 'invalid_type' + }, { + 'id' => 'invalid_match', + 'conditions' => '["and", ["or", ["or", {"match": "invalid", "name": "age", "type": "custom_attribute", "value": 18}]]]', + 'name' => 'invalid_match' + }, { + 'id' => 'nil_value', + 'conditions' => '["and", ["or", ["or", {"match": "gt", "name": "age", "type": "custom_attribute"}]]]', + 'name' => 'nil_value' + }, { + 'id' => 'invalid_name', + 'conditions' => '["and", ["or", ["or", {"match": "gt", "type": "custom_attribute", "value": 18}]]]', + 'name' => 'invalid_name' + }], 'groups' => [{ + 'policy' => 'random', + 'trafficAllocation' => [{ + 'entityId' => '10390965532', + 'endOfRange' => 10_000 + }], + 'experiments' => [{ + 'status' => 'Running', + 'key' => 'group_exp_1', + 'layerId' => '10420222423', + 'trafficAllocation' => [{ + 'entityId' => '10389752311', + 'endOfRange' => 10_000 + }], + 'audienceIds' => [], + 'variations' => [{ + 'variables' => [], + 'featureEnabled' => false, + 'id' => '10389752311', + 'key' => 'a' + }], + 'forcedVariations' => {}, + 'id' => '10390965532' + }, { + 'status' => 'Running', + 'key' => 'group_exp_2', + 'layerId' => '10417730432', + 'trafficAllocation' => [{ + 'entityId' => '10418524243', + 'endOfRange' => 10_000 + }], + 'audienceIds' => [], + 'variations' => [{ + 'variables' => [], + 'featureEnabled' => false, + 'id' => '10418524243', + 'key' => 'a' + }], + 'forcedVariations' => {}, + 'id' => '10420843432' + }], + 'id' => '13142870430' + }], 'attributes' => [{ + 'id' => '10401066117', + 'key' => 'gender' + }, { + 'id' => '10401066170', + 'key' => 'testvar' + }], 'accountId' => '10367498574', 'events' => [{ + 'experimentIds' => ['10420810910'], + 'id' => '10404198134', + 'key' => 'event1' + }, { + 'experimentIds' => %w[10420810910 10390977673], + 'id' => '10404198135', + 'key' => 'event_multiple_running_exp_attached' + }], 'revision' => '241' + }.freeze + VALID_CONFIG_BODY_JSON = JSON.dump(VALID_CONFIG_BODY) INVALID_CONFIG_BODY = VALID_CONFIG_BODY.dup @@ -1456,6 +1732,7 @@ module OptimizelySpec CONFIG_DICT_WITH_TYPED_AUDIENCES_JSON = JSON.dump(CONFIG_DICT_WITH_TYPED_AUDIENCES) SIMILAR_RULE_KEYS_JSON = JSON.dump(SIMILAR_RULE_KEYS) + DECIDE_FORCED_DECISION_JSON = JSON.dump(DECIDE_FORCED_DECISION) # SEND_FLAG_DECISIONS_DISABLED_CONFIG = VALID_CONFIG_BODY.dup # SEND_FLAG_DECISIONS_DISABLED_CONFIG['sendFlagDecisions'] = false end