diff --git a/optimizely/bucketer.py b/optimizely/bucketer.py index f36a101e..2309c6b3 100644 --- a/optimizely/bucketer.py +++ b/optimizely/bucketer.py @@ -1,4 +1,4 @@ -# Copyright 2016, Optimizely +# Copyright 2016-2017, 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 @@ -133,25 +133,3 @@ def bucket(self, experiment, user_id): self.config.logger.log(enums.LogLevels.INFO, 'User "%s" is in no variation.' % user_id) return None - - def get_forced_variation(self, experiment, user_id): - """ Determine if a user is forced into a variation for the given experiment and return that variation. - - Args: - experiment: Object representing the experiment for which user is to be bucketed. - user_id: ID for the user. - - Returns: - Variation in which the user with ID user_id is forced into. None if no variation. - """ - - forced_variations = experiment.forcedVariations - if forced_variations and user_id in forced_variations: - variation_key = forced_variations.get(user_id) - variation = self.config.get_variation_from_key(experiment.key, variation_key) - if variation: - self.config.logger.log(enums.LogLevels.INFO, - 'User "%s" is forced in variation "%s".' % (user_id, variation_key)) - return variation - - return None diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py new file mode 100644 index 00000000..5639771f --- /dev/null +++ b/optimizely/decision_service.py @@ -0,0 +1,150 @@ +# Copyright 2017, 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 sys + +from . import bucketer +from .helpers import audience as audience_helper +from .helpers import enums +from .helpers import experiment as experiment_helper +from .helpers import validator +from .user_profile import UserProfile + + +class DecisionService(object): + """ Class encapsulating all decision related capabilities. """ + + def __init__(self, config, user_profile_service): + self.bucketer = bucketer.Bucketer(config) + self.user_profile_service = user_profile_service + self.config = config + self.logger = config.logger + + def get_forced_variation(self, experiment, user_id): + """ Determine if a user is forced into a variation for the given experiment and return that variation. + + Args: + experiment: Object representing the experiment for which user is to be bucketed. + user_id: ID for the user. + + Returns: + Variation in which the user with ID user_id is forced into. None if no variation. + """ + + forced_variations = experiment.forcedVariations + if forced_variations and user_id in forced_variations: + variation_key = forced_variations.get(user_id) + variation = self.config.get_variation_from_key(experiment.key, variation_key) + if variation: + self.config.logger.log(enums.LogLevels.INFO, + 'User "%s" is forced in variation "%s".' % (user_id, variation_key)) + return variation + + return None + + def get_stored_variation(self, experiment, user_profile): + """ Determine if the user has a stored variation available for the given experiment and return that. + + Args: + experiment: Object representing the experiment for which user is to be bucketed. + user_profile: UserProfile object representing the user's 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 = self.config.get_variation_from_id(experiment.key, variation_id) + if variation: + self.config.logger.log(enums.LogLevels.INFO, + 'Found a stored decision. User "%s" is in variation "%s" of experiment "%s".' % + (user_id, variation.key, experiment.key)) + return variation + + return None + + def get_variation(self, experiment, user_id, attributes): + """ Top-level function to help determine variation user should be put in. + + First, check if experiment is running. + Second, check if user is forced in a variation. + Third, check if there is a stored decision for the user and return the corresponding variation. + Fourth, figure out if user is in the experiment by evaluating audience conditions if any. + Fifth, bucket the user and return the variation. + + Args: + experiment_key: Experiment for which user variation needs to be determined. + user_id: ID for user. + attributes: Dict representing user attributes. + + Returns: + Variation user should see. None if user is not in experiment or experiment is not running. + """ + + # Check if experiment is running + if not experiment_helper.is_experiment_running(experiment): + self.logger.log(enums.LogLevels.INFO, 'Experiment "%s" is not running.' % experiment.key) + return None + + # Check to see if user is white-listed for a certain variation + variation = self.get_forced_variation(experiment, user_id) + if variation: + return variation + + # Check to see if user has a decision available for the given experiment + user_profile = UserProfile(user_id) + if self.user_profile_service: + try: + retrieved_profile = self.user_profile_service.lookup(user_id) + except: + error = sys.exc_info()[1] + self.logger.log( + enums.LogLevels.ERROR, + 'Unable to retrieve user profile for user "%s" as lookup failed. Error: %s' % (user_id, str(error)) + ) + retrieved_profile = None + + if validator.is_user_profile_valid(retrieved_profile): + user_profile = UserProfile(**retrieved_profile) + variation = self.get_stored_variation(experiment, user_profile) + if variation: + return variation + else: + self.logger.log(enums.LogLevels.WARNING, 'User profile has invalid format.') + + # Bucket user and store the new decision + if not audience_helper.is_user_in_experiment(self.config, experiment, attributes): + self.logger.log( + enums.LogLevels.INFO, + 'User "%s" does not meet conditions to be in experiment "%s".' % (user_id, experiment.key) + ) + return None + + variation = self.bucketer.bucket(experiment, user_id) + + if variation: + # Store this new decision and return the variation for the user + if self.user_profile_service: + try: + user_profile.save_variation_for_experiment(experiment.id, variation.id) + self.user_profile_service.save(user_profile.__dict__) + except: + error = sys.exc_info()[1] + self.logger.log(enums.LogLevels.ERROR, + 'Unable to save user profile for user "%s". Error: %s' % (user_id, str(error))) + return variation + + return None diff --git a/optimizely/event_builder.py b/optimizely/event_builder.py index 3efacf2f..67ee655a 100644 --- a/optimizely/event_builder.py +++ b/optimizely/event_builder.py @@ -32,9 +32,8 @@ def __init__(self, url, params, http_verb=None, headers=None): class BaseEventBuilder(object): """ Base class which encapsulates methods to build events for tracking impressions and conversions. """ - def __init__(self, config, bucketer): + def __init__(self, config): self.config = config - self.bucketer = bucketer self.params = {} @abstractproperty @@ -183,14 +182,13 @@ def _add_required_params_for_impression(self, experiment, variation_id): self.EventParams.IS_LAYER_HOLDBACK: False } - def _add_required_params_for_conversion(self, event_key, user_id, event_tags, valid_experiments): + def _add_required_params_for_conversion(self, event_key, event_tags, decisions): """ Add parameters that are required for the conversion event to register. Args: event_key: Key representing the event which needs to be recorded. - user_id: ID for user. event_tags: Dict representing metadata associated with the event. - valid_experiments: List of tuples representing valid experiments IDs and variation IDs. + decisions: List of tuples representing valid experiments IDs and variation IDs. """ self.params[self.EventParams.IS_GLOBAL_HOLDBACK] = False @@ -219,7 +217,7 @@ def _add_required_params_for_conversion(self, event_key, user_id, event_tags, va self.params[self.EventParams.EVENT_FEATURES].append(event_feature) self.params[self.EventParams.LAYER_STATES] = [] - for experiment_id, variation_id in valid_experiments: + for experiment_id, variation_id in decisions: experiment = self.config.get_experiment_from_id(experiment_id) self.params[self.EventParams.LAYER_STATES].append({ self.EventParams.LAYER_ID: experiment.layerId, @@ -256,7 +254,7 @@ def create_impression_event(self, experiment, variation_id, user_id, attributes) http_verb=self.HTTP_VERB, headers=self.HTTP_HEADERS) - def create_conversion_event(self, event_key, user_id, attributes, event_tags, valid_experiments): + def create_conversion_event(self, event_key, user_id, attributes, event_tags, decisions): """ Create conversion Event to be sent to the logging endpoint. Args: @@ -264,7 +262,7 @@ def create_conversion_event(self, event_key, user_id, attributes, event_tags, va user_id: ID for user. attributes: Dict representing user attributes and values. event_tags: Dict representing metadata associated with the event. - valid_experiments: List of tuples representing experiments IDs and variation IDs. + decisions: List of tuples representing experiments IDs and variation IDs. Returns: Event object encapsulating the conversion event. @@ -272,7 +270,7 @@ def create_conversion_event(self, event_key, user_id, attributes, event_tags, va self.params = {} self._add_common_params(user_id, attributes) - self._add_required_params_for_conversion(event_key, user_id, event_tags, valid_experiments) + self._add_required_params_for_conversion(event_key, event_tags, decisions) return Event(self.CONVERSION_ENDPOINT, self.params, http_verb=self.HTTP_VERB, diff --git a/optimizely/helpers/validator.py b/optimizely/helpers/validator.py index 21aeff4d..9f5d3901 100644 --- a/optimizely/helpers/validator.py +++ b/optimizely/helpers/validator.py @@ -14,6 +14,7 @@ import json import jsonschema +from optimizely.user_profile import UserProfile from . import constants @@ -117,3 +118,36 @@ def are_event_tags_valid(event_tags): """ return type(event_tags) is dict + + +def is_user_profile_valid(user_profile): + """ Determine if provided user profile is valid or not. + + Args: + user_profile: User's profile which needs to be validated. + + Returns: + Boolean depending upon whether profile is valid or not. + """ + + if not user_profile: + return False + + if not type(user_profile) is dict: + return False + + if not UserProfile.USER_ID_KEY in user_profile: + return False + + if not UserProfile.EXPERIMENT_BUCKET_MAP_KEY in user_profile: + return False + + experiment_bucket_map = user_profile.get(UserProfile.EXPERIMENT_BUCKET_MAP_KEY) + if not type(experiment_bucket_map) is dict: + return False + + for decision in experiment_bucket_map.values(): + if type(decision) is not dict or UserProfile.VARIATION_ID_KEY not in decision: + return False + + return True diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index ee111df1..ef28bdd7 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -14,15 +14,13 @@ import numbers import sys -from . import bucketer +from . import decision_service from . import event_builder from . import exceptions from . import project_config from .error_handler import NoOpErrorHandler as noop_error_handler from .event_dispatcher import EventDispatcher as default_event_dispatcher -from .helpers import audience as audience_helper from .helpers import enums -from .helpers import experiment as experiment_helper from .helpers import validator from .logger import NoOpLogger as noop_logger from .logger import SimpleLogger @@ -55,7 +53,6 @@ def __init__(self, self.event_dispatcher = event_dispatcher or default_event_dispatcher self.logger = logger or noop_logger self.error_handler = error_handler or noop_error_handler - self.user_profile_service = user_profile_service try: self._validate_instantiation_options(datafile, skip_json_validation) @@ -80,8 +77,8 @@ def __init__(self, self.logger.log(enums.LogLevels.ERROR, enums.Errors.UNSUPPORTED_DATAFILE_VERSION) return - self.bucketer = bucketer.Bucketer(self.config) - self.event_builder = event_builder.EventBuilder(self.config, self.bucketer) + self.event_builder = event_builder.EventBuilder(self.config) + self.decision_service = decision_service.DecisionService(self.config, user_profile_service) def _validate_instantiation_options(self, datafile, skip_json_validation): """ Helper method to validate all instantiation parameters. @@ -106,25 +103,6 @@ def _validate_instantiation_options(self, datafile, skip_json_validation): if not validator.is_error_handler_valid(self.error_handler): raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT_ERROR.format('error_handler')) - def _validate_preconditions(self, experiment, attributes=None, event_tags=None): - """ Helper method to validate all pre-conditions before we go ahead to bucket user. - - Args: - experiment: Object representing the experiment. - attributes: Dict representing user attributes. - - Returns: - Boolean depending upon whether all conditions are met or not. - """ - if not self._validate_user_inputs(attributes, event_tags): - return False - - if not experiment_helper.is_experiment_running(experiment): - self.logger.log(enums.LogLevels.INFO, 'Experiment "%s" is not running.' % experiment.key) - return False - - return True - def _validate_user_inputs(self, attributes=None, event_tags=None): """ Helper method to validate user inputs. @@ -149,8 +127,8 @@ def _validate_user_inputs(self, attributes=None, event_tags=None): return True - def _get_valid_experiments_for_event(self, event, user_id, attributes): - """ Helper method to determine which experiments we should track for the given event. + def _get_decisions(self, event, user_id, attributes): + """ Helper method to retrieve decisions for the user for experiment(s) using the provided event. Args: event: The event which needs to be recorded. @@ -160,7 +138,7 @@ def _get_valid_experiments_for_event(self, event, user_id, attributes): Returns: List of tuples representing valid experiment IDs and variation IDs into which the user is bucketed. """ - valid_experiments = [] + decisions = [] for experiment_id in event.experimentIds: experiment = self.config.get_experiment_from_id(experiment_id) variation_key = self.get_variation(experiment.key, user_id, attributes) @@ -170,9 +148,9 @@ def _get_valid_experiments_for_event(self, event, user_id, attributes): continue variation = self.config.get_variation_from_key(experiment.key, variation_key) - valid_experiments.append((experiment_id, variation.id)) + decisions.append((experiment_id, variation.id)) - return valid_experiments + return decisions def activate(self, experiment_key, user_id, attributes=None): """ Buckets visitor and sends impression event to Optimizely. @@ -243,13 +221,15 @@ def track(self, event_key, user_id, attributes=None, event_tags=None): self.logger.log(enums.LogLevels.INFO, 'Not tracking user "%s" for event "%s".' % (user_id, event_key)) return - # Filter out experiments that are not running or that do not include the user in audience conditions - valid_experiments = self._get_valid_experiments_for_event(event, user_id, attributes) + # Filter out experiments that are not running or that do not include the user in audience + # conditions and then determine the decision i.e. the corresponding variation + decisions = self._get_decisions(event, user_id, attributes) - # Create and dispatch conversion event if there are valid experiments - if valid_experiments: - conversion_event = self.event_builder.create_conversion_event(event_key, user_id, attributes, event_tags, - valid_experiments) + # Create and dispatch conversion event if there are any decisions + if decisions: + conversion_event = self.event_builder.create_conversion_event( + event_key, user_id, attributes, event_tags, decisions + ) self.logger.log(enums.LogLevels.INFO, 'Tracking event "%s" for user "%s".' % (event_key, user_id)) self.logger.log(enums.LogLevels.DEBUG, 'Dispatching conversion event to URL %s with params %s.' % (conversion_event.url, @@ -287,22 +267,10 @@ def get_variation(self, experiment_key, user_id, attributes=None): user_id)) return None - if not self._validate_preconditions(experiment, attributes): + if not self._validate_user_inputs(attributes): return None - forced_variation = self.bucketer.get_forced_variation(experiment, user_id) - if forced_variation: - return forced_variation.key - - if not audience_helper.is_user_in_experiment(self.config, experiment, attributes): - self.logger.log( - enums.LogLevels.INFO, - 'User "%s" does not meet conditions to be in experiment "%s".' % (user_id, experiment.key) - ) - return None - - variation = self.bucketer.bucket(experiment, user_id) - + variation = self.decision_service.get_variation(experiment, user_id, attributes) if variation: return variation.key diff --git a/optimizely/user_profile.py b/optimizely/user_profile.py index 3fc64275..0c9f494e 100644 --- a/optimizely/user_profile.py +++ b/optimizely/user_profile.py @@ -20,10 +20,43 @@ class UserProfile(object): variation ID identifying the variation for the user. """ + USER_ID_KEY = 'user_id' + EXPERIMENT_BUCKET_MAP_KEY = 'experiment_bucket_map' + VARIATION_ID_KEY = 'variation_id' + def __init__(self, user_id, experiment_bucket_map=None, **kwargs): self.user_id = user_id self.experiment_bucket_map = experiment_bucket_map or {} + def __eq__(self, other): + return self.__dict__ == other.__dict__ + + def get_variation_for_experiment(self, experiment_id): + """ Helper method to retrieve variation ID for given experiment. + + Args: + experiment_id: ID for experiment for which variation needs to be looked up for. + + Returns: + Variation ID corresponding to the experiment. None if no decision available. + """ + + return self.experiment_bucket_map.get(experiment_id, {self.VARIATION_ID_KEY: None}).get(self.VARIATION_ID_KEY) + + def save_variation_for_experiment(self, experiment_id, variation_id): + """ Helper method to save new experiment/variation as part of the user's profile. + + Args: + experiment_id: ID for experiment for which the decision is to be stored. + variation_id: ID for variation that the user saw. + """ + + self.experiment_bucket_map.update({ + experiment_id: { + self.VARIATION_ID_KEY: variation_id + } + }) + class UserProfileService(object): """ Class encapsulating user profile service functionality. diff --git a/tests/helpers_tests/test_validator.py b/tests/helpers_tests/test_validator.py index b8783cbb..3f7da189 100644 --- a/tests/helpers_tests/test_validator.py +++ b/tests/helpers_tests/test_validator.py @@ -101,6 +101,35 @@ def test_are_event_tags_valid__returns_false(self): self.assertFalse(validator.are_event_tags_valid(['key', 'value'])) self.assertFalse(validator.are_event_tags_valid(42)) + def test_is_user_profile_valid__returns_true(self): + """ Test that valid user profile returns True. """ + + self.assertTrue(validator.is_user_profile_valid({'user_id': 'test_user', 'experiment_bucket_map': {}})) + self.assertTrue(validator.is_user_profile_valid({'user_id': 'test_user', + 'experiment_bucket_map': {'1234': {'variation_id': '5678'}}})) + self.assertTrue(validator.is_user_profile_valid({'user_id': 'test_user', + 'experiment_bucket_map': {'1234': {'variation_id': '5678'}}, + 'additional_key': 'additional_value'})) + self.assertTrue(validator.is_user_profile_valid({'user_id': 'test_user', + 'experiment_bucket_map': {'1234': + {'variation_id': '5678', + 'additional_key': 'additional_value'} + }})) + + def test_is_user_profile_valid__returns_false(self): + """ Test that invalid user profile returns True. """ + + self.assertFalse(validator.is_user_profile_valid(None)) + self.assertFalse(validator.is_user_profile_valid('user_id')) + self.assertFalse(validator.is_user_profile_valid({'some_key': 'some_value'})) + self.assertFalse(validator.is_user_profile_valid({'user_id': 'test_user'})) + self.assertFalse(validator.is_user_profile_valid({'user_id': 'test_user', 'experiment_bucket_map': 'some_value'})) + self.assertFalse(validator.is_user_profile_valid({'user_id': 'test_user', + 'experiment_bucket_map': {'1234': 'some_value'}})) + self.assertFalse(validator.is_user_profile_valid({'user_id': 'test_user', + 'experiment_bucket_map': {'1234': {'variation_id': '5678'}, + '1235': {'some_key': 'some_value'}}})) + class DatafileValidationTests(base.BaseTest): diff --git a/tests/test_bucketing.py b/tests/test_bucketing.py index 8da60eb0..bbcb0ae5 100644 --- a/tests/test_bucketing.py +++ b/tests/test_bucketing.py @@ -73,29 +73,6 @@ def test_bucket__invalid_experiment(self): self.assertIsNone(self.bucketer.bucket(self.project_config.get_experiment_from_key('invalid_experiment'), 'test_user')) - def test_get_forced_variation__user_in_forced_variation(self): - """ Test that bucket returns variation ID for variation user is forced in. """ - - with mock.patch('optimizely.bucketer.Bucketer._generate_bucket_value') as mock_generate_bucket_value: - self.assertEqual(entities.Variation('111128', 'control'), - self.bucketer.get_forced_variation(self.project_config.get_experiment_from_key('test_experiment'), 'user_1')) - - # Confirm that bucket value generation did not happen - self.assertEqual(0, mock_generate_bucket_value.call_count) - - def test_get_forced_variation__user_in_forced_variation__invalid_variation_id(self): - """ Test that bucket returns None when variation user is forced in is invalid. """ - - with mock.patch('optimizely.bucketer.Bucketer._generate_bucket_value') as mock_generate_bucket_value, \ - mock.patch('optimizely.project_config.ProjectConfig.get_variation_from_key', - return_value=None) as mock_get_variation_id: - self.assertIsNone(self.bucketer.get_forced_variation(self.project_config.get_experiment_from_key('test_experiment'), - 'user_1')) - - mock_get_variation_id.assert_called_once_with('test_experiment', 'control') - # Confirm that bucket value generation did not happen - self.assertEqual(0, mock_generate_bucket_value.call_count) - def test_bucket__experiment_in_group(self): """ Test that for provided bucket values correct variation ID is returned. """ @@ -131,16 +108,6 @@ def test_bucket__experiment_in_group(self): self.assertEqual([mock.call('test_user19228'), mock.call('test_user32222')], mock_generate_bucket_value.call_args_list) - def test_get_forced_variation__experiment_in_group__user_in_forced_variation(self): - """ Test that bucket returns variation ID for variation user is forced in. """ - - with mock.patch('optimizely.bucketer.Bucketer._generate_bucket_value') as mock_generate_bucket_value: - self.assertEqual(entities.Variation('28905', 'group_exp_2_control'), - self.bucketer.get_forced_variation(self.project_config.get_experiment_from_key('group_exp_2'), 'user_1')) - - # Confirm that bucket value generation did not happen - self.assertEqual(0, mock_generate_bucket_value.call_count) - def test_bucket_number(self): """ Test output of _generate_bucket_value for different inputs. """ @@ -228,16 +195,6 @@ def test_bucket(self): self.assertEqual(mock.call(enums.LogLevels.INFO, 'User "test_user" is in no variation.'), mock_logging.call_args_list[1]) - def test_get_forced_variation__user_in_forced_variation(self): - """ Test that expected log messages are logged during forced bucketing. """ - - with mock.patch('optimizely.bucketer.Bucketer._generate_bucket_value'),\ - mock.patch('optimizely.logger.SimpleLogger.log') as mock_logging: - self.assertEqual(entities.Variation('111128', 'control'), - self.bucketer.get_forced_variation(self.project_config.get_experiment_from_key('test_experiment'), 'user_1')) - - mock_logging.assert_called_with(enums.LogLevels.INFO, 'User "user_1" is forced in variation "control".') - def test_bucket__experiment_in_group(self): """ Test that for provided bucket values correct variation ID is returned. """ @@ -313,14 +270,3 @@ def test_bucket__experiment_in_group(self): mock_logging.call_args_list[2]) self.assertEqual(mock.call(enums.LogLevels.INFO, 'User "test_user" is in no variation.'), mock_logging.call_args_list[3]) - - def test_get_forced_variation__experiment_in_group__user_in_forced_variation(self): - """ Test that expected log messages are logged during forced bucketing. """ - - with mock.patch('optimizely.bucketer.Bucketer._generate_bucket_value'),\ - mock.patch('optimizely.logger.SimpleLogger.log') as mock_logging: - self.assertEqual(entities.Variation('28905', 'group_exp_2_control'), - self.bucketer.get_forced_variation(self.project_config.get_experiment_from_key('group_exp_2'), 'user_1')) - - # Confirm that bucket value generation did not happen - mock_logging.assert_called_with(enums.LogLevels.INFO, 'User "user_1" is forced in variation "group_exp_2_control".') diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py new file mode 100644 index 00000000..dfa67755 --- /dev/null +++ b/tests/test_decision_service.py @@ -0,0 +1,307 @@ +# Copyright 2017, 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 mock + +from optimizely import entities +from optimizely import user_profile +from optimizely.helpers import enums +from . import base + + +class DecisionServiceTest(base.BaseTest): + + def setUp(self): + base.BaseTest.setUp(self) + self.decision_service = self.optimizely.decision_service + # Set UserProfileService for the purposes of testing + self.decision_service.user_profile_service = user_profile.UserProfileService() + + def test_get_forced_variation__user_in_forced_variation(self): + """ Test that expected variation is returned if user is forced in a variation. """ + + experiment = self.project_config.get_experiment_from_key('test_experiment') + with mock.patch('optimizely.logger.NoOpLogger.log') as mock_logging: + self.assertEqual(entities.Variation('111128', 'control'), + self.decision_service.get_forced_variation(experiment, 'user_1')) + + mock_logging.assert_called_with(enums.LogLevels.INFO, 'User "user_1" is forced in variation "control".') + + def test_get_forced_variation__user_in_forced_variation__invalid_variation_id(self): + """ Test that get_forced_variation returns None when variation user is forced in is invalid. """ + + experiment = self.project_config.get_experiment_from_key('test_experiment') + with mock.patch('optimizely.project_config.ProjectConfig.get_variation_from_key', + return_value=None) as mock_get_variation_id: + self.assertIsNone(self.decision_service.get_forced_variation(experiment, 'user_1')) + + mock_get_variation_id.assert_called_once_with('test_experiment', 'control') + + def test_get_stored_variation__stored_decision_available(self): + """ Test that stored decision is retrieved as expected. """ + + experiment = self.project_config.get_experiment_from_key('test_experiment') + profile = user_profile.UserProfile('test_user', experiment_bucket_map={'111127': {'variation_id': '111128'}}) + with mock.patch('optimizely.logger.NoOpLogger.log') as mock_logging: + self.assertEqual(entities.Variation('111128', 'control'), + self.decision_service.get_stored_variation(experiment, profile)) + + mock_logging.assert_called_with( + enums.LogLevels.INFO, + 'Found a stored decision. User "test_user" is in variation "control" of experiment "test_experiment".' + ) + + def test_get_stored_variation__no_stored_decision_available(self): + """ Test that get_stored_variation returns None when no decision is available. """ + + experiment = self.project_config.get_experiment_from_key('test_experiment') + profile = user_profile.UserProfile('test_user') + self.assertIsNone(self.decision_service.get_stored_variation(experiment, profile)) + + def test_get_variation__experiment_not_running(self): + """ Test that get_variation returns None if experiment is not Running. """ + + experiment = self.project_config.get_experiment_from_key('test_experiment') + # Mark experiment paused + experiment.status = 'Paused' + with mock.patch('optimizely.decision_service.DecisionService.get_forced_variation') as mock_get_forced_variation, \ + mock.patch('optimizely.logger.NoOpLogger.log') as mock_logging, \ + mock.patch('optimizely.decision_service.DecisionService.get_stored_variation') as mock_get_stored_variation, \ + mock.patch('optimizely.helpers.audience.is_user_in_experiment') as mock_audience_check, \ + mock.patch('optimizely.bucketer.Bucketer.bucket') as mock_bucket, \ + mock.patch('optimizely.user_profile.UserProfileService.lookup') as mock_lookup, \ + mock.patch('optimizely.user_profile.UserProfileService.save') as mock_save: + self.assertIsNone(self.decision_service.get_variation(experiment, 'user_1', None)) + + mock_logging.assert_called_once_with(enums.LogLevels.INFO, 'Experiment "test_experiment" is not running.') + # Assert no calls are made to other services + self.assertEqual(0, mock_get_forced_variation.call_count) + self.assertEqual(0, mock_get_stored_variation.call_count) + self.assertEqual(0, mock_audience_check.call_count) + self.assertEqual(0, mock_bucket.call_count) + self.assertEqual(0, mock_lookup.call_count) + self.assertEqual(0, mock_save.call_count) + + def test_get_variation__user_forced_in_variation(self): + """ Test that get_variation returns forced variation if user is forced in a variation. """ + + experiment = self.project_config.get_experiment_from_key('test_experiment') + with mock.patch('optimizely.decision_service.DecisionService.get_forced_variation', + return_value=entities.Variation('111128', 'control')) as mock_get_forced_variation, \ + mock.patch('optimizely.decision_service.DecisionService.get_stored_variation') as mock_get_stored_variation, \ + mock.patch('optimizely.helpers.audience.is_user_in_experiment') as mock_audience_check, \ + mock.patch('optimizely.bucketer.Bucketer.bucket') as mock_bucket, \ + mock.patch('optimizely.user_profile.UserProfileService.lookup') as mock_lookup, \ + mock.patch('optimizely.user_profile.UserProfileService.save') as mock_save: + self.assertEqual(entities.Variation('111128', 'control'), + self.decision_service.get_variation(experiment, 'user_1', None)) + + # Assert that forced variation is returned and stored decision or bucketing service are not involved + mock_get_forced_variation.assert_called_once_with(experiment, 'user_1') + self.assertEqual(0, mock_get_stored_variation.call_count) + self.assertEqual(0, mock_audience_check.call_count) + self.assertEqual(0, mock_bucket.call_count) + self.assertEqual(0, mock_lookup.call_count) + self.assertEqual(0, mock_save.call_count) + + def test_get_variation__user_has_stored_decision(self): + """ Test that get_variation returns stored decision if user has variation available for given experiment. """ + + experiment = self.project_config.get_experiment_from_key('test_experiment') + with mock.patch('optimizely.decision_service.DecisionService.get_forced_variation', + return_value=None) as mock_get_forced_variation, \ + mock.patch('optimizely.decision_service.DecisionService.get_stored_variation', + return_value=entities.Variation('111128', 'control')) as mock_get_stored_variation, \ + mock.patch('optimizely.helpers.audience.is_user_in_experiment') as mock_audience_check, \ + mock.patch('optimizely.bucketer.Bucketer.bucket') as mock_bucket, \ + mock.patch( + 'optimizely.user_profile.UserProfileService.lookup', + return_value={'user_id': 'test_user', + 'experiment_bucket_map': {'111127': {'variation_id': '111128'}}}) as mock_lookup, \ + mock.patch('optimizely.user_profile.UserProfileService.save') as mock_save: + self.assertEqual(entities.Variation('111128', 'control'), + self.decision_service.get_variation(experiment, 'test_user', None)) + + # Assert that stored variation is returned and bucketing service is not involved + mock_get_forced_variation.assert_called_once_with(experiment, 'test_user') + mock_lookup.assert_called_once_with('test_user') + mock_get_stored_variation.assert_called_once_with(experiment, + user_profile.UserProfile('test_user', + {'111127': {'variation_id': '111128'}})) + self.assertEqual(0, mock_audience_check.call_count) + self.assertEqual(0, mock_bucket.call_count) + self.assertEqual(0, mock_save.call_count) + + def test_get_variation__user_bucketed_for_new_experiment__user_profile_service_available(self): + """ Test that get_variation buckets and returns variation if no forced variation or decision available. + Also, stores decision if user profile service is available. """ + + experiment = self.project_config.get_experiment_from_key('test_experiment') + with mock.patch('optimizely.decision_service.DecisionService.get_forced_variation', + return_value=None) as mock_get_forced_variation, \ + mock.patch('optimizely.decision_service.DecisionService.get_stored_variation', + return_value=None) as mock_get_stored_variation, \ + mock.patch('optimizely.helpers.audience.is_user_in_experiment', return_value=True) as mock_audience_check, \ + mock.patch('optimizely.bucketer.Bucketer.bucket', + 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: + self.assertEqual(entities.Variation('111129', 'variation'), + self.decision_service.get_variation(experiment, 'test_user', None)) + + # Assert that user is bucketed and new decision is stored + mock_get_forced_variation.assert_called_once_with(experiment, 'test_user') + mock_lookup.assert_called_once_with('test_user') + self.assertEqual(1, mock_get_stored_variation.call_count) + mock_audience_check.assert_called_once_with(self.project_config, experiment, None) + mock_bucket.assert_called_once_with(experiment, 'test_user') + mock_save.assert_called_once_with({'user_id': 'test_user', + 'experiment_bucket_map': {'111127': {'variation_id': '111129'}}}) + + def test_get_variation__user_bucketed_for_new_experiment__user_profile_service_not_available(self): + """ Test that get_variation buckets and returns variation if + no forced variation and no user profile service available. """ + + # Unset user profile service + self.decision_service.user_profile_service = None + + experiment = self.project_config.get_experiment_from_key('test_experiment') + with mock.patch('optimizely.decision_service.DecisionService.get_forced_variation', + return_value=None) as mock_get_forced_variation, \ + mock.patch('optimizely.decision_service.DecisionService.get_stored_variation') as mock_get_stored_variation, \ + mock.patch('optimizely.helpers.audience.is_user_in_experiment', return_value=True) as mock_audience_check, \ + mock.patch('optimizely.bucketer.Bucketer.bucket', + 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: + self.assertEqual(entities.Variation('111129', 'variation'), + self.decision_service.get_variation(experiment, 'test_user', None)) + + # Assert that user is bucketed and new decision is not stored as user profile service is not available + mock_get_forced_variation.assert_called_once_with(experiment, 'test_user') + self.assertEqual(0, mock_lookup.call_count) + self.assertEqual(0, mock_get_stored_variation.call_count) + mock_audience_check.assert_called_once_with(self.project_config, experiment, None) + mock_bucket.assert_called_once_with(experiment, 'test_user') + self.assertEqual(0, mock_save.call_count) + + def test_get_variation__user_does_not_meet_audience_conditions(self): + """ Test that get_variation returns None if user is not in experiment. """ + + experiment = self.project_config.get_experiment_from_key('test_experiment') + with mock.patch('optimizely.decision_service.DecisionService.get_forced_variation', + return_value=None) as mock_get_forced_variation, \ + mock.patch('optimizely.decision_service.DecisionService.get_stored_variation', + return_value=None) as mock_get_stored_variation, \ + mock.patch('optimizely.helpers.audience.is_user_in_experiment', return_value=False) as mock_audience_check, \ + mock.patch('optimizely.bucketer.Bucketer.bucket') 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: + self.assertIsNone(self.decision_service.get_variation(experiment, 'test_user', None)) + + # Assert that user is bucketed and new decision is stored + mock_get_forced_variation.assert_called_once_with(experiment, 'test_user') + mock_lookup.assert_called_once_with('test_user') + mock_get_stored_variation.assert_called_once_with(experiment, user_profile.UserProfile('test_user')) + mock_audience_check.assert_called_once_with(self.project_config, experiment, None) + self.assertEqual(0, mock_bucket.call_count) + self.assertEqual(0, mock_save.call_count) + + def test_get_variation__user_profile_in_invalid_format(self): + """ Test that get_variation handles invalid user profile gracefully. """ + + experiment = self.project_config.get_experiment_from_key('test_experiment') + with mock.patch('optimizely.decision_service.DecisionService.get_forced_variation', + return_value=None) as mock_get_forced_variation, \ + mock.patch('optimizely.decision_service.DecisionService.get_stored_variation') as mock_get_stored_variation, \ + mock.patch('optimizely.helpers.audience.is_user_in_experiment', return_value=True) as mock_audience_check, \ + mock.patch('optimizely.bucketer.Bucketer.bucket', + return_value=entities.Variation('111129', 'variation')) as mock_bucket, \ + mock.patch('optimizely.logger.NoOpLogger.log') as mock_logging, \ + mock.patch('optimizely.user_profile.UserProfileService.lookup', + return_value='invalid_profile') as mock_lookup, \ + mock.patch('optimizely.user_profile.UserProfileService.save') as mock_save: + self.assertEqual(entities.Variation('111129', 'variation'), + self.decision_service.get_variation(experiment, 'test_user', None)) + + # Assert that user is bucketed and new decision is stored + mock_get_forced_variation.assert_called_once_with(experiment, 'test_user') + mock_lookup.assert_called_once_with('test_user') + # Stored decision is not consulted as user profile is invalid + self.assertEqual(0, mock_get_stored_variation.call_count) + mock_audience_check.assert_called_once_with(self.project_config, experiment, None) + mock_logging.assert_called_with(enums.LogLevels.WARNING, 'User profile has invalid format.') + mock_bucket.assert_called_once_with(experiment, 'test_user') + mock_save.assert_called_once_with({'user_id': 'test_user', + 'experiment_bucket_map': {'111127': {'variation_id': '111129'}}}) + + def test_get_variation__user_profile_lookup_fails(self): + """ Test that get_variation acts gracefully when lookup fails. """ + + experiment = self.project_config.get_experiment_from_key('test_experiment') + with mock.patch('optimizely.decision_service.DecisionService.get_forced_variation', + return_value=None) as mock_get_forced_variation, \ + mock.patch('optimizely.decision_service.DecisionService.get_stored_variation') as mock_get_stored_variation, \ + mock.patch('optimizely.helpers.audience.is_user_in_experiment', return_value=True) as mock_audience_check, \ + mock.patch('optimizely.bucketer.Bucketer.bucket', + return_value=entities.Variation('111129', 'variation')) as mock_bucket, \ + mock.patch('optimizely.logger.NoOpLogger.log') as mock_logging, \ + 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: + self.assertEqual(entities.Variation('111129', 'variation'), + self.decision_service.get_variation(experiment, 'test_user', None)) + + # Assert that user is bucketed and new decision is stored + mock_get_forced_variation.assert_called_once_with(experiment, 'test_user') + mock_lookup.assert_called_once_with('test_user') + # Stored decision is not consulted as lookup failed + self.assertEqual(0, mock_get_stored_variation.call_count) + mock_audience_check.assert_called_once_with(self.project_config, experiment, None) + mock_logging.assert_any_call( + enums.LogLevels.ERROR, + 'Unable to retrieve user profile for user "test_user" as lookup failed. Error: major problem') + mock_bucket.assert_called_once_with(experiment, 'test_user') + mock_save.assert_called_once_with({'user_id': 'test_user', + 'experiment_bucket_map': {'111127': {'variation_id': '111129'}}}) + + def test_get_variation__user_profile_save_fails(self): + """ Test that get_variation acts gracefully when save fails. """ + + experiment = self.project_config.get_experiment_from_key('test_experiment') + with mock.patch('optimizely.decision_service.DecisionService.get_forced_variation', + return_value=None) as mock_get_forced_variation, \ + mock.patch('optimizely.decision_service.DecisionService.get_stored_variation') as mock_get_stored_variation, \ + mock.patch('optimizely.helpers.audience.is_user_in_experiment', return_value=True) as mock_audience_check, \ + mock.patch('optimizely.bucketer.Bucketer.bucket', + return_value=entities.Variation('111129', 'variation')) as mock_bucket, \ + mock.patch('optimizely.logger.NoOpLogger.log') as mock_logging, \ + 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: + self.assertEqual(entities.Variation('111129', 'variation'), + self.decision_service.get_variation(experiment, 'test_user', None)) + + # Assert that user is bucketed and new decision is stored + mock_get_forced_variation.assert_called_once_with(experiment, 'test_user') + mock_lookup.assert_called_once_with('test_user') + self.assertEqual(0, mock_get_stored_variation.call_count) + mock_audience_check.assert_called_once_with(self.project_config, experiment, None) + mock_logging.assert_any_call( + enums.LogLevels.ERROR, + 'Unable to save user profile for user "test_user". Error: major problem') + mock_bucket.assert_called_once_with(experiment, 'test_user') + mock_save.assert_called_once_with({'user_id': 'test_user', + 'experiment_bucket_map': {'111127': {'variation_id': '111129'}}}) diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index 83d5601e..08af8241 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -119,11 +119,11 @@ def test_invalid_json_raises_schema_validation_off(self): def test_activate(self): """ Test that activate calls dispatch_event with right params and returns expected variation. """ - with mock.patch('optimizely.helpers.audience.is_user_in_experiment', return_value=True) as mock_audience_check,\ - mock.patch('optimizely.bucketer.Bucketer.bucket', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129')) as mock_bucket,\ - mock.patch('time.time', return_value=42),\ - mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation', + return_value=self.project_config.get_variation_from_id('test_experiment', '111129')) as mock_decision,\ + mock.patch('time.time', return_value=42),\ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: self.assertEqual('variation', self.optimizely.activate('test_experiment', 'test_user')) expected_params = { @@ -143,9 +143,9 @@ def test_activate(self): 'clientVersion': version.__version__, 'clientEngine': 'python-sdk' } - mock_audience_check.assert_called_once_with(self.project_config, - self.project_config.get_experiment_from_key('test_experiment'), None) - mock_bucket.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment'), 'test_user') + mock_decision.assert_called_once_with( + self.project_config.get_experiment_from_key('test_experiment'), 'test_user', None + ) self.assertEqual(1, mock_dispatch_event.call_count) self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/log/decision', expected_params, 'POST', {'Content-Type': 'application/json'}) @@ -154,11 +154,11 @@ def test_activate__with_attributes__audience_match(self): """ Test that activate calls dispatch_event with right params and returns expected variation when attributes are provided and audience conditions are met. """ - with mock.patch('optimizely.helpers.audience.is_user_in_experiment', return_value=True) as mock_audience_check,\ - mock.patch('optimizely.bucketer.Bucketer.bucket', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129')) as mock_bucket,\ - mock.patch('time.time', return_value=42),\ - mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation', + 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('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: self.assertEqual('variation', self.optimizely.activate('test_experiment', 'test_user', {'test_attribute': 'test_value'})) @@ -185,10 +185,8 @@ def test_activate__with_attributes__audience_match(self): 'clientVersion': version.__version__, 'clientEngine': 'python-sdk' } - mock_audience_check.assert_called_once_with(self.project_config, - self.project_config.get_experiment_from_key('test_experiment'), - {'test_attribute': 'test_value'}) - mock_bucket.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment'), 'test_user') + mock_get_variation.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', {'test_attribute': 'test_value'}) self.assertEqual(1, mock_dispatch_event.call_count) self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/log/decision', expected_params, 'POST', {'Content-Type': 'application/json'}) @@ -263,10 +261,10 @@ def test_activate__invalid_object(self): def test_track__with_attributes(self): """ Test that track calls dispatch_event with right params when attributes are provided. """ - with mock.patch('optimizely.bucketer.Bucketer.bucket', + with mock.patch('optimizely.decision_service.DecisionService.get_variation', return_value=self.project_config.get_variation_from_id( 'test_experiment', '111128' - )) as mock_bucket,\ + )) as mock_get_variation,\ mock.patch('time.time', return_value=42),\ mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: self.optimizely.track('test_event', 'test_user', attributes={'test_attribute': 'test_value'}) @@ -302,7 +300,8 @@ def test_track__with_attributes(self): }], 'accountId': '12001' } - mock_bucket.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment'), 'test_user') + mock_get_variation.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', {'test_attribute': 'test_value'}) self.assertEqual(1, mock_dispatch_event.call_count) self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/log/event', expected_params, 'POST', {'Content-Type': 'application/json'}) @@ -334,10 +333,10 @@ def test_track__with_attributes__invalid_attributes(self): def test_track__with_event_value(self): """ Test that track calls dispatch_event with right params when event_value information is provided. """ - with mock.patch('optimizely.bucketer.Bucketer.bucket', + with mock.patch('optimizely.decision_service.DecisionService.get_variation', return_value=self.project_config.get_variation_from_id( 'test_experiment', '111128' - )) as mock_bucket,\ + )) as mock_get_variation,\ mock.patch('time.time', return_value=42),\ mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: self.optimizely.track('test_event', 'test_user', attributes={'test_attribute': 'test_value'}, @@ -387,7 +386,8 @@ def test_track__with_event_value(self): }], 'accountId': '12001' } - mock_bucket.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment'), 'test_user') + mock_get_variation.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', {'test_attribute': 'test_value'}) self.assertEqual(1, mock_dispatch_event.call_count) # Sort event features based on ID @@ -400,10 +400,10 @@ def test_track__with_event_value(self): def test_track__with_deprecated_event_value(self): """ Test that track calls dispatch_event with right params when event_value information is provided. """ - with mock.patch('optimizely.bucketer.Bucketer.bucket', + with mock.patch('optimizely.decision_service.DecisionService.get_variation', return_value=self.project_config.get_variation_from_id( 'test_experiment', '111128' - )) as mock_bucket,\ + )) as mock_get_variation,\ mock.patch('time.time', return_value=42),\ mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: self.optimizely.track('test_event', 'test_user', attributes={'test_attribute': 'test_value'}, event_tags=4200) @@ -447,7 +447,8 @@ def test_track__with_deprecated_event_value(self): }], 'accountId': '12001' } - mock_bucket.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment'), 'test_user') + mock_get_variation.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', {'test_attribute': 'test_value'}) self.assertEqual(1, mock_dispatch_event.call_count) self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/log/event', expected_params, 'POST', {'Content-Type': 'application/json'}) @@ -455,10 +456,10 @@ def test_track__with_deprecated_event_value(self): def test_track__with_invalid_event_value(self): """ Test that track calls dispatch_event with right params when event_value information is provided. """ - with mock.patch('optimizely.bucketer.Bucketer.bucket', + with mock.patch('optimizely.decision_service.DecisionService.get_variation', return_value=self.project_config.get_variation_from_id( 'test_experiment', '111128' - )) as mock_bucket,\ + )) as mock_get_variation,\ mock.patch('time.time', return_value=42),\ mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: self.optimizely.track('test_event', 'test_user', attributes={'test_attribute': 'test_value'}, @@ -501,7 +502,8 @@ def test_track__with_invalid_event_value(self): 'accountId': '12001' } - mock_bucket.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment'), 'test_user') + mock_get_variation.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', {'test_attribute': 'test_value'}) self.assertEqual(1, mock_dispatch_event.call_count) self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/log/event', expected_params, 'POST', {'Content-Type': 'application/json'}) @@ -590,9 +592,9 @@ def setUp(self): def test_activate(self): """ Test that expected log messages are logged during activate. """ - with mock.patch('optimizely.helpers.audience.is_user_in_experiment', return_value=True),\ - mock.patch('optimizely.bucketer.Bucketer.bucket', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129')),\ + with mock.patch('optimizely.decision_service.DecisionService.get_variation', + return_value=self.project_config.get_variation_from_id( + 'test_experiment', '111129')),\ mock.patch('time.time', return_value=42),\ mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event'),\ mock.patch('optimizely.logger.SimpleLogger.log') as mock_logging: @@ -609,8 +611,8 @@ def test_activate(self): def test_track(self): """ Test that expected log messages are logged during track. """ - with mock.patch('optimizely.bucketer.Bucketer.bucket', - return_value=self.project_config.get_variation_from_id('test_experiment', '111128')),\ + with mock.patch('optimizely.helpers.audience.is_user_in_experiment', + return_value=False),\ mock.patch('time.time', return_value=42),\ mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event'),\ mock.patch('optimizely.logger.SimpleLogger.log') as mock_logging: @@ -691,7 +693,8 @@ def test_track__deprecated_event_tag(self): self.optimizely.track('test_event', 'test_user', event_tags=4200) mock_logging.assert_any_call(enums.LogLevels.WARNING, - 'Event value is deprecated in track call. Use event tags to pass in revenue value instead.') + 'Event value is deprecated in track call. ' + 'Use event tags to pass in revenue value instead.') def test_track__invalid_event_tag(self): """ Test that expected log messages are logged during track when attributes are in invalid format. """ diff --git a/tests/test_user_profile.py b/tests/test_user_profile.py new file mode 100644 index 00000000..e2082ac8 --- /dev/null +++ b/tests/test_user_profile.py @@ -0,0 +1,52 @@ +# Copyright 2017, 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 unittest + +from optimizely import user_profile + + +class UserProfileTest(unittest.TestCase): + + def setUp(self): + user_id = 'test_user' + experiment_bucket_map = { + '199912': { + 'variation_id': '14512525' + } + } + + self.profile = user_profile.UserProfile(user_id, experiment_bucket_map=experiment_bucket_map) + + def test_get_variation_for_experiment__decision_exists(self): + """ Test that variation ID is retrieved correctly if a decision exists in the experiment bucket map. """ + + self.assertEqual('14512525', self.profile.get_variation_for_experiment('199912')) + + def test_get_variation_for_experiment__no_decision_exists(self): + """ Test that None is returned if no decision exists in the experiment bucket map. """ + + self.assertIsNone(self.profile.get_variation_for_experiment('199924')) + + def test_set_variation_for_experiment__no_previous_decision(self): + """ Test that decision for new experiment/variation is stored correctly. """ + + self.profile.save_variation_for_experiment('1993412', '118822') + self.assertEqual({'199912': {'variation_id': '14512525'}, + '1993412': {'variation_id': '118822'}}, self.profile.experiment_bucket_map) + + def test_set_variation_for_experiment__previous_decision_available(self): + """ Test that decision for is updated correctly if new experiment/variation combination is available. """ + + self.profile.save_variation_for_experiment('199912', '1224525') + self.assertEqual({'199912': {'variation_id': '1224525'}}, self.profile.experiment_bucket_map)