diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 2f63ef535..a63f4be43 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -34,6 +34,11 @@ import com.optimizely.ab.optimizelyconfig.OptimizelyConfig; import com.optimizely.ab.optimizelyconfig.OptimizelyConfigManager; import com.optimizely.ab.optimizelyconfig.OptimizelyConfigService; +import com.optimizely.ab.optimizelydecision.DecisionMessage; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; +import com.optimizely.ab.optimizelydecision.OptimizelyDecision; import com.optimizely.ab.optimizelyjson.OptimizelyJSON; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -76,7 +81,6 @@ public class Optimizely implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(Optimizely.class); - @VisibleForTesting final DecisionService decisionService; @VisibleForTesting @Deprecated @@ -86,6 +90,8 @@ public class Optimizely implements AutoCloseable { @VisibleForTesting final ErrorHandler errorHandler; + public final List defaultDecideOptions; + private final ProjectConfigManager projectConfigManager; @Nullable @@ -104,7 +110,8 @@ private Optimizely(@Nonnull EventHandler eventHandler, @Nullable UserProfileService userProfileService, @Nonnull ProjectConfigManager projectConfigManager, @Nullable OptimizelyConfigManager optimizelyConfigManager, - @Nonnull NotificationCenter notificationCenter + @Nonnull NotificationCenter notificationCenter, + @Nonnull List defaultDecideOptions ) { this.eventHandler = eventHandler; this.eventProcessor = eventProcessor; @@ -114,6 +121,7 @@ private Optimizely(@Nonnull EventHandler eventHandler, this.projectConfigManager = projectConfigManager; this.optimizelyConfigManager = optimizelyConfigManager; this.notificationCenter = notificationCenter; + this.defaultDecideOptions = defaultDecideOptions; } /** @@ -779,7 +787,6 @@ T getFeatureVariableValueForType(@Nonnull String featureKey, } // Helper method which takes type and variable value and convert it to object to use in Listener DecisionInfo object variable value - @VisibleForTesting Object convertStringToType(String variableValue, String type) { if (variableValue != null) { switch (type) { @@ -1129,6 +1136,202 @@ public OptimizelyConfig getOptimizelyConfig() { return new OptimizelyConfigService(projectConfig).getConfig(); } + //============ decide ============// + + /** + * Create a context of the user for which decision APIs will be called. + * + * A user context will be created successfully even when the SDK is not fully configured yet. + * + * @param userId The user ID to be used for bucketing. + * @param attributes: A map of attribute names to current user attribute values. + * @return An OptimizelyUserContext associated with this OptimizelyClient. + */ + public OptimizelyUserContext createUserContext(@Nonnull String userId, + @Nonnull Map attributes) { + if (userId == null) { + logger.warn("The userId parameter must be nonnull."); + return null; + } + + return new OptimizelyUserContext(this, userId, attributes); + } + + public OptimizelyUserContext createUserContext(@Nonnull String userId) { + return new OptimizelyUserContext(this, userId); + } + + OptimizelyDecision decide(@Nonnull OptimizelyUserContext user, + @Nonnull String key, + @Nonnull List options) { + + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + return OptimizelyDecision.newErrorDecision(key, user, DecisionMessage.SDK_NOT_READY.reason()); + } + + FeatureFlag flag = projectConfig.getFeatureKeyMapping().get(key); + if (flag == null) { + return OptimizelyDecision.newErrorDecision(key, user, DecisionMessage.FLAG_KEY_INVALID.reason(key)); + } + + String userId = user.getUserId(); + Map attributes = user.getAttributes(); + Boolean decisionEventDispatched = false; + List allOptions = getAllOptions(options); + DecisionReasons decisionReasons = DefaultDecisionReasons.newInstance(allOptions); + + Map copiedAttributes = new HashMap<>(attributes); + FeatureDecision flagDecision = decisionService.getVariationForFeature( + flag, + userId, + copiedAttributes, + projectConfig, + allOptions, + decisionReasons); + + Boolean flagEnabled = false; + if (flagDecision.variation != null) { + if (flagDecision.variation.getFeatureEnabled()) { + flagEnabled = true; + } + } + logger.info("Feature \"{}\" is enabled for user \"{}\"? {}", key, userId, flagEnabled); + + Map variableMap = new HashMap<>(); + if (!allOptions.contains(OptimizelyDecideOption.EXCLUDE_VARIABLES)) { + variableMap = getDecisionVariableMap( + flag, + flagDecision.variation, + flagEnabled, + decisionReasons); + } + OptimizelyJSON optimizelyJSON = new OptimizelyJSON(variableMap); + + FeatureDecision.DecisionSource decisionSource = FeatureDecision.DecisionSource.ROLLOUT; + if (flagDecision.decisionSource != null) { + decisionSource = flagDecision.decisionSource; + } + + List reasonsToReport = decisionReasons.toReport(); + String variationKey = flagDecision.variation != null ? flagDecision.variation.getKey() : null; + // TODO: add ruleKey values when available later. use a copy of experimentKey until then. + // add to event metadata as well (currently set to experimentKey) + String ruleKey = flagDecision.experiment != null ? flagDecision.experiment.getKey() : null; + + if (!allOptions.contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT)) { + sendImpression( + projectConfig, + flagDecision.experiment, + userId, + copiedAttributes, + flagDecision.variation, + key, + decisionSource.toString(), + flagEnabled); + decisionEventDispatched = true; + } + + DecisionNotification decisionNotification = DecisionNotification.newFlagDecisionNotificationBuilder() + .withUserId(userId) + .withAttributes(copiedAttributes) + .withFlagKey(key) + .withEnabled(flagEnabled) + .withVariables(variableMap) + .withVariationKey(variationKey) + .withRuleKey(ruleKey) + .withReasons(reasonsToReport) + .withDecisionEventDispatched(decisionEventDispatched) + .build(); + notificationCenter.send(decisionNotification); + + return new OptimizelyDecision( + null, + false, + optimizelyJSON, + ruleKey, + key, + user, + reasonsToReport); + } + + Map decideForKeys(@Nonnull OptimizelyUserContext user, + @Nonnull List keys, + @Nonnull List options) { + Map decisionMap = new HashMap<>(); + + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing isFeatureEnabled call."); + return decisionMap; + } + + if (keys.isEmpty()) return decisionMap; + + List allOptions = getAllOptions(options); + + for (String key : keys) { + OptimizelyDecision decision = decide(user, key, options); + if (!allOptions.contains(OptimizelyDecideOption.ENABLED_FLAGS_ONLY) || decision.getEnabled()) { + decisionMap.put(key, decision); + } + } + + return decisionMap; + } + + Map decideAll(@Nonnull OptimizelyUserContext user, + @Nonnull List options) { + Map decisionMap = new HashMap<>(); + + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing isFeatureEnabled call."); + return decisionMap; + } + + List allFlags = projectConfig.getFeatureFlags(); + List allFlagKeys = new ArrayList<>(); + for (int i = 0; i < allFlags.size(); i++) allFlagKeys.add(allFlags.get(i).getKey()); + + return decideForKeys(user, allFlagKeys, options); + } + + private List getAllOptions(List options) { + List copiedOptions = new ArrayList(defaultDecideOptions); + if (options != null) { + copiedOptions.addAll(options); + } + return copiedOptions; + } + + private Map getDecisionVariableMap(@Nonnull FeatureFlag flag, + @Nonnull Variation variation, + @Nonnull Boolean featureEnabled, + @Nonnull DecisionReasons decisionReasons) { + Map valuesMap = new HashMap(); + for (FeatureVariable variable : flag.getVariables()) { + String value = variable.getDefaultValue(); + if (featureEnabled) { + FeatureVariableUsageInstance instance = variation.getVariableIdToFeatureVariableUsageInstanceMap().get(variable.getId()); + if (instance != null) { + value = instance.getValue(); + } + } + + Object convertedValue = convertStringToType(value, variable.getType()); + if (convertedValue == null) { + decisionReasons.addError(DecisionMessage.VARIABLE_VALUE_INVALID.reason(variable.getKey())); + } else if (convertedValue instanceof OptimizelyJSON) { + convertedValue = ((OptimizelyJSON) convertedValue).toMap(); + } + + valuesMap.put(variable.getKey(), convertedValue); + } + + return valuesMap; + } + /** * Helper method which makes separate copy of attributesMap variable and returns it * @@ -1233,6 +1436,7 @@ public static class Builder { private OptimizelyConfigManager optimizelyConfigManager; private UserProfileService userProfileService; private NotificationCenter notificationCenter; + private List defaultDecideOptions; // For backwards compatibility private AtomicProjectConfigManager fallbackConfigManager = new AtomicProjectConfigManager(); @@ -1304,6 +1508,11 @@ public Builder withDatafile(String datafile) { return this; } + public Builder withDefaultDecideOptions(List defaultDecideOtions) { + this.defaultDecideOptions = defaultDecideOtions; + return this; + } + // Helper functions for making testing easier protected Builder withBucketing(Bucketer bucketer) { this.bucketer = bucketer; @@ -1372,7 +1581,13 @@ public Optimizely build() { eventProcessor = new ForwardingEventProcessor(eventHandler, notificationCenter); } - return new Optimizely(eventHandler, eventProcessor, errorHandler, decisionService, userProfileService, projectConfigManager, optimizelyConfigManager, notificationCenter); + if (defaultDecideOptions != null) { + defaultDecideOptions = Collections.unmodifiableList(defaultDecideOptions); + } else { + defaultDecideOptions = Collections.emptyList(); + } + + return new Optimizely(eventHandler, eventProcessor, errorHandler, decisionService, userProfileService, projectConfigManager, optimizelyConfigManager, notificationCenter, defaultDecideOptions); } } } diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java new file mode 100644 index 000000000..300d50d6f --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -0,0 +1,199 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; +import com.optimizely.ab.optimizelydecision.OptimizelyDecision; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class OptimizelyUserContext { + @Nonnull + private final String userId; + + @Nonnull + private final Map attributes; + + @Nonnull + private final Optimizely optimizely; + + private static final Logger logger = LoggerFactory.getLogger(OptimizelyUserContext.class); + + public OptimizelyUserContext(@Nonnull Optimizely optimizely, + @Nonnull String userId, + @Nonnull Map attributes) { + this.optimizely = optimizely; + this.userId = userId; + if (attributes != null) { + this.attributes = Collections.synchronizedMap(new HashMap<>(attributes)); + } else { + this.attributes = Collections.synchronizedMap(new HashMap<>()); + } + } + + public OptimizelyUserContext(@Nonnull Optimizely optimizely, @Nonnull String userId) { + this(optimizely, userId, Collections.EMPTY_MAP); + } + + public String getUserId() { + return userId; + } + + public Map getAttributes() { + return new HashMap(attributes); + } + + public Optimizely getOptimizely() { + return optimizely; + } + + /** + * Set an attribute for a given key. + * + * @param key An attribute key + * @param value An attribute value + */ + public void setAttribute(@Nonnull String key, @Nullable Object value) { + attributes.put(key, value); + } + + /** + * Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context, which contains all data required to deliver the flag. + *
    + *
  • If the SDK finds an error, it’ll return a decision with null for variationKey. The decision will include an error message in reasons. + *
+ * @param key A flag key for which a decision will be made. + * @param options A list of options for decision-making. + * @return A decision result. + */ + public OptimizelyDecision decide(@Nonnull String key, + @Nonnull List options) { + return optimizely.decide(this, key, options); + } + + /** + * Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context, which contains all data required to deliver the flag. + * + * @param key A flag key for which a decision will be made. + * @return A decision result. + */ + public OptimizelyDecision decide(@Nonnull String key) { + return decide(key, Collections.emptyList()); + } + + /** + * Returns a key-map of decision results ({@link OptimizelyDecision}) for multiple flag keys and a user context. + *
    + *
  • If the SDK finds an error for a key, the response will include a decision for the key showing reasons for the error. + *
  • The SDK will always return key-mapped decisions. When it can not process requests, it’ll return an empty map after logging the errors. + *
+ * @param keys A list of flag keys for which decisions will be made. + * @param options A list of options for decision-making. + * @return All decision results mapped by flag keys. + */ + public Map decideForKeys(@Nonnull List keys, + @Nonnull List options) { + return optimizely.decideForKeys(this, keys, options); + } + + /** + * Returns a key-map of decision results for multiple flag keys and a user context. + * + * @param keys A list of flag keys for which decisions will be made. + * @return All decision results mapped by flag keys. + */ + public Map decideForKeys(@Nonnull List keys) { + return decideForKeys(keys, Collections.emptyList()); + } + + /** + * Returns a key-map of decision results ({@link OptimizelyDecision}) for all active flag keys. + * + * @param options A list of options for decision-making. + * @return All decision results mapped by flag keys. + */ + public Map decideAll(@Nonnull List options) { + return optimizely.decideAll(this, options); + } + + /** + * Returns a key-map of decision results ({@link OptimizelyDecision}) for all active flag keys. + * + * @return A dictionary of all decision results, mapped by flag keys. + */ + public Map decideAll() { + return decideAll(Collections.emptyList()); + } + + /** + * Track an event. + * + * @param eventName The event name. + * @param eventTags A map of event tag names to event tag values. + * @throws UnknownEventTypeException + */ + public void trackEvent(@Nonnull String eventName, + @Nonnull Map eventTags) throws UnknownEventTypeException { + optimizely.track(eventName, userId, attributes, eventTags); + } + + /** + * Track an event. + * + * @param eventName The event name. + * @throws UnknownEventTypeException + */ + public void trackEvent(@Nonnull String eventName) throws UnknownEventTypeException { + trackEvent(eventName, Collections.emptyMap()); + } + + // Utils + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) return false; + if (obj == this) return true; + OptimizelyUserContext userContext = (OptimizelyUserContext) obj; + return userId.equals(userContext.getUserId()) && + attributes.equals(userContext.getAttributes()) && + optimizely.equals(userContext.getOptimizely()); + } + + @Override + public int hashCode() { + int hash = userId.hashCode(); + hash = 31 * hash + attributes.hashCode(); + hash = 31 * hash + optimizely.hashCode(); + return hash; + } + + @Override + public String toString() { + return "OptimizelyUserContext {" + + "userId='" + userId + '\'' + + ", attributes='" + attributes + '\'' + + '}'; + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java b/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java index c9295c6bb..0429dd585 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java @@ -18,11 +18,9 @@ import com.optimizely.ab.annotations.VisibleForTesting; import com.optimizely.ab.bucketing.internal.MurmurHash3; -import com.optimizely.ab.config.Experiment; -import com.optimizely.ab.config.Group; -import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.config.TrafficAllocation; -import com.optimizely.ab.config.Variation; +import com.optimizely.ab.config.*; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -71,7 +69,8 @@ private String bucketToEntity(int bucketValue, List trafficAl private Experiment bucketToExperiment(@Nonnull Group group, @Nonnull String bucketingId, - @Nonnull ProjectConfig projectConfig) { + @Nonnull ProjectConfig projectConfig, + @Nonnull DecisionReasons reasons) { // "salt" the bucket id using the group id String bucketKey = bucketingId + group.getId(); @@ -91,7 +90,8 @@ private Experiment bucketToExperiment(@Nonnull Group group, } private Variation bucketToVariation(@Nonnull Experiment experiment, - @Nonnull String bucketingId) { + @Nonnull String bucketingId, + @Nonnull DecisionReasons reasons) { // "salt" the bucket id using the experiment id String experimentId = experiment.getId(); String experimentKey = experiment.getKey(); @@ -107,14 +107,16 @@ private Variation bucketToVariation(@Nonnull Experiment experiment, if (bucketedVariationId != null) { Variation bucketedVariation = experiment.getVariationIdToVariationMap().get(bucketedVariationId); String variationKey = bucketedVariation.getKey(); - logger.info("User with bucketingId \"{}\" is in variation \"{}\" of experiment \"{}\".", bucketingId, variationKey, + String message = reasons.addInfo("User with bucketingId \"%s\" is in variation \"%s\" of experiment \"%s\".", bucketingId, variationKey, experimentKey); + logger.info(message); return bucketedVariation; } // user was not bucketed to a variation - logger.info("User with bucketingId \"{}\" is not in any variation of experiment \"{}\".", bucketingId, experimentKey); + String message = reasons.addInfo("User with bucketingId \"%s\" is not in any variation of experiment \"%s\".", bucketingId, experimentKey); + logger.info(message); return null; } @@ -123,12 +125,15 @@ private Variation bucketToVariation(@Nonnull Experiment experiment, * * @param experiment The Experiment in which the user is to be bucketed. * @param bucketingId string A customer-assigned value used to create the key for the murmur hash. + * @param projectConfig The current projectConfig + * @param reasons Decision log messages * @return Variation the user is bucketed into or null. */ @Nullable public Variation bucket(@Nonnull Experiment experiment, @Nonnull String bucketingId, - @Nonnull ProjectConfig projectConfig) { + @Nonnull ProjectConfig projectConfig, + @Nonnull DecisionReasons reasons) { // ---------- Bucket User ---------- String groupId = experiment.getGroupId(); // check whether the experiment belongs to a group @@ -136,9 +141,10 @@ public Variation bucket(@Nonnull Experiment experiment, Group experimentGroup = projectConfig.getGroupIdMapping().get(groupId); // bucket to an experiment only if group entities are to be mutually exclusive if (experimentGroup.getPolicy().equals(Group.RANDOM_POLICY)) { - Experiment bucketedExperiment = bucketToExperiment(experimentGroup, bucketingId, projectConfig); + Experiment bucketedExperiment = bucketToExperiment(experimentGroup, bucketingId, projectConfig, reasons); if (bucketedExperiment == null) { - logger.info("User with bucketingId \"{}\" is not in any experiment of group {}.", bucketingId, experimentGroup.getId()); + String message = reasons.addInfo("User with bucketingId \"%s\" is not in any experiment of group %s.", bucketingId, experimentGroup.getId()); + logger.info(message); return null; } else { @@ -146,19 +152,27 @@ public Variation bucket(@Nonnull Experiment experiment, // if the experiment a user is bucketed in within a group isn't the same as the experiment provided, // don't perform further bucketing within the experiment if (!bucketedExperiment.getId().equals(experiment.getId())) { - logger.info("User with bucketingId \"{}\" is not in experiment \"{}\" of group {}.", bucketingId, experiment.getKey(), + String message = reasons.addInfo("User with bucketingId \"%s\" is not in experiment \"%s\" of group %s.", bucketingId, experiment.getKey(), experimentGroup.getId()); + logger.info(message); return null; } - logger.info("User with bucketingId \"{}\" is in experiment \"{}\" of group {}.", bucketingId, experiment.getKey(), + String message = reasons.addInfo("User with bucketingId \"%s\" is in experiment \"%s\" of group %s.", bucketingId, experiment.getKey(), experimentGroup.getId()); + logger.info(message); } } - return bucketToVariation(experiment, bucketingId); + return bucketToVariation(experiment, bucketingId, reasons); } + @Nullable + public Variation bucket(@Nonnull Experiment experiment, + @Nonnull String bucketingId, + @Nonnull ProjectConfig projectConfig) { + return bucket(experiment, bucketingId, projectConfig, DefaultDecisionReasons.newInstance()); + } //======== Helper methods ========// @@ -175,5 +189,4 @@ int generateBucketValue(int hashCode) { return (int) Math.floor(MAX_TRAFFIC_VALUE * ratio); } - } diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java index 13091472d..588f6eefb 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java @@ -18,19 +18,22 @@ import com.optimizely.ab.OptimizelyRuntimeException; import com.optimizely.ab.config.*; import com.optimizely.ab.error.ErrorHandler; -import com.optimizely.ab.internal.ExperimentUtils; import com.optimizely.ab.internal.ControlAttribute; - +import com.optimizely.ab.internal.ExperimentUtils; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; - import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.EXPERIMENT; import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.RULE; @@ -81,24 +84,28 @@ public DecisionService(@Nonnull Bucketer bucketer, * @param experiment The Experiment the user will be bucketed into. * @param userId The userId of the user. * @param filteredAttributes The user's attributes. This should be filtered to just attributes in the Datafile. + * @param projectConfig The current projectConfig + * @param options An array of decision options + * @param reasons Decision log messages * @return The {@link Variation} the user is allocated into. */ @Nullable public Variation getVariation(@Nonnull Experiment experiment, @Nonnull String userId, @Nonnull Map filteredAttributes, - @Nonnull ProjectConfig projectConfig) { - - if (!ExperimentUtils.isExperimentActive(experiment)) { + @Nonnull ProjectConfig projectConfig, + @Nonnull List options, + @Nonnull DecisionReasons reasons) { + if (!ExperimentUtils.isExperimentActive(experiment, reasons)) { return null; } // look for forced bucketing first. - Variation variation = getForcedVariation(experiment, userId); + Variation variation = getForcedVariation(experiment, userId, reasons); // check for whitelisting if (variation == null) { - variation = getWhitelistedVariation(experiment, userId); + variation = getWhitelistedVariation(experiment, userId, reasons); } if (variation != null) { @@ -106,42 +113,46 @@ public Variation getVariation(@Nonnull Experiment experiment, } // fetch the user profile map from the user profile service + boolean ignoreUPS = options.contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE); UserProfile userProfile = null; - if (userProfileService != null) { + if (userProfileService != null && !ignoreUPS) { try { Map userProfileMap = userProfileService.lookup(userId); if (userProfileMap == null) { - logger.info("We were unable to get a user profile map from the UserProfileService."); + String message = reasons.addInfo("We were unable to get a user profile map from the UserProfileService."); + logger.info(message); } else if (UserProfileUtils.isValidUserProfileMap(userProfileMap)) { userProfile = UserProfileUtils.convertMapToUserProfile(userProfileMap); } else { - logger.warn("The UserProfileService returned an invalid map."); + String message = reasons.addInfo("The UserProfileService returned an invalid map."); + logger.warn(message); } } catch (Exception exception) { - logger.error(exception.getMessage()); + String message = reasons.addInfo(exception.getMessage()); + logger.error(message); errorHandler.handleError(new OptimizelyRuntimeException(exception)); } - } - // check if user exists in user profile - if (userProfile != null) { - variation = getStoredVariation(experiment, userProfile, projectConfig); - // return the stored variation if it exists - if (variation != null) { - return variation; + // check if user exists in user profile + if (userProfile != null) { + variation = getStoredVariation(experiment, userProfile, projectConfig, reasons); + // return the stored variation if it exists + if (variation != null) { + return variation; + } + } else { // if we could not find a user profile, make a new one + userProfile = new UserProfile(userId, new HashMap()); } - } else { // if we could not find a user profile, make a new one - userProfile = new UserProfile(userId, new HashMap()); } - if (ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, experiment, filteredAttributes, EXPERIMENT, experiment.getKey())) { - String bucketingId = getBucketingId(userId, filteredAttributes); - variation = bucketer.bucket(experiment, bucketingId, projectConfig); + if (ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, experiment, filteredAttributes, EXPERIMENT, experiment.getKey(), reasons)) { + String bucketingId = getBucketingId(userId, filteredAttributes, reasons); + variation = bucketer.bucket(experiment, bucketingId, projectConfig, reasons); if (variation != null) { - if (userProfileService != null) { - saveVariation(experiment, variation, userProfile); + if (userProfileService != null && !ignoreUPS) { + saveVariation(experiment, variation, userProfile, reasons); } else { logger.debug("This decision will not be saved since the UserProfileService is null."); } @@ -150,46 +161,72 @@ public Variation getVariation(@Nonnull Experiment experiment, return variation; } - logger.info("User \"{}\" does not meet conditions to be in experiment \"{}\".", userId, experiment.getKey()); + String message = reasons.addInfo("User \"%s\" does not meet conditions to be in experiment \"%s\".", userId, experiment.getKey()); + logger.info(message); return null; } + @Nullable + public Variation getVariation(@Nonnull Experiment experiment, + @Nonnull String userId, + @Nonnull Map filteredAttributes, + @Nonnull ProjectConfig projectConfig) { + return getVariation(experiment, userId, filteredAttributes, projectConfig, Collections.emptyList(), DefaultDecisionReasons.newInstance()); + } + /** * Get the variation the user is bucketed into for the FeatureFlag * * @param featureFlag The feature flag the user wants to access. * @param userId User Identifier * @param filteredAttributes A map of filtered attributes. + * @param projectConfig The current projectConfig + * @param options An array of decision options + * @param reasons Decision log messages * @return {@link FeatureDecision} */ @Nonnull public FeatureDecision getVariationForFeature(@Nonnull FeatureFlag featureFlag, @Nonnull String userId, @Nonnull Map filteredAttributes, - @Nonnull ProjectConfig projectConfig) { + @Nonnull ProjectConfig projectConfig, + @Nonnull List options, + @Nonnull DecisionReasons reasons) { if (!featureFlag.getExperimentIds().isEmpty()) { for (String experimentId : featureFlag.getExperimentIds()) { Experiment experiment = projectConfig.getExperimentIdMapping().get(experimentId); - Variation variation = getVariation(experiment, userId, filteredAttributes, projectConfig); + Variation variation = getVariation(experiment, userId, filteredAttributes, projectConfig, options, reasons); if (variation != null) { return new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.FEATURE_TEST); } } } else { - logger.info("The feature flag \"{}\" is not used in any experiments.", featureFlag.getKey()); + String message = reasons.addInfo("The feature flag \"%s\" is not used in any experiments.", featureFlag.getKey()); + logger.info(message); } - FeatureDecision featureDecision = getVariationForFeatureInRollout(featureFlag, userId, filteredAttributes, projectConfig); + FeatureDecision featureDecision = getVariationForFeatureInRollout(featureFlag, userId, filteredAttributes, projectConfig, reasons); if (featureDecision.variation == null) { - logger.info("The user \"{}\" was not bucketed into a rollout for feature flag \"{}\".", + String message = reasons.addInfo("The user \"%s\" was not bucketed into a rollout for feature flag \"%s\".", userId, featureFlag.getKey()); + logger.info(message); } else { - logger.info("The user \"{}\" was bucketed into a rollout for feature flag \"{}\".", + String message = reasons.addInfo("The user \"%s\" was bucketed into a rollout for feature flag \"%s\".", userId, featureFlag.getKey()); + logger.info(message); } return featureDecision; } + @Nonnull + public FeatureDecision getVariationForFeature(@Nonnull FeatureFlag featureFlag, + @Nonnull String userId, + @Nonnull Map filteredAttributes, + @Nonnull ProjectConfig projectConfig) { + + return getVariationForFeature(featureFlag, userId, filteredAttributes, projectConfig,Collections.emptyList(), DefaultDecisionReasons.newInstance()); + } + /** * Try to bucket the user into a rollout rule. * Evaluate the user for rules in priority order by seeing if the user satisfies the audience. @@ -198,49 +235,56 @@ public FeatureDecision getVariationForFeature(@Nonnull FeatureFlag featureFlag, * @param featureFlag The feature flag the user wants to access. * @param userId User Identifier * @param filteredAttributes A map of filtered attributes. + * @param projectConfig The current projectConfig + * @param reasons Decision log messages * @return {@link FeatureDecision} */ @Nonnull FeatureDecision getVariationForFeatureInRollout(@Nonnull FeatureFlag featureFlag, @Nonnull String userId, @Nonnull Map filteredAttributes, - @Nonnull ProjectConfig projectConfig) { + @Nonnull ProjectConfig projectConfig, + @Nonnull DecisionReasons reasons) { // use rollout to get variation for feature if (featureFlag.getRolloutId().isEmpty()) { - logger.info("The feature flag \"{}\" is not used in a rollout.", featureFlag.getKey()); + String message = reasons.addInfo("The feature flag \"%s\" is not used in a rollout.", featureFlag.getKey()); + logger.info(message); return new FeatureDecision(null, null, null); } Rollout rollout = projectConfig.getRolloutIdMapping().get(featureFlag.getRolloutId()); if (rollout == null) { - logger.error("The rollout with id \"{}\" was not found in the datafile for feature flag \"{}\".", + String message = reasons.addInfo("The rollout with id \"%s\" was not found in the datafile for feature flag \"%s\".", featureFlag.getRolloutId(), featureFlag.getKey()); + logger.error(message); return new FeatureDecision(null, null, null); } // for all rules before the everyone else rule int rolloutRulesLength = rollout.getExperiments().size(); - String bucketingId = getBucketingId(userId, filteredAttributes); + String bucketingId = getBucketingId(userId, filteredAttributes, reasons); Variation variation; for (int i = 0; i < rolloutRulesLength - 1; i++) { Experiment rolloutRule = rollout.getExperiments().get(i); - if (ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, rolloutRule, filteredAttributes, RULE, Integer.toString(i + 1))) { - variation = bucketer.bucket(rolloutRule, bucketingId, projectConfig); + if (ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, rolloutRule, filteredAttributes, RULE, Integer.toString(i + 1), reasons)) { + variation = bucketer.bucket(rolloutRule, bucketingId, projectConfig, reasons); if (variation == null) { break; } return new FeatureDecision(rolloutRule, variation, FeatureDecision.DecisionSource.ROLLOUT); } else { - logger.debug("User \"{}\" does not meet conditions for targeting rule \"{}\".", userId, i + 1); + String message = reasons.addInfo("User \"%s\" does not meet conditions for targeting rule \"%d\".", userId, i + 1); + logger.debug(message); } } // get last rule which is the fall back rule Experiment finalRule = rollout.getExperiments().get(rolloutRulesLength - 1); - if (ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, finalRule, filteredAttributes, RULE, "Everyone Else")) { - variation = bucketer.bucket(finalRule, bucketingId, projectConfig); + if (ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, finalRule, filteredAttributes, RULE, "Everyone Else", reasons)) { + variation = bucketer.bucket(finalRule, bucketingId, projectConfig, reasons); if (variation != null) { - logger.debug("User \"{}\" meets conditions for targeting rule \"Everyone Else\".", userId); + String message = reasons.addInfo("User \"%s\" meets conditions for targeting rule \"Everyone Else\".", userId); + logger.debug(message); return new FeatureDecision(finalRule, variation, FeatureDecision.DecisionSource.ROLLOUT); } @@ -248,44 +292,68 @@ FeatureDecision getVariationForFeatureInRollout(@Nonnull FeatureFlag featureFlag return new FeatureDecision(null, null, null); } + @Nonnull + FeatureDecision getVariationForFeatureInRollout(@Nonnull FeatureFlag featureFlag, + @Nonnull String userId, + @Nonnull Map filteredAttributes, + @Nonnull ProjectConfig projectConfig) { + return getVariationForFeatureInRollout(featureFlag, userId, filteredAttributes, projectConfig, DefaultDecisionReasons.newInstance()); + } + /** * Get the variation the user has been whitelisted into. * * @param experiment {@link Experiment} in which user is to be bucketed. * @param userId User Identifier + * @param reasons Decision log messages * @return null if the user is not whitelisted into any variation * {@link Variation} the user is bucketed into if the user has a specified whitelisted variation. */ @Nullable - Variation getWhitelistedVariation(@Nonnull Experiment experiment, @Nonnull String userId) { + Variation getWhitelistedVariation(@Nonnull Experiment experiment, + @Nonnull String userId, + @Nonnull DecisionReasons reasons) { // if a user has a forced variation mapping, return the respective variation Map userIdToVariationKeyMap = experiment.getUserIdToVariationKeyMap(); if (userIdToVariationKeyMap.containsKey(userId)) { String forcedVariationKey = userIdToVariationKeyMap.get(userId); Variation forcedVariation = experiment.getVariationKeyToVariationMap().get(forcedVariationKey); if (forcedVariation != null) { - logger.info("User \"{}\" is forced in variation \"{}\".", userId, forcedVariationKey); + String message = reasons.addInfo("User \"%s\" is forced in variation \"%s\".", userId, forcedVariationKey); + logger.info(message); } else { - logger.error("Variation \"{}\" is not in the datafile. Not activating user \"{}\".", + String message = reasons.addInfo("Variation \"%s\" is not in the datafile. Not activating user \"%s\".", forcedVariationKey, userId); + logger.error(message); } return forcedVariation; } return null; } + @Nullable + Variation getWhitelistedVariation(@Nonnull Experiment experiment, + @Nonnull String userId) { + + return getWhitelistedVariation(experiment, userId, DefaultDecisionReasons.newInstance()); + } + /** * Get the {@link Variation} that has been stored for the user in the {@link UserProfileService} implementation. * * @param experiment {@link Experiment} in which the user was bucketed. * @param userProfile {@link UserProfile} of the user. + * @param projectConfig The current projectConfig + * @param reasons Decision log messages * @return null if the {@link UserProfileService} implementation is null or the user was not previously bucketed. * else return the {@link Variation} the user was previously bucketed into. */ @Nullable Variation getStoredVariation(@Nonnull Experiment experiment, @Nonnull UserProfile userProfile, - @Nonnull ProjectConfig projectConfig) { + @Nonnull ProjectConfig projectConfig, + @Nonnull DecisionReasons reasons) { + // ---------- Check User Profile for Sticky Bucketing ---------- // If a user profile instance is present then check it for a saved variation String experimentId = experiment.getId(); @@ -299,35 +367,45 @@ Variation getStoredVariation(@Nonnull Experiment experiment, .getVariationIdToVariationMap() .get(variationId); if (savedVariation != null) { - logger.info("Returning previously activated variation \"{}\" of experiment \"{}\" " + - "for user \"{}\" from user profile.", + String message = reasons.addInfo("Returning previously activated variation \"%s\" of experiment \"%s\" for user \"%s\" from user profile.", savedVariation.getKey(), experimentKey, userProfile.userId); + logger.info(message); // A variation is stored for this combined bucket id return savedVariation; } else { - logger.info("User \"{}\" was previously bucketed into variation with ID \"{}\" for experiment \"{}\", " + - "but no matching variation was found for that user. We will re-bucket the user.", + String message = reasons.addInfo("User \"%s\" was previously bucketed into variation with ID \"%s\" for experiment \"%s\", but no matching variation was found for that user. We will re-bucket the user.", userProfile.userId, variationId, experimentKey); + logger.info(message); return null; } } else { - logger.info("No previously activated variation of experiment \"{}\" " + - "for user \"{}\" found in user profile.", + String message = reasons.addInfo("No previously activated variation of experiment \"%s\" for user \"%s\" found in user profile.", experimentKey, userProfile.userId); + logger.info(message); return null; } } + @Nullable + Variation getStoredVariation(@Nonnull Experiment experiment, + @Nonnull UserProfile userProfile, + @Nonnull ProjectConfig projectConfig) { + return getStoredVariation(experiment, userProfile, projectConfig, DefaultDecisionReasons.newInstance()); + } + /** * Save a {@link Variation} of an {@link Experiment} for a user in the {@link UserProfileService}. * * @param experiment The experiment the user was buck * @param variation The Variation to save. * @param userProfile A {@link UserProfile} instance of the user information. + * @param reasons Decision log messages */ void saveVariation(@Nonnull Experiment experiment, @Nonnull Variation variation, - @Nonnull UserProfile userProfile) { + @Nonnull UserProfile userProfile, + @Nonnull DecisionReasons reasons) { + // only save if the user has implemented a user profile service if (userProfileService != null) { String experimentId = experiment.getId(); @@ -353,28 +431,42 @@ void saveVariation(@Nonnull Experiment experiment, } } + void saveVariation(@Nonnull Experiment experiment, + @Nonnull Variation variation, + @Nonnull UserProfile userProfile) { + saveVariation(experiment, variation, userProfile, DefaultDecisionReasons.newInstance()); + } + /** * Get the bucketingId of a user if a bucketingId exists in attributes, or else default to userId. * * @param userId The userId of the user. * @param filteredAttributes The user's attributes. This should be filtered to just attributes in the Datafile. + * @param reasons Decision log messages * @return bucketingId if it is a String type in attributes. * else return userId */ String getBucketingId(@Nonnull String userId, - @Nonnull Map filteredAttributes) { + @Nonnull Map filteredAttributes, + @Nonnull DecisionReasons reasons) { String bucketingId = userId; if (filteredAttributes != null && filteredAttributes.containsKey(ControlAttribute.BUCKETING_ATTRIBUTE.toString())) { if (String.class.isInstance(filteredAttributes.get(ControlAttribute.BUCKETING_ATTRIBUTE.toString()))) { bucketingId = (String) filteredAttributes.get(ControlAttribute.BUCKETING_ATTRIBUTE.toString()); logger.debug("BucketingId is valid: \"{}\"", bucketingId); } else { - logger.warn("BucketingID attribute is not a string. Defaulted to userId"); + String message = reasons.addInfo("BucketingID attribute is not a string. Defaulted to userId"); + logger.warn(message); } } return bucketingId; } + String getBucketingId(@Nonnull String userId, + @Nonnull Map filteredAttributes) { + return getBucketingId(userId, filteredAttributes, DefaultDecisionReasons.newInstance()); + } + public ConcurrentHashMap> getForcedVariationMapping() { return forcedVariationMapping; } @@ -393,8 +485,6 @@ public ConcurrentHashMap> getForcedVar public boolean setForcedVariation(@Nonnull Experiment experiment, @Nonnull String userId, @Nullable String variationKey) { - - Variation variation = null; // keep in mind that you can pass in a variationKey that is null if you want to @@ -455,13 +545,14 @@ public boolean setForcedVariation(@Nonnull Experiment experiment, * * @param experiment The experiment forced. * @param userId The user ID to be used for bucketing. + * @param reasons Decision log messages * @return The variation the user was bucketed into. This value can be null if the * forced variation fails. */ @Nullable public Variation getForcedVariation(@Nonnull Experiment experiment, - @Nonnull String userId) { - + @Nonnull String userId, + @Nonnull DecisionReasons reasons) { // if the user id is invalid, return false. if (!validateUserId(userId)) { return null; @@ -473,8 +564,9 @@ public Variation getForcedVariation(@Nonnull Experiment experiment, if (variationId != null) { Variation variation = experiment.getVariationIdToVariationMap().get(variationId); if (variation != null) { - logger.debug("Variation \"{}\" is mapped to experiment \"{}\" and user \"{}\" in the forced variation map", + String message = reasons.addInfo("Variation \"%s\" is mapped to experiment \"%s\" and user \"%s\" in the forced variation map", variation.getKey(), experiment.getKey(), userId); + logger.debug(message); return variation; } } else { @@ -484,6 +576,12 @@ public Variation getForcedVariation(@Nonnull Experiment experiment, return null; } + @Nullable + public Variation getForcedVariation(@Nonnull Experiment experiment, + @Nonnull String userId) { + return getForcedVariation(experiment, userId, DefaultDecisionReasons.newInstance()); + } + /** * Helper function to check that the provided userId is valid * @@ -498,4 +596,5 @@ private boolean validateUserId(String userId) { return true; } + } 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 8b458d059..20c15e95d 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,10 +17,10 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.optimizelydecision.DecisionReasons; import javax.annotation.Nonnull; import javax.annotation.Nullable; -import javax.annotation.concurrent.Immutable; import java.util.List; import java.util.Map; @@ -40,7 +40,9 @@ public List getConditions() { } @Nullable - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, + Map attributes, + DecisionReasons reasons) { if (conditions == null) return null; boolean foundNull = false; // According to the matrix where: @@ -51,7 +53,7 @@ public Boolean evaluate(ProjectConfig config, Map attributes) { // true and true is true // null and null is null for (Condition condition : conditions) { - Boolean conditionEval = condition.evaluate(config, attributes); + Boolean conditionEval = condition.evaluate(config, attributes, reasons); if (conditionEval == null) { foundNull = true; } else if (!conditionEval) { // false with nulls or trues is false. diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java index 57a4e5bec..fb076a90d 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java @@ -18,14 +18,12 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.internal.InvalidAudienceCondition; +import com.optimizely.ab.optimizelydecision.DecisionReasons; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nullable; -import javax.annotation.concurrent.Immutable; import java.util.Map; import java.util.Objects; @@ -66,16 +64,19 @@ public String getAudienceId() { @Nullable @Override - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, + Map attributes, + DecisionReasons reasons) { if (config != null) { audience = config.getAudienceIdMapping().get(audienceId); } if (audience == null) { - logger.error("Audience {} could not be found.", audienceId); + String message = reasons.addInfo("Audience %s could not be found.", audienceId); + logger.error(message); return null; } logger.debug("Starting to evaluate audience \"{}\" with conditions: {}.", audience.getId(), audience.getConditions()); - Boolean result = audience.getConditions().evaluate(config, attributes); + Boolean result = audience.getConditions().evaluate(config, attributes, reasons); logger.debug("Audience \"{}\" evaluated to {}.", audience.getId(), result); return result; } 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 772d2b03e..4d108214c 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 @@ -17,6 +17,7 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.optimizelydecision.DecisionReasons; import javax.annotation.Nullable; import java.util.Map; @@ -27,5 +28,8 @@ public interface Condition { @Nullable - Boolean evaluate(ProjectConfig config, Map attributes); + Boolean evaluate(ProjectConfig config, + Map attributes, + DecisionReasons reasons); + } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java index 8f8aedeae..b5978d200 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java @@ -16,6 +16,7 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.optimizelydecision.DecisionReasons; import javax.annotation.Nullable; import java.util.Map; @@ -23,7 +24,9 @@ public class EmptyCondition implements Condition { @Nullable @Override - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, + Map attributes, + DecisionReasons reasons) { return true; } 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 b7f45f2ac..8a523bb8d 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 @@ -17,11 +17,11 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; -import javax.annotation.Nonnull; - import java.util.Map; /** @@ -41,9 +41,11 @@ public Condition getCondition() { } @Nullable - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, + Map attributes, + DecisionReasons reasons) { - Boolean conditionEval = condition == null ? null : condition.evaluate(config, attributes); + Boolean conditionEval = condition == null ? null : condition.evaluate(config, attributes, reasons); return (conditionEval == null ? null : !conditionEval); } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java index fcf5100db..ef76d92ad 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java @@ -16,6 +16,7 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.optimizelydecision.DecisionReasons; import javax.annotation.Nullable; import java.util.Map; @@ -23,7 +24,10 @@ public class NullCondition implements Condition { @Nullable @Override - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, + Map attributes, + DecisionReasons reasons) { return null; } + } 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 70572a9a9..b2c2f0afe 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 com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.optimizelydecision.DecisionReasons; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -45,11 +46,13 @@ public List getConditions() { // false or false is false // null or null is null @Nullable - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, + Map attributes, + DecisionReasons reasons) { if (conditions == null) return null; boolean foundNull = false; for (Condition condition : conditions) { - Boolean conditionEval = condition.evaluate(config, attributes); + Boolean conditionEval = condition.evaluate(config, attributes, reasons); if (conditionEval == null) { // true with falses and nulls is still true foundNull = true; } else if (conditionEval) { 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 277f2f184..152bb7048 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 @@ -21,6 +21,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.optimizely.ab.config.ProjectConfig; import com.optimizely.ab.config.audience.match.*; +import com.optimizely.ab.optimizelydecision.DecisionReasons; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -71,7 +72,9 @@ public Object getValue() { } @Nullable - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, + Map attributes, + DecisionReasons reasons) { if (attributes == null) { attributes = Collections.emptyMap(); } @@ -79,7 +82,8 @@ public Boolean evaluate(ProjectConfig config, Map attributes) { Object userAttributeValue = attributes.get(name); if (!"custom_attribute".equals(type)) { - logger.warn("Audience condition \"{}\" uses an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK.", this); + String message = reasons.addInfo("Audience condition \"%s\" uses an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK.", this); + logger.warn(message); return null; // unknown type } // check user attribute value is equal @@ -94,26 +98,27 @@ public Boolean evaluate(ProjectConfig config, Map attributes) { } catch(UnknownValueTypeException e) { if (!attributes.containsKey(name)) { //Missing attribute value - logger.debug("Audience condition \"{}\" evaluated to UNKNOWN because no value was passed for user attribute \"{}\"", this, name); + String message = reasons.addInfo("Audience condition \"%s\" evaluated to UNKNOWN because no value was passed for user attribute \"%s\"", this, name); + logger.debug(message); } else { //if attribute value is not valid if (userAttributeValue != null) { - logger.warn( - "Audience condition \"{}\" evaluated to UNKNOWN because a value of type \"{}\" was passed for user attribute \"{}\"", + String message = reasons.addInfo("Audience condition \"%s\" evaluated to UNKNOWN because a value of type \"%s\" was passed for user attribute \"%s\"", this, userAttributeValue.getClass().getCanonicalName(), name); + logger.warn(message); } else { - logger.debug( - "Audience condition \"{}\" evaluated to UNKNOWN because a null value was passed for user attribute \"{}\"", - this, - name); + String message = reasons.addInfo("Audience condition \"%s\" evaluated to UNKNOWN because a null value was passed for user attribute \"%s\"", this, name); + logger.debug(message); } } } catch (UnknownMatchTypeException | UnexpectedValueTypeException e) { - logger.warn("Audience condition \"{}\" " + e.getMessage(), this); + String message = reasons.addInfo("Audience condition \"%s\" " + e.getMessage(), this); + logger.warn(message); } catch (NullPointerException e) { - logger.error("attribute or value null for match {}", match != null ? match : "legacy condition", e); + String message = reasons.addInfo("attribute or value null for match %s", match != null ? match : "legacy condition"); + logger.error(message, e); } return null; } diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java index 8189dae72..aec6cdce2 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java @@ -19,6 +19,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.optimizely.ab.annotations.VisibleForTesting; +import java.util.StringJoiner; + public class DecisionMetadata { @JsonProperty("flag_key") @@ -87,6 +89,18 @@ public int hashCode() { return result; } + @Override + public String toString() { + return new StringJoiner(", ", DecisionMetadata.class.getSimpleName() + "[", "]") + .add("flagKey='" + flagKey + "'") + .add("ruleKey='" + ruleKey + "'") + .add("ruleType='" + ruleType + "'") + .add("variationKey='" + variationKey + "'") + .add("enabled=" + enabled) + .toString(); + } + + public static class Builder { private String ruleType; 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 f5109b624..e24cc1b6a 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 @@ -21,6 +21,8 @@ import com.optimizely.ab.config.audience.AudienceIdCondition; import com.optimizely.ab.config.audience.Condition; import com.optimizely.ab.config.audience.OrCondition; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -41,18 +43,25 @@ private ExperimentUtils() { * Helper method to validate all pre-conditions before bucketing a user. * * @param experiment the experiment we are validating pre-conditions for + * @param reasons Decision log messages * @return whether the pre-conditions are satisfied */ - public static boolean isExperimentActive(@Nonnull Experiment experiment) { + public static boolean isExperimentActive(@Nonnull Experiment experiment, + @Nonnull DecisionReasons reasons) { if (!experiment.isActive()) { - logger.info("Experiment \"{}\" is not running.", experiment.getKey()); + String message = reasons.addInfo("Experiment \"%s\" is not running.", experiment.getKey()); + logger.info(message); return false; } return true; } + public static boolean isExperimentActive(@Nonnull Experiment experiment) { + return isExperimentActive(experiment, DefaultDecisionReasons.newInstance()); + } + /** * Determines whether a user satisfies audience conditions for the experiment. * @@ -61,29 +70,50 @@ public static boolean isExperimentActive(@Nonnull Experiment experiment) { * @param attributes the attributes of the user * @param loggingEntityType It can be either experiment or rule. * @param loggingKey In case of loggingEntityType is experiment it will be experiment key or else it will be rule number. + * @param reasons Decision log messages * @return whether the user meets the criteria for the experiment */ public static boolean doesUserMeetAudienceConditions(@Nonnull ProjectConfig projectConfig, @Nonnull Experiment experiment, @Nonnull Map attributes, @Nonnull String loggingEntityType, - @Nonnull String loggingKey) { + @Nonnull String loggingKey, + @Nonnull DecisionReasons reasons) { if (experiment.getAudienceConditions() != null) { logger.debug("Evaluating audiences for {} \"{}\": {}.", loggingEntityType, loggingKey, experiment.getAudienceConditions()); - Boolean resolveReturn = evaluateAudienceConditions(projectConfig, experiment, attributes, loggingEntityType, loggingKey); + Boolean resolveReturn = evaluateAudienceConditions(projectConfig, experiment, attributes, loggingEntityType, loggingKey, reasons); return resolveReturn == null ? false : resolveReturn; } else { - Boolean resolveReturn = evaluateAudience(projectConfig, experiment, attributes, loggingEntityType, loggingKey); + Boolean resolveReturn = evaluateAudience(projectConfig, experiment, attributes, loggingEntityType, loggingKey, reasons); return Boolean.TRUE.equals(resolveReturn); } } + /** + * Determines whether a user satisfies audience conditions for the experiment. + * + * @param projectConfig the current projectConfig + * @param experiment the experiment we are evaluating audiences for + * @param attributes the attributes of the user + * @param loggingEntityType It can be either experiment or rule. + * @param loggingKey In case of loggingEntityType is experiment it will be experiment key or else it will be rule number. + * @return whether the user meets the criteria for the experiment + */ + public static boolean doesUserMeetAudienceConditions(@Nonnull ProjectConfig projectConfig, + @Nonnull Experiment experiment, + @Nonnull Map attributes, + @Nonnull String loggingEntityType, + @Nonnull String loggingKey) { + return doesUserMeetAudienceConditions(projectConfig, experiment, attributes, loggingEntityType, loggingKey, DefaultDecisionReasons.newInstance()); + } + @Nullable public static Boolean evaluateAudience(@Nonnull ProjectConfig projectConfig, @Nonnull Experiment experiment, @Nonnull Map attributes, @Nonnull String loggingEntityType, - @Nonnull String loggingKey) { + @Nonnull String loggingKey, + @Nonnull DecisionReasons reasons) { List experimentAudienceIds = experiment.getAudienceIds(); // if there are no audiences, ALL users should be part of the experiment @@ -101,9 +131,10 @@ public static Boolean evaluateAudience(@Nonnull ProjectConfig projectConfig, logger.debug("Evaluating audiences for {} \"{}\": {}.", loggingEntityType, loggingKey, conditions); - Boolean result = implicitOr.evaluate(projectConfig, attributes); + Boolean result = implicitOr.evaluate(projectConfig, attributes, reasons); - logger.info("Audiences for {} \"{}\" collectively evaluated to {}.", loggingEntityType, loggingKey, result); + String message = reasons.addInfo("Audiences for %s \"%s\" collectively evaluated to %s.", loggingEntityType, loggingKey, result); + logger.info(message); return result; } @@ -113,20 +144,22 @@ public static Boolean evaluateAudienceConditions(@Nonnull ProjectConfig projectC @Nonnull Experiment experiment, @Nonnull Map attributes, @Nonnull String loggingEntityType, - @Nonnull String loggingKey) { + @Nonnull String loggingKey, + @Nonnull DecisionReasons reasons) { Condition conditions = experiment.getAudienceConditions(); if (conditions == null) return null; try { - Boolean result = conditions.evaluate(projectConfig, attributes); - logger.info("Audiences for {} \"{}\" collectively evaluated to {}.", loggingEntityType, loggingKey, result); + Boolean result = conditions.evaluate(projectConfig, attributes, reasons); + String message = reasons.addInfo("Audiences for %s \"%s\" collectively evaluated to %s.", loggingEntityType, loggingKey, result); + logger.info(message); return result; } catch (Exception e) { - logger.error("Condition invalid", e); + String message = reasons.addInfo("Condition invalid: %s", e.getMessage()); + logger.error(message); return null; } } - } diff --git a/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java b/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java index 0a7ea6e3c..d97e5bf40 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019, Optimizely, Inc. and contributors * + * Copyright 2019-2020, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -24,6 +24,7 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.util.HashMap; +import java.util.List; import java.util.Map; /** @@ -350,4 +351,102 @@ public DecisionNotification build() { decisionInfo); } } + + public static FlagDecisionNotificationBuilder newFlagDecisionNotificationBuilder() { + return new FlagDecisionNotificationBuilder(); + } + + public static class FlagDecisionNotificationBuilder { + public final static String FLAG_KEY = "flagKey"; + public final static String ENABLED = "enabled"; + public final static String VARIABLES = "variables"; + public final static String VARIATION_KEY = "variationKey"; + public final static String RULE_KEY = "ruleKey"; + public final static String REASONS = "reasons"; + public final static String DECISION_EVENT_DISPATCHED = "decisionEventDispatched"; + + private String flagKey; + private Boolean enabled; + private Object variables; + private String userId; + private Map attributes; + private String variationKey; + private String ruleKey; + private List reasons; + private Boolean decisionEventDispatched; + + private Map decisionInfo; + + public FlagDecisionNotificationBuilder withUserId(String userId) { + this.userId = userId; + return this; + } + + public FlagDecisionNotificationBuilder withAttributes(Map attributes) { + this.attributes = attributes; + return this; + } + + public FlagDecisionNotificationBuilder withFlagKey(String flagKey) { + this.flagKey = flagKey; + return this; + } + + public FlagDecisionNotificationBuilder withEnabled(Boolean enabled) { + this.enabled = enabled; + return this; + } + + public FlagDecisionNotificationBuilder withVariables(Object variables) { + this.variables = variables; + return this; + } + + public FlagDecisionNotificationBuilder withVariationKey(String key) { + this.variationKey = key; + return this; + } + + public FlagDecisionNotificationBuilder withRuleKey(String key) { + this.ruleKey = key; + return this; + } + + public FlagDecisionNotificationBuilder withReasons(List reasons) { + this.reasons = reasons; + return this; + } + + public FlagDecisionNotificationBuilder withDecisionEventDispatched(Boolean dispatched) { + this.decisionEventDispatched = dispatched; + return this; + } + + public DecisionNotification build() { + if (flagKey == null) { + throw new OptimizelyRuntimeException("flagKey not set"); + } + + if (enabled == null) { + throw new OptimizelyRuntimeException("enabled not set"); + } + + decisionInfo = new HashMap() {{ + put(FLAG_KEY, flagKey); + put(ENABLED, enabled); + put(VARIABLES, variables); + put(VARIATION_KEY, variationKey); + put(RULE_KEY, ruleKey); + put(REASONS, reasons); + put(DECISION_EVENT_DISPATCHED, decisionEventDispatched); + }}; + + return new DecisionNotification( + NotificationCenter.DecisionNotificationType.FLAG.toString(), + userId, + attributes, + decisionInfo); + } + } + } diff --git a/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java b/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java index 4b0b3e406..499f8ec43 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java @@ -1,6 +1,6 @@ /** * - * Copyright 2017-2019, Optimizely and contributors + * Copyright 2017-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -55,7 +55,8 @@ public enum DecisionNotificationType { FEATURE("feature"), FEATURE_TEST("feature-test"), FEATURE_VARIABLE("feature-variable"), - ALL_FEATURE_VARIABLES("all-feature-variables"); + ALL_FEATURE_VARIABLES("all-feature-variables"), + FLAG("flag"); private final String key; diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionMessage.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionMessage.java new file mode 100644 index 000000000..c66be6bee --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionMessage.java @@ -0,0 +1,34 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.optimizely.ab.optimizelydecision; + +public enum DecisionMessage { + SDK_NOT_READY("Optimizely SDK not configured properly yet."), + FLAG_KEY_INVALID("No flag was found for key \"%s\"."), + VARIABLE_VALUE_INVALID("Variable value for key \"%s\" is invalid or wrong type."); + + private String format; + + DecisionMessage(String format) { + this.format = format; + } + + public String reason(Object... args){ + return String.format(format, args); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionReasons.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionReasons.java new file mode 100644 index 000000000..0983ee4d2 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionReasons.java @@ -0,0 +1,29 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +import java.util.List; + +public interface DecisionReasons { + + public void addError(String format, Object... args); + + public String addInfo(String format, Object... args); + + public List toReport(); + +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DefaultDecisionReasons.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DefaultDecisionReasons.java new file mode 100644 index 000000000..dd84d04fe --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DefaultDecisionReasons.java @@ -0,0 +1,54 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; + +public class DefaultDecisionReasons implements DecisionReasons { + + private final List errors = new ArrayList<>(); + private final List infos = new ArrayList<>(); + + public static DecisionReasons newInstance(@Nullable List options) { + if (options != null && options.contains(OptimizelyDecideOption.INCLUDE_REASONS)) return new DefaultDecisionReasons(); + else return new ErrorsDecisionReasons(); + } + + public static DecisionReasons newInstance() { + return newInstance(null); + } + + public void addError(String format, Object... args) { + String message = String.format(format, args); + errors.add(message); + } + + public String addInfo(String format, Object... args) { + String message = String.format(format, args); + infos.add(message); + return message; + } + + public List toReport() { + List reasons = new ArrayList<>(errors); + reasons.addAll(infos); + return reasons; + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/ErrorsDecisionReasons.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/ErrorsDecisionReasons.java new file mode 100644 index 000000000..91875eece --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/ErrorsDecisionReasons.java @@ -0,0 +1,56 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +import java.util.ArrayList; +import java.util.List; + +public class ErrorsDecisionReasons implements DecisionReasons { + + private final List errors = new ArrayList<>(); + + public void addError(String format, Object... args) { + String message = String.format(format, args); + errors.add(message); + } + + public String addInfo(String format, Object... args) { + // skip tracking and pass-through reasons other than critical errors. + return String.format(format, args); + } + + public List toReport() { + return new ArrayList<>(errors); + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecideOption.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecideOption.java new file mode 100644 index 000000000..ccd08bb63 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecideOption.java @@ -0,0 +1,25 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +public enum OptimizelyDecideOption { + DISABLE_DECISION_EVENT, + ENABLED_FLAGS_ONLY, + IGNORE_USER_PROFILE_SERVICE, + INCLUDE_REASONS, + EXCLUDE_VARIABLES +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecision.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecision.java new file mode 100644 index 000000000..201324f20 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecision.java @@ -0,0 +1,157 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.optimizelyjson.OptimizelyJSON; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class OptimizelyDecision { + @Nullable + private final String variationKey; + + private final boolean enabled; + + @Nonnull + private final OptimizelyJSON variables; + + @Nullable + private final String ruleKey; + + @Nonnull + private final String flagKey; + + @Nonnull + private final OptimizelyUserContext userContext; + + @Nonnull + private List reasons; + + + public OptimizelyDecision(@Nullable String variationKey, + boolean enabled, + @Nonnull OptimizelyJSON variables, + @Nullable String ruleKey, + @Nonnull String flagKey, + @Nonnull OptimizelyUserContext userContext, + @Nonnull List reasons) { + this.variationKey = variationKey; + this.enabled = enabled; + this.variables = variables; + this.ruleKey = ruleKey; + this.flagKey = flagKey; + this.userContext = userContext; + this.reasons = reasons; + } + + @Nullable + public String getVariationKey() { + return variationKey; + } + + public boolean getEnabled() { + return enabled; + } + + @Nonnull + public OptimizelyJSON getVariables() { + return variables; + } + + @Nullable + public String getRuleKey() { + return ruleKey; + } + + @Nonnull + public String getFlagKey() { + return flagKey; + } + + @Nullable + public OptimizelyUserContext getUserContext() { + return userContext; + } + + @Nonnull + public List getReasons() { + return reasons; + } + + public static OptimizelyDecision newErrorDecision(@Nonnull String key, + @Nonnull OptimizelyUserContext user, + @Nonnull String error) { + return new OptimizelyDecision( + null, + false, + new OptimizelyJSON(Collections.emptyMap()), + null, + key, + user, + Arrays.asList(error)); + } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) return false; + if (obj == this) return true; + OptimizelyDecision d = (OptimizelyDecision) obj; + return equals(variationKey, d.getVariationKey()) && + equals(enabled, d.getEnabled()) && + equals(variables, d.getVariables()) && + equals(ruleKey, d.getRuleKey()) && + equals(flagKey, d.getFlagKey()) && + equals(userContext, d.getUserContext()) && + equals(reasons, d.getReasons()); + } + + private static boolean equals(Object a, Object b) { + return a == b || (a != null && a.equals(b)); + } + + @Override + public int hashCode() { + int hash = variationKey != null ? variationKey.hashCode() : 0; + hash = 31 * hash + (enabled ? 1 : 0); + hash = 31 * hash + variables.hashCode(); + hash = 31 * hash + (ruleKey != null ? ruleKey.hashCode() : 0); + hash = 31 * hash + flagKey.hashCode(); + hash = 31 * hash + userContext.hashCode(); + hash = 31 * hash + reasons.hashCode(); + return hash; + } + + @Override + public String toString() { + return "OptimizelyDecision {" + + "variationKey='" + variationKey + '\'' + + ", enabled='" + enabled + '\'' + + ", variables='" + variables + '\'' + + ", ruleKey='" + ruleKey + '\'' + + ", flagKey='" + flagKey + '\'' + + ", userContext='" + userContext + '\'' + + ", enabled='" + enabled + '\'' + + ", reasons='" + reasons + '\'' + + '}'; + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyjson/OptimizelyJSON.java b/core-api/src/main/java/com/optimizely/ab/optimizelyjson/OptimizelyJSON.java index 811999e24..97bff838c 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelyjson/OptimizelyJSON.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyjson/OptimizelyJSON.java @@ -157,5 +157,24 @@ private T getValueInternal(@Nullable Object object, Class clazz) { return null; } + public boolean isEmpty() { + return map == null || map.isEmpty(); + } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) return false; + if (obj == this) return true; + if (toMap() == null) return false; + + return toMap().equals(((OptimizelyJSON) obj).toMap()); + } + + @Override + public int hashCode() { + int hash = toMap() != null ? toMap().hashCode() : 0; + return hash; + } + } diff --git a/core-api/src/test/java/com/optimizely/ab/EventHandlerRule.java b/core-api/src/test/java/com/optimizely/ab/EventHandlerRule.java index 4245a8f8a..577a1891d 100644 --- a/core-api/src/test/java/com/optimizely/ab/EventHandlerRule.java +++ b/core-api/src/test/java/com/optimizely/ab/EventHandlerRule.java @@ -28,9 +28,7 @@ import java.util.stream.Collectors; import static com.optimizely.ab.config.ProjectConfig.RESERVED_ATTRIBUTE_PREFIX; -import static junit.framework.TestCase.assertTrue; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; /** * EventHandlerRule is a JUnit rule that implements an Optimizely {@link EventHandler}. @@ -108,9 +106,14 @@ public void expectImpression(String experientId, String variationId, String user } public void expectImpression(String experientId, String variationId, String userId, Map attributes) { - expect(experientId, variationId, IMPRESSION_EVENT_NAME, userId, attributes, null); + expectImpression(experientId, variationId, userId, attributes, null); } + public void expectImpression(String experientId, String variationId, String userId, Map attributes, DecisionMetadata metadata) { + expect(experientId, variationId, IMPRESSION_EVENT_NAME, userId, attributes, null, metadata); + } + + public void expectConversion(String eventName, String userId) { expectConversion(eventName, userId, Collections.emptyMap()); } @@ -124,11 +127,17 @@ public void expectConversion(String eventName, String userId, Map att } public void expect(String experientId, String variationId, String eventName, String userId, - Map attributes, Map tags) { - CanonicalEvent expectedEvent = new CanonicalEvent(experientId, variationId, eventName, userId, attributes, tags); + Map attributes, Map tags, DecisionMetadata metadata) { + CanonicalEvent expectedEvent = new CanonicalEvent(experientId, variationId, eventName, userId, attributes, tags, metadata); expectedEvents.add(expectedEvent); } + public void expect(String experientId, String variationId, String eventName, String userId, + Map attributes, Map tags) { + expect(experientId, variationId, eventName, userId, attributes, tags, null); + } + + @Override public void dispatchEvent(LogEvent logEvent) { logger.info("Receiving event: {}", logEvent); @@ -161,7 +170,8 @@ public void dispatchEvent(LogEvent logEvent) { visitor.getAttributes().stream() .filter(attribute -> !attribute.getKey().startsWith(RESERVED_ATTRIBUTE_PREFIX)) .collect(Collectors.toMap(Attribute::getKey, Attribute::getValue)), - event.getTags() + event.getTags(), + decision.getMetadata() ); logger.info("Adding dispatched, event: {}", actual); @@ -179,33 +189,45 @@ private static class CanonicalEvent { private String visitorId; private Map attributes; private Map tags; + private DecisionMetadata metadata; public CanonicalEvent(String experimentId, String variationId, String eventName, - String visitorId, Map attributes, Map tags) { + String visitorId, Map attributes, Map tags, + DecisionMetadata metadata) { this.experimentId = experimentId; this.variationId = variationId; this.eventName = eventName; this.visitorId = visitorId; this.attributes = attributes; this.tags = tags; + this.metadata = metadata; + } + + public CanonicalEvent(String experimentId, String variationId, String eventName, + String visitorId, Map attributes, Map tags) { + this(experimentId, variationId, eventName, visitorId, attributes, tags, null); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; + CanonicalEvent that = (CanonicalEvent) o; + + boolean isMetaDataEqual = (metadata == null) || Objects.equals(metadata, that.metadata); return Objects.equals(experimentId, that.experimentId) && Objects.equals(variationId, that.variationId) && Objects.equals(eventName, that.eventName) && Objects.equals(visitorId, that.visitorId) && Objects.equals(attributes, that.attributes) && - Objects.equals(tags, that.tags); + Objects.equals(tags, that.tags) && + isMetaDataEqual; } @Override public int hashCode() { - return Objects.hash(experimentId, variationId, eventName, visitorId, attributes, tags); + return Objects.hash(experimentId, variationId, eventName, visitorId, attributes, tags, metadata); } @Override @@ -217,6 +239,7 @@ public String toString() { .add("visitorId='" + visitorId + "'") .add("attributes=" + attributes) .add("tags=" + tags) + .add("metadata=" + metadata) .toString(); } } diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java index 8bd613481..932150337 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java @@ -21,6 +21,7 @@ import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.error.NoOpErrorHandler; import com.optimizely.ab.event.EventHandler; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Rule; import org.junit.Test; @@ -29,11 +30,15 @@ import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; +import java.util.Arrays; +import java.util.List; + import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.*; import static org.hamcrest.CoreMatchers.instanceOf; import static org.hamcrest.CoreMatchers.is; import static org.junit.Assert.*; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** * Tests for {@link Optimizely#builder(String, EventHandler)}. @@ -169,4 +174,25 @@ public void withProjectConfigManagerAndFallbackDatafile() throws Exception { // Project Config manager takes precedence. assertFalse(optimizelyClient.isValid()); } + + @Test + public void withDefaultDecideOptions() throws Exception { + List options = Arrays.asList( + OptimizelyDecideOption.DISABLE_DECISION_EVENT, + OptimizelyDecideOption.ENABLED_FLAGS_ONLY, + OptimizelyDecideOption.EXCLUDE_VARIABLES + ); + + Optimizely optimizelyClient = Optimizely.builder(validConfigJsonV4(), mockEventHandler) + .build(); + assertEquals(optimizelyClient.defaultDecideOptions.size(), 0); + + optimizelyClient = Optimizely.builder(validConfigJsonV4(), mockEventHandler) + .withDefaultDecideOptions(options) + .build(); + assertEquals(optimizelyClient.defaultDecideOptions.get(0), OptimizelyDecideOption.DISABLE_DECISION_EVENT); + assertEquals(optimizelyClient.defaultDecideOptions.get(1), OptimizelyDecideOption.ENABLED_FLAGS_ONLY); + assertEquals(optimizelyClient.defaultDecideOptions.get(2), OptimizelyDecideOption.EXCLUDE_VARIABLES); + } + } 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 cd4c926c8..6cb7eb360 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -377,7 +377,7 @@ public void activateForNullVariation() throws Exception { Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); Map testUserAttributes = Collections.singletonMap("browser_type", "chromey"); - when(mockBucketer.bucket(activatedExperiment, testBucketingId, validProjectConfig)).thenReturn(null); + when(mockBucketer.bucket(eq(activatedExperiment), eq(testBucketingId), eq(validProjectConfig), anyObject())).thenReturn(null); logbackVerifier.expectMessage(Level.INFO, "Not activating user \"userId\" for experiment \"" + activatedExperiment.getKey() + "\"."); @@ -936,7 +936,7 @@ public void activateWithInvalidDatafile() throws Exception { assertNull(expectedVariation); // make sure we didn't even attempt to bucket the user - verify(mockBucketer, never()).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); + verify(mockBucketer, never()).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); } //======== track tests ========// @@ -1237,7 +1237,7 @@ public void trackWithInvalidDatafile() throws Exception { optimizely.track("event_with_launched_and_running_experiments", genericUserId); // make sure we didn't even attempt to bucket the user or fire any conversion events - verify(mockBucketer, never()).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); + verify(mockBucketer, never()).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); } @@ -1254,7 +1254,7 @@ public void getVariation() throws Exception { Optimizely optimizely = optimizelyBuilder.withBucketing(mockBucketer).build(); - when(mockBucketer.bucket(activatedExperiment, testUserId, validProjectConfig)).thenReturn(bucketedVariation); + when(mockBucketer.bucket(eq(activatedExperiment), eq(testUserId), eq(validProjectConfig), anyObject())).thenReturn(bucketedVariation); Map testUserAttributes = new HashMap<>(); testUserAttributes.put("browser_type", "chrome"); @@ -1264,7 +1264,7 @@ public void getVariation() throws Exception { testUserAttributes); // verify that the bucketing algorithm was called correctly - verify(mockBucketer).bucket(activatedExperiment, testUserId, validProjectConfig); + verify(mockBucketer).bucket(eq(activatedExperiment), eq(testUserId), eq(validProjectConfig), anyObject()); assertThat(actualVariation, is(bucketedVariation)); // verify that we didn't attempt to dispatch an event @@ -1285,13 +1285,13 @@ public void getVariationWithExperimentKey() throws Exception { .withConfig(noAudienceProjectConfig) .build(); - when(mockBucketer.bucket(activatedExperiment, testUserId, noAudienceProjectConfig)).thenReturn(bucketedVariation); + when(mockBucketer.bucket(eq(activatedExperiment), eq(testUserId), eq(noAudienceProjectConfig), anyObject())).thenReturn(bucketedVariation); // activate the experiment Variation actualVariation = optimizely.getVariation(activatedExperiment.getKey(), testUserId); // verify that the bucketing algorithm was called correctly - verify(mockBucketer).bucket(activatedExperiment, testUserId, noAudienceProjectConfig); + verify(mockBucketer).bucket(eq(activatedExperiment), eq(testUserId), eq(noAudienceProjectConfig), anyObject()); assertThat(actualVariation, is(bucketedVariation)); // verify that we didn't attempt to dispatch an event @@ -1346,7 +1346,7 @@ public void getVariationWithAudiences() throws Exception { Experiment experiment = validProjectConfig.getExperiments().get(0); Variation bucketedVariation = experiment.getVariations().get(0); - when(mockBucketer.bucket(experiment, testUserId, validProjectConfig)).thenReturn(bucketedVariation); + when(mockBucketer.bucket(eq(experiment), eq(testUserId), eq(validProjectConfig), anyObject())).thenReturn(bucketedVariation); Optimizely optimizely = optimizelyBuilder.withBucketing(mockBucketer).build(); @@ -1355,7 +1355,7 @@ public void getVariationWithAudiences() throws Exception { Variation actualVariation = optimizely.getVariation(experiment.getKey(), testUserId, testUserAttributes); - verify(mockBucketer).bucket(experiment, testUserId, validProjectConfig); + verify(mockBucketer).bucket(eq(experiment), eq(testUserId), eq(validProjectConfig), anyObject()); assertThat(actualVariation, is(bucketedVariation)); } @@ -1396,7 +1396,7 @@ public void getVariationNoAudiences() throws Exception { Experiment experiment = noAudienceProjectConfig.getExperiments().get(0); Variation bucketedVariation = experiment.getVariations().get(0); - when(mockBucketer.bucket(experiment, testUserId, noAudienceProjectConfig)).thenReturn(bucketedVariation); + when(mockBucketer.bucket(eq(experiment), eq(testUserId), eq(noAudienceProjectConfig), anyObject())).thenReturn(bucketedVariation); Optimizely optimizely = optimizelyBuilder .withConfig(noAudienceProjectConfig) @@ -1405,7 +1405,7 @@ public void getVariationNoAudiences() throws Exception { Variation actualVariation = optimizely.getVariation(experiment.getKey(), testUserId); - verify(mockBucketer).bucket(experiment, testUserId, noAudienceProjectConfig); + verify(mockBucketer).bucket(eq(experiment), eq(testUserId), eq(noAudienceProjectConfig), anyObject()); assertThat(actualVariation, is(bucketedVariation)); } @@ -1463,7 +1463,7 @@ public void getVariationForGroupExperimentWithMatchingAttributes() throws Except attributes.put("browser_type", "chrome"); } - when(mockBucketer.bucket(experiment, "user", validProjectConfig)).thenReturn(variation); + when(mockBucketer.bucket(eq(experiment), eq("user"), eq(validProjectConfig), anyObject())).thenReturn(variation); Optimizely optimizely = optimizelyBuilder.withBucketing(mockBucketer).build(); @@ -1521,7 +1521,7 @@ public void getVariationWithInvalidDatafile() throws Exception { assertNull(variation); // make sure we didn't even attempt to bucket the user - verify(mockBucketer, never()).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); + verify(mockBucketer, never()).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); } //======== Notification listeners ========// @@ -4627,4 +4627,47 @@ public void getOptimizelyConfigValidDatafile() { assertEquals(optimizely.getOptimizelyConfig().getDatafile(), validDatafile); } + // OptimizelyUserContext + + @Test + public void createUserContext_withAttributes() { + String userId = "testUser1"; + Map attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + + Optimizely optimizely = optimizelyBuilder.build(); + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + + assertEquals(user.getOptimizely(), optimizely); + assertEquals(user.getUserId(), userId); + assertEquals(user.getAttributes(), attributes); + } + + @Test + public void createUserContext_noAttributes() { + String userId = "testUser1"; + + Optimizely optimizely = optimizelyBuilder.build(); + OptimizelyUserContext user = optimizely.createUserContext(userId); + + assertEquals(user.getOptimizely(), optimizely); + assertEquals(user.getUserId(), userId); + assertTrue(user.getAttributes().isEmpty()); + } + + @Test + public void createUserContext_multiple() { + String userId1 = "testUser1"; + String userId2 = "testUser1"; + Map attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + + Optimizely optimizely = optimizelyBuilder.build(); + OptimizelyUserContext user1 = optimizely.createUserContext(userId1, attributes); + OptimizelyUserContext user2 = optimizely.createUserContext(userId2); + + assertEquals(user1.getUserId(), userId1); + assertEquals(user1.getAttributes(), attributes); + assertEquals(user2.getUserId(), userId2); + assertTrue(user2.getAttributes().isEmpty()); + } + } diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java new file mode 100644 index 000000000..50c62141d --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -0,0 +1,1165 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import com.google.common.base.Charsets; +import com.google.common.io.Resources; +import com.optimizely.ab.bucketing.FeatureDecision; +import com.optimizely.ab.bucketing.UserProfileService; +import com.optimizely.ab.config.*; +import com.optimizely.ab.config.parser.ConfigParseException; +import com.optimizely.ab.event.ForwardingEventProcessor; +import com.optimizely.ab.event.internal.payload.DecisionMetadata; +import com.optimizely.ab.notification.NotificationCenter; +import com.optimizely.ab.optimizelydecision.DecisionMessage; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; +import com.optimizely.ab.optimizelydecision.OptimizelyDecision; +import com.optimizely.ab.optimizelyjson.OptimizelyJSON; +import junit.framework.TestCase; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import java.util.*; + +import static com.optimizely.ab.config.ValidProjectConfigV4.ATTRIBUTE_HOUSE_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.AUDIENCE_GRYFFINDOR_VALUE; +import static com.optimizely.ab.notification.DecisionNotification.ExperimentDecisionNotificationBuilder.VARIATION_KEY; +import static com.optimizely.ab.notification.DecisionNotification.FlagDecisionNotificationBuilder.*; +import static junit.framework.TestCase.assertEquals; +import static junit.framework.TestCase.assertTrue; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +public class OptimizelyUserContextTest { + @Rule + public EventHandlerRule eventHandler = new EventHandlerRule(); + + String userId = "tester"; + boolean isListenerCalled = false; + + Optimizely optimizely; + String datafile; + ProjectConfig config; + Map experimentIdMapping; + Map featureKeyMapping; + Map groupIdMapping; + + @Before + public void setUp() throws Exception { + datafile = Resources.toString(Resources.getResource("config/decide-project-config.json"), Charsets.UTF_8); + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .build(); + } + + @Test + public void optimizelyUserContext_withAttributes() { + Map attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + OptimizelyUserContext user = new OptimizelyUserContext(optimizely, userId, attributes); + + assertEquals(user.getOptimizely(), optimizely); + assertEquals(user.getUserId(), userId); + assertEquals(user.getAttributes(), attributes); + } + + @Test + public void optimizelyUserContext_noAttributes() { + OptimizelyUserContext user = new OptimizelyUserContext(optimizely, userId); + + assertEquals(user.getOptimizely(), optimizely); + assertEquals(user.getUserId(), userId); + assertTrue(user.getAttributes().isEmpty()); + } + + @Test + public void setAttribute() { + Map attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + OptimizelyUserContext user = new OptimizelyUserContext(optimizely, userId, attributes); + + user.setAttribute("k1", "v1"); + user.setAttribute("k2", true); + user.setAttribute("k3", 100); + user.setAttribute("k4", 3.5); + + assertEquals(user.getOptimizely(), optimizely); + assertEquals(user.getUserId(), userId); + Map newAttributes = user.getAttributes(); + assertEquals(newAttributes.get(ATTRIBUTE_HOUSE_KEY), AUDIENCE_GRYFFINDOR_VALUE); + assertEquals(newAttributes.get("k1"), "v1"); + assertEquals(newAttributes.get("k2"), true); + assertEquals(newAttributes.get("k3"), 100); + assertEquals(newAttributes.get("k4"), 3.5); + } + + @Test + public void setAttribute_noAttribute() { + OptimizelyUserContext user = new OptimizelyUserContext(optimizely, userId); + + user.setAttribute("k1", "v1"); + user.setAttribute("k2", true); + + assertEquals(user.getOptimizely(), optimizely); + assertEquals(user.getUserId(), userId); + Map newAttributes = user.getAttributes(); + assertEquals(newAttributes.get("k1"), "v1"); + assertEquals(newAttributes.get("k2"), true); + } + + @Test + public void setAttribute_override() { + Map attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + OptimizelyUserContext user = new OptimizelyUserContext(optimizely, userId, attributes); + + user.setAttribute("k1", "v1"); + user.setAttribute(ATTRIBUTE_HOUSE_KEY, "v2"); + + Map newAttributes = user.getAttributes(); + assertEquals(newAttributes.get("k1"), "v1"); + assertEquals(newAttributes.get(ATTRIBUTE_HOUSE_KEY), "v2"); + } + + @Test + public void setAttribute_nullValue() { + Map attributes = Collections.singletonMap("k1", null); + OptimizelyUserContext user = new OptimizelyUserContext(optimizely, userId, attributes); + + Map newAttributes = user.getAttributes(); + assertEquals(newAttributes.get("k1"), null); + + user.setAttribute("k1", true); + newAttributes = user.getAttributes(); + assertEquals(newAttributes.get("k1"), true); + + user.setAttribute("k1", null); + newAttributes = user.getAttributes(); + assertEquals(newAttributes.get("k1"), null); + } + + // decide + + @Test + public void decide_featureTest() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_2"; + String experimentKey = "exp_no_audience"; + String variationKey = "variation_with_traffic"; + String experimentId = "10420810910"; + String variationId = "10418551353"; + OptimizelyJSON variablesExpected = optimizely.getAllFeatureVariables(flagKey, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getVariationKey(), variationKey); + assertTrue(decision.getEnabled()); + assertEquals(decision.getVariables().toMap(), variablesExpected.toMap()); + assertEquals(decision.getRuleKey(), experimentKey); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), user); + assertTrue(decision.getReasons().isEmpty()); + + DecisionMetadata metadata = new DecisionMetadata.Builder() + .setFlagKey(flagKey) + .setRuleKey(experimentKey) + .setRuleType(FeatureDecision.DecisionSource.FEATURE_TEST.toString()) + .setVariationKey(variationKey) + .setEnabled(true) + .build(); + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap(), metadata); + } + + @Test + public void decide_rollout() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_1"; + String experimentKey = "18322080788"; + String variationKey = "18257766532"; + String experimentId = "18322080788"; + String variationId = "18257766532"; + OptimizelyJSON variablesExpected = optimizely.getAllFeatureVariables(flagKey, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getVariationKey(), variationKey); + assertTrue(decision.getEnabled()); + assertEquals(decision.getVariables().toMap(), variablesExpected.toMap()); + assertEquals(decision.getRuleKey(), experimentKey); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), user); + assertTrue(decision.getReasons().isEmpty()); + + DecisionMetadata metadata = new DecisionMetadata.Builder() + .setFlagKey(flagKey) + .setRuleKey(experimentKey) + .setRuleType(FeatureDecision.DecisionSource.ROLLOUT.toString()) + .setVariationKey(variationKey) + .setEnabled(true) + .build(); + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap(), metadata); + } + + @Test + public void decide_nullVariation() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_3"; + OptimizelyJSON variablesExpected = new OptimizelyJSON(Collections.emptyMap()); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getVariationKey(), null); + assertFalse(decision.getEnabled()); + assertEquals(decision.getVariables().toMap(), variablesExpected.toMap()); + assertEquals(decision.getRuleKey(), null); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), user); + assertTrue(decision.getReasons().isEmpty()); + + DecisionMetadata metadata = new DecisionMetadata.Builder() + .setFlagKey(flagKey) + .setRuleKey("") + .setRuleType(FeatureDecision.DecisionSource.ROLLOUT.toString()) + .setVariationKey("") + .setEnabled(false) + .build(); + eventHandler.expectImpression(null, "", userId, Collections.emptyMap(), metadata); + } + + // decideAll + + @Test + public void decideAll_oneFlag() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_2"; + String experimentKey = "exp_no_audience"; + String variationKey = "variation_with_traffic"; + String experimentId = "10420810910"; + String variationId = "10418551353"; + + List flagKeys = Arrays.asList(flagKey); + OptimizelyJSON variablesExpected = optimizely.getAllFeatureVariables(flagKey, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + Map decisions = user.decideForKeys(flagKeys); + + assertTrue(decisions.size() == 1); + OptimizelyDecision decision = decisions.get(flagKey); + + OptimizelyDecision expDecision = new OptimizelyDecision( + variationKey, + true, + variablesExpected, + experimentKey, + flagKey, + user, + Collections.emptyList()); + assertEquals(decision, expDecision); + + DecisionMetadata metadata = new DecisionMetadata.Builder() + .setFlagKey(flagKey) + .setRuleKey(experimentKey) + .setRuleType(FeatureDecision.DecisionSource.FEATURE_TEST.toString()) + .setVariationKey(variationKey) + .setEnabled(true) + .build(); + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap(), metadata); + } + + @Test + public void decideAll_twoFlags() { + String flagKey1 = "feature_1"; + String flagKey2 = "feature_2"; + + List flagKeys = Arrays.asList(flagKey1, flagKey2); + OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); + OptimizelyJSON variablesExpected2 = optimizely.getAllFeatureVariables(flagKey2, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); + Map decisions = user.decideForKeys(flagKeys); + + assertTrue(decisions.size() == 2); + + assertEquals( + decisions.get(flagKey1), + new OptimizelyDecision("a", + true, + variablesExpected1, + "exp_with_audience", + flagKey1, + user, + Collections.emptyList())); + assertEquals( + decisions.get(flagKey2), + new OptimizelyDecision("variation_with_traffic", + true, + variablesExpected2, + "exp_no_audience", + flagKey2, + user, + Collections.emptyList())); + } + + @Test + public void decideAll_allFlags() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey1 = "feature_1"; + String flagKey2 = "feature_2"; + String flagKey3 = "feature_3"; + Map attributes = Collections.singletonMap("gender", "f"); + + OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); + OptimizelyJSON variablesExpected2 = optimizely.getAllFeatureVariables(flagKey2, userId); + OptimizelyJSON variablesExpected3 = new OptimizelyJSON(Collections.emptyMap()); + + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + Map decisions = user.decideAll(); + + assertTrue(decisions.size() == 3); + + assertEquals( + decisions.get(flagKey1), + new OptimizelyDecision( + "a", + true, + variablesExpected1, + "exp_with_audience", + flagKey1, + user, + Collections.emptyList())); + assertEquals( + decisions.get(flagKey2), + new OptimizelyDecision( + "variation_with_traffic", + true, + variablesExpected2, + "exp_no_audience", + flagKey2, + user, + Collections.emptyList())); + assertEquals( + decisions.get(flagKey3), + new OptimizelyDecision( + null, + false, + variablesExpected3, + null, + flagKey3, + user, + Collections.emptyList())); + + eventHandler.expectImpression("10390977673", "10389729780", userId, attributes); + eventHandler.expectImpression("10420810910", "10418551353", userId, attributes); + eventHandler.expectImpression(null, "", userId, attributes); + } + + @Test + public void decideAll_allFlags_enabledFlagsOnly() { + String flagKey1 = "feature_1"; + OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); + Map decisions = user.decideAll(Arrays.asList(OptimizelyDecideOption.ENABLED_FLAGS_ONLY)); + + assertTrue(decisions.size() == 2); + + assertEquals( + decisions.get(flagKey1), + new OptimizelyDecision( + "a", + true, + variablesExpected1, + "exp_with_audience", + flagKey1, + user, + Collections.emptyList())); + } + + // trackEvent + + @Test + public void trackEvent() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + Map attributes = Collections.singletonMap("gender", "f"); + String eventKey = "event1"; + Map eventTags = Collections.singletonMap("name", "carrot"); + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + user.trackEvent(eventKey, eventTags); + + eventHandler.expectConversion(eventKey, userId, attributes, eventTags); + } + + @Test + public void trackEvent_noEventTags() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + Map attributes = Collections.singletonMap("gender", "f"); + String eventKey = "event1"; + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + user.trackEvent(eventKey); + + eventHandler.expectConversion(eventKey, userId, attributes); + } + + @Test + public void trackEvent_emptyAttributes() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String eventKey = "event1"; + Map eventTags = Collections.singletonMap("name", "carrot"); + OptimizelyUserContext user = optimizely.createUserContext(userId); + user.trackEvent(eventKey, eventTags); + + eventHandler.expectConversion(eventKey, userId, Collections.emptyMap(), eventTags); + } + + // send events + + @Test + public void decide_sendEvent() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_2"; + String variationKey = "variation_with_traffic"; + String experimentId = "10420810910"; + String variationId = "10418551353"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getVariationKey(), variationKey); + + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap()); + } + + @Test + public void decide_doNotSendEvent_withOption() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_2"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.DISABLE_DECISION_EVENT)); + + assertEquals(decision.getVariationKey(), "variation_with_traffic"); + } + + // notifications + + @Test + public void decisionNotification() { + String flagKey = "feature_2"; + String variationKey = "variation_with_traffic"; + boolean enabled = true; + OptimizelyJSON variables = optimizely.getAllFeatureVariables(flagKey, userId); + String ruleKey = "exp_no_audience"; + List reasons = Collections.emptyList(); + + final Map testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(FLAG_KEY, flagKey); + testDecisionInfoMap.put(VARIATION_KEY, variationKey); + testDecisionInfoMap.put(ENABLED, enabled); + testDecisionInfoMap.put(VARIABLES, variables.toMap()); + testDecisionInfoMap.put(RULE_KEY, ruleKey); + testDecisionInfoMap.put(REASONS, reasons); + + Map attributes = Collections.singletonMap("gender", "f"); + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + + optimizely.addDecisionNotificationHandler( + decisionNotification -> { + Assert.assertEquals(decisionNotification.getType(), NotificationCenter.DecisionNotificationType.FLAG.toString()); + Assert.assertEquals(decisionNotification.getUserId(), userId); + Assert.assertEquals(decisionNotification.getAttributes(), attributes); + Assert.assertEquals(decisionNotification.getDecisionInfo(), testDecisionInfoMap); + isListenerCalled = true; + }); + + isListenerCalled = false; + testDecisionInfoMap.put(DECISION_EVENT_DISPATCHED, true); + user.decide(flagKey); + assertTrue(isListenerCalled); + + isListenerCalled = false; + testDecisionInfoMap.put(DECISION_EVENT_DISPATCHED, false); + user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.DISABLE_DECISION_EVENT)); + assertTrue(isListenerCalled); + } + + // options + + @Test + public void decideOptions_bypassUPS() throws Exception { + String flagKey = "feature_2"; // embedding experiment: "exp_no_audience" + String experimentId = "10420810910"; // "exp_no_audience" + String variationId1 = "10418551353"; + String variationId2 = "10418510624"; + String variationKey1 = "variation_with_traffic"; + String variationKey2 = "variation_no_traffic"; + + UserProfileService ups = mock(UserProfileService.class); + when(ups.lookup(userId)).thenReturn(createUserProfileMap(experimentId, variationId2)); + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withUserProfileService(ups) + .build(); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + // should return variationId2 set by UPS + assertEquals(decision.getVariationKey(), variationKey2); + + decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE)); + // should ignore variationId2 set by UPS and return variationId1 + assertEquals(decision.getVariationKey(), variationKey1); + // also should not save either + verify(ups, never()).save(anyObject()); + } + + @Test + public void decideOptions_excludeVariables() { + String flagKey = "feature_1"; + OptimizelyUserContext user = optimizely.createUserContext(userId); + + OptimizelyDecision decision = user.decide(flagKey); + assertTrue(decision.getVariables().toMap().size() > 0); + + decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.EXCLUDE_VARIABLES)); + assertTrue(decision.getVariables().toMap().size() == 0); + } + + @Test + public void decideOptions_includeReasons() { + OptimizelyUserContext user = optimizely.createUserContext(userId); + + String flagKey = "invalid_key"; + OptimizelyDecision decision = user.decide(flagKey); + assertEquals(decision.getReasons().size(), 1); + TestCase.assertEquals(decision.getReasons().get(0), DecisionMessage.FLAG_KEY_INVALID.reason(flagKey)); + + decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + assertEquals(decision.getReasons().size(), 1); + assertEquals(decision.getReasons().get(0), DecisionMessage.FLAG_KEY_INVALID.reason(flagKey)); + + flagKey = "feature_1"; + decision = user.decide(flagKey); + assertEquals(decision.getReasons().size(), 0); + + decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + assertTrue(decision.getReasons().size() > 0); + } + + public void decideOptions_disableDispatchEvent() { + // tested already with decide_doNotSendEvent() above + } + + public void decideOptions_enabledFlagsOnly() { + // tested already with decideAll_allFlags_enabledFlagsOnly() above + } + + @Test + public void decideOptions_defaultDecideOptions() { + List options = Arrays.asList( + OptimizelyDecideOption.EXCLUDE_VARIABLES + ); + + optimizely = Optimizely.builder() + .withDatafile(datafile) + .withDefaultDecideOptions(options) + .build(); + + String flagKey = "feature_1"; + OptimizelyUserContext user = optimizely.createUserContext(userId); + + // should be excluded by DefaultDecideOption + OptimizelyDecision decision = user.decide(flagKey); + assertTrue(decision.getVariables().toMap().size() == 0); + + decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS, OptimizelyDecideOption.EXCLUDE_VARIABLES)); + // other options should work as well + assertTrue(decision.getReasons().size() > 0); + // redundant setting ignored + assertTrue(decision.getVariables().toMap().size() == 0); + } + + // errors + + @Test + public void decide_sdkNotReady() { + String flagKey = "feature_1"; + + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertNull(decision.getVariationKey()); + assertFalse(decision.getEnabled()); + assertTrue(decision.getVariables().isEmpty()); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), user); + + assertEquals(decision.getReasons().size(), 1); + assertEquals(decision.getReasons().get(0), DecisionMessage.SDK_NOT_READY.reason()); + } + + @Test + public void decide_invalidFeatureKey() { + String flagKey = "invalid_key"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertNull(decision.getVariationKey()); + assertFalse(decision.getEnabled()); + assertTrue(decision.getVariables().isEmpty()); + assertEquals(decision.getReasons().size(), 1); + assertEquals(decision.getReasons().get(0), DecisionMessage.FLAG_KEY_INVALID.reason(flagKey)); + } + + @Test + public void decideAll_sdkNotReady() { + List flagKeys = Arrays.asList("feature_1"); + + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext user = optimizely.createUserContext(userId); + Map decisions = user.decideForKeys(flagKeys); + + assertEquals(decisions.size(), 0); + } + + @Test + public void decideAll_errorDecisionIncluded() { + String flagKey1 = "feature_2"; + String flagKey2 = "invalid_key"; + + List flagKeys = Arrays.asList(flagKey1, flagKey2); + OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + Map decisions = user.decideForKeys(flagKeys); + + assertEquals(decisions.size(), 2); + + assertEquals( + decisions.get(flagKey1), + new OptimizelyDecision( + "variation_with_traffic", + true, + variablesExpected1, + "exp_no_audience", + flagKey1, + user, + Collections.emptyList())); + assertEquals( + decisions.get(flagKey2), + OptimizelyDecision.newErrorDecision( + flagKey2, + user, + DecisionMessage.FLAG_KEY_INVALID.reason(flagKey2))); + } + + // reasons (errors) + + @Test + public void decideReasons_sdkNotReady() { + String flagKey = "feature_1"; + + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getReasons().size(), 1); + assertEquals(decision.getReasons().get(0), DecisionMessage.SDK_NOT_READY.reason()); + } + + @Test + public void decideReasons_featureKeyInvalid() { + String flagKey = "invalid_key"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getReasons().size(), 1); + assertEquals(decision.getReasons().get(0), DecisionMessage.FLAG_KEY_INVALID.reason(flagKey)); + } + + @Test + public void decideReasons_variableValueInvalid() { + String flagKey = "feature_1"; + + FeatureFlag flag = getSpyFeatureFlag(flagKey); + List variables = Arrays.asList(new FeatureVariable("any-id", "any-key", "invalid", null, "integer", null)); + when(flag.getVariables()).thenReturn(variables); + addSpyFeatureFlag(flag); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getReasons().get(0), DecisionMessage.VARIABLE_VALUE_INVALID.reason("any-key")); + } + + // reasons (logs with includeReasons) + + @Test + public void decideReasons_conditionNoMatchingAudience() throws ConfigParseException { + String flagKey = "feature_1"; + String audienceId = "invalid_id"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("Audience %s could not be found.", audienceId) + )); + } + + @Test + public void decideReasons_evaluateAttributeInvalidType() { + String flagKey = "feature_1"; + String audienceId = "13389130056"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("country", 25)); + + assertTrue(decision.getReasons().contains( + String.format("Audience condition \"{name='country', type='custom_attribute', match='exact', value='US'}\" evaluated to UNKNOWN because a value of type \"java.lang.Integer\" was passed for user attribute \"country\"") + )); + } + + @Test + public void decideReasons_evaluateAttributeValueOutOfRange() { + String flagKey = "feature_1"; + String audienceId = "age_18"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", (float)Math.pow(2, 54))); + + assertTrue(decision.getReasons().contains( + String.format("Audience condition \"{name='age', type='custom_attribute', match='gt', value=18.0}\" evaluated to UNKNOWN because a value of type \"java.lang.Float\" was passed for user attribute \"age\"") + )); + } + + @Test + public void decideReasons_userAttributeInvalidType() { + String flagKey = "feature_1"; + String audienceId = "invalid_type"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", 25)); + + assertTrue(decision.getReasons().contains( + String.format("Audience condition \"{name='age', type='invalid', match='gt', value=18.0}\" uses an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK.") + )); + } + + @Test + public void decideReasons_userAttributeInvalidMatch() { + String flagKey = "feature_1"; + String audienceId = "invalid_match"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", 25)); + + assertTrue(decision.getReasons().contains( + String.format("Audience condition \"{name='age', type='custom_attribute', match='invalid', value=18.0}\" uses an unknown match type. You may need to upgrade to a newer release of the Optimizely SDK.") + )); + } + + @Test + public void decideReasons_userAttributeNilValue() { + String flagKey = "feature_1"; + String audienceId = "nil_value"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", 25)); + + assertTrue(decision.getReasons().contains( + String.format("Audience condition \"{name='age', type='custom_attribute', match='gt', value=null}\" evaluated to UNKNOWN because a value of type \"java.lang.Integer\" was passed for user attribute \"age\"") + )); + } + + @Test + public void decideReasons_missingAttributeValue() { + String flagKey = "feature_1"; + String audienceId = "age_18"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("Audience condition \"{name='age', type='custom_attribute', match='gt', value=18.0}\" evaluated to UNKNOWN because no value was passed for user attribute \"age\"") + )); + } + + @Test + public void decideReasons_experimentNotRunning() { + String flagKey = "feature_1"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.isActive()).thenReturn(false); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("Experiment \"exp_with_audience\" is not running.") + )); + } + + @Test + public void decideReasons_gotVariationFromUserProfile() throws Exception { + String flagKey = "feature_2"; // embedding experiment: "exp_no_audience" + String experimentId = "10420810910"; // "exp_no_audience" + String experimentKey = "exp_no_audience"; + String variationId2 = "10418510624"; + String variationKey2 = "variation_no_traffic"; + + UserProfileService ups = mock(UserProfileService.class); + when(ups.lookup(userId)).thenReturn(createUserProfileMap(experimentId, variationId2)); + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withUserProfileService(ups) + .build(); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("Returning previously activated variation \"%s\" of experiment \"%s\" for user \"%s\" from user profile.", variationKey2, experimentKey, userId) + )); + } + + @Test + public void decideReasons_forcedVariationFound() { + String flagKey = "feature_1"; + String variationKey = "b"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getUserIdToVariationKeyMap()).thenReturn(Collections.singletonMap(userId, variationKey)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("User \"%s\" is forced in variation \"%s\".", userId, variationKey) + )); + } + + @Test + public void decideReasons_forcedVariationFoundButInvalid() { + String flagKey = "feature_1"; + String variationKey = "invalid-key"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getUserIdToVariationKeyMap()).thenReturn(Collections.singletonMap(userId, variationKey)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("Variation \"%s\" is not in the datafile. Not activating user \"%s\".", variationKey, userId) + )); + } + + @Test + public void decideReasons_userMeetsConditionsForTargetingRule() { + String flagKey = "feature_1"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + user.setAttribute("country", "US"); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("The user \"%s\" was bucketed into a rollout for feature flag \"%s\".", userId, flagKey) + )); + } + + @Test + public void decideReasons_userDoesntMeetConditionsForTargetingRule() { + String flagKey = "feature_1"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + user.setAttribute("country", "CA"); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("User \"%s\" does not meet conditions for targeting rule \"%d\".", userId, 1) + )); + } + + @Test + public void decideReasons_userBucketedIntoTargetingRule() { + String flagKey = "feature_1"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + user.setAttribute("country", "US"); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("The user \"%s\" was bucketed into a rollout for feature flag \"%s\".", userId, flagKey) + )); + } + + @Test + public void decideReasons_userBucketedIntoEveryoneTargetingRule() { + String flagKey = "feature_1"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + user.setAttribute("country", "KO"); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("User \"%s\" meets conditions for targeting rule \"Everyone Else\".", userId) + )); + } + + @Test + public void decideReasons_userNotBucketedIntoTargetingRule() { + String flagKey = "feature_1"; + String experimentKey = "3332020494"; // experimentKey of rollout[2] + + OptimizelyUserContext user = optimizely.createUserContext(userId); + user.setAttribute("browser", "safari"); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("User with bucketingId \"%s\" is not in any variation of experiment \"%s\".", userId, experimentKey) + )); + } + + @Test + public void decideReasons_userBucketedIntoVariationInExperiment() { + String flagKey = "feature_2"; + String experimentKey = "exp_no_audience"; + String variationKey = "variation_with_traffic"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("User with bucketingId \"%s\" is in variation \"%s\" of experiment \"%s\".", userId, variationKey, experimentKey) + )); + } + + @Test + public void decideReasons_userNotBucketedIntoVariation() { + String flagKey = "feature_2"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getTrafficAllocation()).thenReturn(Arrays.asList(new TrafficAllocation("any-id", 0))); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", 25)); + + assertTrue(decision.getReasons().contains( + String.format("User with bucketingId \"%s\" is not in any variation of experiment \"exp_no_audience\".", userId) + )); + } + + @Test + public void decideReasons_userBucketedIntoExperimentInGroup() { + String flagKey = "feature_3"; + String experimentId = "10390965532"; // "group_exp_1" + + FeatureFlag flag = getSpyFeatureFlag(flagKey); + when(flag.getExperimentIds()).thenReturn(Arrays.asList(experimentId)); + addSpyFeatureFlag(flag); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("User with bucketingId \"tester\" is in experiment \"group_exp_1\" of group 13142870430.") + )); + } + + @Test + public void decideReasons_userNotBucketedIntoExperimentInGroup() { + String flagKey = "feature_3"; + String experimentId = "10420843432"; // "group_exp_2" + + FeatureFlag flag = getSpyFeatureFlag(flagKey); + when(flag.getExperimentIds()).thenReturn(Arrays.asList(experimentId)); + addSpyFeatureFlag(flag); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("User with bucketingId \"tester\" is not in experiment \"group_exp_2\" of group 13142870430.") + )); + } + + @Test + public void decideReasons_userNotBucketedIntoAnyExperimentInGroup() { + String flagKey = "feature_3"; + String experimentId = "10390965532"; // "group_exp_1" + String groupId = "13142870430"; + + FeatureFlag flag = getSpyFeatureFlag(flagKey); + when(flag.getExperimentIds()).thenReturn(Arrays.asList(experimentId)); + addSpyFeatureFlag(flag); + + Group group = getSpyGroup(groupId); + when(group.getTrafficAllocation()).thenReturn(Collections.emptyList()); + addSpyGroup(group); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("User with bucketingId \"tester\" is not in any experiment of group 13142870430.") + )); + } + + @Test + public void decideReasons_userNotInExperiment() { + String flagKey = "feature_1"; + String experimentKey = "exp_with_audience"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("User \"%s\" does not meet conditions to be in experiment \"%s\".", userId, experimentKey) + )); + } + + // utils + + Map createUserProfileMap(String experimentId, String variationId) { + Map userProfileMap = new HashMap(); + userProfileMap.put(UserProfileService.userIdKey, userId); + + Map decisionMap = new HashMap(1); + decisionMap.put(UserProfileService.variationIdKey, variationId); + + Map> decisionsMap = new HashMap>(); + decisionsMap.put(experimentId, decisionMap); + userProfileMap.put(UserProfileService.experimentBucketMapKey, decisionsMap); + + return userProfileMap; + } + + void setAudienceForFeatureTest(String flagKey, String audienceId) throws ConfigParseException { + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + } + + Experiment getSpyExperiment(String flagKey) { + setMockConfig(); + String experimentId = config.getFeatureKeyMapping().get(flagKey).getExperimentIds().get(0); + return spy(experimentIdMapping.get(experimentId)); + } + + FeatureFlag getSpyFeatureFlag(String flagKey) { + setMockConfig(); + return spy(config.getFeatureKeyMapping().get(flagKey)); + } + + Group getSpyGroup(String groupId) { + setMockConfig(); + return spy(groupIdMapping.get(groupId)); + } + + void addSpyExperiment(Experiment experiment) { + experimentIdMapping.put(experiment.getId(), experiment); + when(config.getExperimentIdMapping()).thenReturn(experimentIdMapping); + } + + void addSpyFeatureFlag(FeatureFlag flag) { + featureKeyMapping.put(flag.getKey(), flag); + when(config.getFeatureKeyMapping()).thenReturn(featureKeyMapping); + } + + void addSpyGroup(Group group) { + groupIdMapping.put(group.getId(), group); + when(config.getGroupIdMapping()).thenReturn(groupIdMapping); + } + + void setMockConfig() { + if (config != null) return; + + ProjectConfig configReal = null; + try { + configReal = new DatafileProjectConfig.Builder().withDatafile(datafile).build(); + config = spy(configReal); + optimizely = Optimizely.builder().withConfig(config).build(); + experimentIdMapping = new HashMap<>(config.getExperimentIdMapping()); + groupIdMapping = new HashMap<>(config.getGroupIdMapping()); + featureKeyMapping = new HashMap<>(config.getFeatureKeyMapping()); + } catch (ConfigParseException e) { + fail("ProjectConfig build failed"); + } + } + + OptimizelyDecision callDecideWithIncludeReasons(String flagKey, Map attributes) { + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + return user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + } + + OptimizelyDecision callDecideWithIncludeReasons(String flagKey) { + return callDecideWithIncludeReasons(flagKey, Collections.emptyMap()); + } + +} 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 2a3030314..d12cd9a56 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 @@ -15,18 +15,12 @@ ***************************************************************************/ package com.optimizely.ab.bucketing; -import com.optimizely.ab.config.Experiment; -import com.optimizely.ab.config.FeatureFlag; -import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.config.DatafileProjectConfigTestUtils; -import com.optimizely.ab.config.Rollout; -import com.optimizely.ab.config.TrafficAllocation; -import com.optimizely.ab.config.ValidProjectConfigV4; -import com.optimizely.ab.config.Variation; +import ch.qos.logback.classic.Level; +import com.optimizely.ab.config.*; import com.optimizely.ab.error.ErrorHandler; -import com.optimizely.ab.internal.LogbackVerifier; - import com.optimizely.ab.internal.ControlAttribute; +import com.optimizely.ab.internal.LogbackVerifier; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -39,42 +33,13 @@ import java.util.List; import java.util.Map; -import ch.qos.logback.classic.Level; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; - -import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.noAudienceProjectConfigV3; -import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV3; -import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4; -import static com.optimizely.ab.config.ValidProjectConfigV4.ATTRIBUTE_HOUSE_KEY; -import static com.optimizely.ab.config.ValidProjectConfigV4.ATTRIBUTE_NATIONALITY_KEY; -import static com.optimizely.ab.config.ValidProjectConfigV4.AUDIENCE_ENGLISH_CITIZENS_VALUE; -import static com.optimizely.ab.config.ValidProjectConfigV4.AUDIENCE_GRYFFINDOR_VALUE; -import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_FLAG_MULTI_VARIATE_FEATURE; -import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_FLAG_SINGLE_VARIABLE_INTEGER; -import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_MULTI_VARIATE_FEATURE_KEY; -import static com.optimizely.ab.config.ValidProjectConfigV4.ROLLOUT_2; -import static com.optimizely.ab.config.ValidProjectConfigV4.ROLLOUT_3_EVERYONE_ELSE_RULE; -import static com.optimizely.ab.config.ValidProjectConfigV4.ROLLOUT_3_EVERYONE_ELSE_RULE_ENABLED_VARIATION; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.*; +import static com.optimizely.ab.config.ValidProjectConfigV4.*; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.anyMapOf; -import static org.mockito.Matchers.anyString; -import static org.mockito.Matchers.eq; -import static org.mockito.Mockito.atMost; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.junit.Assert.*; +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.*; public class DecisionServiceTest { @@ -130,8 +95,8 @@ public void getVariationWhitelistedPrecedesAudienceEval() throws Exception { // no attributes provided for a experiment that has an audience assertThat(decisionService.getVariation(experiment, whitelistedUserId, Collections.emptyMap(), validProjectConfig), is(expectedVariation)); - verify(decisionService).getWhitelistedVariation(experiment, whitelistedUserId); - verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), any(ProjectConfig.class)); + verify(decisionService).getWhitelistedVariation(eq(experiment), eq(whitelistedUserId), anyObject()); + verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), any(ProjectConfig.class), anyObject()); } /** @@ -153,7 +118,7 @@ public void getForcedVariationBeforeWhitelisting() throws Exception { assertThat(decisionService.getVariation(experiment, whitelistedUserId, Collections.emptyMap(), validProjectConfig), is(expectedVariation)); //verify(decisionService).getForcedVariation(experiment.getKey(), whitelistedUserId); - verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), any(ProjectConfig.class)); + verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), any(ProjectConfig.class), anyObject()); assertEquals(decisionService.getWhitelistedVariation(experiment, whitelistedUserId), whitelistVariation); assertTrue(decisionService.setForcedVariation(experiment, whitelistedUserId, null)); assertNull(decisionService.getForcedVariation(experiment, whitelistedUserId)); @@ -177,7 +142,7 @@ public void getVariationForcedPrecedesAudienceEval() throws Exception { // no attributes provided for a experiment that has an audience assertThat(decisionService.getVariation(experiment, genericUserId, Collections.emptyMap(), validProjectConfig), is(expectedVariation)); - verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), eq(validProjectConfig)); + verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), eq(validProjectConfig), anyObject()); assertEquals(decisionService.setForcedVariation(experiment, genericUserId, null), true); assertNull(decisionService.getForcedVariation(experiment, genericUserId)); } @@ -322,14 +287,17 @@ public void getVariationForFeatureReturnsNullWhenItGetsNoVariationsForExperiment any(Experiment.class), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject(), + anyObject() ); // do not bucket to any rollouts doReturn(new FeatureDecision(null, null, null)).when(decisionService).getVariationForFeatureInRollout( any(FeatureFlag.class), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject() ); // try to get a variation back from the decision service for the feature flag @@ -363,14 +331,18 @@ public void getVariationForFeatureReturnsVariationReturnedFromGetVariation() { eq(ValidProjectConfigV4.EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject(), + anyObject() ); doReturn(ValidProjectConfigV4.VARIATION_MUTEX_GROUP_EXP_2_VAR_1).when(decisionService).getVariation( eq(ValidProjectConfigV4.EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject(), + anyObject() ); FeatureDecision featureDecision = decisionService.getVariationForFeature( @@ -409,7 +381,9 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() eq(featureExperiment), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject(), + anyObject() ); // return variation for rollout @@ -418,7 +392,8 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() eq(featureFlag), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject() ); // make sure we get the right variation back @@ -436,7 +411,8 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() any(FeatureFlag.class), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject() ); // make sure we ask for experiment bucketing once @@ -444,7 +420,9 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() any(Experiment.class), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject(), + anyObject() ); } @@ -469,7 +447,9 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails eq(featureExperiment), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject(), + anyObject() ); // return variation for rollout @@ -478,7 +458,8 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails eq(featureFlag), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject() ); // make sure we get the right variation back @@ -496,7 +477,8 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails any(FeatureFlag.class), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject() ); // make sure we ask for experiment bucketing once @@ -504,7 +486,9 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails any(Experiment.class), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject(), + anyObject() ); logbackVerifier.expectMessage( @@ -550,7 +534,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenFeatureIsNotAttachedTo @Test public void getVariationForFeatureInRolloutReturnsNullWhenUserIsExcludedFromAllTraffic() { Bucketer mockBucketer = mock(Bucketer.class); - when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(null); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(null); DecisionService decisionService = new DecisionService( mockBucketer, @@ -572,7 +556,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserIsExcludedFromAllT // with fall back bucketing, the user has at most 2 chances to get bucketed with traffic allocation // one chance with the audience rollout rule // one chance with the everyone else rule - verify(mockBucketer, atMost(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); + verify(mockBucketer, atMost(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); } /** @@ -583,7 +567,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserIsExcludedFromAllT @Test public void getVariationForFeatureInRolloutReturnsNullWhenUserFailsAllAudiencesAndTraffic() { Bucketer mockBucketer = mock(Bucketer.class); - when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(null); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(null); DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null); @@ -597,7 +581,7 @@ public void getVariationForFeatureInRolloutReturnsNullWhenUserFailsAllAudiencesA assertNull(featureDecision.decisionSource); // user is only bucketed once for the everyone else rule - verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); + verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); } /** @@ -611,7 +595,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsAllAudie Rollout rollout = ROLLOUT_2; Experiment everyoneElseRule = rollout.getExperiments().get(rollout.getExperiments().size() - 1); Variation expectedVariation = everyoneElseRule.getVariations().get(0); - when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class))).thenReturn(expectedVariation); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(expectedVariation); DecisionService decisionService = new DecisionService( mockBucketer, @@ -637,7 +621,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsAllAudie assertEquals(FeatureDecision.DecisionSource.ROLLOUT, featureDecision.decisionSource); // verify user is only bucketed once for everyone else rule - verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); + verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); } /** @@ -652,8 +636,8 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI Rollout rollout = ROLLOUT_2; Experiment everyoneElseRule = rollout.getExperiments().get(rollout.getExperiments().size() - 1); Variation expectedVariation = everyoneElseRule.getVariations().get(0); - when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(null); - when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class))).thenReturn(expectedVariation); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(null); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(expectedVariation); DecisionService decisionService = new DecisionService( mockBucketer, @@ -675,7 +659,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI logbackVerifier.expectMessage(Level.DEBUG, "User \"genericUserId\" meets conditions for targeting rule \"Everyone Else\"."); // verify user is only bucketed once for everyone else rule - verify(mockBucketer, times(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); + verify(mockBucketer, times(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); } /** @@ -694,9 +678,9 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI Variation englishCitizenVariation = englishCitizensRule.getVariations().get(0); Experiment everyoneElseRule = rollout.getExperiments().get(rollout.getExperiments().size() - 1); Variation expectedVariation = everyoneElseRule.getVariations().get(0); - when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(null); - when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class))).thenReturn(expectedVariation); - when(mockBucketer.bucket(eq(englishCitizensRule), anyString(), any(ProjectConfig.class))).thenReturn(englishCitizenVariation); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(null); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(expectedVariation); + when(mockBucketer.bucket(eq(englishCitizensRule), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(englishCitizenVariation); DecisionService decisionService = new DecisionService( mockBucketer, @@ -721,7 +705,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI assertEquals(FeatureDecision.DecisionSource.ROLLOUT, featureDecision.decisionSource); // verify user is only bucketed once for everyone else rule - verify(mockBucketer, times(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); + verify(mockBucketer, times(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); } /** @@ -737,9 +721,9 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTargetin Variation englishCitizenVariation = englishCitizensRule.getVariations().get(0); Experiment everyoneElseRule = rollout.getExperiments().get(rollout.getExperiments().size() - 1); Variation everyoneElseVariation = everyoneElseRule.getVariations().get(0); - when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(null); - when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class))).thenReturn(everyoneElseVariation); - when(mockBucketer.bucket(eq(englishCitizensRule), anyString(), any(ProjectConfig.class))).thenReturn(englishCitizenVariation); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(null); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(everyoneElseVariation); + when(mockBucketer.bucket(eq(englishCitizensRule), anyString(), any(ProjectConfig.class), anyObject())).thenReturn(englishCitizenVariation); DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null); @@ -759,7 +743,7 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTargetin logbackVerifier.expectMessage(Level.DEBUG, "Audience \"4194404272\" evaluated to true."); logbackVerifier.expectMessage(Level.INFO, "Audiences for rule \"3\" collectively evaluated to true"); // verify user is only bucketed once for everyone else rule - verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); + verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class), anyObject()); } //========= white list tests ==========/ @@ -912,7 +896,7 @@ public void getVariationSavesBucketedVariationIntoUserProfile() throws Exception Collections.singletonMap(experiment.getId(), decision)); Bucketer mockBucketer = mock(Bucketer.class); - when(mockBucketer.bucket(experiment, userProfileId, noAudienceProjectConfig)).thenReturn(variation); + when(mockBucketer.bucket(eq(experiment), eq(userProfileId), eq(noAudienceProjectConfig), anyObject())).thenReturn(variation); DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, userProfileService); @@ -974,7 +958,7 @@ public void getVariationSavesANewUserProfile() throws Exception { UserProfileService userProfileService = mock(UserProfileService.class); DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService); - when(bucketer.bucket(experiment, userProfileId, noAudienceProjectConfig)).thenReturn(variation); + when(bucketer.bucket(eq(experiment), eq(userProfileId), eq(noAudienceProjectConfig), anyObject())).thenReturn(variation); when(userProfileService.lookup(userProfileId)).thenReturn(null); assertEquals(variation, decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), noAudienceProjectConfig)); @@ -988,7 +972,7 @@ public void getVariationBucketingId() throws Exception { Experiment experiment = validProjectConfig.getExperiments().get(0); Variation expectedVariation = experiment.getVariations().get(0); - when(bucketer.bucket(experiment, "bucketId", validProjectConfig)).thenReturn(expectedVariation); + when(bucketer.bucket(eq(experiment), eq("bucketId"), eq(validProjectConfig), anyObject())).thenReturn(expectedVariation); Map attr = new HashMap(); attr.put(ControlAttribute.BUCKETING_ATTRIBUTE.toString(), "bucketId"); @@ -1012,8 +996,8 @@ public void getVariationForRolloutWithBucketingId() { attributes.put(ControlAttribute.BUCKETING_ATTRIBUTE.toString(), bucketingId); Bucketer bucketer = mock(Bucketer.class); - when(bucketer.bucket(rolloutRuleExperiment, userId, v4ProjectConfig)).thenReturn(null); - when(bucketer.bucket(rolloutRuleExperiment, bucketingId, v4ProjectConfig)).thenReturn(rolloutVariation); + when(bucketer.bucket(eq(rolloutRuleExperiment), eq(userId), eq(v4ProjectConfig), anyObject())).thenReturn(null); + when(bucketer.bucket(eq(rolloutRuleExperiment), eq(bucketingId), eq(v4ProjectConfig), anyObject())).thenReturn(rolloutVariation); DecisionService decisionService = spy(new DecisionService( bucketer, 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 772d22ef7..216099298 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 @@ -18,27 +18,18 @@ import ch.qos.logback.classic.Level; import com.optimizely.ab.internal.LogbackVerifier; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import java.math.BigInteger; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.*; -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; -import static org.mockito.Mockito.when; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; /** * Tests for the evaluation of different audience condition types (And, Or, Not, and UserAttribute) @@ -53,6 +44,8 @@ public class AudienceConditionEvaluationTest { Map testUserAttributes; Map testTypedUserAttributes; + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + @Before public void initialize() { testUserAttributes = new HashMap<>(); @@ -77,7 +70,7 @@ public void userAttributeEvaluateTrue() throws Exception { assertNull(testInstance.getMatch()); assertEquals(testInstance.getName(), "browser_type"); assertEquals(testInstance.getType(), "custom_attribute"); - assertTrue(testInstance.evaluate(null, testUserAttributes)); + assertTrue(testInstance.evaluate(null, testUserAttributes, reasons)); } /** @@ -86,7 +79,7 @@ public void userAttributeEvaluateTrue() throws Exception { @Test public void userAttributeEvaluateFalse() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", null, "firefox"); - assertFalse(testInstance.evaluate(null, testUserAttributes)); + assertFalse(testInstance.evaluate(null, testUserAttributes, reasons)); } /** @@ -95,7 +88,7 @@ public void userAttributeEvaluateFalse() throws Exception { @Test public void userAttributeUnknownAttribute() throws Exception { UserAttribute testInstance = new UserAttribute("unknown_dim", "custom_attribute", null, "unknown"); - assertFalse(testInstance.evaluate(null, testUserAttributes)); + assertFalse(testInstance.evaluate(null, testUserAttributes, reasons)); } /** @@ -104,7 +97,7 @@ public void userAttributeUnknownAttribute() throws Exception { @Test public void invalidMatchCondition() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "unknown_dimension", null, "chrome"); - assertNull(testInstance.evaluate(null, testUserAttributes)); + assertNull(testInstance.evaluate(null, testUserAttributes, reasons)); } /** @@ -113,7 +106,7 @@ public void invalidMatchCondition() throws Exception { @Test public void invalidMatch() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "blah", "chrome"); - assertNull(testInstance.evaluate(null, testUserAttributes)); + assertNull(testInstance.evaluate(null, testUserAttributes, reasons)); logbackVerifier.expectMessage(Level.WARN, "Audience condition \"{name='browser_type', type='custom_attribute', match='blah', value='chrome'}\" uses an unknown match type. You may need to upgrade to a newer release of the Optimizely SDK"); } @@ -124,7 +117,7 @@ public void invalidMatch() throws Exception { @Test public void unexpectedAttributeType() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "gt", 20); - assertNull(testInstance.evaluate(null, testUserAttributes)); + assertNull(testInstance.evaluate(null, testUserAttributes, reasons)); logbackVerifier.expectMessage(Level.WARN, "Audience condition \"{name='browser_type', type='custom_attribute', match='gt', value=20}\" evaluated to UNKNOWN because a value of type \"java.lang.String\" was passed for user attribute \"browser_type\""); } @@ -135,7 +128,7 @@ public void unexpectedAttributeType() throws Exception { @Test public void unexpectedAttributeTypeNull() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "gt", 20); - assertNull(testInstance.evaluate(null, Collections.singletonMap("browser_type", null))); + assertNull(testInstance.evaluate(null, Collections.singletonMap("browser_type", null), reasons)); logbackVerifier.expectMessage(Level.DEBUG, "Audience condition \"{name='browser_type', type='custom_attribute', match='gt', value=20}\" evaluated to UNKNOWN because a null value was passed for user attribute \"browser_type\""); } @@ -147,7 +140,7 @@ public void unexpectedAttributeTypeNull() throws Exception { @Test public void missingAttribute() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "gt", 20); - assertNull(testInstance.evaluate(null, Collections.EMPTY_MAP)); + assertNull(testInstance.evaluate(null, Collections.EMPTY_MAP, reasons)); logbackVerifier.expectMessage(Level.DEBUG, "Audience condition \"{name='browser_type', type='custom_attribute', match='gt', value=20}\" evaluated to UNKNOWN because no value was passed for user attribute \"browser_type\""); } @@ -158,7 +151,7 @@ public void missingAttribute() throws Exception { @Test public void nullAttribute() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "gt", 20); - assertNull(testInstance.evaluate(null, null)); + assertNull(testInstance.evaluate(null, null, reasons)); logbackVerifier.expectMessage(Level.DEBUG, "Audience condition \"{name='browser_type', type='custom_attribute', match='gt', value=20}\" evaluated to UNKNOWN because no value was passed for user attribute \"browser_type\""); } @@ -169,7 +162,7 @@ public void nullAttribute() throws Exception { @Test public void unknownConditionType() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "blah", "exists", "firefox"); - assertNull(testInstance.evaluate(null, testUserAttributes)); + assertNull(testInstance.evaluate(null, testUserAttributes, reasons)); logbackVerifier.expectMessage(Level.WARN, "Audience condition \"{name='browser_type', type='blah', match='exists', value='firefox'}\" uses an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK."); } @@ -183,9 +176,9 @@ 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(null, attributes)); + assertTrue(testInstance.evaluate(null, attributes, reasons)); attributes.put("browser_type", null); - assertFalse(testInstance.evaluate(null, attributes)); + assertFalse(testInstance.evaluate(null, attributes, reasons)); } /** @@ -195,16 +188,16 @@ public void existsMatchConditionEmptyStringEvaluatesTrue() throws Exception { @Test public void existsMatchConditionEvaluatesTrue() throws Exception { UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "exists", "firefox"); - assertTrue(testInstance.evaluate(null, testUserAttributes)); + assertTrue(testInstance.evaluate(null, testUserAttributes, reasons)); 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(null, testTypedUserAttributes)); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceObject.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceObject.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -215,8 +208,8 @@ public void existsMatchConditionEvaluatesTrue() throws Exception { 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(null, testTypedUserAttributes)); - assertFalse(testInstanceNull.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstance.evaluate(null, testTypedUserAttributes, reasons)); + assertFalse(testInstanceNull.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -231,11 +224,11 @@ public void exactMatchConditionEvaluatesTrue() { UserAttribute testInstanceFloat = new UserAttribute("num_size", "custom_attribute", "exact", (float) 3); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "exact", 3.55); - assertTrue(testInstanceString.evaluate(null, testUserAttributes)); - assertTrue(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 3))); - assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceString.evaluate(null, testUserAttributes, reasons)); + assertTrue(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 3), reasons)); + assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -268,22 +261,22 @@ public void exactMatchConditionEvaluatesNullWithInvalidUserAttr() { assertNull(testInstanceInteger.evaluate( null, - Collections.singletonMap("num_size", bigInteger))); + Collections.singletonMap("num_size", bigInteger), reasons)); assertNull(testInstanceFloat.evaluate( null, - Collections.singletonMap("num_size", invalidFloatValue))); + Collections.singletonMap("num_size", invalidFloatValue), reasons)); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); + Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble), reasons)); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); + Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble), reasons)); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", infiniteNANDouble)))); + Collections.singletonMap("num_counts", infiniteNANDouble)), reasons)); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", largeDouble)))); + Collections.singletonMap("num_counts", largeDouble)), reasons)); } /** @@ -301,10 +294,10 @@ public void invalidExactMatchConditionEvaluatesNull() { UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "exact", infiniteNegativeInfiniteDouble); UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "exact", infiniteNANDouble); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -318,10 +311,10 @@ public void exactMatchConditionEvaluatesFalse() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "exact", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "exact", 5.55); - assertFalse(testInstanceString.evaluate(null, testUserAttributes)); - assertFalse(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceString.evaluate(null, testUserAttributes, reasons)); + assertFalse(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); + assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -337,15 +330,15 @@ public void exactMatchConditionEvaluatesNull() { 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(null, testTypedUserAttributes)); - assertNull(testInstanceString.evaluate(null, testUserAttributes)); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceDouble.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceString.evaluate(null, testUserAttributes, reasons)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes, reasons)); Map attr = new HashMap<>(); attr.put("browser_type", "true"); - assertNull(testInstanceString.evaluate(null, attr)); + assertNull(testInstanceString.evaluate(null, attr, reasons)); } /** @@ -359,13 +352,13 @@ public void gtMatchConditionEvaluatesTrue() { UserAttribute testInstanceFloat = new UserAttribute("num_size", "custom_attribute", "gt", (float) 2); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "gt", 2.55); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 3))); - assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 3), reasons)); + assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); Map badAttributes = new HashMap<>(); badAttributes.put("num_size", "bobs burgers"); - assertNull(testInstanceInteger.evaluate(null, badAttributes)); + assertNull(testInstanceInteger.evaluate(null, badAttributes, reasons)); } /** @@ -399,22 +392,22 @@ public void gtMatchConditionEvaluatesNullWithInvalidUserAttr() { assertNull(testInstanceInteger.evaluate( null, - Collections.singletonMap("num_size", bigInteger))); + Collections.singletonMap("num_size", bigInteger), reasons)); assertNull(testInstanceFloat.evaluate( null, - Collections.singletonMap("num_size", invalidFloatValue))); + Collections.singletonMap("num_size", invalidFloatValue), reasons)); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); + Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble), reasons)); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); + Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble), reasons)); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", infiniteNANDouble)))); + Collections.singletonMap("num_counts", infiniteNANDouble)), reasons)); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", largeDouble)))); + Collections.singletonMap("num_counts", largeDouble)), reasons)); } /** @@ -432,10 +425,10 @@ public void gtMatchConditionEvaluatesNullWithInvalidAttr() { UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "gt", infiniteNegativeInfiniteDouble); UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "gt", infiniteNANDouble); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -448,8 +441,8 @@ public void gtMatchConditionEvaluatesFalse() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "gt", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "gt", 5.55); - assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -463,10 +456,10 @@ public void gtMatchConditionEvaluatesNull() { 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(null, testUserAttributes)); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceString.evaluate(null, testUserAttributes, reasons)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes, reasons)); } @@ -481,13 +474,13 @@ public void geMatchConditionEvaluatesTrue() { UserAttribute testInstanceFloat = new UserAttribute("num_size", "custom_attribute", "ge", (float) 2); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "ge", 2.55); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 2))); - assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceFloat.evaluate(null, Collections.singletonMap("num_size", (float) 2), reasons)); + assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); Map badAttributes = new HashMap<>(); badAttributes.put("num_size", "bobs burgers"); - assertNull(testInstanceInteger.evaluate(null, badAttributes)); + assertNull(testInstanceInteger.evaluate(null, badAttributes, reasons)); } /** @@ -521,22 +514,22 @@ public void geMatchConditionEvaluatesNullWithInvalidUserAttr() { assertNull(testInstanceInteger.evaluate( null, - Collections.singletonMap("num_size", bigInteger))); + Collections.singletonMap("num_size", bigInteger), reasons)); assertNull(testInstanceFloat.evaluate( null, - Collections.singletonMap("num_size", invalidFloatValue))); + Collections.singletonMap("num_size", invalidFloatValue), reasons)); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); + Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble), reasons)); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); + Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble), reasons)); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", infiniteNANDouble)))); + Collections.singletonMap("num_counts", infiniteNANDouble)), reasons)); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", largeDouble)))); + Collections.singletonMap("num_counts", largeDouble)), reasons)); } /** @@ -554,10 +547,10 @@ public void geMatchConditionEvaluatesNullWithInvalidAttr() { UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "ge", infiniteNegativeInfiniteDouble); UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "ge", infiniteNANDouble); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -570,8 +563,8 @@ public void geMatchConditionEvaluatesFalse() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "ge", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "ge", 5.55); - assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -585,10 +578,10 @@ public void geMatchConditionEvaluatesNull() { UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "ge", 3.5); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "ge", 3.5); - assertNull(testInstanceString.evaluate(null, testUserAttributes)); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceString.evaluate(null, testUserAttributes, reasons)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes, reasons)); } @@ -602,8 +595,8 @@ public void ltMatchConditionEvaluatesTrue() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "lt", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "lt", 5.55); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -616,8 +609,8 @@ public void ltMatchConditionEvaluatesFalse() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "lt", 2); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "lt", 2.55); - assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -631,10 +624,10 @@ public void ltMatchConditionEvaluatesNull() { 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(null, testUserAttributes)); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceString.evaluate(null, testUserAttributes, reasons)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -668,22 +661,22 @@ public void ltMatchConditionEvaluatesNullWithInvalidUserAttr() { assertNull(testInstanceInteger.evaluate( null, - Collections.singletonMap("num_size", bigInteger))); + Collections.singletonMap("num_size", bigInteger), reasons)); assertNull(testInstanceFloat.evaluate( null, - Collections.singletonMap("num_size", invalidFloatValue))); + Collections.singletonMap("num_size", invalidFloatValue), reasons)); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); + Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble), reasons)); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); + Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble), reasons)); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", infiniteNANDouble)))); + Collections.singletonMap("num_counts", infiniteNANDouble)), reasons)); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", largeDouble)))); + Collections.singletonMap("num_counts", largeDouble)), reasons)); } /** @@ -701,10 +694,10 @@ public void ltMatchConditionEvaluatesNullWithInvalidAttributes() { UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "lt", infiniteNegativeInfiniteDouble); UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "lt", infiniteNANDouble); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes, reasons)); } @@ -718,8 +711,8 @@ public void leMatchConditionEvaluatesTrue() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "le", 5); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "le", 5.55); - assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertTrue(testInstanceDouble.evaluate(null, Collections.singletonMap("num_counts", 5.55))); + assertTrue(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertTrue(testInstanceDouble.evaluate(null, Collections.singletonMap("num_counts", 5.55), reasons)); } /** @@ -732,8 +725,8 @@ public void leMatchConditionEvaluatesFalse() { UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "le", 2); UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "le", 2.55); - assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes)); + assertFalse(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertFalse(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -747,10 +740,10 @@ public void leMatchConditionEvaluatesNull() { UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "le", 3.5); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "le", 3.5); - assertNull(testInstanceString.evaluate(null, testUserAttributes)); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceString.evaluate(null, testUserAttributes, reasons)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -784,22 +777,22 @@ public void leMatchConditionEvaluatesNullWithInvalidUserAttr() { assertNull(testInstanceInteger.evaluate( null, - Collections.singletonMap("num_size", bigInteger))); + Collections.singletonMap("num_size", bigInteger), reasons)); assertNull(testInstanceFloat.evaluate( null, - Collections.singletonMap("num_size", invalidFloatValue))); + Collections.singletonMap("num_size", invalidFloatValue), reasons)); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble))); + Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble), reasons)); assertNull(testInstanceDouble.evaluate( null, - Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble))); + Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble), reasons)); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", infiniteNANDouble)))); + Collections.singletonMap("num_counts", infiniteNANDouble)), reasons)); assertNull(testInstanceDouble.evaluate( null, Collections.singletonMap("num_counts", - Collections.singletonMap("num_counts", largeDouble)))); + Collections.singletonMap("num_counts", largeDouble)), reasons)); } /** @@ -817,10 +810,10 @@ public void leMatchConditionEvaluatesNullWithInvalidAttributes() { UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "le", infiniteNegativeInfiniteDouble); UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "le", infiniteNANDouble); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstancePositiveInfinite.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNANDouble.evaluate(null, testTypedUserAttributes, reasons)); } /** @@ -830,7 +823,7 @@ public void leMatchConditionEvaluatesNullWithInvalidAttributes() { @Test public void substringMatchConditionEvaluatesTrue() { UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "substring", "chrome"); - assertTrue(testInstanceString.evaluate(null, testUserAttributes)); + assertTrue(testInstanceString.evaluate(null, testUserAttributes, reasons)); } /** @@ -840,7 +833,7 @@ public void substringMatchConditionEvaluatesTrue() { @Test public void substringMatchConditionPartialMatchEvaluatesTrue() { UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "substring", "chro"); - assertTrue(testInstanceString.evaluate(null, testUserAttributes)); + assertTrue(testInstanceString.evaluate(null, testUserAttributes, reasons)); } /** @@ -850,7 +843,7 @@ public void substringMatchConditionPartialMatchEvaluatesTrue() { @Test public void substringMatchConditionEvaluatesFalse() { UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "substring", "chr0me"); - assertFalse(testInstanceString.evaluate(null, testUserAttributes)); + assertFalse(testInstanceString.evaluate(null, testUserAttributes, reasons)); } /** @@ -865,11 +858,11 @@ public void substringMatchConditionEvaluatesNull() { UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "substring", "chrome1"); UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "substring", "chrome1"); - assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceDouble.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes)); - assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes)); + assertNull(testInstanceBoolean.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceInteger.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceDouble.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceObject.evaluate(null, testTypedUserAttributes, reasons)); + assertNull(testInstanceNull.evaluate(null, testTypedUserAttributes, reasons)); } //======== Semantic version evaluation tests ========// @@ -880,7 +873,7 @@ public void testSemanticVersionEqualsMatchInvalidInput() { Map testAttributes = new HashMap(); testAttributes.put("version", 2.0); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); } @Test @@ -888,7 +881,7 @@ public void semanticVersionInvalidMajorShouldBeNumberOnly() { Map testAttributes = new HashMap(); testAttributes.put("version", "a.1.2"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); } @Test @@ -896,7 +889,7 @@ public void semanticVersionInvalidMinorShouldBeNumberOnly() { Map testAttributes = new HashMap(); testAttributes.put("version", "1.b.2"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); } @Test @@ -904,7 +897,7 @@ public void semanticVersionInvalidPatchShouldBeNumberOnly() { Map testAttributes = new HashMap(); testAttributes.put("version", "1.2.c"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test SemanticVersionEqualsMatch returns null if given invalid UserCondition Variable type @@ -913,7 +906,7 @@ public void testSemanticVersionEqualsMatchInvalidUserConditionVariable() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.0"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", 2.0); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test SemanticVersionGTMatch returns null if given invalid value type @@ -922,7 +915,7 @@ public void testSemanticVersionGTMatchInvalidInput() { Map testAttributes = new HashMap(); testAttributes.put("version", false); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test SemanticVersionGEMatch returns null if given invalid value type @@ -931,7 +924,7 @@ public void testSemanticVersionGEMatchInvalidInput() { Map testAttributes = new HashMap(); testAttributes.put("version", 2); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test SemanticVersionLTMatch returns null if given invalid value type @@ -940,7 +933,7 @@ public void testSemanticVersionLTMatchInvalidInput() { Map testAttributes = new HashMap(); testAttributes.put("version", 2); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test SemanticVersionLEMatch returns null if given invalid value type @@ -949,7 +942,7 @@ public void testSemanticVersionLEMatchInvalidInput() { Map testAttributes = new HashMap(); testAttributes.put("version", 2); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.0.0"); - assertNull(testInstanceString.evaluate(null, testAttributes)); + assertNull(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test if not same when targetVersion is only major.minor.patch and version is major.minor @@ -958,7 +951,7 @@ public void testIsSemanticNotSameConditionValueMajorMinorPatch() { Map testAttributes = new HashMap(); testAttributes.put("version", "1.2"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "1.2.0"); - assertFalse(testInstanceString.evaluate(null, testAttributes)); + assertFalse(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test if same when target is only major but user condition checks only major.minor,patch @@ -967,7 +960,7 @@ public void testIsSemanticSameSingleDigit() { Map testAttributes = new HashMap(); testAttributes.put("version", "3.0.0"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "3"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test if greater when User value patch is greater even when its beta @@ -976,7 +969,7 @@ public void testIsSemanticGreaterWhenUserConditionComparesMajorMinorAndPatchVers Map testAttributes = new HashMap(); testAttributes.put("version", "3.1.1-beta"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "3.1.0"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test if greater when preRelease is greater alphabetically @@ -985,7 +978,7 @@ public void testIsSemanticGreaterWhenMajorMinorPatchReleaseVersionCharacter() { Map testAttributes = new HashMap(); testAttributes.put("version", "3.1.1-beta.y.1+1.1"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "3.1.1-beta.x.1+1.1"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test if greater when preRelease version number is greater @@ -994,7 +987,7 @@ public void testIsSemanticGreaterWhenMajorMinorPatchPreReleaseVersionNum() { Map testAttributes = new HashMap(); testAttributes.put("version", "3.1.1-beta.x.2+1.1"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "3.1.1-beta.x.1+1.1"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test if equals semantic version even when only same preRelease is passed in user attribute and no build meta @@ -1003,7 +996,7 @@ public void testIsSemanticEqualWhenMajorMinorPatchPreReleaseVersionNum() { Map testAttributes = new HashMap(); testAttributes.put("version", "3.1.1-beta.x.1"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "3.1.1-beta.x.1"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test if not same @@ -1012,7 +1005,7 @@ public void testIsSemanticNotSameReturnsFalse() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.2"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.1.1"); - assertFalse(testInstanceString.evaluate(null, testAttributes)); + assertFalse(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test when target is full semantic version major.minor.patch @@ -1021,7 +1014,7 @@ public void testIsSemanticSameFull() { Map testAttributes = new HashMap(); testAttributes.put("version", "3.0.1"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "3.0.1"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare less when user condition checks only major.minor @@ -1030,7 +1023,7 @@ public void testIsSemanticLess() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.6"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.2"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // When user condition checks major.minor but target is major.minor.patch then its equals @@ -1039,7 +1032,7 @@ public void testIsSemanticLessFalse() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.0"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.1"); - assertFalse(testInstanceString.evaluate(null, testAttributes)); + assertFalse(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare less when target is full major.minor.patch @@ -1048,7 +1041,7 @@ public void testIsSemanticFullLess() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.6"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.1.9"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare greater when user condition checks only major.minor @@ -1057,7 +1050,7 @@ public void testIsSemanticMore() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.3.6"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.2"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare greater when both are major.minor.patch-beta but target is greater than user condition @@ -1066,7 +1059,7 @@ public void testIsSemanticMoreWhenBeta() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.3.6-beta"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.3.5-beta"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare greater when target is major.minor.patch @@ -1075,7 +1068,7 @@ public void testIsSemanticFullMore() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.7"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.6"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare greater when target is major.minor.patch is smaller then it returns false @@ -1084,7 +1077,7 @@ public void testSemanticVersionGTFullMoreReturnsFalse() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.10"); - assertFalse(testInstanceString.evaluate(null, testAttributes)); + assertFalse(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare equal when both are exactly same - major.minor.patch-beta @@ -1093,7 +1086,7 @@ public void testIsSemanticFullEqual() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9-beta"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.1.9-beta"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare equal when both major.minor.patch is same, but due to beta user condition is smaller @@ -1102,7 +1095,7 @@ public void testIsSemanticLessWhenBeta() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.9-beta"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare greater when target is major.minor.patch-beta and user condition only compares major.minor.patch @@ -1111,7 +1104,7 @@ public void testIsSemanticGreaterBeta() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.9-beta"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare equal when target is major.minor.patch @@ -1120,7 +1113,7 @@ public void testIsSemanticLessEqualsWhenEqualsReturnsTrue() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.1.9"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare less when target is major.minor.patch @@ -1129,7 +1122,7 @@ public void testIsSemanticLessEqualsWhenLessReturnsTrue() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.132.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.233.91"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare less when target is major.minor.patch @@ -1138,7 +1131,7 @@ public void testIsSemanticLessEqualsWhenGreaterReturnsFalse() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.233.91"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.132.009"); - assertFalse(testInstanceString.evaluate(null, testAttributes)); + assertFalse(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare equal when target is major.minor.patch @@ -1147,7 +1140,7 @@ public void testIsSemanticGreaterEqualsWhenEqualsReturnsTrue() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.1.9"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.1.9"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare less when target is major.minor.patch @@ -1156,7 +1149,7 @@ public void testIsSemanticGreaterEqualsWhenLessReturnsTrue() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.233.91"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.132.9"); - assertTrue(testInstanceString.evaluate(null, testAttributes)); + assertTrue(testInstanceString.evaluate(null, testAttributes, reasons)); } // Test compare less when target is major.minor.patch @@ -1165,7 +1158,7 @@ public void testIsSemanticGreaterEqualsWhenLessReturnsFalse() { Map testAttributes = new HashMap(); testAttributes.put("version", "2.132.009"); UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.233.91"); - assertFalse(testInstanceString.evaluate(null, testAttributes)); + assertFalse(testInstanceString.evaluate(null, testAttributes, reasons)); } /** @@ -1174,7 +1167,7 @@ public void testIsSemanticGreaterEqualsWhenLessReturnsFalse() { @Test public void notConditionEvaluateNull() { NotCondition notCondition = new NotCondition(new NullCondition()); - assertNull(notCondition.evaluate(null, testUserAttributes)); + assertNull(notCondition.evaluate(null, testUserAttributes, reasons)); } /** @@ -1183,11 +1176,11 @@ public void notConditionEvaluateNull() { @Test public void notConditionEvaluateTrue() { UserAttribute userAttribute = mock(UserAttribute.class); - when(userAttribute.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); NotCondition notCondition = new NotCondition(userAttribute); - assertTrue(notCondition.evaluate(null, testUserAttributes)); - verify(userAttribute, times(1)).evaluate(null, testUserAttributes); + assertTrue(notCondition.evaluate(null, testUserAttributes, reasons)); + verify(userAttribute, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); } /** @@ -1196,11 +1189,11 @@ public void notConditionEvaluateTrue() { @Test public void notConditionEvaluateFalse() { UserAttribute userAttribute = mock(UserAttribute.class); - when(userAttribute.evaluate(null, testUserAttributes)).thenReturn(true); + when(userAttribute.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(true); NotCondition notCondition = new NotCondition(userAttribute); - assertFalse(notCondition.evaluate(null, testUserAttributes)); - verify(userAttribute, times(1)).evaluate(null, testUserAttributes); + assertFalse(notCondition.evaluate(null, testUserAttributes, reasons)); + verify(userAttribute, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); } /** @@ -1209,20 +1202,20 @@ public void notConditionEvaluateFalse() { @Test public void orConditionEvaluateTrue() { UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(null, testUserAttributes)).thenReturn(true); + when(userAttribute1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(true); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); List conditions = new ArrayList(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertTrue(orCondition.evaluate(null, testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); + assertTrue(orCondition.evaluate(null, testUserAttributes, reasons)); + verify(userAttribute1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); // shouldn't be called due to short-circuiting in 'Or' evaluation - verify(userAttribute2, times(0)).evaluate(null, testUserAttributes); + verify(userAttribute2, times(0)).evaluate(eq(null), eq(testUserAttributes), anyObject()); } /** @@ -1231,20 +1224,20 @@ public void orConditionEvaluateTrue() { @Test public void orConditionEvaluateTrueWithNullAndTrue() { UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(null, testUserAttributes)).thenReturn(null); + when(userAttribute1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(null); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(true); + when(userAttribute2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(true); List conditions = new ArrayList(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertTrue(orCondition.evaluate(null, testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); + assertTrue(orCondition.evaluate(null, testUserAttributes, reasons)); + verify(userAttribute1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); // shouldn't be called due to short-circuiting in 'Or' evaluation - verify(userAttribute2, times(1)).evaluate(null, testUserAttributes); + verify(userAttribute2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); } /** @@ -1253,20 +1246,20 @@ public void orConditionEvaluateTrueWithNullAndTrue() { @Test public void orConditionEvaluateNullWithNullAndFalse() { UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(null, testUserAttributes)).thenReturn(null); + when(userAttribute1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(null); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); List conditions = new ArrayList(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertNull(orCondition.evaluate(null, testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); + assertNull(orCondition.evaluate(null, testUserAttributes, reasons)); + verify(userAttribute1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); // shouldn't be called due to short-circuiting in 'Or' evaluation - verify(userAttribute2, times(1)).evaluate(null, testUserAttributes); + verify(userAttribute2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); } /** @@ -1275,20 +1268,20 @@ public void orConditionEvaluateNullWithNullAndFalse() { @Test public void orConditionEvaluateFalseWithFalseAndFalse() { UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); List conditions = new ArrayList(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertFalse(orCondition.evaluate(null, testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); + assertFalse(orCondition.evaluate(null, testUserAttributes, reasons)); + verify(userAttribute1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); // shouldn't be called due to short-circuiting in 'Or' evaluation - verify(userAttribute2, times(1)).evaluate(null, testUserAttributes); + verify(userAttribute2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); } /** @@ -1297,19 +1290,19 @@ public void orConditionEvaluateFalseWithFalseAndFalse() { @Test public void orConditionEvaluateFalse() { UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); List conditions = new ArrayList(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertFalse(orCondition.evaluate(null, testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); - verify(userAttribute2, times(1)).evaluate(null, testUserAttributes); + assertFalse(orCondition.evaluate(null, testUserAttributes, reasons)); + verify(userAttribute1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + verify(userAttribute2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); } /** @@ -1318,19 +1311,19 @@ public void orConditionEvaluateFalse() { @Test public void andConditionEvaluateTrue() { OrCondition orCondition1 = mock(OrCondition.class); - when(orCondition1.evaluate(null, testUserAttributes)).thenReturn(true); + when(orCondition1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(true); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(null, testUserAttributes)).thenReturn(true); + when(orCondition2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(true); List conditions = new ArrayList(); conditions.add(orCondition1); conditions.add(orCondition2); AndCondition andCondition = new AndCondition(conditions); - assertTrue(andCondition.evaluate(null, testUserAttributes)); - verify(orCondition1, times(1)).evaluate(null, testUserAttributes); - verify(orCondition2, times(1)).evaluate(null, testUserAttributes); + assertTrue(andCondition.evaluate(null, testUserAttributes, reasons)); + verify(orCondition1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + verify(orCondition2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); } /** @@ -1339,19 +1332,19 @@ public void andConditionEvaluateTrue() { @Test public void andConditionEvaluateFalseWithNullAndFalse() { OrCondition orCondition1 = mock(OrCondition.class); - when(orCondition1.evaluate(null, testUserAttributes)).thenReturn(null); + when(orCondition1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(null); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(null, testUserAttributes)).thenReturn(false); + when(orCondition2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); List conditions = new ArrayList(); conditions.add(orCondition1); conditions.add(orCondition2); AndCondition andCondition = new AndCondition(conditions); - assertFalse(andCondition.evaluate(null, testUserAttributes)); - verify(orCondition1, times(1)).evaluate(null, testUserAttributes); - verify(orCondition2, times(1)).evaluate(null, testUserAttributes); + assertFalse(andCondition.evaluate(null, testUserAttributes, reasons)); + verify(orCondition1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + verify(orCondition2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); } /** @@ -1360,19 +1353,19 @@ public void andConditionEvaluateFalseWithNullAndFalse() { @Test public void andConditionEvaluateNullWithNullAndTrue() { OrCondition orCondition1 = mock(OrCondition.class); - when(orCondition1.evaluate(null, testUserAttributes)).thenReturn(null); + when(orCondition1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(null); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(null, testUserAttributes)).thenReturn(true); + when(orCondition2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(true); List conditions = new ArrayList(); conditions.add(orCondition1); conditions.add(orCondition2); AndCondition andCondition = new AndCondition(conditions); - assertNull(andCondition.evaluate(null, testUserAttributes)); - verify(orCondition1, times(1)).evaluate(null, testUserAttributes); - verify(orCondition2, times(1)).evaluate(null, testUserAttributes); + assertNull(andCondition.evaluate(null, testUserAttributes, reasons)); + verify(orCondition1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); + verify(orCondition2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); } /** @@ -1381,10 +1374,10 @@ public void andConditionEvaluateNullWithNullAndTrue() { @Test public void andConditionEvaluateFalse() { OrCondition orCondition1 = mock(OrCondition.class); - when(orCondition1.evaluate(null, testUserAttributes)).thenReturn(false); + when(orCondition1.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(false); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(null, testUserAttributes)).thenReturn(true); + when(orCondition2.evaluate(eq(null), eq(testUserAttributes), anyObject())).thenReturn(true); // and[false, true] List conditions = new ArrayList(); @@ -1392,13 +1385,13 @@ public void andConditionEvaluateFalse() { conditions.add(orCondition2); AndCondition andCondition = new AndCondition(conditions); - assertFalse(andCondition.evaluate(null, testUserAttributes)); - verify(orCondition1, times(1)).evaluate(null, testUserAttributes); + assertFalse(andCondition.evaluate(null, testUserAttributes, reasons)); + verify(orCondition1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject()); // shouldn't be called due to short-circuiting in 'And' evaluation - verify(orCondition2, times(0)).evaluate(null, testUserAttributes); + verify(orCondition2, times(0)).evaluate(eq(null), eq(testUserAttributes), anyObject()); OrCondition orCondition3 = mock(OrCondition.class); - when(orCondition3.evaluate(null, testUserAttributes)).thenReturn(null); + when(orCondition3.evaluate(null, testUserAttributes, reasons)).thenReturn(null); // and[null, false] List conditions2 = new ArrayList(); @@ -1406,7 +1399,7 @@ public void andConditionEvaluateFalse() { conditions2.add(orCondition1); AndCondition andCondition2 = new AndCondition(conditions2); - assertFalse(andCondition2.evaluate(null, testUserAttributes)); + assertFalse(andCondition2.evaluate(null, testUserAttributes, reasons)); // and[true, false, null] List conditions3 = new ArrayList(); @@ -1415,7 +1408,7 @@ public void andConditionEvaluateFalse() { conditions3.add(orCondition1); AndCondition andCondition3 = new AndCondition(conditions3); - assertFalse(andCondition3.evaluate(null, testUserAttributes)); + assertFalse(andCondition3.evaluate(null, testUserAttributes, reasons)); } /** @@ -1447,8 +1440,8 @@ public void nullValueEvaluate() { attributeValue ); - assertNull(nullValueAttribute.evaluate(null, Collections.emptyMap())); - assertNull(nullValueAttribute.evaluate(null, Collections.singletonMap(attributeName, attributeValue))); - assertNull(nullValueAttribute.evaluate(null, (Collections.singletonMap(attributeName, "")))); + assertNull(nullValueAttribute.evaluate(null, Collections.emptyMap(), reasons)); + assertNull(nullValueAttribute.evaluate(null, Collections.singletonMap(attributeName, attributeValue), reasons)); + assertNull(nullValueAttribute.evaluate(null, (Collections.singletonMap(attributeName, "")), reasons)); } } diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelydecision/OptimizelyDecisionTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelydecision/OptimizelyDecisionTest.java new file mode 100644 index 000000000..dd2c476af --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelydecision/OptimizelyDecisionTest.java @@ -0,0 +1,79 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +import com.optimizely.ab.Optimizely; +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.optimizelyjson.OptimizelyJSON; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static junit.framework.TestCase.assertEquals; +import static org.junit.Assert.assertTrue; + +public class OptimizelyDecisionTest { + + @Test + public void testOptimizelyDecision() { + String variationKey = "var1"; + boolean enabled = true; + OptimizelyJSON variables = new OptimizelyJSON("{\"k1\":\"v1\"}"); + String ruleKey = null; + String flagKey = "flag1"; + OptimizelyUserContext userContext = new OptimizelyUserContext(Optimizely.builder().build(), "tester"); + List reasons = new ArrayList<>(); + + OptimizelyDecision decision = new OptimizelyDecision( + variationKey, + enabled, + variables, + ruleKey, + flagKey, + userContext, + reasons + ); + + assertEquals(decision.getVariationKey(), variationKey); + assertEquals(decision.getEnabled(), enabled); + assertEquals(decision.getVariables(), variables); + assertEquals(decision.getRuleKey(), ruleKey); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), userContext); + assertEquals(decision.getReasons(), reasons); + } + + @Test + public void testNewErrorDecision() { + String flagKey = "flag1"; + OptimizelyUserContext userContext = new OptimizelyUserContext(Optimizely.builder().build(), "tester"); + String error = "SDK has an error"; + + OptimizelyDecision decision = OptimizelyDecision.newErrorDecision(flagKey, userContext, error); + + assertEquals(decision.getVariationKey(), null); + assertEquals(decision.getEnabled(), false); + assertTrue(decision.getVariables().isEmpty()); + assertEquals(decision.getRuleKey(), null); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), userContext); + assertEquals(decision.getReasons().size(), 1); + assertEquals(decision.getReasons().get(0), error); + } + +} diff --git a/core-api/src/test/resources/config/decide-project-config.json b/core-api/src/test/resources/config/decide-project-config.json new file mode 100644 index 000000000..eb7b0f802 --- /dev/null +++ b/core-api/src/test/resources/config/decide-project-config.json @@ -0,0 +1,346 @@ +{ + "version": "4", + "sendFlagDecisions": true, + "rollouts": [ + { + "experiments": [ + { + "audienceIds": ["13389130056"], + "forcedVariations": {}, + "id": "3332020515", + "key": "3332020515", + "layerId": "3319450668", + "status": "Running", + "trafficAllocation": [ + { + "endOfRange": 10000, + "entityId": "3324490633" + } + ], + "variations": [ + { + "featureEnabled": true, + "id": "3324490633", + "key": "3324490633", + "variables": [] + } + ] + }, + { + "audienceIds": ["12208130097"], + "forcedVariations": {}, + "id": "3332020494", + "key": "3332020494", + "layerId": "3319450668", + "status": "Running", + "trafficAllocation": [ + { + "endOfRange": 0, + "entityId": "3324490562" + } + ], + "variations": [ + { + "featureEnabled": true, + "id": "3324490562", + "key": "3324490562", + "variables": [] + } + ] + }, + { + "status": "Running", + "audienceIds": [], + "variations": [ + { + "variables": [], + "id": "18257766532", + "key": "18257766532", + "featureEnabled": true + } + ], + "id": "18322080788", + "key": "18322080788", + "layerId": "18263344648", + "trafficAllocation": [ + { + "entityId": "18257766532", + "endOfRange": 10000 + } + ], + "forcedVariations": { } + } + ], + "id": "3319450668" + } + ], + "anonymizeIP": true, + "botFiltering": true, + "projectId": "10431130345", + "variables": [], + "featureFlags": [ + { + "experimentIds": ["10390977673"], + "id": "4482920077", + "key": "feature_1", + "rolloutId": "3319450668", + "variables": [ + { + "defaultValue": "42", + "id": "2687470095", + "key": "i_42", + "type": "integer" + }, + { + "defaultValue": "4.2", + "id": "2689280165", + "key": "d_4_2", + "type": "double" + }, + { + "defaultValue": "true", + "id": "2689660112", + "key": "b_true", + "type": "boolean" + }, + { + "defaultValue": "foo", + "id": "2696150066", + "key": "s_foo", + "type": "string" + }, + { + "defaultValue": "{\"value\":1}", + "id": "2696150067", + "key": "j_1", + "type": "string", + "subType": "json" + }, + { + "defaultValue": "invalid", + "id": "2696150068", + "key": "i_1", + "type": "invalid", + "subType": "" + } + ] + }, + { + "experimentIds": ["10420810910"], + "id": "4482920078", + "key": "feature_2", + "rolloutId": "", + "variables": [ + { + "defaultValue": "42", + "id": "2687470095", + "key": "i_42", + "type": "integer" + } + ] + }, + { + "experimentIds": [], + "id": "44829230000", + "key": "feature_3", + "rolloutId": "", + "variables": [] + } + ], + "experiments": [ + { + "status": "Running", + "key": "exp_with_audience", + "layerId": "10420273888", + "trafficAllocation": [ + { + "entityId": "10389729780", + "endOfRange": 10000 + } + ], + "audienceIds": ["13389141123"], + "variations": [ + { + "variables": [], + "featureEnabled": true, + "id": "10389729780", + "key": "a" + }, + { + "variables": [], + "id": "10416523121", + "key": "b" + } + ], + "forcedVariations": {}, + "id": "10390977673" + }, + { + "status": "Running", + "key": "exp_no_audience", + "layerId": "10417730432", + "trafficAllocation": [ + { + "entityId": "10418551353", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "variations": [ + { + "variables": [], + "featureEnabled": true, + "id": "10418551353", + "key": "variation_with_traffic" + }, + { + "variables": [], + "featureEnabled": false, + "id": "10418510624", + "key": "variation_no_traffic" + } + ], + "forcedVariations": {}, + "id": "10420810910" + } + ], + "audiences": [ + { + "id": "13389141123", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"exact\", \"name\": \"gender\", \"type\": \"custom_attribute\", \"value\": \"f\"}]]]", + "name": "gender" + }, + { + "id": "13389130056", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"exact\", \"name\": \"country\", \"type\": \"custom_attribute\", \"value\": \"US\"}]]]", + "name": "US" + }, + { + "id": "12208130097", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"exact\", \"name\": \"browser\", \"type\": \"custom_attribute\", \"value\": \"safari\"}]]]", + "name": "safari" + }, + { + "id": "age_18", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"gt\", \"name\": \"age\", \"type\": \"custom_attribute\", \"value\": 18}]]]", + "name": "age_18" + }, + { + "id": "invalid_format", + "conditions": "[]", + "name": "invalid_format" + }, + { + "id": "invalid_condition", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"gt\", \"name\": \"age\", \"type\": \"custom_attribute\", \"value\": \"US\"}]]]", + "name": "invalid_condition" + }, + { + "id": "invalid_type", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"gt\", \"name\": \"age\", \"type\": \"invalid\", \"value\": 18}]]]", + "name": "invalid_type" + }, + { + "id": "invalid_match", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"invalid\", \"name\": \"age\", \"type\": \"custom_attribute\", \"value\": 18}]]]", + "name": "invalid_match" + }, + { + "id": "nil_value", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"gt\", \"name\": \"age\", \"type\": \"custom_attribute\"}]]]", + "name": "nil_value" + }, + { + "id": "invalid_name", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"gt\", \"type\": \"custom_attribute\", \"value\": 18}]]]", + "name": "invalid_name" + } + ], + "groups": [ + { + "policy": "random", + "trafficAllocation": [ + { + "entityId": "10390965532", + "endOfRange": 10000 + } + ], + "experiments": [ + { + "status": "Running", + "key": "group_exp_1", + "layerId": "10420222423", + "trafficAllocation": [ + { + "entityId": "10389752311", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "variations": [ + { + "variables": [], + "featureEnabled": false, + "id": "10389752311", + "key": "a" + } + ], + "forcedVariations": {}, + "id": "10390965532" + }, + { + "status": "Running", + "key": "group_exp_2", + "layerId": "10417730432", + "trafficAllocation": [ + { + "entityId": "10418524243", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "variations": [ + { + "variables": [], + "featureEnabled": false, + "id": "10418524243", + "key": "a" + } + ], + "forcedVariations": {}, + "id": "10420843432" + } + ], + "id": "13142870430" + } + ], + "attributes": [ + { + "id": "10401066117", + "key": "gender" + }, + { + "id": "10401066170", + "key": "testvar" + } + ], + "accountId": "10367498574", + "events": [ + { + "experimentIds": [ + "10420810910" + ], + "id": "10404198134", + "key": "event1" + }, + { + "experimentIds": [ + "10420810910", + "10390977673" + ], + "id": "10404198135", + "key": "event_multiple_running_exp_attached" + } + ], + "revision": "241" +}