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 78762377c..aa3214dd6 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -34,9 +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.OptimizelyDecideOption; +import com.optimizely.ab.optimizelydecision.OptimizelyDecision; import com.optimizely.ab.optimizelyjson.OptimizelyJSON; -import com.optimizely.ab.optimizelyusercontext.OptimizelyDecideOption; -import com.optimizely.ab.optimizelyusercontext.OptimizelyUserContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -78,7 +80,7 @@ public class Optimizely implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(Optimizely.class); - public final DecisionService decisionService; + final DecisionService decisionService; @VisibleForTesting @Deprecated final EventHandler eventHandler; @@ -226,11 +228,11 @@ private Variation activate(@Nullable ProjectConfig projectConfig, return variation; } - public void sendImpression(@Nonnull ProjectConfig projectConfig, - @Nonnull Experiment experiment, - @Nonnull String userId, - @Nonnull Map filteredAttributes, - @Nonnull Variation variation) { + void sendImpression(@Nonnull ProjectConfig projectConfig, + @Nonnull Experiment experiment, + @Nonnull String userId, + @Nonnull Map filteredAttributes, + @Nonnull Variation variation) { if (!experiment.isRunning()) { logger.info("Experiment has \"Launched\" status so not dispatching event during activation."); return; @@ -741,7 +743,7 @@ 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 - public Object convertStringToType(String variableValue, String type) { + Object convertStringToType(String variableValue, String type) { if (variableValue != null) { switch (type) { case FeatureVariable.DOUBLE_TYPE: @@ -1090,6 +1092,8 @@ public OptimizelyConfig getOptimizelyConfig() { return new OptimizelyConfigService(projectConfig).getConfig(); } + //============ decide ============// + /** * Create a context of the user for which decision APIs will be called. * @@ -1108,6 +1112,173 @@ 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.createErrorDecision(key, user, DecisionMessage.SDK_NOT_READY.reason()); + } + + FeatureFlag flag = projectConfig.getFeatureKeyMapping().get(key); + if (flag == null) { + return OptimizelyDecision.createErrorDecision(key, user, DecisionMessage.FLAG_KEY_INVALID.reason(key)); + } + + String userId = user.getUserId(); + Map attributes = user.getAttributes(); + Boolean sentEvent = false; + Boolean flagEnabled = false; + List allOptions = getAllOptions(options); + DecisionReasons decisionReasons = new DecisionReasons(allOptions); + + Map copiedAttributes = new HashMap<>(attributes); + FeatureDecision flagDecision = decisionService.getVariationForFeature( + flag, + userId, + copiedAttributes, + projectConfig, + allOptions, + decisionReasons); + + if (flagDecision.variation != null) { + if (flagDecision.decisionSource.equals(FeatureDecision.DecisionSource.FEATURE_TEST)) { + if (!allOptions.contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT)) { + sendImpression( + projectConfig, + flagDecision.experiment, + userId, + copiedAttributes, + flagDecision.variation); + sentEvent = true; + } + } else { + String message = String.format("The user \"%s\" is not included in an experiment for flag \"%s\".", userId, key); + logger.info(message); + decisionReasons.addInfo(message); + } + if (flagDecision.variation.getFeatureEnabled()) { + flagEnabled = true; + } + } + + Map variableMap = new HashMap<>(); + if (!allOptions.contains(OptimizelyDecideOption.EXCLUDE_VARIABLES)) { + variableMap = getDecisionVariableMap( + flag, + flagDecision.variation, + flagEnabled, + decisionReasons); + } + + OptimizelyJSON optimizelyJSON = new OptimizelyJSON(variableMap); + + 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. + String ruleKey = flagDecision.experiment != null ? flagDecision.experiment.getKey() : null; + + DecisionNotification decisionNotification = DecisionNotification.newFlagDecisionNotificationBuilder() + .withUserId(userId) + .withAttributes(copiedAttributes) + .withFlagKey(key) + .withEnabled(flagEnabled) + .withVariables(variableMap) + .withVariationKey(variationKey) + .withRuleKey(ruleKey) + .withReasons(reasonsToReport) + .withDecisionEventDispatched(sentEvent) + .build(); + notificationCenter.send(decisionNotification); + + logger.info("Feature \"{}\" is enabled for user \"{}\"? {}", key, userId, flagEnabled); + + return new OptimizelyDecision( + variationKey, + flagEnabled, + 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); + 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 * 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..5f65bca36 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -0,0 +1,186 @@ +/** + * + * 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 java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +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; + this.attributes = new ConcurrentHashMap<>(attributes); + } + + public OptimizelyUserContext(@Nonnull Optimizely optimizely, @Nonnull String userId) { + this(optimizely, userId, new HashMap<>()); + } + + 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, @Nonnull 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(String key) { + return optimizely.decide(this, 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 optimizely.decideForKeys(this, 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 optimizely.decideAll(this, 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; + } +} 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..576227408 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,17 +18,16 @@ 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.OptimizelyDecideOption; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; +import java.util.Collections; import java.util.List; /** @@ -71,7 +70,9 @@ private String bucketToEntity(int bucketValue, List trafficAl private Experiment bucketToExperiment(@Nonnull Group group, @Nonnull String bucketingId, - @Nonnull ProjectConfig projectConfig) { + @Nonnull ProjectConfig projectConfig, + @Nonnull List options, + @Nonnull DecisionReasons reasons) { // "salt" the bucket id using the group id String bucketKey = bucketingId + group.getId(); @@ -91,7 +92,9 @@ private Experiment bucketToExperiment(@Nonnull Group group, } private Variation bucketToVariation(@Nonnull Experiment experiment, - @Nonnull String bucketingId) { + @Nonnull String bucketingId, + @Nonnull List options, + @Nonnull DecisionReasons reasons) { // "salt" the bucket id using the experiment id String experimentId = experiment.getId(); String experimentKey = experiment.getKey(); @@ -107,14 +110,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.addInfoF("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.addInfoF("User with bucketingId \"%s\" is not in any variation of experiment \"%s\".", bucketingId, experimentKey); + logger.info(message); return null; } @@ -123,12 +128,17 @@ 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 options An array of decision options + * @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 List options, + @Nonnull DecisionReasons reasons) { // ---------- Bucket User ---------- String groupId = experiment.getGroupId(); // check whether the experiment belongs to a group @@ -136,9 +146,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, options, reasons); if (bucketedExperiment == null) { - logger.info("User with bucketingId \"{}\" is not in any experiment of group {}.", bucketingId, experimentGroup.getId()); + String message = reasons.addInfoF("User with bucketingId \"%s\" is not in any experiment of group %s.", bucketingId, experimentGroup.getId()); + logger.info(message); return null; } else { @@ -146,19 +157,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.addInfoF("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.addInfoF("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, options, reasons); } + @Nullable + public Variation bucket(@Nonnull Experiment experiment, + @Nonnull String bucketingId, + @Nonnull ProjectConfig projectConfig) { + return bucket(experiment, bucketingId, projectConfig, Collections.emptyList(), new DecisionReasons()); + } //======== Helper methods ========// @@ -175,5 +194,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..f80075c7d 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,21 @@ 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.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 +83,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, options, reasons)) { return null; } // look for forced bucketing first. - Variation variation = getForcedVariation(experiment, userId); + Variation variation = getForcedVariation(experiment, userId, options, reasons); // check for whitelisting if (variation == null) { - variation = getWhitelistedVariation(experiment, userId); + variation = getWhitelistedVariation(experiment, userId, options, reasons); } if (variation != null) { @@ -112,21 +118,24 @@ public Variation getVariation(@Nonnull Experiment experiment, 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.addInfoF("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.addInfoF("The UserProfileService returned an invalid map."); + logger.warn(message); } } catch (Exception exception) { - logger.error(exception.getMessage()); + String message = reasons.addInfoF(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); + variation = getStoredVariation(experiment, userProfile, projectConfig, options, reasons); // return the stored variation if it exists if (variation != null) { return variation; @@ -135,13 +144,13 @@ public Variation getVariation(@Nonnull Experiment experiment, 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(), options, reasons)) { + String bucketingId = getBucketingId(userId, filteredAttributes, options, reasons); + variation = bucketer.bucket(experiment, bucketingId, projectConfig, options, reasons); if (variation != null) { if (userProfileService != null) { - saveVariation(experiment, variation, userProfile); + saveVariation(experiment, variation, userProfile, options, reasons); } else { logger.debug("This decision will not be saved since the UserProfileService is null."); } @@ -150,46 +159,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.addInfoF("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(), new DecisionReasons()); + } + /** * 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.addInfoF("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, options, reasons); if (featureDecision.variation == null) { - logger.info("The user \"{}\" was not bucketed into a rollout for feature flag \"{}\".", + String message = reasons.addInfoF("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.addInfoF("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(), new DecisionReasons()); + } + /** * 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 +233,58 @@ 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 options An array of decision options + * @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 List options, + @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.addInfoF("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.addInfoF("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, options, 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), options, reasons)) { + variation = bucketer.bucket(rolloutRule, bucketingId, projectConfig, options, 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.addInfoF("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", options, reasons)) { + variation = bucketer.bucket(finalRule, bucketingId, projectConfig, options, reasons); if (variation != null) { - logger.debug("User \"{}\" meets conditions for targeting rule \"Everyone Else\".", userId); + String message = reasons.addInfoF("User \"%s\" meets conditions for targeting rule \"Everyone Else\".", userId); + logger.debug(message); return new FeatureDecision(finalRule, variation, FeatureDecision.DecisionSource.ROLLOUT); } @@ -248,44 +292,74 @@ 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, Collections.emptyList(), new DecisionReasons()); + } + /** * 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 options An array of decision options + * @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 List options, + @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.addInfoF("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.addInfoF("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, Collections.emptyList(), new DecisionReasons()); + } + /** * 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 options An array of decision options + * @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 List options, + @Nonnull DecisionReasons reasons) { + + if (options.contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE)) return null; + // ---------- 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 +373,49 @@ 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.addInfoF("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.addInfoF("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.addInfoF("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, Collections.emptyList(), new DecisionReasons()); + } + /** * 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 options An array of decision options + * @param reasons Decision log messages */ void saveVariation(@Nonnull Experiment experiment, @Nonnull Variation variation, - @Nonnull UserProfile userProfile) { + @Nonnull UserProfile userProfile, + @Nonnull List options, + @Nonnull DecisionReasons reasons) { + + if (options.contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE)) return; + // only save if the user has implemented a user profile service if (userProfileService != null) { String experimentId = experiment.getId(); @@ -353,28 +441,44 @@ void saveVariation(@Nonnull Experiment experiment, } } + void saveVariation(@Nonnull Experiment experiment, + @Nonnull Variation variation, + @Nonnull UserProfile userProfile) { + saveVariation(experiment, variation, userProfile, Collections.emptyList(), new DecisionReasons()); + } + /** * 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 options An array of decision options + * @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 List options, + @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.addInfoF("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, Collections.emptyList(), new DecisionReasons()); + } + public ConcurrentHashMap> getForcedVariationMapping() { return forcedVariationMapping; } @@ -393,8 +497,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 +557,16 @@ public boolean setForcedVariation(@Nonnull Experiment experiment, * * @param experiment The experiment forced. * @param userId The user ID to be used for bucketing. + * @param options An array of decision options + * @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 List options, + @Nonnull DecisionReasons reasons) { // if the user id is invalid, return false. if (!validateUserId(userId)) { return null; @@ -473,8 +578,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.addInfoF("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 +590,12 @@ public Variation getForcedVariation(@Nonnull Experiment experiment, return null; } + @Nullable + public Variation getForcedVariation(@Nonnull Experiment experiment, + @Nonnull String userId) { + return getForcedVariation(experiment, userId, Collections.emptyList(), new DecisionReasons()); + } + /** * Helper function to check that the provided userId is valid * @@ -498,4 +610,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..a8a5f3e65 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,12 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import javax.annotation.Nonnull; import javax.annotation.Nullable; -import javax.annotation.concurrent.Immutable; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -40,7 +42,10 @@ public List getConditions() { } @Nullable - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, + Map attributes, + List options, + DecisionReasons reasons) { if (conditions == null) return null; boolean foundNull = false; // According to the matrix where: @@ -51,7 +56,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, options, reasons); if (conditionEval == null) { foundNull = true; } else if (!conditionEval) { // false with nulls or trues is false. @@ -67,6 +72,11 @@ public Boolean evaluate(ProjectConfig config, Map attributes) { return true; // otherwise, return true } + @Nullable + public Boolean evaluate(ProjectConfig config, Map attributes) { + return evaluate(config, attributes, Collections.emptyList(), new DecisionReasons()); + } + @Override public String toString() { StringBuilder s = new StringBuilder(); 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..ee58fe4d9 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,15 @@ 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 com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nullable; -import javax.annotation.concurrent.Immutable; +import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -66,20 +67,29 @@ public String getAudienceId() { @Nullable @Override - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, + Map attributes, + List options, + DecisionReasons reasons) { if (config != null) { audience = config.getAudienceIdMapping().get(audienceId); } if (audience == null) { - logger.error("Audience {} could not be found.", audienceId); + String message = reasons.addInfoF("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, options, reasons); logger.debug("Audience \"{}\" evaluated to {}.", audience.getId(), result); return result; } + @Nullable + public Boolean evaluate(ProjectConfig config, Map attributes) { + return evaluate(config, attributes, Collections.emptyList(), new DecisionReasons()); + } + @Override public boolean equals(Object o) { if (this == o) return true; 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..dff29a0dc 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,8 +17,11 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import javax.annotation.Nullable; +import java.util.List; import java.util.Map; /** @@ -26,6 +29,13 @@ */ public interface Condition { + @Nullable + Boolean evaluate(ProjectConfig config, + Map attributes, + List options, + DecisionReasons reasons); + @Nullable Boolean evaluate(ProjectConfig config, Map attributes); + } 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..ad9d79b41 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,15 +16,27 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import javax.annotation.Nullable; +import java.util.Collections; +import java.util.List; import java.util.Map; public class EmptyCondition implements Condition { @Nullable @Override - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, + Map attributes, + List options, + DecisionReasons reasons) { return true; } + @Nullable + public Boolean evaluate(ProjectConfig config, Map attributes) { + return evaluate(config, attributes, Collections.emptyList(), new DecisionReasons()); + } + } 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..96a192d98 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,15 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; import javax.annotation.Nonnull; +import java.util.Collections; +import java.util.List; import java.util.Map; /** @@ -41,12 +45,20 @@ public Condition getCondition() { } @Nullable - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, + Map attributes, + List options, + DecisionReasons reasons) { - Boolean conditionEval = condition == null ? null : condition.evaluate(config, attributes); + Boolean conditionEval = condition == null ? null : condition.evaluate(config, attributes, options, reasons); return (conditionEval == null ? null : !conditionEval); } + @Nullable + public Boolean evaluate(ProjectConfig config, Map attributes) { + return evaluate(config, attributes, Collections.emptyList(), new DecisionReasons()); + } + @Override public String toString() { StringBuilder s = new StringBuilder(); 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..bd49229ae 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,14 +16,26 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import javax.annotation.Nullable; +import java.util.Collections; +import java.util.List; import java.util.Map; public class NullCondition implements Condition { @Nullable @Override - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, + Map attributes, + List options, + DecisionReasons reasons) { return null; } + + @Nullable + public Boolean evaluate(ProjectConfig config, Map attributes) { + return evaluate(config, attributes, Collections.emptyList(), new DecisionReasons()); + } } 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..c3f890b98 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,10 +17,13 @@ package com.optimizely.ab.config.audience; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -45,11 +48,14 @@ 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, + List options, + 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, options, reasons); if (conditionEval == null) { // true with falses and nulls is still true foundNull = true; } else if (conditionEval) { @@ -65,6 +71,11 @@ public Boolean evaluate(ProjectConfig config, Map attributes) { return false; } + @Nullable + public Boolean evaluate(ProjectConfig config, Map attributes) { + return evaluate(config, attributes, Collections.emptyList(), new DecisionReasons()); + } + @Override public String toString() { StringBuilder s = new StringBuilder(); 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..5044e3dac 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,8 @@ 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 com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -28,6 +30,7 @@ import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; import java.util.Collections; +import java.util.List; import java.util.Map; /** @@ -71,7 +74,10 @@ public Object getValue() { } @Nullable - public Boolean evaluate(ProjectConfig config, Map attributes) { + public Boolean evaluate(ProjectConfig config, + Map attributes, + List options, + DecisionReasons reasons) { if (attributes == null) { attributes = Collections.emptyMap(); } @@ -79,7 +85,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.addInfoF("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,30 +101,36 @@ 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.addInfoF("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.addInfoF("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.addInfoF("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.addInfoF("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.addInfoF("attribute or value null for match %s", match != null ? match : "legacy condition"); + logger.error(message, e); } return null; } + @Nullable + public Boolean evaluate(ProjectConfig config, Map attributes) { + return evaluate(config, attributes, Collections.emptyList(), new DecisionReasons()); + } + @Override public String toString() { final String valueStr; 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..479e1c237 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,12 +21,15 @@ 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.OptimizelyDecideOption; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -41,18 +44,27 @@ private ExperimentUtils() { * Helper method to validate all pre-conditions before bucketing a user. * * @param experiment the experiment we are validating pre-conditions for + * @param options An array of decision options + * @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 List options, + @Nonnull DecisionReasons reasons) { if (!experiment.isActive()) { - logger.info("Experiment \"{}\" is not running.", experiment.getKey()); + String message = reasons.addInfoF("Experiment \"%s\" is not running.", experiment.getKey()); + logger.info(message); return false; } return true; } + public static boolean isExperimentActive(@Nonnull Experiment experiment) { + return isExperimentActive(experiment, Collections.emptyList(), new DecisionReasons()); + } + /** * Determines whether a user satisfies audience conditions for the experiment. * @@ -61,29 +73,53 @@ 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 options An array of decision options + * @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 List options, + @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, options, reasons); return resolveReturn == null ? false : resolveReturn; } else { - Boolean resolveReturn = evaluateAudience(projectConfig, experiment, attributes, loggingEntityType, loggingKey); + Boolean resolveReturn = evaluateAudience(projectConfig, experiment, attributes, loggingEntityType, loggingKey, options, 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, Collections.emptyList(), new DecisionReasons()); + } + @Nullable public static Boolean evaluateAudience(@Nonnull ProjectConfig projectConfig, @Nonnull Experiment experiment, @Nonnull Map attributes, @Nonnull String loggingEntityType, - @Nonnull String loggingKey) { + @Nonnull String loggingKey, + @Nonnull List options, + @Nonnull DecisionReasons reasons) { List experimentAudienceIds = experiment.getAudienceIds(); // if there are no audiences, ALL users should be part of the experiment @@ -101,9 +137,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, options, reasons); - logger.info("Audiences for {} \"{}\" collectively evaluated to {}.", loggingEntityType, loggingKey, result); + String message = reasons.addInfoF("Audiences for %s \"%s\" collectively evaluated to %s.", loggingEntityType, loggingKey, result); + logger.info(message); return result; } @@ -113,20 +150,23 @@ public static Boolean evaluateAudienceConditions(@Nonnull ProjectConfig projectC @Nonnull Experiment experiment, @Nonnull Map attributes, @Nonnull String loggingEntityType, - @Nonnull String loggingKey) { + @Nonnull String loggingKey, + @Nonnull List options, + @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, options, reasons); + String message = reasons.addInfoF("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.addInfoF("Condition invalid: %s", e.getMessage()); + logger.error(message); return null; } } - -} +} \ No newline at end of file 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..d98b12e41 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 @@ -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,101 @@ 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<>(); + decisionInfo.put(FLAG_KEY, flagKey); + decisionInfo.put(ENABLED, enabled); + decisionInfo.put(VARIABLES, variables); + decisionInfo.put(VARIATION_KEY, variationKey); + decisionInfo.put(RULE_KEY, ruleKey); + decisionInfo.put(REASONS, reasons); + decisionInfo.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..ff13c8d09 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 @@ -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..0a25b29eb --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionReasons.java @@ -0,0 +1,60 @@ +/** + * + * 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.Nonnull; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class DecisionReasons { + + private final List errors = new ArrayList<>(); + private final List logs = new ArrayList<>(); + private boolean includeReasons; + + public DecisionReasons(@Nonnull List options) { + this.includeReasons = options.contains(OptimizelyDecideOption.INCLUDE_REASONS); + } + + public DecisionReasons() { + this(Collections.emptyList()); + } + + public void addError(String message) { + errors.add(message); + } + + public void addInfo(String message) { + logs.add(message); + } + + public String addInfoF(String format, Object... args) { + String message = String.format(format, args); + if(includeReasons) addInfo(message); + return message; + } + + public List toReport() { + List reasons = new ArrayList<>(errors); + if(includeReasons) { + reasons.addAll(logs); + } + return reasons; + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyusercontext/OptimizelyDecideOption.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecideOption.java similarity index 94% rename from core-api/src/main/java/com/optimizely/ab/optimizelyusercontext/OptimizelyDecideOption.java rename to core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecideOption.java index e7f6d6874..ccd08bb63 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelyusercontext/OptimizelyDecideOption.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecideOption.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.optimizely.ab.optimizelyusercontext; +package com.optimizely.ab.optimizelydecision; public enum OptimizelyDecideOption { DISABLE_DECISION_EVENT, diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyusercontext/OptimizelyDecision.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecision.java similarity index 70% rename from core-api/src/main/java/com/optimizely/ab/optimizelyusercontext/OptimizelyDecision.java rename to core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecision.java index dce7f763e..77c05642f 100644 --- a/core-api/src/main/java/com/optimizely/ab/optimizelyusercontext/OptimizelyDecision.java +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecision.java @@ -14,8 +14,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.optimizely.ab.optimizelyusercontext; +package com.optimizely.ab.optimizelydecision; +import com.optimizely.ab.OptimizelyUserContext; import com.optimizely.ab.optimizelyjson.OptimizelyJSON; import javax.annotation.Nonnull; @@ -112,4 +113,35 @@ public boolean hasFailed() { return variationKey == null; } + @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 != null ? variables.hashCode() : 0); + 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; + } + + } 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..2815dea6d 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,20 @@ private T getValueInternal(@Nullable Object object, Class clazz) { return null; } + @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/main/java/com/optimizely/ab/optimizelyusercontext/OptimizelyUserContext.java b/core-api/src/main/java/com/optimizely/ab/optimizelyusercontext/OptimizelyUserContext.java deleted file mode 100644 index 71d877fe0..000000000 --- a/core-api/src/main/java/com/optimizely/ab/optimizelyusercontext/OptimizelyUserContext.java +++ /dev/null @@ -1,96 +0,0 @@ -/** - * - * 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.optimizelyusercontext; - -import com.optimizely.ab.Optimizely; -import com.optimizely.ab.UnknownEventTypeException; - -import javax.annotation.Nonnull; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -public class OptimizelyUserContext { - private final String userId; - private final Map attributes; - private final Optimizely optimizely; - - public OptimizelyUserContext(@Nonnull Optimizely optimizely, - @Nonnull String userId, - @Nonnull Map attributes) { - this.optimizely = optimizely; - this.userId = userId; - this.attributes = new HashMap<>(attributes); - } - - public OptimizelyUserContext(@Nonnull Optimizely optimizely, @Nonnull String userId) { - this(optimizely, userId, new HashMap<>()); - } - - public String getUserId() { - return userId; - } - - public Map getAttributes() { - return attributes; - } - - public Optimizely getOptimizely() { - return optimizely; - } - - public void setAttribute(@Nonnull String key, @Nonnull Object value) { - attributes.put(key, value); - } - - public OptimizelyDecision decide(@Nonnull String key, - @Nonnull OptimizelyDecideOption[] options) { - return OptimizelyDecision.createErrorDecision(key, this, "N/A"); - } - - public OptimizelyDecision decide(String key) { - return decide(key, new OptimizelyDecideOption[0]); - } - - public Map decideForKeys(@Nonnull List keys, - @Nonnull List options) { - return new HashMap<>(); - } - - public Map decideForKeys(@Nonnull List keys) { - return decideForKeys(keys, Collections.emptyList()); - } - - public Map decideAll(@Nonnull List options) { - return decideForKeys(Collections.emptyList(), options); - } - - public Map decideAll() { - return decideAll(Collections.emptyList()); - } - - public void trackEvent(@Nonnull String eventName, - @Nonnull Map eventTags) throws UnknownEventTypeException { - optimizely.track(eventName, userId, attributes, eventTags); - } - - public void trackEvent(@Nonnull String eventName) throws UnknownEventTypeException { - trackEvent(eventName, Collections.emptyMap()); - } - -} 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 7e6579658..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,7 +21,7 @@ import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.error.NoOpErrorHandler; import com.optimizely.ab.event.EventHandler; -import com.optimizely.ab.optimizelyusercontext.OptimizelyDecideOption; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Rule; import org.junit.Test; 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 2aced2256..2e3cedc88 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -35,7 +35,6 @@ import com.optimizely.ab.internal.LogbackVerifier; import com.optimizely.ab.notification.*; import com.optimizely.ab.optimizelyjson.OptimizelyJSON; -import com.optimizely.ab.optimizelyusercontext.OptimizelyUserContext; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Before; import org.junit.Rule; @@ -378,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(), anyObject())).thenReturn(null); logbackVerifier.expectMessage(Level.INFO, "Not activating user \"userId\" for experiment \"" + activatedExperiment.getKey() + "\"."); @@ -937,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(), anyObject()); } //======== track tests ========// @@ -1238,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(), anyObject()); verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); } @@ -1255,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(), anyObject())).thenReturn(bucketedVariation); Map testUserAttributes = new HashMap<>(); testUserAttributes.put("browser_type", "chrome"); @@ -1265,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(), anyObject()); assertThat(actualVariation, is(bucketedVariation)); // verify that we didn't attempt to dispatch an event @@ -1286,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(), 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(), anyObject()); assertThat(actualVariation, is(bucketedVariation)); // verify that we didn't attempt to dispatch an event @@ -1347,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(), anyObject())).thenReturn(bucketedVariation); Optimizely optimizely = optimizelyBuilder.withBucketing(mockBucketer).build(); @@ -1356,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(), anyObject()); assertThat(actualVariation, is(bucketedVariation)); } @@ -1397,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(), anyObject())).thenReturn(bucketedVariation); Optimizely optimizely = optimizelyBuilder .withConfig(noAudienceProjectConfig) @@ -1406,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(), anyObject()); assertThat(actualVariation, is(bucketedVariation)); } @@ -1464,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(), anyObject())).thenReturn(variation); Optimizely optimizely = optimizelyBuilder.withBucketing(mockBucketer).build(); @@ -1522,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(), anyObject()); } //======== Notification listeners ========// 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..ce8131049 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -0,0 +1,1031 @@ +/** + * + * 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.UserProfileService; +import com.optimizely.ab.config.*; +import com.optimizely.ab.config.parser.ConfigParseException; +import com.optimizely.ab.event.ForwardingEventProcessor; +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"); + } + + // decide + + @Test + public void decide() { + String flagKey = "feature_2"; + OptimizelyJSON variablesExpected = optimizely.getAllFeatureVariables(flagKey, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getVariationKey(), "variation_with_traffic"); + assertTrue(decision.getEnabled()); + assertEquals(decision.getVariables().toMap(), variablesExpected.toMap()); + assertEquals(decision.getRuleKey(), "exp_no_audience"); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), user); + assertTrue(decision.getReasons().isEmpty()); + } + + // decideAll + + @Test + public void decideAll_oneFlag() { + String flagKey = "feature_2"; + 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( + "variation_with_traffic", + true, + variablesExpected, + "exp_no_audience", + flagKey, + user, + Collections.emptyList()); + assertEquals(decision, expDecision); + } + + @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() { + String flagKey1 = "feature_1"; + String flagKey2 = "feature_2"; + String flagKey3 = "feature_3"; + + OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); + OptimizelyJSON variablesExpected2 = optimizely.getAllFeatureVariables(flagKey2, userId); + OptimizelyJSON variablesExpected3 = new OptimizelyJSON(Collections.emptyMap()); + + OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); + 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())); + } + + @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 experimentId = "10420810910"; + String variationId = "10418551353"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getVariationKey(), "variation_with_traffic"); + + eventHandler.expectImpression(experimentId, variationId, userId); + } + + @Test + public void decide_doNotSendEvent() { + 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()); + assertNull(decision.getVariables()); + 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()); + 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.createErrorDecision( + 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..9c7eae98a 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(), anyObject()); + verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), any(ProjectConfig.class), anyObject(), 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(), 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(), anyObject()); assertEquals(decisionService.setForcedVariation(experiment, genericUserId, null), true); assertNull(decisionService.getForcedVariation(experiment, genericUserId)); } @@ -322,14 +287,18 @@ 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(), + anyObject() ); // try to get a variation back from the decision service for the feature flag @@ -363,14 +332,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 +382,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 +393,9 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() eq(featureFlag), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject(), + anyObject() ); // make sure we get the right variation back @@ -436,7 +413,9 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() any(FeatureFlag.class), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject(), + anyObject() ); // make sure we ask for experiment bucketing once @@ -444,7 +423,9 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() any(Experiment.class), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject(), + anyObject() ); } @@ -469,7 +450,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 +461,9 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails eq(featureFlag), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject(), + anyObject() ); // make sure we get the right variation back @@ -496,7 +481,9 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails any(FeatureFlag.class), anyString(), anyMapOf(String.class, String.class), - any(ProjectConfig.class) + any(ProjectConfig.class), + anyObject(), + anyObject() ); // make sure we ask for experiment bucketing once @@ -504,7 +491,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 +539,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(), anyObject())).thenReturn(null); DecisionService decisionService = new DecisionService( mockBucketer, @@ -572,7 +561,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(), anyObject()); } /** @@ -583,7 +572,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(), anyObject())).thenReturn(null); DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null); @@ -597,7 +586,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(), anyObject()); } /** @@ -611,7 +600,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(), anyObject())).thenReturn(expectedVariation); DecisionService decisionService = new DecisionService( mockBucketer, @@ -637,7 +626,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(), anyObject()); } /** @@ -652,8 +641,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(), anyObject())).thenReturn(null); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class), anyObject(), anyObject())).thenReturn(expectedVariation); DecisionService decisionService = new DecisionService( mockBucketer, @@ -675,7 +664,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(), anyObject()); } /** @@ -694,9 +683,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(), anyObject())).thenReturn(null); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class), anyObject(), anyObject())).thenReturn(expectedVariation); + when(mockBucketer.bucket(eq(englishCitizensRule), anyString(), any(ProjectConfig.class), anyObject(), anyObject())).thenReturn(englishCitizenVariation); DecisionService decisionService = new DecisionService( mockBucketer, @@ -721,7 +710,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(), anyObject()); } /** @@ -737,9 +726,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(), anyObject())).thenReturn(null); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class), anyObject(), anyObject())).thenReturn(everyoneElseVariation); + when(mockBucketer.bucket(eq(englishCitizensRule), anyString(), any(ProjectConfig.class), anyObject(), anyObject())).thenReturn(englishCitizenVariation); DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null); @@ -759,7 +748,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(), anyObject()); } //========= white list tests ==========/ @@ -912,7 +901,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(), anyObject())).thenReturn(variation); DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, userProfileService); @@ -974,7 +963,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(), anyObject())).thenReturn(variation); when(userProfileService.lookup(userProfileId)).thenReturn(null); assertEquals(variation, decisionService.getVariation(experiment, userProfileId, Collections.emptyMap(), noAudienceProjectConfig)); @@ -988,7 +977,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(), anyObject())).thenReturn(expectedVariation); Map attr = new HashMap(); attr.put(ControlAttribute.BUCKETING_ATTRIBUTE.toString(), "bucketId"); @@ -1012,8 +1001,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(), anyObject())).thenReturn(null); + when(bucketer.bucket(eq(rolloutRuleExperiment), eq(bucketingId), eq(v4ProjectConfig), anyObject(), 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..dbcdda88e 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,16 @@ import ch.qos.logback.classic.Level; 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; 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) @@ -1183,11 +1172,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(), anyObject())).thenReturn(false); NotCondition notCondition = new NotCondition(userAttribute); assertTrue(notCondition.evaluate(null, testUserAttributes)); - verify(userAttribute, times(1)).evaluate(null, testUserAttributes); + verify(userAttribute, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject(), anyObject()); } /** @@ -1196,11 +1185,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(), anyObject())).thenReturn(true); NotCondition notCondition = new NotCondition(userAttribute); assertFalse(notCondition.evaluate(null, testUserAttributes)); - verify(userAttribute, times(1)).evaluate(null, testUserAttributes); + verify(userAttribute, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject(), anyObject()); } /** @@ -1209,10 +1198,10 @@ 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(), anyObject())).thenReturn(true); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute2.evaluate(eq(null), eq(testUserAttributes), anyObject(), anyObject())).thenReturn(false); List conditions = new ArrayList(); conditions.add(userAttribute1); @@ -1220,9 +1209,9 @@ public void orConditionEvaluateTrue() { OrCondition orCondition = new OrCondition(conditions); assertTrue(orCondition.evaluate(null, testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); + verify(userAttribute1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject(), 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(), anyObject()); } /** @@ -1231,10 +1220,10 @@ 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(), anyObject())).thenReturn(null); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(true); + when(userAttribute2.evaluate(eq(null), eq(testUserAttributes), anyObject(), anyObject())).thenReturn(true); List conditions = new ArrayList(); conditions.add(userAttribute1); @@ -1242,9 +1231,9 @@ public void orConditionEvaluateTrueWithNullAndTrue() { OrCondition orCondition = new OrCondition(conditions); assertTrue(orCondition.evaluate(null, testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); + verify(userAttribute1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject(), 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(), anyObject()); } /** @@ -1253,10 +1242,10 @@ 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(), anyObject())).thenReturn(null); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute2.evaluate(eq(null), eq(testUserAttributes), anyObject(), anyObject())).thenReturn(false); List conditions = new ArrayList(); conditions.add(userAttribute1); @@ -1264,9 +1253,9 @@ public void orConditionEvaluateNullWithNullAndFalse() { OrCondition orCondition = new OrCondition(conditions); assertNull(orCondition.evaluate(null, testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); + verify(userAttribute1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject(), 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(), anyObject()); } /** @@ -1275,10 +1264,10 @@ 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(), anyObject())).thenReturn(false); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute2.evaluate(eq(null), eq(testUserAttributes), anyObject(), anyObject())).thenReturn(false); List conditions = new ArrayList(); conditions.add(userAttribute1); @@ -1286,9 +1275,9 @@ public void orConditionEvaluateFalseWithFalseAndFalse() { OrCondition orCondition = new OrCondition(conditions); assertFalse(orCondition.evaluate(null, testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); + verify(userAttribute1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject(), 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(), anyObject()); } /** @@ -1297,10 +1286,10 @@ 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(), anyObject())).thenReturn(false); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(null, testUserAttributes)).thenReturn(false); + when(userAttribute2.evaluate(eq(null), eq(testUserAttributes), anyObject(), anyObject())).thenReturn(false); List conditions = new ArrayList(); conditions.add(userAttribute1); @@ -1308,8 +1297,8 @@ public void orConditionEvaluateFalse() { OrCondition orCondition = new OrCondition(conditions); assertFalse(orCondition.evaluate(null, testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(null, testUserAttributes); - verify(userAttribute2, times(1)).evaluate(null, testUserAttributes); + verify(userAttribute1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject(), anyObject()); + verify(userAttribute2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject(), anyObject()); } /** @@ -1318,10 +1307,10 @@ 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(), anyObject())).thenReturn(true); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(null, testUserAttributes)).thenReturn(true); + when(orCondition2.evaluate(eq(null), eq(testUserAttributes), anyObject(), anyObject())).thenReturn(true); List conditions = new ArrayList(); conditions.add(orCondition1); @@ -1329,8 +1318,8 @@ public void andConditionEvaluateTrue() { AndCondition andCondition = new AndCondition(conditions); assertTrue(andCondition.evaluate(null, testUserAttributes)); - verify(orCondition1, times(1)).evaluate(null, testUserAttributes); - verify(orCondition2, times(1)).evaluate(null, testUserAttributes); + verify(orCondition1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject(), anyObject()); + verify(orCondition2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject(), anyObject()); } /** @@ -1339,10 +1328,10 @@ 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(), anyObject())).thenReturn(null); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(null, testUserAttributes)).thenReturn(false); + when(orCondition2.evaluate(eq(null), eq(testUserAttributes), anyObject(), anyObject())).thenReturn(false); List conditions = new ArrayList(); conditions.add(orCondition1); @@ -1350,8 +1339,8 @@ public void andConditionEvaluateFalseWithNullAndFalse() { AndCondition andCondition = new AndCondition(conditions); assertFalse(andCondition.evaluate(null, testUserAttributes)); - verify(orCondition1, times(1)).evaluate(null, testUserAttributes); - verify(orCondition2, times(1)).evaluate(null, testUserAttributes); + verify(orCondition1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject(), anyObject()); + verify(orCondition2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject(), anyObject()); } /** @@ -1360,10 +1349,10 @@ 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(), anyObject())).thenReturn(null); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(null, testUserAttributes)).thenReturn(true); + when(orCondition2.evaluate(eq(null), eq(testUserAttributes), anyObject(), anyObject())).thenReturn(true); List conditions = new ArrayList(); conditions.add(orCondition1); @@ -1371,8 +1360,8 @@ public void andConditionEvaluateNullWithNullAndTrue() { AndCondition andCondition = new AndCondition(conditions); assertNull(andCondition.evaluate(null, testUserAttributes)); - verify(orCondition1, times(1)).evaluate(null, testUserAttributes); - verify(orCondition2, times(1)).evaluate(null, testUserAttributes); + verify(orCondition1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject(), anyObject()); + verify(orCondition2, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject(), anyObject()); } /** @@ -1381,10 +1370,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(), anyObject())).thenReturn(false); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(null, testUserAttributes)).thenReturn(true); + when(orCondition2.evaluate(eq(null), eq(testUserAttributes), anyObject(), anyObject())).thenReturn(true); // and[false, true] List conditions = new ArrayList(); @@ -1393,9 +1382,9 @@ public void andConditionEvaluateFalse() { AndCondition andCondition = new AndCondition(conditions); assertFalse(andCondition.evaluate(null, testUserAttributes)); - verify(orCondition1, times(1)).evaluate(null, testUserAttributes); + verify(orCondition1, times(1)).evaluate(eq(null), eq(testUserAttributes), anyObject(), 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(), anyObject()); OrCondition orCondition3 = mock(OrCondition.class); when(orCondition3.evaluate(null, testUserAttributes)).thenReturn(null); diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyusercontext/OptimizelyDecisionTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelydecision/OptimizelyDecisionTest.java similarity index 96% rename from core-api/src/test/java/com/optimizely/ab/optimizelyusercontext/OptimizelyDecisionTest.java rename to core-api/src/test/java/com/optimizely/ab/optimizelydecision/OptimizelyDecisionTest.java index dd010731b..92fdbde4a 100644 --- a/core-api/src/test/java/com/optimizely/ab/optimizelyusercontext/OptimizelyDecisionTest.java +++ b/core-api/src/test/java/com/optimizely/ab/optimizelydecision/OptimizelyDecisionTest.java @@ -14,9 +14,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.optimizely.ab.optimizelyusercontext; +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; diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyusercontext/OptimizelyUserContextTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyusercontext/OptimizelyUserContextTest.java deleted file mode 100644 index 3f08873f4..000000000 --- a/core-api/src/test/java/com/optimizely/ab/optimizelyusercontext/OptimizelyUserContextTest.java +++ /dev/null @@ -1,101 +0,0 @@ -/** - * - * 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.optimizelyusercontext; - -import com.google.common.base.Charsets; -import com.google.common.io.Resources; -import com.optimizely.ab.Optimizely; -import org.junit.Before; -import org.junit.Test; - -import java.util.Collections; -import java.util.Map; - -import static com.optimizely.ab.config.ValidProjectConfigV4.ATTRIBUTE_HOUSE_KEY; -import static com.optimizely.ab.config.ValidProjectConfigV4.AUDIENCE_GRYFFINDOR_VALUE; -import static junit.framework.TestCase.assertEquals; -import static junit.framework.TestCase.assertTrue; - -public class OptimizelyUserContextTest { - - public Optimizely optimizely; - public String userId = "tester"; - - @Before - public void setUp() throws Exception { - String datafile = Resources.toString(Resources.getResource("config/decide-project-config.json"), Charsets.UTF_8); - - optimizely = new Optimizely.Builder() - .withDatafile(datafile) - .build(); - } - - @Test - public void testOptimizelyUserContext_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 testOptimizelyUserContext_noAttributes() { - OptimizelyUserContext user = new OptimizelyUserContext(optimizely, userId); - - assertEquals(user.getOptimizely(), optimizely); - assertEquals(user.getUserId(), userId); - assertTrue(user.getAttributes().isEmpty()); - } - - @Test - public void testSetAttribute() { - 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 testSetAttribute_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"); - - assertEquals(user.getOptimizely(), optimizely); - assertEquals(user.getUserId(), userId); - Map newAttributes = user.getAttributes(); - assertEquals(newAttributes.get("k1"), "v1"); - assertEquals(newAttributes.get(ATTRIBUTE_HOUSE_KEY), "v2"); - } - -} diff --git a/core-api/src/test/resources/config/decide-project-config.json b/core-api/src/test/resources/config/decide-project-config.json index 28f45db01..d6b53bdc0 100644 --- a/core-api/src/test/resources/config/decide-project-config.json +++ b/core-api/src/test/resources/config/decide-project-config.json @@ -314,6 +314,10 @@ } ], "attributes": [ + { + "id": "10401066117", + "key": "gender" + }, { "id": "10401066170", "key": "testvar"