diff --git a/optimizely/helpers/condition.py b/optimizely/helpers/condition.py index 0676aecb..3321f9f5 100644 --- a/optimizely/helpers/condition.py +++ b/optimizely/helpers/condition.py @@ -18,6 +18,7 @@ from . import validator from .enums import CommonAudienceEvaluationLogs as audience_logs +from .enums import Errors, SemverType class ConditionOperatorTypes(object): @@ -30,7 +31,14 @@ class ConditionMatchTypes(object): EXACT = 'exact' EXISTS = 'exists' GREATER_THAN = 'gt' + GREATER_THAN_OR_EQUAL = 'ge' LESS_THAN = 'lt' + LESS_THAN_OR_EQUAL = 'le' + SEMVER_EQ = 'semver_eq' + SEMVER_GE = 'semver_ge' + SEMVER_GT = 'semver_gt' + SEMVER_LE = 'semver_le' + SEMVER_LT = 'semver_lt' SUBSTRING = 'substring' @@ -84,6 +92,105 @@ def is_value_a_number(self, value): return False + def is_pre_release(self, target): + """ Method to check if the given version contains "-" + + Args: + target: Given version in string. + + Returns: + Boolean: + - True if the given version does contain "-" + - False if it doesn't + """ + return SemverType.IS_PRE_RELEASE in target + + def is_build(self, target): + """ Method to check if the given version contains "+" + + Args: + target: Given version in string. + + Returns: + Boolean: + - True if the given version does contain "+" + - False if it doesn't + """ + return SemverType.IS_BUILD in target + + def has_white_space(self, target): + """ Method to check if the given version contains " " (white space) + + Args: + target: Given version in string. + + Returns: + Boolean: + - True if the given version does contain " " + - False if it doesn't + """ + return SemverType.HAS_WHITE_SPACE in target + + def compare_user_version_with_target_version(self, index): + """ Method to compare user version with target version. + + Args: + index: Index of the condition to be evaluated. + + Returns: + Int: + - 0 if user version is equal to target version. + - 1 if user version is greater than target version. + - -1 if user version is less than target version or, in case of exact string match, doesn't match the target + version. + None: + - if the user version value is not string type or is null. + """ + condition_name = self.condition_data[index][0] + target_version = self.condition_data[index][1] + user_version = self.attributes.get(condition_name) + + if not isinstance(target_version, string_types): + self.logger.warning(audience_logs.UNKNOWN_CONDITION_VALUE.format(self._get_condition_json(index), )) + return None + + if not isinstance(user_version, string_types): + self.logger.warning( + audience_logs.UNEXPECTED_TYPE.format( + self._get_condition_json(index), type(user_version), condition_name + ) + ) + return None + + target_version_parts = self.split_semantic_version(target_version) + if target_version_parts is None: + return None + + user_version_parts = self.split_semantic_version(user_version) + if user_version_parts is None: + return None + + user_version_parts_len = len(user_version_parts) + + for (idx, _) in enumerate(target_version_parts): + if user_version_parts_len <= idx: + return 1 if self.is_pre_release(target_version) else -1 + elif not user_version_parts[idx].isdigit(): + if user_version_parts[idx] < target_version_parts[idx]: + return -1 + elif user_version_parts[idx] > target_version_parts[idx]: + return 1 + else: + user_version_part = int(user_version_parts[idx]) + target_version_part = int(target_version_parts[idx]) + if user_version_part > target_version_part: + return 1 + elif user_version_part < target_version_part: + return -1 + if self.is_pre_release(user_version) and not self.is_pre_release(target_version): + return -1 + return 0 + def exact_evaluator(self, index): """ Evaluate the given exact match condition for the user attributes. @@ -171,6 +278,40 @@ def greater_than_evaluator(self, index): return user_value > condition_value + def greater_than_or_equal_evaluator(self, index): + """ Evaluate the given greater than or equal to match condition for the user attributes. + + Args: + index: Index of the condition to be evaluated. + + Returns: + Boolean: + - True if the user attribute value is greater than or equal to the condition value. + - False if the user attribute value is less than the condition value. + None: if the condition value isn't finite or the user attribute value isn't finite. + """ + condition_name = self.condition_data[index][0] + condition_value = self.condition_data[index][1] + user_value = self.attributes.get(condition_name) + + if not validator.is_finite_number(condition_value): + self.logger.warning(audience_logs.UNKNOWN_CONDITION_VALUE.format(self._get_condition_json(index))) + return None + + if not self.is_value_a_number(user_value): + self.logger.warning( + audience_logs.UNEXPECTED_TYPE.format(self._get_condition_json(index), type(user_value), condition_name) + ) + return None + + if not validator.is_finite_number(user_value): + self.logger.warning( + audience_logs.INFINITE_ATTRIBUTE_VALUE.format(self._get_condition_json(index), condition_name) + ) + return None + + return user_value >= condition_value + def less_than_evaluator(self, index): """ Evaluate the given less than match condition for the user attributes. @@ -205,6 +346,40 @@ def less_than_evaluator(self, index): return user_value < condition_value + def less_than_or_equal_evaluator(self, index): + """ Evaluate the given less than or equal to match condition for the user attributes. + + Args: + index: Index of the condition to be evaluated. + + Returns: + Boolean: + - True if the user attribute value is less than or equal to the condition value. + - False if the user attribute value is greater than the condition value. + None: if the condition value isn't finite or the user attribute value isn't finite. + """ + condition_name = self.condition_data[index][0] + condition_value = self.condition_data[index][1] + user_value = self.attributes.get(condition_name) + + if not validator.is_finite_number(condition_value): + self.logger.warning(audience_logs.UNKNOWN_CONDITION_VALUE.format(self._get_condition_json(index))) + return None + + if not self.is_value_a_number(user_value): + self.logger.warning( + audience_logs.UNEXPECTED_TYPE.format(self._get_condition_json(index), type(user_value), condition_name) + ) + return None + + if not validator.is_finite_number(user_value): + self.logger.warning( + audience_logs.INFINITE_ATTRIBUTE_VALUE.format(self._get_condition_json(index), condition_name) + ) + return None + + return user_value <= condition_value + def substring_evaluator(self, index): """ Evaluate the given substring match condition for the given user attributes. @@ -233,14 +408,171 @@ def substring_evaluator(self, index): return condition_value in user_value + def semver_equal_evaluator(self, index): + """ Evaluate the given semantic version equal match target version for the user version. + + Args: + index: Index of the condition to be evaluated. + + Returns: + Boolean: + - True if the user version is equal (==) to the target version. + - False if the user version is not equal (!=) to the target version. + None: + - if the user version value is not string type or is null. + """ + result = self.compare_user_version_with_target_version(index) + if result is None: + return None + + return result == 0 + + def semver_greater_than_evaluator(self, index): + """ Evaluate the given semantic version greater than match target version for the user version. + + Args: + index: Index of the condition to be evaluated. + + Returns: + Boolean: + - True if the user version is greater than the target version. + - False if the user version is less than or equal to the target version. + None: + - if the user version value is not string type or is null. + """ + result = self.compare_user_version_with_target_version(index) + if result is None: + return None + + return result > 0 + + def semver_less_than_evaluator(self, index): + """ Evaluate the given semantic version less than match target version for the user version. + + Args: + index: Index of the condition to be evaluated. + + Returns: + Boolean: + - True if the user version is less than the target version. + - False if the user version is greater than or equal to the target version. + None: + - if the user version value is not string type or is null. + """ + result = self.compare_user_version_with_target_version(index) + if result is None: + return None + + return result < 0 + + def semver_less_than_or_equal_evaluator(self, index): + """ Evaluate the given semantic version less than or equal to match target version for the user version. + + Args: + index: Index of the condition to be evaluated. + + Returns: + Boolean: + - True if the user version is less than or equal to the target version. + - False if the user version is greater than the target version. + None: + - if the user version value is not string type or is null. + """ + result = self.compare_user_version_with_target_version(index) + if result is None: + return None + + return result <= 0 + + def semver_greater_than_or_equal_evaluator(self, index): + """ Evaluate the given semantic version greater than or equal to match target version for the user version. + + Args: + index: Index of the condition to be evaluated. + + Returns: + Boolean: + - True if the user version is greater than or equal to the target version. + - False if the user version is less than the target version. + None: + - if the user version value is not string type or is null. + """ + result = self.compare_user_version_with_target_version(index) + if result is None: + return None + + return result >= 0 + EVALUATORS_BY_MATCH_TYPE = { ConditionMatchTypes.EXACT: exact_evaluator, ConditionMatchTypes.EXISTS: exists_evaluator, ConditionMatchTypes.GREATER_THAN: greater_than_evaluator, + ConditionMatchTypes.GREATER_THAN_OR_EQUAL: greater_than_or_equal_evaluator, ConditionMatchTypes.LESS_THAN: less_than_evaluator, - ConditionMatchTypes.SUBSTRING: substring_evaluator, + ConditionMatchTypes.LESS_THAN_OR_EQUAL: less_than_or_equal_evaluator, + ConditionMatchTypes.SEMVER_EQ: semver_equal_evaluator, + ConditionMatchTypes.SEMVER_GE: semver_greater_than_or_equal_evaluator, + ConditionMatchTypes.SEMVER_GT: semver_greater_than_evaluator, + ConditionMatchTypes.SEMVER_LE: semver_less_than_or_equal_evaluator, + ConditionMatchTypes.SEMVER_LT: semver_less_than_evaluator, + ConditionMatchTypes.SUBSTRING: substring_evaluator } + def split_semantic_version(self, target): + """ Method to split the given version. + + Args: + target: Given version. + + Returns: + List: + - The array of version split into smaller parts i.e major, minor, patch etc + None: + - if the given version is invalid in format + """ + target_prefix = target + target_suffix = "" + target_parts = [] + + # check that target shouldn't have white space + if self.has_white_space(target): + self.logger.warning(Errors.INVALID_ATTRIBUTE_FORMAT) + return None + + # check for pre release e.g. 1.0.0-alpha where 'alpha' is a pre release + # otherwise check for build e.g. 1.0.0+001 where 001 is a build metadata + if self.is_pre_release(target): + target_parts = target.split(SemverType.IS_PRE_RELEASE) + elif self.is_build(target): + target_parts = target.split(SemverType.IS_BUILD) + + # split target version into prefix and suffix + if target_parts: + if len(target_parts) < 1: + self.logger.warning(Errors.INVALID_ATTRIBUTE_FORMAT) + return None + target_prefix = str(target_parts[0]) + target_suffix = target_parts[1:] + + # check dot counts in target_prefix + dot_count = target_prefix.count(".") + if dot_count > 2: + self.logger.warning(Errors.INVALID_ATTRIBUTE_FORMAT) + return None + + target_version_parts = target_prefix.split(".") + if len(target_version_parts) != dot_count + 1: + self.logger.warning(Errors.INVALID_ATTRIBUTE_FORMAT) + return None + for part in target_version_parts: + if not part.isdigit(): + self.logger.warning(Errors.INVALID_ATTRIBUTE_FORMAT) + return None + + if target_suffix: + target_version_parts.extend(target_suffix) + return target_version_parts + def evaluate(self, index): """ Given a custom attribute audience condition and user attributes, evaluate the condition against the attributes. diff --git a/optimizely/helpers/enums.py b/optimizely/helpers/enums.py index beaba157..44dfe030 100644 --- a/optimizely/helpers/enums.py +++ b/optimizely/helpers/enums.py @@ -157,3 +157,9 @@ class NotificationTypes(object): OPTIMIZELY_CONFIG_UPDATE = 'OPTIMIZELY_CONFIG_UPDATE' TRACK = 'TRACK:event_key, user_id, attributes, event_tags, event' LOG_EVENT = 'LOG_EVENT:log_event' + + +class SemverType(object): + IS_PRE_RELEASE = '-' + HAS_WHITE_SPACE = ' ' + IS_BUILD = '+' diff --git a/tests/base.py b/tests/base.py index 432d5287..9dceec2d 100644 --- a/tests/base.py +++ b/tests/base.py @@ -518,6 +518,7 @@ def setUp(self, config_dict='config_dict'): '3468206647', '3468206644', '3468206643', + '18278344267' ], 'variations': [ {'variables': [], 'id': '11557362669', 'key': '11557362669', 'featureEnabled': True} @@ -556,7 +557,8 @@ def setUp(self, config_dict='config_dict'): 'audienceConditions': [ 'and', ['or', '3468206642', '3988293898'], - ['or', '3988293899', '3468206646', '3468206647', '3468206644', '3468206643'], + ['or', '3988293899', '3468206646', '3468206647', '3468206644', '3468206643', + '18278344267'], ], 'variations': [ {'variables': [], 'id': '11557362670', 'key': '11557362670', 'featureEnabled': True} @@ -626,6 +628,7 @@ def setUp(self, config_dict='config_dict'): '3468206647', '3468206644', '3468206643', + '18278344267' ], 'variations': [ { @@ -653,6 +656,7 @@ def setUp(self, config_dict='config_dict'): '3468206647', '3468206644', '3468206643', + '18278344267' ], 'forcedVariations': {}, }, @@ -667,7 +671,7 @@ def setUp(self, config_dict='config_dict'): 'audienceConditions': [ 'and', ['or', '3468206642', '3988293898'], - ['or', '3988293899', '3468206646', '3468206647', '3468206644', '3468206643'], + ['or', '3988293899', '3468206646', '3468206647', '3468206644', '3468206643', '18278344267'], ], 'forcedVariations': {}, }, @@ -689,7 +693,7 @@ def setUp(self, config_dict='config_dict'): 'audienceConditions': [ 'and', ['or', '3468206642', '3988293898'], - ['or', '3988293899', '3468206646', '3468206647', '3468206644', '3468206643'], + ['or', '3988293899', '3468206646', '3468206647', '3468206644', '3468206643', '18278344267'], ], 'forcedVariations': {}, }, @@ -837,6 +841,37 @@ def setUp(self, config_dict='config_dict'): ], ], }, + { + "id": "18278344267", + "name": "semverReleaseLt1.2.3Gt1.0.0", + "conditions": [ + "and", + [ + "or", + [ + "or", + { + "value": "1.2.3", + "type": "custom_attribute", + "name": "android-release", + "match": "semver_lt" + } + ] + ], + [ + "or", + [ + "or", + { + "value": "1.0.0", + "type": "custom_attribute", + "name": "android-release", + "match": "semver_gt" + } + ] + ] + ] + } ], 'groups': [], 'attributes': [ @@ -844,6 +879,7 @@ def setUp(self, config_dict='config_dict'): {'key': 'lasers', 'id': '594016'}, {'key': 'should_do_it', 'id': '594017'}, {'key': 'favorite_ice_cream', 'id': '594018'}, + {'key': 'android-release', 'id': '594019'}, ], 'botFiltering': False, 'accountId': '4879520872', diff --git a/tests/helpers_tests/test_condition.py b/tests/helpers_tests/test_condition.py index b4dee368..192a872b 100644 --- a/tests/helpers_tests/test_condition.py +++ b/tests/helpers_tests/test_condition.py @@ -1,4 +1,4 @@ -# Copyright 2016-2019, Optimizely +# Copyright 2016-2020, 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 @@ -24,6 +24,20 @@ integerCondition = ['num_users', 10, 'custom_attribute', 'exact'] doubleCondition = ['pi_value', 3.14, 'custom_attribute', 'exact'] +semver_equal_2_0_0_condition_list = [['Android', "2.0.0", 'custom_attribute', 'semver_eq']] +semver_equal_2_condition_list = [['Android', "2", 'custom_attribute', 'semver_eq']] +semver_equal_2_0_condition_list = [['Android', "2.0", 'custom_attribute', 'semver_eq']] +semver_equal_2_0_1_beta_condition_list = [['Android', "2.0.1-beta", 'custom_attribute', 'semver_eq']] +semver_greater_than_2_0_0_condition_list = [['Android', "2.0.0", 'custom_attribute', 'semver_gt']] +semver_greater_than_2_0_0_beta_condition_list = [['Android', "2.0.0-beta", 'custom_attribute', 'semver_gt']] +semver_greater_than_or_equal_2_0_9_beta_condition_list = [['Android', "2.0.9-beta", 'custom_attribute', 'semver_ge']] +semver_greater_than_or_equal_2_0_9_condition_list = [['Android', "2.0.9", 'custom_attribute', 'semver_ge']] +semver_less_than_2_0_0_condition_list = [['Android', "2.0.0", 'custom_attribute', 'semver_lt']] +semver_less_than_2_0_0_release_condition_list = [['Android', "2.0.0-release", 'custom_attribute', 'semver_lt']] +semver_less_than_2_0_0_beta_condition_list = [['Android', "2.0.0-beta", 'custom_attribute', 'semver_lt']] +semver_less_than_or_equal_2_0_1_beta_condition_list = [['Android', "2.0.1-beta", 'custom_attribute', 'semver_le']] +semver_less_than_or_equal_2_0_1_condition_list = [['Android', "2.0.1", 'custom_attribute', 'semver_le']] + exists_condition_list = [['input_value', None, 'custom_attribute', 'exists']] exact_string_condition_list = [['favorite_constellation', 'Lacerta', 'custom_attribute', 'exact']] exact_int_condition_list = [['lasers_count', 9000, 'custom_attribute', 'exact']] @@ -32,8 +46,12 @@ substring_condition_list = [['headline_text', 'buy now', 'custom_attribute', 'substring']] gt_int_condition_list = [['meters_travelled', 48, 'custom_attribute', 'gt']] gt_float_condition_list = [['meters_travelled', 48.2, 'custom_attribute', 'gt']] +ge_int_condition_list = [['meters_travelled', 48, 'custom_attribute', 'ge']] +ge_float_condition_list = [['meters_travelled', 48.2, 'custom_attribute', 'ge']] lt_int_condition_list = [['meters_travelled', 48, 'custom_attribute', 'lt']] lt_float_condition_list = [['meters_travelled', 48.2, 'custom_attribute', 'lt']] +le_int_condition_list = [['meters_travelled', 48, 'custom_attribute', 'le']] +le_float_condition_list = [['meters_travelled', 48.2, 'custom_attribute', 'le']] class CustomAttributeConditionEvaluator(base.BaseTest): @@ -108,6 +126,273 @@ def test_evaluate__returns_null__when_condition_has_an_invalid_type_property(sel self.assertIsNone(evaluator.evaluate(0)) + def test_evaluate__returns_true__when_user_version_2_matches_target_version_2(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_equal_2_condition_list, {'Android': '2'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_user_version_2_2_matches_target_version_2(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_equal_2_condition_list, {'Android': '2.2'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_user_version_2_0_matches_target_version_2_0(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_equal_2_0_condition_list, {'Android': '2.0'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_user_version_2_0_0_matches_target_version_2_0_0(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_equal_2_0_0_condition_list, {'Android': '2.0.0'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_false__when_user_version_2_0_does_not_match_target_version_2_0(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_equal_2_0_0_condition_list, {'Android': '2.0'}, self.mock_client_logger + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_user_version_2_0_0_release_is_greater_than_target_version_2_0_0_beta( + self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_greater_than_2_0_0_beta_condition_list, {'Android': '2.0.0-release'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_user_version_2_0_1_is_greater_than_target_version__2_0_0(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_greater_than_2_0_0_condition_list, {'Android': '2.0.1'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_user_version_1_9_9_is_less_than_target_version_2_0_0(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_less_than_2_0_0_condition_list, {'Android': '1.9.9'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_user_version_1_9_0_beta_is_less_than_target_version_2_0_0(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_less_than_2_0_0_condition_list, {'Android': '1.9.0-beta'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_user_version_2_0_0_release_is_less_than_target_version_2_0_0(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_less_than_2_0_0_condition_list, {'Android': '2.0.0-release'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_user_version_2_0_0_beta_is_less_than_target_version_2_0_0_release(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_less_than_2_0_0_release_condition_list, {'Android': '2.0.0-beta'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_false__when_user_version_2_0_0_release_is_not_less_than_target_version_2_0_0_beta(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_less_than_2_0_0_beta_condition_list, {'Android': '2.0.0-release'}, self.mock_client_logger + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_user_version_2_0_9_is_greater_than_or_equal_to_target_version_2_0_9(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_greater_than_or_equal_2_0_9_condition_list, {'Android': '2.0.9'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_user_version_2_3_is_greater_than_or_equal_to_target_version_2_0_9(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_greater_than_or_equal_2_0_9_condition_list, {'Android': '2.3'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_user_version_2_0_9_beta_is_greater_than_or_equal_to_target_version_2_0_9_beta( + self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_greater_than_or_equal_2_0_9_beta_condition_list, {'Android': '2.0.9-beta'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_false__when_user_version_1_0_0_is_not_greater_than_or_equal_to_target_version_2_0_9( + self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_greater_than_or_equal_2_0_9_condition_list, {'Android': '1.0.0'}, self.mock_client_logger + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_user_version_2_0_1_is_less_than_or_equal_to_target_version_2_0_1(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_less_than_or_equal_2_0_1_condition_list, {'Android': '2.0.1'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_user_version_1_1_is_less_than_or_equal_to_target_version_2_0_1(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_less_than_or_equal_2_0_1_condition_list, {'Android': '1.1'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_user_version_2_0_1_beta_is_less_than_or_equal_to_target_version_2_0_1(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_less_than_or_equal_2_0_1_condition_list, {'Android': '2.0.1-beta'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_false__when_user_version_3_0_1_is_not_less_than_or_equal_to_target_version_2_0_1(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_less_than_or_equal_2_0_1_condition_list, {'Android': '3.0.1'}, self.mock_client_logger + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_evaluate__returns_null__when_no_user_version_provided(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_equal_2_0_condition_list, {}, self.mock_client_logger + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_evaluate__returns_null__when_user_provided_version_is_null(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_equal_2_0_condition_list, {'Android': None}, self.mock_client_logger + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_evaluate__returns_exception__when_user_provided_version_is_invalid1(self): + + condition_helper.CustomAttributeConditionEvaluator( + semver_equal_2_0_condition_list, {'Android': "+"}, self.mock_client_logger + ) + + self.assertRaises(Exception) + + def test_evaluate__returns_exception__when_user_provided_version_is_invalid2(self): + + condition_helper.CustomAttributeConditionEvaluator( + semver_equal_2_0_condition_list, {'Android': "+--"}, self.mock_client_logger + ) + + self.assertRaises(Exception) + + def test_evaluate__returns_exception__when_user_provided_version_is_invalid3(self): + + condition_helper.CustomAttributeConditionEvaluator( + semver_equal_2_0_condition_list, {'Android': "...+"}, self.mock_client_logger + ) + + self.assertRaises(Exception) + + def test_evaluate__returns_exception__when_user_provided_version_is_invalid4(self): + + condition_helper.CustomAttributeConditionEvaluator( + semver_equal_2_0_condition_list, {'Android': "+test"}, self.mock_client_logger + ) + + self.assertRaises(Exception) + + def test_evaluate__returns_exception__when_user_provided_version_is_invalid5(self): + + condition_helper.CustomAttributeConditionEvaluator( + semver_equal_2_0_condition_list, {'Android': "3.6"}, self.mock_client_logger + ) + + self.assertRaises(Exception) + + def test_evaluate__returns_exception__when_user_provided_version_is_invalid6(self): + + condition_helper.CustomAttributeConditionEvaluator( + semver_equal_2_0_condition_list, {'Android': "2"}, self.mock_client_logger + ) + + self.assertRaises(Exception) + + def test_evaluate__returns_exception__when_user_provided_version_is_invalid7(self): + + condition_helper.CustomAttributeConditionEvaluator( + semver_equal_2_0_condition_list, {'Android': "3.90"}, self.mock_client_logger + ) + + self.assertRaises(Exception) + + def test_evaluate__returns_exception__when_user_provided_version_is_invalid8(self): + + condition_helper.CustomAttributeConditionEvaluator( + semver_equal_2_0_condition_list, {'Android': "3.90.2.8"}, self.mock_client_logger + ) + + self.assertRaises(Exception) + + def test_evaluate__returns_exception__when_user_provided_version_is_invalid9(self): + + condition_helper.CustomAttributeConditionEvaluator( + semver_equal_2_0_condition_list, {'Android': "-2.4"}, self.mock_client_logger + ) + + self.assertRaises(Exception) + + def test_evaluate__returns_exception__when_user_provided_version_is_invalid10(self): + + condition_helper.CustomAttributeConditionEvaluator( + semver_equal_2_0_condition_list, {'Android': True}, self.mock_client_logger + ) + + self.assertRaises(Exception) + + def test_evaluate__returns_exception__when_user_provided_version_is_invalid11(self): + + condition_helper.CustomAttributeConditionEvaluator( + semver_equal_2_0_condition_list, {'Android': False}, self.mock_client_logger + ) + + self.assertRaises(Exception) + def test_exists__returns_false__when_no_user_provided_value(self): evaluator = condition_helper.CustomAttributeConditionEvaluator( @@ -507,7 +792,149 @@ def test_greater_than_float__returns_null__when_no_user_provided_value(self): self.assertIsNone(evaluator.evaluate(0)) - def test_less_than_int__returns_true__when_user_value_less_than_condition_value(self,): + def test_greater_than_or_equal_int__returns_true__when_user_value_greater_than_or_equal_condition_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_int_condition_list, {'meters_travelled': 48.1}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_int_condition_list, {'meters_travelled': 48}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_int_condition_list, {'meters_travelled': 49}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + if PY2: + evaluator = condition_helper.CustomAttributeConditionEvaluator( + gt_int_condition_list, {'meters_travelled': long(49)}, self.mock_client_logger, + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_greater_than_or_equal_float__returns_true__when_user_value_greater_than_or_equal_condition_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_float_condition_list, {'meters_travelled': 48.3}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_float_condition_list, {'meters_travelled': 48.2}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_float_condition_list, {'meters_travelled': 49}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + if PY2: + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_float_condition_list, {'meters_travelled': long(49)}, self.mock_client_logger, + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_greater_than_or_equal_int__returns_false__when_user_value_not_greater_than_or_equal_condition_value( + self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_int_condition_list, {'meters_travelled': 47.9}, self.mock_client_logger + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_int_condition_list, {'meters_travelled': 47}, self.mock_client_logger + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + if PY2: + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_int_condition_list, {'meters_travelled': long(47)}, self.mock_client_logger, + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_greater_than_or_equal_float__returns_false__when_user_value_not_greater_than_or_equal_condition_value( + self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_float_condition_list, {'meters_travelled': 48.1}, self.mock_client_logger + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_float_condition_list, {'meters_travelled': 48}, self.mock_client_logger + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + if PY2: + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_float_condition_list, {'meters_travelled': long(48)}, self.mock_client_logger, + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_greater_than_or_equal_int__returns_null__when_user_value_is_not_a_number(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_int_condition_list, {'meters_travelled': 'a long way'}, self.mock_client_logger, + ) + + self.assertIsNone(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_int_condition_list, {'meters_travelled': False}, self.mock_client_logger + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_greater_than_or_equal_float__returns_null__when_user_value_is_not_a_number(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_float_condition_list, {'meters_travelled': 'a long way'}, self.mock_client_logger, + ) + + self.assertIsNone(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_float_condition_list, {'meters_travelled': False}, self.mock_client_logger, + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_greater_than_or_equal_int__returns_null__when_no_user_provided_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_int_condition_list, {}, self.mock_client_logger + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_greater_than_or_equal_float__returns_null__when_no_user_provided_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_float_condition_list, {}, self.mock_client_logger + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_less_than_int__returns_true__when_user_value_less_than_condition_value(self): evaluator = condition_helper.CustomAttributeConditionEvaluator( lt_int_condition_list, {'meters_travelled': 47.9}, self.mock_client_logger @@ -623,6 +1050,140 @@ def test_less_than_float__returns_null__when_no_user_provided_value(self): self.assertIsNone(evaluator.evaluate(0)) + def test_less_than_or_equal_int__returns_true__when_user_value_less_than_or_equal_condition_value(self,): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_int_condition_list, {'meters_travelled': 47.9}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_int_condition_list, {'meters_travelled': 47}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_int_condition_list, {'meters_travelled': 48}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + if PY2: + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_int_condition_list, {'meters_travelled': long(47)}, self.mock_client_logger, + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_int_condition_list, {'meters_travelled': long(48)}, self.mock_client_logger, + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_less_than_or_equal_float__returns_true__when_user_value_less_than_or_equal_condition_value(self,): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_float_condition_list, {'meters_travelled': 48.1}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_float_condition_list, {'meters_travelled': 48.2}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_float_condition_list, {'meters_travelled': 48}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + if PY2: + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_float_condition_list, {'meters_travelled': long(48)}, self.mock_client_logger, + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_less_than_or_equal_int__returns_false__when_user_value_not_less_than_or_equal_condition_value(self,): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_int_condition_list, {'meters_travelled': 48.1}, self.mock_client_logger + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_int_condition_list, {'meters_travelled': 49}, self.mock_client_logger + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + if PY2: + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_int_condition_list, {'meters_travelled': long(49)}, self.mock_client_logger, + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_less_than_or_equal_float__returns_false__when_user_value_not_less_than_or_equal_condition_value(self,): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_float_condition_list, {'meters_travelled': 48.3}, self.mock_client_logger + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_float_condition_list, {'meters_travelled': 49}, self.mock_client_logger + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + if PY2: + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_float_condition_list, {'meters_travelled': long(49)}, self.mock_client_logger, + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_less_than_or_equal_int__returns_null__when_user_value_is_not_a_number(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_int_condition_list, {'meters_travelled': False}, self.mock_client_logger + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_less_than_or_equal_float__returns_null__when_user_value_is_not_a_number(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_float_condition_list, {'meters_travelled': False}, self.mock_client_logger, + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_less_than_or_equal_int__returns_null__when_no_user_provided_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_int_condition_list, {}, self.mock_client_logger + ) + + self.assertIsNone(evaluator.evaluate(0)) + + def test_less_than_or_equal_float__returns_null__when_no_user_provided_value(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_float_condition_list, {}, self.mock_client_logger + ) + + self.assertIsNone(evaluator.evaluate(0)) + def test_greater_than__calls_is_finite_number(self): """ Test that CustomAttributeConditionEvaluator.evaluate returns True if is_finite_number returns True. Returns None if is_finite_number returns False. """ @@ -705,7 +1266,95 @@ def is_finite_number__accepting_both_values(value): return True with mock.patch( - 'optimizely.helpers.validator.is_finite_number', side_effect=is_finite_number__accepting_both_values, + 'optimizely.helpers.validator.is_finite_number', side_effect=is_finite_number__accepting_both_values, + ): + self.assertTrue(evaluator.evaluate(0)) + + def test_greater_than_or_equal__calls_is_finite_number(self): + """ Test that CustomAttributeConditionEvaluator.evaluate returns True + if is_finite_number returns True. Returns None if is_finite_number returns False. """ + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_int_condition_list, {'meters_travelled': 48.1}, self.mock_client_logger + ) + + def is_finite_number__rejecting_condition_value(value): + if value == 48: + return False + return True + + with mock.patch( + 'optimizely.helpers.validator.is_finite_number', + side_effect=is_finite_number__rejecting_condition_value, + ) as mock_is_finite: + self.assertIsNone(evaluator.evaluate(0)) + + # assert that isFiniteNumber only needs to reject condition value to stop evaluation. + mock_is_finite.assert_called_once_with(48) + + def is_finite_number__rejecting_user_attribute_value(value): + if value == 48.1: + return False + return True + + with mock.patch( + 'optimizely.helpers.validator.is_finite_number', + side_effect=is_finite_number__rejecting_user_attribute_value, + ) as mock_is_finite: + self.assertIsNone(evaluator.evaluate(0)) + + # assert that isFiniteNumber evaluates user value only if it has accepted condition value. + mock_is_finite.assert_has_calls([mock.call(48), mock.call(48.1)]) + + def is_finite_number__accepting_both_values(value): + return True + + with mock.patch( + 'optimizely.helpers.validator.is_finite_number', side_effect=is_finite_number__accepting_both_values, + ): + self.assertTrue(evaluator.evaluate(0)) + + def test_less_than_or_equal__calls_is_finite_number(self): + """ Test that CustomAttributeConditionEvaluator.evaluate returns True + if is_finite_number returns True. Returns None if is_finite_number returns False. """ + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_int_condition_list, {'meters_travelled': 47}, self.mock_client_logger + ) + + def is_finite_number__rejecting_condition_value(value): + if value == 48: + return False + return True + + with mock.patch( + 'optimizely.helpers.validator.is_finite_number', + side_effect=is_finite_number__rejecting_condition_value, + ) as mock_is_finite: + self.assertIsNone(evaluator.evaluate(0)) + + # assert that isFiniteNumber only needs to reject condition value to stop evaluation. + mock_is_finite.assert_called_once_with(48) + + def is_finite_number__rejecting_user_attribute_value(value): + if value == 47: + return False + return True + + with mock.patch( + 'optimizely.helpers.validator.is_finite_number', + side_effect=is_finite_number__rejecting_user_attribute_value, + ) as mock_is_finite: + self.assertIsNone(evaluator.evaluate(0)) + + # assert that isFiniteNumber evaluates user value only if it has accepted condition value. + mock_is_finite.assert_has_calls([mock.call(48), mock.call(47)]) + + def is_finite_number__accepting_both_values(value): + return True + + with mock.patch( + 'optimizely.helpers.validator.is_finite_number', side_effect=is_finite_number__accepting_both_values, ): self.assertTrue(evaluator.evaluate(0)) @@ -1367,3 +2016,14 @@ def test_substring__condition_value_invalid(self): 'newer release of the Optimizely SDK.' ).format(json.dumps(expected_condition_log)) ) + + def test_invalid_semver__returns_None__when_semver_is_invalid(self): + invalid_test_cases = ["-", ".", "..", "+", "+test", " ", "2 .0. 0", + "2.", ".0.0", "1.2.2.2", "2.x", ",", + "+build-prerelese"] + + for invalid_test_case in invalid_test_cases: + evaluator = condition_helper.CustomAttributeConditionEvaluator( + semver_less_than_or_equal_2_0_1_condition_list, {'Android': invalid_test_case}, self.mock_client_logger) + + self.assertIsNone(evaluator.evaluate(0)) diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index 94783a7a..f586c44c 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -844,6 +844,48 @@ def test_activate__with_attributes__typed_audience_match(self): self.assertTrue(expected_attr in [x.__dict__ for x in mock_process.call_args[0][0].visitor_attributes]) + def test_activate__with_attributes__typed_audience_with_semver_match(self): + """ Test that activate calls process with right params and returns expected + variation when attributes are provided and typed audience conditions are met. """ + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_typed_audiences)) + + with mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process: + # Should be included via exact match string audience with id '18278344267' + self.assertEqual( + 'A', opt_obj.activate('typed_audience_experiment', 'test_user', {'android-release': '1.0.1'}), + ) + expected_attr = { + 'type': 'custom', + 'value': '1.0.1', + 'entity_id': '594019', + 'key': 'android-release', + } + + self.assertTrue(expected_attr in [x.__dict__ for x in mock_process.call_args[0][0].visitor_attributes]) + + mock_process.reset() + + with mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process: + self.assertEqual( + 'A', opt_obj.activate('typed_audience_experiment', 'test_user', {'android-release': "1.2.2"}), + ) + expected_attr = { + 'type': 'custom', + 'value': "1.2.2", + 'entity_id': '594019', + 'key': 'android-release', + } + + self.assertTrue(expected_attr in [x.__dict__ for x in mock_process.call_args[0][0].visitor_attributes]) + + def test_activate__with_attributes__typed_audience_with_semver_mismatch(self): + """ Test that activate returns None when typed audience conditions do not match. """ + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_typed_audiences)) + + with mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process: + self.assertIsNone(opt_obj.activate('typed_audience_experiment', 'test_user', {'android-release': '1.2.9'})) + self.assertEqual(0, mock_process.call_count) + def test_activate__with_attributes__typed_audience_mismatch(self): """ Test that activate returns None when typed audience conditions do not match. """ opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_typed_audiences))