From 60930282d578a784ce7f6cafe04d3f1103b73019 Mon Sep 17 00:00:00 2001 From: Owais Date: Tue, 18 Dec 2018 16:36:52 +0500 Subject: [PATCH 1/2] feat(audience match types): Update condition evaluator for new audience match types --- src/Optimizely/Utils/ConditionEvaluator.php | 120 --- .../Utils/ConditionTreeEvaluator.php | 158 +++ .../CustomAttributeConditionEvaluator.php | 231 +++++ src/Optimizely/Utils/Validator.php | 53 +- tests/UtilsTests/ConditionEvaluatorTest.php | 88 -- .../UtilsTests/ConditionTreeEvaluatorTest.php | 340 +++++++ .../CustomAttributeConditionEvaluatorTest.php | 902 ++++++++++++++++++ 7 files changed, 1682 insertions(+), 210 deletions(-) delete mode 100644 src/Optimizely/Utils/ConditionEvaluator.php create mode 100644 src/Optimizely/Utils/ConditionTreeEvaluator.php create mode 100644 src/Optimizely/Utils/CustomAttributeConditionEvaluator.php delete mode 100644 tests/UtilsTests/ConditionEvaluatorTest.php create mode 100644 tests/UtilsTests/ConditionTreeEvaluatorTest.php create mode 100644 tests/UtilsTests/CustomAttributeConditionEvaluatorTest.php diff --git a/src/Optimizely/Utils/ConditionEvaluator.php b/src/Optimizely/Utils/ConditionEvaluator.php deleted file mode 100644 index f91a3b53..00000000 --- a/src/Optimizely/Utils/ConditionEvaluator.php +++ /dev/null @@ -1,120 +0,0 @@ -evaluate($condition, $userAttributes); - if (!$result) { - return false; - } - } - - return true; - } - - /** - * @param $conditions array Audience conditions list. - * @param $userAttributes array Associative array of user attributes to values. - * - * @return boolean True if any one of the conditions evaluate to True. - */ - private function orEvaluator($conditions, $userAttributes) - { - foreach ($conditions as $condition) { - $result = $this->evaluate($condition, $userAttributes); - if ($result) { - return true; - } - } - - return false; - } - - /** - * @param $condition array Audience conditions list consisting of single condition. - * @param $userAttributes array Associative array of user attributes to values. - * - * @return boolean True if the condition evaluates to False. - */ - private function notEvaluator($condition, $userAttributes) - { - if (count($condition) != 1) { - return false; - } - - return !$this->evaluate($condition[0], $userAttributes); - } - - /** - * Function to evaluate audience conditions against user's attributes. - * - * @param $conditions array Nested array of and/or/not conditions representing the audience conditions. - * @param $userAttributes array Associative array of user attributes to values. - * - * @return boolean Representing if audience conditions are satisfied or not. - */ - public function evaluate($conditions, $userAttributes) - { - if (is_array($conditions)) { - switch ($conditions[0]) { - case self::AND_OPERATOR: - array_shift($conditions); - return $this->andEvaluator($conditions, $userAttributes); - case self::OR_OPERATOR: - array_shift($conditions); - return $this->orEvaluator($conditions, $userAttributes); - case self::NOT_OPERATOR: - array_shift($conditions); - return $this->notEvaluator($conditions, $userAttributes); - default: - return false; - } - } - - $conditionName = $conditions->{'name'}; - if (!isset($userAttributes[$conditionName])) { - return false; - } - return $userAttributes[$conditionName] == $conditions->{'value'}; - } -} diff --git a/src/Optimizely/Utils/ConditionTreeEvaluator.php b/src/Optimizely/Utils/ConditionTreeEvaluator.php new file mode 100644 index 00000000..ecd77b62 --- /dev/null +++ b/src/Optimizely/Utils/ConditionTreeEvaluator.php @@ -0,0 +1,158 @@ +evaluate($condition, $leafEvaluator); + + if($result === false) { + return false; + } + + if($result === null) { + $sawNullResult = true; + } + } + + return $sawNullResult ? null : true; + } + + /** + * Evaluates an array of conditions as if the evaluator had been applied + * to each entry and the results OR-ed together. + * + * @param array $conditions Audience conditions list. + * @param callable $leafEvaluator Method to evaluate leaf condition. + * + * @return null|boolean True if any operand evaluates to true. + * False if all operands evaluate to false. + * Null if conditions couldn't be evaluated. + */ + protected function orEvaluator(array $conditions, callable $leafEvaluator) + { + $sawNullResult = false; + foreach ($conditions as $condition) { + $result = $this->evaluate($condition, $leafEvaluator); + + if($result === true) { + return true; + } + + if($result === null) { + $sawNullResult = true; + } + } + + return $sawNullResult ? null : false; + } + + /** + * Evaluates an array of conditions as if the evaluator had been applied + * to a single entry and NOT was applied to the result. + * + * @param array $conditions Audience conditions list. + * @param callable $leafEvaluator Method to evaluate leaf condition. + * + * @return null|boolean True if the operand evaluates to false. + * False if the operand evaluates to true. + * Null if conditions is empty or couldn't be evaluated. + */ + protected function notEvaluator(array $condition, callable $leafEvaluator) + { + if (empty($condition)) { + return null; + } + + $result = $this->evaluate($condition[0], $leafEvaluator); + return $result === null ? null: !$result; + } + + /** + * Function to evaluate audience conditions against user's attributes. + * + * @param array $conditions Nested array of and/or/not conditions representing the audience conditions. + * @param callable $leafEvaluator Method to evaluate leaf condition. + * + * @return null|boolean Result of evaluating the conditions using the operator rules. + * and the leaf evaluator. Null if conditions couldn't be evaluated. + */ + public function evaluate($conditions, callable $leafEvaluator) + { + if (is_array($conditions)) { + + if(in_array($conditions[0], $this->getOperators())) { + $operator = array_shift($conditions); + } else { + $operator = self::OR_OPERATOR; + } + + $evaluatorFunc = $this->getEvaluatorByOperatorType($operator); + return $this->{$evaluatorFunc}($conditions, $leafEvaluator); + } + + $leafCondition = $conditions; + return $leafEvaluator($leafCondition); + } +} diff --git a/src/Optimizely/Utils/CustomAttributeConditionEvaluator.php b/src/Optimizely/Utils/CustomAttributeConditionEvaluator.php new file mode 100644 index 00000000..c4195e48 --- /dev/null +++ b/src/Optimizely/Utils/CustomAttributeConditionEvaluator.php @@ -0,0 +1,231 @@ +userAttributes = $userAttributes; + } + + /** + * Gets the supported match types for condition evaluation. + * + * @return array List of supported match types. + */ + protected function getMatchTypes() + { + return array(self::EXACT_MATCH_TYPE, self::EXISTS_MATCH_TYPE, self::GREATER_THAN_MATCH_TYPE, + self::LESS_THAN_MATCH_TYPE, self::SUBSTRING_MATCH_TYPE); + } + + /** + * Gets the evaluator method name for the given match type. + * + * @param string $matchType Match type for which to get evaluator. + * + * @return string Corresponding evaluator method name. + */ + protected function getEvaluatorByMatchType($matchType) + { + $evaluatorsByMatchType = array(); + $evaluatorsByMatchType[self::EXACT_MATCH_TYPE] = 'exactEvaluator'; + $evaluatorsByMatchType[self::EXISTS_MATCH_TYPE] = 'existsEvaluator'; + $evaluatorsByMatchType[self::GREATER_THAN_MATCH_TYPE] = 'greaterThanEvaluator'; + $evaluatorsByMatchType[self::LESS_THAN_MATCH_TYPE] = 'lessThanEvaluator'; + $evaluatorsByMatchType[self::SUBSTRING_MATCH_TYPE] = 'substringEvaluator'; + + return $evaluatorsByMatchType[$matchType]; + } + + /** + * Checks if the given input is a valid value for exact condition evaluation. + * + * @param $value Input to check. + * + * @return boolean true if given input is a string/boolean/finite number, false otherwise. + */ + protected function isValueValidForExactConditions($value) + { + if(is_string($value) || is_bool($value) || Validator::isFiniteNumber($value)) { + return true; + } + + return false; + } + + /** + * Evaluate the given exact match condition for the given user attributes. + * + * @param object $condition + * + * @return null|boolean true if the user attribute value is equal (===) to the condition value, + * false if the user attribute value is not equal (!==) to the condition value, + * null if the condition value or user attribute value has an invalid type, or + * if there is a mismatch between the user attribute type and the condition + * value type. + */ + protected function exactEvaluator($condition) + { + $conditionName = $condition->{'name'}; + $conditionValue = $condition->{'value'}; + $userValue = isset($this->userAttributes[$conditionName]) ? $this->userAttributes[$conditionName]: null; + + if(!$this->isValueValidForExactConditions($userValue) || + !$this->isValueValidForExactConditions($conditionValue) || + !Validator::areValuesSameType($conditionValue, $userValue)) { + return null; + } + + return $conditionValue == $userValue; + } + + /** + * Evaluate the given exists match condition for the given user attributes. + * + * @param object $condition + * + * @return null|boolean true if both: + * 1) the user attributes have a value for the given condition, and + * 2) the user attribute value is not null. + * false otherwise. + */ + protected function existsEvaluator($condition) + { + $conditionName = $condition->{'name'}; + return isset($this->userAttributes[$conditionName]); + } + + /** + * Evaluate the given greater than match condition for the given user attributes. + * + * @param object $condition + * + * @return boolean true if the user attribute value is greater than the condition value, + * false if the user attribute value is less than or equal to the condition value, + * null if the condition value isn't a number or the user attribute value + * isn't a number. + */ + protected function greaterThanEvaluator($condition) + { + $conditionName = $condition->{'name'}; + $conditionValue = $condition->{'value'}; + $userValue = isset($this->userAttributes[$conditionName]) ? $this->userAttributes[$conditionName]: null; + + if(!Validator::isFiniteNumber($userValue) || !Validator::isFiniteNumber($conditionValue)) { + return null; + } + + return $userValue > $conditionValue; + } + + /** + * Evaluate the given less than match condition for the given user attributes. + * + * @param object $condition + * + * @return boolean true if the user attribute value is less than the condition value, + * false if the user attribute value is greater than or equal to the condition value, + * null if the condition value isn't a number or the user attribute value + * isn't a number. + */ + protected function lessThanEvaluator($condition) + { + $conditionName = $condition->{'name'}; + $conditionValue = $condition->{'value'}; + $userValue = isset($this->userAttributes[$conditionName]) ? $this->userAttributes[$conditionName]: null; + + if(!Validator::isFiniteNumber($userValue) || !Validator::isFiniteNumber($conditionValue)) { + return null; + } + + return $userValue < $conditionValue; + } + + /** + * Evaluate the given substring than match condition for the given user attributes. + * + * @param object $condition + * + * @return boolean true if the condition value is a substring of the user attribute value, + * false if the condition value is not a substring of the user attribute value, + * null if the condition value isn't a string or the user attribute value + * isn't a string. + */ + protected function substringEvaluator($condition) + { + $conditionName = $condition->{'name'}; + $conditionValue = $condition->{'value'}; + $userValue = isset($this->userAttributes[$conditionName]) ? $this->userAttributes[$conditionName]: null; + + if(!is_string($userValue) || !is_string($conditionValue)) { + return null; + } + + return strpos($userValue, $conditionValue) !== false; + } + + /** + * Function to evaluate audience conditions against user's attributes. + * + * @param array $leafCondition Condition to be evaluated. + * + * @return null|boolean true/false if the given user attributes match/don't match the given conditions, + * null if the given user attributes and conditions can't be evaluated. + */ + public function evaluate($leafCondition) + { + if($leafCondition->{'type'} !== self::CUSTOM_ATTRIBUTE_CONDITION_TYPE) { + return null; + } + + if(!isset($leafCondition->{'match'})) { + $conditionMatch = self::EXACT_MATCH_TYPE; + } else { + $conditionMatch = $leafCondition->{'match'}; + } + + if(!in_array($conditionMatch, $this->getMatchTypes())) { + return null; + } + + $evaluatorForMatch = $this->getEvaluatorByMatchType($conditionMatch); + return $this->$evaluatorForMatch($leafCondition); + } +} diff --git a/src/Optimizely/Utils/Validator.php b/src/Optimizely/Utils/Validator.php index 5b8dd7e0..57becd54 100644 --- a/src/Optimizely/Utils/Validator.php +++ b/src/Optimizely/Utils/Validator.php @@ -21,6 +21,8 @@ use Optimizely\Entity\Experiment; use Optimizely\Logger\LoggerInterface; use Optimizely\ProjectConfig; +use Optimizely\Utils\ConditionTreeEvaluator; +use Optimizely\Utils\CustomAttributeConditionEvaluator; class Validator { @@ -114,11 +116,16 @@ public static function isUserInExperiment($config, $experiment, $userAttributes) return false; } + $customAttrCondEval = new CustomAttributeConditionEvaluator($userAttributes); + $evaluateCustomAttr = function($leafCondition) use ($customAttrCondEval) { + return $customAttrCondEval->evaluate($leafCondition); + }; + // Return true if conditions for any audience are met. - $conditionEvaluator = new ConditionEvaluator(); + $conditionTreeEvaluator = new ConditionTreeEvaluator(); foreach ($audienceIds as $audienceId) { $audience = $config->getAudience($audienceId); - $result = $conditionEvaluator->evaluate($audience->getConditionsList(), $userAttributes); + $result = $conditionTreeEvaluator->evaluate($audience->getConditionsList(), $evaluateCustomAttr); if ($result) { return true; } @@ -174,4 +181,46 @@ public static function validateNonEmptyString($value) return false; } + + /** + * Checks if the given input is a number and is not one of NAN, INF, -INF. + * + * @param $value Input to check. + * + * @return boolean true if given input is a number but not +/-Infinity or NAN, false otherwise. + */ + public static function isFiniteNumber($value) + { + if(!is_numeric($value) ) { + return false; + } + + if(is_string($value) || is_nan($value) || is_infinite($value)) { + return false; + } + + return true; + } + + /** + * Method to verify that both values belong to same type. + * Float/Double and Integer are considered similar. + * + * @param mixed $firstVal + * @param mixed $secondVal + * + * @return bool True if values belong to similar types. Otherwise, False. + */ + public static function areValuesSameType($firstVal, $secondVal) + { + $firstValType = gettype($firstVal); + $secondValType = gettype($secondVal); + $numberTypes = array('double', 'integer'); + + if(in_array($firstValType, $numberTypes) && in_array($secondValType, $numberTypes)) { + return True; + } + + return $firstValType == $secondValType; + } } diff --git a/tests/UtilsTests/ConditionEvaluatorTest.php b/tests/UtilsTests/ConditionEvaluatorTest.php deleted file mode 100644 index 355f4031..00000000 --- a/tests/UtilsTests/ConditionEvaluatorTest.php +++ /dev/null @@ -1,88 +0,0 @@ -deserializeAudienceConditions($conditions); - - $this->conditionsList = $decoder->getConditionsList(); - $this->conditionEvaluator = new ConditionEvaluator(); - } - - public function testEvaluateConditionsMatch() - { - $userAttributes = [ - 'device_type' => 'iPhone', - 'location' => 'San Francisco', - 'browser' => 'Chrome' - ]; - - $this->assertTrue($this->conditionEvaluator->evaluate($this->conditionsList, $userAttributes)); - } - - - public function testEvaluateConditionsDoNotMatch() - { - $userAttributes = [ - 'device_type' => 'iPhone', - 'location' => 'San Francisco', - 'browser' => 'Firefox' - ]; - - $this->assertFalse($this->conditionEvaluator->evaluate($this->conditionsList, $userAttributes)); - } - - public function testEvaluateEmptyUserAttributes() - { - $userAttributes = []; - $this->assertFalse($this->conditionEvaluator->evaluate($this->conditionsList, $userAttributes)); - } - - public function testEvaluateNullUserAttributes() - { - $userAttributes = null; - $this->assertFalse($this->conditionEvaluator->evaluate($this->conditionsList, $userAttributes)); - } - - public function testTypedUserAttributesEvaluateTrue() - { - $decoder = new ConditionDecoder(); - $conditions = "[\"and\", [\"or\", [\"or\", {\"name\": \"device_type\", \"type\": \"custom_attribute\", \"value\": \"iPhone\"}]], [\"or\", [\"or\", {\"name\": \"is_firefox\", \"type\": \"custom_attribute\", \"value\": false}]], [\"or\", [\"or\", {\"name\": \"num_users\", \"type\": \"custom_attribute\", \"value\": 15}]], [\"or\", [\"or\", {\"name\": \"pi_value\", \"type\": \"custom_attribute\", \"value\": 3.14}]]]"; - $decoder->deserializeAudienceConditions($conditions); - - $userAttributes = [ - 'device_type' => 'iPhone', - 'is_firefox' => false, - 'num_users' => 15, - 'pi_value' => 3.14 - ]; - $this->conditionsList = $decoder->getConditionsList(); - $this->assertTrue($this->conditionEvaluator->evaluate($this->conditionsList, $userAttributes)); - } -} diff --git a/tests/UtilsTests/ConditionTreeEvaluatorTest.php b/tests/UtilsTests/ConditionTreeEvaluatorTest.php new file mode 100644 index 00000000..d63f61ad --- /dev/null +++ b/tests/UtilsTests/ConditionTreeEvaluatorTest.php @@ -0,0 +1,340 @@ +conditionA = (object)[ + 'name' => 'browser_type', + 'value' => 'safari', + 'type' => 'custom_attribute' + ]; + + $this->conditionB = (object)[ + 'name' => 'device_model', + 'value' => 'iphone6', + 'type' => 'custom_attribute' + ]; + + $this->conditionC = (object)[ + 'name' => 'location', + 'match' => 'exact', + 'type' => 'custom_attribute', + 'value' => 'CA' + ]; + + $this->conditionTreeEvaluator = new ConditionTreeEvaluator(); + } + + /** + * Helper method to create a callback that returns passed arguments on consecutive calls. + * + * @param mixed $a + * @param mixed $b + * @param mixed $c + * + * @return callable + */ + protected function getLeafEvaluator($a, $b = null, $c = null) { + $numOfCalls = 0; + + $leafEvaluator = function ($some_arg) use (&$numOfCalls, $a, $b, $c) { + $numOfCalls++; + if($numOfCalls == 1) + return $a; + if($numOfCalls == 2) + return $b; + if($numOfCalls == 3) + return $c; + + return null; + }; + + return $leafEvaluator; + } + + + // Test that evaluate returns true when the leaf condition evaluator returns true. + public function testEvaluateReturnsTrueWhenLeafConditionReturnsTrue() + { + $this->assertTrue( + $this->conditionTreeEvaluator->evaluate($this->conditionA, $this->getLeafEvaluator(true)) + ); + } + + // Test that evaluate returns false when the leaf condition evaluator returns false. + public function testEvaluateReturnsFalseWhenLeafConditionReturnsFalse() + { + $this->assertFalse( + $this->conditionTreeEvaluator->evaluate($this->conditionA, $this->getLeafEvaluator(false)) + ); + } + + // Test that andEvaluator returns false when any one condition evaluates to false. + public function testAndEvaluatorReturnsFalseWhenAnyOneConditionEvaluatesFalse() + { + $this->assertFalse( + $this->conditionTreeEvaluator->evaluate( + ['and', $this->conditionA, $this->conditionB], + $this->getLeafEvaluator(true, false) + ) + ); + } + + // Test that andEvaluator returns true when all conditions evaluate to true. + public function testAndEvaluatorReturnsTrueWhenAllConditionsEvaluateTrue() + { + $this->assertTrue( + $this->conditionTreeEvaluator->evaluate( + ['and', $this->conditionA, $this->conditionB], + $this->getLeafEvaluator(true, true) + ) + ); + } + + // Test that andEvaluator returns null when all operands evaluate to null. + public function testAndEvaluatorReturnsNullWhenAllNulls() + { + $this->assertNull( + $this->conditionTreeEvaluator->evaluate( + ['and', $this->conditionA, $this->conditionB], + $this->getLeafEvaluator(null, null) + ) + ); + } + + // Test that andEvaluator returns null when operands evaluate to trues and null. + public function testAndEvaluatorReturnsNullWhenTruesAndNull() + { + $this->assertNull( + $this->conditionTreeEvaluator->evaluate( + ['and', $this->conditionA, $this->conditionB, $this->conditionC], + $this->getLeafEvaluator(true, true, null) + ) + ); + + $this->assertNull( + $this->conditionTreeEvaluator->evaluate( + ['and', $this->conditionA, $this->conditionB, $this->conditionC], + $this->getLeafEvaluator(null, true, true) + ) + ); + } + + // Test that andEvaluator returns false when operands evaluate to falses and null. + public function testAndEvaluatorReturnsFalseWhenFalsesAndNull() + { + $this->assertFalse( + $this->conditionTreeEvaluator->evaluate( + ['and', $this->conditionA, $this->conditionB, $this->conditionC], + $this->getLeafEvaluator(false, false, null) + ) + ); + + $this->assertFalse( + $this->conditionTreeEvaluator->evaluate( + ['and', $this->conditionA, $this->conditionB, $this->conditionC], + $this->getLeafEvaluator(null, false, false) + ) + ); + } + + // Test that andEvaluator returns false when operands evaluate to trues, falses and null. + public function testAndEvaluatorReturnsFalseWhenTruesFalsesAndNull() + { + $this->assertFalse( + $this->conditionTreeEvaluator->evaluate( + ['and', $this->conditionA, $this->conditionB, $this->conditionC], + $this->getLeafEvaluator(true, false, null) + ) + ); + } + + // Test that orEvaluator returns true when any one condition evaluates to true. + public function testOrEvaluatorReturnsTrueWhenAnyOneConditionEvaluatesTrue() + { + $this->assertTrue( + $this->conditionTreeEvaluator->evaluate( + ['or', $this->conditionA, $this->conditionB], + $this->getLeafEvaluator(false, true) + ) + ); + } + + // Test that orEvaluator returns false when all conditions evaluates to false. + public function testOrEvaluatorReturnsFalseWhenAllConditionsAreFalse() + { + $this->assertFalse( + $this->conditionTreeEvaluator->evaluate( + ['or', $this->conditionA, $this->conditionB], + $this->getLeafEvaluator(false, false) + ) + ); + } + + // Test that orEvaluator returns null when all operands evaluate to null. + public function testOrEvaluatorReturnsNullWhenAllNulls() + { + $this->assertNull( + $this->conditionTreeEvaluator->evaluate( + ['or', $this->conditionA, $this->conditionB], + $this->getLeafEvaluator(null, null) + ) + ); + } + + // Test that orEvaluator returns true when operands evaluate to trues and null. + public function testOrEvaluatorReturnsTrueWhenTruesAndNull() + { + $this->assertTrue( + $this->conditionTreeEvaluator->evaluate( + ['or', $this->conditionA, $this->conditionB, $this->conditionC], + $this->getLeafEvaluator(true, true, null) + ) + ); + + $this->assertTrue( + $this->conditionTreeEvaluator->evaluate( + ['or', $this->conditionA, $this->conditionB, $this->conditionC], + $this->getLeafEvaluator(null, true, true) + ) + ); + } + + // Test that orEvaluator returns null when operands evaluate to falses and null. + public function testOrEvaluatorReturnsNullWhenFalsesAndNull() + { + $this->assertNull( + $this->conditionTreeEvaluator->evaluate( + ['or', $this->conditionA, $this->conditionB, $this->conditionC], + $this->getLeafEvaluator(false, false, null) + ) + ); + + $this->assertNull( + $this->conditionTreeEvaluator->evaluate( + ['or', $this->conditionA, $this->conditionB, $this->conditionC], + $this->getLeafEvaluator(null, false, false) + ) + ); + } + + // Test that orEvaluator returns true when operands evaluate to trues, falses and null. + public function testOrEvaluatorReturnsTrueWhenTruesFalsesAndNull() + { + $this->assertTrue( + $this->conditionTreeEvaluator->evaluate( + ['or', $this->conditionA, $this->conditionB, $this->conditionC], + $this->getLeafEvaluator(false, null, true) + ) + ); + } + + // Test that notEvaluator returns true when condition evaluates to false. + public function testNotEvaluatorReturnsTrueWhenConditionEvaluatesFalse() + { + $this->assertTrue( + $this->conditionTreeEvaluator->evaluate( + ['not', $this->conditionA], + $this->getLeafEvaluator(false) + ) + ); + } + + // Test that notEvaluator returns false when condition evaluates to true. + public function testNotEvaluatorReturnsFalseWhenConditionEvaluatesTrue() + { + $this->assertFalse( + $this->conditionTreeEvaluator->evaluate( + ['not', $this->conditionA], + $this->getLeafEvaluator(true) + ) + ); + } + + // Test that notEvaluator negates first condition and ignores rest. + public function testNotEvaluatorNegatesFirstConditionIgnoresRest() + { + $this->assertTrue( + $this->conditionTreeEvaluator->evaluate( + ['not', $this->conditionA, $this->conditionB, $this->conditionC], + $this->getLeafEvaluator(false, true, null) + ) + ); + + $this->assertFalse( + $this->conditionTreeEvaluator->evaluate( + ['not', $this->conditionA, $this->conditionB, $this->conditionC], + $this->getLeafEvaluator(true, false, null) + ) + ); + + $this->assertNull( + $this->conditionTreeEvaluator->evaluate( + ['not', $this->conditionA, $this->conditionB, $this->conditionC], + $this->getLeafEvaluator(null, false, true) + ) + ); + } + + // Test that notEvaluator returns null when condition evaluates to null. + public function testNotEvaluatorReturnsNullWhenConditionEvaluatesNull() + { + $this->assertNull( + $this->conditionTreeEvaluator->evaluate( + ['not', $this->conditionA], + $this->getLeafEvaluator(null) + ) + ); + } + + // Test that notEvaluator returns null when there are no conditions. + public function testNotEvaluatorReturnsNullWhenNoConditionsGiven() + { + $this->assertNull( + $this->conditionTreeEvaluator->evaluate( + ['not'], + $this->getLeafEvaluator(null) + ) + ); + } + + // Test that by default OR operator is assumed when the first item in conditions is not + // a recognized operator. + public function testEvaluateAssumesOrOperatorWhenFirstArrayItemUnrecognizedOperator() + { + $this->assertTrue( + $this->conditionTreeEvaluator->evaluate( + [$this->conditionA, $this->conditionB], + $this->getLeafEvaluator(false, true) + ) + ); + + $this->assertFalse( + $this->conditionTreeEvaluator->evaluate( + [$this->conditionA, $this->conditionB], + $this->getLeafEvaluator(false, false) + ) + ); + } + +} diff --git a/tests/UtilsTests/CustomAttributeConditionEvaluatorTest.php b/tests/UtilsTests/CustomAttributeConditionEvaluatorTest.php new file mode 100644 index 00000000..4f308085 --- /dev/null +++ b/tests/UtilsTests/CustomAttributeConditionEvaluatorTest.php @@ -0,0 +1,902 @@ +browserConditionSafari = (object)[ + 'type' => 'custom_attribute', + 'name' => 'browser_type', + 'value' => 'safari', + 'match' => 'exact' + ]; + $this->booleanCondition = (object)[ + 'type' => 'custom_attribute', + 'name' => 'is_firefox', + 'value' => true, + 'match' => 'exact' + ]; + $this->integerCondition = (object)[ + 'type' => 'custom_attribute', + 'name' => 'num_users', + 'value' => 10, + 'match' => 'exact' + ]; + $this->doubleCondition = (object)[ + 'type' => 'custom_attribute', + 'name' => 'pi_value', + 'value' => 3.14, + 'match' => 'exact' + ]; + $this->existsCondition = (object)[ + 'type' => 'custom_attribute', + 'name' => 'input_value', + 'value' => null, + 'match' => 'exists' + ]; + $this->exactStringCondition = (object)[ + 'name' => 'favorite_constellation', + 'value' =>'Lacerta', + 'type' => 'custom_attribute', + 'match' =>'exact' + ]; + $this->exactIntCondition = (object)[ + 'name' => 'lasers_count', + 'value' => 9000, + 'type' => 'custom_attribute', + 'match' => 'exact' + ]; + $this->exactFloatCondition = (object)[ + 'name' => 'lasers_count', + 'value' => 9000.0, + 'type' => 'custom_attribute', + 'match' => 'exact' + ]; + $this->exactBoolCondition = (object)[ + 'name' => 'did_register_user', + 'value' => false, + 'type' => 'custom_attribute', + 'match' => 'exact' + ]; + $this->substringCondition = (object)[ + 'name' => 'headline_text', + 'value' => 'buy now', + 'type' => 'custom_attribute', + 'match' => 'substring' + ]; + $this->gtIntCondition = (object)[ + 'name' => 'meters_travelled', + 'value' => 48, + 'type' => 'custom_attribute', + 'match' => 'gt' + ]; + $this->gtFloatCondition = (object)[ + 'name' => 'meters_travelled', + 'value' => 48.2, + 'type' => 'custom_attribute', + 'match' => 'gt' + ]; + $this->ltIntCondition = (object)[ + 'name' => 'meters_travelled', + 'value' => 48, + 'type' => 'custom_attribute', + 'match' => 'lt' + ]; + $this->ltFloatCondition = (object)[ + 'name' => 'meters_travelled', + 'value' => 48.2, + 'type' => 'custom_attribute', + 'match' => 'lt' + ]; + } + + public function testEvaluateReturnsTrueWhenAttrsPassAudienceCondition() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['browser_type' => 'safari'] + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->browserConditionSafari + ) + ); + } + + public function testEvaluateReturnsFalseWhenAttrsFailAudienceConditions() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['browser_type' => 'chrome'] + ); + + $this->assertFalse( + $customAttrConditionEvaluator->evaluate( + $this->browserConditionSafari + ) + ); + } + + public function testEvaluateForDifferentTypedAttributes() + { + $userAttributes = [ + 'browser_type' => 'safari', + 'is_firefox' => true, + 'num_users' => 10, + 'pi_value' => 3.14 + ]; + + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + $userAttributes + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->browserConditionSafari + ) + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->booleanCondition + ) + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->integerCondition + ) + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->doubleCondition + ) + ); + } + + public function testEvaluateReturnsNullForInvalidMatchProperty() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['weird_condition' => 'hi'] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + (object)[ + 'type' => 'custom_attribute', + 'name' => 'weird_condition', + 'value' => 'hi', + 'match' => 'weird_match' + ] + ) + ); + + } + + public function testEvaluateAssumesExactWhenConditionMatchPropertyIsNull() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['favorite_constellation' => 'Lacerta'] + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + (object)[ + 'type' => 'custom_attribute', + 'name' => 'favorite_constellation', + 'value' => 'Lacerta', + 'match' => null + ] + ) + ); + } + + public function testEvaluateReturnsNullWhenConditionHasInvalidTypeProperty() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['weird_condition' => 'hi'] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + (object)[ + 'type' => 'weird_type', + 'name' => 'weird_condition', + 'value' => 'hi', + 'match' => 'exact' + ] + ) + ); + } + + public function testExistsReturnsFalseWhenNoUserProvidedValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + [] + ); + + $this->assertFalse( + $customAttrConditionEvaluator->evaluate( + $this->existsCondition + ) + ); + } + + public function testExistsReturnsFalseWhenUserProvidedValueIsNull() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['input_value' => null] + ); + + $this->assertFalse( + $customAttrConditionEvaluator->evaluate( + $this->existsCondition + ) + ); + } + + public function testExistsReturnsTrueWhenUserProvidedValueIsString() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['input_value' => 'hi'] + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->existsCondition + ) + ); + } + + public function testExistsReturnsTrueWhenUserProvidedValueIsNumber() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['input_value' => 10] + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->existsCondition + ) + ); + + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['input_value' => 10.0] + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->existsCondition + ) + ); + + } + + public function testExistsReturnsTrueWhenUserProvidedValueIsBoolean() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['input_value' => false] + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->existsCondition + ) + ); + } + + public function testExactStringReturnsTrueWhenAttrsEqualToConditionValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['favorite_constellation' => 'Lacerta'] + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->exactStringCondition + ) + ); + } + + public function testExactStringReturnsFalseWhenAttrsNotEqualToConditionValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['favorite_constellation' => 'The Big Dipper'] + ); + + $this->assertFalse( + $customAttrConditionEvaluator->evaluate( + $this->exactStringCondition + ) + ); + } + + public function testExactStringReturnsNullWhenAttrsIsDifferentTypeFromConditionValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['favorite_constellation' => false] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->exactStringCondition + ) + ); + } + + public function testExactStringReturnsNullWhenNoUserProvidedValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + [] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->exactStringCondition + ) + ); + } + + public function testExactIntReturnsTrueWhenAttrsEqualToConditionValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['lasers_count' => 9000] + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->exactIntCondition + ) + ); + } + + public function testExactFloatReturnsTrueWhenAttrsEqualToConditionValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['lasers_count' => 9000.0] + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->exactFloatCondition + ) + ); + } + + public function testExactIntReturnsFalseWhenAttrsNotEqualToConditionValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['lasers_count' => 8000] + ); + + $this->assertFalse( + $customAttrConditionEvaluator->evaluate( + $this->exactIntCondition + ) + ); + } + + public function testExactFloatReturnsFalseWhenAttrsNotEqualToConditionValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['lasers_count' => 8000.0] + ); + + $this->assertFalse( + $customAttrConditionEvaluator->evaluate( + $this->exactFloatCondition + ) + ); + } + + public function testExactIntReturnsNullWhenAttrsIsDifferentTypeFromConditionValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['lasers_count' => 'hi'] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->exactIntCondition + ) + ); + + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['lasers_count' => true] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->exactIntCondition + ) + ); + } + + public function testExactFloatReturnsNullWhenAttrsIsDifferentTypeFromConditionValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['lasers_count' => 'hi'] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->exactFloatCondition + ) + ); + + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['lasers_count' => true] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->exactFloatCondition + ) + ); + } + + public function testExactIntReturnsNullWhenNoUserProvidedValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + [] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->exactIntCondition + ) + ); + } + + public function testExactFloatReturnsNullWhenNoUserProvidedValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + [] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->exactFloatCondition + ) + ); + } + + public function testExactBoolReturnsTrueWhenAttrsEqualToConditionValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['did_register_user' => false] + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->exactBoolCondition + ) + ); + } + + public function testExactBoolReturnsFalseWhenAttrsNotEqualToConditionValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['did_register_user' => true] + ); + + $this->assertFalse( + $customAttrConditionEvaluator->evaluate( + $this->exactBoolCondition + ) + ); + } + + public function testExactBoolReturnsNullWhenAttrsIsDifferentTypeFromConditionValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['did_register_user' => 0] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->exactBoolCondition + ) + ); + } + + public function testExactBoolReturnsNullWhenWhenNoUserProvidedValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + [] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->exactBoolCondition + ) + ); + } + + public function testSubstringReturnsTrueWhenConditionValueIsSubstringOfUserValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['headline_text' => 'Limited time, buy now!'] + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->substringCondition + ) + ); + } + + public function testSubstringReturnsFalseWhenConditionValueIsNotSubstringOfUserValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['headline_text' => 'Breaking news!'] + ); + + $this->assertFalse( + $customAttrConditionEvaluator->evaluate( + $this->substringCondition + ) + ); + } + + public function testSubstringReturnsNullWhenUserProvidedvalueNotAString() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['headline_text' => 10] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->substringCondition + ) + ); + } + + public function testSubstringReturnsNullWhenNoUserProvidedValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + [] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->substringCondition + ) + ); + } + + public function testGreaterThanIntReturnsTrueWhenUserValueGreaterThanConditionValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => 48.1] + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->gtIntCondition + ) + ); + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => 49] + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->gtIntCondition + ) + ); + } + + public function testGreaterThanFloatReturnsTrueWhenUserValueGreaterThanConditionValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => 48.3] + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->gtFloatCondition + ) + ); + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => 49] + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->gtFloatCondition + ) + ); + } + + public function testGreaterThanIntReturnsFalseWhenUserValueNotGreaterThanConditionValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => 47.9] + ); + + $this->assertFalse( + $customAttrConditionEvaluator->evaluate( + $this->gtIntCondition + ) + ); + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => 47] + ); + + $this->assertFalse( + $customAttrConditionEvaluator->evaluate( + $this->gtIntCondition + ) + ); + } + + public function testGreaterThanFloatReturnsFalseWhenUserValueNotGreaterThanConditionValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => 48.2] + ); + + $this->assertFalse( + $customAttrConditionEvaluator->evaluate( + $this->gtFloatCondition + ) + ); + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => 48] + ); + + $this->assertFalse( + $customAttrConditionEvaluator->evaluate( + $this->gtFloatCondition + ) + ); + } + + public function testGreaterThanIntReturnsNullWhenUserValueIsNotANumber() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => 'a long way'] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->gtIntCondition + ) + ); + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => false] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->gtIntCondition + ) + ); + } + + public function testGreaterThanFloatReturnsNullWhenUserValueIsNotANumber() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => 'a long way'] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->gtFloatCondition + ) + ); + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => false] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->gtFloatCondition + ) + ); + } + + public function testGreaterThanIntReturnsNullWhenNoUserProvidedValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + [] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->gtIntCondition + ) + ); + } + + public function testGreaterThanFloatReturnsNullWhenNoUserProvidedValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + [] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->gtFloatCondition + ) + ); + } + + public function testLessThanIntReturnsTrueWhenUserValueLessThanConditionValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => 47.9] + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->ltIntCondition + ) + ); + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => 47] + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->ltIntCondition + ) + ); + } + + public function testLessThanFloatReturnsTrueWhenUserValueLessThanConditionValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => 48.1] + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->ltFloatCondition + ) + ); + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => 48] + ); + + $this->assertTrue( + $customAttrConditionEvaluator->evaluate( + $this->ltFloatCondition + ) + ); + } + + public function testLessThanIntReturnsFalseWhenUserValueNotLessThanConditionValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => 48.1] + ); + + $this->assertFalse( + $customAttrConditionEvaluator->evaluate( + $this->ltIntCondition + ) + ); + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => 49] + ); + + $this->assertFalse( + $customAttrConditionEvaluator->evaluate( + $this->ltIntCondition + ) + ); + } + + public function testLessThanFloatReturnsFalseWhenUserValueNotLessThanConditionValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => 48.2] + ); + + $this->assertFalse( + $customAttrConditionEvaluator->evaluate( + $this->ltFloatCondition + ) + ); + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => 49] + ); + + $this->assertFalse( + $customAttrConditionEvaluator->evaluate( + $this->ltFloatCondition + ) + ); + } + + public function testLessThanIntReturnsNullWhenUserValueIsNotANumber() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => 'a long way'] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->ltIntCondition + ) + ); + + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => false] + ); + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->ltIntCondition + ) + ); + } + + public function testLessThanFloatReturnsNullWhenUserValueIsNotANumber() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => 'a long way'] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->ltFloatCondition + ) + ); + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + ['meters_travelled' => false] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->ltFloatCondition + ) + ); + } + + public function testLessThanIntReturnsNullWhenNoUserProvidedValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + [] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->ltIntCondition + ) + ); + } + + public function testLessThanFloatReturnsNullWhenNoUserProvidedValue() + { + $customAttrConditionEvaluator = new CustomAttributeConditionEvaluator( + [] + ); + + $this->assertNull( + $customAttrConditionEvaluator->evaluate( + $this->ltFloatCondition + ) + ); + } +} From d617d1837b2da293404aa0f4203935c1fb027bad Mon Sep 17 00:00:00 2001 From: Owais Akbani Date: Sat, 22 Dec 2018 04:51:58 +0500 Subject: [PATCH 2/2] feat (audience match types): Update audience evaluator and project config for new audience match types (#145) --- src/Optimizely/ProjectConfig.php | 15 +- src/Optimizely/Utils/ConditionDecoder.php | 44 --- .../Utils/ConditionTreeEvaluator.php | 2 +- .../CustomAttributeConditionEvaluator.php | 41 ++- src/Optimizely/Utils/Validator.php | 19 +- .../DecisionServiceTest.php | 2 +- tests/OptimizelyTest.php | 164 ++++++++++++ tests/ProjectConfigTest.php | 29 ++ tests/TestData.php | 251 ++++++++++++++++++ tests/UtilsTests/ConditionDecoderTest.php | 58 ---- .../UtilsTests/ConditionTreeEvaluatorTest.php | 6 +- .../CustomAttributeConditionEvaluatorTest.php | 34 +-- tests/UtilsTests/ValidatorTest.php | 64 ++++- 13 files changed, 576 insertions(+), 153 deletions(-) delete mode 100644 src/Optimizely/Utils/ConditionDecoder.php delete mode 100644 tests/UtilsTests/ConditionDecoderTest.php diff --git a/src/Optimizely/ProjectConfig.php b/src/Optimizely/ProjectConfig.php index 674c2e96..fd172299 100644 --- a/src/Optimizely/ProjectConfig.php +++ b/src/Optimizely/ProjectConfig.php @@ -214,6 +214,7 @@ public function __construct($datafile, $logger, $errorHandler) $events = $config['events'] ?: []; $attributes = $config['attributes'] ?: []; $audiences = $config['audiences'] ?: []; + $typedAudiences = isset($config['typedAudiences']) ? $config['typedAudiences']: []; $rollouts = isset($config['rollouts']) ? $config['rollouts'] : []; $featureFlags = isset($config['featureFlags']) ? $config['featureFlags']: []; @@ -221,6 +222,7 @@ public function __construct($datafile, $logger, $errorHandler) $this->_experimentKeyMap = ConfigParser::generateMap($experiments, 'key', Experiment::class); $this->_eventKeyMap = ConfigParser::generateMap($events, 'key', Event::class); $this->_attributeKeyMap = ConfigParser::generateMap($attributes, 'key', Attribute::class); + $typedAudienceIdMap = ConfigParser::generateMap($typedAudiences, 'id', Audience::class); $this->_audienceIdMap = ConfigParser::generateMap($audiences, 'id', Audience::class); $this->_rollouts = ConfigParser::generateMap($rollouts, null, Rollout::class); $this->_featureFlags = ConfigParser::generateMap($featureFlags, null, FeatureFlag::class); @@ -249,12 +251,19 @@ public function __construct($datafile, $logger, $errorHandler) } } - $conditionDecoder = new ConditionDecoder(); foreach (array_values($this->_audienceIdMap) as $audience) { - $conditionDecoder->deserializeAudienceConditions($audience->getConditions()); - $audience->setConditionsList($conditionDecoder->getConditionsList()); + $audience->setConditionsList(json_decode($audience->getConditions(), true)); } + // Conditions in typedAudiences are not expected to be string-encoded so they don't need + // to be decoded unlike audiences. + foreach (array_values($typedAudienceIdMap) as $typedAudience) { + $typedAudience->setConditionsList($typedAudience->getConditions()); + } + + // Overwrite audiences by typedAudiences. + $this->_audienceIdMap = array_replace($this->_audienceIdMap, $typedAudienceIdMap); + $rolloutVariationIdMap = []; $rolloutVariationKeyMap = []; foreach ($this->_rollouts as $rollout) { diff --git a/src/Optimizely/Utils/ConditionDecoder.php b/src/Optimizely/Utils/ConditionDecoder.php deleted file mode 100644 index ba0df673..00000000 --- a/src/Optimizely/Utils/ConditionDecoder.php +++ /dev/null @@ -1,44 +0,0 @@ -_conditionsList = json_decode($conditions); - } - - /** - * @return array JSON decoded audience conditions. - */ - public function getConditionsList() - { - return $this->_conditionsList; - } -} diff --git a/src/Optimizely/Utils/ConditionTreeEvaluator.php b/src/Optimizely/Utils/ConditionTreeEvaluator.php index ecd77b62..fe84c2c1 100644 --- a/src/Optimizely/Utils/ConditionTreeEvaluator.php +++ b/src/Optimizely/Utils/ConditionTreeEvaluator.php @@ -140,7 +140,7 @@ protected function notEvaluator(array $condition, callable $leafEvaluator) */ public function evaluate($conditions, callable $leafEvaluator) { - if (is_array($conditions)) { + if (!Validator::doesArrayContainOnlyStringKeys($conditions)) { if(in_array($conditions[0], $this->getOperators())) { $operator = array_shift($conditions); diff --git a/src/Optimizely/Utils/CustomAttributeConditionEvaluator.php b/src/Optimizely/Utils/CustomAttributeConditionEvaluator.php index c4195e48..1ead3749 100644 --- a/src/Optimizely/Utils/CustomAttributeConditionEvaluator.php +++ b/src/Optimizely/Utils/CustomAttributeConditionEvaluator.php @@ -44,6 +44,21 @@ public function __construct(array $userAttributes) $this->userAttributes = $userAttributes; } + /** + * Sets null for missing keys in a leaf condition. + * + * @param array $leafCondition The leaf condition node of an audience. + */ + protected function setNullForMissingKeys(array $leafCondition) + { + $keys = ['type', 'match', 'value']; + foreach($keys as $key) { + $leafCondition[$key] = isset($leafCondition[$key]) ? $leafCondition[$key]: null; + } + + return $leafCondition; + } + /** * Gets the supported match types for condition evaluation. * @@ -103,8 +118,8 @@ protected function isValueValidForExactConditions($value) */ protected function exactEvaluator($condition) { - $conditionName = $condition->{'name'}; - $conditionValue = $condition->{'value'}; + $conditionName = $condition['name']; + $conditionValue = $condition['value']; $userValue = isset($this->userAttributes[$conditionName]) ? $this->userAttributes[$conditionName]: null; if(!$this->isValueValidForExactConditions($userValue) || @@ -128,7 +143,7 @@ protected function exactEvaluator($condition) */ protected function existsEvaluator($condition) { - $conditionName = $condition->{'name'}; + $conditionName = $condition['name']; return isset($this->userAttributes[$conditionName]); } @@ -144,8 +159,8 @@ protected function existsEvaluator($condition) */ protected function greaterThanEvaluator($condition) { - $conditionName = $condition->{'name'}; - $conditionValue = $condition->{'value'}; + $conditionName = $condition['name']; + $conditionValue = $condition['value']; $userValue = isset($this->userAttributes[$conditionName]) ? $this->userAttributes[$conditionName]: null; if(!Validator::isFiniteNumber($userValue) || !Validator::isFiniteNumber($conditionValue)) { @@ -167,8 +182,8 @@ protected function greaterThanEvaluator($condition) */ protected function lessThanEvaluator($condition) { - $conditionName = $condition->{'name'}; - $conditionValue = $condition->{'value'}; + $conditionName = $condition['name']; + $conditionValue = $condition['value']; $userValue = isset($this->userAttributes[$conditionName]) ? $this->userAttributes[$conditionName]: null; if(!Validator::isFiniteNumber($userValue) || !Validator::isFiniteNumber($conditionValue)) { @@ -190,8 +205,8 @@ protected function lessThanEvaluator($condition) */ protected function substringEvaluator($condition) { - $conditionName = $condition->{'name'}; - $conditionValue = $condition->{'value'}; + $conditionName = $condition['name']; + $conditionValue = $condition['value']; $userValue = isset($this->userAttributes[$conditionName]) ? $this->userAttributes[$conditionName]: null; if(!is_string($userValue) || !is_string($conditionValue)) { @@ -211,14 +226,16 @@ protected function substringEvaluator($condition) */ public function evaluate($leafCondition) { - if($leafCondition->{'type'} !== self::CUSTOM_ATTRIBUTE_CONDITION_TYPE) { + $leafCondition = $this->setNullForMissingKeys($leafCondition); + + if($leafCondition['type'] !== self::CUSTOM_ATTRIBUTE_CONDITION_TYPE) { return null; } - if(!isset($leafCondition->{'match'})) { + if(($leafCondition['match']) === null) { $conditionMatch = self::EXACT_MATCH_TYPE; } else { - $conditionMatch = $leafCondition->{'match'}; + $conditionMatch = $leafCondition['match']; } if(!in_array($conditionMatch, $this->getMatchTypes())) { diff --git a/src/Optimizely/Utils/Validator.php b/src/Optimizely/Utils/Validator.php index 57becd54..00cde806 100644 --- a/src/Optimizely/Utils/Validator.php +++ b/src/Optimizely/Utils/Validator.php @@ -111,9 +111,8 @@ public static function isUserInExperiment($config, $experiment, $userAttributes) return true; } - // Return false if there is audience, but no user attributes. - if (empty($userAttributes)) { - return false; + if ($userAttributes == null) { + $userAttributes = []; } $customAttrCondEval = new CustomAttributeConditionEvaluator($userAttributes); @@ -223,4 +222,18 @@ public static function areValuesSameType($firstVal, $secondVal) return $firstValType == $secondValType; } + + /** + * Returns true only if given input is an array with all of it's keys of type string. + * @param mixed $arr + * @return bool True if array contains all string keys. Otherwise, false. + */ + public static function doesArrayContainOnlyStringKeys($arr) + { + if(!is_array($arr) || empty($arr)) { + return false; + } + + return count(array_filter(array_keys($arr), 'is_string')) == count(array_keys($arr)); + } } diff --git a/tests/DecisionServiceTests/DecisionServiceTest.php b/tests/DecisionServiceTests/DecisionServiceTest.php index 7af23d98..eb0e349c 100644 --- a/tests/DecisionServiceTests/DecisionServiceTest.php +++ b/tests/DecisionServiceTests/DecisionServiceTest.php @@ -1138,7 +1138,7 @@ public function testGetVariationForFeatureRolloutWhenUserDoesNotQualifyForAnyTar $experiment2 = $rollout->getExperiments()[2]; // Set an AudienceId for everyone else/last rule so that user does not qualify for audience - $experiment2->setAudienceIds(["11154"]); + $experiment2->setAudienceIds(["11155"]); $expected_variation = $experiment2->getVariations()[0]; // Provide null attributes so that user does not qualify for audience diff --git a/tests/OptimizelyTest.php b/tests/OptimizelyTest.php index 94cdc050..0f283397 100644 --- a/tests/OptimizelyTest.php +++ b/tests/OptimizelyTest.php @@ -49,6 +49,7 @@ class OptimizelyTest extends \PHPUnit_Framework_TestCase public function setUp() { $this->datafile = DATAFILE; + $this->typedAudiencesDataFile = DATAFILE_WITH_TYPED_AUDIENCES; $this->testBucketingIdControl = 'testBucketingIdControl!'; // generates bucketing number 3741 $this->testBucketingIdVariation = '123456789'; // generates bucketing number 4567 $this->variationKeyControl = 'control'; @@ -61,8 +62,12 @@ public function setUp() ->setMethods(array('log')) ->getMock(); $this->optimizelyObject = new Optimizely($this->datafile, null, $this->loggerMock); + $this->optimizelyTypedAudienceObject = new Optimizely( + $this->typedAudiencesDataFile, null, $this->loggerMock + ); $this->projectConfig = new ProjectConfig($this->datafile, $this->loggerMock, new NoOpErrorHandler()); + $this->projectConfigForTypedAudience = new ProjectConfig($this->typedAudiencesDataFile, $this->loggerMock, new NoOpErrorHandler()); // Mock EventBuilder $this->eventBuilderMock = $this->getMockBuilder(EventBuilder::class) @@ -602,6 +607,58 @@ public function testActivateWithAttributesOfDifferentTypes() $this->assertEquals('control', $optimizelyMock->activate('test_experiment', 'test_user', $userAttributes)); } + public function testActivateWithAttributesTypedAudienceMatch() + { + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->typedAudiencesDataFile , null, null)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + $userAttributes = [ + 'house' => 'Gryffindor' + ]; + + // Verify that sendImpressionEvent is called with expected attributes + $optimizelyMock->expects($this->at(0)) + ->method('sendImpressionEvent') + ->with('typed_audience_experiment', 'A', 'test_user', $userAttributes); + + // Should be included via exact match string audience with id '3468206642' + $this->assertEquals('A', $optimizelyMock->activate('typed_audience_experiment', 'test_user', $userAttributes)); + + $userAttributes = [ + 'lasers' => 45.5 + ]; + + // Verify that sendImpressionEvent is called with expected attributes + $optimizelyMock->expects($this->at(0)) + ->method('sendImpressionEvent') + ->with('typed_audience_experiment', 'A', 'test_user', $userAttributes); + + //Should be included via exact match number audience with id '3468206646' + $this->assertEquals('A', $optimizelyMock->activate('typed_audience_experiment', 'test_user', $userAttributes)); + + } + + public function testActivateWithAttributesTypedAudienceMismatch() + { + $userAttributes = [ + 'house' => 'Hufflepuff' + ]; + + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->typedAudiencesDataFile , null, $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + // Verify that sendImpressionEvent is not called + $optimizelyMock->expects($this->never()) + ->method('sendImpressionEvent'); + + // Call activate + $this->assertNull($optimizelyMock->activate('typed_audience_experiment', 'test_user', $userAttributes)); + } + public function testActivateExperimentNotRunning() { $optimizelyMock = $this->getMockBuilder(Optimizely::class) @@ -1863,6 +1920,56 @@ public function testTrackWithAttributesWithEventValue() $optlyObject->track('purchase', 'test_user', $userAttributes, array('revenue' => 42)); } + public function testTrackWithAttributesTypedAudienceMatch() + { + $userAttributes = [ + 'house' => 'Welcome to Slytherin!' + ]; + + $this->eventBuilderMock->expects($this->once()) + ->method('createConversionEvent') + ->with( + $this->projectConfigForTypedAudience, + 'item_bought', + [ + '11564051718' => '11617170975', + '1323241597' => '1423767503' + ], + 'test_user', + $userAttributes, + array('revenue' => 42) + ) + ->willReturn(new LogEvent('logx.optimizely.com/track', ['param1' => 'val1'], 'POST', [])); + + $optlyObject = new Optimizely($this->typedAudiencesDataFile, new ValidEventDispatcher(), $this->loggerMock); + + $eventBuilder = new \ReflectionProperty(Optimizely::class, '_eventBuilder'); + $eventBuilder->setAccessible(true); + $eventBuilder->setValue($optlyObject, $this->eventBuilderMock); + + // Should be included via substring match string audience with id '3988293898' + $optlyObject->track('item_bought', 'test_user', $userAttributes, array('revenue' => 42)); + } + + public function testTrackWithAttributesTypedAudienceMismatch() + { + $userAttributes = [ + 'house' => 'Hufflepuff!' + ]; + + $this->eventBuilderMock->expects($this->never()) + ->method('createConversionEvent'); + + $optlyObject = new Optimizely($this->typedAudiencesDataFile, new ValidEventDispatcher(), $this->loggerMock); + + $eventBuilder = new \ReflectionProperty(Optimizely::class, '_eventBuilder'); + $eventBuilder->setAccessible(true); + $eventBuilder->setValue($optlyObject, $this->eventBuilderMock); + + // Call track + $optlyObject->track('item_bought', 'test_user', $userAttributes, array('revenue' => 42)); + } + public function testTrackWithEmptyUserID() { $userAttributes = [ @@ -2654,6 +2761,36 @@ public function testIsFeatureEnabledGivenFeatureRolloutAndFeatureEnabledIsFalse( $this->assertFalse($optimizelyMock->isFeatureEnabled('boolean_single_variable_feature', 'user_id', [])); } + public function testIsFeatureEnabledGivenFeatureRolloutTypedAudienceMatch() + { + $userAttributes = [ + 'favorite_ice_cream' => 'chocolate' + ]; + + // Should be included via exists match audience with id '3988293899' + $this->assertTrue( + $this->optimizelyTypedAudienceObject->isFeatureEnabled('feat', 'test_user', $userAttributes) + ); + + $userAttributes = [ + 'lasers' => -3 + ]; + + // Should be included via less-than match audience with id '3468206644' + $this->assertTrue( + $this->optimizelyTypedAudienceObject->isFeatureEnabled('feat', 'test_user', $userAttributes) + ); + } + + public function testIsFeatureEnabledGivenFeatureRolloutTypedAudienceMismatch() + { + $userAttributes = []; + + $this->assertFalse( + $this->optimizelyTypedAudienceObject->isFeatureEnabled('feat', 'test_user', $userAttributes) + ); + } + public function testIsFeatureEnabledWithEmptyUserID() { $optimizelyMock = $this->getMockBuilder(Optimizely::class) @@ -3259,6 +3396,33 @@ public function testGetFeatureVariableMethodsReturnNullWhenGetVariableValueForTy ); } + public function testGetFeatureVariableReturnsVariableValueForTypedAudienceMatch() + { + $userAttributes = [ + 'lasers' => 71 + ]; + + // Should be included in the feature test via greater-than match audience with id '3468206647' + $this->assertEquals('xyz', $this->optimizelyTypedAudienceObject->getFeatureVariableString('feat_with_var', 'x', 'user1', $userAttributes)); + + $userAttributes = [ + 'should_do_it' => true + ]; + + // Should be included in the feature test via exact match boolean audience with id '3468206643' + $this->assertEquals('xyz', $this->optimizelyTypedAudienceObject->getFeatureVariableString('feat_with_var', 'x', 'user1', $userAttributes)); + } + + public function testGetFeatureVariableReturnsDefaultValueForTypedAudienceMismatch() + { + $userAttributes = [ + 'lasers' => 50 + ]; + + // Should be included in the feature test via greater-than match audience with id '3468206647' + $this->assertEquals('x', $this->optimizelyTypedAudienceObject->getFeatureVariableString('feat_with_var', 'x', 'user1', $userAttributes)); + } + public function testSendImpressionEventWithNoAttributes() { $optlyObject = new OptimizelyTester($this->datafile, new ValidEventDispatcher(), $this->loggerMock); diff --git a/tests/ProjectConfigTest.php b/tests/ProjectConfigTest.php index e84d6538..b19c7526 100644 --- a/tests/ProjectConfigTest.php +++ b/tests/ProjectConfigTest.php @@ -508,6 +508,35 @@ public function testGetAudienceValidId() $this->assertEquals('iPhone users in San Francisco', $audience->getName()); } + public function testGetAudiencePrefersTypedAudiencesOverAudiences() + { + $projectConfig = new ProjectConfig( + DATAFILE_WITH_TYPED_AUDIENCES, $this->loggerMock, $this->errorHandlerMock + ); + + // test that typedAudience is returned when an audience exists with the same ID. + $audience = $projectConfig->getAudience('3988293898'); + + $this->assertEquals('3988293898', $audience->getId()); + $this->assertEquals('substringString', $audience->getName()); + + $expectedConditions = json_decode('["and", ["or", ["or", {"name": "house", "type": "custom_attribute", + "match": "substring", "value": "Slytherin"}]]]', true); + $this->assertEquals($expectedConditions, $audience->getConditions()); + $this->assertEquals($expectedConditions, $audience->getConditionsList()); + + // test that normal audience is returned if no typedAudience exists with the same ID. + $audience = $projectConfig->getAudience('3468206642'); + + $this->assertEquals('3468206642', $audience->getId()); + $this->assertEquals('exactString', $audience->getName()); + + $expectedConditions = '["and", ["or", ["or", {"name": "house", "type": "custom_attribute", "value": "Gryffindor"}]]]'; + $this->assertEquals($expectedConditions, $audience->getConditions()); + $expectedConditionsList = json_decode($expectedConditions, true); + $this->assertEquals($expectedConditionsList, $audience->getConditionsList()); + } + public function testGetAudienceInvalidKey() { $this->loggerMock->expects($this->once()) diff --git a/tests/TestData.php b/tests/TestData.php index 79f4b627..ac739474 100644 --- a/tests/TestData.php +++ b/tests/TestData.php @@ -843,6 +843,257 @@ }' ); +define( + 'DATAFILE_WITH_TYPED_AUDIENCES', + '{ + "version": "4", + "rollouts": [ + { + "experiments": [ + { + "status": "Running", + "key": "11488548027", + "layerId": "11551226731", + "trafficAllocation": [ + { + "entityId": "11557362669", + "endOfRange": 10000 + } + ], + "audienceIds": ["3468206642", "3988293898", "3988293899", "3468206646", + "3468206647", "3468206644", "3468206643"], + "variations": [ + { + "variables": [], + "id": "11557362669", + "key": "11557362669", + "featureEnabled":true + } + ], + "forcedVariations": {}, + "id": "11488548027" + } + ], + "id": "11551226731" + }, + { + "experiments": [ + { + "status": "Paused", + "key": "11630490911", + "layerId": "11638870867", + "trafficAllocation": [ + { + "entityId": "11475708558", + "endOfRange": 0 + } + ], + "audienceIds": [], + "variations": [ + { + "variables": [], + "id": "11475708558", + "key": "11475708558", + "featureEnabled":false + } + ], + "forcedVariations": {}, + "id": "11630490911" + } + ], + "id": "11638870867" + } + ], + "anonymizeIP": false, + "projectId": "11624721371", + "variables": [], + "featureFlags": [ + { + "experimentIds": [], + "rolloutId": "11551226731", + "variables": [], + "id": "11477755619", + "key": "feat" + }, + { + "experimentIds": [ + "11564051718" + ], + "rolloutId": "11638870867", + "variables": [ + { + "defaultValue": "x", + "type": "string", + "id": "11535264366", + "key": "x" + } + ], + "id": "11567102051", + "key": "feat_with_var" + } + ], + "experiments": [ + { + "status": "Running", + "key": "feat_with_var_test", + "layerId": "11504144555", + "trafficAllocation": [ + { + "entityId": "11617170975", + "endOfRange": 10000 + } + ], + "audienceIds": ["3468206642", "3988293898", "3988293899", "3468206646", + "3468206647", "3468206644", "3468206643"], + "variations": [ + { + "variables": [ + { + "id": "11535264366", + "value": "xyz" + } + ], + "id": "11617170975", + "key": "variation_2", + "featureEnabled": true + } + ], + "forcedVariations": {}, + "id": "11564051718" + }, + { + "id": "1323241597", + "key": "typed_audience_experiment", + "layerId": "1630555627", + "status": "Running", + "variations": [ + { + "id": "1423767503", + "key": "A", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1423767503", + "endOfRange": 10000 + } + ], + "audienceIds": ["3468206642", "3988293898", "3988293899", "3468206646", + "3468206647", "3468206644", "3468206643"], + "forcedVariations": {} + } + ], + "audiences": [ + { + "id": "3468206642", + "name": "exactString", + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"house\", \"type\": \"custom_attribute\", \"value\": \"Gryffindor\"}]]]" + }, + { + "id": "3988293898", + "name": "$$dummySubstringString", + "conditions": "{ \"type\": \"custom_attribute\", \"name\": \"$opt_dummy_attribute\", \"value\": \"impossible_value\" }" + }, + { + "id": "3988293899", + "name": "$$dummyExists", + "conditions": "{ \"type\": \"custom_attribute\", \"name\": \"$opt_dummy_attribute\", \"value\": \"impossible_value\" }" + }, + { + "id": "3468206646", + "name": "$$dummyExactNumber", + "conditions": "{ \"type\": \"custom_attribute\", \"name\": \"$opt_dummy_attribute\", \"value\": \"impossible_value\" }" + }, + { + "id": "3468206647", + "name": "$$dummyGtNumber", + "conditions": "{ \"type\": \"custom_attribute\", \"name\": \"$opt_dummy_attribute\", \"value\": \"impossible_value\" }" + }, + { + "id": "3468206644", + "name": "$$dummyLtNumber", + "conditions": "{ \"type\": \"custom_attribute\", \"name\": \"$opt_dummy_attribute\", \"value\": \"impossible_value\" }" + }, + { + "id": "3468206643", + "name": "$$dummyExactBoolean", + "conditions": "{ \"type\": \"custom_attribute\", \"name\": \"$opt_dummy_attribute\", \"value\": \"impossible_value\" }" + } + ], + "typedAudiences": [ + { + "id": "3988293898", + "name": "substringString", + "conditions": ["and", ["or", ["or", {"name": "house", "type": "custom_attribute", + "match": "substring", "value": "Slytherin"}]]] + }, + { + "id": "3988293899", + "name": "exists", + "conditions": ["and", ["or", ["or", {"name": "favorite_ice_cream", "type": "custom_attribute", + "match": "exists"}]]] + }, + { + "id": "3468206646", + "name": "exactNumber", + "conditions": ["and", ["or", ["or", {"name": "lasers", "type": "custom_attribute", + "match": "exact", "value": 45.5}]]] + }, + { + "id": "3468206647", + "name": "gtNumber", + "conditions": ["and", ["or", ["or", {"name": "lasers", "type": "custom_attribute", + "match": "gt", "value": 70}]]] + }, + { + "id": "3468206644", + "name": "ltNumber", + "conditions": ["and", ["or", ["or", {"name": "lasers", "type": "custom_attribute", + "match": "lt", "value": 1.0}]]] + }, + { + "id": "3468206643", + "name": "exactBoolean", + "conditions": ["and", ["or", ["or", {"name": "should_do_it", "type": "custom_attribute", + "match": "exact", "value": true}]]] + } + ], + "groups": [], + "attributes": [ + { + "key": "house", + "id": "594015" + }, + { + "key": "lasers", + "id": "594016" + }, + { + "key": "should_do_it", + "id": "594017" + }, + { + "key": "favorite_ice_cream", + "id": "594018" + } + ], + "botFiltering": false, + "accountId": "4879520872", + "events": [ + { + "key": "item_bought", + "id": "594089", + "experimentIds": [ + "11564051718", + "1323241597" + ] + } + ], + "revision": "3" + }' +); + /** * Class TestBucketer * Extending Bucketer for the sake of tests. diff --git a/tests/UtilsTests/ConditionDecoderTest.php b/tests/UtilsTests/ConditionDecoderTest.php deleted file mode 100644 index a94298e3..00000000 --- a/tests/UtilsTests/ConditionDecoderTest.php +++ /dev/null @@ -1,58 +0,0 @@ -conditionDecoder = new ConditionDecoder(); - $conditions = "[\"and\", [\"or\", [\"or\", {\"name\": \"device_type\", \"type\": \"custom_attribute\", \"value\": \"iPhone\"}]], [\"or\", [\"or\", {\"name\": \"location\", \"type\": \"custom_attribute\", \"value\": \"San Francisco\"}]]]"; - $this->conditionDecoder->deserializeAudienceConditions($conditions); - } - - public function testGetConditionsList() - { - $this->assertEquals( - [ - 'and', [ - 'or', [ - 'or', (object)[ - 'name' => 'device_type', - 'type' => 'custom_attribute', - 'value' => 'iPhone' - ] - ] - ], [ - 'or', [ - 'or', (object)[ - 'name' => 'location', - 'type' => 'custom_attribute', - 'value' => 'San Francisco' - ] - ] - ] - ], - $this->conditionDecoder->getConditionsList() - ); - } -} diff --git a/tests/UtilsTests/ConditionTreeEvaluatorTest.php b/tests/UtilsTests/ConditionTreeEvaluatorTest.php index d63f61ad..e9c5d3de 100644 --- a/tests/UtilsTests/ConditionTreeEvaluatorTest.php +++ b/tests/UtilsTests/ConditionTreeEvaluatorTest.php @@ -23,19 +23,19 @@ class ConditionTreeEvaluatorTest extends \PHPUnit_Framework_TestCase { public function setUp() { - $this->conditionA = (object)[ + $this->conditionA = [ 'name' => 'browser_type', 'value' => 'safari', 'type' => 'custom_attribute' ]; - $this->conditionB = (object)[ + $this->conditionB = [ 'name' => 'device_model', 'value' => 'iphone6', 'type' => 'custom_attribute' ]; - $this->conditionC = (object)[ + $this->conditionC = [ 'name' => 'location', 'match' => 'exact', 'type' => 'custom_attribute', diff --git a/tests/UtilsTests/CustomAttributeConditionEvaluatorTest.php b/tests/UtilsTests/CustomAttributeConditionEvaluatorTest.php index 4f308085..2577d8f0 100644 --- a/tests/UtilsTests/CustomAttributeConditionEvaluatorTest.php +++ b/tests/UtilsTests/CustomAttributeConditionEvaluatorTest.php @@ -23,85 +23,85 @@ class CustomAttributeConditionEvaluatorTest extends \PHPUnit_Framework_TestCase { public function setUp() { - $this->browserConditionSafari = (object)[ + $this->browserConditionSafari = [ 'type' => 'custom_attribute', 'name' => 'browser_type', 'value' => 'safari', 'match' => 'exact' ]; - $this->booleanCondition = (object)[ + $this->booleanCondition = [ 'type' => 'custom_attribute', 'name' => 'is_firefox', 'value' => true, 'match' => 'exact' ]; - $this->integerCondition = (object)[ + $this->integerCondition = [ 'type' => 'custom_attribute', 'name' => 'num_users', 'value' => 10, 'match' => 'exact' ]; - $this->doubleCondition = (object)[ + $this->doubleCondition = [ 'type' => 'custom_attribute', 'name' => 'pi_value', 'value' => 3.14, 'match' => 'exact' ]; - $this->existsCondition = (object)[ + $this->existsCondition = [ 'type' => 'custom_attribute', 'name' => 'input_value', 'value' => null, 'match' => 'exists' ]; - $this->exactStringCondition = (object)[ + $this->exactStringCondition = [ 'name' => 'favorite_constellation', 'value' =>'Lacerta', 'type' => 'custom_attribute', 'match' =>'exact' ]; - $this->exactIntCondition = (object)[ + $this->exactIntCondition = [ 'name' => 'lasers_count', 'value' => 9000, 'type' => 'custom_attribute', 'match' => 'exact' ]; - $this->exactFloatCondition = (object)[ + $this->exactFloatCondition = [ 'name' => 'lasers_count', 'value' => 9000.0, 'type' => 'custom_attribute', 'match' => 'exact' ]; - $this->exactBoolCondition = (object)[ + $this->exactBoolCondition = [ 'name' => 'did_register_user', 'value' => false, 'type' => 'custom_attribute', 'match' => 'exact' ]; - $this->substringCondition = (object)[ + $this->substringCondition = [ 'name' => 'headline_text', 'value' => 'buy now', 'type' => 'custom_attribute', 'match' => 'substring' ]; - $this->gtIntCondition = (object)[ + $this->gtIntCondition = [ 'name' => 'meters_travelled', 'value' => 48, 'type' => 'custom_attribute', 'match' => 'gt' ]; - $this->gtFloatCondition = (object)[ + $this->gtFloatCondition = [ 'name' => 'meters_travelled', 'value' => 48.2, 'type' => 'custom_attribute', 'match' => 'gt' ]; - $this->ltIntCondition = (object)[ + $this->ltIntCondition = [ 'name' => 'meters_travelled', 'value' => 48, 'type' => 'custom_attribute', 'match' => 'lt' ]; - $this->ltFloatCondition = (object)[ + $this->ltFloatCondition = [ 'name' => 'meters_travelled', 'value' => 48.2, 'type' => 'custom_attribute', @@ -181,7 +181,7 @@ public function testEvaluateReturnsNullForInvalidMatchProperty() $this->assertNull( $customAttrConditionEvaluator->evaluate( - (object)[ + [ 'type' => 'custom_attribute', 'name' => 'weird_condition', 'value' => 'hi', @@ -200,7 +200,7 @@ public function testEvaluateAssumesExactWhenConditionMatchPropertyIsNull() $this->assertTrue( $customAttrConditionEvaluator->evaluate( - (object)[ + [ 'type' => 'custom_attribute', 'name' => 'favorite_constellation', 'value' => 'Lacerta', @@ -218,7 +218,7 @@ public function testEvaluateReturnsNullWhenConditionHasInvalidTypeProperty() $this->assertNull( $customAttrConditionEvaluator->evaluate( - (object)[ + [ 'type' => 'weird_type', 'name' => 'weird_condition', 'value' => 'hi', diff --git a/tests/UtilsTests/ValidatorTest.php b/tests/UtilsTests/ValidatorTest.php index 715b54a9..f14dfcd9 100644 --- a/tests/UtilsTests/ValidatorTest.php +++ b/tests/UtilsTests/ValidatorTest.php @@ -18,6 +18,7 @@ namespace Optimizely\Tests; use Monolog\Logger; +use Optimizely\Entity\Audience; use Optimizely\ErrorHandler\NoOpErrorHandler; use Optimizely\Logger\NoOpLogger; use Optimizely\ProjectConfig; @@ -170,25 +171,44 @@ public function testIsUserInExperimentNoAudienceUsedInExperiment() ); } + // test that Audience evaluation proceeds if provided attributes are empty or null. public function testIsUserInExperimentAudienceUsedInExperimentNoAttributesProvided() { - $config = new ProjectConfig(DATAFILE, new NoOpLogger(), new NoOpErrorHandler()); + $configMock = $this->getMockBuilder(ProjectConfig::class) + ->setConstructorArgs(array(DATAFILE, $this->loggerMock, new NoOpErrorHandler())) + ->setMethods(array('getAudience')) + ->getMock(); - // Test with empty attributes - $this->assertFalse( + $existsCondition = [ + 'type' => 'custom_attribute', + 'name' => 'input_value', + 'match' => 'exists', + 'value' => null + ]; + + $experiment = $configMock->getExperimentFromKey('test_experiment'); + $experiment->setAudienceIds(['007']); + $audience = new Audience(); + $audience->setConditionsList(['not', $existsCondition]); + + $configMock + ->method('getAudience') + ->with('007') + ->will($this->returnValue($audience)); + + $this->assertTrue( Validator::isUserInExperiment( - $config, - $config->getExperimentFromKey('test_experiment'), - [] + $configMock, + $experiment, + null ) ); - // Test with null attributes - $this->assertFalse( + $this->assertTrue( Validator::isUserInExperiment( - $config, - $config->getExperimentFromKey('test_experiment'), - null + $configMock, + $experiment, + [] ) ); } @@ -244,4 +264,26 @@ public function testIsFeatureFlagValid() $this->assertFalse(Validator::isFeatureFlagValid($config, $featureFlag)); } + + public function testDoesArrayContainOnlyStringKeys() + { + // Valid values + $this->assertTrue(Validator::doesArrayContainOnlyStringKeys( + ["name"=> "favorite_ice_cream", "type"=> "custom_attribute", "match"=> "exists"]) + ); + + // Invalid values + $this->assertFalse(Validator::doesArrayContainOnlyStringKeys([])); + $this->assertFalse(Validator::doesArrayContainOnlyStringKeys((object)[])); + $this->assertFalse(Validator::doesArrayContainOnlyStringKeys( + ["and", ["or", ["or", ["name"=> "favorite_ice_cream", "type"=> "custom_attribute","match"=> "exists"]]]] + )); + $this->assertFalse(Validator::doesArrayContainOnlyStringKeys(['hello' => 'world', 0 => 'bye'])); + $this->assertFalse(Validator::doesArrayContainOnlyStringKeys(['hello' => 'world', '0' => 'bye'])); + $this->assertFalse(Validator::doesArrayContainOnlyStringKeys(['hello' => 'world', 'and'])); + $this->assertFalse(Validator::doesArrayContainOnlyStringKeys('helloworld')); + $this->assertFalse(Validator::doesArrayContainOnlyStringKeys(12)); + $this->assertFalse(Validator::doesArrayContainOnlyStringKeys('12.5')); + $this->assertFalse(Validator::doesArrayContainOnlyStringKeys(true)); + } }