diff --git a/CHANGELOG b/CHANGELOG index 5cb6ed33..8f5a7c0d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,9 @@ +## 1.5.0 +December 13, 2017 + +* Implemented IP anonymization. +* Implemented bucketing IDs. +* Implemented Notification Listeners. ------------------------------------------------------------------------------- ## 1.4.0 October 3, 2017 diff --git a/lib/optimizely.rb b/lib/optimizely.rb index f466217c..bd8f7613 100644 --- a/lib/optimizely.rb +++ b/lib/optimizely.rb @@ -24,6 +24,7 @@ require_relative 'optimizely/helpers/validator' require_relative 'optimizely/helpers/variable_type' require_relative 'optimizely/logger' +require_relative 'optimizely/notification_center' require_relative 'optimizely/project_config' module Optimizely @@ -38,6 +39,7 @@ class Project attr_reader :event_builder attr_reader :event_dispatcher attr_reader :logger + attr_reader :notification_center def initialize(datafile, event_dispatcher = nil, logger = nil, error_handler = nil, skip_json_validation = false, user_profile_service = nil) # Constructor for Projects. @@ -83,6 +85,7 @@ def initialize(datafile, event_dispatcher = nil, logger = nil, error_handler = n @decision_service = DecisionService.new(@config, @user_profile_service) @event_builder = EventBuilder.new(@config) + @notification_center = NotificationCenter.new(@logger, @error_handler) end def activate(experiment_key, user_id, attributes = nil) @@ -231,6 +234,10 @@ def track(event_key, user_id, attributes = nil, event_tags = nil) rescue => e @logger.log(Logger::ERROR, "Unable to dispatch conversion event. Error: #{e}") end + @notification_center.send_notifications( + NotificationCenter::NOTIFICATION_TYPES[:TRACK], + event_key, user_id, attributes, event_tags, conversion_event + ) end def is_feature_enabled(feature_flag_key, user_id, attributes = nil) @@ -512,6 +519,11 @@ def send_impression(experiment, variation_key, user_id, attributes = nil) rescue => e @logger.log(Logger::ERROR, "Unable to dispatch impression event. Error: #{e}") end + variation = @config.get_variation_from_id(experiment_key, variation_id) + @notification_center.send_notifications( + NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE], + experiment,user_id, attributes, variation, impression_event + ) end end end diff --git a/lib/optimizely/bucketer.rb b/lib/optimizely/bucketer.rb index 794b1dcf..dcb6ac47 100644 --- a/lib/optimizely/bucketer.rb +++ b/lib/optimizely/bucketer.rb @@ -20,7 +20,7 @@ module Optimizely class Bucketer # Optimizely bucketing algorithm that evenly distributes visitors. - BUCKETING_ID_TEMPLATE = '%{user_id}%{entity_id}' + BUCKETING_ID_TEMPLATE = '%{bucketing_id}%{entity_id}' HASH_SEED = 1 MAX_HASH_VALUE = 2**32 MAX_TRAFFIC_VALUE = 10_000 @@ -35,13 +35,15 @@ def initialize(config) @config = config end - def bucket(experiment, user_id) + def bucket(experiment, bucketing_id, user_id) # Determines ID of variation to be shown for a given experiment key and user ID. # # experiment - Experiment for which visitor is to be bucketed. + # bucketing_id - String A customer-assigned value used to generate the bucketing key # user_id - String ID for user. # # Returns variation in which visitor with ID user_id has been placed. Nil if no variation. + return nil if experiment.nil? # check if experiment is in a group; if so, check if user is bucketed into specified experiment experiment_id = experiment['id'] @@ -51,7 +53,7 @@ def bucket(experiment, user_id) group = @config.group_key_map.fetch(group_id) if Helpers::Group.random_policy?(group) traffic_allocations = group.fetch('trafficAllocation') - bucketed_experiment_id = find_bucket(user_id, group_id, traffic_allocations) + bucketed_experiment_id = find_bucket(bucketing_id, user_id, group_id, traffic_allocations) # return if the user is not bucketed into any experiment unless bucketed_experiment_id @config.logger.log(Logger::INFO, "User '#{user_id}' is in no experiment.") @@ -76,7 +78,7 @@ def bucket(experiment, user_id) end traffic_allocations = experiment['trafficAllocation'] - variation_id = find_bucket(user_id, experiment_id, traffic_allocations) + variation_id = find_bucket(bucketing_id, user_id, experiment_id, traffic_allocations) if variation_id && variation_id != '' variation = @config.get_variation_from_id(experiment_key, variation_id) variation_key = variation ? variation['key'] : nil @@ -96,18 +98,18 @@ def bucket(experiment, user_id) nil end - def find_bucket(user_id, parent_id, traffic_allocations) + def find_bucket(bucketing_id, user_id, parent_id, traffic_allocations) # Helper function to find the matching entity ID for a given bucketing value in a list of traffic allocations. # + # bucketing_id - String A customer-assigned value user to generate bucketing key # user_id - String ID for user # parent_id - String entity ID to use for bucketing ID # traffic_allocations - Array of traffic allocations # # Returns entity ID corresponding to the provided bucket value or nil if no match is found. - - bucketing_id = sprintf(BUCKETING_ID_TEMPLATE, user_id: user_id, entity_id: parent_id) - bucket_value = generate_bucket_value(bucketing_id) - @config.logger.log(Logger::DEBUG, "Assigned bucket #{bucket_value} to user '#{user_id}'.") + bucketing_key = sprintf(BUCKETING_ID_TEMPLATE, bucketing_id: bucketing_id, entity_id: parent_id) + bucket_value = generate_bucket_value(bucketing_key) + @config.logger.log(Logger::DEBUG, "Assigned bucket #{bucket_value} to user '#{user_id}' with bucketing ID: '#{bucketing_id}'.") traffic_allocations.each do |traffic_allocation| current_end_of_range = traffic_allocation['endOfRange'] @@ -122,25 +124,25 @@ def find_bucket(user_id, parent_id, traffic_allocations) private - def generate_bucket_value(bucketing_id) + def generate_bucket_value(bucketing_key) # Helper function to generate bucket value in half-closed interval [0, MAX_TRAFFIC_VALUE). # - # bucketing_id - String ID for bucketing. + # bucketing_key - String - Value used to generate bucket value # - # Returns bucket value corresponding to the provided bucketing ID. + # Returns bucket value corresponding to the provided bucketing key. - ratio = (generate_unsigned_hash_code_32_bit(bucketing_id)).to_f / MAX_HASH_VALUE + ratio = (generate_unsigned_hash_code_32_bit(bucketing_key)).to_f / MAX_HASH_VALUE (ratio * MAX_TRAFFIC_VALUE).to_i end - def generate_unsigned_hash_code_32_bit(bucketing_id) + def generate_unsigned_hash_code_32_bit(bucketing_key) # Helper function to retreive hash code # - # bucketing_id - String ID for bucketing. + # bucketing_key - String - Value used for the key of the murmur hash # # Returns hash code which is a 32 bit unsigned integer. - MurmurHash3::V32.str_hash(bucketing_id, @bucket_seed) & UNSIGNED_MAX_32_BIT_VALUE + MurmurHash3::V32.str_hash(bucketing_key, @bucket_seed) & UNSIGNED_MAX_32_BIT_VALUE end end end diff --git a/lib/optimizely/decision_service.rb b/lib/optimizely/decision_service.rb index 94225d72..7199b214 100644 --- a/lib/optimizely/decision_service.rb +++ b/lib/optimizely/decision_service.rb @@ -16,6 +16,9 @@ require_relative './bucketer' module Optimizely + + RESERVED_ATTRIBUTE_KEY_BUCKETING_ID = "\$opt_bucketing_id".freeze + class DecisionService # Optimizely's decision service that determines into which variation of an experiment a user will be allocated. # @@ -28,16 +31,16 @@ class DecisionService # 4. Check user profile service for past bucketing decisions (sticky bucketing) # 5. Check audience targeting # 6. Use Murmurhash3 to bucket the user - + attr_reader :bucketer attr_reader :config - + def initialize(config, user_profile_service = nil) @config = config @user_profile_service = user_profile_service @bucketer = Bucketer.new(@config) end - + def get_variation(experiment_key, user_id, attributes = nil) # Determines variation into which user will be bucketed. # @@ -46,56 +49,65 @@ def get_variation(experiment_key, user_id, attributes = nil) # attributes - Hash representing user attributes # # Returns variation ID where visitor will be bucketed (nil if experiment is inactive or user does not meet audience conditions) - + + # By default, the bucketing ID should be the user ID + bucketing_id = user_id; + + # If the bucketing ID key is defined in attributes, then use that in place of the userID + if attributes and attributes[RESERVED_ATTRIBUTE_KEY_BUCKETING_ID].is_a? String + unless attributes[RESERVED_ATTRIBUTE_KEY_BUCKETING_ID].empty? + bucketing_id = attributes[RESERVED_ATTRIBUTE_KEY_BUCKETING_ID] + @config.logger.log(Logger::DEBUG, "Setting the bucketing ID '#{bucketing_id}'") + end + end + # Check to make sure experiment is active experiment = @config.get_experiment_from_key(experiment_key) - if experiment.nil? - return nil - end - + return nil if experiment.nil? + experiment_id = experiment['id'] unless @config.experiment_running?(experiment) @config.logger.log(Logger::INFO, "Experiment '#{experiment_key}' is not running.") return nil end - + # Check if a forced variation is set for the user forced_variation = @config.get_forced_variation(experiment_key, user_id) return forced_variation['id'] if forced_variation - + # Check if user is in a white-listed variation whitelisted_variation_id = get_whitelisted_variation_id(experiment_key, user_id) return whitelisted_variation_id if whitelisted_variation_id - + # Check for saved bucketing decisions user_profile = get_user_profile(user_id) saved_variation_id = get_saved_variation_id(experiment_id, user_profile) if saved_variation_id @config.logger.log( - Logger::INFO, - "Returning previously activated variation ID #{saved_variation_id} of experiment '#{experiment_key}' for user '#{user_id}' from user profile." + Logger::INFO, + "Returning previously activated variation ID #{saved_variation_id} of experiment '#{experiment_key}' for user '#{user_id}' from user profile." ) return saved_variation_id end - + # Check audience conditions unless Audience.user_in_experiment?(@config, experiment, attributes) @config.logger.log( - Logger::INFO, - "User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'." + Logger::INFO, + "User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'." ) return nil end - + # Bucket normally - variation = @bucketer.bucket(experiment, user_id) + variation = @bucketer.bucket(experiment, bucketing_id, user_id) variation_id = variation ? variation['id'] : nil - + # Persist bucketing decision save_user_profile(user_profile, experiment_id, variation_id) variation_id end - + def get_variation_for_feature(feature_flag, user_id, attributes = nil) # Get the variation the user is bucketed into for the given FeatureFlag. # @@ -104,35 +116,33 @@ def get_variation_for_feature(feature_flag, user_id, attributes = nil) # attributes - Hash representing user attributes # # Returns hash with the experiment and variation where visitor will be bucketed (nil if the user is not bucketed into any of the experiments on the feature) - + # check if the feature is being experiment on and whether the user is bucketed into the experiment decision = get_variation_for_feature_experiment(feature_flag, user_id, attributes) - unless decision.nil? - return decision - end - + return decision unless decision.nil? + feature_flag_key = feature_flag['key'] variation = get_variation_for_feature_rollout(feature_flag, user_id, attributes) if variation @config.logger.log( - Logger::INFO, - "User '#{user_id}' is in the rollout for feature flag '#{feature_flag_key}'." + Logger::INFO, + "User '#{user_id}' is in the rollout for feature flag '#{feature_flag_key}'." ) # return decision with nil experiment so we don't track impressions for it return { - 'experiment' => nil, - 'variation' => variation + 'experiment' => nil, + 'variation' => variation } else @config.logger.log( - Logger::INFO, - "User '#{user_id}' is not in the rollout for feature flag '#{feature_flag_key}'." + Logger::INFO, + "User '#{user_id}' is not in the rollout for feature flag '#{feature_flag_key}'." ) end - + return nil end - + def get_variation_for_feature_experiment(feature_flag, user_id, attributes = nil) # Gets the variation the user is bucketed into for the feature flag's experiment. # @@ -142,7 +152,7 @@ def get_variation_for_feature_experiment(feature_flag, user_id, attributes = nil # # Returns a hash with the experiment and variation where visitor will be bucketed # or nil if the user is not bucketed into any of the experiments on the feature - + feature_flag_key = feature_flag['key'] unless feature_flag['experimentIds'].empty? # check if experiment is part of mutex group @@ -150,12 +160,12 @@ def get_variation_for_feature_experiment(feature_flag, user_id, attributes = nil experiment = @config.experiment_id_map[experiment_id] unless experiment @config.logger.log( - Logger::DEBUG, - "Feature flag experiment with ID '#{experiment_id}' is not in the datafile." + Logger::DEBUG, + "Feature flag experiment with ID '#{experiment_id}' is not in the datafile." ) return nil end - + group_id = experiment['groupId'] # if experiment is part of mutex group we first determine which experiment (if any) in the group the user is part of if group_id and @config.group_key_map.has_key?(group_id) @@ -163,15 +173,15 @@ def get_variation_for_feature_experiment(feature_flag, user_id, attributes = nil bucketed_experiment_id = @bucketer.find_bucket(user_id, group_id, group['trafficAllocation']) if bucketed_experiment_id.nil? @config.logger.log( - Logger::INFO, - "The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'." + Logger::INFO, + "The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'." ) return nil end else bucketed_experiment_id = experiment_id end - + if feature_flag['experimentIds'].include?(bucketed_experiment_id) experiment = @config.experiment_id_map[bucketed_experiment_id] experiment_key = experiment['key'] @@ -179,30 +189,30 @@ def get_variation_for_feature_experiment(feature_flag, user_id, attributes = nil unless variation_id.nil? variation = @config.variation_id_map[experiment_key][variation_id] @config.logger.log( - Logger::INFO, - "The user '#{user_id}' is bucketed into experiment '#{experiment_key}' of feature '#{feature_flag_key}'." + Logger::INFO, + "The user '#{user_id}' is bucketed into experiment '#{experiment_key}' of feature '#{feature_flag_key}'." ) return { - 'variation' => variation, - 'experiment' => experiment + 'variation' => variation, + 'experiment' => experiment } else @config.logger.log( - Logger::INFO, - "The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'." + Logger::INFO, + "The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'." ) end end else @config.logger.log( - Logger::DEBUG, - "The feature flag '#{feature_flag_key}' is not used in any experiments." + Logger::DEBUG, + "The feature flag '#{feature_flag_key}' is not used in any experiments." ) end - + return nil end - + def get_variation_for_feature_rollout(feature_flag, user_id, attributes = nil) # Determine which variation the user is in for a given rollout. # Returns the variation of the first experiment the user qualifies for. @@ -212,77 +222,77 @@ def get_variation_for_feature_rollout(feature_flag, user_id, attributes = nil) # attributes - Hash representing user attributes # # Returns the variation the user is bucketed into or nil if not bucketed into any of the targeting rules - + rollout_id = feature_flag['rolloutId'] if rollout_id.nil? or rollout_id.empty? feature_flag_key = feature_flag['key'] @config.logger.log( - Logger::DEBUG, - "Feature flag '#{feature_flag_key}' is not part of a rollout." + Logger::DEBUG, + "Feature flag '#{feature_flag_key}' is not part of a rollout." ) return nil end - + rollout = @config.get_rollout_from_id(rollout_id) unless rollout.nil? or rollout['experiments'].empty? rollout_experiments = rollout['experiments'] number_of_rules = rollout_experiments.length - 1 - + # Go through each experiment in order and try to get the variation for the user for index in (0...number_of_rules) experiment = rollout_experiments[index] experiment_key = experiment['key'] - + # Check that user meets audience conditions for targeting rule unless Audience.user_in_experiment?(@config, experiment, attributes) @config.logger.log( - Logger::DEBUG, - "User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'." + Logger::DEBUG, + "User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'." ) # move onto the next targeting rule next end - + @config.logger.log( - Logger::DEBUG, - "User '#{user_id}' meets conditions for targeting rule '#{index + 1}'." + Logger::DEBUG, + "User '#{user_id}' meets conditions for targeting rule '#{index + 1}'." ) variation = @bucketer.bucket(experiment, user_id) unless variation.nil? variation_key = variation['key'] return variation end - + # User failed traffic allocation, jump to Everyone Else rule @config.logger.log( - Logger::DEBUG, - "User '#{user_id}' is not in the traffic group for the targeting rule. Checking 'Eveyrone Else' rule now." + Logger::DEBUG, + "User '#{user_id}' is not in the traffic group for the targeting rule. Checking 'Eveyrone Else' rule now." ) break end - + # Evalute the "Everyone Else" rule, which is the last rule. everyone_else_experiment = rollout_experiments[number_of_rules] variation = @bucketer.bucket(everyone_else_experiment, user_id) unless variation.nil? @config.logger.log( - Logger::DEBUG, - "User '#{user_id}' meets conditions for targeting rule 'Everyone Else'." + Logger::DEBUG, + "User '#{user_id}' meets conditions for targeting rule 'Everyone Else'." ) return variation end - + @config.logger.log( - Logger::DEBUG, - "User '#{user_id}' does not meet conditions for targeting rule 'Everyone Else'." + Logger::DEBUG, + "User '#{user_id}' does not meet conditions for targeting rule 'Everyone Else'." ) end - + return nil end - + private - + def get_whitelisted_variation_id(experiment_key, user_id) # Determine if a user is whitelisted into a variation for the given experiment and return the ID of that variation # @@ -290,32 +300,32 @@ def get_whitelisted_variation_id(experiment_key, user_id) # user_id - ID for the user # # Returns variation ID into which user_id is whitelisted (nil if no variation) - + whitelisted_variations = @config.get_whitelisted_variations(experiment_key) - + return nil unless whitelisted_variations - + whitelisted_variation_key = whitelisted_variations[user_id] - + return nil unless whitelisted_variation_key - + whitelisted_variation_id = @config.get_variation_id_from_key(experiment_key, whitelisted_variation_key) - + unless whitelisted_variation_id @config.logger.log( - Logger::INFO, - "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}', which is not in the datafile." + Logger::INFO, + "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}', which is not in the datafile." ) return nil end - + @config.logger.log( - Logger::INFO, - "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}' of experiment '#{experiment_key}'." + Logger::INFO, + "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}' of experiment '#{experiment_key}'." ) whitelisted_variation_id end - + def get_saved_variation_id(experiment_id, user_profile) # Retrieve variation ID of stored bucketing decision for a given experiment from a given user profile # @@ -324,56 +334,56 @@ def get_saved_variation_id(experiment_id, user_profile) # # Returns string variation ID (nil if no decision is found) return nil unless user_profile[:experiment_bucket_map] - + decision = user_profile[:experiment_bucket_map][experiment_id] return nil unless decision variation_id = decision[:variation_id] return variation_id if @config.variation_id_exists?(experiment_id, variation_id) - + @config.logger.log( - Logger::INFO, - "User '#{user_profile['user_id']}' was previously bucketed into variation ID '#{variation_id}' for experiment '#{experiment_id}', but no matching variation was found. Re-bucketing user." + Logger::INFO, + "User '#{user_profile['user_id']}' was previously bucketed into variation ID '#{variation_id}' for experiment '#{experiment_id}', but no matching variation was found. Re-bucketing user." ) nil end - + def get_user_profile(user_id) # Determine if a user is forced into a variation for the given experiment and return the ID of that variation # # user_id - String ID for the user # # Returns Hash stored user profile (or a default one if lookup fails or user profile service not provided) - + user_profile = { - :user_id => user_id, - :experiment_bucket_map => {} + :user_id => user_id, + :experiment_bucket_map => {} } - + return user_profile unless @user_profile_service - + begin user_profile = @user_profile_service.lookup(user_id) || user_profile rescue => e @config.logger.log(Logger::ERROR, "Error while looking up user profile for user ID '#{user_id}': #{e}.") end - + user_profile end - - + + def save_user_profile(user_profile, experiment_id, variation_id) # Save a given bucketing decision to a given user profile # # user_profile - Hash user profile # experiment_id - String experiment ID # variation_id - String variation ID - + return unless @user_profile_service - + user_id = user_profile[:user_id] begin user_profile[:experiment_bucket_map][experiment_id] = { - :variation_id => variation_id + :variation_id => variation_id } @user_profile_service.save(user_profile) @config.logger.log(Logger::INFO, "Saved variation ID #{variation_id} of experiment ID #{experiment_id} for user '#{user_id}'.") @@ -382,4 +392,4 @@ def save_user_profile(user_profile, experiment_id, variation_id) end end end -end +end \ No newline at end of file diff --git a/lib/optimizely/event_builder.rb b/lib/optimizely/event_builder.rb index 7d524cc3..768dbb5a 100644 --- a/lib/optimizely/event_builder.rb +++ b/lib/optimizely/event_builder.rb @@ -20,6 +20,9 @@ require 'securerandom' module Optimizely + + RESERVED_ATTRIBUTE_KEY_BUCKETING_ID_EVENT_PARAM_KEY = "optimizely_bucketing_id".freeze + class Event # Representation of an event which can be sent to the Optimizely logging endpoint. @@ -69,22 +72,33 @@ def get_common_params(user_id, attributes) attribute_value = attributes[attribute_key] next if attribute_value.nil? - # Skip attributes not in the datafile - attribute_id = @config.get_attribute_id(attribute_key) - next unless attribute_id - - feature = { - entity_id: attribute_id, - key: attribute_key, - type: CUSTOM_ATTRIBUTE_FEATURE_TYPE, - value: attribute_value - } + if attribute_key.eql? RESERVED_ATTRIBUTE_KEY_BUCKETING_ID + # TODO (Copied from PHP-SDK) (Alda): the type for bucketing ID attribute may change so + # that custom attributes are not overloaded + feature = { + entity_id: RESERVED_ATTRIBUTE_KEY_BUCKETING_ID, + key: RESERVED_ATTRIBUTE_KEY_BUCKETING_ID_EVENT_PARAM_KEY, + type: CUSTOM_ATTRIBUTE_FEATURE_TYPE, + value: attribute_value + } + else + # Skip attributes not in the datafile + attribute_id = @config.get_attribute_id(attribute_key) + next unless attribute_id + + feature = { + entity_id: attribute_id, + key: attribute_key, + type: CUSTOM_ATTRIBUTE_FEATURE_TYPE, + value: attribute_value + } - visitor_attributes.push(feature) end - end + visitor_attributes.push(feature) + end + end - common_params = { + common_params = { account_id: @config.account_id, project_id: @config.project_id, visitors: [ @@ -93,11 +107,12 @@ def get_common_params(user_id, attributes) snapshots: [], visitor_id: user_id } - ], - revision: @config.revision, - client_name: CLIENT_ENGINE, - client_version: VERSION - } + ], + anonymize_ip: @config.anonymize_ip, + revision: @config.revision, + client_name: CLIENT_ENGINE, + client_version: VERSION + } common_params end diff --git a/lib/optimizely/exceptions.rb b/lib/optimizely/exceptions.rb index fa7fb64c..7812532a 100644 --- a/lib/optimizely/exceptions.rb +++ b/lib/optimizely/exceptions.rb @@ -95,4 +95,13 @@ def initialize(type) super("Provided #{type} is in an invalid format.") end end + + class InvalidNotificationType < Error + # Raised when an invalid notification type is provided + + def initialize(msg = 'Provided notification type is invalid.') + super + end + end + end diff --git a/lib/optimizely/notification_center.rb b/lib/optimizely/notification_center.rb new file mode 100644 index 00000000..cfbed7c0 --- /dev/null +++ b/lib/optimizely/notification_center.rb @@ -0,0 +1,148 @@ +# +# Copyright 2017, Optimizely and contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +module Optimizely + class NotificationCenter + attr_reader :notifications + attr_reader :notification_id + + NOTIFICATION_TYPES = { + ACTIVATE: 'ACTIVATE: experiment, user_id, attributes, variation, event', + TRACK: 'TRACK: event_key, user_id, attributes, event_tags, event' + }.freeze + + def initialize(logger, error_handler) + @notification_id = 1 + @notifications = {} + NOTIFICATION_TYPES.values.each { |value| @notifications[value] = [] } + @logger = logger + @error_handler = error_handler + end + + def add_notification_listener(notification_type, notification_callback) + # Adds notification callback to the notification center + + # Args: + # notification_type: one of the constants in NOTIFICATION_TYPES + # notification_callback: function to call when the event is sent + + # Returns: + # notification ID used to remove the notification + + return nil unless notification_type_valid?(notification_type) + + unless notification_callback + @logger.log Logger::ERROR, 'Callback can not be empty.' + return nil + end + + unless notification_callback.is_a? Method + @logger.log Logger::ERROR, 'Invalid notification callback given.' + return nil + end + + @notifications[notification_type].each do |notification| + return -1 if notification[:callback] == notification_callback + end + @notifications[notification_type].push(notification_id: @notification_id, callback: notification_callback) + notification_id = @notification_id + @notification_id += 1 + notification_id + end + + def remove_notification_listener(notification_id) + # Removes previously added notification callback + + # Args: + # notification_id: + # Returns: + # The function returns true if found and removed, false otherwise + unless notification_id + @logger.log Logger::ERROR, 'Notification ID can not be empty.' + return nil + end + @notifications.each do |key, _array| + @notifications[key].each do |notification| + if notification_id == notification[:notification_id] + @notifications[key].delete(notification_id: notification_id, callback: notification[:callback]) + return true + end + end + end + false + end + + def clear_notifications(notification_type) + # Removes notifications for a certain notification type + # + # Args: + # notification_type: one of the constants in NOTIFICATION_TYPES + + return nil unless notification_type_valid?(notification_type) + + @notifications[notification_type] = [] + @logger.log Logger::INFO, "All callbacks for notification type #{notification_type} have been removed." + end + + def clean_all_notifications + # Removes all notifications + @notifications.keys.each { |key| @notifications[key] = [] } + end + + def send_notifications(notification_type, *args) + # Sends off the notification for the specific event. Uses var args to pass in a + # arbitrary list of parameters according to which notification type was sent + + # Args: + # notification_type: one of the constants in NOTIFICATION_TYPES + # args: list of arguments to the callback + return nil unless notification_type_valid?(notification_type) + + @notifications[notification_type].each do |notification| + begin + notification_callback = notification[:callback] + notification_callback.call(*args) + @logger.log Logger::INFO, "Notification #{notification_type} sent successfully." + rescue => e + @logger.log(Logger::ERROR, "Problem calling notify callback. Error: #{e}") + return nil + end + end + end + + private + + def notification_type_valid?(notification_type) + # Validates notification type + + # Args: + # notification_type: one of the constants in NOTIFICATION_TYPES + + # Returns true if notification_type is valid, false otherwise + + unless notification_type + @logger.log Logger::ERROR, 'Notification type can not be empty.' + return false + end + + unless @notifications.include?(notification_type) + @logger.log Logger::ERROR, 'Invalid notification type.' + @error_handler.handle_error InvalidNotificationType + return false + end + true + end + end +end diff --git a/lib/optimizely/project_config.rb b/lib/optimizely/project_config.rb index 217c58d8..b594dc17 100644 --- a/lib/optimizely/project_config.rb +++ b/lib/optimizely/project_config.rb @@ -38,6 +38,8 @@ class ProjectConfig attr_reader :groups attr_reader :parsing_succeeded attr_reader :project_id + # Boolean - denotes if Optimizely should remove the last block of visitors' IP address before storing event data + attr_reader :anonymize_ip attr_reader :revision attr_reader :rollouts attr_reader :version @@ -86,6 +88,7 @@ def initialize(datafile, logger, error_handler) @feature_flags = config.fetch('featureFlags', []) @groups = config.fetch('groups', []) @project_id = config['projectId'] + @anonymize_ip = (config.has_key? 'anonymizeIP')? config['anonymizeIP'] :false @revision = config['revision'] @rollouts = config.fetch('rollouts', []) diff --git a/lib/optimizely/version.rb b/lib/optimizely/version.rb index 8321f434..34255dfc 100644 --- a/lib/optimizely/version.rb +++ b/lib/optimizely/version.rb @@ -16,6 +16,6 @@ module Optimizely CLIENT_ENGINE = 'ruby-sdk'.freeze - VERSION = '2.0.0.beta'.freeze + VERSION = '1.5.0'.freeze end diff --git a/spec/bucketing_spec.rb b/spec/bucketing_spec.rb index f3941078..7266887e 100644 --- a/spec/bucketing_spec.rb +++ b/spec/bucketing_spec.rb @@ -25,9 +25,9 @@ let(:config) { Optimizely::ProjectConfig.new(config_body_JSON, spy_logger, error_handler) } let(:bucketer) { Optimizely::Bucketer.new(config) } - def get_bucketing_id(user_id, entity_id=nil) + def get_bucketing_key(bucketing_id, entity_id=nil) entity_id = entity_id || 1886780721 - sprintf(Optimizely::Bucketer::BUCKETING_ID_TEMPLATE, {user_id: user_id, entity_id: entity_id}) + sprintf(Optimizely::Bucketer::BUCKETING_ID_TEMPLATE, {bucketing_id: bucketing_id, entity_id: entity_id}) end it 'should return correct variation ID when provided bucket value' do @@ -37,22 +37,22 @@ def get_bucketing_id(user_id, entity_id=nil) # Variation 1 expected_variation_1 = config.get_variation_from_id('test_experiment', '111128') - expect(bucketer.bucket(experiment, 'test_user')).to eq(expected_variation_1) + expect(bucketer.bucket(experiment,'bucket_id_ignored', 'test_user')).to eq(expected_variation_1) # Variation 2 expected_variation_2 = config.get_variation_from_id('test_experiment','111129') - expect(bucketer.bucket(experiment, 'test_user')).to eq(expected_variation_2) + expect(bucketer.bucket(experiment,'bucket_id_ignored', 'test_user')).to eq(expected_variation_2) # No matching variation - expect(bucketer.bucket(experiment, 'test_user')).to be_nil + expect(bucketer.bucket(experiment,'bucket_id_ignored', 'test_user')).to be_nil end it 'should test the output of generate_bucket_value for different inputs' do - expect(bucketer.send(:generate_bucket_value, get_bucketing_id('ppid1'))).to eq(5254) - expect(bucketer.send(:generate_bucket_value, get_bucketing_id('ppid2'))).to eq(4299) - expect(bucketer.send(:generate_bucket_value, get_bucketing_id('ppid2', 1886780722))).to eq(2434) - expect(bucketer.send(:generate_bucket_value, get_bucketing_id('ppid3'))).to eq(5439) - expect(bucketer.send(:generate_bucket_value, get_bucketing_id( + expect(bucketer.send(:generate_bucket_value, get_bucketing_key('ppid1'))).to eq(5254) + expect(bucketer.send(:generate_bucket_value, get_bucketing_key('ppid2'))).to eq(4299) + expect(bucketer.send(:generate_bucket_value, get_bucketing_key('ppid2', 1886780722))).to eq(2434) + expect(bucketer.send(:generate_bucket_value, get_bucketing_key('ppid3'))).to eq(5439) + expect(bucketer.send(:generate_bucket_value, get_bucketing_key( 'a very very very very very very very very very very very very very very very long ppd string'))).to eq(6128) end @@ -61,10 +61,10 @@ def get_bucketing_id(user_id, entity_id=nil) experiment = config.get_experiment_from_key('group1_exp1') expected_variation = config.get_variation_from_id('group1_exp1','130001') - expect(bucketer.bucket(experiment, 'test_user')).to eq(expected_variation) + expect(bucketer.bucket(experiment,'bucket_id_ignored','test_user')).to eq(expected_variation) expect(spy_logger).to have_received(:log).exactly(4).times expect(spy_logger).to have_received(:log).twice - .with(Logger::DEBUG, "Assigned bucket 3000 to user 'test_user'.") + .with(Logger::DEBUG, "Assigned bucket 3000 to user 'test_user' with bucketing ID: 'bucket_id_ignored'.") expect(spy_logger).to have_received(:log) .with(Logger::INFO, "User 'test_user' is in experiment 'group1_exp1' of group 101.") expect(spy_logger).to have_received(:log) @@ -75,9 +75,9 @@ def get_bucketing_id(user_id, entity_id=nil) expect(bucketer).to receive(:generate_bucket_value).once.and_return(3000) experiment = config.get_experiment_from_key('group1_exp2') - expect(bucketer.bucket(experiment, 'test_user')).to be_nil + expect(bucketer.bucket(experiment,'bucket_id_ignored', 'test_user')).to be_nil expect(spy_logger).to have_received(:log) - .with(Logger::DEBUG, "Assigned bucket 3000 to user 'test_user'.") + .with(Logger::DEBUG, "Assigned bucket 3000 to user 'test_user' with bucketing ID: 'bucket_id_ignored'.") expect(spy_logger).to have_received(:log) .with(Logger::INFO, "User 'test_user' is not in experiment 'group1_exp2' of group 101.") end @@ -86,7 +86,7 @@ def get_bucketing_id(user_id, entity_id=nil) expect(bucketer).to receive(:find_bucket).once.and_return(nil) experiment = config.get_experiment_from_key('group1_exp2') - expect(bucketer.bucket(experiment, 'test_user')).to be_nil + expect(bucketer.bucket(experiment,'bucket_id_ignored', 'test_user')).to be_nil expect(spy_logger).to have_received(:log) .with(Logger::INFO, "User 'test_user' is in no experiment.") end @@ -96,10 +96,10 @@ def get_bucketing_id(user_id, entity_id=nil) experiment = config.get_experiment_from_key('group2_exp1') expected_variation = config.get_variation_from_id('group2_exp1','144443') - expect(bucketer.bucket(experiment, 'test_user')).to eq(expected_variation) + expect(bucketer.bucket(experiment,'bucket_id_ignored', 'test_user')).to eq(expected_variation) expect(spy_logger).to have_received(:log).twice expect(spy_logger).to have_received(:log) - .with(Logger::DEBUG, "Assigned bucket 3000 to user 'test_user'.") + .with(Logger::DEBUG, "Assigned bucket 3000 to user 'test_user' with bucketing ID: 'bucket_id_ignored'.") expect(spy_logger).to have_received(:log) .with(Logger::INFO, "User 'test_user' is in variation 'g2_e1_v1' of experiment 'group2_exp1'.") end @@ -108,47 +108,84 @@ def get_bucketing_id(user_id, entity_id=nil) expect(bucketer).to receive(:generate_bucket_value).and_return(50_000) experiment = config.get_experiment_from_key('group2_exp1') - expect(bucketer.bucket(experiment, 'test_user')).to be_nil + expect(bucketer.bucket(experiment,'bucket_id_ignored', 'test_user')).to be_nil expect(spy_logger).to have_received(:log).twice expect(spy_logger).to have_received(:log) - .with(Logger::DEBUG, "Assigned bucket 50000 to user 'test_user'.") + .with(Logger::DEBUG, "Assigned bucket 50000 to user 'test_user' with bucketing ID: 'bucket_id_ignored'.") expect(spy_logger).to have_received(:log) .with(Logger::INFO, "User 'test_user' is in no variation.") end it 'should call generate_bucket_value with the proper arguments during variation bucketing' do - expected_bucketing_id = get_bucketing_id('test_user', '111127') + expected_bucketing_id = get_bucketing_key('bucket_id_string', '111127') expect(bucketer).to receive(:generate_bucket_value).once.with(expected_bucketing_id).and_call_original experiment = config.get_experiment_from_key('test_experiment') - bucketer.bucket(experiment, 'test_user') + bucketer.bucket(experiment,'bucket_id_string', 'test_user') end it 'should call generate_bucket_value with the proper arguments during grouped experiment bucketing' do - expected_bucketing_id = get_bucketing_id('test_user', '101') + expected_bucketing_id = get_bucketing_key('ppid8','101') expect(bucketer).to receive(:generate_bucket_value).once.with(expected_bucketing_id).and_call_original + experiment = config.get_experiment_from_key('group1_exp1') - bucketer.bucket(experiment, 'test_user') + bucketer.bucket(experiment,'ppid8','test_user') + + expect(spy_logger).to have_received(:log) + .with(Logger::INFO, "User 'test_user' is not in experiment '#{experiment['key']}' of group #{experiment['groupId']}.") end it 'should return nil when user is in an empty traffic allocation range due to sticky bucketing' do expect(bucketer).to receive(:find_bucket).once.and_return('') experiment = config.get_experiment_from_key('test_experiment') - expect(bucketer.bucket(experiment, 'test_user')).to be_nil + expect(bucketer.bucket(experiment,'bucket_id_ignored', 'test_user')).to be_nil expect(spy_logger).to have_received(:log) .with(Logger::INFO, "User 'test_user' is in no variation.") expect(spy_logger).to have_received(:log) .with(Logger::DEBUG, "Bucketed into an empty traffic range. Returning nil.") end + describe 'Bucketing with Bucketing Id' do + # Bucketing with bucketing ID + # Make sure that the bucketing ID is used for the bucketing and not the user ID + it 'should bucket to a variation different than the one expected with the userId' do + experiment = config.get_experiment_from_key('test_experiment') + + # Bucketing with user id as bucketing id - 'test_user111127' produces bucket value < 5000 thus buckets to control + expected_variation = config.get_variation_from_id('test_experiment','111128') + expect(bucketer.bucket(experiment,'test_user', 'test_user')).to be(expected_variation) + + # Bucketing with bucketing id - 'any_string789111127' produces bucket value btw 5000 to 10,000 thus buckets to variation + expected_variation = config.get_variation_from_id('test_experiment','111129') + expect(bucketer.bucket(experiment,'any_string789', 'test_user')).to be(expected_variation) + end + + # Bucketing with invalid experiment key and bucketing ID + it 'should return nil with invalid experiment and bucketing ID' do + expect(bucketer.bucket(config.get_experiment_from_key('invalid_experiment'),'some_id', 'test_user')).to be(nil) + end + + # Bucketing with grouped experiments and bucketing ID + # Make sure that the bucketing ID is used for the bucketing and not the user ID + it 'should bucket to a variation different than the one expected with the userId in grouped experiments' do + experiment = config.get_experiment_from_key('group1_exp1') + + expected_variation = nil + expect(bucketer.bucket(experiment,'test_user', 'test_user')).to be(expected_variation) + + expected_variation = config.get_variation_from_id('group1_exp1','130002') + expect(bucketer.bucket(experiment,'123456789', 'test_user')).to be(expected_variation) + end + end + describe 'logging' do it 'should log the results of bucketing a user into variation 1' do expect(bucketer).to receive(:generate_bucket_value).and_return(50) experiment = config.get_experiment_from_key('test_experiment') - bucketer.bucket(experiment, 'test_user') + bucketer.bucket(experiment,'bucket_id_ignored', 'test_user') expect(spy_logger).to have_received(:log).twice - expect(spy_logger).to have_received(:log).with(Logger::DEBUG, "Assigned bucket 50 to user 'test_user'.") + expect(spy_logger).to have_received(:log).with(Logger::DEBUG, "Assigned bucket 50 to user 'test_user' with bucketing ID: 'bucket_id_ignored'.") expect(spy_logger).to have_received(:log).with( Logger::INFO, "User 'test_user' is in variation 'control' of experiment 'test_experiment'." @@ -159,10 +196,10 @@ def get_bucketing_id(user_id, entity_id=nil) expect(bucketer).to receive(:generate_bucket_value).and_return(5050) experiment = config.get_experiment_from_key('test_experiment') - bucketer.bucket(experiment, 'test_user') + bucketer.bucket(experiment,'bucket_id_ignored', 'test_user') expect(spy_logger).to have_received(:log).twice expect(spy_logger).to have_received(:log) - .with(Logger::DEBUG, "Assigned bucket 5050 to user 'test_user'.") + .with(Logger::DEBUG, "Assigned bucket 5050 to user 'test_user' with bucketing ID: 'bucket_id_ignored'.") expect(spy_logger).to have_received(:log).with( Logger::INFO, "User 'test_user' is in variation 'variation' of experiment 'test_experiment'." @@ -173,10 +210,10 @@ def get_bucketing_id(user_id, entity_id=nil) expect(bucketer).to receive(:generate_bucket_value).and_return(50000) experiment = config.get_experiment_from_key('test_experiment') - bucketer.bucket(experiment, 'test_user') + bucketer.bucket(experiment,'bucket_id_ignored', 'test_user') expect(spy_logger).to have_received(:log).twice expect(spy_logger).to have_received(:log) - .with(Logger::DEBUG, "Assigned bucket 50000 to user 'test_user'.") + .with(Logger::DEBUG, "Assigned bucket 50000 to user 'test_user' with bucketing ID: 'bucket_id_ignored'.") expect(spy_logger).to have_received(:log) .with(Logger::INFO, "User 'test_user' is in no variation.") end diff --git a/spec/decision_service_spec.rb b/spec/decision_service_spec.rb index 04b05ced..f85d1aaa 100644 --- a/spec/decision_service_spec.rb +++ b/spec/decision_service_spec.rb @@ -25,18 +25,18 @@ let(:spy_user_profile_service) { spy('user_profile_service') } let(:config) { Optimizely::ProjectConfig.new(config_body_JSON, spy_logger, error_handler) } let(:decision_service) { Optimizely::DecisionService.new(config, spy_user_profile_service) } - + describe '#get_variation' do before(:example) do # stub out bucketer and audience evaluator so we can make sure they are / aren't called allow(decision_service.bucketer).to receive(:bucket).and_call_original allow(decision_service).to receive(:get_whitelisted_variation_id).and_call_original allow(Optimizely::Audience).to receive(:user_in_experiment?).and_call_original - + # by default, spy user profile service should no-op. we override this behavior in specific tests allow(spy_user_profile_service).to receive(:lookup).and_return(nil) end - + it 'should return the correct variation ID for a given user for whom a variation has been forced' do config.set_forced_variation('test_experiment','test_user', 'variation') expect(decision_service.get_variation('test_experiment', 'test_user')).to eq('111129') @@ -45,70 +45,102 @@ expect(decision_service.bucketer).not_to have_received(:bucket) expect(Optimizely::Audience).not_to have_received(:user_in_experiment?) end - + + it 'should return the correct variation ID (using Bucketing ID attrbiute) for a given user for whom a variation has been forced' do + user_attributes = { + 'browser_type' => 'firefox', + OptimizelySpec::RESERVED_ATTRIBUTE_KEY_BUCKETING_ID => 'pid' + } + config.set_forced_variation('test_experiment_with_audience','test_user', 'control_with_audience') + expect(decision_service.get_variation('test_experiment_with_audience', 'test_user', user_attributes)).to eq('122228') + # 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) + expect(Optimizely::Audience).not_to have_received(:user_in_experiment?) + end + it 'should return the correct variation ID for a given user ID and key of a running experiment' do expect(decision_service.get_variation('test_experiment', 'test_user')).to eq('111128') - + expect(spy_logger).to have_received(:log) - .once.with(Logger::INFO,"User 'test_user' is in variation 'control' of experiment 'test_experiment'.") + .once.with(Logger::INFO,"User 'test_user' is in variation 'control' of experiment 'test_experiment'.") expect(decision_service).to have_received(:get_whitelisted_variation_id).once expect(decision_service.bucketer).to have_received(:bucket).once end - + it 'should return correct variation ID if user ID is in whitelisted Variations and variation is valid' do expect(decision_service.get_variation('test_experiment', 'forced_user1')).to eq('111128') expect(spy_logger).to have_received(:log) - .once.with(Logger::INFO, "User 'forced_user1' is whitelisted into variation 'control' of experiment 'test_experiment'.") - + .once.with(Logger::INFO, "User 'forced_user1' is whitelisted into variation 'control' of experiment 'test_experiment'.") + expect(decision_service.get_variation('test_experiment', 'forced_user2')).to eq('111129') expect(spy_logger).to have_received(:log) - .once.with(Logger::INFO, "User 'forced_user2' is whitelisted into variation 'variation' of experiment 'test_experiment'.") - + .once.with(Logger::INFO, "User 'forced_user2' is whitelisted into variation 'variation' of experiment 'test_experiment'.") + # whitelisted variations should short circuit bucketing expect(decision_service.bucketer).not_to have_received(:bucket) # whitelisted variations should short circuit audience evaluation expect(Optimizely::Audience).not_to have_received(:user_in_experiment?) end - + + it 'should return correct variation ID (using Bucketing ID attrbiute) if user ID is in whitelisted Variations and variation is valid' do + user_attributes = { + 'browser_type' => 'firefox', + OptimizelySpec::RESERVED_ATTRIBUTE_KEY_BUCKETING_ID => 'pid' + } + expect(decision_service.get_variation('test_experiment', 'forced_user1',user_attributes)).to eq('111128') + expect(spy_logger).to have_received(:log) + .once.with(Logger::INFO, "User 'forced_user1' is whitelisted into variation 'control' of experiment 'test_experiment'.") + + expect(decision_service.get_variation('test_experiment', 'forced_user2',user_attributes)).to eq('111129') + expect(spy_logger).to have_received(:log) + .once.with(Logger::INFO, "User 'forced_user2' is whitelisted into variation 'variation' of experiment 'test_experiment'.") + + # whitelisted variations should short circuit bucketing + expect(decision_service.bucketer).not_to have_received(:bucket) + # whitelisted variations should short circuit audience evaluation + expect(Optimizely::Audience).not_to have_received(:user_in_experiment?) + end + 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'} expect(decision_service.get_variation('test_experiment_with_audience', 'forced_audience_user', user_attributes)).to eq('122229') expect(spy_logger).to have_received(:log) - .once.with( - Logger::INFO, - "User 'forced_audience_user' is whitelisted into variation 'variation_with_audience' of experiment 'test_experiment_with_audience'." - ) - + .once.with( + Logger::INFO, + "User 'forced_audience_user' is whitelisted into variation 'variation_with_audience' of experiment 'test_experiment_with_audience'." + ) + # forced variations should short circuit bucketing expect(decision_service.bucketer).not_to have_received(:bucket) # forced variations should short circuit audience evaluation expect(Optimizely::Audience).not_to have_received(:user_in_experiment?) end - + it 'should return nil if the experiment key is invalid' do expect(decision_service.get_variation('totally_invalid_experiment', 'test_user', {})).to eq(nil) - + expect(spy_logger).to have_received(:log) - .once.with(Logger::ERROR,"Experiment key 'totally_invalid_experiment' is not in datafile.") + .once.with(Logger::ERROR,"Experiment key 'totally_invalid_experiment' is not in datafile.") end - + it 'should return nil if the user does not meet the audience conditions for a given experiment' do user_attributes = {'browser_type' => 'chrome'} expect(decision_service.get_variation('test_experiment_with_audience', 'test_user', user_attributes)).to eq(nil) 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'.") - + .once.with(Logger::INFO,"User 'test_user' does not meet the conditions to be in experiment 'test_experiment_with_audience'.") + # should have checked forced variations expect(decision_service).to have_received(:get_whitelisted_variation_id).once # wrong audience conditions should short circuit bucketing expect(decision_service.bucketer).not_to have_received(:bucket) end - + it 'should return nil if the given experiment is not running' do expect(decision_service.get_variation('test_experiment_not_started', 'test_user')).to eq(nil) expect(spy_logger).to have_received(:log) - .once.with(Logger::INFO,"Experiment 'test_experiment_not_started' is not running.") - + .once.with(Logger::INFO,"Experiment 'test_experiment_not_started' is not running.") + # non-running experiments should short circuit whitelisting expect(decision_service).not_to have_received(:get_whitelisted_variation_id) # non-running experiments should short circuit audience evaluation @@ -116,68 +148,94 @@ # non-running experiments should short circuit bucketing expect(decision_service.bucketer).not_to have_received(:bucket) end - + it 'should respect forced variations within mutually exclusive grouped experiments' do expect(decision_service.get_variation('group1_exp2', 'forced_group_user1')).to eq('130004') expect(spy_logger).to have_received(:log) - .once.with(Logger::INFO, "User 'forced_group_user1' is whitelisted into variation 'g1_e2_v2' of experiment 'group1_exp2'.") - + .once.with(Logger::INFO, "User 'forced_group_user1' is whitelisted into variation 'g1_e2_v2' of experiment 'group1_exp2'.") + # forced variations should short circuit bucketing expect(decision_service.bucketer).not_to have_received(:bucket) # forced variations should short circuit audience evaluation expect(Optimizely::Audience).not_to have_received(:user_in_experiment?) end - + it 'should bucket normally if user is whitelisted into a forced variation that is not in the datafile' do expect(decision_service.get_variation('test_experiment', 'forced_user_with_invalid_variation')).to eq('111128') expect(spy_logger).to have_received(:log) - .once.with( - Logger::INFO, - "User 'forced_user_with_invalid_variation' is whitelisted into variation 'invalid_variation', which is not in the datafile." - ) + .once.with( + Logger::INFO, + "User 'forced_user_with_invalid_variation' is whitelisted into variation 'invalid_variation', which is not in the datafile." + ) # bucketing should have occured experiment = config.get_experiment_from_key('test_experiment') - expect(decision_service.bucketer).to have_received(:bucket).once.with(experiment, 'forced_user_with_invalid_variation') + # since we do not pass bucketing id attribute, bucketer will recieve user id as the bucketing id + expect(decision_service.bucketer).to have_received(:bucket).once.with(experiment,'forced_user_with_invalid_variation','forced_user_with_invalid_variation') end - + describe 'when a UserProfile service is provided' do it 'should look up the UserProfile, bucket normally, and save the result if no saved profile is found' do expected_user_profile = { - :user_id => 'test_user', - :experiment_bucket_map => { - '111127' => { - :variation_id => '111128' - } + :user_id => 'test_user', + :experiment_bucket_map => { + '111127' => { + :variation_id => '111128' } + } } expect(spy_user_profile_service).to receive(:lookup).once.and_return(nil) - + expect(decision_service.get_variation('test_experiment', 'test_user')).to eq('111128') - + # bucketing should have occurred expect(decision_service.bucketer).to have_received(:bucket).once # bucketing decision should have been saved expect(spy_user_profile_service).to have_received(:save).once.with(expected_user_profile) expect(spy_logger).to have_received(:log).once - .with(Logger::INFO, "Saved variation ID 111128 of experiment ID 111127 for user 'test_user'.") + .with(Logger::INFO, "Saved variation ID 111128 of experiment ID 111127 for user 'test_user'.") end - + + it 'should look up the UserProfile, bucket normally (using Bucketing ID attribute), and save the result if no saved profile is found' do + expected_user_profile = { + :user_id => 'test_user', + :experiment_bucket_map => { + '111127' => { + :variation_id => '111129' + } + } + } + user_attributes = { + 'browser_type' => 'firefox', + OptimizelySpec::RESERVED_ATTRIBUTE_KEY_BUCKETING_ID => 'pid' + } + expect(spy_user_profile_service).to receive(:lookup).once.and_return(nil) + + expect(decision_service.get_variation('test_experiment', 'test_user',user_attributes)).to eq('111129') + + # bucketing should have occurred + expect(decision_service.bucketer).to have_received(:bucket).once + # bucketing decision should have been saved + expect(spy_user_profile_service).to have_received(:save).once.with(expected_user_profile) + expect(spy_logger).to have_received(:log).once + .with(Logger::INFO, "Saved variation ID 111129 of experiment ID 111127 for user 'test_user'.") + end + it 'should look up the user profile and skip normal bucketing if a profile with a saved decision is found' do saved_user_profile = { - :user_id => 'test_user', - :experiment_bucket_map => { - '111127' => { - :variation_id => '111129' - } + :user_id => 'test_user', + :experiment_bucket_map => { + '111127' => { + :variation_id => '111129' } + } } expect(spy_user_profile_service).to receive(:lookup) - .with('test_user').once.and_return(saved_user_profile) - + .with('test_user').once.and_return(saved_user_profile) + expect(decision_service.get_variation('test_experiment', 'test_user')).to eq('111129') 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.") - + .with(Logger::INFO, "Returning previously activated variation ID 111129 of experiment 'test_experiment' for user 'test_user' from user profile.") + # saved user profiles should short circuit bucketing expect(decision_service.bucketer).not_to have_received(:bucket) # saved user profiles should short circuit audience evaluation @@ -185,159 +243,159 @@ # the user profile should not be updated if bucketing did not take place expect(spy_user_profile_service).not_to have_received(:save) end - + it 'should look up the user profile and bucket normally if a profile without a saved decision is found' do saved_user_profile = { - :user_id => 'test_user', - :experiment_bucket_map => { - # saved decision, but not for this experiment - '122227' => { - :variation_id => '122228' - } + :user_id => 'test_user', + :experiment_bucket_map => { + # saved decision, but not for this experiment + '122227' => { + :variation_id => '122228' } + } } expect(spy_user_profile_service).to receive(:lookup) - .once.with('test_user').and_return(saved_user_profile) - + .once.with('test_user').and_return(saved_user_profile) + expect(decision_service.get_variation('test_experiment', 'test_user')).to eq('111128') - + # bucketing should have occurred expect(decision_service.bucketer).to have_received(:bucket).once - + # user profile should have been updated with bucketing decision expected_user_profile = { - :user_id => 'test_user', - :experiment_bucket_map => { - '111127' => { - :variation_id => '111128' - }, - '122227' => { - :variation_id => '122228' - } + :user_id => 'test_user', + :experiment_bucket_map => { + '111127' => { + :variation_id => '111128' + }, + '122227' => { + :variation_id => '122228' } + } } expect(spy_user_profile_service).to have_received(:save).once.with(expected_user_profile) end - + it 'should bucket normally if the user profile contains a variation ID not in the datafile' do saved_user_profile = { - :user_id => 'test_user', - :experiment_bucket_map => { - # saved decision, but with invalid variation ID - '111127' => { - :variation_id => '111111' - } + :user_id => 'test_user', + :experiment_bucket_map => { + # saved decision, but with invalid variation ID + '111127' => { + :variation_id => '111111' } + } } expect(spy_user_profile_service).to receive(:lookup) - .once.with('test_user').and_return(saved_user_profile) - + .once.with('test_user').and_return(saved_user_profile) + expect(decision_service.get_variation('test_experiment', 'test_user')).to eq('111128') - + # bucketing should have occurred expect(decision_service.bucketer).to have_received(:bucket).once - + # user profile should have been updated with bucketing decision expected_user_profile = { - :user_id => 'test_user', - :experiment_bucket_map => { - '111127' => { - :variation_id => '111128' - } + :user_id => 'test_user', + :experiment_bucket_map => { + '111127' => { + :variation_id => '111128' } + } } expect(spy_user_profile_service).to have_received(:save).with(expected_user_profile) end - + 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) - + expect(decision_service.get_variation('test_experiment', 'test_user')).to eq('111128') - + 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.") + .with(Logger::ERROR, "Error while looking up user profile for user ID 'test_user': uncaught throw :LookupError.") # bucketing should have occurred expect(decision_service.bucketer).to have_received(:bucket).once end - + 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) - + expect(decision_service.get_variation('test_experiment', 'test_user')).to eq('111128') - + expect(spy_logger).to have_received(:log).once - .with(Logger::ERROR, "Error while saving user profile for user ID 'test_user': uncaught throw :SaveError.") + .with(Logger::ERROR, "Error while saving user profile for user ID 'test_user': uncaught throw :SaveError.") end end end - + describe '#get_variation_for_feature_experiment' do user_attributes = {} user_id = 'user_1' - + describe 'when the feature flag\'s experiment ids array is empty' do it 'should return nil and log a message' do user_attributes = {} feature_flag = config.feature_flag_key_map['empty_feature'] expect(decision_service.get_variation_for_feature_experiment(feature_flag, 'user_1', user_attributes)).to eq(nil) - + expect(spy_logger).to have_received(:log).once - .with(Logger::DEBUG, "The feature flag 'empty_feature' is not used in any experiments.") + .with(Logger::DEBUG, "The feature flag 'empty_feature' is not used in any experiments.") end end - + describe 'when the feature flag is associated with a non-mutex experiment' do describe 'and the experiment is not in the datafile' do it 'should return nil and log a message' do feature_flag = config.feature_flag_key_map['boolean_feature'].dup feature_flag['experimentIds'] = ['1333333337'] # totally invalid exp id expect(decision_service.get_variation_for_feature_experiment(feature_flag, user_id, user_attributes)).to eq(nil) - + expect(spy_logger).to have_received(:log).once - .with(Logger::DEBUG, "Feature flag experiment with ID '1333333337' is not in the datafile.") + .with(Logger::DEBUG, "Feature flag experiment with ID '1333333337' is not in the datafile.") end end - + describe 'and the user is not bucketed into the feature flag\'s experiments' do before(:each) do multivariate_experiment = config.experiment_key_map['test_experiment_multivariate'] - + # make sure the user is not bucketed into the feature experiment allow(decision_service).to receive(:get_variation) - .with(multivariate_experiment['key'], 'user_1', user_attributes) - .and_return(nil) + .with(multivariate_experiment['key'], 'user_1', user_attributes) + .and_return(nil) end - + it 'should return nil and log a message' do feature_flag = config.feature_flag_key_map['multi_variate_feature'] expect(decision_service.get_variation_for_feature_experiment(feature_flag, 'user_1', user_attributes)).to eq(nil) - + 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'.") + .with(Logger::INFO, "The user 'user_1' is not bucketed into any of the experiments on the feature 'multi_variate_feature'.") end end - + 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') end - + it 'should return the variation' do user_attributes = {} feature_flag = config.feature_flag_key_map['multi_variate_feature'] expected_decision = { - 'experiment' => config.experiment_key_map['test_experiment_multivariate'], - 'variation' => config.variation_id_map['test_experiment_multivariate']['122231'] + 'experiment' => config.experiment_key_map['test_experiment_multivariate'], + 'variation' => config.variation_id_map['test_experiment_multivariate']['122231'] } expect(decision_service.get_variation_for_feature_experiment(feature_flag, 'user_1', user_attributes)).to eq(expected_decision) - + expect(spy_logger).to have_received(:log).once - .with(Logger::INFO, "The user 'user_1' is bucketed into experiment 'test_experiment_multivariate' of feature 'multi_variate_feature'.") + .with(Logger::INFO, "The user 'user_1' is bucketed into experiment 'test_experiment_multivariate' of feature 'multi_variate_feature'.") end end end - + describe 'when the feature flag is associated with a mutex experiment' do mutex_exp = nil expected_decision = nil @@ -347,72 +405,72 @@ mutex_exp = config.experiment_key_map['group1_exp1'] expected_variation = mutex_exp['variations'][0] expected_decision = { - 'experiment' => mutex_exp, - 'variation' => expected_variation + 'experiment' => mutex_exp, + 'variation' => expected_variation } allow(decision_service.bucketer).to receive(:find_bucket) - .with(user_id, group_1['id'], group_1['trafficAllocation']) - .and_return(mutex_exp['id']) - + .with(user_id, group_1['id'], group_1['trafficAllocation']) + .and_return(mutex_exp['id']) + allow(decision_service).to receive(:get_variation) - .and_return(expected_variation['id']) + .and_return(expected_variation['id']) end - + it 'should return the variation the user is bucketed into' do feature_flag = config.feature_flag_key_map['boolean_feature'] expect(decision_service.get_variation_for_feature_experiment(feature_flag, user_id, user_attributes)).to eq(expected_decision) - + expect(spy_logger).to have_received(:log).once - .with(Logger::INFO, "The user 'user_1' is bucketed into experiment 'group1_exp1' of feature 'boolean_feature'.") + .with(Logger::INFO, "The user 'user_1' is bucketed into experiment 'group1_exp1' of feature 'boolean_feature'.") end end - + describe 'and the user is not bucketed into any of the mutex experiments' do before(:each) do group_1 = config.group_key_map['101'] mutex_exp = config.experiment_key_map['group1_exp1'] expected_variation = mutex_exp['variations'][0] allow(decision_service.bucketer).to receive(:find_bucket) - .with(user_id, group_1['id'], group_1['trafficAllocation']) - .and_return(nil) + .with(user_id, group_1['id'], group_1['trafficAllocation']) + .and_return(nil) end - + it 'should return nil and log a message' do feature_flag = config.feature_flag_key_map['boolean_feature'] expect(decision_service.get_variation_for_feature_experiment(feature_flag, user_id, user_attributes)).to eq(nil) - + 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 'boolean_feature'.") + .with(Logger::INFO, "The user 'user_1' is not bucketed into any of the experiments on the feature 'boolean_feature'.") end end end end - + describe '#get_variation_for_feature_rollout' do user_attributes = {} user_id = 'user_1' - + 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'] expect(decision_service.get_variation_for_feature_rollout(feature_flag, user_id, user_attributes)).to eq(nil) - + expect(spy_logger).to have_received(:log).once - .with(Logger::DEBUG, "Feature flag 'boolean_feature' is not part of a rollout.") + .with(Logger::DEBUG, "Feature flag 'boolean_feature' is not part of a rollout.") end end - + describe 'when the rollout is not in the datafile' do 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' expect(decision_service.get_variation_for_feature_rollout(feature_flag, user_id, user_attributes)).to eq(nil) - + expect(spy_logger).to have_received(:log).once - .with(Logger::ERROR, "Rollout with ID 'invalid_rollout_id' is not in the datafile.") + .with(Logger::ERROR, "Rollout with ID 'invalid_rollout_id' is not in the datafile.") end end - + describe 'when the rollout does not have any experiments' do it 'should return nil' do experimentless_rollout = config.rollouts[0].dup @@ -422,148 +480,148 @@ expect(decision_service.get_variation_for_feature_rollout(feature_flag, user_id, user_attributes)).to eq(nil) end end - + describe 'when the user qualifies for targeting rule' do describe 'and the user is bucketed into the targeting rule' do it 'should return the variation the user is bucketed into' do feature_flag = config.feature_flag_key_map['boolean_single_variable_feature'] rollout_experiment = config.rollout_id_map[feature_flag['rolloutId']]['experiments'][0] expected_variation = rollout_experiment['variations'][0] - + allow(Optimizely::Audience).to receive(:user_in_experiment?).and_return(true) allow(decision_service.bucketer).to receive(:bucket) - .with(rollout_experiment, user_id) - .and_return(expected_variation) + .with(rollout_experiment, user_id) + .and_return(expected_variation) expect(decision_service.get_variation_for_feature_rollout(feature_flag, user_id, user_attributes)).to eq(expected_variation) - + expect(spy_logger).to have_received(:log).once - .with(Logger::DEBUG, "User 'user_1' meets conditions for targeting rule '1'.") + .with(Logger::DEBUG, "User 'user_1' meets conditions for targeting rule '1'.") end end - + describe 'and the user is not bucketed into the targeting rule' do describe 'and the user is not bucketed into the "Everyone Else" rule' do it 'should log and return nil' do feature_flag = config.feature_flag_key_map['boolean_single_variable_feature'] rollout = config.rollout_id_map[feature_flag['rolloutId']] everyone_else_experiment = rollout['experiments'][2] - + allow(Optimizely::Audience).to receive(:user_in_experiment?).and_return(true) allow(decision_service.bucketer).to receive(:bucket) - .with(rollout['experiments'][0], user_id) - .and_return(nil) + .with(rollout['experiments'][0], user_id) + .and_return(nil) allow(decision_service.bucketer).to receive(:bucket) - .with(everyone_else_experiment, user_id) - .and_return(nil) - + .with(everyone_else_experiment, user_id) + .and_return(nil) + expect(decision_service.get_variation_for_feature_rollout(feature_flag, user_id, user_attributes)).to eq(nil) - + # make sure we only checked the audience for the first rule expect(Optimizely::Audience).to have_received(:user_in_experiment?).once - .with(config, rollout['experiments'][0], user_attributes) + .with(config, rollout['experiments'][0], user_attributes) expect(Optimizely::Audience).not_to have_received(:user_in_experiment?) - .with(config, rollout['experiments'][1], user_attributes) - - + .with(config, rollout['experiments'][1], user_attributes) + + # verify log messages expect(spy_logger).to have_received(:log).once - .with(Logger::DEBUG, "User 'user_1' meets conditions for targeting rule '1'.") + .with(Logger::DEBUG, "User 'user_1' meets conditions for targeting rule '1'.") expect(spy_logger).to have_received(:log).once - .with(Logger::DEBUG, "User 'user_1' is not in the traffic group for the targeting rule. Checking 'Eveyrone Else' rule now.") + .with(Logger::DEBUG, "User 'user_1' is not in the traffic group for the targeting rule. Checking 'Eveyrone Else' rule now.") expect(spy_logger).to have_received(:log).once - .with(Logger::DEBUG, "User 'user_1' does not meet conditions for targeting rule 'Everyone Else'.") + .with(Logger::DEBUG, "User 'user_1' does not meet conditions for targeting rule 'Everyone Else'.") end end - + describe 'and the user is bucketed into the "Everyone Else" rule' do it 'should return the variation the user is bucketed into' do feature_flag = config.feature_flag_key_map['boolean_single_variable_feature'] rollout = config.rollout_id_map[feature_flag['rolloutId']] everyone_else_experiment = rollout['experiments'][2] expected_variation = everyone_else_experiment['variations'][0] - + allow(Optimizely::Audience).to receive(:user_in_experiment?).and_return(true) allow(decision_service.bucketer).to receive(:bucket) - .with(rollout['experiments'][0], user_id) - .and_return(nil) + .with(rollout['experiments'][0], user_id) + .and_return(nil) allow(decision_service.bucketer).to receive(:bucket) - .with(everyone_else_experiment, user_id) - .and_return(expected_variation) - + .with(everyone_else_experiment, user_id) + .and_return(expected_variation) + expect(decision_service.get_variation_for_feature_rollout(feature_flag, user_id, user_attributes)).to eq(expected_variation) - + # make sure we only checked the audience for the first rule expect(Optimizely::Audience).to have_received(:user_in_experiment?).once - .with(config, rollout['experiments'][0], user_attributes) + .with(config, rollout['experiments'][0], user_attributes) expect(Optimizely::Audience).not_to have_received(:user_in_experiment?) - .with(config, rollout['experiments'][1], user_attributes) - - + .with(config, rollout['experiments'][1], user_attributes) + + # verify log messages expect(spy_logger).to have_received(:log).once - .with(Logger::DEBUG, "User 'user_1' meets conditions for targeting rule '1'.") + .with(Logger::DEBUG, "User 'user_1' meets conditions for targeting rule '1'.") expect(spy_logger).to have_received(:log).once - .with(Logger::DEBUG, "User 'user_1' is not in the traffic group for the targeting rule. Checking 'Eveyrone Else' rule now.") + .with(Logger::DEBUG, "User 'user_1' is not in the traffic group for the targeting rule. Checking 'Eveyrone Else' rule now.") expect(spy_logger).to have_received(:log).once - .with(Logger::DEBUG, "User 'user_1' meets conditions for targeting rule 'Everyone Else'.") + .with(Logger::DEBUG, "User 'user_1' meets conditions for targeting rule 'Everyone Else'.") end end end end - + describe 'when the user is not bucketed into any targeting rules' do it 'should try to bucket the user into the "Everyone Else" rule' do feature_flag = config.feature_flag_key_map['boolean_single_variable_feature'] rollout = config.rollout_id_map[feature_flag['rolloutId']] everyone_else_experiment = rollout['experiments'][2] expected_variation = everyone_else_experiment['variations'][0] - + allow(Optimizely::Audience).to receive(:user_in_experiment?).and_return(false) allow(decision_service.bucketer).to receive(:bucket) - .with(everyone_else_experiment, user_id) - .and_return(expected_variation) - + .with(everyone_else_experiment, user_id) + .and_return(expected_variation) + expect(decision_service.get_variation_for_feature_rollout(feature_flag, user_id, user_attributes)).to eq(expected_variation) - + # verify we tried to bucket in all targeting rules except for the everyone else rule expect(Optimizely::Audience).to have_received(:user_in_experiment?).once - .with(config, rollout['experiments'][0], user_attributes) + .with(config, rollout['experiments'][0], user_attributes) expect(Optimizely::Audience).to have_received(:user_in_experiment?) - .with(config, rollout['experiments'][1], user_attributes) + .with(config, rollout['experiments'][1], user_attributes) expect(Optimizely::Audience).not_to have_received(:user_in_experiment?) - .with(config, rollout['experiments'][2], user_attributes) - - + .with(config, rollout['experiments'][2], user_attributes) + + # verify log messages expect(spy_logger).to have_received(:log).once - .with(Logger::DEBUG, "User 'user_1' does not meet the conditions to be in experiment '177770'.") + .with(Logger::DEBUG, "User 'user_1' does not meet the conditions to be in experiment '177770'.") expect(spy_logger).to have_received(:log).once - .with(Logger::DEBUG, "User 'user_1' does not meet the conditions to be in experiment '177772'.") + .with(Logger::DEBUG, "User 'user_1' does not meet the conditions to be in experiment '177772'.") expect(spy_logger).to have_received(:log).once - .with(Logger::DEBUG, "User 'user_1' meets conditions for targeting rule 'Everyone Else'.") + .with(Logger::DEBUG, "User 'user_1' meets conditions for targeting rule 'Everyone Else'.") end end end - + describe '#get_variation_for_feature' do user_attributes = {} user_id = 'user_1' - + describe 'when the user is bucketed into the feature experiment' do it 'should return the bucketed experiment and variation' do feature_flag = config.feature_flag_key_map['string_single_variable_feature'] expected_experiment = config.experiment_id_map[feature_flag['experimentIds'][0]] expected_variation = expected_experiment['variations'][0] expected_decision = { - 'experiment' => expected_experiment, - 'variation' => expected_variation + 'experiment' => expected_experiment, + 'variation' => expected_variation } allow(decision_service).to receive(:get_variation_for_feature_experiment).and_return(expected_decision) - + expect(decision_service.get_variation_for_feature(feature_flag, user_id, user_attributes)).to eq(expected_decision) end end - + describe 'when then user is not bucketed into the feature experiment' do describe 'and the user is bucketed into the feature rollout' do it 'should return the bucketed variation and nil experiment' do @@ -571,29 +629,29 @@ rollout = config.rollout_id_map[feature_flag['rolloutId']] expected_variation = rollout['experiments'][0]['variations'][0] expected_decision = { - 'experiment' => nil, - 'variation' => expected_variation + 'experiment' => nil, + 'variation' => expected_variation } allow(decision_service).to receive(:get_variation_for_feature_experiment).and_return(nil) allow(decision_service).to receive(:get_variation_for_feature_rollout).and_return(expected_variation) - + expect(decision_service.get_variation_for_feature(feature_flag, user_id, user_attributes)).to eq(expected_decision) expect(spy_logger).to have_received(:log).once - .with(Logger::INFO, "User 'user_1' is in the rollout for feature flag 'string_single_variable_feature'.") + .with(Logger::INFO, "User 'user_1' is in the rollout for feature flag 'string_single_variable_feature'.") 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) allow(decision_service).to receive(:get_variation_for_feature_rollout).and_return(nil) - + expect(decision_service.get_variation_for_feature(feature_flag, user_id, user_attributes)).to eq(nil) expect(spy_logger).to have_received(:log).once - .with(Logger::INFO, "User 'user_1' is not in the rollout for feature flag 'string_single_variable_feature'.") + .with(Logger::INFO, "User 'user_1' is not in the rollout for feature flag 'string_single_variable_feature'.") end end end end -end +end \ No newline at end of file diff --git a/spec/event_builder_spec.rb b/spec/event_builder_spec.rb index ebfa6f8b..4a0e93e7 100644 --- a/spec/event_builder_spec.rb +++ b/spec/event_builder_spec.rb @@ -57,6 +57,7 @@ }] }] }], + anonymize_ip: false, revision: '42', client_name: Optimizely::CLIENT_ENGINE, client_version: Optimizely::VERSION @@ -81,6 +82,7 @@ }] }] }], + anonymize_ip: false, revision: '42', client_name: Optimizely::CLIENT_ENGINE, client_version: Optimizely::VERSION @@ -364,4 +366,53 @@ expect(conversion_event.http_verb).to eq(:post) end + # Create impression event with bucketing ID + it 'should create valid Event when create_impression_event is called with Bucketing ID attribute' do + @expected_impression_params[:visitors][0][:attributes] = [{ + entity_id: '111094', + key: 'browser_type', + type: 'custom', + value: 'firefox' + },{ + entity_id: OptimizelySpec::RESERVED_ATTRIBUTE_KEY_BUCKETING_ID, + key: OptimizelySpec::RESERVED_ATTRIBUTE_KEY_BUCKETING_ID_EVENT_PARAM_KEY, + type: 'custom', + value: 'variation' + }] + + user_attributes = { + 'browser_type' => 'firefox', + OptimizelySpec::RESERVED_ATTRIBUTE_KEY_BUCKETING_ID => 'variation' + } + experiment = config.get_experiment_from_key('test_experiment') + impression_event = @event_builder.create_impression_event(experiment, '111128', 'test_user', user_attributes) + expect(impression_event.params).to eq(@expected_impression_params) + expect(impression_event.url).to eq(@expected_endpoint) + expect(impression_event.http_verb).to eq(:post) + end + + # Create conversion event with bucketing ID + it 'should create valid Event when create_conversion_event is called with Bucketing ID attribute' do + @expected_conversion_params[:visitors][0][:attributes] = [{ + entity_id: '111094', + key: 'browser_type', + type: 'custom', + value: 'firefox' + },{ + entity_id: OptimizelySpec::RESERVED_ATTRIBUTE_KEY_BUCKETING_ID, + key: OptimizelySpec::RESERVED_ATTRIBUTE_KEY_BUCKETING_ID_EVENT_PARAM_KEY, + type: 'custom', + value: 'variation' + }] + + user_attributes = { + 'browser_type' => 'firefox', + OptimizelySpec::RESERVED_ATTRIBUTE_KEY_BUCKETING_ID => 'variation' + } + conversion_event = @event_builder.create_conversion_event('test_event', 'test_user', user_attributes, nil, {'111127' => '111128'}) + expect(conversion_event.params).to eq(@expected_conversion_params) + expect(conversion_event.url).to eq(@expected_endpoint) + expect(conversion_event.http_verb).to eq(:post) + end + end diff --git a/spec/notification_center_spec.rb b/spec/notification_center_spec.rb new file mode 100644 index 00000000..c46fd6f2 --- /dev/null +++ b/spec/notification_center_spec.rb @@ -0,0 +1,489 @@ +# +# Copyright 2017, Optimizely and contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +require 'spec_helper' +require 'optimizely/error_handler' +require 'optimizely/event_builder' +require 'optimizely/logger' +require 'optimizely/notification_center' +describe Optimizely::NotificationCenter do + let(:spy_logger) { spy('logger') } + let(:config_body) { OptimizelySpec::VALID_CONFIG_BODY } + let(:config_body_JSON) { OptimizelySpec::VALID_CONFIG_BODY_JSON } + let(:error_handler) { Optimizely::NoOpErrorHandler.new } + let(:logger) { Optimizely::NoOpLogger.new } + let(:notification_center) { Optimizely::NotificationCenter.new(spy_logger, error_handler) } + + before(:context) do + class CallBack + def call + end + end + + @callback = CallBack.new + @callback_reference = @callback.method(:call) + end + + describe '#Notification center' do + describe 'test add notification with invalid params' do + it 'should log and return nil if notification type is empty' do + expect(notification_center.add_notification_listener( + nil, + @callback_reference + )).to eq(nil) + expect(spy_logger).to have_received(:log).once + .with(Logger::ERROR, 'Notification type can not be empty.') + end + + it 'should log and return nil if notification callback is empty' do + expect(notification_center.add_notification_listener( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE], + nil + )).to eq(nil) + expect(spy_logger).to have_received(:log).once + .with(Logger::ERROR, 'Callback can not be empty.') + end + + it 'should log and return nil if invalid notification type given' do + expect(notification_center.add_notification_listener( + 'Test notification type', + @callback_reference + )).to eq(nil) + expect(spy_logger).to have_received(:log).once + .with(Logger::ERROR, 'Invalid notification type.') + end + + it 'should log and return nil if invalid callable given' do + expect(notification_center.add_notification_listener( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE], + 'Invalid callback!' + )).to eq(nil) + expect(spy_logger).to have_received(:log).once + .with(Logger::ERROR, 'Invalid notification callback given.') + end + end + + describe 'test add notification with valid type and callback' do + it 'should add, and return notification ID when a plain function is passed as an argument ' do + expect(notification_center.add_notification_listener( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE], + @callback_reference + )).to eq(1) + # verifies that one notification is added + expect(notification_center.notifications[Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE]].length) + .to eq(1) + end + end + + describe 'test add notification for multiple notification types' do + it 'should add and return notification ID when a valid callback is added for each notification type ' do + Optimizely::NotificationCenter::NOTIFICATION_TYPES.values.each_with_index do |value, index| + notification_id = index + 1 + expect(notification_center.add_notification_listener( + value, + @callback_reference + )).to eq(notification_id) + end + notification_center.notifications.each do |key, _array| + expect(notification_center.notifications[key].length) + .to eq(1) + end + end + + it 'should add and return notification ID when multiple + valid callbacks are added for a single notification type' do + class CallBackSecond + def call + end + end + + @callback_second = CallBackSecond.new + @callback_reference_second = @callback_second.method(:call) + expect( + notification_center.add_notification_listener( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE], + @callback_reference + ) + ).to eq(1) + expect( + notification_center.add_notification_listener( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE], + @callback_reference_second + ) + ).to eq(2) + + expect( + notification_center.notifications[ + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE] + ].length + ).to eq(2) + end + end + + describe 'test add notification that already added callback is not re-added' do + it 'should return -1 if callback already added' do + notification_center.add_notification_listener( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE], + @callback_reference + ) + expect(notification_center.add_notification_listener( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE], + @callback_reference + )).to eq(-1) + end + + it 'should add same callback for a different notification type' do + expect(notification_center.add_notification_listener( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE], + @callback_reference + )).to eq(1) + + expect(notification_center.add_notification_listener( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:TRACK], + @callback_reference + )).to eq(2) + end + end + + describe 'test remove notification' do + let(:raise_error_handler) { Optimizely::RaiseErrorHandler.new } + let(:notification_center) { Optimizely::NotificationCenter.new(spy_logger, raise_error_handler) } + before(:example) do + @inner_notification_center = notification_center + + @callback_second = CallBackSecond.new + @callback_reference_second = @callback_second.method(:call) + # add a callback for multiple notification types + + expect(@inner_notification_center.add_notification_listener( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE], + @callback_reference + )).to eq(1) + + expect(@inner_notification_center.add_notification_listener( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:TRACK], + @callback_reference + )).to eq(2) + + # add another callback for NOTIFICATION_TYPES::ACTIVATE + expect(@inner_notification_center.add_notification_listener( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE], + @callback_reference_second + )).to eq(3) + # Verify that notifications length for NOTIFICATION_TYPES::ACTIVATE is 2 + expect(@inner_notification_center.notifications[ + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE]].length).to eq(2) + # Verify that notifications length for NotificationType::TRACK is 1 + expect(@inner_notification_center.notifications[ + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:TRACK]].length).to eq(1) + end + + it 'should not remove callback for empty notification ID' do + expect(@inner_notification_center.remove_notification_listener(nil)).to eq(nil) + expect(spy_logger).to have_received(:log).once + .with(Logger::ERROR, 'Notification ID can not be empty.') + + expect( + @inner_notification_center.notifications[ + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE] + ].length + ).to eq(2) + expect( + @inner_notification_center.notifications[ + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:TRACK] + ].length + ).to eq(1) + end + + it 'should not remove callback for an invalid notification ID' do + expect(@inner_notification_center.remove_notification_listener(4)) + .to eq(false) + expect( + @inner_notification_center.notifications[ + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE] + ].length + ).to eq(2) + expect( + @inner_notification_center.notifications[ + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:TRACK] + ].length + ).to eq(1) + end + + it 'should remove callback for a valid notification ID' do + expect(@inner_notification_center.remove_notification_listener(3)) + .to eq(true) + expect( + @inner_notification_center.notifications[ + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE] + ].length + ).to eq(1) + expect( + @inner_notification_center.notifications[ + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:TRACK] + ].length + ).to eq(1) + end + + it 'should not remove callback once a callback has already been removed against a notification ID' do + expect(@inner_notification_center.remove_notification_listener(3)) + .to eq(true) + expect(@inner_notification_center.remove_notification_listener(3)) + .to eq(false) + expect( + @inner_notification_center.notifications[ + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE] + ].length + ).to eq(1) + expect( + @inner_notification_center.notifications[ + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:TRACK] + ].length + ).to eq(1) + end + + it 'should not remove notifications for an invalid notification type' do + invalid_type = 'Invalid notification' + expect { @inner_notification_center.clear_notifications(invalid_type) } + .to raise_error(Optimizely::InvalidNotificationType) + expect(spy_logger).to have_received(:log).once + .with(Logger::ERROR, 'Invalid notification type.') + expect( + @inner_notification_center.notifications[ + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE] + ].length + ).to eq(2) + expect( + @inner_notification_center.notifications[ + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:TRACK] + ].length + ).to eq(1) + end + + it 'should remove all notifications for a valid notification type' do + notification_type = Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE] + @inner_notification_center.clear_notifications(notification_type) + expect(spy_logger).to have_received(:log).once + .with(Logger::INFO, "All callbacks for notification type #{notification_type} have been removed.") + expect( + @inner_notification_center.notifications[ + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE] + ].length + ).to eq(0) + expect( + @inner_notification_center.notifications[ + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:TRACK] + ].length + ).to eq(1) + end + + it 'should not throw an error when clear_notifications is called again for the same notification type' do + notification_type = Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE] + @inner_notification_center.clear_notifications(notification_type) + expect { @inner_notification_center.clear_notifications(notification_type) } + .to_not raise_error(Optimizely::InvalidNotificationType) + expect( + @inner_notification_center.notifications[ + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE] + ].length + ).to eq(0) + expect( + @inner_notification_center.notifications[ + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:TRACK] + ].length + ).to eq(1) + end + end + + describe 'clear all notifications' do + let(:raise_error_handler) { Optimizely::RaiseErrorHandler.new } + let(:notification_center) { Optimizely::NotificationCenter.new(spy_logger, raise_error_handler) } + before(:example) do + @inner_notification_center = notification_center + + @callback_second = CallBackSecond.new + @callback_reference_second = @callback_second.method(:call) + + class CallBackThird + def call + end + end + + @callback_third = CallBackThird.new + @callback_reference_third = @callback_third.method(:call) + + # verify that for each of the notification types, the notifications length is zero + @inner_notification_center.notifications.each do |key, _array| + expect(@inner_notification_center.notifications[key]).to be_empty + end + # add a callback for multiple notification types + expect(@inner_notification_center.add_notification_listener( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE], + @callback_reference + )).to eq(1) + + expect(@inner_notification_center.add_notification_listener( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE], + @callback_reference_second + )).to eq(2) + + expect(@inner_notification_center.add_notification_listener( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE], + @callback_reference_third + )).to eq(3) + + expect(@inner_notification_center.add_notification_listener( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:TRACK], + @callback_reference + )).to eq(4) + + expect(@inner_notification_center.add_notification_listener( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:TRACK], + @callback_reference_second + )).to eq(5) + + # verify that notifications length for each type reflects the just added callbacks + + expect(@inner_notification_center.notifications[ + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE]].length).to eq(3) + + expect(@inner_notification_center.notifications[ + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:TRACK]].length).to eq(2) + end + + it 'should remove all notifications for each notification type' do + @inner_notification_center.clean_all_notifications + @inner_notification_center.notifications.each do |key, _array| + expect(@inner_notification_center.notifications[key]).to be_empty + end + end + + it 'clean_all_notifications does not throw an error when called again' do + @inner_notification_center.clean_all_notifications + expect { @inner_notification_center.clean_all_notifications } + .to_not raise_error + end + end + + describe '.send_notifications' do + class Invitation + def initialize(logger) + @logger = logger + end + + def deliver_one(_args) + @logger.log Logger::INFO, 'delivered one.' + end + + def deliver_two(_args) + @logger.log Logger::INFO, 'delivered two.' + end + + def deliver_three + end + end + let(:raise_error_handler) { Optimizely::RaiseErrorHandler.new } + let(:invitation) { Invitation.new(spy_logger) } + let(:notification_center) { Optimizely::NotificationCenter.new(spy_logger, raise_error_handler) } + before(:example) do + config = Optimizely::ProjectConfig.new(config_body_JSON, spy_logger, error_handler) + @event_builder = Optimizely::EventBuilder.new(config) + @args = [ + config.get_experiment_from_key('test_experiment'), + 'test_user', + {}, + '111128', + @event_builder.create_impression_event( + config.get_experiment_from_key('test_experiment'), + '111128', 'test_user', nil + ) + ] + end + + it 'should not raise error and send single notification for a single type' do + notification_type = Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE] + notification_center.add_notification_listener(notification_type, invitation.method(:deliver_one)) + notification_center.notifications[notification_type].each do |notification| + notification_callback = notification[:callback] + expect { notification_callback.call(@args) }.to_not raise_error + end + expect(spy_logger).to have_received(:log).once + .with(Logger::INFO, 'delivered one.') + end + + it 'should return nil when notification type not valid' do + notification_type = Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE] + notification_center.add_notification_listener(notification_type, invitation.method(:deliver_one)) + expect { notification_center.send_notifications('test_type', @args) } + .to raise_error(Optimizely::InvalidNotificationType) + end + + it 'should return nil and log when args are invalid' do + notification_type = Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE] + notification_center.add_notification_listener(notification_type, invitation.method(:deliver_one)) + expect(notification_center.send_notifications(notification_type)).to eq(nil) + expect(spy_logger).to_not have_received(:log) + .with(Logger::INFO, 'delivered one.') + expect(spy_logger).to have_received(:log).once + .with( + Logger::ERROR, + 'Problem calling notify callback. Error: wrong number of arguments (given 0, expected 1)' + ) + end + + it 'should send multiple notifications for a single type' do + notification_type = Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE] + notification_center.add_notification_listener(notification_type, invitation.method(:deliver_one)) + notification_center.add_notification_listener(notification_type, invitation.method(:deliver_two)) + notification_center.send_notifications(notification_type, @args) + expect(spy_logger).to have_received(:log).once + .with(Logger::INFO, 'delivered one.') + expect(spy_logger).to have_received(:log).once + .with(Logger::INFO, 'delivered two.') + end + + it 'should send notifications and verify that all callbacks are called' do + notification_type_decision = Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE] + notification_type_track = Optimizely::NotificationCenter::NOTIFICATION_TYPES[:TRACK] + + notification_center.add_notification_listener(notification_type_decision, invitation.method(:deliver_one)) + notification_center.add_notification_listener(notification_type_decision, invitation.method(:deliver_two)) + notification_center.add_notification_listener(notification_type_track, invitation.method(:deliver_three)) + + notification_center.send_notifications(notification_type_decision, @args) + expect(spy_logger).to have_received(:log).once + .with(Logger::INFO, 'delivered one.') + expect(spy_logger).to have_received(:log).once + .with(Logger::INFO, 'delivered two.') + + # Verifies that all callbacks for NotificationType::ACTIVATE are called and no other callbacks are called + expect(spy_logger).to_not have_received(:log) + .with(Logger::INFO, 'delivered three.') + end + end + + describe '@error_handler' do + let(:raise_error_handler) { Optimizely::RaiseErrorHandler.new } + let(:notification_center) { Optimizely::NotificationCenter.new(spy_logger, raise_error_handler) } + + describe 'validate notification type' do + it 'should raise an error when provided notification type is invalid' do + expect { notification_center.add_notification_listener('invalid_key', @callback_reference) } + .to raise_error(Optimizely::InvalidNotificationType) + end + end + end + end +end diff --git a/spec/project_spec.rb b/spec/project_spec.rb index 736f48c8..91cd0b6d 100644 --- a/spec/project_spec.rb +++ b/spec/project_spec.rb @@ -121,7 +121,6 @@ class InvalidErrorHandler; end before(:example) do allow(Time).to receive(:now).and_return(time_now) allow(SecureRandom).to receive(:uuid).and_return('a68cf1ad-0393-4e18-af87-efe8f01a7c9c'); - @expected_activate_params = { account_id: '12001', project_id: '111001', @@ -142,6 +141,7 @@ class InvalidErrorHandler; end }], visitor_id: 'test_user' }], + anonymize_ip: false, revision: '42', client_name: Optimizely::CLIENT_ENGINE, client_version: Optimizely::VERSION @@ -256,16 +256,22 @@ class InvalidErrorHandler; end expect(project_instance.event_dispatcher).to_not have_received(:dispatch_event) end - it 'should log when an impression event is dispatched' do + it 'should log and send activate notification when an impression event is dispatched' do params = @expected_activate_params - variation_to_return = project_instance.config.get_variation_from_id('test_experiment', '111128') allow(project_instance.decision_service.bucketer).to receive(:bucket).and_return(variation_to_return) allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) allow(project_instance.config).to receive(:get_audience_ids_for_experiment) .with('test_experiment') .and_return([]) + experiment = project_instance.config.get_experiment_from_key('test_experiment') + expect(project_instance.notification_center).to receive(:send_notifications).with( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE], + experiment,'test_user',nil,variation_to_return, + instance_of(Optimizely::Event) + ) project_instance.activate('test_experiment', 'test_user') + expect(spy_logger).to have_received(:log).once.with(Logger::INFO, include("Dispatching impression event to" \ " URL #{impression_log_url} with params #{params}")) end @@ -282,7 +288,7 @@ class InvalidErrorHandler; end expect { project_instance.activate('test_experiment', 'test_user', 'invalid') } .to raise_error(Optimizely::InvalidAttributeFormatError) end - + it 'should override the audience check if the user is whitelisted to a specific variation' do params = @expected_activate_params params[:visitors][0][:visitor_id] = 'forced_audience_user' @@ -345,6 +351,7 @@ class InvalidErrorHandler; end }], visitor_id: 'test_user' }], + anonymize_ip: false, revision: '42', client_name: Optimizely::CLIENT_ENGINE, client_version: Optimizely::VERSION @@ -358,7 +365,7 @@ class InvalidErrorHandler; end project_instance.track('test_event', 'test_user') expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, conversion_log_url, params, post_headers)).once end - + it 'should properly track an event by calling dispatch_event with right params after forced variation' do params = @expected_track_event_params params[:visitors][0][:snapshots][0][:decisions][0][:variation_id] = '111129' @@ -375,14 +382,19 @@ class InvalidErrorHandler; end expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, "Unable to dispatch conversion event. Error: RuntimeError") end - it 'should properly track an event by calling dispatch_event with right params with revenue provided' do + it 'should send track notification and properly track an event by calling dispatch_event with right params with revenue provided' do params = @expected_track_event_params params[:visitors][0][:snapshots][0][:events][0].merge!({ revenue: 42, tags: {'revenue' => 42} }) - allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + conversion_event = Optimizely::Event.new(:post, conversion_log_url, params, post_headers) + expect(project_instance.notification_center).to receive(:send_notifications) + .with( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:TRACK], + 'test_event','test_user', nil, {'revenue' => 42}, conversion_event + ).once project_instance.track('test_event', 'test_user', nil, {'revenue' => 42}) expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, conversion_log_url, params, post_headers)).once end @@ -522,16 +534,29 @@ class InvalidErrorHandler; end describe '#get_variation' do it 'should have get_variation return expected variation when there are no audiences' do - allow(Optimizely::Audience).to receive(:user_in_experiment?).and_return(true) expect(project_instance.get_variation('test_experiment', 'test_user')) .to eq(config_body['experiments'][0]['variations'][0]['key']) end + it 'should have get_variation return expected variation with bucketing id attribute when there are no audiences' do + expect(project_instance.get_variation('test_experiment', 'test_user',nil)) + .to eq(config_body['experiments'][0]['variations'][0]['key']) + end + it 'should have get_variation return expected variation when audience conditions match' do user_attributes = {'browser_type' => 'firefox'} expect(project_instance.get_variation('test_experiment_with_audience', 'test_user', user_attributes)) .to eq('control_with_audience') end + + it 'should have get_variation return expected variation with bucketing id attribute when audience conditions match' do + user_attributes = { + 'browser_type' => 'firefox', + OptimizelySpec::RESERVED_ATTRIBUTE_KEY_BUCKETING_ID => 'pid' + } + expect(project_instance.get_variation('test_experiment_with_audience', 'test_user', user_attributes)) + .to eq('control_with_audience') + end it 'should have get_variation return nil when attributes are invalid' do allow(project_instance).to receive(:attributes_valid?).and_return(false) @@ -545,10 +570,26 @@ class InvalidErrorHandler; end .to eq(nil) end + it 'should have get_variation return nil with bucketing id attribute when audience conditions do not match' do + user_attributes = {'browser_type' => 'chrome', + OptimizelySpec::RESERVED_ATTRIBUTE_KEY_BUCKETING_ID => 'pid' + } + expect(project_instance.get_variation('test_experiment_with_audience', 'test_user', user_attributes)) + .to eq(nil) + end + it 'should have get_variation return nil when experiment is not Running' do expect(project_instance.get_variation('test_experiment_not_started', 'test_user')).to eq(nil) end + it 'should have get_variation return nil with bucketing id attribute when experiment is not Running' do + user_attributes = { + 'browser_type' => 'firefox', + OptimizelySpec::RESERVED_ATTRIBUTE_KEY_BUCKETING_ID => 'pid' + } + expect(project_instance.get_variation('test_experiment_not_started', 'test_user',user_attributes)).to eq(nil) + end + it 'should raise an exception when called with invalid attributes' do expect { project_instance.get_variation('test_experiment', 'test_user', 'invalid') } .to raise_error(Optimizely::InvalidAttributeFormatError) @@ -599,6 +640,7 @@ class InvalidErrorHandler; end }], visitor_id: 'test_user' }], + anonymize_ip: false, revision: '42', client_name: Optimizely::CLIENT_ENGINE, client_version: Optimizely::VERSION @@ -638,18 +680,26 @@ class InvalidErrorHandler; end expect(spy_logger).to have_received(:log).once.with(Logger::INFO, "Feature 'boolean_single_variable_feature' is enabled for user 'test_user'.") end - it 'should return true and send an impression if the user is bucketed into a feature experiment' do + it 'should return true, send activate notification and an impression if the user is bucketed into a feature experiment' do allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) experiment_to_return = config_body['experiments'][3] variation_to_return = experiment_to_return['variations'][0] decision_to_return = { - 'experiment' => experiment_to_return, - 'variation' => variation_to_return + 'experiment' => experiment_to_return, + 'variation' => variation_to_return } - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + expect(project_instance.notification_center).to receive(:send_notifications) + .with( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE], + experiment_to_return, 'test_user', nil, variation_to_return, + instance_of(Optimizely::Event) + ) + + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + expected_params = @expected_bucketed_params - + expect(project_instance.is_feature_enabled('multi_variate_feature', 'test_user')).to be true expect(spy_logger).to have_received(:log).once.with(Logger::INFO, "Dispatching impression event to URL https://logx.optimizely.com/v1/events with params #{expected_params}.") expect(spy_logger).to have_received(:log).once.with(Logger::INFO, "Feature 'multi_variate_feature' is enabled for user 'test_user'.") @@ -685,25 +735,25 @@ class InvalidErrorHandler; end it 'should log a warning' do variation_to_return = project_instance.config.rollout_id_map['166660']['experiments'][0]['variations'][0] decision_to_return = { - 'experiment' => nil, - 'variation' => variation_to_return + 'experiment' => nil, + 'variation' => variation_to_return } allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) - + expect(project_instance.get_feature_variable_string('boolean_single_variable_feature', 'boolean_variable', user_id, user_attributes)) - .to eq('true') - + .to eq('true') + expect(spy_logger).to have_received(:log).twice expect(spy_logger).to have_received(:log).once - .with( - Logger::INFO, - "Got variable value 'true' for variable 'boolean_variable' of feature flag 'boolean_single_variable_feature'." - ) + .with( + Logger::INFO, + "Got variable value 'true' for variable 'boolean_variable' of feature flag 'boolean_single_variable_feature'." + ) expect(spy_logger).to have_received(:log).once - .with( - Logger::WARN, - "Requested variable type 'string' but variable 'boolean_variable' is of type 'boolean'." - ) + .with( + Logger::WARN, + "Requested variable type 'string' but variable 'boolean_variable' is of type 'boolean'." + ) end end diff --git a/spec/spec_params.rb b/spec/spec_params.rb index e103d61b..30b3e89c 100644 --- a/spec/spec_params.rb +++ b/spec/spec_params.rb @@ -16,9 +16,12 @@ require 'json' module OptimizelySpec + RESERVED_ATTRIBUTE_KEY_BUCKETING_ID = "\$opt_bucketing_id".freeze + RESERVED_ATTRIBUTE_KEY_BUCKETING_ID_EVENT_PARAM_KEY = "optimizely_bucketing_id".freeze VALID_CONFIG_BODY = { 'accountId' => '12001', 'projectId' => '111001', + 'anonymizeIP'=> false, 'revision' => '42', 'version' => '2', 'events' => [{