diff --git a/.travis.yml b/.travis.yml index 8eacd7c7..e4ece294 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,7 @@ language: python python: - "2.7" - "3.4" - - "3.5" + - "3.5.5" - "3.6" - "pypy" - "pypy3" diff --git a/CHANGELOG.md b/CHANGELOG.md index 500c15cc..2d37192e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.1.1 +August 21st, 2018 + +- Fix: record conversions for all experiments using an event when using track([#136](https://github.com/optimizely/python-sdk/pull/136)). + ## 2.1.0 July 2nd, 2018 diff --git a/optimizely/event_builder.py b/optimizely/event_builder.py index 907b20fa..30b74fbe 100644 --- a/optimizely/event_builder.py +++ b/optimizely/event_builder.py @@ -244,40 +244,41 @@ def _get_required_params_for_conversion(self, event_key, event_tags, decisions): Returns: Dict consisting of the decisions and events info for conversion event. """ + snapshot = {} + snapshot[self.EventParams.DECISIONS] = [] for experiment_id, variation_id in decisions: - snapshot = {} + experiment = self.config.get_experiment_from_id(experiment_id) if variation_id: - snapshot[self.EventParams.DECISIONS] = [{ + snapshot[self.EventParams.DECISIONS].append({ self.EventParams.EXPERIMENT_ID: experiment_id, self.EventParams.VARIATION_ID: variation_id, self.EventParams.CAMPAIGN_ID: experiment.layerId - }] + }) - event_dict = { - self.EventParams.EVENT_ID: self.config.get_event(event_key).id, - self.EventParams.TIME: self._get_time(), - self.EventParams.KEY: event_key, - self.EventParams.UUID: str(uuid.uuid4()) - } - - if event_tags: - revenue_value = event_tag_utils.get_revenue_value(event_tags, self.logger) - if revenue_value is not None: - event_dict[event_tag_utils.REVENUE_METRIC_TYPE] = revenue_value + event_dict = { + self.EventParams.EVENT_ID: self.config.get_event(event_key).id, + self.EventParams.TIME: self._get_time(), + self.EventParams.KEY: event_key, + self.EventParams.UUID: str(uuid.uuid4()) + } - numeric_value = event_tag_utils.get_numeric_value(event_tags, self.logger) - if numeric_value is not None: - event_dict[event_tag_utils.NUMERIC_METRIC_TYPE] = numeric_value + if event_tags: + revenue_value = event_tag_utils.get_revenue_value(event_tags, self.logger) + if revenue_value is not None: + event_dict[event_tag_utils.REVENUE_METRIC_TYPE] = revenue_value - if len(event_tags) > 0: - event_dict[self.EventParams.TAGS] = event_tags + numeric_value = event_tag_utils.get_numeric_value(event_tags, self.logger) + if numeric_value is not None: + event_dict[event_tag_utils.NUMERIC_METRIC_TYPE] = numeric_value - snapshot[self.EventParams.EVENTS] = [event_dict] + if len(event_tags) > 0: + event_dict[self.EventParams.TAGS] = event_tags - return snapshot + snapshot[self.EventParams.EVENTS] = [event_dict] + return snapshot def create_impression_event(self, experiment, variation_id, user_id, attributes): """ Create impression Event to be sent to the logging endpoint. @@ -320,7 +321,6 @@ def create_conversion_event(self, event_key, user_id, attributes, event_tags, de conversion_params = self._get_required_params_for_conversion(event_key, event_tags, decisions) params[self.EventParams.USERS][0][self.EventParams.SNAPSHOTS].append(conversion_params) - return Event(self.EVENTS_URL, params, http_verb=self.HTTP_VERB, diff --git a/optimizely/helpers/validator.py b/optimizely/helpers/validator.py index 9c27418f..48887089 100644 --- a/optimizely/helpers/validator.py +++ b/optimizely/helpers/validator.py @@ -1,4 +1,4 @@ -# Copyright 2016-2017, Optimizely +# Copyright 2016-2018, 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 @@ -13,6 +13,7 @@ import json import jsonschema +from six import string_types from optimizely.user_profile import UserProfile from . import constants @@ -151,3 +152,18 @@ def is_user_profile_valid(user_profile): return False return True + + +def is_non_empty_string(input_id_key): + """ Determine if provided input_id_key is a non-empty string or not. + + Args: + input_id_key: Variable which needs to be validated. + + Returns: + Boolean depending upon whether input is valid or not. + """ + if input_id_key and isinstance(input_id_key, string_types): + return True + + return False diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 18201789..c0a7f733 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -179,6 +179,7 @@ def _send_impression_event(self, experiment, variation, user_id, attributes): self.event_dispatcher.dispatch_event(impression_event) except: self.logger.exception('Unable to dispatch impression event!') + self.notification_center.send_notifications(enums.NotificationTypes.ACTIVATE, experiment, user_id, attributes, variation, impression_event) @@ -262,6 +263,14 @@ def activate(self, experiment_key, user_id, attributes=None): self.logger.error(enums.Errors.INVALID_DATAFILE.format('activate')) return None + if not validator.is_non_empty_string(experiment_key): + self.logger.error(enums.Errors.INVALID_INPUT_ERROR.format('experiment_key')) + return None + + if not validator.is_non_empty_string(user_id): + self.logger.error(enums.Errors.INVALID_INPUT_ERROR.format('user_id')) + return None + variation_key = self.get_variation(experiment_key, user_id, attributes) if not variation_key: @@ -291,6 +300,14 @@ def track(self, event_key, user_id, attributes=None, event_tags=None): self.logger.error(enums.Errors.INVALID_DATAFILE.format('track')) return + if not validator.is_non_empty_string(event_key): + self.logger.error(enums.Errors.INVALID_INPUT_ERROR.format('event_key')) + return + + if not validator.is_non_empty_string(user_id): + self.logger.error(enums.Errors.INVALID_INPUT_ERROR.format('user_id')) + return + if not self._validate_user_inputs(attributes, event_tags): return @@ -339,6 +356,14 @@ def get_variation(self, experiment_key, user_id, attributes=None): self.logger.error(enums.Errors.INVALID_DATAFILE.format('get_variation')) return None + if not validator.is_non_empty_string(experiment_key): + self.logger.error(enums.Errors.INVALID_INPUT_ERROR.format('experiment_key')) + return None + + if not validator.is_non_empty_string(user_id): + self.logger.error(enums.Errors.INVALID_INPUT_ERROR.format('user_id')) + return None + experiment = self.config.get_experiment_from_key(experiment_key) if not experiment: @@ -373,12 +398,12 @@ def is_feature_enabled(self, feature_key, user_id, attributes=None): self.logger.error(enums.Errors.INVALID_DATAFILE.format('is_feature_enabled')) return False - if feature_key is None: - self.logger.error(enums.Errors.NONE_FEATURE_KEY_PARAMETER) + if not validator.is_non_empty_string(feature_key): + self.logger.error(enums.Errors.INVALID_INPUT_ERROR.format('feature_key')) return False - if user_id is None: - self.logger.error(enums.Errors.NONE_USER_ID_PARAMETER) + if not validator.is_non_empty_string(user_id): + self.logger.error(enums.Errors.INVALID_INPUT_ERROR.format('user_id')) return False feature = self.config.get_feature_from_key(feature_key) @@ -417,6 +442,10 @@ def get_enabled_features(self, user_id, attributes=None): self.logger.error(enums.Errors.INVALID_DATAFILE.format('get_enabled_features')) return enabled_features + if not validator.is_non_empty_string(user_id): + self.logger.error(enums.Errors.INVALID_INPUT_ERROR.format('user_id')) + return enabled_features + for feature in self.config.feature_key_map.values(): if self.is_feature_enabled(feature.key, user_id, attributes): enabled_features.append(feature.key) diff --git a/optimizely/version.py b/optimizely/version.py index e91c0145..92bf4020 100644 --- a/optimizely/version.py +++ b/optimizely/version.py @@ -11,5 +11,5 @@ # See the License for the specific language governing permissions and # limitations under the License. -version_info = (2, 1, 0) +version_info = (2, 1, 1) __version__ = '.'.join(str(v) for v in version_info) diff --git a/tests/base.py b/tests/base.py index 72b78c7a..05be7935 100644 --- a/tests/base.py +++ b/tests/base.py @@ -19,7 +19,7 @@ class BaseTest(unittest.TestCase): - def setUp(self): + def setUp(self, config_dict='config_dict'): self.config_dict = { 'revision': '42', 'version': '2', @@ -375,5 +375,152 @@ def setUp(self): }] } - self.optimizely = optimizely.Optimizely(json.dumps(self.config_dict)) + self.config_dict_with_multiple_experiments = { + 'revision': '42', + 'version': '2', + 'events': [{ + 'key': 'test_event', + 'experimentIds': ['111127', '111130'], + 'id': '111095' + }, { + 'key': 'Total Revenue', + 'experimentIds': ['111127'], + 'id': '111096' + }], + 'experiments': [{ + 'key': 'test_experiment', + 'status': 'Running', + 'forcedVariations': { + 'user_1': 'control', + 'user_2': 'control' + }, + 'layerId': '111182', + 'audienceIds': ['11154'], + 'trafficAllocation': [{ + 'entityId': '111128', + 'endOfRange': 4000 + }, { + 'entityId': '', + 'endOfRange': 5000 + }, { + 'entityId': '111129', + 'endOfRange': 9000 + }], + 'id': '111127', + 'variations': [{ + 'key': 'control', + 'id': '111128' + }, { + 'key': 'variation', + 'id': '111129' + }] + }, { + 'key': 'test_experiment_2', + 'status': 'Running', + 'forcedVariations': { + 'user_1': 'control', + 'user_2': 'control' + }, + 'layerId': '111182', + 'audienceIds': ['11154'], + 'trafficAllocation': [{ + 'entityId': '111131', + 'endOfRange': 4000 + }, { + 'entityId': '', + 'endOfRange': 5000 + }, { + 'entityId': '111132', + 'endOfRange': 9000 + }], + 'id': '111130', + 'variations': [{ + 'key': 'control', + 'id': '111133' + }, { + 'key': 'variation', + 'id': '111134' + }] + }], + 'groups': [{ + 'id': '19228', + 'policy': 'random', + 'experiments': [{ + 'id': '32222', + 'key': 'group_exp_1', + 'status': 'Running', + 'audienceIds': [], + 'layerId': '111183', + 'variations': [{ + 'key': 'group_exp_1_control', + 'id': '28901' + }, { + 'key': 'group_exp_1_variation', + 'id': '28902' + }], + 'forcedVariations': { + 'user_1': 'group_exp_1_control', + 'user_2': 'group_exp_1_control' + }, + 'trafficAllocation': [{ + 'entityId': '28901', + 'endOfRange': 3000 + }, { + 'entityId': '28902', + 'endOfRange': 9000 + }] + }, { + 'id': '32223', + 'key': 'group_exp_2', + 'status': 'Running', + 'audienceIds': [], + 'layerId': '111184', + 'variations': [{ + 'key': 'group_exp_2_control', + 'id': '28905' + }, { + 'key': 'group_exp_2_variation', + 'id': '28906' + }], + 'forcedVariations': { + 'user_1': 'group_exp_2_control', + 'user_2': 'group_exp_2_control' + }, + 'trafficAllocation': [{ + 'entityId': '28905', + 'endOfRange': 8000 + }, { + 'entityId': '28906', + 'endOfRange': 10000 + }] + }], + 'trafficAllocation': [{ + 'entityId': '32222', + "endOfRange": 3000 + }, { + 'entityId': '32223', + 'endOfRange': 7500 + }] + }], + 'accountId': '12001', + 'attributes': [{ + 'key': 'test_attribute', + 'id': '111094' + }], + 'audiences': [{ + 'name': 'Test attribute users 1', + 'conditions': '["and", ["or", ["or", ' + '{"name": "test_attribute", "type": "custom_attribute", "value": "test_value_1"}]]]', + 'id': '11154' + }, { + 'name': 'Test attribute users 2', + 'conditions': '["and", ["or", ["or", ' + '{"name": "test_attribute", "type": "custom_attribute", "value": "test_value_2"}]]]', + 'id': '11159' + }], + 'projectId': '111001' + } + + config = getattr(self, config_dict) + self.optimizely = optimizely.Optimizely(json.dumps(config)) self.project_config = self.optimizely.config diff --git a/tests/helpers_tests/test_validator.py b/tests/helpers_tests/test_validator.py index 4c833d95..33d7f7be 100644 --- a/tests/helpers_tests/test_validator.py +++ b/tests/helpers_tests/test_validator.py @@ -1,4 +1,4 @@ -# Copyright 2016-2017, Optimizely +# Copyright 2016-2018, 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 @@ -130,6 +130,22 @@ def test_is_user_profile_valid__returns_false(self): 'experiment_bucket_map': {'1234': {'variation_id': '5678'}, '1235': {'some_key': 'some_value'}}})) + def test_is_non_empty_string(self): + """ Test that the method returns True only for a non-empty string. """ + + self.assertFalse(validator.is_non_empty_string(None)) + self.assertFalse(validator.is_non_empty_string([])) + self.assertFalse(validator.is_non_empty_string({})) + self.assertFalse(validator.is_non_empty_string(0)) + self.assertFalse(validator.is_non_empty_string(99)) + self.assertFalse(validator.is_non_empty_string(1.2)) + self.assertFalse(validator.is_non_empty_string(True)) + self.assertFalse(validator.is_non_empty_string(False)) + self.assertFalse(validator.is_non_empty_string('')) + + self.assertTrue(validator.is_non_empty_string('0')) + self.assertTrue(validator.is_non_empty_string('test_user')) + class DatafileValidationTests(base.BaseTest): diff --git a/tests/test_event_builder.py b/tests/test_event_builder.py index 17dd1b88..56ccc2ef 100644 --- a/tests/test_event_builder.py +++ b/tests/test_event_builder.py @@ -42,7 +42,7 @@ def test_init(self): class EventBuilderTest(base.BaseTest): def setUp(self): - base.BaseTest.setUp(self) + base.BaseTest.setUp(self, 'config_dict_with_multiple_experiments') self.event_builder = self.optimizely.event_builder def _validate_event_object(self, event_obj, expected_url, expected_params, expected_verb, expected_headers): @@ -655,3 +655,64 @@ def test_create_conversion_event__with_invalid_event_tags(self): expected_params, event_builder.EventBuilder.HTTP_VERB, event_builder.EventBuilder.HTTP_HEADERS) + + def test_create_conversion_event__when_event_is_used_in_multiple_experiments(self): + """ Test that create_conversion_event creates Event object with + right params when multiple experiments use the same event. """ + + expected_params = { + 'client_version': version.__version__, + 'project_id': '111001', + 'visitors': [{ + 'attributes': [{ + 'entity_id': '111094', + 'type': 'custom', + 'value': 'test_value', + 'key': 'test_attribute' + }], + 'visitor_id': 'test_user', + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111129', + 'experiment_id': '111127', + 'campaign_id': '111182' + }, { + 'experiment_id': '111130', + 'variation_id': '111131', + 'campaign_id': '111182' + }], + 'events': [{ + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'tags': { + 'non-revenue': 'abc', + 'revenue': 4200, + 'value': 1.234 + }, + 'timestamp': 42123, + 'revenue': 4200, + 'value': 1.234, + 'key': 'test_event', + 'entity_id': '111095' + }] + }] + }], + 'account_id': '12001', + 'client_name': 'python-sdk', + 'anonymize_ip': False, + 'revision': '42' + } + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'): + event_obj = self.event_builder.create_conversion_event( + 'test_event', + 'test_user', + {'test_attribute': 'test_value'}, + {'revenue': 4200, 'value': 1.234, 'non-revenue': 'abc'}, + [('111127', '111129'), ('111130', '111131')] + ) + self._validate_event_object(event_obj, + event_builder.EventBuilder.EVENTS_URL, + expected_params, + event_builder.EventBuilder.HTTP_VERB, + event_builder.EventBuilder.HTTP_HEADERS) diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index 8f108759..590a701c 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -1143,6 +1143,30 @@ def test_track__invalid_object(self): mock_client_logging.error.assert_called_once_with('Datafile has invalid format. Failing "track".') + def test_track__invalid_experiment_key(self): + """ Test that None is returned and expected log messages are logged during track \ + when exp_key is in invalid format. """ + + with mock.patch.object(self.optimizely, 'logger') as mock_client_logging, \ + mock.patch('optimizely.helpers.validator.is_non_empty_string', return_value=False) as mock_validator: + self.assertIsNone(self.optimizely.track(99, 'test_user')) + + mock_validator.assert_any_call(99) + + mock_client_logging.error.assert_called_once_with('Provided "event_key" is in an invalid format.') + + def test_track__invalid_user_id(self): + """ Test that None is returned and expected log messages are logged during track \ + when user_id is in invalid format. """ + + with mock.patch.object(self.optimizely, 'logger') as mock_client_logging, \ + mock.patch('optimizely.helpers.validator.is_non_empty_string', side_effect=[True, False]) as mock_validator: + self.assertIsNone(self.optimizely.track('test_event', 99)) + + mock_validator.assert_any_call(99) + + mock_client_logging.error.assert_called_once_with('Provided "user_id" is in an invalid format.') + def test_get_variation__invalid_object(self): """ Test that get_variation logs error if Optimizely object is not created correctly. """ @@ -1153,7 +1177,7 @@ def test_get_variation__invalid_object(self): mock_client_logging.error.assert_called_once_with('Datafile has invalid format. Failing "get_variation".') - def test_get_variation_invalid_experiment_key(self): + def test_get_variation_unknown_experiment_key(self): """ Test that get_variation retuns None when invalid experiment key is given. """ with mock.patch.object(self.optimizely, 'logger') as mock_client_logging: self.optimizely.get_variation('aabbccdd', 'test_user', None) @@ -1162,25 +1186,29 @@ def test_get_variation_invalid_experiment_key(self): 'Experiment key "aabbccdd" is invalid. Not activating user "test_user".' ) - def test_is_feature_enabled__returns_false_for_none_feature_key(self): - """ Test that is_feature_enabled returns false if the provided feature key is None. """ + def test_is_feature_enabled__returns_false_for_invalid_feature_key(self): + """ Test that is_feature_enabled returns false if the provided feature key is invalid. """ opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) - with mock.patch.object(opt_obj, 'logger') as mock_client_logging: + with mock.patch.object(opt_obj, 'logger') as mock_client_logging,\ + mock.patch('optimizely.helpers.validator.is_non_empty_string', return_value=False) as mock_validator: self.assertFalse(opt_obj.is_feature_enabled(None, 'test_user')) - mock_client_logging.error.assert_called_once_with(enums.Errors.NONE_FEATURE_KEY_PARAMETER) + mock_validator.assert_any_call(None) + mock_client_logging.error.assert_called_with('Provided "feature_key" is in an invalid format.') - def test_is_feature_enabled__returns_false_for_none_user_id(self): - """ Test that is_feature_enabled returns false if the provided user ID is None. """ + def test_is_feature_enabled__returns_false_for_invalid_user_id(self): + """ Test that is_feature_enabled returns false if the provided user ID is invalid. """ opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) - with mock.patch.object(opt_obj, 'logger') as mock_client_logging: - self.assertFalse(opt_obj.is_feature_enabled('feature_key', None)) + with mock.patch.object(opt_obj, 'logger') as mock_client_logging,\ + mock.patch('optimizely.helpers.validator.is_non_empty_string', side_effect=[True, False]) as mock_validator: + self.assertFalse(opt_obj.is_feature_enabled('feature_key', 1.2)) - mock_client_logging.error.assert_called_once_with(enums.Errors.NONE_USER_ID_PARAMETER) + mock_validator.assert_any_call(1.2) + mock_client_logging.error.assert_called_with('Provided "user_id" is in an invalid format.') def test_is_feature_enabled__returns_false_for_invalid_feature(self): """ Test that the feature is not enabled for the user if the provided feature key is invalid. """ @@ -1188,7 +1216,7 @@ def test_is_feature_enabled__returns_false_for_invalid_feature(self): opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) with mock.patch('optimizely.decision_service.DecisionService.get_variation_for_feature') as mock_decision, \ - mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: self.assertFalse(opt_obj.is_feature_enabled('invalid_feature', 'user1')) self.assertFalse(mock_decision.called) @@ -1462,6 +1490,14 @@ def side_effect(*args, **kwargs): mock_is_feature_enabled.assert_any_call('test_feature_in_group', 'user_1', None) mock_is_feature_enabled.assert_any_call('test_feature_in_experiment_and_rollout', 'user_1', None) + def test_get_enabled_features_invalid_user_id(self): + with mock.patch.object(self.optimizely, 'logger') as mock_client_logging, \ + mock.patch('optimizely.helpers.validator.is_non_empty_string', return_value=False) as mock_validator: + self.optimizely.get_enabled_features(1.2) + + mock_validator.assert_any_call(1.2) + mock_client_logging.error.assert_called_once_with('Provided "user_id" is in an invalid format.') + def test_get_enabled_features__invalid_object(self): """ Test that get_enabled_features returns empty list if Optimizely object is not valid. """ @@ -2003,6 +2039,52 @@ def test_get_variation__invalid_attributes(self): mock_client_logging.error.assert_called_once_with('Provided attributes are in an invalid format.') + def test_get_variation__invalid_experiment_key(self): + """ Test that None is returned and expected log messages are logged during get_variation \ + when exp_key is in invalid format. """ + + with mock.patch.object(self.optimizely, 'logger') as mock_client_logging,\ + mock.patch('optimizely.helpers.validator.is_non_empty_string', return_value=False) as mock_validator: + self.assertIsNone(self.optimizely.get_variation(99, 'test_user')) + + mock_validator.assert_any_call(99) + mock_client_logging.error.assert_called_once_with('Provided "experiment_key" is in an invalid format.') + + def test_get_variation__invalid_user_id(self): + """ Test that None is returned and expected log messages are logged during get_variation \ + when user_id is in invalid format. """ + + with mock.patch.object(self.optimizely, 'logger') as mock_client_logging,\ + mock.patch('optimizely.helpers.validator.is_non_empty_string', side_effect=[True, False]) as mock_validator: + self.assertIsNone(self.optimizely.get_variation('test_experiment', 99)) + + mock_validator.assert_any_call(99) + mock_client_logging.error.assert_called_once_with('Provided "user_id" is in an invalid format.') + + def test_activate__invalid_experiment_key(self): + """ Test that None is returned and expected log messages are logged during activate \ + when exp_key is in invalid format. """ + + with mock.patch.object(self.optimizely, 'logger') as mock_client_logging,\ + mock.patch('optimizely.helpers.validator.is_non_empty_string', return_value=False) as mock_validator: + self.assertIsNone(self.optimizely.activate(99, 'test_user')) + + mock_validator.assert_any_call(99) + + mock_client_logging.error.assert_called_once_with('Provided "experiment_key" is in an invalid format.') + + def test_activate__invalid_user_id(self): + """ Test that None is returned and expected log messages are logged during activate \ + when user_id is in invalid format. """ + + with mock.patch.object(self.optimizely, 'logger') as mock_client_logging,\ + mock.patch('optimizely.helpers.validator.is_non_empty_string', side_effect=[True, False]) as mock_validator: + self.assertIsNone(self.optimizely.activate('test_experiment', 99)) + + mock_validator.assert_any_call(99) + + mock_client_logging.error.assert_called_once_with('Provided "user_id" is in an invalid format.') + def test_activate__invalid_attributes(self): """ Test that expected log messages are logged during activate when attributes are in invalid format. """ with mock.patch.object(self.optimizely, 'logger') as mock_client_logging: