diff --git a/.travis.yml b/.travis.yml index 3417e7f91..ae9570ac1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -26,3 +26,5 @@ branches: - /^\d+\.\d+\.(\d|[x])+(-SNAPSHOT|-alpha|-beta)?\d*$/ # trigger builds on tags which are semantically versioned to ship the SDK. after_success: - ./gradlew coveralls uploadArchives --console plain +after_failure: + - cat /home/travis/build/optimizely/java-sdk/core-api/build/reports/findbugs/main.html diff --git a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java index 25a84f823..5b2f67caa 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java @@ -80,6 +80,7 @@ public String toString() { private final Boolean botFiltering; private final List attributes; private final List audiences; + private final List typedAudiences; private final List events; private final List experiments; private final List featureFlags; @@ -136,6 +137,7 @@ public ProjectConfig(String accountId, String projectId, String version, String version, attributes, audiences, + null, eventType, experiments, null, @@ -154,6 +156,7 @@ public ProjectConfig(String accountId, String version, List attributes, List audiences, + List typedAudiences, List events, List experiments, List featureFlags, @@ -170,6 +173,14 @@ public ProjectConfig(String accountId, this.attributes = Collections.unmodifiableList(attributes); this.audiences = Collections.unmodifiableList(audiences); + + if (typedAudiences != null) { + this.typedAudiences = Collections.unmodifiableList(typedAudiences); + } + else { + this.typedAudiences = Collections.emptyList(); + } + this.events = Collections.unmodifiableList(events); if (featureFlags == null) { this.featureFlags = Collections.emptyList(); @@ -206,7 +217,14 @@ public ProjectConfig(String accountId, this.featureKeyMapping = ProjectConfigUtils.generateNameMapping(this.featureFlags); // generate audience id to audience mapping - this.audienceIdMapping = ProjectConfigUtils.generateIdMapping(audiences); + if (typedAudiences == null) { + this.audienceIdMapping = ProjectConfigUtils.generateIdMapping(audiences); + } + else { + List combinedList = new ArrayList<>(audiences); + combinedList.addAll(typedAudiences); + this.audienceIdMapping = ProjectConfigUtils.generateIdMapping(combinedList); + } this.experimentIdMapping = ProjectConfigUtils.generateIdMapping(this.experiments); this.groupIdMapping = ProjectConfigUtils.generateIdMapping(groups); this.rolloutIdMapping = ProjectConfigUtils.generateIdMapping(this.rollouts); @@ -386,6 +404,10 @@ public List getAudiences() { return audiences; } + public List getTypedAudiences() { + return typedAudiences; + } + public Condition getAudienceConditionsFromId(String audienceId) { Audience audience = audienceIdMapping.get(audienceId); @@ -581,6 +603,7 @@ public String toString() { ", botFiltering=" + botFiltering + ", attributes=" + attributes + ", audiences=" + audiences + + ", typedAudiences=" + typedAudiences + ", events=" + events + ", experiments=" + experiments + ", featureFlags=" + featureFlags + diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java index 375205e42..852cf10a2 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java @@ -17,6 +17,7 @@ package com.optimizely.ab.config.audience; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; import java.util.List; import java.util.Map; @@ -36,13 +37,32 @@ public List getConditions() { return conditions; } - public boolean evaluate(Map attributes) { + public @Nullable + Boolean evaluate(Map attributes) { + boolean foundNull = false; + // According to the matrix where: + // false and true is false + // false and null is false + // true and null is null. + // true and false is false + // true and true is true + // null and null is null for (Condition condition : conditions) { - if (!condition.evaluate(attributes)) + Boolean conditionEval = condition.evaluate(attributes); + if (conditionEval == null) { + foundNull = true; + } + else if (!conditionEval) { // false with nulls or trues is false. return false; + } + // true and nulls with no false will be null. } - return true; + if (foundNull) { // true and null or all null returns null + return null; + } + + return true; // otherwise, return true } @Override diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java index c84f39aef..997e6922f 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java @@ -16,6 +16,7 @@ */ package com.optimizely.ab.config.audience; +import javax.annotation.Nullable; import java.util.Map; /** @@ -23,5 +24,6 @@ */ public interface Condition { - boolean evaluate(Map attributes); -} \ No newline at end of file + @Nullable + Boolean evaluate(Map attributes); +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java index 84b8c010e..e8121f76e 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java @@ -16,6 +16,7 @@ */ package com.optimizely.ab.config.audience; +import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; import javax.annotation.Nonnull; @@ -37,8 +38,9 @@ public Condition getCondition() { return condition; } - public boolean evaluate(Map attributes) { - return !condition.evaluate(attributes); + public @Nullable Boolean evaluate(Map attributes) { + Boolean conditionEval = condition.evaluate(attributes); + return (conditionEval == null ? null : !conditionEval); } @Override diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java index 52a53c952..768f6427f 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java @@ -17,6 +17,7 @@ package com.optimizely.ab.config.audience; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; import java.util.List; import java.util.Map; @@ -36,10 +37,26 @@ public List getConditions() { return conditions; } - public boolean evaluate(Map attributes) { + // According to the matrix: + // true returns true + // false or null is null + // false or false is false + // null or null is null + public @Nullable Boolean evaluate(Map attributes) { + boolean foundNull = false; for (Condition condition : conditions) { - if (condition.evaluate(attributes)) + Boolean conditionEval = condition.evaluate(attributes); + if (conditionEval == null) { // true with falses and nulls is still true + foundNull = true; + } + else if (conditionEval) { return true; + } + } + + // if found null and false return null. all false return false + if (foundNull) { + return null; } return false; diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java b/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java index c8627a4a1..6bb457859 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java @@ -16,6 +16,8 @@ */ package com.optimizely.ab.config.audience; +import com.optimizely.ab.config.audience.match.MatchType; + import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; @@ -29,11 +31,13 @@ public class UserAttribute implements Condition { private final String name; private final String type; + private final String match; private final Object value; - public UserAttribute(@Nonnull String name, @Nonnull String type, @Nullable Object value) { + public UserAttribute(@Nonnull String name, @Nonnull String type, @Nullable String match, @Nullable Object value) { this.name = name; this.type = type; + this.match = match; this.value = value; } @@ -45,25 +49,31 @@ public String getType() { return type; } + public String getMatch() { + return match; + } + public Object getValue() { return value; } - public boolean evaluate(Map attributes) { + public @Nullable Boolean evaluate(Map attributes) { // Valid for primative types, but needs to change when a value is an object or an array Object userAttributeValue = attributes.get(name); - if (value != null) { // if there is a value in the condition - // check user attribute value is equal - return value.equals(userAttributeValue); + if (!"custom_attribute".equals(type)) { + MatchType.logger.error(String.format("condition type not equal to `custom_attribute` %s", type != null ? type : "")); + return null; // unknown type } - else if (userAttributeValue != null) { // if the datafile value is null but user has a value for this attribute - // return false since null != nonnull - return false; + // check user attribute value is equal + try { + return MatchType.getMatchType(match, value).getMatcher().eval(userAttributeValue); } - else { // both are null - return true; + catch (NullPointerException np) { + MatchType.logger.error(String.format("attribute or value null for match %s", match != null ? match : "legacy condition"),np); + return null; } + } @Override diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/AttributeMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/AttributeMatch.java new file mode 100644 index 000000000..c0fd6dd07 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/AttributeMatch.java @@ -0,0 +1,32 @@ +/** + * + * Copyright 2018, 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; + +abstract class AttributeMatch implements Match { + T convert(Object o) { + try { + T rv = (T)o; + return rv; + } catch(java.lang.ClassCastException e) { + MatchType.logger.error( + "Cannot evaluate targeting condition since the value for attribute is an incompatible type", + e + ); + return null; + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/DefaultMatchForLegacyAttributes.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/DefaultMatchForLegacyAttributes.java new file mode 100644 index 000000000..493d3551c --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/DefaultMatchForLegacyAttributes.java @@ -0,0 +1,36 @@ +/** + * + * Copyright 2018, 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; + +/** + * This is a temporary class. It mimics the current behaviour for + * legacy custom attributes. This will be dropped for ExactMatch and the unit tests need to be fixed. + * @param + */ +class DefaultMatchForLegacyAttributes extends AttributeMatch { + T value; + protected DefaultMatchForLegacyAttributes(T value) { + this.value = value; + } + + public @Nullable + Boolean eval(Object attributeValue) { + return value.equals(convert(attributeValue)); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExactMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExactMatch.java new file mode 100644 index 000000000..0f7e51a48 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExactMatch.java @@ -0,0 +1,32 @@ +/** + * + * Copyright 2018, 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 ExactMatch extends AttributeMatch { + T value; + protected ExactMatch(T value) { + this.value = value; + } + + public @Nullable + Boolean eval(Object attributeValue) { + if (!value.getClass().isInstance(attributeValue)) return null; + return value.equals(convert(attributeValue)); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExistsMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExistsMatch.java new file mode 100644 index 000000000..8434188cd --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExistsMatch.java @@ -0,0 +1,34 @@ +/** + * + * Copyright 2018, 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 edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import javax.annotation.Nullable; + +class ExistsMatch extends AttributeMatch { + @SuppressFBWarnings("URF_UNREAD_FIELD") + Object value; + protected ExistsMatch(Object value) { + this.value = value; + } + + public @Nullable + Boolean eval(Object attributeValue) { + return attributeValue != null; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/GTMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/GTMatch.java new file mode 100644 index 000000000..354a5e04f --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/GTMatch.java @@ -0,0 +1,37 @@ +/** + * + * Copyright 2018, 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 GTMatch extends AttributeMatch { + Number value; + protected GTMatch(Number value) { + this.value = value; + } + + public @Nullable + Boolean eval(Object attributeValue) { + try { + return convert(attributeValue).doubleValue() > value.doubleValue(); + } + catch (Exception e) { + MatchType.logger.error("Greater than match failed ", e); + return null; + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/LTMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/LTMatch.java new file mode 100644 index 000000000..ee71694dc --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/LTMatch.java @@ -0,0 +1,38 @@ +/** + * + * Copyright 2018, 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 LTMatch extends AttributeMatch { + Number value; + protected LTMatch(Number value) { + this.value = value; + } + + public @Nullable + Boolean eval(Object attributeValue) { + try { + return convert(attributeValue).doubleValue() < value.doubleValue(); + } + catch (Exception e) { + MatchType.logger.error("Less than match failed ", e); + return null; + } + } +} + diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/Match.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/Match.java new file mode 100644 index 000000000..c0506ee4f --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/Match.java @@ -0,0 +1,23 @@ +/** + * + * Copyright 2018, 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; + +public interface Match { + @Nullable Boolean eval(Object attributeValue); +} 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 new file mode 100644 index 000000000..064fc543c --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchType.java @@ -0,0 +1,90 @@ +/** + * + * Copyright 2018, 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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; + +public class MatchType { + + public static final Logger logger = LoggerFactory.getLogger(MatchType.class); + + private String matchType; + private Match matcher; + + public static MatchType getMatchType(String matchType, Object conditionValue) { + if (matchType == null) matchType = "legacy_custom_attribute"; + + switch (matchType) { + case "exists": + return new MatchType(matchType, new ExistsMatch(conditionValue)); + case "exact": + if (conditionValue instanceof String) { + return new MatchType(matchType, new ExactMatch((String) conditionValue)); + } + else if (conditionValue instanceof Integer || conditionValue instanceof Double) { + return new MatchType(matchType, new ExactMatch((Number) conditionValue)); + } + else if (conditionValue instanceof Boolean) { + return new MatchType(matchType, new ExactMatch((Boolean) conditionValue)); + } + break; + case "substring": + if (conditionValue instanceof String) { + return new MatchType(matchType, new SubstringMatch((String) conditionValue)); + } + break; + case "gt": + if (conditionValue instanceof Integer || conditionValue instanceof Double) { + return new MatchType(matchType, new GTMatch((Number) conditionValue)); + } + break; + case "lt": + if (conditionValue instanceof Integer || conditionValue instanceof Double) { + return new MatchType(matchType, new LTMatch((Number) conditionValue)); + } + break; + case "legacy_custom_attribute": + if (conditionValue instanceof String) { + return new MatchType(matchType, new DefaultMatchForLegacyAttributes((String) conditionValue)); + } + break; + default: + return new MatchType(matchType, new NullMatch()); + } + + return new MatchType(matchType, new NullMatch()); + } + + + private MatchType(String type, Match matcher) { + this.matchType = type; + this.matcher = matcher; + } + + public @Nonnull + Match getMatcher() { + return matcher; + } + + @Override + public String toString() { + return matchType; + } +} \ No newline at end of file diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/NullMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/NullMatch.java new file mode 100644 index 000000000..3da47a8fc --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/NullMatch.java @@ -0,0 +1,34 @@ +/** + * + * Copyright 2018, 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 edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +import javax.annotation.Nullable; + +class NullMatch extends AttributeMatch { + @SuppressFBWarnings("URF_UNREAD_FIELD") + Object value; + protected NullMatch() { + this.value = null; + } + + public @Nullable + Boolean eval(Object attributeValue) { + return null; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SubstringMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SubstringMatch.java new file mode 100644 index 000000000..69d8c91d9 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SubstringMatch.java @@ -0,0 +1,38 @@ +/** + * + * Copyright 2018, 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 SubstringMatch extends AttributeMatch { + String value; + protected SubstringMatch(String value) { + this.value = value; + } + + public @Nullable + Boolean eval(Object attributeValue) { + try { + return value.contains(convert(attributeValue)); + } + catch (Exception e) { + MatchType.logger.error("Substring match failed ", e); + return null; + } + } +} + diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/AudienceGsonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/AudienceGsonDeserializer.java index 8158aeb4b..6d39dfdc1 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/AudienceGsonDeserializer.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/AudienceGsonDeserializer.java @@ -67,19 +67,23 @@ private Condition parseConditions(List rawObjectList) { List objectList = (List)rawObjectList.get(i); conditions.add(parseConditions(objectList)); } else { - LinkedTreeMap conditionMap = (LinkedTreeMap)rawObjectList.get(i); - conditions.add(new UserAttribute(conditionMap.get("name"), conditionMap.get("type"), - conditionMap.get("value"))); + LinkedTreeMap conditionMap = (LinkedTreeMap)rawObjectList.get(i); + conditions.add(new UserAttribute((String)conditionMap.get("name"), (String)conditionMap.get("type"), + (String)conditionMap.get("match"), conditionMap.get("value"))); } } Condition condition; - if (operand.equals("and")) { - condition = new AndCondition(conditions); - } else if (operand.equals("or")) { - condition = new OrCondition(conditions); - } else { - condition = new NotCondition(conditions.get(0)); + switch (operand) { + case "and": + condition = new AndCondition(conditions); + break; + case "or": + condition = new OrCondition(conditions); + break; + default: + condition = new NotCondition(conditions.get(0)); + break; } return condition; diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/AudienceJacksonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/AudienceJacksonDeserializer.java index 1d0efc3e7..c487136f6 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/AudienceJacksonDeserializer.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/AudienceJacksonDeserializer.java @@ -59,19 +59,23 @@ private Condition parseConditions(List rawObjectList) { List objectList = (List)rawObjectList.get(i); conditions.add(parseConditions(objectList)); } else { - HashMap conditionMap = (HashMap)rawObjectList.get(i); - conditions.add(new UserAttribute(conditionMap.get("name"), conditionMap.get("type"), - conditionMap.get("value"))); + HashMap conditionMap = (HashMap)rawObjectList.get(i); + conditions.add(new UserAttribute((String)conditionMap.get("name"), (String)conditionMap.get("type"), + (String)conditionMap.get("match"), conditionMap.get("value"))); } } Condition condition; - if (operand.equals("and")) { - condition = new AndCondition(conditions); - } else if (operand.equals("or")) { - condition = new OrCondition(conditions); - } else { - condition = new NotCondition(conditions.get(0)); + switch (operand) { + case "and": + condition = new AndCondition(conditions); + break; + case "or": + condition = new OrCondition(conditions); + break; + default: + condition = new NotCondition(conditions.get(0)); + break; } return condition; diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java index 593fa56ae..2394698d1 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java @@ -41,6 +41,7 @@ import javax.annotation.Nonnull; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -68,7 +69,17 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse attributes = parseAttributes(rootObject.getJSONArray("attributes")); List events = parseEvents(rootObject.getJSONArray("events")); - List audiences = parseAudiences(rootObject.getJSONArray("audiences")); + List audiences = Collections.emptyList(); + + if (rootObject.has("audiences")) { + audiences = parseAudiences(rootObject.getJSONArray("audiences")); + } + + List typedAudiences = null; + if (rootObject.has("typedAudiences")) { + typedAudiences = parseAudiences(rootObject.getJSONArray("typedAudiences")); + } + List groups = parseGroups(rootObject.getJSONArray("groups")); boolean anonymizeIP = false; @@ -98,6 +109,7 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse version, attributes, audiences, + typedAudiences, events, experiments, featureFlags, @@ -289,25 +301,27 @@ private Condition parseConditions(JSONArray conditionJson) { conditions.add(parseConditions(conditionJson.getJSONArray(i))); } else { JSONObject conditionMap = (JSONObject)obj; - String value = null; - if (conditionMap.has("value")) { - value = conditionMap.getString("value"); - } + Object value = conditionMap.has("value") ? conditionMap.get("value") : null; + String match = conditionMap.has("match") ? (String) conditionMap.get("match") : null; conditions.add(new UserAttribute( (String)conditionMap.get("name"), (String)conditionMap.get("type"), - value + match, value )); } } Condition condition; - if (operand.equals("and")) { - condition = new AndCondition(conditions); - } else if (operand.equals("or")) { - condition = new OrCondition(conditions); - } else { - condition = new NotCondition(conditions.get(0)); + switch (operand) { + case "and": + condition = new AndCondition(conditions); + break; + case "or": + condition = new OrCondition(conditions); + break; + default: + condition = new NotCondition(conditions.get(0)); + break; } return condition; diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java index c4784b5c4..fbbdab578 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java @@ -43,6 +43,7 @@ import javax.annotation.Nonnull; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -70,7 +71,17 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse attributes = parseAttributes((JSONArray)rootObject.get("attributes")); List events = parseEvents((JSONArray)rootObject.get("events")); - List audiences = parseAudiences((JSONArray)parser.parse(rootObject.get("audiences").toString())); + List audiences = Collections.emptyList(); + + if (rootObject.containsKey("audiences")) { + audiences = parseAudiences((JSONArray)parser.parse(rootObject.get("audiences").toString())); + } + + List typedAudiences = null; + if (rootObject.containsKey("typedAudiences")) { + typedAudiences = parseAudiences((JSONArray)parser.parse(rootObject.get("typedAudiences").toString())); + } + List groups = parseGroups((JSONArray)rootObject.get("groups")); boolean anonymizeIP = false; @@ -100,6 +111,7 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse version, attributes, audiences, + typedAudiences, events, experiments, featureFlags, @@ -298,7 +310,7 @@ private Condition parseConditions(JSONArray conditionJson) { } else { JSONObject conditionMap = (JSONObject)obj; conditions.add(new UserAttribute((String)conditionMap.get("name"), (String)conditionMap.get("type"), - (String)conditionMap.get("value"))); + (String)conditionMap.get("match"), conditionMap.get("value"))); } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigGsonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigGsonDeserializer.java index c556b9063..b2e848137 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigGsonDeserializer.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigGsonDeserializer.java @@ -33,6 +33,7 @@ import com.optimizely.ab.config.audience.Audience; import java.lang.reflect.Type; +import java.util.Collections; import java.util.List; /** @@ -67,9 +68,15 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa List events = context.deserialize(jsonObject.get("events").getAsJsonArray(), eventsType); - List audiences = - context.deserialize(jsonObject.get("audiences").getAsJsonArray(), audienceType); + List audiences = Collections.emptyList(); + if (jsonObject.has("audiences")) { + audiences = context.deserialize(jsonObject.get("audiences").getAsJsonArray(), audienceType); + } + List typedAudiences = null; + if (jsonObject.has("typedAudiences")) { + typedAudiences = context.deserialize(jsonObject.get("typedAudiences").getAsJsonArray(), audienceType); + } boolean anonymizeIP = false; // live variables should be null if using V2 List liveVariables = null; @@ -101,6 +108,7 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa version, attributes, audiences, + typedAudiences, events, experiments, featureFlags, diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigJacksonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigJacksonDeserializer.java index 76cac7412..164876b39 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigJacksonDeserializer.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigJacksonDeserializer.java @@ -34,6 +34,7 @@ import com.optimizely.ab.config.audience.Audience; import java.io.IOException; +import java.util.Collections; import java.util.List; class ProjectConfigJacksonDeserializer extends JsonDeserializer { @@ -63,8 +64,21 @@ public ProjectConfig deserialize(JsonParser parser, DeserializationContext conte List events = mapper.readValue(node.get("events").toString(), new TypeReference>() {}); - List audiences = mapper.readValue(node.get("audiences").toString(), - new TypeReference>() {}); + + List audiences = Collections.emptyList(); + + if (node.has("audiences")) { + audiences = mapper.readValue(node.get("audiences").toString(), + new TypeReference>() {}); + } + + List typedAudiences = null; + + if (node.has("typedAudiences")) { + typedAudiences = mapper.readValue(node.get("typedAudiences").toString(), + new TypeReference>() { + }); + } boolean anonymizeIP = false; List liveVariables = null; @@ -95,6 +109,7 @@ public ProjectConfig deserialize(JsonParser parser, DeserializationContext conte version, attributes, audiences, + typedAudiences, events, experiments, featureFlags, diff --git a/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java b/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java index be2d74408..1b17b7db3 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java @@ -66,14 +66,11 @@ public static boolean isUserInExperiment(@Nonnull ProjectConfig projectConfig, return true; } - // if there are audiences, but no user attributes, the user is not in the experiment. - if (attributes == null || attributes.isEmpty()) { - return false; - } - for (String audienceId : experimentAudienceIds) { Condition conditions = projectConfig.getAudienceConditionsFromId(audienceId); - if (conditions.evaluate(attributes)) { + Boolean conditionEval = conditions.evaluate(attributes); + + if (conditionEval != null && conditionEval) { return true; } } diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java index 1add5241e..11800a4f7 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -137,7 +137,7 @@ public static Collection data() throws IOException { private static final String testBucketingId = "bucketingId"; private static final String testBucketingIdKey = ControlAttribute.BUCKETING_ATTRIBUTE.toString(); private static final Map testParams = Collections.singletonMap("test", "params"); - private static final LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, null); + private static final LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, new EventBatch()); private int datafileVersion; private String validDatafile; @@ -197,10 +197,196 @@ public void activateEndToEnd() throws Exception { when(mockBucketer.bucket(activatedExperiment, bucketingId)) .thenReturn(bucketedVariation); + logbackVerifier.expectMessage(Level.DEBUG, String.format("No variation for experiment \"%s\" mapped to user \"%s\" in the forced variation map", activatedExperiment.getKey(), testUserId)); + + logbackVerifier.expectMessage(Level.DEBUG, "BucketingId is valid: \"bucketingId\""); + + logbackVerifier.expectMessage(Level.INFO, "This decision will not be saved since the UserProfileService is null."); + + logbackVerifier.expectMessage(Level.INFO, "Activating user \"userId\" in experiment \"" + + activatedExperiment.getKey() + "\"."); + logbackVerifier.expectMessage(Level.DEBUG, "Dispatching impression event to URL test_url with params " + + testParams + " and payload \"{}\""); + + // activate the experiment + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), userId, testUserAttributes); + + // verify that the bucketing algorithm was called correctly + verify(mockBucketer).bucket(activatedExperiment, bucketingId); + assertThat(actualVariation, is(bucketedVariation)); + + // verify that dispatchEvent was called with the correct LogEvent object + verify(mockEventHandler).dispatchEvent(logEventToDispatch); + } + + /** + * Verify that the {@link Optimizely#activate(Experiment, String, Map)} call correctly builds an endpoint url and + * request params and passes them through {@link EventHandler#dispatchEvent(LogEvent)}. + */ + @Test + public void activateEndToEndWithTypedAudienceInt() throws Exception { + Experiment activatedExperiment; + Map testUserAttributes = new HashMap(); + String bucketingKey = testBucketingIdKey; + String userId = testUserId; + String bucketingId = testBucketingId; + if(datafileVersion >= 4) { + activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT_KEY); + testUserAttributes.put(ATTRIBUTE_INTEGER_KEY, 2); // should be gt 1. + } + else { + activatedExperiment = validProjectConfig.getExperiments().get(0); + testUserAttributes.put("browser_type", "chrome"); + } + testUserAttributes.put(bucketingKey, bucketingId); + Variation bucketedVariation = activatedExperiment.getVariations().get(0); + EventFactory mockEventFactory = mock(EventFactory.class); + + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) + .withBucketing(mockBucketer) + .withEventBuilder(mockEventFactory) + .withConfig(validProjectConfig) + .withErrorHandler(mockErrorHandler) + .build(); + + + when(mockEventFactory.createImpressionEvent(validProjectConfig, activatedExperiment, bucketedVariation, testUserId, + testUserAttributes)) + .thenReturn(logEventToDispatch); + + when(mockBucketer.bucket(activatedExperiment, bucketingId)) + .thenReturn(bucketedVariation); + + logbackVerifier.expectMessage(Level.DEBUG, String.format("No variation for experiment \"%s\" mapped to user \"%s\" in the forced variation map", activatedExperiment.getKey(), testUserId)); + + logbackVerifier.expectMessage(Level.DEBUG, "BucketingId is valid: \"bucketingId\""); + + logbackVerifier.expectMessage(Level.INFO, "This decision will not be saved since the UserProfileService is null."); + + logbackVerifier.expectMessage(Level.INFO, "Activating user \"userId\" in experiment \"" + + activatedExperiment.getKey() + "\"."); + logbackVerifier.expectMessage(Level.DEBUG, "Dispatching impression event to URL test_url with params " + + testParams + " and payload \"{}\""); + + // activate the experiment + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), userId, testUserAttributes); + + // verify that the bucketing algorithm was called correctly + verify(mockBucketer).bucket(activatedExperiment, bucketingId); + assertThat(actualVariation, is(bucketedVariation)); + + // verify that dispatchEvent was called with the correct LogEvent object + verify(mockEventHandler).dispatchEvent(logEventToDispatch); + } + + /** + * Verify that the {@link Optimizely#activate(Experiment, String, Map)} call correctly builds an endpoint url and + * request params and passes them through {@link EventHandler#dispatchEvent(LogEvent)}. + */ + @Test + public void activateEndToEndWithTypedAudienceBool() throws Exception { + Experiment activatedExperiment; + Map testUserAttributes = new HashMap(); + String bucketingKey = testBucketingIdKey; + String userId = testUserId; + String bucketingId = testBucketingId; + if(datafileVersion >= 4) { + activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT_KEY); + testUserAttributes.put(ATTRIBUTE_BOOLEAN_KEY, true); // should be eq true. + } + else { + activatedExperiment = validProjectConfig.getExperiments().get(0); + testUserAttributes.put("browser_type", "chrome"); + } + testUserAttributes.put(bucketingKey, bucketingId); + Variation bucketedVariation = activatedExperiment.getVariations().get(0); + EventFactory mockEventFactory = mock(EventFactory.class); + + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) + .withBucketing(mockBucketer) + .withEventBuilder(mockEventFactory) + .withConfig(validProjectConfig) + .withErrorHandler(mockErrorHandler) + .build(); + + + when(mockEventFactory.createImpressionEvent(validProjectConfig, activatedExperiment, bucketedVariation, testUserId, + testUserAttributes)) + .thenReturn(logEventToDispatch); + + when(mockBucketer.bucket(activatedExperiment, bucketingId)) + .thenReturn(bucketedVariation); + + logbackVerifier.expectMessage(Level.DEBUG, String.format("No variation for experiment \"%s\" mapped to user \"%s\" in the forced variation map", activatedExperiment.getKey(), testUserId)); + + logbackVerifier.expectMessage(Level.DEBUG, "BucketingId is valid: \"bucketingId\""); + + logbackVerifier.expectMessage(Level.INFO, "This decision will not be saved since the UserProfileService is null."); + logbackVerifier.expectMessage(Level.INFO, "Activating user \"userId\" in experiment \"" + activatedExperiment.getKey() + "\"."); logbackVerifier.expectMessage(Level.DEBUG, "Dispatching impression event to URL test_url with params " + - testParams + " and payload \"\""); + testParams + " and payload \"{}\""); + + // activate the experiment + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), userId, testUserAttributes); + + // verify that the bucketing algorithm was called correctly + verify(mockBucketer).bucket(activatedExperiment, bucketingId); + assertThat(actualVariation, is(bucketedVariation)); + + // verify that dispatchEvent was called with the correct LogEvent object + verify(mockEventHandler).dispatchEvent(logEventToDispatch); + } + + /** + * Verify that the {@link Optimizely#activate(Experiment, String, Map)} call correctly builds an endpoint url and + * request params and passes them through {@link EventHandler#dispatchEvent(LogEvent)}. + */ + @Test + public void activateEndToEndWithTypedAudienceDouble() throws Exception { + Experiment activatedExperiment; + Map testUserAttributes = new HashMap(); + String bucketingKey = testBucketingIdKey; + String userId = testUserId; + String bucketingId = testBucketingId; + if(datafileVersion >= 4) { + activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT_KEY); + testUserAttributes.put(ATTRIBUTE_DOUBLE_KEY, 99.9); // should be lt 100. + } + else { + activatedExperiment = validProjectConfig.getExperiments().get(0); + testUserAttributes.put("browser_type", "chrome"); + } + testUserAttributes.put(bucketingKey, bucketingId); + Variation bucketedVariation = activatedExperiment.getVariations().get(0); + EventFactory mockEventFactory = mock(EventFactory.class); + + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) + .withBucketing(mockBucketer) + .withEventBuilder(mockEventFactory) + .withConfig(validProjectConfig) + .withErrorHandler(mockErrorHandler) + .build(); + + + when(mockEventFactory.createImpressionEvent(validProjectConfig, activatedExperiment, bucketedVariation, testUserId, + testUserAttributes)) + .thenReturn(logEventToDispatch); + + when(mockBucketer.bucket(activatedExperiment, bucketingId)) + .thenReturn(bucketedVariation); + + logbackVerifier.expectMessage(Level.DEBUG, String.format("No variation for experiment \"%s\" mapped to user \"%s\" in the forced variation map", activatedExperiment.getKey(), testUserId)); + + logbackVerifier.expectMessage(Level.DEBUG, "BucketingId is valid: \"bucketingId\""); + + logbackVerifier.expectMessage(Level.INFO, "This decision will not be saved since the UserProfileService is null."); + + logbackVerifier.expectMessage(Level.INFO, "Activating user \"userId\" in experiment \"" + + activatedExperiment.getKey() + "\"."); + logbackVerifier.expectMessage(Level.DEBUG, "Dispatching impression event to URL test_url with params " + + testParams + " and payload \"{}\""); // activate the experiment Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), userId, testUserAttributes); @@ -678,7 +864,7 @@ public void activateWithUnknownAttribute() throws Exception { activatedExperiment.getKey() + "\"."); logbackVerifier.expectMessage(Level.WARN, "Attribute(s) [unknownAttribute] not in the datafile."); logbackVerifier.expectMessage(Level.DEBUG, "Dispatching impression event to URL test_url with params " + - testParams + " and payload \"\""); + testParams + " and payload \"{}\""); // Use an immutable map to also check that we're not attempting to change the provided attribute map Variation actualVariation = @@ -982,12 +1168,18 @@ public void activateUserNoAttributesWithAudiences() throws Exception { Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) .build(); - logbackVerifier.expectMessage(Level.INFO, - "User \"userId\" does not meet conditions to be in experiment \"" + experiment.getKey() + "\"."); - logbackVerifier.expectMessage(Level.INFO, "Not activating user \"userId\" for experiment \"" + - experiment.getKey() + "\"."); - - assertNull(optimizely.activate(experiment.getKey(), testUserId)); + /** + * TBD: This should be fixed. The v4 datafile does not contain the same condition so + * results are different. We have made a change around 9/7/18 where we evaluate audience + * regardless if you pass in a attribute list or not. In this case there is a not("broswer_type = "firefox") + * This causes the user to be bucketed now because they don't have browser_type set to firefox. + */ + if (datafileVersion == 4) { + assertNull(optimizely.activate(experiment.getKey(), testUserId)); + } + else { + assertNotNull(optimizely.activate(experiment.getKey(), testUserId)); + } } /** @@ -1415,7 +1607,7 @@ public void trackEventWithAttributes() throws Exception { logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + "\" for user \"" + genericUserId + "\"."); logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + - testParams + " and payload \"\""); + testParams + " and payload \"{}\""); // call track optimizely.track(eventType.getKey(), genericUserId, attributes); @@ -1485,7 +1677,7 @@ public void trackEventWithNullAttributes() throws Exception { logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + "\" for user \"" + genericUserId + "\"."); logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + - testParams + " and payload \"\""); + testParams + " and payload \"{}\""); // call track Map attributes = null; @@ -1555,7 +1747,7 @@ public void trackEventWithNullAttributeValues() throws Exception { logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + "\" for user \"" + genericUserId + "\"."); logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + - testParams + " and payload \"\""); + testParams + " and payload \"{}\""); // call track Map attributes = new HashMap(); @@ -1626,7 +1818,7 @@ public void trackEventWithUnknownAttribute() throws Exception { "\" for user \"" + genericUserId + "\"."); logbackVerifier.expectMessage(Level.WARN, "Attribute(s) [unknownAttribute] not in the datafile."); logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + - testParams + " and payload \"\""); + testParams + " and payload \"{}\""); // call track optimizely.track(eventType.getKey(), genericUserId, ImmutableMap.of("unknownAttribute", "attributeValue")); @@ -1699,7 +1891,7 @@ public void trackEventWithEventTags() throws Exception { logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + "\" for user \"" + genericUserId + "\"."); logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + - testParams + " and payload \"\""); + testParams + " and payload \"{}\""); // call track optimizely.track(eventType.getKey(), genericUserId, Collections.emptyMap(), eventTags); @@ -1773,7 +1965,7 @@ public void trackEventWithNullEventTags() throws Exception { logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + "\" for user \"" + genericUserId + "\"."); logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + - testParams + " and payload \"\""); + testParams + " and payload \"{}\""); // call track optimizely.track(eventType.getKey(), genericUserId, Collections.emptyMap(), null); @@ -2247,11 +2439,18 @@ public void getVariationWithAudiencesNoAttributes() throws Exception { .withErrorHandler(mockErrorHandler) .build(); - logbackVerifier.expectMessage(Level.INFO, - "User \"userId\" does not meet conditions to be in experiment \"" + experiment.getKey() + "\"."); - Variation actualVariation = optimizely.getVariation(experiment.getKey(), testUserId); - assertNull(actualVariation); + /** + * This test now passes because the audience is evaludated even if there is no + * attributes passed in. In version 2,3 of the datafile, the audience is a not condition + * which evaluates to true if it is absent. + */ + if (datafileVersion >= 4) { + assertNull(actualVariation); + } + else { + assertNotNull(actualVariation); + } } /** @@ -2630,11 +2829,13 @@ public void removeNotificationListenerNotificationCenter() throws Exception { .build(); Map attributes = new HashMap(); - attributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); - when(mockEventFactory.createImpressionEvent(validProjectConfig, activatedExperiment, - bucketedVariation, genericUserId, attributes)) - .thenReturn(logEventToDispatch); + if (datafileVersion >= 4) { + attributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + } + else { + attributes.put("browser_type", "chrome"); + } when(mockBucketer.bucket(activatedExperiment, genericUserId)) .thenReturn(bucketedVariation); @@ -2827,7 +3028,7 @@ public void trackEventWithListenerAttributes() throws Exception { logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + "\" for user \"" + genericUserId + "\"."); logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + - testParams + " and payload \"\""); + testParams + " and payload \"{}\""); TrackNotificationListener trackNotification = new TrackNotificationListener() { @Override @@ -2911,7 +3112,7 @@ public void trackEventWithListenerNullAttributes() throws Exception { logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + "\" for user \"" + genericUserId + "\"."); logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + - testParams + " and payload \"\""); + testParams + " and payload \"{}\""); TrackNotificationListener trackNotification = new TrackNotificationListener() { @Override diff --git a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java index 0b65e9f91..48271759e 100644 --- a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java @@ -148,8 +148,6 @@ public void getForcedVariationBeforeWhitelisting() throws Exception { // user excluded without audiences and whitelisting assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap())); - logbackVerifier.expectMessage(Level.INFO, "User \"" + genericUserId + "\" does not meet conditions to be in experiment \"etag1\"."); - // set the runtimeForcedVariation validProjectConfig.setForcedVariation(experiment.getKey(), whitelistedUserId, expectedVariation.getKey()); // no attributes provided for a experiment that has an audience @@ -177,8 +175,6 @@ public void getVariationForcedPrecedesAudienceEval() throws Exception { // user excluded without audiences and whitelisting assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap())); - logbackVerifier.expectMessage(Level.INFO, "User \"" + genericUserId + "\" does not meet conditions to be in experiment \"etag1\"."); - // set the runtimeForcedVariation validProjectConfig.setForcedVariation(experiment.getKey(), genericUserId, expectedVariation.getKey()); // no attributes provided for a experiment that has an audience @@ -210,10 +206,6 @@ public void getVariationForcedBeforeUserProfile() throws Exception { // ensure that normal users still get excluded from the experiment when they fail audience evaluation assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap())); - logbackVerifier.expectMessage(Level.INFO, - "User \"" + genericUserId + "\" does not meet conditions to be in experiment \"" - + experiment.getKey() + "\"."); - // ensure that a user with a saved user profile, sees the same variation regardless of audience evaluation assertEquals(variation, decisionService.getVariation(experiment, userProfileId, Collections.emptyMap())); @@ -249,10 +241,6 @@ public void getVariationEvaluatesUserProfileBeforeAudienceTargeting() throws Exc // ensure that normal users still get excluded from the experiment when they fail audience evaluation assertNull(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap())); - logbackVerifier.expectMessage(Level.INFO, - "User \"" + genericUserId + "\" does not meet conditions to be in experiment \"" - + experiment.getKey() + "\"."); - // ensure that a user with a saved user profile, sees the same variation regardless of audience evaluation assertEquals(variation, decisionService.getVariation(experiment, userProfileId, Collections.emptyMap())); diff --git a/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTest.java b/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTest.java index 5fd28dcab..68a96bf5e 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTest.java @@ -106,7 +106,7 @@ public void verifyGetExperimentsForInvalidEvent() throws Exception { @Test public void verifyGetAudienceConditionsFromValidId() throws Exception { List userAttributes = new ArrayList(); - userAttributes.add(new UserAttribute("browser_type", "custom_attribute", "firefox")); + userAttributes.add(new UserAttribute("browser_type", "custom_attribute", null, "firefox")); OrCondition orInner = new OrCondition(userAttributes); @@ -392,4 +392,4 @@ public void getAttributeIDWhenAttributeKeyPrefixIsMatched() { " has reserved prefix $opt_; using attribute ID instead of reserved attribute name."); } -} \ No newline at end of file +} diff --git a/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTestUtils.java b/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTestUtils.java index 87ef97523..ac8f0a873 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTestUtils.java +++ b/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTestUtils.java @@ -88,7 +88,7 @@ private static ProjectConfig generateValidProjectConfigV2() { new EventType("100", "no_running_experiments", singletonList("118"))); List userAttributes = new ArrayList(); - userAttributes.add(new UserAttribute("browser_type", "custom_attribute", "firefox")); + userAttributes.add(new UserAttribute("browser_type", "custom_attribute", null, "firefox")); OrCondition orInner = new OrCondition(userAttributes); @@ -245,7 +245,7 @@ private static ProjectConfig generateValidProjectConfigV3() { new EventType("100", "no_running_experiments", singletonList("118"))); List userAttributes = new ArrayList(); - userAttributes.add(new UserAttribute("browser_type", "custom_attribute", "firefox")); + userAttributes.add(new UserAttribute("browser_type", "custom_attribute", null, "firefox")); OrCondition orInner = new OrCondition(userAttributes); @@ -459,6 +459,7 @@ public static void verifyProjectConfig(@CheckForNull ProjectConfig actual, @Nonn verifyAttributes(actual.getAttributes(), expected.getAttributes()); verifyAudiences(actual.getAudiences(), expected.getAudiences()); + verifyAudiences(actual.getTypedAudiences(), expected.getTypedAudiences()); verifyEvents(actual.getEventTypes(), expected.getEventTypes()); verifyExperiments(actual.getExperiments(), expected.getExperiments()); verifyFeatureFlags(actual.getFeatureFlags(), expected.getFeatureFlags()); @@ -581,7 +582,6 @@ private static void verifyAudiences(List actual, List expect assertThat(actualAudience.getId(), is(expectedAudience.getId())); assertThat(actualAudience.getKey(), is(expectedAudience.getKey())); assertThat(actualAudience.getConditions(), is(expectedAudience.getConditions())); - assertThat(actualAudience.getConditions(), is(expectedAudience.getConditions())); } } diff --git a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java index 160048ca4..d4068a79b 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java +++ b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java @@ -63,6 +63,42 @@ public class ValidProjectConfigV4 { // audiences private static final String CUSTOM_ATTRIBUTE_TYPE = "custom_attribute"; + private static final String AUDIENCE_BOOL_ID = "3468206643"; + private static final String AUDIENCE_BOOL_KEY = "BOOL"; + public static final Boolean AUDIENCE_BOOL_VALUE = true; + private static final Audience TYPED_AUDIENCE_BOOL = new Audience( + AUDIENCE_BOOL_ID, + AUDIENCE_BOOL_KEY, + new AndCondition(Collections.singletonList( + new OrCondition(Collections.singletonList( + new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_BOOLEAN_KEY, + CUSTOM_ATTRIBUTE_TYPE, "exact", + AUDIENCE_BOOL_VALUE))))))) + ); + private static final String AUDIENCE_INT_ID = "3468206644"; + private static final String AUDIENCE_INT_KEY = "INT"; + public static final Number AUDIENCE_INT_VALUE = 1.0; + private static final Audience TYPED_AUDIENCE_INT = new Audience( + AUDIENCE_INT_ID, + AUDIENCE_INT_KEY, + new AndCondition(Collections.singletonList( + new OrCondition(Collections.singletonList( + new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_INTEGER_KEY, + CUSTOM_ATTRIBUTE_TYPE, "gt", + AUDIENCE_INT_VALUE))))))) + ); + private static final String AUDIENCE_DOUBLE_ID = "3468206645"; + private static final String AUDIENCE_DOUBLE_KEY = "DOUBLE"; + public static final Double AUDIENCE_DOUBLE_VALUE = 100.0; + private static final Audience TYPED_AUDIENCE_DOUBLE = new Audience( + AUDIENCE_DOUBLE_ID, + AUDIENCE_DOUBLE_KEY, + new AndCondition(Collections.singletonList( + new OrCondition(Collections.singletonList( + new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_DOUBLE_KEY, + CUSTOM_ATTRIBUTE_TYPE, "lt", + AUDIENCE_DOUBLE_VALUE))))))) + ); private static final String AUDIENCE_GRYFFINDOR_ID = "3468206642"; private static final String AUDIENCE_GRYFFINDOR_KEY = "Gryffindors"; public static final String AUDIENCE_GRYFFINDOR_VALUE = "Gryffindor"; @@ -72,9 +108,19 @@ public class ValidProjectConfigV4 { new AndCondition(Collections.singletonList( new OrCondition(Collections.singletonList( new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_HOUSE_KEY, - CUSTOM_ATTRIBUTE_TYPE, + CUSTOM_ATTRIBUTE_TYPE, null, AUDIENCE_GRYFFINDOR_VALUE))))))) ); + private static final Audience TYPED_AUDIENCE_GRYFFINDOR = new Audience( + AUDIENCE_GRYFFINDOR_ID, + AUDIENCE_GRYFFINDOR_KEY, + new AndCondition(Collections.singletonList( + new OrCondition(Collections.singletonList( + new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_HOUSE_KEY, + CUSTOM_ATTRIBUTE_TYPE, "exact", + AUDIENCE_GRYFFINDOR_VALUE))))))) + ); + private static final String AUDIENCE_SLYTHERIN_ID = "3988293898"; private static final String AUDIENCE_SLYTHERIN_KEY = "Slytherins"; public static final String AUDIENCE_SLYTHERIN_VALUE = "Slytherin"; @@ -84,7 +130,17 @@ public class ValidProjectConfigV4 { new AndCondition(Collections.singletonList( new OrCondition(Collections.singletonList( new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_HOUSE_KEY, - CUSTOM_ATTRIBUTE_TYPE, + CUSTOM_ATTRIBUTE_TYPE, null, + AUDIENCE_SLYTHERIN_VALUE))))))) + ); + + private static final Audience TYPED_AUDIENCE_SLYTHERIN = new Audience( + AUDIENCE_SLYTHERIN_ID, + AUDIENCE_SLYTHERIN_KEY, + new AndCondition(Collections.singletonList( + new OrCondition(Collections.singletonList( + new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_HOUSE_KEY, + CUSTOM_ATTRIBUTE_TYPE, "substring", AUDIENCE_SLYTHERIN_VALUE))))))) ); @@ -97,7 +153,16 @@ public class ValidProjectConfigV4 { new AndCondition(Collections.singletonList( new OrCondition(Collections.singletonList( new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_NATIONALITY_KEY, - CUSTOM_ATTRIBUTE_TYPE, + CUSTOM_ATTRIBUTE_TYPE, null, + AUDIENCE_ENGLISH_CITIZENS_VALUE))))))) + ); + private static final Audience TYPED_AUDIENCE_ENGLISH_CITIZENS = new Audience( + AUDIENCE_ENGLISH_CITIZENS_ID, + AUDIENCE_ENGLISH_CITIZENS_KEY, + new AndCondition(Collections.singletonList( + new OrCondition(Collections.singletonList( + new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_NATIONALITY_KEY, + CUSTOM_ATTRIBUTE_TYPE, "exact", AUDIENCE_ENGLISH_CITIZENS_VALUE))))))) ); private static final String AUDIENCE_WITH_MISSING_VALUE_ID = "2196265320"; @@ -105,12 +170,13 @@ public class ValidProjectConfigV4 { public static final String AUDIENCE_WITH_MISSING_VALUE_VALUE = "English"; private static final UserAttribute ATTRIBUTE_WITH_VALUE = new UserAttribute( ATTRIBUTE_NATIONALITY_KEY, - CUSTOM_ATTRIBUTE_TYPE, + CUSTOM_ATTRIBUTE_TYPE, null, AUDIENCE_WITH_MISSING_VALUE_VALUE ); private static final UserAttribute ATTRIBUTE_WITHOUT_VALUE = new UserAttribute( ATTRIBUTE_NATIONALITY_KEY, CUSTOM_ATTRIBUTE_TYPE, + null, null ); private static final Audience AUDIENCE_WITH_MISSING_VALUE = new Audience( @@ -365,6 +431,50 @@ public class ValidProjectConfigV4 { ) ) ); + private static final String LAYER_TYPEDAUDIENCE_EXPERIMENT_ID = "1630555627"; + private static final String EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT_ID = "1323241597"; + public static final String EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT_KEY = "typed_audience_experiment"; + private static final String VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_A_ID = "1423767503"; + private static final String VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_A_KEY = "A"; + private static final Variation VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_A = new Variation( + VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_A_ID, + VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_A_KEY, + Collections.emptyList() + ); + private static final String VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_B_ID = "3433458315"; + private static final String VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_B_KEY = "B"; + private static final Variation VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_B = new Variation( + VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_B_ID, + VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_B_KEY, + Collections.emptyList() + ); + + private static final Experiment EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT = new Experiment( + EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT_ID, + EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT_KEY, + Experiment.ExperimentStatus.RUNNING.toString(), + LAYER_TYPEDAUDIENCE_EXPERIMENT_ID, + ProjectConfigTestUtils.createListOfObjects( + AUDIENCE_BOOL_ID, + AUDIENCE_INT_ID, + AUDIENCE_DOUBLE_ID + ), + ProjectConfigTestUtils.createListOfObjects( + VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_A, + VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_B + ), + Collections.EMPTY_MAP, + ProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_A_ID, + 5000 + ), + new TrafficAllocation( + VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_B_ID, + 10000 + ) + ) + ); private static final String LAYER_FIRST_GROUPED_EXPERIMENT_ID = "3301900159"; private static final String EXPERIMENT_FIRST_GROUPED_EXPERIMENT_ID = "2738374745"; private static final String EXPERIMENT_FIRST_GROUPED_EXPERIMENT_KEY = "first_grouped_experiment"; @@ -1056,6 +1166,15 @@ public static ProjectConfig generateValidProjectConfigV4() { audiences.add(AUDIENCE_ENGLISH_CITIZENS); audiences.add(AUDIENCE_WITH_MISSING_VALUE); + List typedAudiences = new ArrayList(); + typedAudiences.add(TYPED_AUDIENCE_BOOL); + typedAudiences.add(TYPED_AUDIENCE_INT); + typedAudiences.add(TYPED_AUDIENCE_DOUBLE); + typedAudiences.add(TYPED_AUDIENCE_GRYFFINDOR); + typedAudiences.add(TYPED_AUDIENCE_SLYTHERIN); + typedAudiences.add(TYPED_AUDIENCE_ENGLISH_CITIZENS); + typedAudiences.add(AUDIENCE_WITH_MISSING_VALUE); + // list events List events = new ArrayList(); events.add(EVENT_BASIC_EVENT); @@ -1065,6 +1184,7 @@ public static ProjectConfig generateValidProjectConfigV4() { // list experiments List experiments = new ArrayList(); experiments.add(EXPERIMENT_BASIC_EXPERIMENT); + experiments.add(EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT); experiments.add(EXPERIMENT_MULTIVARIATE_EXPERIMENT); experiments.add(EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT); experiments.add(EXPERIMENT_PAUSED_EXPERIMENT); @@ -1100,6 +1220,7 @@ public static ProjectConfig generateValidProjectConfigV4() { VERSION, attributes, audiences, + typedAudiences, events, experiments, featureFlags, 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 a41fafdc4..d841d40a4 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 @@ -28,8 +28,10 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -56,6 +58,7 @@ public void initialize() { testTypedUserAttributes.put("num_counts", 3.55); testTypedUserAttributes.put("num_size", 3); testTypedUserAttributes.put("meta_data", testUserAttributes); + testTypedUserAttributes.put("null_val", null); } /** @@ -63,43 +66,281 @@ public void initialize() { */ @Test public void userAttributeEvaluateTrue() throws Exception { - UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "chrome"); + UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", null,"chrome"); + assertTrue(testInstance.hashCode() != 0); + assertNull(testInstance.getMatch()); + assertEquals(testInstance.getName(), "browser_type"); + assertEquals(testInstance.getType(), "custom_attribute"); assertTrue(testInstance.evaluate(testUserAttributes)); } + /** + * Verify that UserAttribute.evaluate returns false on non-exact-matching visitor attribute data. + */ + @Test + public void userAttributeEvaluateFalse() throws Exception { + UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", null,"firefox"); + assertFalse(testInstance.evaluate(testUserAttributes)); + } /** - * Verify that UserAttribute.evaluate returns true on exact-matching visitor attribute data. + * Verify that UserAttribute.evaluate returns false on unknown visitor attributes. */ @Test - public void typedUserAttributeEvaluateTrue() throws Exception { - UserAttribute testInstance = new UserAttribute("meta_data", "custom_attribute", testUserAttributes); - UserAttribute testInstance2 = new UserAttribute("is_firefox", "custom_attribute", true); - UserAttribute testInstance3 = new UserAttribute("num_counts", "custom_attribute", 3.55); - UserAttribute testInstance4 = new UserAttribute("num_size", "custom_attribute", 3); + public void userAttributeUnknownAttribute() throws Exception { + UserAttribute testInstance = new UserAttribute("unknown_dim", "custom_attribute", null,"unknown"); + assertFalse(testInstance.evaluate(testUserAttributes)); + } - assertTrue(testInstance.evaluate(testTypedUserAttributes)); - assertTrue(testInstance2.evaluate(testTypedUserAttributes)); - assertTrue(testInstance3.evaluate(testTypedUserAttributes)); - assertTrue(testInstance4.evaluate(testTypedUserAttributes)); + /** + * Verify that UserAttribute.evaluate returns null on invalid match type. + */ + @Test + public void invalidMatchCondition() throws Exception { + UserAttribute testInstance = new UserAttribute("browser_type", "unknown_dimension", null,"chrome"); + assertNull(testInstance.evaluate(testUserAttributes)); } /** - * Verify that UserAttribute.evaluate returns false on non-exact-matching visitor attribute data. + * Verify that UserAttribute.evaluate returns null on invalid match type. */ @Test - public void userAttributeEvaluateFalse() throws Exception { - UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "firefox"); - assertFalse(testInstance.evaluate(testUserAttributes)); + public void invalidMatch() throws Exception { + UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "blah","chrome"); + assertNull(testInstance.evaluate(testUserAttributes)); } /** - * Verify that UserAttribute.evaluate returns false on unknown visitor attributes. + * Verify that UserAttribute.evaluate for EXIST match type returns true for known visitor + * attributes with non-null instances and empty string. */ @Test - public void userAttributeUnknownAttribute() throws Exception { - UserAttribute testInstance = new UserAttribute("unknown_dim", "custom_attribute", "unknown"); - assertFalse(testInstance.evaluate(testUserAttributes)); + public void existsMatchConditionEmptyStringEvaluatesTrue() throws Exception { + UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute","exists", "firefox"); + Map attributes = new HashMap<>(); + attributes.put("browser_type", ""); + assertTrue(testInstance.evaluate(attributes)); + attributes.put("browser_type", null); + assertFalse(testInstance.evaluate(attributes)); + } + + /** + * Verify that UserAttribute.evaluate for EXIST match type returns true for known visitor + * attributes with non-null instances + */ + @Test + public void existsMatchConditionEvaluatesTrue() throws Exception { + UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute","exists", "firefox"); + assertTrue(testInstance.evaluate(testUserAttributes)); + + UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute","exists", false); + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute","exists", 5); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute","exists", 4.55); + UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute","exists", testUserAttributes); + assertTrue(testInstanceBoolean.evaluate(testTypedUserAttributes)); + assertTrue(testInstanceInteger.evaluate(testTypedUserAttributes)); + assertTrue(testInstanceDouble.evaluate(testTypedUserAttributes)); + assertTrue(testInstanceObject.evaluate(testTypedUserAttributes)); + } + + /** + * Verify that UserAttribute.evaluate for EXIST match type returns false for unknown visitor + * attributes OR null visitor attributes. + */ + @Test + public void existsMatchConditionEvaluatesFalse() throws Exception { + UserAttribute testInstance = new UserAttribute("bad_var", "custom_attribute","exists", "chrome"); + UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute","exists", "chrome"); + assertFalse(testInstance.evaluate(testTypedUserAttributes)); + assertFalse(testInstanceNull.evaluate(testTypedUserAttributes)); + } + + /** + * Verify that UserAttribute.evaluate for EXACT match type returns true for known visitor + * attributes where the values and the value's type are the same + */ + @Test + public void exactMatchConditionEvaluatesTrue() throws Exception { + UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute","exact", "chrome"); + UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute","exact", true); + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute","exact", 3); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute","exact", 3.55); + + assertTrue(testInstanceString.evaluate(testUserAttributes)); + assertTrue(testInstanceBoolean.evaluate(testTypedUserAttributes)); + assertTrue(testInstanceInteger.evaluate(testTypedUserAttributes)); + assertTrue(testInstanceDouble.evaluate(testTypedUserAttributes)); + } + + /** + * Verify that UserAttribute.evaluate for EXACT match type returns false for known visitor + * attributes where the value's type are the same, but the values are different + */ + @Test + public void exactMatchConditionEvaluatesFalse() throws Exception { + UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute","exact", "firefox"); + UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute","exact", false); + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute","exact", 5); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute","exact", 5.55); + + assertFalse(testInstanceString.evaluate(testUserAttributes)); + assertFalse(testInstanceBoolean.evaluate(testTypedUserAttributes)); + assertFalse(testInstanceInteger.evaluate(testTypedUserAttributes)); + assertFalse(testInstanceDouble.evaluate(testTypedUserAttributes)); + } + + /** + * Verify that UserAttribute.evaluate for EXACT match type returns null for known visitor + * attributes where the value's type are different OR for values with null and object type. + */ + @Test + public void exactMatchConditionEvaluatesNull() throws Exception { + UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute","exact", testUserAttributes); + UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute","exact", true); + UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute","exact", "true"); + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute","exact", "3"); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "exact", "3.55"); + UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute","exact", "null_val"); + + assertNull(testInstanceObject.evaluate(testTypedUserAttributes)); + assertNull(testInstanceString.evaluate(testUserAttributes)); + assertNull(testInstanceBoolean.evaluate(testTypedUserAttributes)); + assertNull(testInstanceInteger.evaluate(testTypedUserAttributes)); + assertNull(testInstanceDouble.evaluate(testTypedUserAttributes)); + assertNull(testInstanceNull.evaluate(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 greater than + * the condition's value. + */ + @Test + public void gtMatchConditionEvaluatesTrue() throws Exception { + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute","gt", 2); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute","gt", 2.55); + + assertTrue(testInstanceInteger.evaluate(testTypedUserAttributes)); + assertTrue(testInstanceDouble.evaluate(testTypedUserAttributes)); + + Map badAttributes = new HashMap<>(); + badAttributes.put("num_size", "bobs burgers"); + assertNull(testInstanceInteger.evaluate(badAttributes)); + } + + /** + * Verify that UserAttribute.evaluate for GT match type returns false for known visitor + * attributes where the value's type is a number, and the UserAttribute's value is not greater + * than the condition's value. + */ + @Test + public void gtMatchConditionEvaluatesFalse() throws Exception { + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute","gt", 5); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute","gt", 5.55); + + assertFalse(testInstanceInteger.evaluate(testTypedUserAttributes)); + assertFalse(testInstanceDouble.evaluate(testTypedUserAttributes)); + } + + /** + * Verify that UserAttribute.evaluate for GT match type returns null if the UserAttribute's + * value type is not a number. + */ + @Test + public void gtMatchConditionEvaluatesNull() throws Exception { + UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute","gt", 3.5); + UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute","gt", 3.5); + UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute","gt", 3.5); + UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute","gt", 3.5); + + assertNull(testInstanceString.evaluate(testUserAttributes)); + assertNull(testInstanceBoolean.evaluate(testTypedUserAttributes)); + assertNull(testInstanceObject.evaluate(testTypedUserAttributes)); + assertNull(testInstanceNull.evaluate(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 + * the condition's value. + */ + @Test + public void ltMatchConditionEvaluatesTrue() throws Exception { + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute","lt", 5); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute","lt", 5.55); + + assertTrue(testInstanceInteger.evaluate(testTypedUserAttributes)); + assertTrue(testInstanceDouble.evaluate(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 not less + * than the condition's value. + */ + @Test + public void ltMatchConditionEvaluatesFalse() throws Exception { + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute","lt", 2); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute","lt", 2.55); + + assertFalse(testInstanceInteger.evaluate(testTypedUserAttributes)); + assertFalse(testInstanceDouble.evaluate(testTypedUserAttributes)); + } + + /** + * Verify that UserAttribute.evaluate for LT match type returns null if the UserAttribute's + * value type is not a number. + */ + @Test + public void ltMatchConditionEvaluatesNull() throws Exception { + UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute","lt", 3.5); + UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute","lt", 3.5); + UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute","lt", 3.5); + UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute","lt", 3.5); + + assertNull(testInstanceString.evaluate(testUserAttributes)); + assertNull(testInstanceBoolean.evaluate(testTypedUserAttributes)); + assertNull(testInstanceObject.evaluate(testTypedUserAttributes)); + assertNull(testInstanceNull.evaluate(testTypedUserAttributes)); + } + + /** + * Verify that UserAttribute.evaluate for SUBSTRING match type returns true if the + * UserAttribute's value is a substring of the condition's value. + */ + @Test + public void substringMatchConditionEvaluatesTrue() throws Exception { + UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute","substring", "chrome1"); + assertTrue(testInstanceString.evaluate(testUserAttributes)); + } + + /** + * Verify that UserAttribute.evaluate for SUBSTRING match type returns true if the + * UserAttribute's value is NOT a substring of the condition's value. + */ + @Test + public void substringMatchConditionEvaluatesFalse() throws Exception { + UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute","substring", "chr"); + assertFalse(testInstanceString.evaluate(testUserAttributes)); + } + + /** + * Verify that UserAttribute.evaluate for SUBSTRING match type returns null if the + * UserAttribute's value type is not a string. + */ + @Test + public void substringMatchConditionEvaluatesNull() throws Exception { + UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute","substring", "chrome1"); + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute","substring", "chrome1"); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute","substring", "chrome1"); + UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute","substring", "chrome1"); + UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute","substring", "chrome1"); + + assertNull(testInstanceBoolean.evaluate(testTypedUserAttributes)); + assertNull(testInstanceInteger.evaluate(testTypedUserAttributes)); + assertNull(testInstanceDouble.evaluate(testTypedUserAttributes)); + assertNull(testInstanceObject.evaluate(testTypedUserAttributes)); + assertNull(testInstanceNull.evaluate(testTypedUserAttributes)); } /** @@ -150,6 +391,72 @@ public void orConditionEvaluateTrue() throws Exception { verify(userAttribute2, times(0)).evaluate(testUserAttributes); } + /** + * Verify that OrCondition.evaluate returns true when at least one of its operand conditions evaluate to true. + */ + @Test + public void orConditionEvaluateTrueWithNullAndTrue() throws Exception { + UserAttribute userAttribute1 = mock(UserAttribute.class); + when(userAttribute1.evaluate(testUserAttributes)).thenReturn(null); + + UserAttribute userAttribute2 = mock(UserAttribute.class); + when(userAttribute2.evaluate(testUserAttributes)).thenReturn(true); + + List conditions = new ArrayList(); + conditions.add(userAttribute1); + conditions.add(userAttribute2); + + OrCondition orCondition = new OrCondition(conditions); + assertTrue(orCondition.evaluate(testUserAttributes)); + verify(userAttribute1, times(1)).evaluate(testUserAttributes); + // shouldn't be called due to short-circuiting in 'Or' evaluation + verify(userAttribute2, times(1)).evaluate(testUserAttributes); + } + + /** + * Verify that OrCondition.evaluate returns true when at least one of its operand conditions evaluate to true. + */ + @Test + public void orConditionEvaluateNullWithNullAndFalse() throws Exception { + UserAttribute userAttribute1 = mock(UserAttribute.class); + when(userAttribute1.evaluate(testUserAttributes)).thenReturn(null); + + UserAttribute userAttribute2 = mock(UserAttribute.class); + when(userAttribute2.evaluate(testUserAttributes)).thenReturn(false); + + List conditions = new ArrayList(); + conditions.add(userAttribute1); + conditions.add(userAttribute2); + + OrCondition orCondition = new OrCondition(conditions); + assertNull(orCondition.evaluate(testUserAttributes)); + verify(userAttribute1, times(1)).evaluate(testUserAttributes); + // shouldn't be called due to short-circuiting in 'Or' evaluation + verify(userAttribute2, times(1)).evaluate(testUserAttributes); + } + + /** + * Verify that OrCondition.evaluate returns true when at least one of its operand conditions evaluate to true. + */ + @Test + public void orConditionEvaluateFalseWithFalseAndFalse() throws Exception { + UserAttribute userAttribute1 = mock(UserAttribute.class); + when(userAttribute1.evaluate(testUserAttributes)).thenReturn(false); + + UserAttribute userAttribute2 = mock(UserAttribute.class); + when(userAttribute2.evaluate(testUserAttributes)).thenReturn(false); + + List conditions = new ArrayList(); + conditions.add(userAttribute1); + conditions.add(userAttribute2); + + OrCondition orCondition = new OrCondition(conditions); + assertFalse(orCondition.evaluate(testUserAttributes)); + verify(userAttribute1, times(1)).evaluate(testUserAttributes); + // shouldn't be called due to short-circuiting in 'Or' evaluation + verify(userAttribute2, times(1)).evaluate(testUserAttributes); + } + /** * Verify that OrCondition.evaluate returns false when all of its operand conditions evaluate to false. */ @@ -192,6 +499,48 @@ public void andConditionEvaluateTrue() throws Exception { verify(orCondition2, times(1)).evaluate(testUserAttributes); } + /** + * Verify that AndCondition.evaluate returns true when all of its operand conditions evaluate to true. + */ + @Test + public void andConditionEvaluateFalseWithNullAndFalse() throws Exception { + OrCondition orCondition1 = mock(OrCondition.class); + when(orCondition1.evaluate(testUserAttributes)).thenReturn(null); + + OrCondition orCondition2 = mock(OrCondition.class); + when(orCondition2.evaluate(testUserAttributes)).thenReturn(false); + + List conditions = new ArrayList(); + conditions.add(orCondition1); + conditions.add(orCondition2); + + AndCondition andCondition = new AndCondition(conditions); + assertFalse(andCondition.evaluate(testUserAttributes)); + verify(orCondition1, times(1)).evaluate(testUserAttributes); + verify(orCondition2, times(1)).evaluate(testUserAttributes); + } + + /** + * Verify that AndCondition.evaluate returns true when all of its operand conditions evaluate to true. + */ + @Test + public void andConditionEvaluateNullWithNullAndTrue() throws Exception { + OrCondition orCondition1 = mock(OrCondition.class); + when(orCondition1.evaluate(testUserAttributes)).thenReturn(null); + + OrCondition orCondition2 = mock(OrCondition.class); + when(orCondition2.evaluate(testUserAttributes)).thenReturn(true); + + List conditions = new ArrayList(); + conditions.add(orCondition1); + conditions.add(orCondition2); + + AndCondition andCondition = new AndCondition(conditions); + assertNull(andCondition.evaluate(testUserAttributes)); + verify(orCondition1, times(1)).evaluate(testUserAttributes); + verify(orCondition2, times(1)).evaluate(testUserAttributes); + } + /** * Verify that AndCondition.evaluate returns false when any one of its operand conditions evaluate to false. */ @@ -203,6 +552,7 @@ public void andConditionEvaluateFalse() throws Exception { OrCondition orCondition2 = mock(OrCondition.class); when(orCondition2.evaluate(testUserAttributes)).thenReturn(true); + // and[false, true] List conditions = new ArrayList(); conditions.add(orCondition1); conditions.add(orCondition2); @@ -212,8 +562,36 @@ public void andConditionEvaluateFalse() throws Exception { verify(orCondition1, times(1)).evaluate(testUserAttributes); // shouldn't be called due to short-circuiting in 'And' evaluation verify(orCondition2, times(0)).evaluate(testUserAttributes); + + OrCondition orCondition3 = mock(OrCondition.class); + when(orCondition3.evaluate(testUserAttributes)).thenReturn(null); + + // and[null, false] + List conditions2 = new ArrayList(); + conditions2.add(orCondition3); + conditions2.add(orCondition1); + + AndCondition andCondition2 = new AndCondition(conditions2); + assertFalse(andCondition2.evaluate(testUserAttributes)); + + // and[true, false, null] + List conditions3 = new ArrayList(); + conditions3.add(orCondition2); + conditions3.add(orCondition3); + conditions3.add(orCondition1); + + AndCondition andCondition3 = new AndCondition(conditions3); + assertFalse(andCondition3.evaluate(testUserAttributes)); } + /** + * Verify that AndCondition.evaluate returns null when any one of its operand conditions evaluate to false. + */ + // @Test + // public void andConditionEvaluateNull() throws Exception { + + // } + /** * Verify that {@link UserAttribute#evaluate(Map)} * called when its attribute value is null @@ -230,11 +608,12 @@ public void nullValueEvaluate() throws Exception { UserAttribute nullValueAttribute = new UserAttribute( attributeName, attributeType, + "exact", attributeValue ); - assertTrue(nullValueAttribute.evaluate(Collections.emptyMap())); - assertTrue(nullValueAttribute.evaluate(Collections.singletonMap(attributeName, attributeValue))); - assertFalse(nullValueAttribute.evaluate((Collections.singletonMap(attributeName, "")))); + assertNull(nullValueAttribute.evaluate(Collections.emptyMap())); + assertNull(nullValueAttribute.evaluate(Collections.singletonMap(attributeName, attributeValue))); + assertNull(nullValueAttribute.evaluate((Collections.singletonMap(attributeName, "")))); } } diff --git a/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java b/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java index bfdcf08df..1954d01fd 100644 --- a/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java +++ b/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java @@ -128,10 +128,10 @@ public void isUserInExperimentReturnsTrueIfExperimentHasNoAudiences() { * then {@link ExperimentUtils#isUserInExperiment(ProjectConfig, Experiment, Map)} should return false. */ @Test - public void isUserInExperimentReturnsFalseIfExperimentHasAudiencesButUserHasNoAttributes() { + public void isUserInExperimentEvaluatesEvenIfExperimentHasAudiencesButUserHasNoAttributes() { Experiment experiment = projectConfig.getExperiments().get(0); - assertFalse(isUserInExperiment(projectConfig, experiment, Collections.emptyMap())); + assertTrue(isUserInExperiment(projectConfig, experiment, Collections.emptyMap())); } /** @@ -171,7 +171,7 @@ public void isUserInExperimentHandlesNullValue() { Map nonMatchingMap = Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, "American"); assertTrue(isUserInExperiment(v4ProjectConfig, experiment, satisfiesFirstCondition)); - assertTrue(isUserInExperiment(v4ProjectConfig, experiment, attributesWithNull)); + assertFalse(isUserInExperiment(v4ProjectConfig, experiment, attributesWithNull)); assertFalse(isUserInExperiment(v4ProjectConfig, experiment, nonMatchingMap)); // It should explicitly be set to null otherwise we will return false on empty maps diff --git a/core-api/src/test/resources/config/valid-project-config-v4.json b/core-api/src/test/resources/config/valid-project-config-v4.json index 48e304205..4abcd69f8 100644 --- a/core-api/src/test/resources/config/valid-project-config-v4.json +++ b/core-api/src/test/resources/config/valid-project-config-v4.json @@ -27,6 +27,43 @@ "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"nationality\", \"type\": \"custom_attribute\", \"value\": \"English\"}, {\"name\": \"nationality\", \"type\": \"custom_attribute\"}]]]" } ], + "typedAudiences": [ + { + "id": "3468206643", + "name": "BOOL", + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"booleanKey\", \"type\": \"custom_attribute\", \"match\":\"exact\", \"value\":true}]]]" + }, + { + "id": "3468206644", + "name": "INT", + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"integerKey\", \"type\": \"custom_attribute\", \"match\":\"gt\", \"value\":1.0}]]]" + }, + { + "id": "3468206645", + "name": "DOUBLE", + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"doubleKey\", \"type\": \"custom_attribute\", \"match\":\"lt\", \"value\":100.0}]]]" + }, + { + "id": "3468206642", + "name": "Gryffindors", + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"house\", \"type\": \"custom_attribute\", \"match\":\"exact\", \"value\":\"Gryffindor\"}]]]" + }, + { + "id": "3988293898", + "name": "Slytherins", + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"house\", \"type\": \"custom_attribute\", \"match\":\"substring\", \"value\":\"Slytherin\"}]]]" + }, + { + "id": "4194404272", + "name": "english_citizens", + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"nationality\", \"type\": \"custom_attribute\", \"match\":\"exact\", \"value\":\"English\"}]]]" + }, + { + "id": "2196265320", + "name": "audience_with_missing_value", + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"nationality\", \"type\": \"custom_attribute\", \"value\": \"English\"}, {\"name\": \"nationality\", \"type\": \"custom_attribute\"}]]]" + } + ], "attributes": [ { "id": "553339214", @@ -114,6 +151,36 @@ "Tom Riddle": "B" } }, + { + "id": "1323241597", + "key": "typed_audience_experiment", + "layerId": "1630555627", + "status": "Running", + "variations": [ + { + "id": "1423767503", + "key": "A", + "variables": [] + }, + { + "id": "3433458315", + "key": "B", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1423767503", + "endOfRange": 5000 + }, + { + "entityId": "3433458315", + "endOfRange": 10000 + } + ], + "audienceIds": ["3468206643", "3468206644", "3468206645"], + "forcedVariations": {} + }, { "id": "3262035800", "key": "multivariate_experiment",