diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/GEMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/GEMatch.java new file mode 100644 index 000000000..8724cfcb0 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/GEMatch.java @@ -0,0 +1,41 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +import static com.optimizely.ab.internal.AttributesUtil.isValidNumber; + +class GEMatch extends AttributeMatch { + Number value; + + protected GEMatch(Number value) { + this.value = value; + } + + @Nullable + public Boolean eval(Object attributeValue) { + try { + if(isValidNumber(attributeValue)) { + return castToValueType(attributeValue, value).doubleValue() >= value.doubleValue(); + } + } catch (Exception e) { + return null; + } + return null; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/LEMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/LEMatch.java new file mode 100644 index 000000000..23d1c03fc --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/LEMatch.java @@ -0,0 +1,42 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +import static com.optimizely.ab.internal.AttributesUtil.isValidNumber; + +class LEMatch extends AttributeMatch { + Number value; + + protected LEMatch(Number value) { + this.value = value; + } + + @Nullable + public Boolean eval(Object attributeValue) { + try { + if(isValidNumber(attributeValue)) { + return castToValueType(attributeValue, value).doubleValue() <= value.doubleValue(); + } + } catch (Exception e) { + return null; + } + return null; + } +} + diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchType.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchType.java index 3bdbb4a7c..7455f1270 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchType.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchType.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018-2019, Optimizely and contributors + * Copyright 2018-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -50,11 +50,21 @@ public static MatchType getMatchType(String matchType, Object conditionValue) th return new MatchType(matchType, new SubstringMatch((String) conditionValue)); } break; + case "ge": + if (isValidNumber(conditionValue)) { + return new MatchType(matchType, new GEMatch((Number) conditionValue)); + } + break; case "gt": if (isValidNumber(conditionValue)) { return new MatchType(matchType, new GTMatch((Number) conditionValue)); } break; + case "le": + if (isValidNumber(conditionValue)) { + return new MatchType(matchType, new LEMatch((Number) conditionValue)); + } + break; case "lt": if (isValidNumber(conditionValue)) { return new MatchType(matchType, new LTMatch((Number) conditionValue)); @@ -65,6 +75,31 @@ public static MatchType getMatchType(String matchType, Object conditionValue) th return new MatchType(matchType, new DefaultMatchForLegacyAttributes((String) conditionValue)); } break; + case "semver_eq": + if (conditionValue instanceof String) { + return new MatchType(matchType, new SemanticVersionEqualsMatch((String) conditionValue)); + } + break; + case "semver_ge": + if (conditionValue instanceof String) { + return new MatchType(matchType, new SemanticVersionGEMatch((String) conditionValue)); + } + break; + case "semver_gt": + if (conditionValue instanceof String) { + return new MatchType(matchType, new SemanticVersionGTMatch((String) conditionValue)); + } + break; + case "semver_le": + if (conditionValue instanceof String) { + return new MatchType(matchType, new SemanticVersionLEMatch((String) conditionValue)); + } + break; + case "semver_lt": + if (conditionValue instanceof String) { + return new MatchType(matchType, new SemanticVersionLTMatch((String) conditionValue)); + } + break; default: throw new UnknownMatchTypeException(); } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java new file mode 100644 index 000000000..1eed1d8fc --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java @@ -0,0 +1,133 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static com.optimizely.ab.internal.AttributesUtil.parseNumeric; +import static com.optimizely.ab.internal.AttributesUtil.stringIsNullOrEmpty; + +public final class SemanticVersion { + + private static final String BUILD_SEPERATOR = "+"; + private static final String PRE_RELEASE_SEPERATOR = "-"; + + private final String version; + + public SemanticVersion(String version) { + this.version = version; + } + + public int compare(SemanticVersion targetedVersion) throws Exception { + + if (targetedVersion == null || stringIsNullOrEmpty(targetedVersion.version)) { + return 0; + } + + String[] targetedVersionParts = targetedVersion.splitSemanticVersion(); + String[] userVersionParts = splitSemanticVersion(); + + for (int index = 0; index < targetedVersionParts.length; index++) { + + if (userVersionParts.length <= index) { + return targetedVersion.isPreRelease() ? 1 : -1; + } + Integer targetVersionPartInt = parseNumeric(targetedVersionParts[index]); + Integer userVersionPartInt = parseNumeric(userVersionParts[index]); + + if (userVersionPartInt == null) { + // Compare strings + int result = userVersionParts[index].compareTo(targetedVersionParts[index]); + if (result != 0) { + return result; + } + } else if (targetVersionPartInt != null) { + if (!userVersionPartInt.equals(targetVersionPartInt)) { + return userVersionPartInt < targetVersionPartInt ? -1 : 1; + } + } else { + return -1; + } + } + + if (!targetedVersion.isPreRelease() && + isPreRelease()) { + return -1; + } + + return 0; + } + + public boolean isPreRelease() { + return version.contains(PRE_RELEASE_SEPERATOR); + } + + public boolean isBuild() { + return version.contains(BUILD_SEPERATOR); + } + + public String[] splitSemanticVersion() throws Exception { + List versionParts = new ArrayList<>(); + // pre-release or build. + String versionSuffix = ""; + // for example: beta.2.1 + String[] preVersionParts; + + // Contains white spaces + if (version.contains(" ")) { // log and throw error + throw new Exception("Semantic version contains white spaces. Invalid Semantic Version."); + } + + if (isBuild() || isPreRelease()) { + String[] partialVersionParts = version.split(isPreRelease() ? + PRE_RELEASE_SEPERATOR : BUILD_SEPERATOR); + + if (partialVersionParts.length <= 1) { + // throw error + throw new Exception("Invalid Semantic Version."); + } + // major.minor.patch + String versionPrefix = partialVersionParts[0]; + + versionSuffix = partialVersionParts[1]; + + preVersionParts = versionPrefix.split("\\."); + } else { + preVersionParts = version.split("\\."); + } + + if (preVersionParts.length > 3) { + // Throw error as pre version should only contain major.minor.patch version + throw new Exception("Invalid Semantic Version."); + } + + for (String preVersionPart : preVersionParts) { + if (parseNumeric(preVersionPart) == null) { + throw new Exception("Invalid Semantic Version."); + } + } + + Collections.addAll(versionParts, preVersionParts); + if (!stringIsNullOrEmpty(versionSuffix)) { + versionParts.add(versionSuffix); + } + + return versionParts.toArray(new String[0]); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionEqualsMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionEqualsMatch.java new file mode 100644 index 000000000..b727d88cf --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionEqualsMatch.java @@ -0,0 +1,41 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +class SemanticVersionEqualsMatch implements Match { + String value; + + protected SemanticVersionEqualsMatch(String value) { + this.value = value; + } + + @Nullable + public Boolean eval(Object attributeValue) { + try { + if (this.value != null && attributeValue instanceof String) { + SemanticVersion conditionalVersion = new SemanticVersion(value); + SemanticVersion userSemanticVersion = new SemanticVersion((String) attributeValue); + return userSemanticVersion.compare(conditionalVersion) == 0; + } + } catch (Exception e) { + return null; + } + return null; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGEMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGEMatch.java new file mode 100644 index 000000000..fd31e1ab2 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGEMatch.java @@ -0,0 +1,41 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +class SemanticVersionGEMatch implements Match { + String value; + + protected SemanticVersionGEMatch(String value) { + this.value = value; + } + + @Nullable + public Boolean eval(Object attributeValue) { + try { + if (this.value != null && attributeValue instanceof String) { + SemanticVersion conditionalVersion = new SemanticVersion(value); + SemanticVersion userSemanticVersion = new SemanticVersion((String) attributeValue); + return userSemanticVersion.compare(conditionalVersion) >= 0; + } + } catch (Exception e) { + return null; + } + return null; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGTMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGTMatch.java new file mode 100644 index 000000000..7ca0f31b1 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGTMatch.java @@ -0,0 +1,41 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +class SemanticVersionGTMatch implements Match { + String value; + + protected SemanticVersionGTMatch(String target) { + this.value = target; + } + + @Nullable + public Boolean eval(Object attributeValue) { + try { + if (this.value != null && attributeValue instanceof String) { + SemanticVersion conditionalVersion = new SemanticVersion(value); + SemanticVersion userSemanticVersion = new SemanticVersion((String) attributeValue); + return userSemanticVersion.compare(conditionalVersion) > 0; + } + } catch (Exception e) { + return null; + } + return null; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLEMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLEMatch.java new file mode 100644 index 000000000..6c7629672 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLEMatch.java @@ -0,0 +1,41 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +class SemanticVersionLEMatch implements Match { + String value; + + protected SemanticVersionLEMatch(String target) { + this.value = target; + } + + @Nullable + public Boolean eval(Object attributeValue) { + try { + if (this.value != null && attributeValue instanceof String) { + SemanticVersion conditionalVersion = new SemanticVersion(value); + SemanticVersion userSemanticVersion = new SemanticVersion((String) attributeValue); + return userSemanticVersion.compare(conditionalVersion) <= 0; + } + } catch (Exception e) { + return null; + } + return null; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLTMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLTMatch.java new file mode 100644 index 000000000..6f67863a1 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLTMatch.java @@ -0,0 +1,41 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +class SemanticVersionLTMatch implements Match { + String value; + + protected SemanticVersionLTMatch(String target) { + this.value = target; + } + + @Nullable + public Boolean eval(Object attributeValue) { + try { + if (this.value != null && attributeValue instanceof String) { + SemanticVersion conditionalVersion = new SemanticVersion(value); + SemanticVersion userSemanticVersion = new SemanticVersion((String) attributeValue); + return userSemanticVersion.compare(conditionalVersion) < 0; + } + } catch (Exception e) { + return null; + } + return null; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/internal/AttributesUtil.java b/core-api/src/main/java/com/optimizely/ab/internal/AttributesUtil.java index 61e0356d0..378e4acb0 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/AttributesUtil.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/AttributesUtil.java @@ -1,6 +1,6 @@ /** * - * Copyright 2019, Optimizely and contributors + * Copyright 2019-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -37,4 +37,27 @@ public static boolean isValidNumber(Object value) { return false; } + /** + * Parse and validate that String is parse able to integer. + * + * @param str String value of integer. + * @return Integer value if is valid and null if not. + */ + public static Integer parseNumeric(String str) { + try { + return Integer.parseInt(str, 10); + } catch (NumberFormatException e) { + return null; + } + } + + /** + * Checks if string is null or empty. + * + * @param str String value. + * @return true if is null or empty else false. + */ + public static boolean stringIsNullOrEmpty(String str) { + return str == null || str.isEmpty(); + } } diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java index 83c5e41df..645025476 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java @@ -469,6 +469,129 @@ public void gtMatchConditionEvaluatesNull() { assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); } + + /** + * Verify that UserAttribute.evaluate for GE match type returns true for known visitor + * attributes where the value's type is a number, and the UserAttribute's value is greater or equal than + * the condition's value. + */ + @Test + public void geMatchConditionEvaluatesTrue() { + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "ge", 2); + UserAttribute testInstanceFloat = new UserAttribute("num_size", "custom_attribute", "ge", (float) 2); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "ge", 2.55); + + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 2))); + assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + + Map badAttributes = new HashMap<>(); + badAttributes.put("num_size", "bobs burgers"); + assertNull(testInstanceInteger.evaluate(null, badAttributes)); + } + + /** + * Verify that UserAttribute.evaluate for GE match type returns null if the UserAttribute's + * value type is invalid number. + */ + @Test + public void geMatchConditionEvaluatesNullWithInvalidUserAttr() { + BigInteger bigInteger = new BigInteger("33221312312312312"); + Double infinitePositiveInfiniteDouble = Double.POSITIVE_INFINITY; + Double infiniteNegativeInfiniteDouble = Double.NEGATIVE_INFINITY; + Double infiniteNANDouble = Double.NaN; + Double largeDouble = Math.pow(2, 53) + 2; + float invalidFloatValue = (float) (Math.pow(2, 53) + 2000000000); + + UserAttribute testInstanceInteger = new UserAttribute( + "num_size", + "custom_attribute", + "ge", + 5); + UserAttribute testInstanceFloat = new UserAttribute( + "num_size", + "custom_attribute", + "ge", + (float) 5); + UserAttribute testInstanceDouble = new UserAttribute( + "num_counts", + "custom_attribute", + "ge", + 5.2); + + assertNull(testInstanceInteger.evaluate( + null, + Collections.singletonMap("num_size", bigInteger))); + assertNull(testInstanceFloat.evaluate( + null, + Collections.singletonMap("num_size", invalidFloatValue))); + assertNull(testInstanceDouble.evaluate( + null, + Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); + assertNull(testInstanceDouble.evaluate( + null, + Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); + assertNull(testInstanceDouble.evaluate( + null, Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", infiniteNANDouble)))); + assertNull(testInstanceDouble.evaluate( + null, Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", largeDouble)))); + } + + /** + * Verify that UserAttribute.evaluate for GE match type returns null if the UserAttribute's + * value type is invalid number. + */ + @Test + public void geMatchConditionEvaluatesNullWithInvalidAttr() { + BigInteger bigInteger = new BigInteger("33221312312312312"); + Double infinitePositiveInfiniteDouble = Double.POSITIVE_INFINITY; + Double infiniteNegativeInfiniteDouble = Double.NEGATIVE_INFINITY; + Double infiniteNANDouble = Double.NaN; + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "ge", bigInteger); + UserAttribute testInstancePositiveInfinite = new UserAttribute("num_counts", "custom_attribute", "ge", infinitePositiveInfiniteDouble); + UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "ge", infiniteNegativeInfiniteDouble); + UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "ge", infiniteNANDouble); + + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); + } + + /** + * Verify that UserAttribute.evaluate for GE match type returns false for known visitor + * attributes where the value's type is a number, and the UserAttribute's value is not greater or equal + * than the condition's value. + */ + @Test + public void geMatchConditionEvaluatesFalse() { + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "ge", 5); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "ge", 5.55); + + assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + } + + /** + * Verify that UserAttribute.evaluate for GE match type returns null if the UserAttribute's + * value type is not a number. + */ + @Test + public void geMatchConditionEvaluatesNull() { + UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "ge", 3.5); + UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute", "ge", 3.5); + UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "ge", 3.5); + UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "ge", 3.5); + + assertNull(testInstanceString.evaluate(null, testUserAttributes)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + } + + /** * Verify that UserAttribute.evaluate for GT match type returns true for known visitor * attributes where the value's type is a number, and the UserAttribute's value is less than @@ -584,6 +707,122 @@ public void ltMatchConditionEvaluatesNullWithInvalidAttributes() { assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); } + + /** + * Verify that UserAttribute.evaluate for LE match type returns true for known visitor + * attributes where the value's type is a number, and the UserAttribute's value is less or equal than + * the condition's value. + */ + @Test + public void leMatchConditionEvaluatesTrue() { + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "le", 5); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "le", 5.55); + + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceDouble.evaluate(null, Collections.singletonMap("num_counts", 5.55))); + } + + /** + * Verify that UserAttribute.evaluate for LE match type returns true for known visitor + * attributes where the value's type is a number, and the UserAttribute's value is not less or equal + * than the condition's value. + */ + @Test + public void leMatchConditionEvaluatesFalse() { + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "le", 2); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "le", 2.55); + + assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + } + + /** + * Verify that UserAttribute.evaluate for LE match type returns null if the UserAttribute's + * value type is not a number. + */ + @Test + public void leMatchConditionEvaluatesNull() { + UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "le", 3.5); + UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute", "le", 3.5); + UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "le", 3.5); + UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "le", 3.5); + + assertNull(testInstanceString.evaluate(null, testUserAttributes)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + } + + /** + * Verify that UserAttribute.evaluate for LE match type returns null if the UserAttribute's + * value type is not a valid number. + */ + @Test + public void leMatchConditionEvaluatesNullWithInvalidUserAttr() { + BigInteger bigInteger = new BigInteger("33221312312312312"); + Double infinitePositiveInfiniteDouble = Double.POSITIVE_INFINITY; + Double infiniteNegativeInfiniteDouble = Double.NEGATIVE_INFINITY; + Double infiniteNANDouble = Double.NaN; + Double largeDouble = Math.pow(2,53) + 2; + float invalidFloatValue = (float) (Math.pow(2, 53) + 2000000000); + + UserAttribute testInstanceInteger = new UserAttribute( + "num_size", + "custom_attribute", + "le", + 5); + UserAttribute testInstanceFloat = new UserAttribute( + "num_size", + "custom_attribute", + "le", + (float) 5); + UserAttribute testInstanceDouble = new UserAttribute( + "num_counts", + "custom_attribute", + "le", + 5.2); + + assertNull(testInstanceInteger.evaluate( + null, + Collections.singletonMap("num_size", bigInteger))); + assertNull(testInstanceFloat.evaluate( + null, + Collections.singletonMap("num_size", invalidFloatValue))); + assertNull(testInstanceDouble.evaluate( + null, + Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); + assertNull(testInstanceDouble.evaluate( + null, + Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); + assertNull(testInstanceDouble.evaluate( + null, Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", infiniteNANDouble)))); + assertNull(testInstanceDouble.evaluate( + null, Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", largeDouble)))); + } + + /** + * Verify that UserAttribute.evaluate for LE match type returns null if the condition + * value type is not a valid number. + */ + @Test + public void leMatchConditionEvaluatesNullWithInvalidAttributes() { + BigInteger bigInteger = new BigInteger("33221312312312312"); + Double infinitePositiveInfiniteDouble = Double.POSITIVE_INFINITY; + Double infiniteNegativeInfiniteDouble = Double.NEGATIVE_INFINITY; + Double infiniteNANDouble = Double.NaN; + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "le", bigInteger); + UserAttribute testInstancePositiveInfinite = new UserAttribute("num_counts", "custom_attribute", "le", infinitePositiveInfiniteDouble); + UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "le", infiniteNegativeInfiniteDouble); + UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "le", infiniteNANDouble); + + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); + assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); + } + /** * Verify that UserAttribute.evaluate for SUBSTRING match type returns true if the * UserAttribute's value is a substring of the condition's value. @@ -633,6 +872,302 @@ public void substringMatchConditionEvaluatesNull() { assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); } + //======== Semantic version evaluation tests ========// + + // Test SemanticVersionEqualsMatch returns null if given invalid value type + @Test + public void testSemanticVersionEqualsMatchInvalidInput() { + Map testAttributes = new HashMap(); + testAttributes.put("version", 2.0); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); + assertNull(testInstanceString.evaluate(null, testAttributes)); + } + + @Test + public void semanticVersionInvalidMajorShouldBeNumberOnly() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "a.1.2"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); + assertNull(testInstanceString.evaluate(null, testAttributes)); + } + + @Test + public void semanticVersionInvalidMinorShouldBeNumberOnly() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "1.b.2"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); + assertNull(testInstanceString.evaluate(null, testAttributes)); + } + + @Test + public void semanticVersionInvalidPatchShouldBeNumberOnly() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "1.2.c"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); + assertNull(testInstanceString.evaluate(null, testAttributes)); + } + + // Test SemanticVersionEqualsMatch returns null if given invalid UserCondition Variable type + @Test + public void testSemanticVersionEqualsMatchInvalidUserConditionVariable() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.0"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", 2.0); + assertNull(testInstanceString.evaluate(null, testAttributes)); + } + + // Test SemanticVersionGTMatch returns null if given invalid value type + @Test + public void testSemanticVersionGTMatchInvalidInput() { + Map testAttributes = new HashMap(); + testAttributes.put("version", false); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.0.0"); + assertNull(testInstanceString.evaluate(null, testAttributes)); + } + + // Test SemanticVersionGEMatch returns null if given invalid value type + @Test + public void testSemanticVersionGEMatchInvalidInput() { + Map testAttributes = new HashMap(); + testAttributes.put("version", 2); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.0.0"); + assertNull(testInstanceString.evaluate(null, testAttributes)); + } + + // Test SemanticVersionLTMatch returns null if given invalid value type + @Test + public void testSemanticVersionLTMatchInvalidInput() { + Map testAttributes = new HashMap(); + testAttributes.put("version", 2); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.0.0"); + assertNull(testInstanceString.evaluate(null, testAttributes)); + } + + // Test SemanticVersionLEMatch returns null if given invalid value type + @Test + public void testSemanticVersionLEMatchInvalidInput() { + Map testAttributes = new HashMap(); + testAttributes.put("version", 2); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.0.0"); + assertNull(testInstanceString.evaluate(null, testAttributes)); + } + + // Test if not same when targetVersion is only major.minor.patch and version is major.minor + @Test + public void testIsSemanticNotSameConditionValueMajorMinorPatch() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "1.2"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "1.2.0"); + assertFalse(testInstanceString.evaluate(null, testAttributes)); + } + + // Test if same when target is only major but user condition checks only major.minor,patch + @Test + public void testIsSemanticSameSingleDigit() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "3.0.0"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "3"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test if greater when User value patch is greater even when its beta + @Test + public void testIsSemanticGreaterWhenUserConditionComparesMajorMinorAndPatchVersion() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "3.1.1-beta"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "3.1.0"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test if greater when preRelease is greater alphabetically + @Test + public void testIsSemanticGreaterWhenMajorMinorPatchReleaseVersionCharacter() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "3.1.1-beta.y.1+1.1"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "3.1.1-beta.x.1+1.1"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test if greater when preRelease version number is greater + @Test + public void testIsSemanticGreaterWhenMajorMinorPatchPreReleaseVersionNum() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "3.1.1-beta.x.2+1.1"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "3.1.1-beta.x.1+1.1"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test if equals semantic version even when only same preRelease is passed in user attribute and no build meta + @Test + public void testIsSemanticEqualWhenMajorMinorPatchPreReleaseVersionNum() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "3.1.1-beta.x.1"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "3.1.1-beta.x.1"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test if not same + @Test + public void testIsSemanticNotSameReturnsFalse() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.2"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.1.1"); + assertFalse(testInstanceString.evaluate(null, testAttributes)); + } + + // Test when target is full semantic version major.minor.patch + @Test + public void testIsSemanticSameFull() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "3.0.1"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "3.0.1"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare less when user condition checks only major.minor + @Test + public void testIsSemanticLess() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.6"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.2"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // When user condition checks major.minor but target is major.minor.patch then its equals + @Test + public void testIsSemanticLessFalse() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.0"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.1"); + assertFalse(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare less when target is full major.minor.patch + @Test + public void testIsSemanticFullLess() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.6"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.1.9"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare greater when user condition checks only major.minor + @Test + public void testIsSemanticMore() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.3.6"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.2"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare greater when both are major.minor.patch-beta but target is greater than user condition + @Test + public void testIsSemanticMoreWhenBeta() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.3.6-beta"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.3.5-beta"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare greater when target is major.minor.patch + @Test + public void testIsSemanticFullMore() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.7"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.6"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare greater when target is major.minor.patch is smaller then it returns false + @Test + public void testSemanticVersionGTFullMoreReturnsFalse() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.9"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.10"); + assertFalse(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare equal when both are exactly same - major.minor.patch-beta + @Test + public void testIsSemanticFullEqual() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.9-beta"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.1.9-beta"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare equal when both major.minor.patch is same, but due to beta user condition is smaller + @Test + public void testIsSemanticLessWhenBeta() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.9"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.9-beta"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare greater when target is major.minor.patch-beta and user condition only compares major.minor.patch + @Test + public void testIsSemanticGreaterBeta() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.9"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.9-beta"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare equal when target is major.minor.patch + @Test + public void testIsSemanticLessEqualsWhenEqualsReturnsTrue() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.9"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.1.9"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare less when target is major.minor.patch + @Test + public void testIsSemanticLessEqualsWhenLessReturnsTrue() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.132.9"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.233.91"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare less when target is major.minor.patch + @Test + public void testIsSemanticLessEqualsWhenGreaterReturnsFalse() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.233.91"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.132.009"); + assertFalse(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare equal when target is major.minor.patch + @Test + public void testIsSemanticGreaterEqualsWhenEqualsReturnsTrue() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.1.9"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.1.9"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare less when target is major.minor.patch + @Test + public void testIsSemanticGreaterEqualsWhenLessReturnsTrue() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.233.91"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.132.9"); + assertTrue(testInstanceString.evaluate(null, testAttributes)); + } + + // Test compare less when target is major.minor.patch + @Test + public void testIsSemanticGreaterEqualsWhenLessReturnsFalse() { + Map testAttributes = new HashMap(); + testAttributes.put("version", "2.132.009"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.233.91"); + assertFalse(testInstanceString.evaluate(null, testAttributes)); + } + /** * Verify that NotCondition.evaluate returns null when its condition is null. */ diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/SemanticVersionTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/SemanticVersionTest.java new file mode 100644 index 000000000..daa195a2b --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/SemanticVersionTest.java @@ -0,0 +1,120 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience; + +import com.optimizely.ab.config.audience.match.SemanticVersion; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import static org.junit.Assert.*; + +public class SemanticVersionTest { + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Test + public void semanticVersionInvalidMajorShouldBeNumberOnly() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("a.2.1"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidMinorShouldBeNumberOnly() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("1.b.1"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidPatchShouldBeNumberOnly() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("1.2.c"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidShouldBeOfSizeLessThan3() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("1.2.2.3"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionCompareTo() throws Exception { + SemanticVersion targetSV = new SemanticVersion("3.7.1"); + SemanticVersion actualSV = new SemanticVersion("3.7.1"); + assertTrue(actualSV.compare(targetSV) == 0); + } + + @Test + public void semanticVersionCompareToActualLess() throws Exception { + SemanticVersion targetSV = new SemanticVersion("3.7.1"); + SemanticVersion actualSV = new SemanticVersion("3.7.0"); + assertTrue(actualSV.compare(targetSV) < 0); + } + + @Test + public void semanticVersionCompareToActualGreater() throws Exception { + SemanticVersion targetSV = new SemanticVersion("3.7.1"); + SemanticVersion actualSV = new SemanticVersion("3.7.2"); + assertTrue(actualSV.compare(targetSV) > 0); + } + + @Test + public void semanticVersionCompareToPatchMissing() throws Exception { + SemanticVersion targetSV = new SemanticVersion("3.7"); + SemanticVersion actualSV = new SemanticVersion("3.7.1"); + assertTrue(actualSV.compare(targetSV) == 0); + } + + @Test + public void semanticVersionCompareToActualPatchMissing() throws Exception { + SemanticVersion targetSV = new SemanticVersion("3.7.1"); + SemanticVersion actualSV = new SemanticVersion("3.7"); + assertTrue(actualSV.compare(targetSV) < 0); + } + + @Test + public void semanticVersionCompareToActualPreReleaseMissing() throws Exception { + SemanticVersion targetSV = new SemanticVersion("3.7.1-beta"); + SemanticVersion actualSV = new SemanticVersion("3.7.1"); + assertTrue(actualSV.compare(targetSV) > 0); + } + + @Test + public void semanticVersionCompareToAlphaBetaAsciiComparision() throws Exception { + SemanticVersion targetSV = new SemanticVersion("3.7.1-alpha"); + SemanticVersion actualSV = new SemanticVersion("3.7.1-beta"); + assertTrue(actualSV.compare(targetSV) > 0); + } + + @Test + public void semanticVersionCompareToIgnoreMetaComparision() throws Exception { + SemanticVersion targetSV = new SemanticVersion("3.7.1-beta.1+2.3"); + SemanticVersion actualSV = new SemanticVersion("3.7.1-beta.1+2.3"); + assertTrue(actualSV.compare(targetSV) == 0); + } + + @Test + public void semanticVersionCompareToPreReleaseComparision() throws Exception { + SemanticVersion targetSV = new SemanticVersion("3.7.1-beta.1"); + SemanticVersion actualSV = new SemanticVersion("3.7.1-beta.2"); + assertTrue(actualSV.compare(targetSV) > 0); + } +}