diff --git a/optimizely/bucketer.py b/optimizely/bucketer.py index 940a9549..ca5e0f28 100644 --- a/optimizely/bucketer.py +++ b/optimizely/bucketer.py @@ -1,4 +1,4 @@ -# Copyright 2016-2017, 2019-2020 Optimizely +# Copyright 2016-2017, 2019-2021 Optimizely # 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 @@ -71,13 +71,13 @@ def find_bucket(self, project_config, bucketing_id, parent_id, traffic_allocatio traffic_allocations: Traffic allocations representing traffic allotted to experiments or variations. Returns: - Entity ID which may represent experiment or variation. + Entity ID which may represent experiment or variation and """ - bucketing_key = BUCKETING_ID_TEMPLATE.format(bucketing_id=bucketing_id, parent_id=parent_id) bucketing_number = self._generate_bucket_value(bucketing_key) + message = 'Assigned bucket %s to user with bucketing ID "%s".' % (bucketing_number, bucketing_id) project_config.logger.debug( - 'Assigned bucket %s to user with bucketing ID "%s".' % (bucketing_number, bucketing_id) + message ) for traffic_allocation in traffic_allocations: @@ -97,11 +97,13 @@ def bucket(self, project_config, experiment, user_id, bucketing_id): bucketing_id: ID to be used for bucketing the user. Returns: - Variation in which user with ID user_id will be put in. None if no variation. + Variation in which user with ID user_id will be put in. None if no variation + and array of log messages representing decision making. + */. """ - + decide_reasons = [] if not experiment: - return None + return None, decide_reasons # Determine if experiment is in a mutually exclusive group. # This will not affect evaluation of rollout rules. @@ -109,29 +111,43 @@ def bucket(self, project_config, experiment, user_id, bucketing_id): group = project_config.get_group(experiment.groupId) if not group: - return None + return None, decide_reasons user_experiment_id = self.find_bucket( project_config, bucketing_id, experiment.groupId, group.trafficAllocation, ) + if not user_experiment_id: - project_config.logger.info('User "%s" is in no experiment.' % user_id) - return None + message = 'User "%s" is in no experiment.' % user_id + project_config.logger.info(message) + decide_reasons.append(message) + return None, decide_reasons if user_experiment_id != experiment.id: + message = 'User "%s" is not in experiment "%s" of group %s.' \ + % (user_id, experiment.key, experiment.groupId) project_config.logger.info( - 'User "%s" is not in experiment "%s" of group %s.' % (user_id, experiment.key, experiment.groupId) + message ) - return None + decide_reasons.append(message) + return None, decide_reasons + message = 'User "%s" is in experiment %s of group %s.' % (user_id, experiment.key, experiment.groupId) project_config.logger.info( - 'User "%s" is in experiment %s of group %s.' % (user_id, experiment.key, experiment.groupId) + message ) + decide_reasons.append(message) # Bucket user if not in white-list and in group (if any) - variation_id = self.find_bucket(project_config, bucketing_id, experiment.id, experiment.trafficAllocation) + variation_id = self.find_bucket(project_config, bucketing_id, + experiment.id, experiment.trafficAllocation) if variation_id: variation = project_config.get_variation_from_id(experiment.key, variation_id) - return variation + return variation, decide_reasons - return None + else: + message = 'Bucketed into an empty traffic range. Returning nil.' + project_config.logger.info(message) + decide_reasons.append(message) + + return None, decide_reasons diff --git a/optimizely/decision/__init__.py b/optimizely/decision/__init__.py new file mode 100644 index 00000000..016c35cd --- /dev/null +++ b/optimizely/decision/__init__.py @@ -0,0 +1,12 @@ +# Copyright 2021, Optimizely +# 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. diff --git a/optimizely/decision/optimizely_decide_option.py b/optimizely/decision/optimizely_decide_option.py new file mode 100644 index 00000000..4eb8e7e5 --- /dev/null +++ b/optimizely/decision/optimizely_decide_option.py @@ -0,0 +1,20 @@ +# Copyright 2021, Optimizely +# 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. + + +class OptimizelyDecideOption(object): + DISABLE_DECISION_EVENT = 'DISABLE_DECISION_EVENT' + ENABLED_FLAGS_ONLY = 'ENABLED_FLAGS_ONLY' + IGNORE_USER_PROFILE_SERVICE = 'IGNORE_USER_PROFILE_SERVICE' + INCLUDE_REASONS = 'INCLUDE_REASONS' + EXCLUDE_VARIABLES = 'EXCLUDE_VARIABLES' diff --git a/optimizely/decision/optimizely_decision.py b/optimizely/decision/optimizely_decision.py new file mode 100644 index 00000000..781ab2bb --- /dev/null +++ b/optimizely/decision/optimizely_decision.py @@ -0,0 +1,35 @@ +# Copyright 2021, Optimizely +# 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. + + +class OptimizelyDecision(object): + def __init__(self, variation_key=None, enabled=None, + variables=None, rule_key=None, flag_key=None, user_context=None, reasons=None): + self.variation_key = variation_key + self.enabled = enabled or False + self.variables = variables or {} + self.rule_key = rule_key + self.flag_key = flag_key + self.user_context = user_context + self.reasons = reasons or [] + + def as_json(self): + return { + 'variation_key': self.variation_key, + 'enabled': self.enabled, + 'variables': self.variables, + 'rule_key': self.rule_key, + 'flag_key': self.flag_key, + 'user_context': self.user_context.as_json(), + 'reasons': self.reasons + } diff --git a/optimizely/decision/optimizely_decision_message.py b/optimizely/decision/optimizely_decision_message.py new file mode 100644 index 00000000..5b1ab417 --- /dev/null +++ b/optimizely/decision/optimizely_decision_message.py @@ -0,0 +1,18 @@ +# Copyright 2021, Optimizely +# 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. + + +class OptimizelyDecisionMessage(object): + SDK_NOT_READY = 'Optimizely SDK not configured properly yet.' + FLAG_KEY_INVALID = 'No flag was found for key "{}".' + VARIABLE_VALUE_INVALID = 'Variable value for key "{}" is invalid or wrong type.' diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index 56764d7b..52e9d02b 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -1,4 +1,4 @@ -# Copyright 2017-2020, Optimizely +# Copyright 2017-2021, Optimizely # 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 @@ -47,19 +47,21 @@ def _get_bucketing_id(self, user_id, attributes): attributes: Dict representing user attributes. May consist of bucketing ID to be used. Returns: - String representing bucketing ID if it is a String type in attributes else return user ID. + String representing bucketing ID if it is a String type in attributes else return user ID + array of log messages representing decision making. """ - + decide_reasons = [] attributes = attributes or {} bucketing_id = attributes.get(enums.ControlAttributes.BUCKETING_ID) if bucketing_id is not None: if isinstance(bucketing_id, string_types): - return bucketing_id - - self.logger.warning('Bucketing ID attribute is not a string. Defaulted to user_id.') + return bucketing_id, decide_reasons + message = 'Bucketing ID attribute is not a string. Defaulted to user_id.' + self.logger.warning(message) + decide_reasons.append(message) - return user_id + return user_id, decide_reasons def set_forced_variation(self, project_config, experiment_key, user_id, variation_key): """ Sets users to a map of experiments to forced variations. @@ -128,38 +130,43 @@ def get_forced_variation(self, project_config, experiment_key, user_id): user_id: The user ID. Returns: - The variation which the given user and experiment should be forced into. + The variation which the given user and experiment should be forced into and + array of log messages representing decision making. """ - + decide_reasons = [] if user_id not in self.forced_variation_map: - self.logger.debug('User "%s" is not in the forced variation map.' % user_id) - return None + message = 'User "%s" is not in the forced variation map.' % user_id + self.logger.debug(message) + return None, decide_reasons experiment = project_config.get_experiment_from_key(experiment_key) if not experiment: # The invalid experiment key will be logged inside this call. - return None + return None, decide_reasons experiment_to_variation_map = self.forced_variation_map.get(user_id) if not experiment_to_variation_map: + message = 'No experiment "%s" mapped to user "%s" in the forced variation map.' % (experiment_key, user_id) self.logger.debug( - 'No experiment "%s" mapped to user "%s" in the forced variation map.' % (experiment_key, user_id) + message ) - return None + return None, decide_reasons variation_id = experiment_to_variation_map.get(experiment.id) if variation_id is None: - self.logger.debug('No variation mapped to experiment "%s" in the forced variation map.' % experiment_key) - return None + message = 'No variation mapped to experiment "%s" in the forced variation map.' % experiment_key + self.logger.debug(message) + return None, decide_reasons variation = project_config.get_variation_from_id(experiment_key, variation_id) - + message = 'Variation "%s" is mapped to experiment "%s" and user "%s" in the forced variation map' \ + % (variation.key, experiment_key, user_id) self.logger.debug( - 'Variation "%s" is mapped to experiment "%s" and user "%s" in the forced variation map' - % (variation.key, experiment_key, user_id) + message ) - return variation + decide_reasons.append(message) + return variation, decide_reasons def get_whitelisted_variation(self, project_config, experiment, user_id): """ Determine if a user is forced into a variation (through whitelisting) @@ -171,18 +178,21 @@ def get_whitelisted_variation(self, project_config, experiment, user_id): user_id: ID for the user. Returns: - Variation in which the user with ID user_id is forced into. None if no variation. + Variation in which the user with ID user_id is forced into. None if no variation and + array of log messages representing decision making. """ - + decide_reasons = [] forced_variations = experiment.forcedVariations if forced_variations and user_id in forced_variations: variation_key = forced_variations.get(user_id) variation = project_config.get_variation_from_key(experiment.key, variation_key) if variation: - self.logger.info('User "%s" is forced in variation "%s".' % (user_id, variation_key)) - return variation + message = 'User "%s" is forced in variation "%s".' % (user_id, variation_key) + self.logger.info(message) + decide_reasons.append(message) + return variation, decide_reasons - return None + return None, decide_reasons def get_stored_variation(self, project_config, experiment, user_profile): """ Determine if the user has a stored variation available for the given experiment and return that. @@ -195,22 +205,24 @@ def get_stored_variation(self, project_config, experiment, user_profile): Returns: Variation if available. None otherwise. """ - user_id = user_profile.user_id variation_id = user_profile.get_variation_for_experiment(experiment.id) if variation_id: variation = project_config.get_variation_from_id(experiment.key, variation_id) if variation: + message = 'Found a stored decision. User "%s" is in variation "%s" of experiment "%s".'\ + % (user_id, variation.key, experiment.key) self.logger.info( - 'Found a stored decision. User "%s" is in variation "%s" of experiment "%s".' - % (user_id, variation.key, experiment.key) + message ) return variation return None - def get_variation(self, project_config, experiment, user_id, attributes, ignore_user_profile=False): + def get_variation( + self, project_config, experiment, user_id, attributes, ignore_user_profile=False + ): """ Top-level function to help determine variation user should be put in. First, check if experiment is running. @@ -227,23 +239,28 @@ def get_variation(self, project_config, experiment, user_id, attributes, ignore_ ignore_user_profile: True to ignore the user profile lookup. Defaults to False. Returns: - Variation user should see. None if user is not in experiment or experiment is not running. + Variation user should see. None if user is not in experiment or experiment is not running + And an array of log messages representing decision making. """ - + decide_reasons = [] # Check if experiment is running if not experiment_helper.is_experiment_running(experiment): - self.logger.info('Experiment "%s" is not running.' % experiment.key) - return None + message = 'Experiment "%s" is not running.' % experiment.key + self.logger.info(message) + decide_reasons.append(message) + return None, decide_reasons # Check if the user is forced into a variation - variation = self.get_forced_variation(project_config, experiment.key, user_id) + variation, reasons_received = self.get_forced_variation(project_config, experiment.key, user_id) + decide_reasons += reasons_received if variation: - return variation + return variation, decide_reasons # Check to see if user is white-listed for a certain variation - variation = self.get_whitelisted_variation(project_config, experiment, user_id) + variation, reasons_received = self.get_whitelisted_variation(project_config, experiment, user_id) + decide_reasons += reasons_received if variation: - return variation + return variation, decide_reasons # Check to see if user has a decision available for the given experiment user_profile = UserProfile(user_id) @@ -258,28 +275,41 @@ def get_variation(self, project_config, experiment, user_id, attributes, ignore_ user_profile = UserProfile(**retrieved_profile) variation = self.get_stored_variation(project_config, experiment, user_profile) if variation: - return variation + message = 'Returning previously activated variation ID "{}" of experiment ' \ + '"{}" for user "{}" from user profile.'.format(variation, experiment, user_id) + self.logger.info(message) + decide_reasons.append(message) + return variation, decide_reasons else: self.logger.warning('User profile has invalid format.') # Bucket user and store the new decision audience_conditions = experiment.get_audience_conditions_or_ids() - if not audience_helper.does_user_meet_audience_conditions(project_config, audience_conditions, - enums.ExperimentAudienceEvaluationLogs, - experiment.key, - attributes, self.logger): + user_meets_audience_conditions, reasons_received = audience_helper.does_user_meet_audience_conditions( + project_config, audience_conditions, + enums.ExperimentAudienceEvaluationLogs, + experiment.key, + attributes, self.logger) + decide_reasons += reasons_received + if not user_meets_audience_conditions: + message = 'User "{}" does not meet conditions to be in experiment "{}".'.format(user_id, experiment.key) self.logger.info( - 'User "{}" does not meet conditions to be in experiment "{}".'.format(user_id, experiment.key)) - return None + message + ) + decide_reasons.append(message) + return None, decide_reasons # Determine bucketing ID to be used - bucketing_id = self._get_bucketing_id(user_id, attributes) - variation = self.bucketer.bucket(project_config, experiment, user_id, bucketing_id) - + bucketing_id, bucketing_id_reasons = self._get_bucketing_id(user_id, attributes) + decide_reasons += bucketing_id_reasons + variation, bucket_reasons = self.bucketer.bucket(project_config, experiment, user_id, bucketing_id) + decide_reasons += bucket_reasons if variation: + message = 'User "%s" is in variation "%s" of experiment %s.' % (user_id, variation.key, experiment.key) self.logger.info( - 'User "%s" is in variation "%s" of experiment %s.' % (user_id, variation.key, experiment.key) + message ) + decide_reasons.append(message) # Store this new decision and return the variation for the user if not ignore_user_profile and self.user_profile_service: try: @@ -287,14 +317,15 @@ def get_variation(self, project_config, experiment, user_id, attributes, ignore_ self.user_profile_service.save(user_profile.__dict__) except: self.logger.exception('Unable to save user profile for user "{}".'.format(user_id)) - return variation - - self.logger.info('User "%s" is in no variation.' % user_id) - return None + return variation, decide_reasons + message = 'User "%s" is in no variation.' % user_id + self.logger.info(message) + decide_reasons.append(message) + return None, decide_reasons def get_variation_for_rollout(self, project_config, rollout, user_id, attributes=None): """ Determine which experiment/variation the user is in for a given rollout. - Returns the variation of the first experiment the user qualifies for. + Returns the variation of the first experiment the user qualifies for. Args: project_config: Instance of ProjectConfig. @@ -303,9 +334,10 @@ def get_variation_for_rollout(self, project_config, rollout, user_id, attributes attributes: Dict representing user attributes. Returns: - Decision namedtuple consisting of experiment and variation for the user. + Decision namedtuple consisting of experiment and variation for the user and + array of log messages representing decision making. """ - + decide_reasons = [] # Go through each experiment in order and try to get the variation for the user if rollout and len(rollout.experiments) > 0: for idx in range(len(rollout.experiments) - 1): @@ -314,53 +346,72 @@ def get_variation_for_rollout(self, project_config, rollout, user_id, attributes # Check if user meets audience conditions for targeting rule audience_conditions = rollout_rule.get_audience_conditions_or_ids() - if not audience_helper.does_user_meet_audience_conditions(project_config, - audience_conditions, - enums.RolloutRuleAudienceEvaluationLogs, - logging_key, - attributes, - self.logger): + user_meets_audience_conditions, reasons_received = audience_helper.does_user_meet_audience_conditions( + project_config, + audience_conditions, + enums.RolloutRuleAudienceEvaluationLogs, + logging_key, + attributes, + self.logger) + decide_reasons += reasons_received + if not user_meets_audience_conditions: + message = 'User "{}" does not meet conditions for targeting rule {}.'.format(user_id, logging_key) self.logger.debug( - 'User "{}" does not meet conditions for targeting rule {}.'.format(user_id, logging_key)) + message + ) + decide_reasons.append(message) continue - - self.logger.debug( - 'User "{}" meets audience conditions for targeting rule {}.'.format(user_id, idx + 1)) + message = 'User "{}" meets audience conditions for targeting rule {}.'.format(user_id, idx + 1) + self.logger.debug(message) + decide_reasons.append(message) # Determine bucketing ID to be used - bucketing_id = self._get_bucketing_id(user_id, attributes) - variation = self.bucketer.bucket(project_config, rollout_rule, user_id, bucketing_id) + bucketing_id, bucket_reasons = self._get_bucketing_id(user_id, attributes) + decide_reasons += bucket_reasons + variation, reasons = self.bucketer.bucket(project_config, rollout_rule, user_id, bucketing_id) + decide_reasons += reasons if variation: + message = 'User "{}" is in the traffic group of targeting rule {}.'.format(user_id, logging_key) self.logger.debug( - 'User "{}" is in the traffic group of targeting rule {}.'.format(user_id, logging_key) + message ) - return Decision(rollout_rule, variation, enums.DecisionSources.ROLLOUT) + decide_reasons.append(message) + return Decision(rollout_rule, variation, enums.DecisionSources.ROLLOUT), decide_reasons else: + message = 'User "{}" is not in the traffic group for targeting rule {}. ' \ + 'Checking "Everyone Else" rule now.'.format(user_id, logging_key) # Evaluate no further rules self.logger.debug( - 'User "{}" is not in the traffic group for targeting rule {}. ' - 'Checking "Everyone Else" rule now.'.format(user_id, logging_key) + message ) + decide_reasons.append(message) break # Evaluate last rule i.e. "Everyone Else" rule everyone_else_rule = project_config.get_experiment_from_key(rollout.experiments[-1].get('key')) audience_conditions = everyone_else_rule.get_audience_conditions_or_ids() - if audience_helper.does_user_meet_audience_conditions( + audience_eval, audience_reasons = audience_helper.does_user_meet_audience_conditions( project_config, audience_conditions, enums.RolloutRuleAudienceEvaluationLogs, 'Everyone Else', attributes, self.logger - ): + ) + decide_reasons += audience_reasons + if audience_eval: # Determine bucketing ID to be used - bucketing_id = self._get_bucketing_id(user_id, attributes) - variation = self.bucketer.bucket(project_config, everyone_else_rule, user_id, bucketing_id) + bucketing_id, bucket_id_reasons = self._get_bucketing_id(user_id, attributes) + decide_reasons += bucket_id_reasons + variation, bucket_reasons = self.bucketer.bucket( + project_config, everyone_else_rule, user_id, bucketing_id) + decide_reasons += bucket_reasons if variation: - self.logger.debug('User "{}" meets conditions for targeting rule "Everyone Else".'.format(user_id)) - return Decision(everyone_else_rule, variation, enums.DecisionSources.ROLLOUT,) + message = 'User "{}" meets conditions for targeting rule "Everyone Else".'.format(user_id) + self.logger.debug(message) + decide_reasons.append(message) + return Decision(everyone_else_rule, variation, enums.DecisionSources.ROLLOUT,), decide_reasons - return Decision(None, None, enums.DecisionSources.ROLLOUT) + return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons def get_experiment_in_group(self, project_config, group, bucketing_id): """ Determine which experiment in the group the user is bucketed into. @@ -371,26 +422,31 @@ def get_experiment_in_group(self, project_config, group, bucketing_id): bucketing_id: ID to be used for bucketing the user. Returns: - Experiment if the user is bucketed into an experiment in the specified group. None otherwise. + Experiment if the user is bucketed into an experiment in the specified group. None otherwise + and array of log messages representing decision making. """ - - experiment_id = self.bucketer.find_bucket(project_config, bucketing_id, group.id, group.trafficAllocation) + decide_reasons = [] + experiment_id = self.bucketer.find_bucket( + project_config, bucketing_id, group.id, group.trafficAllocation) if experiment_id: experiment = project_config.get_experiment_from_id(experiment_id) if experiment: + message = 'User with bucketing ID "%s" is in experiment %s of group %s.' % \ + (bucketing_id, experiment.key, group.id) self.logger.info( - 'User with bucketing ID "%s" is in experiment %s of group %s.' - % (bucketing_id, experiment.key, group.id) + message ) - return experiment - + decide_reasons.append(message) + return experiment, decide_reasons + message = 'User with bucketing ID "%s" is not in any experiments of group %s.' % (bucketing_id, group.id) self.logger.info( - 'User with bucketing ID "%s" is not in any experiments of group %s.' % (bucketing_id, group.id) + message ) + decide_reasons.append(message) - return None + return None, decide_reasons - def get_variation_for_feature(self, project_config, feature, user_id, attributes=None): + def get_variation_for_feature(self, project_config, feature, user_id, attributes=None, ignore_user_profile=False): """ Returns the experiment/variation the user is bucketed in for the given feature. Args: @@ -398,23 +454,26 @@ def get_variation_for_feature(self, project_config, feature, user_id, attributes feature: Feature for which we are determining if it is enabled or not for the given user. user_id: ID for user. attributes: Dict representing user attributes. + ignore_user_profile: True if we should bypass the user profile service Returns: Decision namedtuple consisting of experiment and variation for the user. """ - - bucketing_id = self._get_bucketing_id(user_id, attributes) - + decide_reasons = [] + bucketing_id, reasons = self._get_bucketing_id(user_id, attributes) + decide_reasons += reasons # First check if the feature is in a mutex group if feature.groupId: group = project_config.get_group(feature.groupId) if group: - experiment = self.get_experiment_in_group(project_config, group, bucketing_id) + experiment, reasons = self.get_experiment_in_group(project_config, group, bucketing_id) + decide_reasons += reasons if experiment and experiment.id in feature.experimentIds: - variation = self.get_variation(project_config, experiment, user_id, attributes) - + variation, variation_reasons = self.get_variation( + project_config, experiment, user_id, attributes, ignore_user_profile) + decide_reasons += variation_reasons if variation: - return Decision(experiment, variation, enums.DecisionSources.FEATURE_TEST) + return Decision(experiment, variation, enums.DecisionSources.FEATURE_TEST), decide_reasons else: self.logger.error(enums.Errors.INVALID_GROUP_ID.format('_get_variation_for_feature')) @@ -423,14 +482,15 @@ def get_variation_for_feature(self, project_config, feature, user_id, attributes # If an experiment is not in a group, then the feature can only be associated with one experiment experiment = project_config.get_experiment_from_id(feature.experimentIds[0]) if experiment: - variation = self.get_variation(project_config, experiment, user_id, attributes) - + variation, variation_reasons = self.get_variation( + project_config, experiment, user_id, attributes, ignore_user_profile) + decide_reasons += variation_reasons if variation: - return Decision(experiment, variation, enums.DecisionSources.FEATURE_TEST) + return Decision(experiment, variation, enums.DecisionSources.FEATURE_TEST), decide_reasons # Next check if user is part of a rollout if feature.rolloutId: rollout = project_config.get_rollout_from_id(feature.rolloutId) return self.get_variation_for_rollout(project_config, rollout, user_id, attributes) else: - return Decision(None, None, enums.DecisionSources.ROLLOUT) + return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons diff --git a/optimizely/entities.py b/optimizely/entities.py index c182c4da..88cd49c4 100644 --- a/optimizely/entities.py +++ b/optimizely/entities.py @@ -1,4 +1,4 @@ -# Copyright 2016-2020, Optimizely +# Copyright 2016-2021, Optimizely # 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 @@ -71,6 +71,9 @@ def get_audience_conditions_or_ids(self): """ Returns audienceConditions if present, otherwise audienceIds. """ return self.audienceConditions if self.audienceConditions is not None else self.audienceIds + def __str__(self): + return self.key + class FeatureFlag(BaseEntity): def __init__(self, id, key, experimentIds, rolloutId, variables, groupId=None, **kwargs): @@ -122,3 +125,6 @@ def __init__(self, id, key, featureEnabled=False, variables=None, **kwargs): self.key = key self.featureEnabled = featureEnabled self.variables = variables or [] + + def __str__(self): + return self.key diff --git a/optimizely/helpers/audience.py b/optimizely/helpers/audience.py index 857d20ef..e9914c66 100644 --- a/optimizely/helpers/audience.py +++ b/optimizely/helpers/audience.py @@ -1,4 +1,4 @@ -# Copyright 2016, 2018-2020, Optimizely +# Copyright 2016, 2018-2021, Optimizely # 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 @@ -35,15 +35,21 @@ def does_user_meet_audience_conditions(config, logger: Provides a logger to send log messages to. Returns: - Boolean representing if user satisfies audience conditions for any of the audiences or not. + Boolean representing if user satisfies audience conditions for any of the audiences or not + And an array of log messages representing decision making. """ - logger.debug(audience_logs.EVALUATING_AUDIENCES_COMBINED.format(logging_key, json.dumps(audience_conditions))) + decide_reasons = [] + message = audience_logs.EVALUATING_AUDIENCES_COMBINED.format(logging_key, json.dumps(audience_conditions)) + logger.debug(message) + decide_reasons.append(message) # Return True in case there are no audiences if audience_conditions is None or audience_conditions == []: - logger.info(audience_logs.AUDIENCE_EVALUATION_RESULT_COMBINED.format(logging_key, 'TRUE')) + message = audience_logs.AUDIENCE_EVALUATION_RESULT_COMBINED.format(logging_key, 'TRUE') + logger.info(message) + decide_reasons.append(message) - return True + return True, decide_reasons if attributes is None: attributes = {} @@ -61,19 +67,22 @@ def evaluate_audience(audience_id): if audience is None: return None - - logger.debug(audience_logs.EVALUATING_AUDIENCE.format(audience_id, audience.conditions)) + _message = audience_logs.EVALUATING_AUDIENCE.format(audience_id, audience.conditions) + logger.debug(_message) result = condition_tree_evaluator.evaluate( audience.conditionStructure, lambda index: evaluate_custom_attr(audience_id, index), ) result_str = str(result).upper() if result is not None else 'UNKNOWN' - logger.debug(audience_logs.AUDIENCE_EVALUATION_RESULT.format(audience_id, result_str)) + _message = audience_logs.AUDIENCE_EVALUATION_RESULT.format(audience_id, result_str) + logger.debug(_message) return result eval_result = condition_tree_evaluator.evaluate(audience_conditions, evaluate_audience) eval_result = eval_result or False - logger.info(audience_logs.AUDIENCE_EVALUATION_RESULT_COMBINED.format(logging_key, str(eval_result).upper())) - return eval_result + message = audience_logs.AUDIENCE_EVALUATION_RESULT_COMBINED.format(logging_key, str(eval_result).upper()) + logger.info(message) + decide_reasons.append(message) + return eval_result, decide_reasons diff --git a/optimizely/helpers/enums.py b/optimizely/helpers/enums.py index 5685f9c8..8339eee6 100644 --- a/optimizely/helpers/enums.py +++ b/optimizely/helpers/enums.py @@ -1,4 +1,4 @@ -# Copyright 2016-2020, Optimizely +# Copyright 2016-2021, Optimizely # 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 @@ -82,10 +82,11 @@ class DatafileVersions(object): class DecisionNotificationTypes(object): AB_TEST = 'ab-test' + ALL_FEATURE_VARIABLES = 'all-feature-variables' FEATURE = 'feature' FEATURE_TEST = 'feature-test' FEATURE_VARIABLE = 'feature-variable' - ALL_FEATURE_VARIABLES = 'all-feature-variables' + FLAG = 'flag' class DecisionSources(object): diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 74bde6a2..1383674a 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -1,4 +1,4 @@ -# Copyright 2016-2020, Optimizely +# Copyright 2016-2021, Optimizely # 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 @@ -10,6 +10,7 @@ # 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. + from six import string_types from . import decision_service @@ -20,31 +21,37 @@ from .config_manager import AuthDatafilePollingConfigManager from .config_manager import PollingConfigManager from .config_manager import StaticConfigManager +from .decision.optimizely_decide_option import OptimizelyDecideOption +from .decision.optimizely_decision import OptimizelyDecision +from .decision.optimizely_decision_message import OptimizelyDecisionMessage from .error_handler import NoOpErrorHandler as noop_error_handler from .event import event_factory, user_event_factory from .event.event_processor import ForwardingEventProcessor from .event_dispatcher import EventDispatcher as default_event_dispatcher from .helpers import enums, validator +from .helpers.enums import DecisionSources from .notification_center import NotificationCenter from .optimizely_config import OptimizelyConfigService +from .optimizely_user_context import OptimizelyUserContext class Optimizely(object): """ Class encapsulating all SDK functionality. """ def __init__( - self, - datafile=None, - event_dispatcher=None, - logger=None, - error_handler=None, - skip_json_validation=False, - user_profile_service=None, - sdk_key=None, - config_manager=None, - notification_center=None, - event_processor=None, - datafile_access_token=None, + self, + datafile=None, + event_dispatcher=None, + logger=None, + error_handler=None, + skip_json_validation=False, + user_profile_service=None, + sdk_key=None, + config_manager=None, + notification_center=None, + event_processor=None, + datafile_access_token=None, + default_decide_options=None ): """ Optimizely init method for managing Custom projects. @@ -68,6 +75,7 @@ def __init__( which simply forwards events to the event dispatcher. To enable event batching configure and use optimizely.event.event_processor.BatchEventProcessor. datafile_access_token: Optional string used to fetch authenticated datafile for a secure project environment. + default_decide_options: Optional list of decide options used with the decide APIs. """ self.logger_name = '.'.join([__name__, self.__class__.__name__]) self.is_valid = True @@ -80,6 +88,17 @@ def __init__( self.event_dispatcher, logger=self.logger, notification_center=self.notification_center, ) + if default_decide_options is None: + self.default_decide_options = [] + else: + self.default_decide_options = default_decide_options + + if isinstance(self.default_decide_options, list): + self.default_decide_options = self.default_decide_options[:] + else: + self.logger.debug('Provided default decide options is not a list.') + self.default_decide_options = [] + try: self._validate_instantiation_options() except exceptions.InvalidInputException as error: @@ -192,7 +211,7 @@ def _send_impression_event(self, project_config, experiment, variation, flag_key ) def _get_feature_variable_for_type( - self, project_config, feature_key, variable_key, variable_type, user_id, attributes, + self, project_config, feature_key, variable_key, variable_type, user_id, attributes ): """ Helper method to determine value for a certain variable attached to a feature flag based on type of variable. @@ -245,7 +264,7 @@ def _get_feature_variable_for_type( feature_enabled = False source_info = {} variable_value = variable.defaultValue - decision = self.decision_service.get_variation_for_feature(project_config, feature_flag, user_id, attributes) + decision, _ = self.decision_service.get_variation_for_feature(project_config, feature_flag, user_id, attributes) if decision.variation: feature_enabled = decision.variation.featureEnabled @@ -328,7 +347,8 @@ def _get_all_feature_variables_for_type( feature_enabled = False source_info = {} - decision = self.decision_service.get_variation_for_feature(project_config, feature_flag, user_id, attributes) + decision, _ = self.decision_service.get_variation_for_feature( + project_config, feature_flag, user_id, attributes) if decision.variation: feature_enabled = decision.variation.featureEnabled @@ -520,7 +540,7 @@ def get_variation(self, experiment_key, user_id, attributes=None): if not self._validate_user_inputs(attributes): return None - variation = self.decision_service.get_variation(project_config, experiment, user_id, attributes) + variation, _ = self.decision_service.get_variation(project_config, experiment, user_id, attributes) if variation: variation_key = variation.key @@ -577,7 +597,7 @@ def is_feature_enabled(self, feature_key, user_id, attributes=None): feature_enabled = False source_info = {} - decision = self.decision_service.get_variation_for_feature(project_config, feature, user_id, attributes) + decision, _ = self.decision_service.get_variation_for_feature(project_config, feature, user_id, attributes) is_source_experiment = decision.source == enums.DecisionSources.FEATURE_TEST is_source_rollout = decision.source == enums.DecisionSources.ROLLOUT @@ -889,7 +909,7 @@ def get_forced_variation(self, experiment_key, user_id): self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('get_forced_variation')) return None - forced_variation = self.decision_service.get_forced_variation(project_config, experiment_key, user_id) + forced_variation, _ = self.decision_service.get_forced_variation(project_config, experiment_key, user_id) return forced_variation.key if forced_variation else None def get_optimizely_config(self): @@ -913,3 +933,229 @@ def get_optimizely_config(self): return self.config_manager.optimizely_config return OptimizelyConfigService(project_config).get_config() + + def create_user_context(self, user_id, attributes=None): + """ + We do not check for is_valid here as a user context can be created successfully + even when the SDK is not fully configured. + + Args: + user_id: string to use as user id for user context + attributes: dictionary of attributes or None + + Returns: + UserContext instance or None if the user id or attributes are invalid. + """ + if not isinstance(user_id, string_types): + self.logger.error(enums.Errors.INVALID_INPUT.format('user_id')) + return None + + if attributes is not None and type(attributes) is not dict: + self.logger.error(enums.Errors.INVALID_INPUT.format('attributes')) + return None + + return OptimizelyUserContext(self, user_id, attributes) + + def _decide(self, user_context, key, decide_options=None): + """ + decide calls optimizely decide with feature key provided + Args: + user_context: UserContent with userid and attributes + key: feature key + decide_options: list of OptimizelyDecideOption + + Returns: + Decision object + """ + + # raising on user context as it is internal and not provided directly by the user. + if not isinstance(user_context, OptimizelyUserContext): + raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT.format('user_context')) + + reasons = [] + + # check if SDK is ready + if not self.is_valid: + self.logger.error(enums.Errors.INVALID_OPTIMIZELY.format('decide')) + reasons.append(OptimizelyDecisionMessage.SDK_NOT_READY) + return OptimizelyDecision(flag_key=key, user_context=user_context, reasons=reasons) + + # validate that key is a string + if not isinstance(key, string_types): + self.logger.error('Key parameter is invalid') + reasons.append(OptimizelyDecisionMessage.FLAG_KEY_INVALID.format(key)) + return OptimizelyDecision(flag_key=key, user_context=user_context, reasons=reasons) + + # validate that key maps to a feature flag + config = self.config_manager.get_config() + if not config: + self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('decide')) + reasons.append(OptimizelyDecisionMessage.SDK_NOT_READY) + return OptimizelyDecision(flag_key=key, user_context=user_context, reasons=reasons) + + feature_flag = config.get_feature_from_key(key) + if feature_flag is None: + self.logger.error("No feature flag was found for key '#{key}'.") + reasons.append(OptimizelyDecisionMessage.FLAG_KEY_INVALID.format(key)) + return OptimizelyDecision(flag_key=key, user_context=user_context, reasons=reasons) + + # merge decide_options and default_decide_options + if isinstance(decide_options, list): + decide_options += self.default_decide_options + else: + self.logger.debug('Provided decide options is not an array. Using default decide options.') + decide_options = self.default_decide_options + + # Create Optimizely Decision Result. + user_id = user_context.user_id + attributes = user_context.get_user_attributes() + variation_key = None + variation = None + feature_enabled = False + rule_key = None + flag_key = key + all_variables = {} + experiment = None + decision_source = DecisionSources.ROLLOUT + source_info = {} + decision_event_dispatched = False + ignore_ups = OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE in decide_options + + decision, decision_reasons = self.decision_service.get_variation_for_feature(config, feature_flag, user_id, + attributes, ignore_ups) + + reasons += decision_reasons + + # Fill in experiment and variation if returned (rollouts can have featureEnabled variables as well.) + if decision.experiment is not None: + experiment = decision.experiment + source_info["experiment"] = experiment + rule_key = experiment.key + if decision.variation is not None: + variation = decision.variation + variation_key = variation.key + feature_enabled = variation.featureEnabled + decision_source = decision.source + source_info["variation"] = variation + + # Send impression event if Decision came from a feature + # test and decide options doesn't include disableDecisionEvent + if OptimizelyDecideOption.DISABLE_DECISION_EVENT not in decide_options: + if decision_source == DecisionSources.FEATURE_TEST or config.send_flag_decisions: + self._send_impression_event(config, experiment, variation, flag_key, rule_key or '', + decision_source, feature_enabled, + user_id, attributes) + decision_event_dispatched = True + + # Generate all variables map if decide options doesn't include excludeVariables + if OptimizelyDecideOption.EXCLUDE_VARIABLES not in decide_options: + for variable_key in feature_flag.variables: + variable = config.get_variable_for_feature(flag_key, variable_key) + variable_value = variable.defaultValue + if feature_enabled: + variable_value = config.get_variable_value_for_variation(variable, decision.variation) + self.logger.debug( + 'Got variable value "%s" for variable "%s" of feature flag "%s".' + % (variable_value, variable_key, flag_key) + ) + + try: + actual_value = config.get_typecast_value(variable_value, variable.type) + except: + self.logger.error('Unable to cast value. Returning None.') + actual_value = None + + all_variables[variable_key] = actual_value + + should_include_reasons = OptimizelyDecideOption.INCLUDE_REASONS in decide_options + + # Send notification + self.notification_center.send_notifications( + enums.NotificationTypes.DECISION, + enums.DecisionNotificationTypes.FLAG, + user_id, + attributes or {}, + { + 'flag_key': flag_key, + 'enabled': feature_enabled, + 'variables': all_variables, + 'variation_key': variation_key, + 'rule_key': rule_key, + 'reasons': reasons if should_include_reasons else [], + 'decision_event_dispatched': decision_event_dispatched + + }, + ) + + return OptimizelyDecision(variation_key=variation_key, enabled=feature_enabled, variables=all_variables, + rule_key=rule_key, flag_key=flag_key, + user_context=user_context, reasons=reasons if should_include_reasons else [] + ) + + def _decide_all(self, user_context, decide_options=None): + """ + decide_all will return a decision for every feature key in the current config + Args: + user_context: UserContent object + decide_options: Array of DecisionOption + + Returns: + A dictionary of feature key to Decision + """ + # raising on user context as it is internal and not provided directly by the user. + if not isinstance(user_context, OptimizelyUserContext): + raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT.format('user_context')) + + # check if SDK is ready + if not self.is_valid: + self.logger.error(enums.Errors.INVALID_OPTIMIZELY.format('decide_all')) + return {} + + config = self.config_manager.get_config() + if not config: + self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('decide')) + return {} + + keys = [] + for f in config.feature_flags: + keys.append(f['key']) + return self._decide_for_keys(user_context, keys, decide_options) + + def _decide_for_keys(self, user_context, keys, decide_options=None): + """ + + Args: + user_context: UserContent + keys: list of feature keys to run decide on. + decide_options: an array of DecisionOption objects + + Returns: + An dictionary of feature key to Decision + """ + # raising on user context as it is internal and not provided directly by the user. + if not isinstance(user_context, OptimizelyUserContext): + raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT.format('user_context')) + + # check if SDK is ready + if not self.is_valid: + self.logger.error(enums.Errors.INVALID_OPTIMIZELY.format('decide_for_keys')) + return {} + + # merge decide_options and default_decide_options + merged_decide_options = [] + if isinstance(decide_options, list): + merged_decide_options = decide_options[:] + merged_decide_options += self.default_decide_options + else: + self.logger.debug('Provided decide options is not an array. Using default decide options.') + merged_decide_options = self.default_decide_options + + enabled_flags_only = OptimizelyDecideOption.ENABLED_FLAGS_ONLY in merged_decide_options + + decisions = {} + for key in keys: + decision = self._decide(user_context, key, decide_options) + if enabled_flags_only and not decision.enabled: + continue + decisions[key] = decision + return decisions diff --git a/optimizely/optimizely_user_context.py b/optimizely/optimizely_user_context.py new file mode 100644 index 00000000..9416f65d --- /dev/null +++ b/optimizely/optimizely_user_context.py @@ -0,0 +1,116 @@ +# Copyright 2021, 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. +# + +import threading + + +class OptimizelyUserContext(object): + """ + Representation of an Optimizely User Context using which APIs are to be called. + """ + + def __init__(self, optimizely_client, user_id, user_attributes=None): + """ Create an instance of the Optimizely User Context. + + Args: + optimizely_client: client used when calling decisions for this user context + user_id: user id of this user context + user_attributes: user attributes to use for this user context + + Returns: + UserContext instance + """ + + self.client = optimizely_client + self.user_id = user_id + + if not isinstance(user_attributes, dict): + user_attributes = {} + + self._user_attributes = user_attributes.copy() if user_attributes else {} + self.lock = threading.Lock() + + def _clone(self): + return OptimizelyUserContext(self.client, self.user_id, self.get_user_attributes()) + + def get_user_attributes(self): + with self.lock: + return self._user_attributes.copy() + + def set_attribute(self, attribute_key, attribute_value): + """ + sets a attribute by key for this user context. + Args: + attribute_key: key to use for attribute + attribute_value: attribute value + + Returns: + None + """ + with self.lock: + self._user_attributes[attribute_key] = attribute_value + + def decide(self, key, options=None): + """ + Call decide on contained Optimizely object + Args: + key: feature key + options: array of DecisionOption + + Returns: + Decision object + """ + if isinstance(options, list): + options = options[:] + + return self.client._decide(self._clone(), key, options) + + def decide_for_keys(self, keys, options=None): + """ + Call decide_for_keys on contained optimizely object + Args: + keys: array of feature keys + options: array of DecisionOption + + Returns: + Dictionary with feature_key keys and Decision object values + """ + if isinstance(options, list): + options = options[:] + + return self.client._decide_for_keys(self._clone(), keys, options) + + def decide_all(self, options=None): + """ + Call decide_all on contained optimizely instance + Args: + options: Array of DecisionOption objects + + Returns: + Dictionary with feature_key keys and Decision object values + """ + if isinstance(options, list): + options = options[:] + + return self.client._decide_all(self._clone(), options) + + def track_event(self, event_key, event_tags=None): + return self.client.track(event_key, self.user_id, self.get_user_attributes(), event_tags) + + def as_json(self): + return { + 'user_id': self.user_id, + 'attributes': self.get_user_attributes(), + } diff --git a/tests/base.py b/tests/base.py index 88d5b73f..254be7c5 100644 --- a/tests/base.py +++ b/tests/base.py @@ -135,7 +135,7 @@ def setUp(self, config_dict='config_dict'): { 'key': 'test_experiment', 'status': 'Running', - 'forcedVariations': {}, + 'forcedVariations': {'user_1': 'control'}, 'layerId': '111182', 'audienceIds': [], 'trafficAllocation': [ diff --git a/tests/helpers_tests/test_audience.py b/tests/helpers_tests/test_audience.py index 95311887..719705d6 100644 --- a/tests/helpers_tests/test_audience.py +++ b/tests/helpers_tests/test_audience.py @@ -1,4 +1,4 @@ -# Copyright 2016-2020, Optimizely +# Copyright 2016-2021, Optimizely # 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 @@ -34,47 +34,48 @@ def test_does_user_meet_audience_conditions__no_audience(self): experiment = self.project_config.get_experiment_from_key('test_experiment') experiment.audienceIds = [] experiment.audienceConditions = [] + user_meets_audience_conditions, _ = audience.does_user_meet_audience_conditions( + self.project_config, + experiment.get_audience_conditions_or_ids(), + enums.ExperimentAudienceEvaluationLogs, + 'test_experiment', + user_attributes, + self.mock_client_logger + ) self.assertStrictTrue( - audience.does_user_meet_audience_conditions( - self.project_config, - experiment.get_audience_conditions_or_ids(), - enums.ExperimentAudienceEvaluationLogs, - 'test_experiment', - user_attributes, - self.mock_client_logger - ) + user_meets_audience_conditions ) # Audience Ids exist but Audience Conditions is Empty experiment = self.project_config.get_experiment_from_key('test_experiment') experiment.audienceIds = ['11154'] experiment.audienceConditions = [] + user_meets_audience_conditions, _ = audience.does_user_meet_audience_conditions( + self.project_config, + experiment.get_audience_conditions_or_ids(), + enums.ExperimentAudienceEvaluationLogs, + 'test_experiment', + user_attributes, + self.mock_client_logger + ) self.assertStrictTrue( - audience.does_user_meet_audience_conditions( - self.project_config, - experiment.get_audience_conditions_or_ids(), - enums.ExperimentAudienceEvaluationLogs, - 'test_experiment', - user_attributes, - self.mock_client_logger - ) - + user_meets_audience_conditions ) # Audience Ids is Empty and Audience Conditions is None experiment = self.project_config.get_experiment_from_key('test_experiment') experiment.audienceIds = [] experiment.audienceConditions = None + user_meets_audience_conditions, _ = audience.does_user_meet_audience_conditions( + self.project_config, + experiment.get_audience_conditions_or_ids(), + enums.ExperimentAudienceEvaluationLogs, + 'test_experiment', + user_attributes, + self.mock_client_logger + ) self.assertStrictTrue( - audience.does_user_meet_audience_conditions( - self.project_config, - experiment.get_audience_conditions_or_ids(), - enums.ExperimentAudienceEvaluationLogs, - 'test_experiment', - user_attributes, - self.mock_client_logger - ) - + user_meets_audience_conditions ) def test_does_user_meet_audience_conditions__with_audience(self): @@ -160,16 +161,16 @@ def test_does_user_meet_audience_conditions__returns_true__when_condition_tree_e user_attributes = {'test_attribute': 'test_value_1'} experiment = self.project_config.get_experiment_from_key('test_experiment') with mock.patch('optimizely.helpers.condition_tree_evaluator.evaluate', return_value=True): - + user_meets_audience_conditions, _ = audience.does_user_meet_audience_conditions( + self.project_config, + experiment.get_audience_conditions_or_ids(), + enums.ExperimentAudienceEvaluationLogs, + 'test_experiment', + user_attributes, + self.mock_client_logger + ) self.assertStrictTrue( - audience.does_user_meet_audience_conditions( - self.project_config, - experiment.get_audience_conditions_or_ids(), - enums.ExperimentAudienceEvaluationLogs, - 'test_experiment', - user_attributes, - self.mock_client_logger - ) + user_meets_audience_conditions ) def test_does_user_meet_audience_conditions_returns_false_when_condition_tree_evaluator_returns_none_or_false(self): @@ -179,29 +180,29 @@ def test_does_user_meet_audience_conditions_returns_false_when_condition_tree_ev user_attributes = {'test_attribute': 'test_value_1'} experiment = self.project_config.get_experiment_from_key('test_experiment') with mock.patch('optimizely.helpers.condition_tree_evaluator.evaluate', return_value=None): - + user_meets_audience_conditions, _ = audience.does_user_meet_audience_conditions( + self.project_config, + experiment.get_audience_conditions_or_ids(), + enums.ExperimentAudienceEvaluationLogs, + 'test_experiment', + user_attributes, + self.mock_client_logger + ) self.assertStrictFalse( - audience.does_user_meet_audience_conditions( - self.project_config, - experiment.get_audience_conditions_or_ids(), - enums.ExperimentAudienceEvaluationLogs, - 'test_experiment', - user_attributes, - self.mock_client_logger - ) + user_meets_audience_conditions ) with mock.patch('optimizely.helpers.condition_tree_evaluator.evaluate', return_value=False): - + user_meets_audience_conditions, _ = audience.does_user_meet_audience_conditions( + self.project_config, + experiment.get_audience_conditions_or_ids(), + enums.ExperimentAudienceEvaluationLogs, + 'test_experiment', + user_attributes, + self.mock_client_logger + ) self.assertStrictFalse( - audience.does_user_meet_audience_conditions( - self.project_config, - experiment.get_audience_conditions_or_ids(), - enums.ExperimentAudienceEvaluationLogs, - 'test_experiment', - user_attributes, - self.mock_client_logger - ) + user_meets_audience_conditions ) def test_does_user_meet_audience_conditions__evaluates_audience_ids(self): diff --git a/tests/test_bucketing.py b/tests/test_bucketing.py index f0268b66..fb71ba13 100644 --- a/tests/test_bucketing.py +++ b/tests/test_bucketing.py @@ -1,4 +1,4 @@ -# Copyright 2016-2020, Optimizely +# Copyright 2016-2021, Optimizely # 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 @@ -37,14 +37,15 @@ def test_bucket(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=42 ) as mock_generate_bucket_value: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', + 'test_user', + ) self.assertEqual( entities.Variation('111128', 'control'), - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('test_experiment'), - 'test_user', - 'test_user', - ), + variation, ) mock_generate_bucket_value.assert_called_once_with('test_user111127') @@ -52,13 +53,14 @@ def test_bucket(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=4242 ) as mock_generate_bucket_value: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('test_experiment'), - 'test_user', - 'test_user', - ) + variation ) mock_generate_bucket_value.assert_called_once_with('test_user111127') @@ -66,14 +68,15 @@ def test_bucket(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=5042 ) as mock_generate_bucket_value: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', + 'test_user', + ) self.assertEqual( entities.Variation('111129', 'variation'), - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('test_experiment'), - 'test_user', - 'test_user', - ), + variation, ) mock_generate_bucket_value.assert_called_once_with('test_user111127') @@ -81,26 +84,27 @@ def test_bucket(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=424242 ) as mock_generate_bucket_value: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('test_experiment'), - 'test_user', - 'test_user', - ) + variation ) mock_generate_bucket_value.assert_called_once_with('test_user111127') def test_bucket__invalid_experiment(self): """ Test that bucket returns None for unknown experiment. """ - + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('invalid_experiment'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('invalid_experiment'), - 'test_user', - 'test_user', - ) + variation ) def test_bucket__invalid_group(self): @@ -110,8 +114,8 @@ def test_bucket__invalid_group(self): experiment = project_config.get_experiment_from_key('group_exp_1') # Set invalid group ID for the experiment experiment.groupId = 'invalid_group_id' - - self.assertIsNone(self.bucketer.bucket(self.project_config, experiment, 'test_user', 'test_user')) + variation, _ = self.bucketer.bucket(self.project_config, experiment, 'test_user', 'test_user') + self.assertIsNone(variation) def test_bucket__experiment_in_group(self): """ Test that for provided bucket values correct variation ID is returned. """ @@ -120,14 +124,15 @@ def test_bucket__experiment_in_group(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', side_effect=[42, 4242], ) as mock_generate_bucket_value: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('group_exp_1'), + 'test_user', + 'test_user', + ) self.assertEqual( entities.Variation('28902', 'group_exp_1_variation'), - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('group_exp_1'), - 'test_user', - 'test_user', - ), + variation, ) self.assertEqual( @@ -138,13 +143,14 @@ def test_bucket__experiment_in_group(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', side_effect=[42, 9500], ) as mock_generate_bucket_value: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('group_exp_1'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('group_exp_1'), - 'test_user', - 'test_user', - ) + variation ) self.assertEqual( [mock.call('test_user19228'), mock.call('test_user32222')], mock_generate_bucket_value.call_args_list, @@ -154,13 +160,14 @@ def test_bucket__experiment_in_group(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', side_effect=[42, 4242], ) as mock_generate_bucket_value: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('group_exp_2'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('group_exp_2'), - 'test_user', - 'test_user', - ) + variation ) mock_generate_bucket_value.assert_called_once_with('test_user19228') @@ -168,13 +175,14 @@ def test_bucket__experiment_in_group(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', side_effect=[42, 424242], ) as mock_generate_bucket_value: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('group_exp_1'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('group_exp_1'), - 'test_user', - 'test_user', - ) + variation ) self.assertEqual( [mock.call('test_user19228'), mock.call('test_user32222')], mock_generate_bucket_value.call_args_list, @@ -223,14 +231,15 @@ def test_bucket(self): with mock.patch('optimizely.bucketer.Bucketer._generate_bucket_value', return_value=42), mock.patch.object( self.project_config, 'logger' ) as mock_config_logging: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', + 'test_user', + ) self.assertEqual( entities.Variation('111128', 'control'), - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('test_experiment'), - 'test_user', - 'test_user', - ), + variation, ) mock_config_logging.debug.assert_called_once_with('Assigned bucket 42 to user with bucketing ID "test_user".') @@ -239,13 +248,14 @@ def test_bucket(self): with mock.patch('optimizely.bucketer.Bucketer._generate_bucket_value', return_value=4242), mock.patch.object( self.project_config, 'logger' ) as mock_config_logging: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('test_experiment'), - 'test_user', - 'test_user', - ) + variation ) mock_config_logging.debug.assert_called_once_with('Assigned bucket 4242 to user with bucketing ID "test_user".') @@ -254,14 +264,15 @@ def test_bucket(self): with mock.patch('optimizely.bucketer.Bucketer._generate_bucket_value', return_value=5042), mock.patch.object( self.project_config, 'logger' ) as mock_config_logging: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', + 'test_user', + ) self.assertEqual( entities.Variation('111129', 'variation'), - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('test_experiment'), - 'test_user', - 'test_user', - ), + variation, ) mock_config_logging.debug.assert_called_once_with('Assigned bucket 5042 to user with bucketing ID "test_user".') @@ -270,13 +281,14 @@ def test_bucket(self): with mock.patch('optimizely.bucketer.Bucketer._generate_bucket_value', return_value=424242), mock.patch.object( self.project_config, 'logger' ) as mock_config_logging: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('test_experiment'), - 'test_user', - 'test_user', - ) + variation ) mock_config_logging.debug.assert_called_once_with( @@ -290,14 +302,15 @@ def test_bucket__experiment_in_group(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', side_effect=[42, 4242], ), mock.patch.object(self.project_config, 'logger') as mock_config_logging: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('group_exp_1'), + 'test_user', + 'test_user', + ) self.assertEqual( entities.Variation('28902', 'group_exp_1_variation'), - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('group_exp_1'), - 'test_user', - 'test_user', - ), + variation, ) mock_config_logging.debug.assert_has_calls( [ @@ -315,13 +328,14 @@ def test_bucket__experiment_in_group(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', side_effect=[8400, 9500], ), mock.patch.object(self.project_config, 'logger') as mock_config_logging: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('group_exp_1'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('group_exp_1'), - 'test_user', - 'test_user', - ) + variation ) mock_config_logging.debug.assert_called_once_with('Assigned bucket 8400 to user with bucketing ID "test_user".') mock_config_logging.info.assert_called_once_with('User "test_user" is in no experiment.') @@ -330,13 +344,14 @@ def test_bucket__experiment_in_group(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', side_effect=[42, 9500], ), mock.patch.object(self.project_config, 'logger') as mock_config_logging: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('group_exp_1'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('group_exp_1'), - 'test_user', - 'test_user', - ) + variation ) mock_config_logging.debug.assert_has_calls( [ @@ -354,13 +369,14 @@ def test_bucket__experiment_in_group(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', side_effect=[42, 4242], ), mock.patch.object(self.project_config, 'logger') as mock_config_logging: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('group_exp_2'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('group_exp_2'), - 'test_user', - 'test_user', - ) + variation ) mock_config_logging.debug.assert_called_once_with('Assigned bucket 42 to user with bucketing ID "test_user".') mock_config_logging.info.assert_called_once_with( @@ -371,13 +387,14 @@ def test_bucket__experiment_in_group(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', side_effect=[42, 424242], ), mock.patch.object(self.project_config, 'logger') as mock_config_logging: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('group_exp_1'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('group_exp_1'), - 'test_user', - 'test_user', - ) + variation ) mock_config_logging.debug.assert_has_calls( diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py index 6875a1c0..f4023d0a 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -1,4 +1,4 @@ -# Copyright 2017-2020, Optimizely +# Copyright 2017-2021, Optimizely # 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 @@ -33,16 +33,19 @@ def test_get_bucketing_id__no_bucketing_id_attribute(self): """ Test that _get_bucketing_id returns correct bucketing ID when there is no bucketing ID attribute. """ # No attributes + bucketing_id, _ = self.decision_service._get_bucketing_id("test_user", None) self.assertEqual( - "test_user", self.decision_service._get_bucketing_id("test_user", None) + "test_user", + bucketing_id ) # With attributes, but no bucketing ID + bucketing_id, _ = self.decision_service._get_bucketing_id( + "test_user", {"random_key": "random_value"} + ) self.assertEqual( "test_user", - self.decision_service._get_bucketing_id( - "test_user", {"random_key": "random_value"} - ), + bucketing_id, ) def test_get_bucketing_id__bucketing_id_attribute(self): @@ -50,11 +53,12 @@ def test_get_bucketing_id__bucketing_id_attribute(self): with mock.patch.object( self.decision_service, "logger" ) as mock_decision_service_logging: + bucketing_id, _ = self.decision_service._get_bucketing_id( + "test_user", {"$opt_bucketing_id": "user_bucket_value"} + ) self.assertEqual( "user_bucket_value", - self.decision_service._get_bucketing_id( - "test_user", {"$opt_bucketing_id": "user_bucket_value"} - ), + bucketing_id, ) mock_decision_service_logging.debug.assert_not_called() @@ -63,33 +67,35 @@ def test_get_bucketing_id__bucketing_id_attribute_not_a_string(self): with mock.patch.object( self.decision_service, "logger" ) as mock_decision_service_logging: + bucketing_id, _ = self.decision_service._get_bucketing_id( + "test_user", {"$opt_bucketing_id": True} + ) self.assertEqual( "test_user", - self.decision_service._get_bucketing_id( - "test_user", {"$opt_bucketing_id": True} - ), + bucketing_id, ) mock_decision_service_logging.warning.assert_called_once_with( "Bucketing ID attribute is not a string. Defaulted to user_id." ) mock_decision_service_logging.reset_mock() + bucketing_id, _ = self.decision_service._get_bucketing_id( + "test_user", {"$opt_bucketing_id": 5.9} + ) self.assertEqual( "test_user", - self.decision_service._get_bucketing_id( - "test_user", {"$opt_bucketing_id": 5.9} - ), + bucketing_id, ) mock_decision_service_logging.warning.assert_called_once_with( "Bucketing ID attribute is not a string. Defaulted to user_id." ) mock_decision_service_logging.reset_mock() - + bucketing_id, _ = self.decision_service._get_bucketing_id( + "test_user", {"$opt_bucketing_id": 5} + ) self.assertEqual( "test_user", - self.decision_service._get_bucketing_id( - "test_user", {"$opt_bucketing_id": 5} - ), + bucketing_id, ) mock_decision_service_logging.warning.assert_called_once_with( "Bucketing ID attribute is not a string. Defaulted to user_id." @@ -154,10 +160,11 @@ def test_set_forced_variation__multiple_sets(self): self.project_config, "test_experiment", "test_user_1", "variation" ) ) + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "test_experiment", "test_user_1" + ) self.assertEqual( - self.decision_service.get_forced_variation( - self.project_config, "test_experiment", "test_user_1" - ).key, + variation.key, "variation", ) # same user, same experiment, different variation @@ -166,10 +173,11 @@ def test_set_forced_variation__multiple_sets(self): self.project_config, "test_experiment", "test_user_1", "control" ) ) + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "test_experiment", "test_user_1" + ) self.assertEqual( - self.decision_service.get_forced_variation( - self.project_config, "test_experiment", "test_user_1" - ).key, + variation.key, "control", ) # same user, different experiment @@ -178,10 +186,11 @@ def test_set_forced_variation__multiple_sets(self): self.project_config, "group_exp_1", "test_user_1", "group_exp_1_control" ) ) + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "group_exp_1", "test_user_1" + ) self.assertEqual( - self.decision_service.get_forced_variation( - self.project_config, "group_exp_1", "test_user_1" - ).key, + variation.key, "group_exp_1_control", ) @@ -191,10 +200,11 @@ def test_set_forced_variation__multiple_sets(self): self.project_config, "test_experiment", "test_user_2", "variation" ) ) + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "test_experiment", "test_user_2" + ) self.assertEqual( - self.decision_service.get_forced_variation( - self.project_config, "test_experiment", "test_user_2" - ).key, + variation.key, "variation", ) # different user, different experiment @@ -203,24 +213,27 @@ def test_set_forced_variation__multiple_sets(self): self.project_config, "group_exp_1", "test_user_2", "group_exp_1_control" ) ) + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "group_exp_1", "test_user_2" + ) self.assertEqual( - self.decision_service.get_forced_variation( - self.project_config, "group_exp_1", "test_user_2" - ).key, + variation.key, "group_exp_1_control", ) # make sure the first user forced variations are still valid + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "test_experiment", "test_user_1" + ) self.assertEqual( - self.decision_service.get_forced_variation( - self.project_config, "test_experiment", "test_user_1" - ).key, + variation.key, "control", ) + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "group_exp_1", "test_user_1" + ) self.assertEqual( - self.decision_service.get_forced_variation( - self.project_config, "group_exp_1", "test_user_1" - ).key, + variation.key, "group_exp_1_control", ) @@ -269,15 +282,17 @@ def test_get_forced_variation__invalid_user_id(self): "test_experiment" ] = "test_variation" + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "test_experiment", None + ) self.assertIsNone( - self.decision_service.get_forced_variation( - self.project_config, "test_experiment", None - ) + variation + ) + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "test_experiment", "" ) self.assertIsNone( - self.decision_service.get_forced_variation( - self.project_config, "test_experiment", "" - ) + variation ) def test_get_forced_variation__invalid_experiment_key(self): @@ -286,21 +301,23 @@ def test_get_forced_variation__invalid_experiment_key(self): self.decision_service.forced_variation_map["test_user"][ "test_experiment" ] = "test_variation" - + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "test_experiment_not_in_datafile", "test_user" + ) self.assertIsNone( - self.decision_service.get_forced_variation( - self.project_config, "test_experiment_not_in_datafile", "test_user" - ) + variation + ) + variation, _ = self.decision_service.get_forced_variation( + self.project_config, None, "test_user" ) self.assertIsNone( - self.decision_service.get_forced_variation( - self.project_config, None, "test_user" - ) + variation + ) + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "", "test_user" ) self.assertIsNone( - self.decision_service.get_forced_variation( - self.project_config, "", "test_user" - ) + variation ) def test_get_forced_variation_with_none_set_for_user(self): @@ -311,10 +328,11 @@ def test_get_forced_variation_with_none_set_for_user(self): with mock.patch.object( self.decision_service, "logger" ) as mock_decision_service_logging: + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "test_experiment", "test_user" + ) self.assertIsNone( - self.decision_service.get_forced_variation( - self.project_config, "test_experiment", "test_user" - ) + variation ) mock_decision_service_logging.debug.assert_called_once_with( 'No experiment "test_experiment" mapped to user "test_user" in the forced variation map.' @@ -331,10 +349,11 @@ def test_get_forced_variation_missing_variation_mapped_to_experiment(self): with mock.patch.object( self.decision_service, "logger" ) as mock_decision_service_logging: + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "test_experiment", "test_user" + ) self.assertIsNone( - self.decision_service.get_forced_variation( - self.project_config, "test_experiment", "test_user" - ) + variation ) mock_decision_service_logging.debug.assert_called_once_with( @@ -348,11 +367,12 @@ def test_get_whitelisted_variation__user_in_forced_variation(self): with mock.patch.object( self.decision_service, "logger" ) as mock_decision_service_logging: + variation, _ = self.decision_service.get_whitelisted_variation( + self.project_config, experiment, "user_1" + ) self.assertEqual( entities.Variation("111128", "control"), - self.decision_service.get_whitelisted_variation( - self.project_config, experiment, "user_1" - ), + variation, ) mock_decision_service_logging.info.assert_called_once_with( @@ -367,10 +387,11 @@ def test_get_whitelisted_variation__user_in_invalid_variation(self): "optimizely.project_config.ProjectConfig.get_variation_from_key", return_value=None, ) as mock_get_variation_id: + variation, _ = self.decision_service.get_whitelisted_variation( + self.project_config, experiment, "user_1" + ) self.assertIsNone( - self.decision_service.get_whitelisted_variation( - self.project_config, experiment, "user_1" - ) + variation ) mock_get_variation_id.assert_called_once_with("test_experiment", "control") @@ -385,11 +406,12 @@ def test_get_stored_variation__stored_decision_available(self): with mock.patch.object( self.decision_service, "logger" ) as mock_decision_service_logging: + variation = self.decision_service.get_stored_variation( + self.project_config, experiment, profile + ) self.assertEqual( entities.Variation("111128", "control"), - self.decision_service.get_stored_variation( - self.project_config, experiment, profile - ), + variation, ) mock_decision_service_logging.info.assert_called_once_with( @@ -401,10 +423,11 @@ def test_get_stored_variation__no_stored_decision_available(self): experiment = self.project_config.get_experiment_from_key("test_experiment") profile = user_profile.UserProfile("test_user") + variation = self.decision_service.get_stored_variation( + self.project_config, experiment, profile + ) self.assertIsNone( - self.decision_service.get_stored_variation( - self.project_config, experiment, profile - ) + variation ) def test_get_variation__experiment_not_running(self): @@ -428,10 +451,11 @@ def test_get_variation__experiment_not_running(self): ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: + variation, _ = self.decision_service.get_variation( + self.project_config, experiment, "test_user", None + ) self.assertIsNone( - self.decision_service.get_variation( - self.project_config, experiment, "test_user", None - ) + variation ) mock_decision_service_logging.info.assert_called_once_with( @@ -451,16 +475,17 @@ def test_get_variation__bucketing_id_provided(self): experiment = self.project_config.get_experiment_from_key("test_experiment") with mock.patch( "optimizely.decision_service.DecisionService.get_forced_variation", - return_value=None, + return_value=[None, []], ), mock.patch( "optimizely.decision_service.DecisionService.get_stored_variation", return_value=None, ), mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=True + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[True, []] ), mock.patch( - "optimizely.bucketer.Bucketer.bucket" + "optimizely.bucketer.Bucketer.bucket", + return_value=[self.project_config.get_variation_from_id("211127", "211129"), []], ) as mock_bucket: - self.decision_service.get_variation( + variation, _ = self.decision_service.get_variation( self.project_config, experiment, "test_user", @@ -481,7 +506,7 @@ def test_get_variation__user_whitelisted_for_variation(self): experiment = self.project_config.get_experiment_from_key("test_experiment") with mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", - return_value=entities.Variation("111128", "control"), + return_value=[entities.Variation("111128", "control"), []], ) as mock_get_whitelisted_variation, mock.patch( "optimizely.decision_service.DecisionService.get_stored_variation" ) as mock_get_stored_variation, mock.patch( @@ -493,11 +518,12 @@ def test_get_variation__user_whitelisted_for_variation(self): ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: + variation, _ = self.decision_service.get_variation( + self.project_config, experiment, "test_user", None + ) self.assertEqual( entities.Variation("111128", "control"), - self.decision_service.get_variation( - self.project_config, experiment, "test_user", None - ), + variation, ) # Assert that forced variation is returned and stored decision or bucketing service are not involved @@ -516,7 +542,7 @@ def test_get_variation__user_has_stored_decision(self): experiment = self.project_config.get_experiment_from_key("test_experiment") with mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", - return_value=None, + return_value=[None, []], ) as mock_get_whitelisted_variation, mock.patch( "optimizely.decision_service.DecisionService.get_stored_variation", return_value=entities.Variation("111128", "control"), @@ -533,11 +559,12 @@ def test_get_variation__user_has_stored_decision(self): ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: + variation, _ = self.decision_service.get_variation( + self.project_config, experiment, "test_user", None + ) self.assertEqual( entities.Variation("111128", "control"), - self.decision_service.get_variation( - self.project_config, experiment, "test_user", None - ), + variation, ) # Assert that stored variation is returned and bucketing service is not involved @@ -567,26 +594,27 @@ def test_get_variation__user_bucketed_for_new_experiment__user_profile_service_a self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", - return_value=None, + return_value=[None, []], ) as mock_get_whitelisted_variation, mock.patch( "optimizely.decision_service.DecisionService.get_stored_variation", return_value=None, ) as mock_get_stored_variation, mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=True + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[True, []] ) as mock_audience_check, mock.patch( "optimizely.bucketer.Bucketer.bucket", - return_value=entities.Variation("111129", "variation"), + return_value=[entities.Variation("111129", "variation"), []], ) as mock_bucket, mock.patch( "optimizely.user_profile.UserProfileService.lookup", return_value={"user_id": "test_user", "experiment_bucket_map": {}}, ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: + variation, _ = self.decision_service.get_variation( + self.project_config, experiment, "test_user", None + ) self.assertEqual( entities.Variation("111129", "variation"), - self.decision_service.get_variation( - self.project_config, experiment, "test_user", None - ), + variation, ) # Assert that user is bucketed and new decision is stored @@ -627,24 +655,25 @@ def test_get_variation__user_bucketed_for_new_experiment__user_profile_service_n self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", - return_value=None, + return_value=[None, []], ) as mock_get_whitelisted_variation, mock.patch( "optimizely.decision_service.DecisionService.get_stored_variation" ) as mock_get_stored_variation, mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=True + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[True, []] ) as mock_audience_check, mock.patch( "optimizely.bucketer.Bucketer.bucket", - return_value=entities.Variation("111129", "variation"), + return_value=[entities.Variation("111129", "variation"), []], ) as mock_bucket, mock.patch( "optimizely.user_profile.UserProfileService.lookup" ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: + variation, _ = self.decision_service.get_variation( + self.project_config, experiment, "test_user", None + ) self.assertEqual( entities.Variation("111129", "variation"), - self.decision_service.get_variation( - self.project_config, experiment, "test_user", None - ), + variation, ) # Assert that user is bucketed and new decision is not stored as user profile service is not available @@ -674,12 +703,12 @@ def test_get_variation__user_does_not_meet_audience_conditions(self): self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", - return_value=None, + return_value=[None, []], ) as mock_get_whitelisted_variation, mock.patch( "optimizely.decision_service.DecisionService.get_stored_variation", return_value=None, ) as mock_get_stored_variation, mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=False + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[False, []] ) as mock_audience_check, mock.patch( "optimizely.bucketer.Bucketer.bucket" ) as mock_bucket, mock.patch( @@ -688,10 +717,11 @@ def test_get_variation__user_does_not_meet_audience_conditions(self): ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: + variation, _ = self.decision_service.get_variation( + self.project_config, experiment, "test_user", None + ) self.assertIsNone( - self.decision_service.get_variation( - self.project_config, experiment, "test_user", None - ) + variation ) # Assert that user is bucketed and new decision is stored @@ -721,25 +751,26 @@ def test_get_variation__user_profile_in_invalid_format(self): self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", - return_value=None, + return_value=[None, []], ) as mock_get_whitelisted_variation, mock.patch( "optimizely.decision_service.DecisionService.get_stored_variation" ) as mock_get_stored_variation, mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=True + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[True, []] ) as mock_audience_check, mock.patch( "optimizely.bucketer.Bucketer.bucket", - return_value=entities.Variation("111129", "variation"), + return_value=[entities.Variation("111129", "variation"), []], ) as mock_bucket, mock.patch( "optimizely.user_profile.UserProfileService.lookup", return_value="invalid_profile", ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: + variation, _ = self.decision_service.get_variation( + self.project_config, experiment, "test_user", None + ) self.assertEqual( entities.Variation("111129", "variation"), - self.decision_service.get_variation( - self.project_config, experiment, "test_user", None - ), + variation, ) # Assert that user is bucketed and new decision is stored @@ -778,25 +809,26 @@ def test_get_variation__user_profile_lookup_fails(self): self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", - return_value=None, + return_value=[None, []], ) as mock_get_whitelisted_variation, mock.patch( "optimizely.decision_service.DecisionService.get_stored_variation" ) as mock_get_stored_variation, mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=True + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[True, []] ) as mock_audience_check, mock.patch( "optimizely.bucketer.Bucketer.bucket", - return_value=entities.Variation("111129", "variation"), + return_value=[entities.Variation("111129", "variation"), []], ) as mock_bucket, mock.patch( "optimizely.user_profile.UserProfileService.lookup", side_effect=Exception("major problem"), ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: + variation, _ = self.decision_service.get_variation( + self.project_config, experiment, "test_user", None + ) self.assertEqual( entities.Variation("111129", "variation"), - self.decision_service.get_variation( - self.project_config, experiment, "test_user", None - ), + variation, ) # Assert that user is bucketed and new decision is stored @@ -835,25 +867,26 @@ def test_get_variation__user_profile_save_fails(self): self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", - return_value=None, + return_value=[None, []], ) as mock_get_whitelisted_variation, mock.patch( "optimizely.decision_service.DecisionService.get_stored_variation" ) as mock_get_stored_variation, mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=True + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[True, []] ) as mock_audience_check, mock.patch( "optimizely.bucketer.Bucketer.bucket", - return_value=entities.Variation("111129", "variation"), + return_value=[entities.Variation("111129", "variation"), []], ) as mock_bucket, mock.patch( "optimizely.user_profile.UserProfileService.lookup", return_value=None ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save", side_effect=Exception("major problem"), ) as mock_save: + variation, _ = self.decision_service.get_variation( + self.project_config, experiment, "test_user", None + ) self.assertEqual( entities.Variation("111129", "variation"), - self.decision_service.get_variation( - self.project_config, experiment, "test_user", None - ), + variation, ) # Assert that user is bucketed and new decision is stored @@ -891,26 +924,27 @@ def test_get_variation__ignore_user_profile_when_specified(self): self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", - return_value=None, + return_value=[None, []], ) as mock_get_whitelisted_variation, mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=True + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[True, []] ) as mock_audience_check, mock.patch( "optimizely.bucketer.Bucketer.bucket", - return_value=entities.Variation("111129", "variation"), + return_value=[entities.Variation("111129", "variation"), []], ) as mock_bucket, mock.patch( "optimizely.user_profile.UserProfileService.lookup" ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: + variation, _ = self.decision_service.get_variation( + self.project_config, + experiment, + "test_user", + None, + ignore_user_profile=True, + ) self.assertEqual( entities.Variation("111129", "variation"), - self.decision_service.get_variation( - self.project_config, - experiment, - "test_user", - None, - ignore_user_profile=True, - ), + variation, ) # Assert that user is bucketed and new decision is NOT stored @@ -946,11 +980,12 @@ def test_get_variation_for_rollout__returns_none_if_no_experiments(self): with self.mock_config_logger as mock_logging: no_experiment_rollout = self.project_config.get_rollout_from_id("201111") + variation_received, _ = self.decision_service.get_variation_for_rollout( + self.project_config, no_experiment_rollout, "test_user" + ) self.assertEqual( decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), - self.decision_service.get_variation_for_rollout( - self.project_config, no_experiment_rollout, "test_user" - ), + variation_received, ) # Assert no log messages were generated @@ -963,20 +998,21 @@ def test_get_variation_for_rollout__returns_decision_if_user_in_rollout(self): rollout = self.project_config.get_rollout_from_id("211111") with mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=True + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[True, []] ), self.mock_decision_logger as mock_decision_service_logging, mock.patch( "optimizely.bucketer.Bucketer.bucket", - return_value=self.project_config.get_variation_from_id("211127", "211129"), + return_value=[self.project_config.get_variation_from_id("211127", "211129"), []], ) as mock_bucket: + variation_received, _ = self.decision_service.get_variation_for_rollout( + self.project_config, rollout, "test_user" + ) self.assertEqual( decision_service.Decision( self.project_config.get_experiment_from_id("211127"), self.project_config.get_variation_from_id("211127", "211129"), enums.DecisionSources.ROLLOUT, ), - self.decision_service.get_variation_for_rollout( - self.project_config, rollout, "test_user" - ), + variation_received, ) # Check all log messages @@ -998,23 +1034,24 @@ def test_get_variation_for_rollout__calls_bucket_with_bucketing_id(self): rollout = self.project_config.get_rollout_from_id("211111") with mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=True + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[True, []] ), self.mock_decision_logger as mock_decision_service_logging, mock.patch( "optimizely.bucketer.Bucketer.bucket", - return_value=self.project_config.get_variation_from_id("211127", "211129"), + return_value=[self.project_config.get_variation_from_id("211127", "211129"), []], ) as mock_bucket: + variation_received, _ = self.decision_service.get_variation_for_rollout( + self.project_config, + rollout, + "test_user", + {"$opt_bucketing_id": "user_bucket_value"}, + ) self.assertEqual( decision_service.Decision( self.project_config.get_experiment_from_id("211127"), self.project_config.get_variation_from_id("211127", "211129"), enums.DecisionSources.ROLLOUT, ), - self.decision_service.get_variation_for_rollout( - self.project_config, - rollout, - "test_user", - {"$opt_bucketing_id": "user_bucket_value"}, - ), + variation_received, ) # Check all log messages @@ -1040,17 +1077,18 @@ def test_get_variation_for_rollout__skips_to_everyone_else_rule(self): ) with mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=True + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[True, []] ) as mock_audience_check, self.mock_decision_logger as mock_decision_service_logging, mock.patch( - "optimizely.bucketer.Bucketer.bucket", side_effect=[None, variation_to_mock] + "optimizely.bucketer.Bucketer.bucket", side_effect=[[None, []], [variation_to_mock, []]] ): + variation_received, _ = self.decision_service.get_variation_for_rollout( + self.project_config, rollout, "test_user" + ) self.assertEqual( decision_service.Decision( everyone_else_exp, variation_to_mock, enums.DecisionSources.ROLLOUT ), - self.decision_service.get_variation_for_rollout( - self.project_config, rollout, "test_user" - ), + variation_received, ) # Check that after first experiment, it skips to the last experiment to check @@ -1096,13 +1134,14 @@ def test_get_variation_for_rollout__returns_none_for_user_not_in_rollout(self): rollout = self.project_config.get_rollout_from_id("211111") with mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=False + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[False, []] ) as mock_audience_check, self.mock_decision_logger as mock_decision_service_logging: + variation_received, _ = self.decision_service.get_variation_for_rollout( + self.project_config, rollout, "test_user" + ) self.assertEqual( decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), - self.decision_service.get_variation_for_rollout( - self.project_config, rollout, "test_user" - ), + variation_received, ) # Check that all experiments in rollout layer were checked @@ -1164,18 +1203,19 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_experiment( ) decision_patch = mock.patch( "optimizely.decision_service.DecisionService.get_variation", - return_value=expected_variation, + return_value=[expected_variation, []], ) with decision_patch as mock_decision, self.mock_decision_logger: + variation_received, _ = self.decision_service.get_variation_for_feature( + self.project_config, feature, "test_user" + ) self.assertEqual( decision_service.Decision( expected_experiment, expected_variation, enums.DecisionSources.FEATURE_TEST, ), - self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user" - ), + variation_received, ) mock_decision.assert_called_once_with( @@ -1183,11 +1223,12 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_experiment( self.project_config.get_experiment_from_key("test_experiment"), "test_user", None, + False ) def test_get_variation_for_feature__returns_variation_for_feature_in_rollout(self): """ Test that get_variation_for_feature returns the variation of - the experiment in the rollout that the user is bucketed into. """ + the experiment in the rollout that the user is bucketed into. """ feature = self.project_config.get_feature_from_key("test_feature_in_rollout") @@ -1196,16 +1237,16 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_rollout(sel ) get_variation_for_rollout_patch = mock.patch( "optimizely.decision_service.DecisionService.get_variation_for_rollout", - return_value=expected_variation, + return_value=[expected_variation, None], ) - with \ - get_variation_for_rollout_patch as mock_get_variation_for_rollout, \ + with get_variation_for_rollout_patch as mock_get_variation_for_rollout, \ self.mock_decision_logger as mock_decision_service_logging: + variation_received, _ = self.decision_service.get_variation_for_feature( + self.project_config, feature, "test_user" + ) self.assertEqual( expected_variation, - self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user" - ), + variation_received, ) expected_rollout = self.project_config.get_rollout_from_id("211111") @@ -1221,7 +1262,7 @@ def test_get_variation_for_feature__returns_variation_if_user_not_in_experiment_ self, ): """ Test that get_variation_for_feature returns the variation of the experiment in the - feature's rollout even if the user is not bucketed into the feature's experiment. """ + feature's rollout even if the user is not bucketed into the feature's experiment. """ feature = self.project_config.get_feature_from_key( "test_feature_in_experiment_and_rollout" @@ -1233,19 +1274,20 @@ def test_get_variation_for_feature__returns_variation_if_user_not_in_experiment_ ) with mock.patch( "optimizely.helpers.audience.does_user_meet_audience_conditions", - side_effect=[False, True], + side_effect=[[False, []], [True, []]], ) as mock_audience_check, self.mock_decision_logger as mock_decision_service_logging, mock.patch( - "optimizely.bucketer.Bucketer.bucket", return_value=expected_variation - ): + "optimizely.bucketer.Bucketer.bucket", return_value=[expected_variation, []]): + + decision, _ = self.decision_service.get_variation_for_feature( + self.project_config, feature, "test_user" + ) self.assertEqual( decision_service.Decision( expected_experiment, expected_variation, enums.DecisionSources.ROLLOUT, ), - self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user" - ), + decision, ) self.assertEqual(2, mock_audience_check.call_count) @@ -1257,6 +1299,7 @@ def test_get_variation_for_feature__returns_variation_if_user_not_in_experiment_ None, mock_decision_service_logging, ) + mock_audience_check.assert_any_call( self.project_config, self.project_config.get_experiment_from_key("211127").get_audience_conditions_or_ids(), @@ -1278,30 +1321,32 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_group(self) ) with mock.patch( "optimizely.decision_service.DecisionService.get_experiment_in_group", - return_value=self.project_config.get_experiment_from_key("group_exp_1"), + return_value=(self.project_config.get_experiment_from_key("group_exp_1"), []), ) as mock_get_experiment_in_group, mock.patch( "optimizely.decision_service.DecisionService.get_variation", - return_value=expected_variation, + return_value=(expected_variation, []), ) as mock_decision: + variation_received, _ = self.decision_service.get_variation_for_feature( + self.project_config, feature, "test_user" + ) self.assertEqual( decision_service.Decision( expected_experiment, expected_variation, enums.DecisionSources.FEATURE_TEST, ), - self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user" - ), + variation_received, ) mock_get_experiment_in_group.assert_called_once_with( - self.project_config, self.project_config.get_group("19228"), "test_user" - ) + self.project_config, self.project_config.get_group("19228"), 'test_user') + mock_decision.assert_called_once_with( self.project_config, self.project_config.get_experiment_from_key("group_exp_1"), "test_user", None, + False ) def test_get_variation_for_feature__returns_none_for_user_not_in_group(self): @@ -1312,20 +1357,21 @@ def test_get_variation_for_feature__returns_none_for_user_not_in_group(self): with mock.patch( "optimizely.decision_service.DecisionService.get_experiment_in_group", - return_value=None, + return_value=[None, []], ) as mock_get_experiment_in_group, mock.patch( "optimizely.decision_service.DecisionService.get_variation" ) as mock_decision: + variation_received, _ = self.decision_service.get_variation_for_feature( + self.project_config, feature, "test_user" + ) self.assertEqual( decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), - self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user" - ), + variation_received, ) mock_get_experiment_in_group.assert_called_once_with( - self.project_config, self.project_config.get_group("19228"), "test_user" - ) + self.project_config, self.project_config.get_group("19228"), "test_user") + self.assertFalse(mock_decision.called) def test_get_variation_for_feature__returns_none_for_user_not_in_experiment(self): @@ -1335,13 +1381,14 @@ def test_get_variation_for_feature__returns_none_for_user_not_in_experiment(self with mock.patch( "optimizely.decision_service.DecisionService.get_variation", - return_value=None, + return_value=[None, []], ) as mock_decision: + variation_received, _ = self.decision_service.get_variation_for_feature( + self.project_config, feature, "test_user" + ) self.assertEqual( decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), - self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user" - ), + variation_received, ) mock_decision.assert_called_once_with( @@ -1349,6 +1396,7 @@ def test_get_variation_for_feature__returns_none_for_user_not_in_experiment(self self.project_config.get_experiment_from_key("test_experiment"), "test_user", None, + False ) def test_get_variation_for_feature__returns_none_for_invalid_group_id(self): @@ -1358,11 +1406,12 @@ def test_get_variation_for_feature__returns_none_for_invalid_group_id(self): feature.groupId = "aabbccdd" with self.mock_decision_logger as mock_decision_service_logging: + variation_received, _ = self.decision_service.get_variation_for_feature( + self.project_config, feature, "test_user" + ) self.assertEqual( decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), - self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user" - ), + variation_received, ) mock_decision_service_logging.error.assert_called_once_with( enums.Errors.INVALID_GROUP_ID.format("_get_variation_for_feature") @@ -1378,13 +1427,14 @@ def test_get_variation_for_feature__returns_none_for_user_in_group_experiment_no with mock.patch( "optimizely.decision_service.DecisionService.get_experiment_in_group", - return_value=self.project_config.get_experiment_from_key("group_exp_2"), + return_value=[self.project_config.get_experiment_from_key("group_exp_2"), []], ) as mock_decision: + variation_received, _ = self.decision_service.get_variation_for_feature( + self.project_config, feature, "test_user" + ) self.assertEqual( decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), - self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user" - ), + variation_received, ) mock_decision.assert_called_once_with( @@ -1399,11 +1449,12 @@ def test_get_experiment_in_group(self): with mock.patch( "optimizely.bucketer.Bucketer.find_bucket", return_value="32222" ), self.mock_decision_logger as mock_decision_service_logging: + variation_received, _ = self.decision_service.get_experiment_in_group( + self.project_config, group, "test_user" + ) self.assertEqual( experiment, - self.decision_service.get_experiment_in_group( - self.project_config, group, "test_user" - ), + variation_received, ) mock_decision_service_logging.info.assert_called_once_with( @@ -1417,10 +1468,11 @@ def test_get_experiment_in_group__returns_none_if_user_not_in_group(self): with mock.patch( "optimizely.bucketer.Bucketer.find_bucket", return_value=None ), self.mock_decision_logger as mock_decision_service_logging: + variation_received, _ = self.decision_service.get_experiment_in_group( + self.project_config, group, "test_user" + ) self.assertIsNone( - self.decision_service.get_experiment_in_group( - self.project_config, group, "test_user" - ) + variation_received ) mock_decision_service_logging.info.assert_called_once_with( diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index 92952556..1c21dc6a 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -1,4 +1,4 @@ -# Copyright 2016-2020, Optimizely +# Copyright 2016-2021, Optimizely # 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 @@ -26,6 +26,7 @@ from optimizely import optimizely_config from optimizely import project_config from optimizely import version +from optimizely.decision.optimizely_decide_option import OptimizelyDecideOption as DecideOption from optimizely.event.event_factory import EventFactory from optimizely.helpers import enums from . import base @@ -303,7 +304,7 @@ def test_activate(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ) as mock_decision, mock.patch('time.time', return_value=42), mock.patch( 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch( @@ -382,7 +383,7 @@ def on_activate(experiment, user_id, attributes, variation, event): ) with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process'): self.assertEqual('variation', self.optimizely.activate('test_experiment', 'test_user')) @@ -415,7 +416,7 @@ def on_track(event_key, user_id, attributes, event_tags, event): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process'): self.optimizely.track('test_event', 'test_user') @@ -443,7 +444,7 @@ def on_activate(event_key, user_id, attributes, event_tags, event): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast: @@ -483,7 +484,7 @@ def on_activate(event_key, user_id, attributes, event_tags, event): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast: @@ -519,7 +520,8 @@ def test_decision_listener__user_not_in_experiment(self): """ Test that activate calls broadcast decision with variation_key 'None' \ when user not in experiment. """ - with mock.patch('optimizely.decision_service.DecisionService.get_variation', return_value=None,), mock.patch( + with mock.patch('optimizely.decision_service.DecisionService.get_variation', + return_value=(None, []),), mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' @@ -544,7 +546,7 @@ def on_track(event_key, user_id, attributes, event_tags, event): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111128'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111128'), []), ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_event_tracked: @@ -566,7 +568,7 @@ def on_track(event_key, user_id, attributes, event_tags, event): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111128'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111128'), []), ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_event_tracked: @@ -593,7 +595,7 @@ def on_track(event_key, user_id, attributes, event_tags, event): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111128'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111128'), []), ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_event_tracked: @@ -635,7 +637,8 @@ def on_activate(experiment, user_id, attributes, variation, event): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=( + decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), []), ) as mock_decision, mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process'): self.assertTrue(opt_obj.is_feature_enabled('test_feature_in_experiment', 'test_user')) @@ -661,7 +664,8 @@ def on_activate(experiment, user_id, attributes, variation, event): mock_variation = project_config.get_variation_from_id('test_experiment', '111129') with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process: @@ -673,13 +677,31 @@ def on_activate(experiment, user_id, attributes, variation, event): self.assertEqual(1, mock_process.call_count) self.assertEqual(True, access_callback[0]) + def test_decide_experiment(self): + """ Test that the feature is enabled for the user if bucketed into variation of a rollout. + Also confirm that no impression event is processed. """ + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + project_config = opt_obj.config_manager.get_config() + + mock_experiment = project_config.get_experiment_from_key('test_experiment') + mock_variation = project_config.get_variation_from_id('test_experiment', '111129') + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), + ): + user_context = opt_obj.create_user_context('test_user') + decision = user_context.decide('test_feature_in_experiment', [DecideOption.DISABLE_DECISION_EVENT]) + self.assertTrue(decision.enabled, "decision should be enabled") + def test_activate__with_attributes__audience_match(self): """ Test that activate calls process with right params and returns expected variation when attributes are provided and audience conditions are met. """ with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ) as mock_get_variation, mock.patch('time.time', return_value=42), mock.patch( 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch( @@ -750,7 +772,7 @@ def test_activate__with_attributes_of_different_types(self): with mock.patch( 'optimizely.bucketer.Bucketer.bucket', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ) as mock_bucket, mock.patch('time.time', return_value=42), mock.patch( 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch( @@ -1024,7 +1046,7 @@ def test_activate__with_attributes__audience_match__bucketing_id_provided(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ) as mock_get_variation, mock.patch('time.time', return_value=42), mock.patch( 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch( @@ -1104,7 +1126,7 @@ def test_activate__with_attributes__no_audience_match(self): """ Test that activate returns None when audience conditions do not match. """ with mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions', - return_value=False) as mock_audience_check: + return_value=(False, [])) as mock_audience_check: self.assertIsNone( self.optimizely.activate('test_experiment', 'test_user', attributes={'test_attribute': 'test_value'},) ) @@ -1171,9 +1193,9 @@ def test_activate__bucketer_returns_none(self): with mock.patch( 'optimizely.helpers.audience.does_user_meet_audience_conditions', - return_value=True), mock.patch( + return_value=(True, [])), mock.patch( 'optimizely.bucketer.Bucketer.bucket', - return_value=None) as mock_bucket, mock.patch( + return_value=(None, [])) as mock_bucket, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process: self.assertIsNone( @@ -1762,7 +1784,7 @@ def test_get_variation(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ), mock.patch('optimizely.notification_center.NotificationCenter.send_notifications') as mock_broadcast: self.assertEqual( 'variation', self.optimizely.get_variation('test_experiment', 'test_user'), @@ -1787,7 +1809,7 @@ def test_get_variation_with_experiment_in_feature(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(project_config.get_variation_from_id('test_experiment', '111129'), []), ), mock.patch('optimizely.notification_center.NotificationCenter.send_notifications') as mock_broadcast: self.assertEqual('variation', opt_obj.get_variation('test_experiment', 'test_user')) @@ -1804,7 +1826,8 @@ def test_get_variation_with_experiment_in_feature(self): def test_get_variation__returns_none(self): """ Test that get_variation returns no variation and broadcasts decision with proper parameters. """ - with mock.patch('optimizely.decision_service.DecisionService.get_variation', return_value=None,), mock.patch( + with mock.patch('optimizely.decision_service.DecisionService.get_variation', + return_value=(None, []),), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast: self.assertEqual( @@ -1962,7 +1985,8 @@ def test_is_feature_enabled__returns_true_for_feature_experiment_if_feature_enab with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process, mock.patch( @@ -2060,7 +2084,8 @@ def test_is_feature_enabled__returns_false_for_feature_experiment_if_feature_dis with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process, mock.patch( @@ -2158,7 +2183,8 @@ def test_is_feature_enabled__returns_true_for_feature_rollout_if_feature_enabled with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process, mock.patch( @@ -2206,7 +2232,8 @@ def test_is_feature_enabled__returns_true_for_feature_rollout_if_feature_enabled with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process, mock.patch( @@ -2306,7 +2333,8 @@ def test_is_feature_enabled__returns_false_for_feature_rollout_if_feature_disabl with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process, mock.patch( @@ -2346,7 +2374,7 @@ def test_is_feature_enabled__returns_false_when_user_is_not_bucketed_into_any_va feature = project_config.get_feature_from_key('test_feature_in_experiment') with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process, mock.patch( @@ -2388,7 +2416,7 @@ def test_is_feature_enabled__returns_false_when_variation_is_nil(self,): feature = project_config.get_feature_from_key('test_feature_in_experiment_and_rollout') with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process, mock.patch( @@ -2491,14 +2519,19 @@ def test_get_enabled_features__broadcasts_decision_for_each_feature(self): def side_effect(*args, **kwargs): feature = args[1] + response = None if feature.key == 'test_feature_in_experiment': - return decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST) + response = decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST) elif feature.key == 'test_feature_in_rollout': - return decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT) + response = decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT) elif feature.key == 'test_feature_in_experiment_and_rollout': - return decision_service.Decision(mock_experiment, mock_variation_2, enums.DecisionSources.FEATURE_TEST,) + response = decision_service.Decision( + mock_experiment, mock_variation_2, enums.DecisionSources.FEATURE_TEST,) else: - return decision_service.Decision(mock_experiment, mock_variation_2, enums.DecisionSources.ROLLOUT) + response = decision_service.Decision(mock_experiment, mock_variation_2, enums.DecisionSources.ROLLOUT) + + return (response, []) with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', side_effect=side_effect, @@ -2622,7 +2655,8 @@ def test_get_feature_variable_boolean(self): mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2659,7 +2693,8 @@ def test_get_feature_variable_double(self): mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2696,7 +2731,8 @@ def test_get_feature_variable_integer(self): mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2733,7 +2769,8 @@ def test_get_feature_variable_string(self): mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2771,7 +2808,8 @@ def test_get_feature_variable_json(self): mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2817,7 +2855,8 @@ def test_get_all_feature_variables(self): 'variable_without_usage': 45} with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2873,7 +2912,8 @@ def test_get_feature_variable(self): # Boolean with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2901,7 +2941,8 @@ def test_get_feature_variable(self): # Double with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2931,7 +2972,8 @@ def test_get_feature_variable(self): # Integer with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2961,7 +3003,8 @@ def test_get_feature_variable(self): # String with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2991,7 +3034,8 @@ def test_get_feature_variable(self): # JSON with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3030,7 +3074,8 @@ def test_get_feature_variable_boolean_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3071,7 +3116,8 @@ def test_get_feature_variable_double_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3112,7 +3158,8 @@ def test_get_feature_variable_integer_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3153,7 +3200,8 @@ def test_get_feature_variable_string_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3194,7 +3242,8 @@ def test_get_feature_variable_json_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3235,7 +3284,8 @@ def test_get_all_feature_variables_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3288,7 +3338,8 @@ def test_get_feature_variable_for_feature_in_rollout(self): # Boolean with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3320,7 +3371,8 @@ def test_get_feature_variable_for_feature_in_rollout(self): # Double with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3352,7 +3404,8 @@ def test_get_feature_variable_for_feature_in_rollout(self): # Integer with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3384,7 +3437,8 @@ def test_get_feature_variable_for_feature_in_rollout(self): # String with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3417,7 +3471,8 @@ def test_get_feature_variable_for_feature_in_rollout(self): # JSON with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3460,7 +3515,8 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va # Boolean with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ): self.assertTrue( opt_obj.get_feature_variable_boolean('test_feature_in_experiment', 'is_working', 'test_user') @@ -3469,7 +3525,8 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va # Double with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ): self.assertEqual( 10.99, opt_obj.get_feature_variable_double('test_feature_in_experiment', 'cost', 'test_user'), @@ -3478,7 +3535,8 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va # Integer with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ): self.assertEqual( 999, opt_obj.get_feature_variable_integer('test_feature_in_experiment', 'count', 'test_user'), @@ -3487,7 +3545,8 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va # String with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ): self.assertEqual( 'devel', opt_obj.get_feature_variable_string('test_feature_in_experiment', 'environment', 'test_user'), @@ -3496,7 +3555,8 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va # JSON with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ): self.assertEqual( {"test": 12}, opt_obj.get_feature_variable_json('test_feature_in_experiment', 'object', 'test_user'), @@ -3505,13 +3565,15 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va # Non-typed with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ): self.assertTrue(opt_obj.get_feature_variable('test_feature_in_experiment', 'is_working', 'test_user')) with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ): self.assertEqual( 10.99, opt_obj.get_feature_variable('test_feature_in_experiment', 'cost', 'test_user'), @@ -3519,7 +3581,8 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ): self.assertEqual( 999, opt_obj.get_feature_variable('test_feature_in_experiment', 'count', 'test_user'), @@ -3527,7 +3590,8 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ): self.assertEqual( 'devel', opt_obj.get_feature_variable('test_feature_in_experiment', 'environment', 'test_user'), @@ -3542,7 +3606,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # Boolean with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3576,7 +3640,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # Double with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3610,7 +3674,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # Integer with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3644,7 +3708,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # String with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3678,7 +3742,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # JSON with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3712,7 +3776,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # Non-typed with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3743,7 +3807,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3776,7 +3840,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3809,7 +3873,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -4122,7 +4186,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self # Boolean with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertTrue( @@ -4137,7 +4202,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self # Double with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 10.99, opt_obj.get_feature_variable_double('test_feature_in_experiment', 'cost', 'test_user'), @@ -4151,7 +4217,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self # Integer with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 999, opt_obj.get_feature_variable_integer('test_feature_in_experiment', 'count', 'test_user'), @@ -4165,7 +4232,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self # String with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 'devel', opt_obj.get_feature_variable_string('test_feature_in_experiment', 'environment', 'test_user'), @@ -4179,7 +4247,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self # JSON with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( {"test": 12}, opt_obj.get_feature_variable_json('test_feature_in_experiment', 'object', 'test_user'), @@ -4193,7 +4262,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self # Non-typed with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertTrue(opt_obj.get_feature_variable('test_feature_in_experiment', 'is_working', 'test_user')) @@ -4205,7 +4275,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 10.99, opt_obj.get_feature_variable('test_feature_in_experiment', 'cost', 'test_user'), @@ -4218,7 +4289,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 999, opt_obj.get_feature_variable('test_feature_in_experiment', 'count', 'test_user'), @@ -4231,7 +4303,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 'devel', opt_obj.get_feature_variable('test_feature_in_experiment', 'environment', 'test_user'), @@ -4252,7 +4325,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r # Boolean with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertFalse(opt_obj.get_feature_variable_boolean('test_feature_in_rollout', 'is_running', 'test_user')) @@ -4264,7 +4338,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r # Double with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 99.99, opt_obj.get_feature_variable_double('test_feature_in_rollout', 'price', 'test_user'), @@ -4278,7 +4353,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r # Integer with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 999, opt_obj.get_feature_variable_integer('test_feature_in_rollout', 'count', 'test_user'), @@ -4292,7 +4368,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r # String with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 'Hello', opt_obj.get_feature_variable_string('test_feature_in_rollout', 'message', 'test_user'), @@ -4305,7 +4382,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r # JSON with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( {"field": 1}, opt_obj.get_feature_variable_json('test_feature_in_rollout', 'object', 'test_user'), @@ -4318,7 +4396,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r # Non-typed with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertFalse(opt_obj.get_feature_variable('test_feature_in_rollout', 'is_running', 'test_user')) @@ -4329,7 +4408,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 99.99, opt_obj.get_feature_variable('test_feature_in_rollout', 'price', 'test_user'), @@ -4342,7 +4422,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 999, opt_obj.get_feature_variable('test_feature_in_rollout', 'count', 'test_user'), @@ -4355,7 +4436,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 'Hello', opt_obj.get_feature_variable('test_feature_in_rollout', 'message', 'test_user'), @@ -4373,7 +4455,8 @@ def test_get_feature_variable__returns_none_if_type_mismatch(self): mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: # "is_working" is boolean variable and we are using double method on it. self.assertIsNone( @@ -4393,7 +4476,8 @@ def test_get_feature_variable__returns_none_if_unable_to_cast(self): mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch( 'optimizely.project_config.ProjectConfig.get_typecast_value', side_effect=ValueError(), ), mock.patch.object( @@ -4610,7 +4694,7 @@ def test_activate(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ), mock.patch('time.time', return_value=42), mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ), mock.patch.object( @@ -4751,7 +4835,7 @@ def test_activate__empty_user_id(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ), mock.patch('time.time', return_value=42), mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ), mock.patch.object( @@ -4965,3 +5049,13 @@ def test_get_forced_variation__invalid_user_id(self): self.assertIsNone(self.optimizely.get_forced_variation('test_experiment', 99)) mock_client_logging.error.assert_called_once_with('Provided "user_id" is in an invalid format.') + + def test_user_context_invalid_user_id(self): + """ + Tests user context. + """ + user_ids = [5, 5.5, None, True, [], {}] + + for u in user_ids: + uc = self.optimizely.create_user_context(u) + self.assertIsNone(uc, "invalid user id should return none") diff --git a/tests/test_user_context.py b/tests/test_user_context.py new file mode 100644 index 00000000..abc18a87 --- /dev/null +++ b/tests/test_user_context.py @@ -0,0 +1,1247 @@ +# Copyright 2021, Optimizely +# 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. +import json + +import mock + +from optimizely.decision.optimizely_decision import OptimizelyDecision +from optimizely.helpers import enums +from . import base +from optimizely import optimizely, decision_service +from optimizely.optimizely_user_context import OptimizelyUserContext +from optimizely.user_profile import UserProfileService + + +class UserContextTest(base.BaseTest): + def setUp(self): + base.BaseTest.setUp(self, 'config_dict_with_multiple_experiments') + + def compare_opt_decisions(self, expected, actual): + self.assertEqual(expected.variation_key, actual.variation_key) + self.assertEqual(expected.enabled, actual.enabled) + self.assertEqual(expected.rule_key, actual.rule_key) + self.assertEqual(expected.flag_key, actual.flag_key) + self.assertEqual(expected.variables, actual.variables) + self.assertEqual(expected.user_context.user_id, actual.user_context.user_id) + self.assertEqual(expected.user_context.get_user_attributes(), actual.user_context.get_user_attributes()) + + def test_user_context(self): + """ + tests user context creating and setting attributes + """ + uc = OptimizelyUserContext(self.optimizely, "test_user") + # user attribute should be empty dict + self.assertEqual({}, uc.get_user_attributes()) + + # user id should be as provided in constructor + self.assertEqual("test_user", uc.user_id) + + # set attribute + uc.set_attribute("browser", "chrome") + self.assertEqual("chrome", uc.get_user_attributes()["browser"], ) + + # set another attribute + uc.set_attribute("color", "red") + self.assertEqual("chrome", uc.get_user_attributes()["browser"]) + self.assertEqual("red", uc.get_user_attributes()["color"]) + + # override existing attribute + uc.set_attribute("browser", "firefox") + self.assertEqual("firefox", uc.get_user_attributes()["browser"]) + self.assertEqual("red", uc.get_user_attributes()["color"]) + + def test_attributes_are_cloned_when_passed_to_user_context(self): + user_id = 'test_user' + attributes = {"browser": "chrome"} + uc = OptimizelyUserContext(self.optimizely, user_id, attributes) + self.assertEqual(attributes, uc.get_user_attributes()) + attributes['new_key'] = 'test_value' + self.assertNotEqual(attributes, uc.get_user_attributes()) + + def test_attributes_default_to_dict_when_passes_as_non_dict(self): + uc = OptimizelyUserContext(self.optimizely, "test_user", True) + # user attribute should be empty dict + self.assertEqual({}, uc.get_user_attributes()) + + uc = OptimizelyUserContext(self.optimizely, "test_user", 10) + # user attribute should be empty dict + self.assertEqual({}, uc.get_user_attributes()) + + uc = OptimizelyUserContext(self.optimizely, "test_user", 'helloworld') + # user attribute should be empty dict + self.assertEqual({}, uc.get_user_attributes()) + + uc = OptimizelyUserContext(self.optimizely, "test_user", []) + # user attribute should be empty dict + self.assertEqual({}, uc.get_user_attributes()) + + def test_user_context_is_cloned_when_passed_to_optimizely_APIs(self): + """ Test that the user context in decide response is not the same object on which + the decide was called """ + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + user_context = opt_obj.create_user_context('test_user') + + # decide + decision = user_context.decide('test_feature_in_rollout') + self.assertNotEqual(user_context, decision.user_context) + + # decide_all + decisions = user_context.decide_all() + self.assertNotEqual(user_context, decisions['test_feature_in_rollout'].user_context) + + # decide_for_keys + decisions = user_context.decide_for_keys(['test_feature_in_rollout']) + self.assertNotEqual(user_context, decisions['test_feature_in_rollout'].user_context) + + def test_decide__SDK_not_ready(self): + opt_obj = optimizely.Optimizely("") + user_context = opt_obj.create_user_context('test_user') + + expected = OptimizelyDecision( + variation_key=None, + rule_key=None, + enabled=False, + variables={}, + flag_key='test_feature', + user_context=user_context + ) + + actual = user_context.decide('test_feature') + + self.compare_opt_decisions(expected, actual) + + self.assertIn( + 'Optimizely SDK not configured properly yet.', + actual.reasons + ) + + def test_decide__invalid_flag_key(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + user_context = opt_obj.create_user_context('test_user', {'some-key': 'some-value'}) + + expected = OptimizelyDecision( + variation_key=None, + rule_key=None, + enabled=False, + variables={}, + flag_key=123, + user_context=user_context + ) + + actual = user_context.decide(123) + + self.compare_opt_decisions(expected, actual) + + self.assertIn( + 'No flag was found for key "123".', + actual.reasons + ) + + def test_decide__unknown_flag_key(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + user_context = opt_obj.create_user_context('test_user') + + expected = OptimizelyDecision( + variation_key=None, + rule_key=None, + enabled=False, + variables={}, + flag_key='unknown_flag_key', + user_context=user_context + ) + + actual = user_context.decide('unknown_flag_key') + + self.compare_opt_decisions(expected, actual) + + self.assertIn( + 'No flag was found for key "unknown_flag_key".', + actual.reasons + ) + + def test_decide__feature_test(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + project_config = opt_obj.config_manager.get_config() + + mock_experiment = project_config.get_experiment_from_key('test_experiment') + mock_variation = project_config.get_variation_from_id('test_experiment', '111129') + + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST), []), + ), mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: + + user_context = opt_obj.create_user_context('test_user', {'browser': 'chrome'}) + actual = user_context.decide('test_feature_in_experiment') + + expected_variables = { + 'is_working': True, + 'environment': 'staging', + 'cost': 10.02, + 'count': 4243, + 'variable_without_usage': 45, + 'object': {"test": 123}, + 'true_object': {"true_test": 1.4} + } + + expected = OptimizelyDecision( + variation_key='variation', + rule_key='test_experiment', + enabled=True, + variables=expected_variables, + flag_key='test_feature_in_experiment', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) + + # assert notification + mock_broadcast_decision.assert_called_with( + enums.NotificationTypes.DECISION, + 'flag', + 'test_user', + {'browser': 'chrome'}, + { + 'flag_key': expected.flag_key, + 'enabled': expected.enabled, + 'variation_key': expected.variation_key, + 'rule_key': expected.rule_key, + 'reasons': expected.reasons, + 'decision_event_dispatched': True, + 'variables': expected.variables, + }, + ) + + # assert event count + self.assertEqual(1, mock_send_event.call_count) + + # assert event payload + mock_send_event.assert_called_with( + project_config, + mock_experiment, + mock_variation, + expected.flag_key, + expected.rule_key, + 'feature-test', + expected.enabled, + 'test_user', + {'browser': 'chrome'} + ) + + def test_decide__feature_test__send_flag_decision_false(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + project_config = opt_obj.config_manager.get_config() + project_config.send_flag_decisions = False + + mock_experiment = project_config.get_experiment_from_key('test_experiment') + mock_variation = project_config.get_variation_from_id('test_experiment', '111129') + + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST), []), + ), mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: + + user_context = opt_obj.create_user_context('test_user') + actual = user_context.decide('test_feature_in_experiment') + + expected_variables = { + 'is_working': True, + 'environment': 'staging', + 'cost': 10.02, + 'count': 4243, + 'variable_without_usage': 45, + 'object': {"test": 123}, + 'true_object': {"true_test": 1.4} + } + + expected = OptimizelyDecision( + variation_key='variation', + rule_key='test_experiment', + enabled=True, + variables=expected_variables, + flag_key='test_feature_in_experiment', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) + + # assert notification count + self.assertEqual(1, mock_broadcast_decision.call_count) + + # assert event count + self.assertEqual(1, mock_send_event.call_count) + + def test_decide_feature_rollout(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + project_config = opt_obj.config_manager.get_config() + + with mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: + + user_attributes = {'test_attribute': 'test_value_1'} + user_context = opt_obj.create_user_context('test_user', user_attributes) + actual = user_context.decide('test_feature_in_rollout') + + expected_variables = { + 'is_running': True, + 'message': 'Hello audience', + 'price': 39.99, + 'count': 399, + 'object': {"field": 12} + } + + expected = OptimizelyDecision( + variation_key='211129', + rule_key='211127', + enabled=True, + variables=expected_variables, + flag_key='test_feature_in_rollout', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) + + # assert notification count + self.assertEqual(1, mock_broadcast_decision.call_count) + + # assert notification + mock_broadcast_decision.assert_called_with( + enums.NotificationTypes.DECISION, + 'flag', + 'test_user', + user_attributes, + { + 'flag_key': expected.flag_key, + 'enabled': expected.enabled, + 'variation_key': expected.variation_key, + 'rule_key': expected.rule_key, + 'reasons': expected.reasons, + 'decision_event_dispatched': True, + 'variables': expected.variables, + }, + ) + + # assert event count + self.assertEqual(1, mock_send_event.call_count) + + # assert event payload + expected_experiment = project_config.get_experiment_from_key(expected.rule_key) + expected_var = project_config.get_variation_from_key(expected.rule_key, expected.variation_key) + mock_send_event.assert_called_with( + project_config, + expected_experiment, + expected_var, + expected.flag_key, + expected.rule_key, + 'rollout', + expected.enabled, + 'test_user', + user_attributes + ) + + def test_decide_feature_rollout__send_flag_decision_false(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + project_config = opt_obj.config_manager.get_config() + project_config.send_flag_decisions = False + + with mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: + + user_attributes = {'test_attribute': 'test_value_1'} + user_context = opt_obj.create_user_context('test_user', user_attributes) + actual = user_context.decide('test_feature_in_rollout') + + expected_variables = { + 'is_running': True, + 'message': 'Hello audience', + 'price': 39.99, + 'count': 399, + 'object': {"field": 12} + } + + expected = OptimizelyDecision( + variation_key='211129', + rule_key='211127', + enabled=True, + variables=expected_variables, + flag_key='test_feature_in_rollout', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) + + # assert notification count + self.assertEqual(1, mock_broadcast_decision.call_count) + + # assert notification + mock_broadcast_decision.assert_called_with( + enums.NotificationTypes.DECISION, + 'flag', + 'test_user', + user_attributes, + { + 'flag_key': expected.flag_key, + 'enabled': expected.enabled, + 'variation_key': expected.variation_key, + 'rule_key': expected.rule_key, + 'reasons': expected.reasons, + 'decision_event_dispatched': False, + 'variables': expected.variables, + }, + ) + + # assert event count + self.assertEqual(0, mock_send_event.call_count) + + def test_decide_feature_null_variation(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + project_config = opt_obj.config_manager.get_config() + + mock_experiment = None + mock_variation = None + + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.ROLLOUT), []), + ), mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: + + user_context = opt_obj.create_user_context('test_user', {'browser': 'chrome'}) + actual = user_context.decide('test_feature_in_experiment') + + expected_variables = { + 'is_working': True, + 'environment': 'devel', + 'cost': 10.99, + 'count': 999, + 'variable_without_usage': 45, + 'object': {"test": 12}, + 'true_object': {"true_test": 23.54} + } + + expected = OptimizelyDecision( + variation_key=None, + rule_key=None, + enabled=False, + variables=expected_variables, + flag_key='test_feature_in_experiment', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) + + # assert notification + mock_broadcast_decision.assert_called_with( + enums.NotificationTypes.DECISION, + 'flag', + 'test_user', + {'browser': 'chrome'}, + { + 'flag_key': expected.flag_key, + 'enabled': expected.enabled, + 'variation_key': expected.variation_key, + 'rule_key': expected.rule_key, + 'reasons': expected.reasons, + 'decision_event_dispatched': True, + 'variables': expected.variables, + }, + ) + + # assert event count + self.assertEqual(1, mock_send_event.call_count) + + # assert event payload + mock_send_event.assert_called_with( + project_config, + mock_experiment, + mock_variation, + expected.flag_key, + '', + 'rollout', + expected.enabled, + 'test_user', + {'browser': 'chrome'} + ) + + def test_decide_feature_null_variation__send_flag_decision_false(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + project_config = opt_obj.config_manager.get_config() + project_config.send_flag_decisions = False + + mock_experiment = None + mock_variation = None + + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.ROLLOUT), []), + ), mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: + + user_context = opt_obj.create_user_context('test_user', {'browser': 'chrome'}) + actual = user_context.decide('test_feature_in_experiment') + + expected_variables = { + 'is_working': True, + 'environment': 'devel', + 'cost': 10.99, + 'count': 999, + 'variable_without_usage': 45, + 'object': {"test": 12}, + 'true_object': {"true_test": 23.54} + } + + expected = OptimizelyDecision( + variation_key=None, + rule_key=None, + enabled=False, + variables=expected_variables, + flag_key='test_feature_in_experiment', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) + + # assert notification + mock_broadcast_decision.assert_called_with( + enums.NotificationTypes.DECISION, + 'flag', + 'test_user', + {'browser': 'chrome'}, + { + 'flag_key': expected.flag_key, + 'enabled': expected.enabled, + 'variation_key': expected.variation_key, + 'rule_key': expected.rule_key, + 'reasons': expected.reasons, + 'decision_event_dispatched': False, + 'variables': expected.variables, + }, + ) + + # assert event count + self.assertEqual(0, mock_send_event.call_count) + + def test_decide__option__disable_decision_event(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + project_config = opt_obj.config_manager.get_config() + + mock_experiment = project_config.get_experiment_from_key('test_experiment') + mock_variation = project_config.get_variation_from_id('test_experiment', '111129') + + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST), []), + ), mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: + + user_context = opt_obj.create_user_context('test_user', {'browser': 'chrome'}) + actual = user_context.decide('test_feature_in_experiment', ['DISABLE_DECISION_EVENT']) + + expected_variables = { + 'is_working': True, + 'environment': 'staging', + 'cost': 10.02, + 'count': 4243, + 'variable_without_usage': 45, + 'object': {"test": 123}, + 'true_object': {"true_test": 1.4} + } + + expected = OptimizelyDecision( + variation_key='variation', + rule_key='test_experiment', + enabled=True, + variables=expected_variables, + flag_key='test_feature_in_experiment', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) + + # assert notification + mock_broadcast_decision.assert_called_with( + enums.NotificationTypes.DECISION, + 'flag', + 'test_user', + {'browser': 'chrome'}, + { + 'flag_key': expected.flag_key, + 'enabled': expected.enabled, + 'variation_key': expected.variation_key, + 'rule_key': expected.rule_key, + 'reasons': expected.reasons, + 'decision_event_dispatched': False, + 'variables': expected.variables, + }, + ) + + # assert event count + self.assertEqual(0, mock_send_event.call_count) + + def test_decide__default_option__disable_decision_event(self): + opt_obj = optimizely.Optimizely( + datafile=json.dumps(self.config_dict_with_features), + default_decide_options=['DISABLE_DECISION_EVENT'] + ) + project_config = opt_obj.config_manager.get_config() + + mock_experiment = project_config.get_experiment_from_key('test_experiment') + mock_variation = project_config.get_variation_from_id('test_experiment', '111129') + + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST), []), + ), mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: + + user_context = opt_obj.create_user_context('test_user', {'browser': 'chrome'}) + actual = user_context.decide('test_feature_in_experiment') + + expected_variables = { + 'is_working': True, + 'environment': 'staging', + 'cost': 10.02, + 'count': 4243, + 'variable_without_usage': 45, + 'object': {"test": 123}, + 'true_object': {"true_test": 1.4} + } + + expected = OptimizelyDecision( + variation_key='variation', + rule_key='test_experiment', + enabled=True, + variables=expected_variables, + flag_key='test_feature_in_experiment', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) + + # assert notification + mock_broadcast_decision.assert_called_with( + enums.NotificationTypes.DECISION, + 'flag', + 'test_user', + {'browser': 'chrome'}, + { + 'flag_key': expected.flag_key, + 'enabled': expected.enabled, + 'variation_key': expected.variation_key, + 'rule_key': expected.rule_key, + 'reasons': expected.reasons, + 'decision_event_dispatched': False, + 'variables': expected.variables, + }, + ) + + # assert event count + self.assertEqual(0, mock_send_event.call_count) + + def test_decide__option__exclude_variables(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + project_config = opt_obj.config_manager.get_config() + + mock_experiment = project_config.get_experiment_from_key('test_experiment') + mock_variation = project_config.get_variation_from_id('test_experiment', '111129') + + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST), []), + ), mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: + + user_context = opt_obj.create_user_context('test_user', {'browser': 'chrome'}) + actual = user_context.decide('test_feature_in_experiment', ['EXCLUDE_VARIABLES']) + + expected_variables = {} + + expected = OptimizelyDecision( + variation_key='variation', + rule_key='test_experiment', + enabled=True, + variables=expected_variables, + flag_key='test_feature_in_experiment', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) + + # assert notification + mock_broadcast_decision.assert_called_with( + enums.NotificationTypes.DECISION, + 'flag', + 'test_user', + {'browser': 'chrome'}, + { + 'flag_key': expected.flag_key, + 'enabled': expected.enabled, + 'variation_key': expected.variation_key, + 'rule_key': expected.rule_key, + 'reasons': expected.reasons, + 'decision_event_dispatched': True, + 'variables': expected.variables, + }, + ) + + # assert event count + self.assertEqual(1, mock_send_event.call_count) + + # assert event payload + mock_send_event.assert_called_with( + project_config, + mock_experiment, + mock_variation, + expected.flag_key, + expected.rule_key, + 'feature-test', + expected.enabled, + 'test_user', + {'browser': 'chrome'} + ) + + def test_decide__option__include_reasons__feature_test(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + + user_context = opt_obj.create_user_context('test_user', {'browser': 'chrome'}) + actual = user_context.decide('test_feature_in_experiment', ['INCLUDE_REASONS']) + + expected_reasons = [ + 'Evaluating audiences for experiment "test_experiment": [].', + 'Audiences for experiment "test_experiment" collectively evaluated to TRUE.', + 'User "test_user" is in variation "control" of experiment test_experiment.' + ] + + self.assertEquals(expected_reasons, actual.reasons) + + def test_decide__option__include_reasons__feature_rollout(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + + user_attributes = {'test_attribute': 'test_value_1'} + user_context = opt_obj.create_user_context('test_user', user_attributes) + actual = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) + + expected_reasons = [ + 'Evaluating audiences for rule 1: ["11154"].', + 'Audiences for rule 1 collectively evaluated to TRUE.', + 'User "test_user" meets audience conditions for targeting rule 1.', + 'User "test_user" is in the traffic group of targeting rule 1.' + ] + + self.assertEquals(expected_reasons, actual.reasons) + + def test_decide__option__enabled_flags_only(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + project_config = opt_obj.config_manager.get_config() + + expected_experiment = project_config.get_experiment_from_key('211127') + expected_var = project_config.get_variation_from_key('211127', '211229') + + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(expected_experiment, expected_var, + enums.DecisionSources.ROLLOUT), []), + ), mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: + + user_attributes = {'test_attribute': 'test_value_1'} + user_context = opt_obj.create_user_context('test_user', user_attributes) + actual = user_context.decide('test_feature_in_rollout', 'ENABLED_FLAGS_ONLY') + + expected_variables = { + 'is_running': False, + 'message': 'Hello', + 'price': 99.99, + 'count': 999, + 'object': {"field": 1} + } + + expected = OptimizelyDecision( + variation_key='211229', + rule_key='211127', + enabled=False, + variables=expected_variables, + flag_key='test_feature_in_rollout', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) + + # assert notification count + self.assertEqual(1, mock_broadcast_decision.call_count) + + # assert notification + mock_broadcast_decision.assert_called_with( + enums.NotificationTypes.DECISION, + 'flag', + 'test_user', + user_attributes, + { + 'flag_key': expected.flag_key, + 'enabled': expected.enabled, + 'variation_key': expected.variation_key, + 'rule_key': expected.rule_key, + 'reasons': expected.reasons, + 'decision_event_dispatched': True, + 'variables': expected.variables, + }, + ) + + # assert event count + self.assertEqual(1, mock_send_event.call_count) + + # assert event payload + mock_send_event.assert_called_with( + project_config, + expected_experiment, + expected_var, + expected.flag_key, + expected.rule_key, + 'rollout', + expected.enabled, + 'test_user', + user_attributes + ) + + def test_decide__default_options__with__options(self): + opt_obj = optimizely.Optimizely( + datafile=json.dumps(self.config_dict_with_features), + default_decide_options=['DISABLE_DECISION_EVENT'] + ) + project_config = opt_obj.config_manager.get_config() + + mock_experiment = project_config.get_experiment_from_key('test_experiment') + mock_variation = project_config.get_variation_from_id('test_experiment', '111129') + + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST), []), + ), mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: + + user_context = opt_obj.create_user_context('test_user', {'browser': 'chrome'}) + actual = user_context.decide('test_feature_in_experiment', ['EXCLUDE_VARIABLES']) + + expected_variables = {} + + expected = OptimizelyDecision( + variation_key='variation', + rule_key='test_experiment', + enabled=True, + variables=expected_variables, + flag_key='test_feature_in_experiment', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) + + # assert notification + mock_broadcast_decision.assert_called_with( + enums.NotificationTypes.DECISION, + 'flag', + 'test_user', + {'browser': 'chrome'}, + { + 'flag_key': expected.flag_key, + 'enabled': expected.enabled, + 'variation_key': expected.variation_key, + 'rule_key': expected.rule_key, + 'reasons': expected.reasons, + 'decision_event_dispatched': False, + 'variables': expected.variables, + }, + ) + + # assert event count + self.assertEqual(0, mock_send_event.call_count) + + def test_decide_for_keys(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + + user_context = opt_obj.create_user_context('test_user') + + mocked_decision_1 = OptimizelyDecision(flag_key='test_feature_in_experiment', enabled=True) + mocked_decision_2 = OptimizelyDecision(flag_key='test_feature_in_rollout', enabled=False) + + def side_effect(*args, **kwargs): + flag = args[1] + if flag == 'test_feature_in_experiment': + return mocked_decision_1 + else: + return mocked_decision_2 + + with mock.patch( + 'optimizely.optimizely.Optimizely._decide', side_effect=side_effect + ) as mock_decide, mock.patch( + 'optimizely.optimizely_user_context.OptimizelyUserContext._clone', + return_value=user_context + ): + + flags = ['test_feature_in_rollout', 'test_feature_in_experiment'] + options = [] + decisions = user_context.decide_for_keys(flags, options) + + self.assertEqual(2, len(decisions)) + + mock_decide.assert_any_call( + user_context, + 'test_feature_in_experiment', + options + ) + + mock_decide.assert_any_call( + user_context, + 'test_feature_in_rollout', + options + ) + + self.assertEqual(mocked_decision_1, decisions['test_feature_in_experiment']) + self.assertEqual(mocked_decision_2, decisions['test_feature_in_rollout']) + + def test_decide_for_keys__option__enabled_flags_only(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + + user_context = opt_obj.create_user_context('test_user') + + mocked_decision_1 = OptimizelyDecision(flag_key='test_feature_in_experiment', enabled=True) + mocked_decision_2 = OptimizelyDecision(flag_key='test_feature_in_rollout', enabled=False) + + def side_effect(*args, **kwargs): + flag = args[1] + if flag == 'test_feature_in_experiment': + return mocked_decision_1 + else: + return mocked_decision_2 + + with mock.patch( + 'optimizely.optimizely.Optimizely._decide', side_effect=side_effect + ) as mock_decide, mock.patch( + 'optimizely.optimizely_user_context.OptimizelyUserContext._clone', + return_value=user_context + ): + + flags = ['test_feature_in_rollout', 'test_feature_in_experiment'] + options = ['ENABLED_FLAGS_ONLY'] + decisions = user_context.decide_for_keys(flags, options) + + self.assertEqual(1, len(decisions)) + + mock_decide.assert_any_call( + user_context, + 'test_feature_in_experiment', + options + ) + + mock_decide.assert_any_call( + user_context, + 'test_feature_in_rollout', + options + ) + + self.assertEqual(mocked_decision_1, decisions['test_feature_in_experiment']) + + def test_decide_for_keys__default_options__with__options(self): + opt_obj = optimizely.Optimizely( + datafile=json.dumps(self.config_dict_with_features), + default_decide_options=['ENABLED_FLAGS_ONLY'] + ) + + user_context = opt_obj.create_user_context('test_user') + + with mock.patch( + 'optimizely.optimizely.Optimizely._decide' + ) as mock_decide, mock.patch( + 'optimizely.optimizely_user_context.OptimizelyUserContext._clone', + return_value=user_context + ): + + flags = ['test_feature_in_experiment'] + options = ['EXCLUDE_VARIABLES'] + user_context.decide_for_keys(flags, options) + + mock_decide.assert_called_with( + user_context, + 'test_feature_in_experiment', + ['EXCLUDE_VARIABLES'] + ) + + def test_decide_for_all(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + + user_context = opt_obj.create_user_context('test_user') + + with mock.patch( + 'optimizely.optimizely.Optimizely._decide_for_keys', return_value='response from decide_for_keys' + ) as mock_decide, mock.patch( + 'optimizely.optimizely_user_context.OptimizelyUserContext._clone', + return_value=user_context + ): + + options = ['DISABLE_DECISION_EVENT'] + decisions = user_context.decide_all(options) + + mock_decide.assert_called_with( + user_context, + [ + 'test_feature_in_experiment', + 'test_feature_in_rollout', + 'test_feature_in_group', + 'test_feature_in_experiment_and_rollout' + ], + options + ) + + self.assertEqual('response from decide_for_keys', decisions) + + def test_decide_options_bypass_UPS(self): + user_id = 'test_user' + + lookup_profile = { + 'user_id': user_id, + 'experiment_bucket_map': { + '111127': { + 'variation_id': '111128' + } + } + } + + save_profile = [] + + class Ups(UserProfileService): + + def lookup(self, user_id): + return lookup_profile + + def save(self, user_profile): + print(user_profile) + save_profile.append(user_profile) + + ups = Ups() + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features), user_profile_service=ups) + project_config = opt_obj.config_manager.get_config() + + mock_variation = project_config.get_variation_from_id('test_experiment', '111129') + + with mock.patch( + 'optimizely.bucketer.Bucketer.bucket', + return_value=(mock_variation, []), + ), mock.patch( + 'optimizely.event.event_processor.ForwardingEventProcessor.process' + ), mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ): + user_context = opt_obj.create_user_context(user_id) + options = [ + 'IGNORE_USER_PROFILE_SERVICE' + ] + + actual = user_context.decide('test_feature_in_experiment', options) + + expected_variables = { + 'is_working': True, + 'environment': 'staging', + 'cost': 10.02, + 'count': 4243, + 'variable_without_usage': 45, + 'object': {"test": 123}, + 'true_object': {"true_test": 1.4} + } + + expected = OptimizelyDecision( + variation_key='variation', + rule_key='test_experiment', + enabled=True, + variables=expected_variables, + flag_key='test_feature_in_experiment', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) + + self.assertEqual([], save_profile) + + def test_decide_reasons__hit_everyone_else_rule__fails_bucketing(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + + user_attributes = {} + user_context = opt_obj.create_user_context('test_user', user_attributes) + actual = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) + + expected_reasons = [ + 'Evaluating audiences for rule 1: ["11154"].', + 'Audiences for rule 1 collectively evaluated to FALSE.', + 'User "test_user" does not meet conditions for targeting rule 1.', + 'Evaluating audiences for rule 2: ["11159"].', + 'Audiences for rule 2 collectively evaluated to FALSE.', + 'User "test_user" does not meet conditions for targeting rule 2.', + 'Evaluating audiences for rule Everyone Else: [].', + 'Audiences for rule Everyone Else collectively evaluated to TRUE.', + 'Bucketed into an empty traffic range. Returning nil.' + ] + + self.assertEquals(expected_reasons, actual.reasons) + + def test_decide_reasons__hit_everyone_else_rule(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + + user_attributes = {} + user_context = opt_obj.create_user_context('abcde', user_attributes) + actual = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) + + expected_reasons = [ + 'Evaluating audiences for rule 1: ["11154"].', + 'Audiences for rule 1 collectively evaluated to FALSE.', + 'User "abcde" does not meet conditions for targeting rule 1.', + 'Evaluating audiences for rule 2: ["11159"].', + 'Audiences for rule 2 collectively evaluated to FALSE.', + 'User "abcde" does not meet conditions for targeting rule 2.', + 'Evaluating audiences for rule Everyone Else: [].', + 'Audiences for rule Everyone Else collectively evaluated to TRUE.', + 'User "abcde" meets conditions for targeting rule "Everyone Else".' + ] + + self.assertEquals(expected_reasons, actual.reasons) + + def test_decide_reasons__hit_rule2__fails_bucketing(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + + user_attributes = {'test_attribute': 'test_value_2'} + user_context = opt_obj.create_user_context('test_user', user_attributes) + actual = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) + + expected_reasons = [ + 'Evaluating audiences for rule 1: ["11154"].', + 'Audiences for rule 1 collectively evaluated to FALSE.', + 'User "test_user" does not meet conditions for targeting rule 1.', + 'Evaluating audiences for rule 2: ["11159"].', + 'Audiences for rule 2 collectively evaluated to TRUE.', + 'User "test_user" meets audience conditions for targeting rule 2.', + 'Bucketed into an empty traffic range. Returning nil.', + 'User "test_user" is not in the traffic group for targeting rule 2. Checking "Everyone Else" rule now.', + 'Evaluating audiences for rule Everyone Else: [].', + 'Audiences for rule Everyone Else collectively evaluated to TRUE.', + 'Bucketed into an empty traffic range. Returning nil.' + ] + + self.assertEquals(expected_reasons, actual.reasons) + + def test_decide_reasons__hit_user_profile_service(self): + user_id = 'test_user' + + lookup_profile = { + 'user_id': user_id, + 'experiment_bucket_map': { + '111127': { + 'variation_id': '111128' + } + } + } + + save_profile = [] + + class Ups(UserProfileService): + + def lookup(self, user_id): + return lookup_profile + + def save(self, user_profile): + print(user_profile) + save_profile.append(user_profile) + + ups = Ups() + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features), user_profile_service=ups) + + user_context = opt_obj.create_user_context(user_id) + options = ['INCLUDE_REASONS'] + + actual = user_context.decide('test_feature_in_experiment', options) + + expected_reasons = [('Returning previously activated variation ID "control" of experiment ' + '"test_experiment" for user "test_user" from user profile.')] + + self.assertEquals(expected_reasons, actual.reasons) + + def test_decide_reasons__forced_variation(self): + user_id = 'test_user' + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + + user_context = opt_obj.create_user_context(user_id) + options = ['INCLUDE_REASONS'] + + opt_obj.set_forced_variation('test_experiment', user_id, 'control') + + actual = user_context.decide('test_feature_in_experiment', options) + + expected_reasons = [('Variation "control" is mapped to experiment ' + '"test_experiment" and user "test_user" in the forced variation map')] + + self.assertEquals(expected_reasons, actual.reasons) + + def test_decide_reasons__whitelisted_variation(self): + user_id = 'user_1' + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + + user_context = opt_obj.create_user_context(user_id) + options = ['INCLUDE_REASONS'] + + actual = user_context.decide('test_feature_in_experiment', options) + + expected_reasons = ['User "user_1" is forced in variation "control".'] + + self.assertEquals(expected_reasons, actual.reasons)