diff --git a/.travis.yml b/.travis.yml index e5a233da..4f0dc6ae 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,7 @@ php: - '7.0' - '7.1' - '7.2' - - '7.3' + - '7.3.24' # revert it back to 7.3 once 7.3.25 latest build gets fixed. install: "composer install" script: - mkdir -p build/logs diff --git a/src/Optimizely/Bucketer.php b/src/Optimizely/Bucketer.php index 1cc6a864..e6bd0dbe 100644 --- a/src/Optimizely/Bucketer.php +++ b/src/Optimizely/Bucketer.php @@ -1,6 +1,6 @@ generateBucketValue($bucketingKey); - $this->_logger->log(Logger::DEBUG, sprintf('Assigned bucket %s to user "%s" with bucketing ID "%s".', $bucketingNumber, $userId, $bucketingId)); + $message = sprintf('Assigned bucket %s to user "%s" with bucketing ID "%s".', $bucketingNumber, $userId, $bucketingId); + $this->_logger->log(Logger::DEBUG, $message); + $decideReasons[] = $message; foreach ($trafficAllocations as $trafficAllocation) { $currentEnd = $trafficAllocation->getEndOfRange(); if ($bucketingNumber < $currentEnd) { - return $trafficAllocation->getEntityId(); + return [$trafficAllocation->getEntityId(), $decideReasons]; } } - return null; + return [null, $decideReasons]; } /** @@ -137,12 +140,14 @@ private function findBucket($bucketingId, $userId, $parentId, $trafficAllocation * @param $bucketingId string A customer-assigned value used to create the key for the murmur hash. * @param $userId string User identifier. * - * @return Variation Variation which will be shown to the user. + * @return [ Variation, array ] Variation which will be shown to the user and array of log messages representing decision making. */ public function bucket(ProjectConfigInterface $config, Experiment $experiment, $bucketingId, $userId) { + $decideReasons = []; + if (is_null($experiment->getKey())) { - return null; + return [ null, $decideReasons ]; } // Determine if experiment is in a mutually exclusive group. @@ -151,47 +156,52 @@ public function bucket(ProjectConfigInterface $config, Experiment $experiment, $ $group = $config->getGroup($experiment->getGroupId()); if (is_null($group->getId())) { - return null; + return [ null, $decideReasons ]; } - $userExperimentId = $this->findBucket($bucketingId, $userId, $group->getId(), $group->getTrafficAllocation()); + list($userExperimentId, $reasons) = $this->findBucket($bucketingId, $userId, $group->getId(), $group->getTrafficAllocation()); + $decideReasons = array_merge($decideReasons, $reasons); + if (empty($userExperimentId)) { - $this->_logger->log(Logger::INFO, sprintf('User "%s" is in no experiment.', $userId)); - return null; + $message = sprintf('User "%s" is in no experiment.', $userId); + $this->_logger->log(Logger::INFO, $message); + $decideReasons[] = $message; + return [ null, $decideReasons ]; } if ($userExperimentId != $experiment->getId()) { - $this->_logger->log( - Logger::INFO, - sprintf( - 'User "%s" is not in experiment %s of group %s.', - $userId, - $experiment->getKey(), - $experiment->getGroupId() - ) - ); - return null; - } - - $this->_logger->log( - Logger::INFO, - sprintf( - 'User "%s" is in experiment %s of group %s.', + $message = sprintf( + 'User "%s" is not in experiment %s of group %s.', $userId, $experiment->getKey(), $experiment->getGroupId() - ) + ); + + $this->_logger->log(Logger::INFO, $message); + $decideReasons[] = $message; + return [ null, $decideReasons ]; + } + + $message = sprintf( + 'User "%s" is in experiment %s of group %s.', + $userId, + $experiment->getKey(), + $experiment->getGroupId() ); + + $this->_logger->log(Logger::INFO, $message); + $decideReasons[] = $message; } // Bucket user if not in whitelist and in group (if any). - $variationId = $this->findBucket($bucketingId, $userId, $experiment->getId(), $experiment->getTrafficAllocation()); + list($variationId, $reasons) = $this->findBucket($bucketingId, $userId, $experiment->getId(), $experiment->getTrafficAllocation()); + $decideReasons = array_merge($decideReasons, $reasons); if (!empty($variationId)) { $variation = $config->getVariationFromId($experiment->getKey(), $variationId); - return $variation; + return [ $variation, $decideReasons ]; } - return null; + return [ null, $decideReasons ]; } } diff --git a/src/Optimizely/Decide/OptimizelyDecideOption.php b/src/Optimizely/Decide/OptimizelyDecideOption.php new file mode 100644 index 00000000..baa33eaa --- /dev/null +++ b/src/Optimizely/Decide/OptimizelyDecideOption.php @@ -0,0 +1,27 @@ +variationKey = $variationKey; + $this->enabled = $enabled === null ? false : $enabled; + $this->variables = $variables === null ? [] : $variables; + $this->ruleKey = $ruleKey; + $this->flagKey = $flagKey; + $this->userContext = $userContext; + $this->reasons = $reasons === null ? [] : $reasons; + } + + public function getVariationKey() + { + return $this->variationKey; + } + + public function getEnabled() + { + return $this->enabled; + } + + public function getVariables() + { + return $this->variables; + } + + public function getRuleKey() + { + return $this->ruleKey; + } + + public function getFlagKey() + { + return $this->flagKey; + } + + public function getUserContext() + { + return $this->userContext; + } + + public function getReasons() + { + return $this->reasons; + } + + public function jsonSerialize() + { + return get_object_vars($this); + } +} diff --git a/src/Optimizely/Decide/OptimizelyDecisionMessage.php b/src/Optimizely/Decide/OptimizelyDecisionMessage.php new file mode 100644 index 00000000..23cdfdc7 --- /dev/null +++ b/src/Optimizely/Decide/OptimizelyDecisionMessage.php @@ -0,0 +1,25 @@ +_logger->log(Logger::WARNING, 'Bucketing ID attribute is not a string. Defaulted to user ID.'); + + $message = 'Bucketing ID attribute is not a string. Defaulted to user ID.'; + $this->_logger->log(Logger::WARNING, $message); + $decideReasons[] = $message; } - return $userId; + return [ $userId, $decideReasons ]; } /** @@ -115,68 +121,91 @@ protected function getBucketingId($userId, $userAttributes) * @param $experiment Experiment Experiment to get the variation for. * @param $userId string User identifier. * @param $attributes array Attributes of the user. + * @param $decideOptions array Options to customize evaluation. * - * @return Variation Variation which the user is bucketed into. + * @return [ Variation, array ] Variation which the user is bucketed into and array of log messages representing decision making. */ - public function getVariation(ProjectConfigInterface $projectConfig, Experiment $experiment, $userId, $attributes = null) + public function getVariation(ProjectConfigInterface $projectConfig, Experiment $experiment, $userId, $attributes = null, $decideOptions = []) { - $bucketingId = $this->getBucketingId($userId, $attributes); + $decideReasons = []; + list($bucketingId, $reasons) = $this->getBucketingId($userId, $attributes); + + $decideReasons = array_merge($decideReasons, $reasons); if (!$experiment->isExperimentRunning()) { - $this->_logger->log(Logger::INFO, sprintf('Experiment "%s" is not running.', $experiment->getKey())); - return null; + $message = sprintf('Experiment "%s" is not running.', $experiment->getKey()); + $this->_logger->log(Logger::INFO, $message); + $decideReasons[] = $message; + return [ null, $decideReasons]; } // check if a forced variation is set - $forcedVariation = $this->getForcedVariation($projectConfig, $experiment->getKey(), $userId); + list($forcedVariation, $reasons) = $this->getForcedVariation($projectConfig, $experiment->getKey(), $userId); + $decideReasons = array_merge($decideReasons, $reasons); if (!is_null($forcedVariation)) { - return $forcedVariation; + return [ $forcedVariation, $decideReasons]; } // check if the user has been whitelisted - $variation = $this->getWhitelistedVariation($projectConfig, $experiment, $userId); + list($variation, $reasons) = $this->getWhitelistedVariation($projectConfig, $experiment, $userId); + $decideReasons = array_merge($decideReasons, $reasons); if (!is_null($variation)) { - return $variation; + return [ $variation, $decideReasons ]; } // check for sticky bucketing - $userProfile = new UserProfile($userId); - if (!is_null($this->_userProfileService)) { - $storedUserProfile = $this->getStoredUserProfile($userId); - if (!is_null($storedUserProfile)) { - $userProfile = $storedUserProfile; - $variation = $this->getStoredVariation($projectConfig, $experiment, $userProfile); - if (!is_null($variation)) { - return $variation; + if (!in_array(OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE, $decideOptions)) { + $userProfile = new UserProfile($userId); + if (!is_null($this->_userProfileService)) { + list($storedUserProfile, $reasons) = $this->getStoredUserProfile($userId); + $decideReasons = array_merge($decideReasons, $reasons); + if (!is_null($storedUserProfile)) { + $userProfile = $storedUserProfile; + list($variation, $reasons) = $this->getStoredVariation($projectConfig, $experiment, $userProfile); + $decideReasons = array_merge($decideReasons, $reasons); + if (!is_null($variation)) { + return [ $variation, $decideReasons ]; + } } } } - if (!Validator::doesUserMeetAudienceConditions($projectConfig, $experiment, $attributes, $this->_logger)) { + list($evalResult, $reasons) = Validator::doesUserMeetAudienceConditions($projectConfig, $experiment, $attributes, $this->_logger); + $decideReasons = array_merge($decideReasons, $reasons); + if (!$evalResult) { + $message = sprintf('User "%s" does not meet conditions to be in experiment "%s".', $userId, $experiment->getKey()); $this->_logger->log( Logger::INFO, - sprintf('User "%s" does not meet conditions to be in experiment "%s".', $userId, $experiment->getKey()) + $message ); - return null; + $decideReasons[] = $message; + return [ null, $decideReasons]; } - $variation = $this->_bucketer->bucket($projectConfig, $experiment, $bucketingId, $userId); + list($variation, $reasons) = $this->_bucketer->bucket($projectConfig, $experiment, $bucketingId, $userId); + $decideReasons = array_merge($decideReasons, $reasons); if ($variation === null) { - $this->_logger->log(Logger::INFO, sprintf('User "%s" is in no variation.', $userId)); + $message = sprintf('User "%s" is in no variation.', $userId); + $this->_logger->log(Logger::INFO, $message); + $decideReasons[] = $message; } else { - $this->saveVariation($experiment, $variation, $userProfile); + if (!in_array(OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE, $decideOptions)) { + $this->saveVariation($experiment, $variation, $userProfile); + } + $message = sprintf( + 'User "%s" is in variation %s of experiment %s.', + $userId, + $variation->getKey(), + $experiment->getKey() + ); $this->_logger->log( Logger::INFO, - sprintf( - 'User "%s" is in variation %s of experiment %s.', - $userId, - $variation->getKey(), - $experiment->getKey() - ) + $message ); + $decideReasons[] = $message; } - return $variation; + return [ $variation, $decideReasons ]; } /** @@ -186,38 +215,50 @@ public function getVariation(ProjectConfigInterface $projectConfig, Experiment $ * @param FeatureFlag $featureFlag The feature flag the user wants to access * @param string $userId user ID * @param array $userAttributes user attributes - * @return Decision if getVariationForFeatureExperiment or getVariationForFeatureRollout returns a Decision - * null otherwise + * @param array $decideOptions Options to customize evaluation. + * + * @return FeatureDecision representing decision. */ - public function getVariationForFeature(ProjectConfigInterface $projectConfig, FeatureFlag $featureFlag, $userId, $userAttributes) + public function getVariationForFeature(ProjectConfigInterface $projectConfig, FeatureFlag $featureFlag, $userId, $userAttributes, $decideOptions = []) { + $decideReasons = []; + //Evaluate in this order: //1. Attempt to bucket user into experiment using feature flag. //2. Attempt to bucket user into rollout using the feature flag. // Check if the feature flag is under an experiment and the the user is bucketed into one of these experiments - $decision = $this->getVariationForFeatureExperiment($projectConfig, $featureFlag, $userId, $userAttributes); - if ($decision) { + $decision = $this->getVariationForFeatureExperiment($projectConfig, $featureFlag, $userId, $userAttributes, $decideOptions); + if ($decision->getVariation()) { return $decision; } + $decideReasons = array_merge($decideReasons, $decision->getReasons()); + // Check if the feature flag has rollout and the user is bucketed into one of it's rules $decision = $this->getVariationForFeatureRollout($projectConfig, $featureFlag, $userId, $userAttributes); - if ($decision) { + $decideReasons = array_merge($decideReasons, $decision->getReasons()); + + if ($decision->getVariation()) { + $message = "User '{$userId}' is bucketed into rollout for feature flag '{$featureFlag->getKey()}'."; $this->_logger->log( Logger::INFO, - "User '{$userId}' is bucketed into rollout for feature flag '{$featureFlag->getKey()}'." + $message ); - return $decision; + $decideReasons[] = $message; + + return new FeatureDecision($decision->getExperiment(), $decision->getVariation(), FeatureDecision::DECISION_SOURCE_ROLLOUT, $decideReasons); } + $message = "User '{$userId}' is not bucketed into rollout for feature flag '{$featureFlag->getKey()}'."; $this->_logger->log( Logger::INFO, - "User '{$userId}' is not bucketed into rollout for feature flag '{$featureFlag->getKey()}'." + $message ); + $decideReasons[] = $message; - return new FeatureDecision(null, null, FeatureDecision::DECISION_SOURCE_ROLLOUT); + return new FeatureDecision(null, null, FeatureDecision::DECISION_SOURCE_ROLLOUT, $decideReasons); } /** @@ -227,21 +268,25 @@ public function getVariationForFeature(ProjectConfigInterface $projectConfig, Fe * @param FeatureFlag $featureFlag The feature flag the user wants to access * @param string $userId user id * @param array $userAttributes user userAttributes - * @return Decision if a variation is returned for the user - * null if feature flag is not used in any experiments or no variation is returned for the user + * @param array $decideOptions Options to customize evaluation. + * + * @return FeatureDecision representing decision. */ - public function getVariationForFeatureExperiment(ProjectConfigInterface $projectConfig, FeatureFlag $featureFlag, $userId, $userAttributes) + public function getVariationForFeatureExperiment(ProjectConfigInterface $projectConfig, FeatureFlag $featureFlag, $userId, $userAttributes, $decideOptions = []) { + $decideReasons = []; $featureFlagKey = $featureFlag->getKey(); $experimentIds = $featureFlag->getExperimentIds(); // Check if there are any experiment IDs inside feature flag if (empty($experimentIds)) { + $message = "The feature flag '{$featureFlagKey}' is not used in any experiments."; $this->_logger->log( Logger::DEBUG, - "The feature flag '{$featureFlagKey}' is not used in any experiments." + $message ); - return null; + $decideReasons[] = $message; + return new FeatureDecision(null, null, null, $decideReasons); } // Evaluate each experiment ID and return the first bucketed experiment variation @@ -252,23 +297,28 @@ public function getVariationForFeatureExperiment(ProjectConfigInterface $project continue; } - $variation = $this->getVariation($projectConfig, $experiment, $userId, $userAttributes); + list($variation, $reasons) = $this->getVariation($projectConfig, $experiment, $userId, $userAttributes, $decideOptions); + $decideReasons = array_merge($decideReasons, $reasons); if ($variation && $variation->getKey()) { + $message = "The user '{$userId}' is bucketed into experiment '{$experiment->getKey()}' of feature '{$featureFlagKey}'."; $this->_logger->log( Logger::INFO, - "The user '{$userId}' is bucketed into experiment '{$experiment->getKey()}' of feature '{$featureFlagKey}'." + $message ); + $decideReasons[] = $message; - return new FeatureDecision($experiment, $variation, FeatureDecision::DECISION_SOURCE_FEATURE_TEST); + return new FeatureDecision($experiment, $variation, FeatureDecision::DECISION_SOURCE_FEATURE_TEST, $decideReasons); } } + $message = "The user '{$userId}' is not bucketed into any of the experiments using the feature '{$featureFlagKey}'."; $this->_logger->log( Logger::INFO, - "The user '{$userId}' is not bucketed into any of the experiments using the feature '{$featureFlagKey}'." + $message ); + $decideReasons[] = $message; - return null; + return new FeatureDecision(null, null, null, $decideReasons); } /** @@ -280,32 +330,32 @@ public function getVariationForFeatureExperiment(ProjectConfigInterface $project * @param FeatureFlag $featureFlag The feature flag the user wants to access * @param string $userId user id * @param array $userAttributes user userAttributes - * @return Decision if a variation is returned for the user - * null if feature flag is not used in a rollout or - * no rollout found against the rollout ID or - * no variation is returned for the user + * @return FeatureDecision representing decision. */ public function getVariationForFeatureRollout(ProjectConfigInterface $projectConfig, FeatureFlag $featureFlag, $userId, $userAttributes) { - $bucketing_id = $this->getBucketingId($userId, $userAttributes); + $decideReasons = []; + list($bucketing_id, $reasons) = $this->getBucketingId($userId, $userAttributes); $featureFlagKey = $featureFlag->getKey(); $rollout_id = $featureFlag->getRolloutId(); if (empty($rollout_id)) { + $message = "Feature flag '{$featureFlagKey}' is not used in a rollout."; $this->_logger->log( Logger::DEBUG, - "Feature flag '{$featureFlagKey}' is not used in a rollout." + $message ); - return null; + $decideReasons[] = $message; + return new FeatureDecision(null, null, null, $decideReasons); } $rollout = $projectConfig->getRolloutFromId($rollout_id); if ($rollout && !($rollout->getId())) { // Error logged and thrown in getRolloutFromId - return null; + return new FeatureDecision(null, null, null, $decideReasons); } $rolloutRules = $rollout->getExperiments(); if (sizeof($rolloutRules) == 0) { - return null; + return new FeatureDecision(null, null, null, $decideReasons); } // Evaluate all rollout rules except for last one @@ -313,19 +363,24 @@ public function getVariationForFeatureRollout(ProjectConfigInterface $projectCon $rolloutRule = $rolloutRules[$i]; // Evaluate if user meets the audience condition of this rollout rule - if (!Validator::doesUserMeetAudienceConditions($projectConfig, $rolloutRule, $userAttributes, $this->_logger, 'Optimizely\Enums\RolloutAudienceEvaluationLogs', $i + 1)) { + list($evalResult, $reasons) = Validator::doesUserMeetAudienceConditions($projectConfig, $rolloutRule, $userAttributes, $this->_logger, 'Optimizely\Enums\RolloutAudienceEvaluationLogs', $i + 1); + $decideReasons = array_merge($decideReasons, $reasons); + if (!$evalResult) { + $message = sprintf("User '%s' does not meet conditions for targeting rule %s.", $userId, $i+1); $this->_logger->log( Logger::DEBUG, - sprintf("User '%s' does not meet conditions for targeting rule %s.", $userId, $i+1) + $message ); + $decideReasons[] = $message; // Evaluate this user for the next rule continue; } // Evaluate if user satisfies the traffic allocation for this rollout rule - $variation = $this->_bucketer->bucket($projectConfig, $rolloutRule, $bucketing_id, $userId); + list($variation, $reasons) = $this->_bucketer->bucket($projectConfig, $rolloutRule, $bucketing_id, $userId); + $decideReasons = array_merge($decideReasons, $reasons); if ($variation && $variation->getKey()) { - return new FeatureDecision($rolloutRule, $variation, FeatureDecision::DECISION_SOURCE_ROLLOUT); + return new FeatureDecision($rolloutRule, $variation, FeatureDecision::DECISION_SOURCE_ROLLOUT, $decideReasons); } break; } @@ -333,19 +388,24 @@ public function getVariationForFeatureRollout(ProjectConfigInterface $projectCon $rolloutRule = $rolloutRules[sizeof($rolloutRules) - 1]; // Evaluate if user meets the audience condition of Everyone Else Rule / Last Rule now - if (!Validator::doesUserMeetAudienceConditions($projectConfig, $rolloutRule, $userAttributes, $this->_logger, 'Optimizely\Enums\RolloutAudienceEvaluationLogs', 'Everyone Else')) { + list($evalResult, $reasons) = Validator::doesUserMeetAudienceConditions($projectConfig, $rolloutRule, $userAttributes, $this->_logger, 'Optimizely\Enums\RolloutAudienceEvaluationLogs', 'Everyone Else'); + $decideReasons = array_merge($decideReasons, $reasons); + if (!$evalResult) { + $message = sprintf("User '%s' does not meet conditions for targeting rule 'Everyone Else'.", $userId); $this->_logger->log( Logger::DEBUG, - sprintf("User '%s' does not meet conditions for targeting rule 'Everyone Else'.", $userId) + $message ); - return null; + $decideReasons[] = $message; + return new FeatureDecision(null, null, null, $decideReasons); } - $variation = $this->_bucketer->bucket($projectConfig, $rolloutRule, $bucketing_id, $userId); + list($variation, $reasons) = $this->_bucketer->bucket($projectConfig, $rolloutRule, $bucketing_id, $userId); + $decideReasons = array_merge($decideReasons, $reasons); if ($variation && $variation->getKey()) { return new FeatureDecision($rolloutRule, $variation, FeatureDecision::DECISION_SOURCE_ROLLOUT); } - return null; + return new FeatureDecision(null, null, null, $decideReasons); } @@ -356,13 +416,15 @@ public function getVariationForFeatureRollout(ProjectConfigInterface $projectCon * @param $experimentKey string Key for experiment. * @param $userId string The user Id. * - * @return Variation The variation which the given user and experiment should be forced into. + * @return [ Variation, array ] The variation which the given user and experiment should be forced into and + * array of log messages representing decision making. */ public function getForcedVariation(ProjectConfigInterface $projectConfig, $experimentKey, $userId) { + $decideReasons = []; if (!isset($this->_forcedVariationMap[$userId])) { $this->_logger->log(Logger::DEBUG, sprintf('User "%s" is not in the forced variation map.', $userId)); - return null; + return [ null, $decideReasons]; } $experimentToVariationMap = $this->_forcedVariationMap[$userId]; @@ -371,20 +433,23 @@ public function getForcedVariation(ProjectConfigInterface $projectConfig, $exper // check for null and empty string experiment ID if (strlen($experimentId) == 0) { // this case is logged in getExperimentFromKey - return null; + return [ null, $decideReasons]; } if (!isset($experimentToVariationMap[$experimentId])) { - $this->_logger->log(Logger::DEBUG, sprintf('No experiment "%s" mapped to user "%s" in the forced variation map.', $experimentKey, $userId)); - return null; + $message = sprintf('No experiment "%s" mapped to user "%s" in the forced variation map.', $experimentKey, $userId); + $this->_logger->log(Logger::DEBUG, $message); + return [ null, $decideReasons]; } $variationId = $experimentToVariationMap[$experimentId]; $variation = $projectConfig->getVariationFromId($experimentKey, $variationId); $variationKey = $variation->getKey(); - $this->_logger->log(Logger::DEBUG, sprintf('Variation "%s" is mapped to experiment "%s" and user "%s" in the forced variation map', $variationKey, $experimentKey, $userId)); - return $variation; + $message = sprintf('Variation "%s" is mapped to experiment "%s" and user "%s" in the forced variation map', $variationKey, $experimentKey, $userId); + $this->_logger->log(Logger::DEBUG, $message); + $decideReasons[] = $message; + return [ $variation, $decideReasons]; } /** @@ -443,39 +508,43 @@ public function setForcedVariation(ProjectConfigInterface $projectConfig, $exper * @param $experiment Experiment Experiment in which user is to be bucketed. * @param $userId string string * - * @return null|Variation Representing the variation the user is forced into. + * @return [ Variation, array ] Representing the variation the user is forced into and + * array of log messages representing decision making. */ private function getWhitelistedVariation(ProjectConfigInterface $projectConfig, Experiment $experiment, $userId) { + $decideReasons = []; // Check if user is whitelisted for a variation. $forcedVariations = $experiment->getForcedVariations(); if (!is_null($forcedVariations) && isset($forcedVariations[$userId])) { $variationKey = $forcedVariations[$userId]; $variation = $projectConfig->getVariationFromKey($experiment->getKey(), $variationKey); if ($variationKey && !empty($variation->getKey())) { - $this->_logger->log( - Logger::INFO, - sprintf('User "%s" is forced in variation "%s" of experiment "%s".', $userId, $variationKey, $experiment->getKey()) - ); + $message = sprintf('User "%s" is forced in variation "%s" of experiment "%s".', $userId, $variationKey, $experiment->getKey()); + + $this->_logger->log(Logger::INFO, $message); + $decideReasons[] = $message; } else { - return null; + return [ null, $decideReasons ]; } - return $variation; + return [ $variation, $decideReasons]; } - return null; + return [ null, $decideReasons ]; } /** * Get the stored user profile for the given user ID. * - * @param $userId string the ID of the user. + * @param $userId string ID of the user. * - * @return null|UserProfile the stored user profile. + * @return [UserProfile, array] the stored user profile and array of log messages representing decision making. */ private function getStoredUserProfile($userId) { + $decideReasons = []; + if (is_null($this->_userProfileService)) { - return null; + return [ null, $decideReasons ]; } try { @@ -486,7 +555,8 @@ private function getStoredUserProfile($userId) sprintf('No user profile found for user with ID "%s".', $userId) ); } elseif (UserProfileUtils::isValidUserProfileMap($userProfileMap)) { - return UserProfileUtils::convertMapToUserProfile($userProfileMap); + $userProfile = UserProfileUtils::convertMapToUserProfile($userProfileMap); + return [ $userProfile, $decideReasons]; } else { $this->_logger->log( Logger::WARNING, @@ -494,13 +564,12 @@ private function getStoredUserProfile($userId) ); } } catch (Exception $e) { - $this->_logger->log( - Logger::ERROR, - sprintf('The User Profile Service lookup method failed: %s.', $e->getMessage()) - ); + $message = sprintf('The User Profile Service lookup method failed: %s.', $e->getMessage()); + $this->_logger->log(Logger::ERROR, $message); + $decideReasons[] = $message; } - return null; + return [ null, $decideReasons ]; } /** @@ -510,10 +579,11 @@ private function getStoredUserProfile($userId) * @param $experiment Experiment The experiment for which we are getting the stored variation. * @param $userProfile UserProfile The user profile from which we are getting the stored variation. * - * @return null|Variation the stored variation or null if not found. + * @return [ Variation, array ] the stored variation or null if not found, and array of log messages representing decision making. */ private function getStoredVariation(ProjectConfigInterface $projectConfig, Experiment $experiment, UserProfile $userProfile) { + $decideReasons = []; $experimentKey = $experiment->getKey(); $userId = $userProfile->getUserId(); $variationId = $userProfile->getVariationForExperiment($experiment->getId()); @@ -523,21 +593,26 @@ private function getStoredVariation(ProjectConfigInterface $projectConfig, Exper Logger::INFO, sprintf('No previously activated variation of experiment "%s" for user "%s" found in user profile.', $experimentKey, $userId) ); - return null; + return [ null, $decideReasons ]; } $variation = $projectConfig->getVariationFromId($experimentKey, $variationId); if (!($variation->getId())) { + $message = sprintf( + '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.', + $userId, + $variationId, + $experimentKey + ); + $this->_logger->log( Logger::INFO, - sprintf( - '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.', - $userId, - $variationId, - $experimentKey - ) + $message ); - return null; + + $decideReasons[] = $message; + + return [ null, $decideReasons ]; } $this->_logger->log( @@ -549,7 +624,7 @@ private function getStoredVariation(ProjectConfigInterface $projectConfig, Exper $userId ) ); - return $variation; + return [ $variation, $decideReasons ]; } /** @@ -579,25 +654,23 @@ private function saveVariation(Experiment $experiment, Variation $variation, Use try { $this->_userProfileService->save($userProfileMap); - $this->_logger->log( - Logger::INFO, - sprintf( - 'Saved variation "%s" of experiment "%s" for user "%s".', - $variation->getKey(), - $experiment->getKey(), - $userProfile->getUserId() - ) + $message = sprintf( + 'Saved variation "%s" of experiment "%s" for user "%s".', + $variation->getKey(), + $experiment->getKey(), + $userProfile->getUserId() ); + + $this->_logger->log(Logger::INFO, $message); } catch (Exception $e) { - $this->_logger->log( - Logger::WARNING, - sprintf( - 'Failed to save variation "%s" of experiment "%s" for user "%s".', - $variation->getKey(), - $experiment->getKey(), - $userProfile->getUserId() - ) + $message = sprintf( + 'Failed to save variation "%s" of experiment "%s" for user "%s".', + $variation->getKey(), + $experiment->getKey(), + $userProfile->getUserId() ); + + $this->_logger->log(Logger::WARNING, $message); } } } diff --git a/src/Optimizely/DecisionService/FeatureDecision.php b/src/Optimizely/DecisionService/FeatureDecision.php index 9aac1305..388a67e5 100644 --- a/src/Optimizely/DecisionService/FeatureDecision.php +++ b/src/Optimizely/DecisionService/FeatureDecision.php @@ -1,6 +1,6 @@ _experiment = $experiment; $this->_variation = $variation; $this->_source = $source; + $this->reasons = $reasons; } public function getExperiment() @@ -71,4 +79,9 @@ public function getSource() { return $this->_source; } + + public function getReasons() + { + return $this->reasons; + } } diff --git a/src/Optimizely/Enums/DecisionNotificationTypes.php b/src/Optimizely/Enums/DecisionNotificationTypes.php index cdff0222..f1b27463 100644 --- a/src/Optimizely/Enums/DecisionNotificationTypes.php +++ b/src/Optimizely/Enums/DecisionNotificationTypes.php @@ -1,6 +1,6 @@ _isValid = true; $this->_eventDispatcher = $eventDispatcher ?: new DefaultEventDispatcher(); @@ -145,6 +155,8 @@ public function __construct( $this->configManager = new StaticProjectConfigManager($datafile, $skipJsonValidation, $this->_logger, $this->_errorHandler); } } + + $this->defaultDecideOptions = $defaultDecideOptions; } /** @@ -241,6 +253,252 @@ protected function sendImpressionEvent($config, $experimentKey, $variationKey, $ ); } + /** + * Create a context of the user for which decision APIs will be called. + * + * A user context will be created successfully even when the SDK is not fully configured yet. + * + * @param $userId string The user ID to be used for bucketing. + * @param $userAttributes array A Hash representing user attribute names and values. + * + * @return OptimizelyUserContext|null An OptimizelyUserContext associated with this OptimizelyClient, + * or null If user attributes are not in valid format. + */ + public function createUserContext($userId, array $userAttributes = []) + { + // We do not check if config is ready as UserContext can be created even when SDK is not ready. + + // validate userId + if (!$this->validateInputs( + [ + self::USER_ID => $userId + ] + ) + ) { + return null; + } + + // validate attributes + if (!$this->validateUserInputs($userAttributes)) { + return null; + } + + return new OptimizelyUserContext($this, $userId, $userAttributes); + } + + /** + * Returns a decision result (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 $userContext OptimizelyUserContext context of the user for which decision will be called. + * @param $key string A flag key for which a decision will be made. + * @param $decideOptions array A list of options for decision making. + * + * @return OptimizelyDecision A decision result + */ + public function decide(OptimizelyUserContext $userContext, $key, array $decideOptions = []) + { + $decideReasons = []; + + // check if SDK is ready + $config = $this->getConfig(); + if ($config === null) { + $this->_logger->log(Logger::ERROR, sprintf(Errors::INVALID_DATAFILE, __FUNCTION__)); + $decideReasons[] = OptimizelyDecisionMessage::SDK_NOT_READY; + return new OptimizelyDecision(null, null, null, null, $key, $userContext, $decideReasons); + } + + // validate that key is a string + if (!$this->validateInputs( + [ + self::FEATURE_FLAG_KEY => $key + ] + ) + ) { + $errorMessage = sprintf(OptimizelyDecisionMessage::FLAG_KEY_INVALID, $key); + $this->_logger->log(Logger::ERROR, $errorMessage); + $decideReasons[] = $errorMessage; + return new OptimizelyDecision(null, null, null, null, $key, $userContext, $decideReasons); + } + + + // validate that key maps to a feature flag + $featureFlag = $config->getFeatureFlagFromKey($key); + if ($featureFlag && (!$featureFlag->getId())) { + // Error logged in DatafileProjectConfig - getFeatureFlagFromKey + $decideReasons[] = sprintf(OptimizelyDecisionMessage::FLAG_KEY_INVALID, $key); + return new OptimizelyDecision(null, null, null, null, $key, $userContext, $decideReasons); + } + + // merge decide options and default decide options + $decideOptions = array_merge($decideOptions, $this->defaultDecideOptions); + + // create optimizely decision result + $userId = $userContext->getUserId(); + $userAttributes = $userContext->getAttributes(); + $variationKey = null; + $featureEnabled = false; + $ruleKey = null; + $flagKey = $key; + $allVariables = []; + $decisionEventDispatched = false; + + // get decision + $decision = $this->_decisionService->getVariationForFeature( + $config, + $featureFlag, + $userId, + $userAttributes, + $decideOptions + ); + + $decideReasons = $decision->getReasons(); + $variation = $decision->getVariation(); + + if ($variation) { + $variationKey = $variation->getKey(); + $featureEnabled = $variation->getFeatureEnabled(); + $ruleKey = $decision->getExperiment()->getKey(); + } else { + $variationKey = null; + $ruleKey = null; + } + + // send impression only if decide options do not contain DISABLE_DECISION_EVENT + if (!in_array(OptimizelyDecideOption::DISABLE_DECISION_EVENT, $decideOptions)) { + $sendFlagDecisions = $config->getSendFlagDecisions(); + $source = $decision->getSource(); + + // send impression for rollout when sendFlagDecisions is enabled. + if ($source == FeatureDecision::DECISION_SOURCE_FEATURE_TEST || $sendFlagDecisions) { + $this->sendImpressionEvent( + $config, + $ruleKey, + $variationKey === null ? '' : $variationKey, + $flagKey, + $ruleKey === null ? '' : $ruleKey, + $source, + $featureEnabled, + $userId, + $userAttributes + ); + + $decisionEventDispatched = true; + } + } + + // Generate all variables map if decide options doesn't include excludeVariables + if (!in_array(OptimizelyDecideOption::EXCLUDE_VARIABLES, $decideOptions)) { + foreach ($featureFlag->getVariables() as $variable) { + $allVariables[$variable->getKey()] = $this->getFeatureVariableValueFromVariation( + $flagKey, + $variable->getKey(), + null, + $featureEnabled, + $variation, + $userId + ); + } + } + + $shouldIncludeReasons = in_array(OptimizelyDecideOption::INCLUDE_REASONS, $decideOptions); + + // send notification + $this->notificationCenter->sendNotifications( + NotificationType::DECISION, + array( + DecisionNotificationTypes::FLAG, + $userId, + $userAttributes, + (object) array( + 'flagKey'=> $flagKey, + 'enabled'=> $featureEnabled, + 'variables' => $allVariables, + 'variationKey' => $variationKey, + 'ruleKey' => $ruleKey, + 'reasons' => $shouldIncludeReasons ? $decideReasons:[], + 'decisionEventDispatched' => $decisionEventDispatched + ) + ) + ); + + return new OptimizelyDecision( + $variationKey, + $featureEnabled, + $allVariables, + $ruleKey, + $flagKey, + $userContext, + $shouldIncludeReasons ? $decideReasons:[] + ); + } + + /** + * Returns a hash of decision results (OptimizelyDecision) for all active flag keys. + * + * @param $userContext OptimizelyUserContext context of the user for which decision will be called. + * @param $decideOptions array A list of options for decision making. + * + * @return array Hash of decisions containing flag keys as hash keys and corresponding decisions as their values. + */ + public function decideAll(OptimizelyUserContext $userContext, array $decideOptions = []) + { + // check if SDK is ready + $config = $this->getConfig(); + if ($config === null) { + $this->_logger->log(Logger::ERROR, sprintf(Errors::INVALID_DATAFILE, __FUNCTION__)); + return []; + } + + // get all feature keys + $keys = []; + $featureFlags = $config->getFeatureFlags(); + foreach ($featureFlags as $feature) { + $keys [] = $feature->getKey(); + } + + return $this->decideForKeys($userContext, $keys, $decideOptions); + } + + /** + * Returns a hash of decision results (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 hash of decisions. When it can not process requests, it'll return an empty hash after logging the errors. + * + * @param $userContext OptimizelyUserContext context of the user for which decision will be called. + * @param $keys array A list of flag keys for which the decisions will be made. + * @param $decideOptions array A list of options for decision making. + * + * @return array Hash of decisions containing flag keys as hash keys and corresponding decisions as their values. + */ + public function decideForKeys(OptimizelyUserContext $userContext, array $keys, array $decideOptions = []) + { + // check if SDK is ready + $config = $this->getConfig(); + if ($config === null) { + $this->_logger->log(Logger::ERROR, sprintf(Errors::INVALID_DATAFILE, __FUNCTION__)); + return []; + } + + // merge decide options and default decide options + $decideOptions = array_merge($decideOptions, $this->defaultDecideOptions); + + $enabledFlagsOnly = in_array(OptimizelyDecideOption::ENABLED_FLAGS_ONLY, $decideOptions); + $decisions = []; + + foreach ($keys as $key) { + $decision = $this->decide($userContext, $key, $decideOptions); + if (!$enabledFlagsOnly || $decision->getEnabled() === true) { + $decisions [$key] = $decision; + } + } + + return $decisions; + } + /** * Buckets visitor and sends impression event to Optimizely. * @@ -274,7 +532,7 @@ public function activate($experimentKey, $userId, $attributes = null) return null; } - $this->sendImpressionEvent($config, $experimentKey, $variationKey, '', $experimentKey, FeatureDecision::DECITION_SOURCE_EXPERIMENT, true, $userId, $attributes); + $this->sendImpressionEvent($config, $experimentKey, $variationKey, '', $experimentKey, FeatureDecision::DECISION_SOURCE_EXPERIMENT, true, $userId, $attributes); return $variationKey; } @@ -424,7 +682,7 @@ public function getVariation($experimentKey, $userId, $attributes = null) return null; } - $variation = $this->_decisionService->getVariation($config, $experiment, $userId, $attributes); + list($variation, $reasons) = $this->_decisionService->getVariation($config, $experiment, $userId, $attributes); $variationKey = ($variation === null) ? null : $variation->getKey(); if ($config->isFeatureExperiment($experiment->getId())) { @@ -504,7 +762,7 @@ public function getForcedVariation($experimentKey, $userId) return null; } - $forcedVariation = $this->_decisionService->getForcedVariation($config, $experimentKey, $userId); + list($forcedVariation, $reasons) = $this->_decisionService->getForcedVariation($config, $experimentKey, $userId); if (isset($forcedVariation)) { return $forcedVariation->getKey(); } else { diff --git a/src/Optimizely/OptimizelyUserContext.php b/src/Optimizely/OptimizelyUserContext.php new file mode 100644 index 00000000..3f176488 --- /dev/null +++ b/src/Optimizely/OptimizelyUserContext.php @@ -0,0 +1,82 @@ +optimizelyClient = $optimizelyClient; + $this->userId = $userId; + $this->attributes = $attributes; + } + + public function setAttribute($key, $value) + { + $this->attributes[$key] = $value; + } + + public function decide($key, array $options = []) + { + return $this->optimizelyClient->decide($this, $key, $options); + } + + public function decideForKeys(array $keys, array $options = []) + { + return $this->optimizelyClient->decideForKeys($this, $keys, $options); + } + + public function decideAll(array $options = []) + { + return $this->optimizelyClient->decideAll($this, $options); + } + + public function trackEvent($eventKey, array $eventTags = []) + { + $eventTags = $eventTags ?: null; + return $this->optimizelyClient->track($eventKey, $this->userId, $this->attributes, $eventTags); + } + + public function getUserId() + { + return $this->userId; + } + + public function getAttributes() + { + return $this->attributes; + } + + public function getOptimizely() + { + return $this->optimizelyClient; + } + + public function jsonSerialize() + { + return [ + 'userId' => $this->userId, + 'attributes' => $this->attributes + ]; + } +} diff --git a/src/Optimizely/Utils/Validator.php b/src/Optimizely/Utils/Validator.php index e76cc0bb..ff2f1c5b 100644 --- a/src/Optimizely/Utils/Validator.php +++ b/src/Optimizely/Utils/Validator.php @@ -1,6 +1,6 @@ log(Logger::INFO, sprintf( + $message = sprintf( $loggingClass::AUDIENCE_EVALUATION_RESULT_COMBINED, $loggingKey, 'TRUE' - )); - return true; + ); + $logger->log(Logger::INFO, $message); + $decideReasons [] = $message; + return [ true, $decideReasons ]; } if ($userAttributes === null) { @@ -209,13 +214,16 @@ public static function doesUserMeetAudienceConditions($config, $experiment, $use $evalResult = $conditionTreeEvaluator->evaluate($audienceConditions, $evaluateAudience); $evalResult = $evalResult || false; - $logger->log(Logger::INFO, sprintf( + $message = sprintf( $loggingClass::AUDIENCE_EVALUATION_RESULT_COMBINED, $loggingKey, strtoupper(var_export($evalResult, true)) - )); + ); + + $logger->log(Logger::INFO, $message); + $decideReasons[] = $message; - return $evalResult; + return [ $evalResult, $decideReasons]; } /** diff --git a/tests/BucketerTest.php b/tests/BucketerTest.php index 5642cd53..28c62b73 100644 --- a/tests/BucketerTest.php +++ b/tests/BucketerTest.php @@ -1,6 +1,6 @@ method('log') ->with(Logger::DEBUG, sprintf('Assigned bucket 1000 to user "%s" with bucketing ID "%s".', $this->testUserId, $this->testBucketingIdControl)); - $this->assertNull( - $bucketer->bucket( - $this->config, - $this->config->getExperimentFromKey('test_experiment'), - $this->testBucketingIdControl, - $this->testUserId - ) + list($actualVariation, $reasons) = $bucketer->bucket( + $this->config, + $this->config->getExperimentFromKey('test_experiment'), + $this->testBucketingIdControl, + $this->testUserId ); + $this->assertNull($actualVariation); + // control $this->loggerMock->expects($this->at(0)) ->method('log') ->with(Logger::DEBUG, sprintf('Assigned bucket 3000 to user "%s" with bucketing ID "%s".', $this->testUserId, $this->testBucketingIdControl)); + list($actualVariation, $reasons) = $bucketer->bucket( + $this->config, + $this->config->getExperimentFromKey('test_experiment'), + $this->testBucketingIdControl, + $this->testUserId + ); + $this->assertEquals( new Variation('7722370027', 'control'), - $bucketer->bucket( - $this->config, - $this->config->getExperimentFromKey('test_experiment'), - $this->testBucketingIdControl, - $this->testUserId - ) + $actualVariation ); // variation @@ -133,14 +135,16 @@ public function testBucketValidExperimentNotInGroup() ->method('log') ->with(Logger::DEBUG, sprintf('Assigned bucket 7000 to user "%s" with bucketing ID "%s".', $this->testUserId, $this->testBucketingIdControl)); + list($actualVariation, $reasons) = $bucketer->bucket( + $this->config, + $this->config->getExperimentFromKey('test_experiment'), + $this->testBucketingIdControl, + $this->testUserId + ); + $this->assertEquals( new Variation('7721010009', 'variation'), - $bucketer->bucket( - $this->config, - $this->config->getExperimentFromKey('test_experiment'), - $this->testBucketingIdControl, - $this->testUserId - ) + $actualVariation ); // No variation @@ -148,14 +152,14 @@ public function testBucketValidExperimentNotInGroup() ->method('log') ->with(Logger::DEBUG, sprintf('Assigned bucket 9000 to user "%s" with bucketing ID "%s".', $this->testUserId, $this->testBucketingIdControl)); - $this->assertNull( - $bucketer->bucket( - $this->config, - $this->config->getExperimentFromKey('test_experiment'), - $this->testBucketingIdControl, - $this->testUserId - ) + list($actualVariation, $reasons) = $bucketer->bucket( + $this->config, + $this->config->getExperimentFromKey('test_experiment'), + $this->testBucketingIdControl, + $this->testUserId ); + + $this->assertNull($actualVariation); } public function testBucketValidExperimentInGroup() @@ -171,13 +175,24 @@ public function testBucketValidExperimentInGroup() $this->loggerMock->expects($this->at(0)) ->method('log') ->with(Logger::DEBUG, sprintf('Assigned bucket 1000 to user "%s" with bucketing ID "%s".', $this->testUserId, $this->testBucketingIdControl)); + + $inExpMessage = 'User "testUserId" is in experiment group_experiment_1 of group 7722400015.'; $this->loggerMock->expects($this->at(1)) ->method('log') - ->with(Logger::INFO, 'User "testUserId" is in experiment group_experiment_1 of group 7722400015.'); + ->with(Logger::INFO, $inExpMessage); $this->loggerMock->expects($this->at(2)) ->method('log') ->with(Logger::DEBUG, sprintf('Assigned bucket 4000 to user "%s" with bucketing ID "%s".', $this->testUserId, $this->testBucketingIdControl)); + list($actualVariation, $reasons) = $bucketer->bucket( + $this->config, + $this->config->getExperimentFromKey('group_experiment_1'), + $this->testBucketingIdControl, + $this->testUserId + ); + + $this->assertContains($inExpMessage, $reasons); + $this->assertEquals( new Variation( '7722260071', @@ -190,12 +205,7 @@ public function testBucketValidExperimentInGroup() ] ] ), - $bucketer->bucket( - $this->config, - $this->config->getExperimentFromKey('group_experiment_1'), - $this->testBucketingIdControl, - $this->testUserId - ) + $actualVariation ); // variation 2 @@ -210,6 +220,14 @@ public function testBucketValidExperimentInGroup() ->method('log') ->with(Logger::DEBUG, sprintf('Assigned bucket 7000 to user "%s" with bucketing ID "%s".', $this->testUserId, $this->testBucketingIdControl)); + + list($actualVariation, $reasons) = $bucketer->bucket( + $this->config, + $this->config->getExperimentFromKey('group_experiment_1'), + $this->testBucketingIdControl, + $this->testUserId + ); + $this->assertEquals( new Variation( '7722360022', @@ -222,12 +240,7 @@ public function testBucketValidExperimentInGroup() ] ] ), - $bucketer->bucket( - $this->config, - $this->config->getExperimentFromKey('group_experiment_1'), - $this->testBucketingIdControl, - $this->testUserId - ) + $actualVariation ); // User not in experiment @@ -235,37 +248,48 @@ public function testBucketValidExperimentInGroup() $this->loggerMock->expects($this->at(0)) ->method('log') ->with(Logger::DEBUG, sprintf('Assigned bucket 5000 to user "%s" with bucketing ID "%s".', $this->testUserId, $this->testBucketingIdControl)); + + $noExpGroupMessage = 'User "testUserId" is not in experiment group_experiment_1 of group 7722400015.'; $this->loggerMock->expects($this->at(1)) ->method('log') - ->with(Logger::INFO, 'User "testUserId" is not in experiment group_experiment_1 of group 7722400015.'); - - $this->assertNull( - $bucketer->bucket( - $this->config, - $this->config->getExperimentFromKey('group_experiment_1'), - $this->testBucketingIdControl, - $this->testUserId - ) + ->with(Logger::INFO, $noExpGroupMessage); + + list($actualVariation, $reasons) = $bucketer->bucket( + $this->config, + $this->config->getExperimentFromKey('group_experiment_1'), + $this->testBucketingIdControl, + $this->testUserId ); + $this->assertContains($noExpGroupMessage, $reasons); + + $this->assertNull($actualVariation); + // User not in any experiment (previously allocated space) $bucketer->setBucketValues([400]); + $bucketingMessage = sprintf('Assigned bucket 400 to user "%s" with bucketing ID "%s".', $this->testUserId, $this->testBucketingIdControl); $this->loggerMock->expects($this->at(0)) ->method('log') - ->with(Logger::DEBUG, sprintf('Assigned bucket 400 to user "%s" with bucketing ID "%s".', $this->testUserId, $this->testBucketingIdControl)); + ->with(Logger::DEBUG, $bucketingMessage); + + $noExpMessage = 'User "testUserId" is in no experiment.'; $this->loggerMock->expects($this->at(1)) ->method('log') - ->with(Logger::INFO, 'User "testUserId" is in no experiment.'); + ->with(Logger::INFO, $noExpMessage); - $this->assertNull( - $bucketer->bucket( - $this->config, - $this->config->getExperimentFromKey('group_experiment_1'), - $this->testBucketingIdControl, - $this->testUserId - ) + list($actualVariation, $reasons) = $bucketer->bucket( + $this->config, + $this->config->getExperimentFromKey('group_experiment_1'), + $this->testBucketingIdControl, + $this->testUserId ); + $this->assertContains($bucketingMessage, $reasons); + $this->assertContains($noExpMessage, $reasons); + $this->assertCount(2, $reasons); + + $this->assertNull($actualVariation); + // User not in any experiment (never allocated space) $bucketer->setBucketValues([9000]); $this->loggerMock->expects($this->at(0)) @@ -274,14 +298,15 @@ public function testBucketValidExperimentInGroup() $this->loggerMock->expects($this->at(1)) ->method('log') ->with(Logger::INFO, 'User "testUserId" is in no experiment.'); - $this->assertNull( - $bucketer->bucket( - $this->config, - $this->config->getExperimentFromKey('group_experiment_1'), - $this->testBucketingIdControl, - $this->testUserId - ) + + list($actualVariation, $reasons) = $bucketer->bucket( + $this->config, + $this->config->getExperimentFromKey('group_experiment_1'), + $this->testBucketingIdControl, + $this->testUserId ); + + $this->assertNull($actualVariation); } public function testBucketInvalidExperiment() @@ -290,9 +315,13 @@ public function testBucketInvalidExperiment() $this->loggerMock->expects($this->never()) ->method('log'); - $this->assertNull( - $bucketer->bucket($this->config, new Experiment(), $this->testBucketingIdControl, $this->testUserId) - ); + $actualResponse = $bucketer->bucket($this->config, new Experiment(), $this->testBucketingIdControl, $this->testUserId); + + $this->assertNull($actualResponse[0]); + + // $this->assertNull( + // $bucketer->bucket($this->config, new Experiment(), $this->testBucketingIdControl, $this->testUserId) + // ); } public function testBucketWithBucketingId() @@ -302,14 +331,17 @@ public function testBucketWithBucketingId() // make sure that the bucketing ID is used for the variation // bucketing and not the user ID + + list($actualVariation, $reasons) = $bucketer->bucket( + $this->config, + $experiment, + $this->testBucketingIdControl, + $this->testUserIdBucketsToVariation + ); + $this->assertEquals( new Variation('7722370027', 'control'), - $bucketer->bucket( - $this->config, - $experiment, - $this->testBucketingIdControl, - $this->testUserIdBucketsToVariation - ) + $actualVariation ); } @@ -320,14 +352,14 @@ public function testBucketVariationInvalidExperimentsWithBucketingId() $bucketer = new TestBucketer($this->loggerMock); $bucketer->setBucketValues([1000, 3000, 7000, 9000]); - $this->assertNull( - $bucketer->bucket( - $this->config, - $this->config->getExperimentFromKey('invalid_experiment'), - $this->testBucketingIdVariation, - $this->testUserId - ) + $actualResponse = $bucketer->bucket( + $this->config, + $this->config->getExperimentFromKey('invalid_experiment'), + $this->testBucketingIdVariation, + $this->testUserId ); + + $this->assertNull($actualResponse[0]); } // make sure that the bucketing ID is used to bucket the user into a group @@ -336,25 +368,28 @@ public function testBucketVariationGroupedExperimentsWithBucketingId() { $bucketer = new Bucketer($this->loggerMock); - $this->assertEquals( - new Variation( - '7725250007', - 'group_exp_2_var_2', - true, - [ - [ - "id" => "155563", - "value" => "groupie_2_v1" - ] - ] - ), - $bucketer->bucket( - $this->config, - $this->config->getExperimentFromKey('group_experiment_2'), - $this->testBucketingIdGroupExp2Var2, - $this->testUserIdBucketsToNoGroup - ) + $expectedVariation = new Variation( + '7725250007', + 'group_exp_2_var_2', + true, + [ + [ + "id" => "155563", + "value" => "groupie_2_v1" + ] + ] + ); + + $actualResponse = $bucketer->bucket( + $this->config, + $this->config->getExperimentFromKey('group_experiment_2'), + $this->testBucketingIdGroupExp2Var2, + $this->testUserIdBucketsToNoGroup ); + + $actualVariation = $actualResponse[0]; + + $this->assertEquals($expectedVariation, $actualVariation); } public function testBucketWithRolloutRule() @@ -383,23 +418,25 @@ public function testBucketWithRolloutRule() ] ); + list($actualVariation, $reasons) = $bucketer->bucket( + $this->config, + $rollout_rule, + 'bucketingId', + 'userId' + ); + $this->assertEquals( $expectedVariation, - $bucketer->bucket( - $this->config, - $rollout_rule, - 'bucketingId', - 'userId' - ) + $actualVariation ); - $this->assertNull( - $bucketer->bucket( - $this->config, - $rollout_rule, - 'bucketingId', - 'userId' - ) + list($actualVariation, $reasons) = $bucketer->bucket( + $this->config, + $rollout_rule, + 'bucketingId', + 'userId' ); + + $this->assertNull($actualVariation); } } diff --git a/tests/DecisionServiceTests/DecisionServiceTest.php b/tests/DecisionServiceTests/DecisionServiceTest.php index 2f5f54a9..6c4669f8 100644 --- a/tests/DecisionServiceTests/DecisionServiceTest.php +++ b/tests/DecisionServiceTests/DecisionServiceTest.php @@ -1,6 +1,6 @@ getMock(); } + public function compareFeatureDecisionsExceptReasons(FeatureDecision $expectedObj, FeatureDecision $actualObj) + { + $this->assertEquals($expectedObj->getVariation(), $actualObj->getVariation()); + $this->assertEquals($expectedObj->getExperiment(), $actualObj->getExperiment()); + $this->assertEquals($expectedObj->getSource(), $actualObj->getSource()); + } + public function testGetVariationReturnsNullWhenExperimentIsNotRunning() { $this->bucketerMock->expects($this->never()) @@ -97,7 +104,7 @@ public function testGetVariationReturnsNullWhenExperimentIsNotRunning() $bucketer->setAccessible(true); $bucketer->setValue($this->decisionService, $this->bucketerMock); - $variation = $this->decisionService->getVariation($this->config, $pausedExperiment, $this->testUserId); + list($variation, $reasons) = $this->decisionService->getVariation($this->config, $pausedExperiment, $this->testUserId); $this->assertNull($variation); } @@ -107,7 +114,7 @@ public function testGetVariationBucketsUserWhenExperimentIsRunning() $expectedVariation = new Variation('7722370027', 'control'); $this->bucketerMock->expects($this->once()) ->method('bucket') - ->willReturn($expectedVariation); + ->willReturn([$expectedVariation, []]); $runningExperiment = $this->config->getExperimentFromKey('test_experiment'); @@ -115,7 +122,7 @@ public function testGetVariationBucketsUserWhenExperimentIsRunning() $bucketer->setAccessible(true); $bucketer->setValue($this->decisionService, $this->bucketerMock); - $variation = $this->decisionService->getVariation($this->config, $runningExperiment, $this->testUserId, $this->testUserAttributes); + list($variation, $reasons) = $this->decisionService->getVariation($this->config, $runningExperiment, $this->testUserId, $this->testUserAttributes); $this->assertEquals( $expectedVariation, @@ -136,8 +143,13 @@ public function testGetBucketingIdWhenBucketingIdIsNotString() $this->loggerMock->expects($this->at(0)) ->method('log') ->with(Logger::WARNING, 'Bucketing ID attribute is not a string. Defaulted to user ID.'); + + $expectedReasons = ['Bucketing ID attribute is not a string. Defaulted to user ID.']; - $this->assertSame($this->testUserId, $decisionService->getBucketingId($this->testUserId, $userAttributesWithBucketingId)); + $this->assertEquals( + [$this->testUserId, $expectedReasons], + $decisionService->getBucketingId($this->testUserId, $userAttributesWithBucketingId) + ); } public function testGetBucketingIdWhenBucketingIdIsNull() @@ -153,7 +165,7 @@ public function testGetBucketingIdWhenBucketingIdIsNull() $this->loggerMock->expects($this->never()) ->method('log'); - $this->assertSame($this->testUserId, $decisionService->getBucketingId($this->testUserId, $userAttributesWithBucketingId)); + $this->assertEquals([$this->testUserId, []], $decisionService->getBucketingId($this->testUserId, $userAttributesWithBucketingId)); } public function testGetBucketingIdWhenBucketingIdIsString() @@ -169,7 +181,7 @@ public function testGetBucketingIdWhenBucketingIdIsString() $this->loggerMock->expects($this->never()) ->method('log'); - $this->assertSame('i_am_bucketing_id', $decisionService->getBucketingId($this->testUserId, $userAttributesWithBucketingId)); + $this->assertEquals(['i_am_bucketing_id', []], $decisionService->getBucketingId($this->testUserId, $userAttributesWithBucketingId)); } public function testGetBucketingIdWhenBucketingIdIsEmptyString() @@ -185,7 +197,7 @@ public function testGetBucketingIdWhenBucketingIdIsEmptyString() $this->loggerMock->expects($this->never()) ->method('log'); - $this->assertSame('', $decisionService->getBucketingId($this->testUserId, $userAttributesWithBucketingId)); + $this->assertEquals(['', []], $decisionService->getBucketingId($this->testUserId, $userAttributesWithBucketingId)); } public function testGetVariationReturnsWhitelistedVariation() @@ -207,7 +219,7 @@ public function testGetVariationReturnsWhitelistedVariation() $bucketer->setAccessible(true); $bucketer->setValue($this->decisionService, $this->bucketerMock); - $variation = $this->decisionService->getVariation($this->config, $runningExperiment, 'user1'); + list($variation, $reasons) = $this->decisionService->getVariation($this->config, $runningExperiment, 'user1'); $this->assertEquals( $expectedVariation, @@ -233,23 +245,31 @@ public function testGetVariationReturnsWhitelistedVariationForGroupedExperiment( $callIndex = 0; $this->bucketerMock->expects($this->never()) ->method('bucket'); + + $forcedVarMessage = 'User "user1" is not in the forced variation map.'; $this->loggerMock->expects($this->at($callIndex++)) ->method('log') - ->with(Logger::DEBUG, 'User "user1" is not in the forced variation map.'); + ->with(Logger::DEBUG, $forcedVarMessage); + + $whitelistedVarMessage = 'User "user1" is forced in variation "group_exp_1_var_1" of experiment "group_experiment_1".'; $this->loggerMock->expects($this->at($callIndex++)) ->method('log') - ->with(Logger::INFO, 'User "user1" is forced in variation "group_exp_1_var_1" of experiment "group_experiment_1".'); + ->with(Logger::INFO, $whitelistedVarMessage); $bucketer = new \ReflectionProperty(DecisionService::class, '_bucketer'); $bucketer->setAccessible(true); $bucketer->setValue($this->decisionService, $this->bucketerMock); - $variation = $this->decisionService->getVariation($this->config, $runningExperiment, 'user1'); + list($variation, $reasons) = $this->decisionService->getVariation($this->config, $runningExperiment, 'user1'); $this->assertEquals( $expectedVariation, $variation ); + + $this->assertNotContains($forcedVarMessage, $reasons); + $this->assertContains($whitelistedVarMessage, $reasons); + $this->assertCount(1, $reasons); } public function testGetVariationBucketsWhenForcedVariationsIsEmpty() @@ -257,7 +277,7 @@ public function testGetVariationBucketsWhenForcedVariationsIsEmpty() $expectedVariation = new Variation('7722370027', 'control'); $this->bucketerMock->expects($this->once()) ->method('bucket') - ->willReturn($expectedVariation); + ->willReturn([$expectedVariation, []]); $runningExperiment = $this->config->getExperimentFromKey('test_experiment'); @@ -270,7 +290,7 @@ public function testGetVariationBucketsWhenForcedVariationsIsEmpty() $bucketer->setAccessible(true); $bucketer->setValue($this->decisionService, $this->bucketerMock); - $variation = $this->decisionService->getVariation($this->config, $runningExperiment, 'user1', $this->testUserAttributes); + list($variation, $reasons) = $this->decisionService->getVariation($this->config, $runningExperiment, 'user1', $this->testUserAttributes); $this->assertEquals( $expectedVariation, @@ -283,7 +303,7 @@ public function testGetVariationBucketsWhenWhitelistedVariationIsInvalid() $expectedVariation = new Variation('7722370027', 'control'); $this->bucketerMock->expects($this->once()) ->method('bucket') - ->willReturn($expectedVariation); + ->willReturn([$expectedVariation, []]); $runningExperiment = $this->config->getExperimentFromKey('test_experiment'); @@ -301,7 +321,7 @@ public function testGetVariationBucketsWhenWhitelistedVariationIsInvalid() $bucketer->setAccessible(true); $bucketer->setValue($this->decisionService, $this->bucketerMock); - $variation = $this->decisionService->getVariation($this->config, $runningExperiment, 'user1', $this->testUserAttributes); + list($variation, $reasons) = $this->decisionService->getVariation($this->config, $runningExperiment, 'user1', $this->testUserAttributes); $this->assertEquals( $expectedVariation, @@ -314,7 +334,7 @@ public function testGetVariationBucketsUserWhenUserIsNotWhitelisted() $expectedVariation = new Variation('7722370027', 'control'); $this->bucketerMock->expects($this->once()) ->method('bucket') - ->willReturn($expectedVariation); + ->willReturn([$expectedVariation, []]); $runningExperiment = $this->config->getExperimentFromKey('test_experiment'); @@ -322,7 +342,7 @@ public function testGetVariationBucketsUserWhenUserIsNotWhitelisted() $bucketer->setAccessible(true); $bucketer->setValue($this->decisionService, $this->bucketerMock); - $variation = $this->decisionService->getVariation($this->config, $runningExperiment, 'not_whitelisted_user', $this->testUserAttributes); + list($variation, $reasons) = $this->decisionService->getVariation($this->config, $runningExperiment, 'not_whitelisted_user', $this->testUserAttributes); $this->assertEquals( $expectedVariation, @@ -341,7 +361,7 @@ public function testGetVariationReturnsNullIfUserDoesNotMeetAudienceConditions() $bucketer->setAccessible(true); $bucketer->setValue($this->decisionService, $this->bucketerMock); - $variation = $this->decisionService->getVariation($this->config, $runningExperiment, $this->testUserId); // no matching attributes + list($variation, $reasons) = $this->decisionService->getVariation($this->config, $runningExperiment, $this->testUserId); // no matching attributes $this->assertNull($variation); } @@ -379,7 +399,7 @@ public function testGetVariationReturnsStoredVariationIfAvailable() $bucketer->setAccessible(true); $bucketer->setValue($this->decisionService, $this->bucketerMock); - $variation = $this->decisionService->getVariation($this->config, $runningExperiment, $userId); + list($variation, $reasons) = $this->decisionService->getVariation($this->config, $runningExperiment, $userId); $this->assertEquals($expectedVariation, $variation); } @@ -391,7 +411,7 @@ public function testGetVariationBucketsIfNoStoredVariation() $this->bucketerMock->expects($this->once()) ->method('bucket') - ->willReturn($expectedVariation); + ->willReturn([$expectedVariation, []]); $this->loggerMock->expects($this->any()) ->method('log') @@ -423,7 +443,7 @@ public function testGetVariationBucketsIfNoStoredVariation() $bucketer->setAccessible(true); $bucketer->setValue($this->decisionService, $this->bucketerMock); - $variation = $this->decisionService->getVariation($this->config, $runningExperiment, $userId, $this->testUserAttributes); + list($variation, $reasons) = $this->decisionService->getVariation($this->config, $runningExperiment, $userId, $this->testUserAttributes); $this->assertEquals($expectedVariation, $variation); // Verify Logs @@ -440,7 +460,7 @@ public function testGetVariationBucketsIfStoredVariationIsInvalid() $this->bucketerMock->expects($this->once()) ->method('bucket') - ->willReturn($expectedVariation); + ->willReturn([$expectedVariation, []]); $this->loggerMock->expects($this->any()) ->method('log') @@ -476,13 +496,17 @@ public function testGetVariationBucketsIfStoredVariationIsInvalid() $bucketer->setAccessible(true); $bucketer->setValue($this->decisionService, $this->bucketerMock); - $variation = $this->decisionService->getVariation($this->config, $runningExperiment, $userId, $this->testUserAttributes); + list($variation, $reasons) = $this->decisionService->getVariation($this->config, $runningExperiment, $userId, $this->testUserAttributes); $this->assertEquals($expectedVariation, $variation); // Verify Logs $this->assertContains([Logger::DEBUG, sprintf('User "%s" is not in the forced variation map.', $userId)], $this->collectedLogs); - $this->assertContains([Logger::INFO, 'User "testUserId" was previously bucketed into variation with ID "invalid" for experiment "test_experiment", but no matching variation was found for that user. We will re-bucket the user.'], $this->collectedLogs); + + $userProfileMsg = 'User "testUserId" was previously bucketed into variation with ID "invalid" for experiment "test_experiment", but no matching variation was found for that user. We will re-bucket the user.'; + $this->assertContains([Logger::INFO, $userProfileMsg], $this->collectedLogs); $this->assertContains([Logger::INFO, 'Saved variation "control" of experiment "test_experiment" for user "testUserId".'], $this->collectedLogs); + + $this->assertContains($userProfileMsg, $reasons); } public function testGetVariationBucketsIfUserProfileServiceLookupThrows() @@ -493,7 +517,7 @@ public function testGetVariationBucketsIfUserProfileServiceLookupThrows() $this->bucketerMock->expects($this->once()) ->method('bucket') - ->willReturn($expectedVariation); + ->willReturn([$expectedVariation, []]); $this->loggerMock->expects($this->any()) ->method('log') @@ -529,13 +553,17 @@ public function testGetVariationBucketsIfUserProfileServiceLookupThrows() $bucketer->setAccessible(true); $bucketer->setValue($this->decisionService, $this->bucketerMock); - $variation = $this->decisionService->getVariation($this->config, $runningExperiment, $userId, $this->testUserAttributes); + list($variation, $reasons) = $this->decisionService->getVariation($this->config, $runningExperiment, $userId, $this->testUserAttributes); $this->assertEquals($expectedVariation, $variation); // Verify Logs $this->assertContains([Logger::DEBUG, sprintf('User "%s" is not in the forced variation map.', $userId)], $this->collectedLogs); - $this->assertContains([Logger::ERROR, 'The User Profile Service lookup method failed: I am error.'], $this->collectedLogs); + + $lookupFailedMsg = 'The User Profile Service lookup method failed: I am error.'; + $this->assertContains([Logger::ERROR, $lookupFailedMsg], $this->collectedLogs); $this->assertContains([Logger::INFO, 'Saved variation "control" of experiment "test_experiment" for user "testUserId".'], $this->collectedLogs); + + $this->assertContains($lookupFailedMsg, $reasons); } public function testGetVariationBucketsIfUserProfileServiceSaveThrows() @@ -546,7 +574,7 @@ public function testGetVariationBucketsIfUserProfileServiceSaveThrows() $this->bucketerMock->expects($this->once()) ->method('bucket') - ->willReturn($expectedVariation); + ->willReturn([$expectedVariation, []]); $this->loggerMock->expects($this->any()) ->method('log') @@ -573,7 +601,7 @@ public function testGetVariationBucketsIfUserProfileServiceSaveThrows() $bucketer->setAccessible(true); $bucketer->setValue($this->decisionService, $this->bucketerMock); - $variation = $this->decisionService->getVariation($this->config, $runningExperiment, $userId, $this->testUserAttributes); + list($variation, $reasons) = $this->decisionService->getVariation($this->config, $runningExperiment, $userId, $this->testUserAttributes); $this->assertEquals($expectedVariation, $variation); // Verify Logs @@ -696,7 +724,8 @@ public function testGetVariationForFeatureExperimentGivenNullExperimentIds() ->method('log') ->with(Logger::DEBUG, "The feature flag 'empty_feature' is not used in any experiments."); - $this->assertNull($this->decisionService->getVariationForFeatureExperiment($this->config, $featureFlag, 'user1', [])); + $actualDecision = $this->decisionServiceMock->getVariationForFeatureExperiment($this->config, $featureFlag, 'user1', []); + $this->assertNull($actualDecision->getVariation()); } // should return nil and log a message when the experiment is not in the datafile @@ -718,19 +747,19 @@ public function testGetVariationForFeatureExperimentGivenExperimentNotInDataFile "The user 'user1' is not bucketed into any of the experiments using the feature 'boolean_feature'." ); - $this->assertNull($this->decisionService->getVariationForFeatureExperiment($this->config, $featureFlag, 'user1', [])); + $actualDecision = $this->decisionServiceMock->getVariationForFeatureExperiment($this->config, $featureFlag, 'user1', []); + $this->assertNull($actualDecision->getVariation()); } // should return nil and log when the user is not bucketed into the feature flag's experiments public function testGetVariationForFeatureExperimentGivenNonMutexGroupAndUserNotBucketed() { $multivariate_experiment = $this->config->getExperimentFromKey('test_experiment_multivariate'); - $map = [ [$multivariate_experiment, 'user1', [], null] ]; // make sure the user is not bucketed into the feature experiment $this->decisionServiceMock->expects($this->at(0)) ->method('getVariation') - ->will($this->returnValueMap($map)); + ->will($this->returnValue([ null, []])); $this->loggerMock->expects($this->at(0)) ->method('log') @@ -739,7 +768,9 @@ public function testGetVariationForFeatureExperimentGivenNonMutexGroupAndUserNot "The user 'user1' is not bucketed into any of the experiments using the feature 'multi_variate_feature'." ); $featureFlag = $this->config->getFeatureFlagFromKey('multi_variate_feature'); - $this->assertNull($this->decisionServiceMock->getVariationForFeatureExperiment($this->config, $featureFlag, 'user1', [])); + + $actualDecision = $this->decisionServiceMock->getVariationForFeatureExperiment($this->config, $featureFlag, 'user1', []); + $this->assertNull($actualDecision->getVariation()); } // should return the variation when the user is bucketed into a variation for the experiment on the feature flag @@ -750,7 +781,7 @@ public function testGetVariationForFeatureExperimentGivenNonMutexGroupAndUserIsB $variation = $this->config->getVariationFromId('test_experiment_multivariate', '122231'); $this->decisionServiceMock->expects($this->at(0)) ->method('getVariation') - ->will($this->returnValue($variation)); + ->will($this->returnValue([$variation, []])); $featureFlag = $this->config->getFeatureFlagFromKey('multi_variate_feature'); $expected_decision = new FeatureDecision($experiment, $variation, FeatureDecision::DECISION_SOURCE_FEATURE_TEST); @@ -759,13 +790,11 @@ public function testGetVariationForFeatureExperimentGivenNonMutexGroupAndUserIsB ->method('log') ->with( Logger::INFO, - "The user 'user1' is bucketed into experiment 'test_experiment_multivariate' of feature 'multi_variate_feature'." + "The user 'user_1' is bucketed into experiment 'test_experiment_multivariate' of feature 'multi_variate_feature'." ); - $this->assertEquals( - $expected_decision, - $this->decisionServiceMock->getVariationForFeatureExperiment($this->config, $featureFlag, 'user1', []) - ); + $actualDecision = $this->decisionServiceMock->getVariationForFeatureExperiment($this->config, $featureFlag, 'user_1', []); + $this->compareFeatureDecisionsExceptReasons($expected_decision, $actualDecision); } // should return the variation the user is bucketed into when the user is bucketed into one of the experiments @@ -775,7 +804,7 @@ public function testGetVariationForFeatureExperimentGivenMutexGroupAndUserIsBuck $variation = $mutex_exp->getVariations()[0]; $this->decisionServiceMock->expects($this->at(0)) ->method('getVariation') - ->will($this->returnValue($variation)); + ->will($this->returnValue([$variation, []])); $mutex_exp = $this->config->getExperimentFromKey('group_experiment_1'); $variation = $mutex_exp->getVariations()[0]; @@ -788,10 +817,9 @@ public function testGetVariationForFeatureExperimentGivenMutexGroupAndUserIsBuck Logger::INFO, "The user 'user_1' is bucketed into experiment 'group_experiment_1' of feature 'mutex_group_feature'." ); - $this->assertEquals( - $expected_decision, - $this->decisionServiceMock->getVariationForFeatureExperiment($this->config, $featureFlag, 'user_1', []) - ); + + $actualDecision = $this->decisionServiceMock->getVariationForFeatureExperiment($this->config, $featureFlag, 'user_1', []); + $this->compareFeatureDecisionsExceptReasons($expected_decision, $actualDecision); } // should return nil and log a message when the user is not bucketed into any of the mutex experiments @@ -801,7 +829,7 @@ public function testGetVariationForFeatureExperimentGivenMutexGroupAndUserNotBuc $variation = $mutex_exp->getVariations()[0]; $this->decisionServiceMock->expects($this->at(0)) ->method('getVariation') - ->will($this->returnValue(null)); + ->will($this->returnValue([null, []])); $mutex_exp = $this->config->getExperimentFromKey('group_experiment_1'); @@ -812,7 +840,14 @@ public function testGetVariationForFeatureExperimentGivenMutexGroupAndUserNotBuc Logger::INFO, "The user 'user_1' is not bucketed into any of the experiments using the feature 'boolean_feature'." ); - $this->assertNull($this->decisionServiceMock->getVariationForFeatureExperiment($this->config, $featureFlag, 'user_1', [])); + + $actualFeatureDecision = $this->decisionServiceMock->getVariationForFeatureExperiment( + $this->config, + $featureFlag, + 'user_1', + [] + ); + $this->assertNull($actualFeatureDecision->getVariation()); } // should return the bucketed experiment and variation @@ -862,10 +897,9 @@ public function testGetVariationForFeatureWhenBucketedToFeatureRollout() FeatureDecision::DECISION_SOURCE_ROLLOUT ); - $decisionServiceMock ->method('getVariationForFeatureExperiment') - ->will($this->returnValue(null)); + ->will($this->returnValue(new FeatureDecision(null, null, null))); $decisionServiceMock ->method('getVariationForFeatureRollout') @@ -878,10 +912,8 @@ public function testGetVariationForFeatureWhenBucketedToFeatureRollout() "User 'user_1' is bucketed into rollout for feature flag 'string_single_variable_feature'." ); - $this->assertEquals( - $expected_decision, - $decisionServiceMock->getVariationForFeature($this->config, $featureFlag, 'user_1', []) - ); + $actualFeatureDecision = $decisionServiceMock->getVariationForFeature($this->config, $featureFlag, 'user_1', []); + $this->compareFeatureDecisionsExceptReasons($expected_decision, $actualFeatureDecision); } // should return null @@ -896,11 +928,11 @@ public function testGetVariationForFeatureWhenTheUserIsNeitherBucketedIntoFeatur $decisionServiceMock ->method('getVariationForFeatureExperiment') - ->will($this->returnValue(null)); + ->will($this->returnValue(new FeatureDecision(null, null, null))); $decisionServiceMock ->method('getVariationForFeatureRollout') - ->will($this->returnValue(null)); + ->will($this->returnValue(new FeatureDecision(null, null, null))); $this->loggerMock->expects($this->at(0)) ->method('log') @@ -915,10 +947,10 @@ public function testGetVariationForFeatureWhenTheUserIsNeitherBucketedIntoFeatur null, FeatureDecision::DECISION_SOURCE_ROLLOUT ); - $this->assertEquals( - $decisionServiceMock->getVariationForFeature($this->config, $featureFlag, 'user_1', []), - $expectedDecision - ); + + $actualFeatureDecision = $decisionServiceMock->getVariationForFeature($this->config, $featureFlag, 'user_1', []); + + $this->compareFeatureDecisionsExceptReasons($expectedDecision, $actualFeatureDecision); } // should return null @@ -934,7 +966,13 @@ public function testGetVariationForFeatureRolloutWhenNoRolloutIsAssociatedToFeat "Feature flag 'boolean_feature' is not used in a rollout." ); - $this->assertNull($this->decisionServiceMock->getVariationForFeatureRollout($this->config, $featureFlag, 'user_1', [])); + $actualFeatureDecision = $this->decisionServiceMock->getVariationForFeatureRollout( + $this->config, + $featureFlag, + 'user_1', + [] + ); + $this->assertNull($actualFeatureDecision->getVariation()); } // should return null @@ -952,7 +990,13 @@ public function testGetVariationForFeatureRolloutWhenRolloutIsNotInDataFile() 'Rollout with ID "invalid_rollout_id" is not in the datafile.' ); - $this->assertNull($this->decisionServiceMock->getVariationForFeatureRollout($this->config, $featureFlag, 'user_1', [])); + $actualFeatureDecision = $this->decisionServiceMock->getVariationForFeatureRollout( + $this->config, + $featureFlag, + 'user_1', + [] + ); + $this->assertNull($actualFeatureDecision->getVariation()); } // should return null @@ -976,7 +1020,8 @@ public function testGetVariationForFeatureRolloutWhenRolloutDoesNotHaveExperimen ->method('getRolloutFromId') ->will($this->returnValue($experiment_less_rollout)); - $this->assertNull($this->decisionService->getVariationForFeatureRollout($configMock, $featureFlag, 'user_1', [])); + $actualFeatureDecision = $this->decisionService->getVariationForFeatureRollout($configMock, $featureFlag, 'user_1', []); + $this->assertNull($actualFeatureDecision->getVariation()); } // ============== when the user qualifies for targeting rule (audience match) ====================== @@ -1004,9 +1049,9 @@ public function testGetVariationForFeatureRolloutWhenUserIsBucketedInTheTargetin $this->bucketerMock ->method('bucket') - ->willReturn($expected_variation); + ->willReturn([$expected_variation, []]); - $this->assertEquals( + $this->compareFeatureDecisionsExceptReasons( $expected_decision, $this->decisionService->getVariationForFeatureRollout($this->config, $featureFlag, 'user_1', $user_attributes) ); @@ -1038,11 +1083,11 @@ public function testGetVariationForFeatureRolloutWhenUserIsNotBucketedInTheTarge // Make bucket return null when called for first targeting rule $this->bucketerMock->expects($this->at(0)) ->method('bucket') - ->willReturn(null); + ->willReturn([null, []]); // Make bucket return expected variation when called second time for everyone else $this->bucketerMock->expects($this->at(1)) ->method('bucket') - ->willReturn($expected_variation); + ->willReturn([$expected_variation, []]); $this->assertEquals( $expected_decision, @@ -1070,15 +1115,20 @@ public function testGetVariationForFeatureRolloutWhenUserIsNeitherBucketedInTheT // Make bucket return null when called for first targeting rule $this->bucketerMock->expects($this->at(0)) ->method('bucket') - ->willReturn(null); + ->willReturn([null, []]); // Make bucket return null when called second time for everyone else $this->bucketerMock->expects($this->at(1)) ->method('bucket') - ->willReturn(null); + ->willReturn([null, []]); - $this->assertNull( - $this->decisionService->getVariationForFeatureRollout($this->config, $featureFlag, 'user_1', $user_attributes) + $actualFeatureDecision = $this->decisionService->getVariationForFeatureRollout( + $this->config, + $featureFlag, + 'user_1', + $user_attributes ); + + $this->assertNull($actualFeatureDecision->getVariation()); } // ============== END of tests - when the user qualifies for targeting rule (audience match) ====================== @@ -1113,7 +1163,7 @@ public function testGetVariationForFeatureRolloutWhenUserDoesNotQualifyForAnyTar // Expect bucket to be called exactly once for the everyone else/last rule. $this->bucketerMock->expects($this->exactly(1)) ->method('bucket') - ->willReturn($expected_variation); + ->willReturn([$expected_variation, []]); $this->loggerMock->expects($this->any()) ->method('log') @@ -1158,7 +1208,9 @@ public function testGetVariationForFeatureRolloutWhenUserDoesNotQualifyForAnyTar ->method('log') ->will($this->returnCallback($this->collectLogsForAssertion)); - $this->assertNull($this->decisionService->getVariationForFeatureRollout($this->config, $featureFlag, 'user_1', $user_attributes)); + $actualFeatureDecision = $this->decisionService->getVariationForFeatureRollout($this->config, $featureFlag, 'user_1', $user_attributes); + + $this->assertNull($actualFeatureDecision->getVariation()); // Verify Logs $this->assertContains([Logger::DEBUG, "User 'user_1' does not meet conditions for targeting rule 1."], $this->collectedLogs); @@ -1193,7 +1245,7 @@ public function testGetVariationForFeatureRolloutLogging() // Expect bucket to be called exactly once for the everyone else/last rule. $this->bucketerMock->expects($this->exactly(1)) ->method('bucket') - ->willReturn($expected_variation); + ->willReturn([$expected_variation, []]); $this->loggerMock->expects($this->any()) ->method('log') @@ -1241,36 +1293,36 @@ public function testSetGetForcedVariation() // invalid experiment key should return a null variation $this->assertFalse($this->decisionService->setForcedVariation($this->config, $invalidExperimentKey, $userId, $variationKey)); - $this->assertNull($this->decisionService->getForcedVariation($this->config, $invalidExperimentKey, $userId)); + $this->assertNull($this->decisionService->getForcedVariation($this->config, $invalidExperimentKey, $userId)[0]); // setting a null variation should return a null variation $this->assertTrue($this->decisionService->setForcedVariation($this->config, $experimentKey, $userId, null)); - $this->assertNull($this->decisionService->getForcedVariation($this->config, $experimentKey, $userId)); + $this->assertNull($this->decisionService->getForcedVariation($this->config, $experimentKey, $userId)[0]); // setting an invalid variation should return a null variation $this->assertFalse($this->decisionService->setForcedVariation($this->config, $experimentKey, $userId, $invalidVariationKey)); - $this->assertNull($this->decisionService->getForcedVariation($this->config, $experimentKey, $userId)); + $this->assertNull($this->decisionService->getForcedVariation($this->config, $experimentKey, $userId)[0]); // confirm the forced variation is returned after a set $this->assertTrue($this->decisionService->setForcedVariation($this->config, $experimentKey, $userId, $variationKey)); - $forcedVariation = $this->decisionService->getForcedVariation($this->config, $experimentKey, $userId); + $forcedVariation = $this->decisionService->getForcedVariation($this->config, $experimentKey, $userId)[0]; $this->assertEquals($variationKey, $forcedVariation->getKey()); // check multiple sets $this->assertTrue($this->decisionService->setForcedVariation($this->config, $experimentKey2, $userId, $variationKey2)); - $forcedVariation2 = $this->decisionService->getForcedVariation($this->config, $experimentKey2, $userId); + $forcedVariation2 = $this->decisionService->getForcedVariation($this->config, $experimentKey2, $userId)[0]; $this->assertEquals($variationKey2, $forcedVariation2->getKey()); // make sure the second set does not overwrite the first set - $forcedVariation = $this->decisionService->getForcedVariation($this->config, $experimentKey, $userId); + $forcedVariation = $this->decisionService->getForcedVariation($this->config, $experimentKey, $userId)[0]; $this->assertEquals($variationKey, $forcedVariation->getKey()); // make sure unsetting the second experiment-to-variation mapping does not unset the // first experiment-to-variation mapping $this->assertTrue($this->decisionService->setForcedVariation($this->config, $experimentKey2, $userId, null)); - $forcedVariation = $this->decisionService->getForcedVariation($this->config, $experimentKey, $userId); + $forcedVariation = $this->decisionService->getForcedVariation($this->config, $experimentKey, $userId)[0]; $this->assertEquals($variationKey, $forcedVariation->getKey()); // an invalid user ID should return a null variation - $this->assertNull($this->decisionService->getForcedVariation($this->config, $experimentKey, $invalidUserId)); + $this->assertNull($this->decisionService->getForcedVariation($this->config, $experimentKey, $invalidUserId)[0]); } // test that all the logs in setForcedVariation are getting called @@ -1339,9 +1391,11 @@ public function testGetForcedVariationLogs() $this->loggerMock->expects($this->at($callIndex++)) ->method('log') ->with(Logger::DEBUG, sprintf('No experiment "%s" mapped to user "%s" in the forced variation map.', $pausedExperimentKey, $userId)); + + $varMappedMsg = sprintf('Variation "%s" is mapped to experiment "%s" and user "%s" in the forced variation map', $variationKey, $experimentKey, $userId); $this->loggerMock->expects($this->at($callIndex++)) ->method('log') - ->with(Logger::DEBUG, sprintf('Variation "%s" is mapped to experiment "%s" and user "%s" in the forced variation map', $variationKey, $experimentKey, $userId)); + ->with(Logger::DEBUG, $varMappedMsg); $this->config = new DatafileProjectConfig(DATAFILE, $this->loggerMock, new NoOpErrorHandler()); @@ -1349,6 +1403,8 @@ public function testGetForcedVariationLogs() $this->decisionService->getForcedVariation($this->config, $experimentKey, $invalidUserId); $this->decisionService->getForcedVariation($this->config, $invalidExperimentKey, $userId); $this->decisionService->getForcedVariation($this->config, $pausedExperimentKey, $userId); - $this->decisionService->getForcedVariation($this->config, $experimentKey, $userId); + list($var, $reasons) = $this->decisionService->getForcedVariation($this->config, $experimentKey, $userId); + + $this->assertContains($varMappedMsg, $reasons); } } diff --git a/tests/OptimizelyTest.php b/tests/OptimizelyTest.php index 39f3f45b..c24f4107 100644 --- a/tests/OptimizelyTest.php +++ b/tests/OptimizelyTest.php @@ -1,6 +1,6 @@ staticConfigManager = new StaticProjectConfigManager($this->datafile, true, $this->loggerMock, new NoOpErrorHandler); } + public function compareOptimizelyDecisions(OptimizelyDecision $expectedOptimizelyDecision, OptimizelyDecision $optimizelyDecision) + { + $this->assertEquals($expectedOptimizelyDecision->getVariationKey(), $optimizelyDecision->getVariationKey()); + $this->assertEquals($expectedOptimizelyDecision->getEnabled(), $optimizelyDecision->getEnabled()); + $this->assertEquals($expectedOptimizelyDecision->getVariables(), $optimizelyDecision->getVariables()); + $this->assertEquals($expectedOptimizelyDecision->getRuleKey(), $optimizelyDecision->getRuleKey()); + $this->assertEquals($expectedOptimizelyDecision->getFlagKey(), $optimizelyDecision->getFlagKey()); + $this->assertEquals($expectedOptimizelyDecision->getUserContext(), $optimizelyDecision->getUserContext()); + } + public function testIsValidForInvalidOptimizelyObject() { $optlyObject = new Optimizely('Random datafile'); @@ -310,6 +323,1745 @@ public function testInitWithBothSdkKeyAndDatafile() $this->assertEquals('3', $optimizelyClient->configManager->getConfig()->getRevision()); } + public function testCreateUserContextInvalidOptimizelyObject() + { + $optimizely = new Optimizely('Random datafile'); + + $userCxt = $optimizely->createUserContext('test_user'); + $this->assertInstanceof(OptimizelyUserContext::class, $userCxt); + } + + public function testCreateUserContextCallsValidateInputsWithUserId() + { + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile)) + ->setMethods(array('validateInputs')) + ->getMock(); + + $userId = 'test_user'; + $inputArray = [ + Optimizely::USER_ID => $userId + ]; + + // assert that validateInputs gets called with exactly same keys + $optimizelyMock->expects($this->once()) + ->method('validateInputs') + ->with($inputArray) + ->willReturn(false); + + $this->assertNull($optimizelyMock->createUserContext($userId)); + } + + public function testCreateUserContextWithNonArrayAttributes() + { + try { + $this->optimizelyObject->createUserContext('test_user', 42); + } catch (Exception $exception) { + return; + } catch (TypeError $exception) { + return; + } + + $this->fail('Unexpected behavior. UserContext should have thrown an error.'); + } + + public function testCreateUserContextInvalidAttributes() + { + $this->loggerMock->expects($this->exactly(1)) + ->method('log'); + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::ERROR, 'Provided attributes are in an invalid format.'); + + $errorHandlerMock = $this->getMockBuilder(NoOpErrorHandler::class) + ->setMethods(array('handleError')) + ->getMock(); + $errorHandlerMock->expects($this->once()) + ->method('handleError') + ->with(new InvalidAttributeException('Provided attributes are in an invalid format.')); + + $optimizely = new Optimizely($this->datafile, null, $this->loggerMock, $errorHandlerMock); + + $this->assertNull($optimizely->createUserContext('test_user', [5,6,7])); + } + + public function testDecide() + { + $optimizely = new Optimizely($this->datafile); + $userContext = $optimizely->createUserContext('test_user', ['device_type' => 'iPhone']); + + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + $decisionServiceMock = $this->getMockBuilder(DecisionService::class) + ->setConstructorArgs(array($this->loggerMock)) + ->setMethods(array('getVariationForFeature')) + ->getMock(); + + $decisionService = new \ReflectionProperty(Optimizely::class, '_decisionService'); + $decisionService->setAccessible(true); + $decisionService->setValue($optimizelyMock, $decisionServiceMock); + + // Mock getVariationForFeature to return a valid decision with experiment and variation keys + $experiment = $this->projectConfig->getExperimentFromKey('test_experiment_double_feature'); + $variation = $this->projectConfig->getVariationFromKey('test_experiment_double_feature', 'control'); + + // assert that featureEnabled for $variation is true + $this->assertTrue($variation->getFeatureEnabled()); + + $expected_decision = new FeatureDecision( + $experiment, + $variation, + FeatureDecision::DECISION_SOURCE_FEATURE_TEST + ); + + $decisionServiceMock->expects($this->exactly(1)) + ->method('getVariationForFeature') + ->will($this->returnValue($expected_decision)); + + // Verify that sendNotifications is called with expected params + $arrayParam = array( + DecisionNotificationTypes::FLAG, + 'test_user', + ['device_type' => 'iPhone'], + (object) array( + 'flagKey'=>'double_single_variable_feature', + 'enabled'=> true, + 'variables'=> ["double_variable" => 42.42], + 'variationKey' => 'control', + 'ruleKey' => 'test_experiment_double_feature', + 'reasons' => [], + 'decisionEventDispatched' => true + ) + ); + + $this->notificationCenterMock->expects($this->once()) + ->method('sendNotifications') + ->with( + NotificationType::DECISION, + $arrayParam + ); + + //assert that sendImpressionEvent is called with expected params + $optimizelyMock->expects($this->exactly(1)) + ->method('sendImpressionEvent') + ->with( + $this->projectConfig, + 'test_experiment_double_feature', + 'control', + 'double_single_variable_feature', + 'test_experiment_double_feature', + FeatureDecision::DECISION_SOURCE_FEATURE_TEST, + true, + 'test_user', + ['device_type' => 'iPhone'] + ); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $optimizelyDecision = $optimizelyMock->decide($userContext, 'double_single_variable_feature'); + $expectedOptimizelyDecision = new OptimizelyDecision( + 'control', + true, + ['double_variable' => 42.42], + 'test_experiment_double_feature', + 'double_single_variable_feature', + $userContext, + [] + ); + + $this->compareOptimizelyDecisions($expectedOptimizelyDecision, $optimizelyDecision); + } + + public function testDecideWhenSdkNotReady() + { + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array('invalid', null, $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + + //assert that sendImpressionEvent is not called + $optimizelyMock->expects($this->never()) + ->method('sendImpressionEvent'); + + $expectedOptimizelyDecision = new OptimizelyDecision( + null, + null, + null, + null, + 'double_single_variable_feature', + $userContext, + [] + ); + + $optimizelyDecision = $optimizelyMock->decide($userContext, 'double_single_variable_feature'); + + $this->compareOptimizelyDecisions($expectedOptimizelyDecision, $optimizelyDecision); + $this->assertEquals($optimizelyDecision->getReasons(), ['Optimizely SDK not configured properly yet.']); + } + + public function testDecideWhenFlagKeyIsNotValid() + { + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + + $this->notificationCenterMock->expects($this->never()) + ->method('sendNotifications'); + + //assert that sendImpressionEvent is called with expected params + $optimizelyMock->expects($this->never()) + ->method('sendImpressionEvent'); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $optimizelyDecision = $optimizelyMock->decide($userContext, 123); + $this->assertEquals($optimizelyDecision->getReasons(), ['No flag was found for key "123".']); + } + + public function testDecideWhenFlagKeyIsNotAvailable() + { + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + + $this->notificationCenterMock->expects($this->never()) + ->method('sendNotifications'); + + //assert that sendImpressionEvent is not called. + $optimizelyMock->expects($this->never()) + ->method('sendImpressionEvent'); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $optimizelyDecision = $optimizelyMock->decide($userContext, 'unknown_key'); + $this->assertEquals($optimizelyDecision->getReasons(), ['No flag was found for key "unknown_key".']); + } + + public function testDecidewhenUserIsBucketedIntoFeatureExperiment() + { + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + $decisionServiceMock = $this->getMockBuilder(DecisionService::class) + ->setConstructorArgs(array($this->loggerMock)) + ->setMethods(array('getVariationForFeature')) + ->getMock(); + + $decisionService = new \ReflectionProperty(Optimizely::class, '_decisionService'); + $decisionService->setAccessible(true); + $decisionService->setValue($optimizelyMock, $decisionServiceMock); + + // Mock getVariationForFeature to return a valid decision with experiment and variation keys + $experiment = $this->projectConfig->getExperimentFromKey('test_experiment_double_feature'); + $variation = $this->projectConfig->getVariationFromKey('test_experiment_double_feature', 'control'); + + // assert that featureEnabled for $variation is true + $this->assertTrue($variation->getFeatureEnabled()); + + $expected_decision = new FeatureDecision( + $experiment, + $variation, + FeatureDecision::DECISION_SOURCE_FEATURE_TEST + ); + + $decisionServiceMock->expects($this->exactly(1)) + ->method('getVariationForFeature') + ->will($this->returnValue($expected_decision)); + + // Verify that sendNotifications is called with expected params + $arrayParam = array( + DecisionNotificationTypes::FLAG, + 'test_user', + ['device_type' => 'iPhone'], + (object) array( + 'flagKey'=>'double_single_variable_feature', + 'enabled'=> true, + 'variables'=> ["double_variable" => 42.42], + 'variationKey' => 'control', + 'ruleKey' => 'test_experiment_double_feature', + 'reasons' => [], + 'decisionEventDispatched' => true + ) + ); + + $this->notificationCenterMock->expects($this->once()) + ->method('sendNotifications') + ->with( + NotificationType::DECISION, + $arrayParam + ); + + //assert that sendImpressionEvent is called with expected params + $optimizelyMock->expects($this->exactly(1)) + ->method('sendImpressionEvent') + ->with( + $this->projectConfig, + 'test_experiment_double_feature', + 'control', + 'double_single_variable_feature', + 'test_experiment_double_feature', + FeatureDecision::DECISION_SOURCE_FEATURE_TEST, + true, + 'test_user', + ['device_type' => 'iPhone'] + ); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + $optimizelyDecision = $optimizelyMock->decide($userContext, 'double_single_variable_feature'); + $expectedOptimizelyDecision = new OptimizelyDecision( + 'control', + true, + ['double_variable' => 42.42], + 'test_experiment_double_feature', + 'double_single_variable_feature', + $userContext, + [] + ); + + $this->compareOptimizelyDecisions($expectedOptimizelyDecision, $optimizelyDecision); + } + + public function testDecidewhenUserIsBucketedIntoRolloutAndSendFlagDecisionIsTrue() + { + $datafileWithSendFlagDecisionsTrue = json_decode($this->datafile); + $datafileWithSendFlagDecisionsTrue->sendFlagDecisions = true; + + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array(json_encode($datafileWithSendFlagDecisionsTrue), null, $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + $decisionServiceMock = $this->getMockBuilder(DecisionService::class) + ->setConstructorArgs(array($this->loggerMock)) + ->setMethods(array('getVariationForFeature')) + ->getMock(); + + $decisionService = new \ReflectionProperty(Optimizely::class, '_decisionService'); + $decisionService->setAccessible(true); + $decisionService->setValue($optimizelyMock, $decisionServiceMock); + + // Mock getVariationForFeature to return a valid decision with experiment and variation keys + $experiment = $this->projectConfig->getExperimentFromKey('test_experiment_double_feature'); + $variation = $this->projectConfig->getVariationFromKey('test_experiment_double_feature', 'control'); + + // assert that featureEnabled for $variation is true + $this->assertTrue($variation->getFeatureEnabled()); + + $expected_decision = new FeatureDecision( + $experiment, + $variation, + FeatureDecision::DECISION_SOURCE_ROLLOUT + ); + + $decisionServiceMock->expects($this->exactly(1)) + ->method('getVariationForFeature') + ->will($this->returnValue($expected_decision)); + + // Verify that sendNotifications is called with expected params + $arrayParam = array( + DecisionNotificationTypes::FLAG, + 'test_user', + ['device_type' => 'iPhone'], + (object) array( + 'flagKey'=>'double_single_variable_feature', + 'enabled'=> true, + 'variables'=> ["double_variable" => 42.42], + 'variationKey' => 'control', + 'ruleKey' => 'test_experiment_double_feature', + 'reasons' => [], + 'decisionEventDispatched' => true + ) + ); + + $this->notificationCenterMock->expects($this->once()) + ->method('sendNotifications') + ->with( + NotificationType::DECISION, + $arrayParam + ); + + //assert that sendImpressionEvent is called with expected params + $optimizelyMock->expects($this->exactly(1)) + ->method('sendImpressionEvent') + ->with( + $this->anything(), + 'test_experiment_double_feature', + 'control', + 'double_single_variable_feature', + 'test_experiment_double_feature', + FeatureDecision::DECISION_SOURCE_ROLLOUT, + true, + 'test_user', + ['device_type' => 'iPhone'] + ); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + $optimizelyDecision = $optimizelyMock->decide($userContext, 'double_single_variable_feature'); + $expectedOptimizelyDecision = new OptimizelyDecision( + 'control', + true, + ['double_variable' => 42.42], + 'test_experiment_double_feature', + 'double_single_variable_feature', + $userContext, + [] + ); + + $this->compareOptimizelyDecisions($expectedOptimizelyDecision, $optimizelyDecision); + } + + public function testDecidewhenUserIsBucketedIntoRolloutAndSendFlagDecisionIsFalse() + { + $datafileWithSendFlagDecisionsFalse = json_decode($this->datafile); + $datafileWithSendFlagDecisionsFalse->sendFlagDecisions = false; + + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array(json_encode($datafileWithSendFlagDecisionsFalse), null, $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + $decisionServiceMock = $this->getMockBuilder(DecisionService::class) + ->setConstructorArgs(array($this->loggerMock)) + ->setMethods(array('getVariationForFeature')) + ->getMock(); + + $decisionService = new \ReflectionProperty(Optimizely::class, '_decisionService'); + $decisionService->setAccessible(true); + $decisionService->setValue($optimizelyMock, $decisionServiceMock); + + // Mock getVariationForFeature to return a valid decision with experiment and variation keys + $experiment = $this->projectConfig->getExperimentFromKey('test_experiment_double_feature'); + $variation = $this->projectConfig->getVariationFromKey('test_experiment_double_feature', 'control'); + + // assert that featureEnabled for $variation is true + $this->assertTrue($variation->getFeatureEnabled()); + + $expected_decision = new FeatureDecision( + $experiment, + $variation, + FeatureDecision::DECISION_SOURCE_ROLLOUT + ); + + $decisionServiceMock->expects($this->exactly(1)) + ->method('getVariationForFeature') + ->will($this->returnValue($expected_decision)); + + // Verify that sendNotifications is called with expected params + $arrayParam = array( + DecisionNotificationTypes::FLAG, + 'test_user', + ['device_type' => 'iPhone'], + (object) array( + 'flagKey'=>'double_single_variable_feature', + 'enabled'=> true, + 'variables'=> ["double_variable" => 42.42], + 'variationKey' => 'control', + 'ruleKey' => 'test_experiment_double_feature', + 'reasons' => [], + 'decisionEventDispatched' => false + ) + ); + + $this->notificationCenterMock->expects($this->once()) + ->method('sendNotifications') + ->with( + NotificationType::DECISION, + $arrayParam + ); + + //assert that sendImpressionEvent is not called + $optimizelyMock->expects($this->never()) + ->method('sendImpressionEvent'); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + $optimizelyDecision = $optimizelyMock->decide($userContext, 'double_single_variable_feature'); + $expectedOptimizelyDecision = new OptimizelyDecision( + 'control', + true, + ['double_variable' => 42.42], + 'test_experiment_double_feature', + 'double_single_variable_feature', + $userContext, + [] + ); + + $this->compareOptimizelyDecisions($expectedOptimizelyDecision, $optimizelyDecision); + } + + public function testDecidewhenDecisionServiceReturnsNullAndSendFlagDecisionIsTrue() + { + $datafileWithSendFlagDecisionsFalse = json_decode($this->datafile); + $datafileWithSendFlagDecisionsFalse->sendFlagDecisions = true; + + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array(json_encode($datafileWithSendFlagDecisionsFalse), null, $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + $decisionServiceMock = $this->getMockBuilder(DecisionService::class) + ->setConstructorArgs(array($this->loggerMock)) + ->setMethods(array('getVariationForFeature')) + ->getMock(); + + $decisionService = new \ReflectionProperty(Optimizely::class, '_decisionService'); + $decisionService->setAccessible(true); + $decisionService->setValue($optimizelyMock, $decisionServiceMock); + + // Mock getVariationForFeature to return a valid decision with experiment and variation keys + $experiment = $this->projectConfig->getExperimentFromKey('test_experiment_double_feature'); + $variation = $this->projectConfig->getVariationFromKey('test_experiment_double_feature', 'control'); + + // assert that featureEnabled for $variation is true + $this->assertTrue($variation->getFeatureEnabled()); + + $expected_decision = new FeatureDecision( + null, + null, + FeatureDecision::DECISION_SOURCE_ROLLOUT + ); + + $decisionServiceMock->expects($this->exactly(1)) + ->method('getVariationForFeature') + ->will($this->returnValue($expected_decision)); + + // Verify that sendNotifications is called with expected params + $arrayParam = array( + DecisionNotificationTypes::FLAG, + 'test_user', + ['device_type' => 'iPhone'], + (object) array( + 'flagKey'=>'double_single_variable_feature', + 'enabled'=> false, + 'variables'=> ["double_variable" => 14.99], + 'variationKey' => null, + 'ruleKey' => null, + 'reasons' => [], + 'decisionEventDispatched' => true + ) + ); + + $this->notificationCenterMock->expects($this->once()) + ->method('sendNotifications') + ->with( + NotificationType::DECISION, + $arrayParam + ); + + //assert that sendImpressionEvent is called with expected params + $optimizelyMock->expects($this->exactly(1)) + ->method('sendImpressionEvent') + ->with( + $this->anything(), + null, + null, + 'double_single_variable_feature', + null, + FeatureDecision::DECISION_SOURCE_ROLLOUT, + false, + 'test_user', + ['device_type' => 'iPhone'] + ); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + + $optimizelyDecision = $optimizelyMock->decide($userContext, 'double_single_variable_feature'); + $expectedOptimizelyDecision = new OptimizelyDecision( + null, + false, + ['double_variable' => 14.99], + null, + 'double_single_variable_feature', + $userContext, + [] + ); + + $this->compareOptimizelyDecisions($expectedOptimizelyDecision, $optimizelyDecision); + } + + public function testwhenDecisionServiceReturnNullAndSendFlagDecisionIsFalse() + { + $datafileWithSendFlagDecisionsFalse = json_decode($this->datafile); + $datafileWithSendFlagDecisionsFalse->sendFlagDecisions = false; + + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array(json_encode($datafileWithSendFlagDecisionsFalse), null, $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + $decisionServiceMock = $this->getMockBuilder(DecisionService::class) + ->setConstructorArgs(array($this->loggerMock)) + ->setMethods(array('getVariationForFeature')) + ->getMock(); + + $decisionService = new \ReflectionProperty(Optimizely::class, '_decisionService'); + $decisionService->setAccessible(true); + $decisionService->setValue($optimizelyMock, $decisionServiceMock); + + $expected_decision = new FeatureDecision( + null, + null, + FeatureDecision::DECISION_SOURCE_ROLLOUT + ); + + $decisionServiceMock->expects($this->exactly(1)) + ->method('getVariationForFeature') + ->will($this->returnValue($expected_decision)); + + // Verify that sendNotifications is called with expected params + $arrayParam = array( + DecisionNotificationTypes::FLAG, + 'test_user', + ['device_type' => 'iPhone'], + (object) array( + 'flagKey'=>'double_single_variable_feature', + 'enabled'=> false, + 'variables'=> ["double_variable" => 14.99], + 'variationKey' => null, + 'ruleKey' => null, + 'reasons' => [], + 'decisionEventDispatched' => false + ) + ); + + $this->notificationCenterMock->expects($this->once()) + ->method('sendNotifications') + ->with( + NotificationType::DECISION, + $arrayParam + ); + + //assert that sendImpressionEvent is called with expected params + $optimizelyMock->expects($this->never()) + ->method('sendImpressionEvent'); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + $optimizelyDecision = $optimizelyMock->decide($userContext, 'double_single_variable_feature'); + $expectedOptimizelyDecision = new OptimizelyDecision( + null, + false, + ['double_variable' => 14.99], + null, + 'double_single_variable_feature', + $userContext, + [] + ); + + $this->compareOptimizelyDecisions($expectedOptimizelyDecision, $optimizelyDecision); + } + + public function testDecideOptionDisableDecisionEvent() + { + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + + $decisionServiceMock = $this->getMockBuilder(DecisionService::class) + ->setConstructorArgs(array($this->loggerMock)) + ->setMethods(array('getVariationForFeature')) + ->getMock(); + + $decisionService = new \ReflectionProperty(Optimizely::class, '_decisionService'); + $decisionService->setAccessible(true); + $decisionService->setValue($optimizelyMock, $decisionServiceMock); + + $experiment = $this->projectConfig->getExperimentFromKey('test_experiment_double_feature'); + $variation = $this->projectConfig->getVariationFromKey('test_experiment_double_feature', 'control'); + + $expected_decision = new FeatureDecision( + $experiment, + $variation, + FeatureDecision::DECISION_SOURCE_FEATURE_TEST + ); + + $decisionServiceMock->expects($this->exactly(1)) + ->method('getVariationForFeature') + ->will($this->returnValue($expected_decision)); + + //Verify that sendNotifications is called with expected params + $arrayParam = array( + DecisionNotificationTypes::FLAG, + 'test_user', + ['device_type' => 'iPhone'], + (object) array( + 'flagKey'=>'double_single_variable_feature', + 'enabled'=> true, + 'variables'=> ["double_variable" => 42.42], + 'variationKey' => 'control', + 'ruleKey' => 'test_experiment_double_feature', + 'reasons' => [], + 'decisionEventDispatched' => false + ) + ); + + $this->notificationCenterMock->expects($this->exactly(1)) + ->method('sendNotifications') + ->with( + NotificationType::DECISION, + $arrayParam + ); + + //assert that sendImpressionEvent is not called. + $optimizelyMock->expects($this->never()) + ->method('sendImpressionEvent'); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $optimizelyDecision = $optimizelyMock->decide($userContext, 'double_single_variable_feature', ['DISABLE_DECISION_EVENT']); + } + + public function testDecideOptionDisableDecisionEventWhenPassedInDefaultOptions() + { + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array( + $this->datafile, null, $this->loggerMock, null, null, null, null, null, null, ['DISABLE_DECISION_EVENT'] + )) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + + $decisionServiceMock = $this->getMockBuilder(DecisionService::class) + ->setConstructorArgs(array($this->loggerMock)) + ->setMethods(array('getVariationForFeature')) + ->getMock(); + + $decisionService = new \ReflectionProperty(Optimizely::class, '_decisionService'); + $decisionService->setAccessible(true); + $decisionService->setValue($optimizelyMock, $decisionServiceMock); + + $experiment = $this->projectConfig->getExperimentFromKey('test_experiment_double_feature'); + $variation = $this->projectConfig->getVariationFromKey('test_experiment_double_feature', 'control'); + + $expected_decision = new FeatureDecision( + $experiment, + $variation, + FeatureDecision::DECISION_SOURCE_FEATURE_TEST + ); + + $decisionServiceMock->expects($this->exactly(1)) + ->method('getVariationForFeature') + ->will($this->returnValue($expected_decision)); + + //Verify that sendNotifications is called with expected params + $arrayParam = array( + DecisionNotificationTypes::FLAG, + 'test_user', + ['device_type' => 'iPhone'], + (object) array( + 'flagKey'=>'double_single_variable_feature', + 'enabled'=> true, + 'variables'=> ["double_variable" => 42.42], + 'variationKey' => 'control', + 'ruleKey' => 'test_experiment_double_feature', + 'reasons' => [], + 'decisionEventDispatched' => false + ) + ); + + $this->notificationCenterMock->expects($this->exactly(1)) + ->method('sendNotifications') + ->with( + NotificationType::DECISION, + $arrayParam + ); + + //assert that sendImpressionEvent is not called. + $optimizelyMock->expects($this->never()) + ->method('sendImpressionEvent'); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $optimizelyDecision = $optimizelyMock->decide($userContext, 'double_single_variable_feature'); + } + + public function testDecideRespectsUserProfileServiceLookup() + { + $userProfileServiceMock = $this->getMockBuilder(UserProfileServiceInterface::class) + ->getMock(); + + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock, null, null, $userProfileServiceMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + + // mock userProfileServiceMock to return stored variation 'variation' rather than 'control'. + $storedUserProfile = array( + 'user_id' => 'test_user', + 'experiment_bucket_map' => array( + '122238' => array( + 'variation_id' => '122240' + ) + ) + ); + + $userProfileServiceMock->expects($this->once()) + ->method('lookup') + ->willReturn($storedUserProfile); + + + $userProfileServiceMock->expects($this->never()) + ->method('save'); + + //Verify that sendNotifications is called with expected params + $arrayParam = array( + DecisionNotificationTypes::FLAG, + 'test_user', + ['device_type' => 'iPhone'], + (object) array( + 'flagKey'=>'double_single_variable_feature', + 'enabled'=> false, + 'variables'=> ["double_variable" => 14.99], + 'variationKey' => 'variation', + 'ruleKey' => 'test_experiment_double_feature', + 'reasons' => [], + 'decisionEventDispatched' => true + ) + ); + + $this->notificationCenterMock->expects($this->exactly(1)) + ->method('sendNotifications') + ->with( + NotificationType::DECISION, + $arrayParam + ); + + //assert that sendImpressionEvent is called with expected params + $optimizelyMock->expects($this->exactly(1)) + ->method('sendImpressionEvent') + ->with( + $this->projectConfig, + 'test_experiment_double_feature', + 'variation', + 'double_single_variable_feature', + 'test_experiment_double_feature', + FeatureDecision::DECISION_SOURCE_FEATURE_TEST, + false, + 'test_user', + ['device_type' => 'iPhone'] + ); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $optimizelyDecision = $optimizelyMock->decide($userContext, 'double_single_variable_feature', []); + } + + public function testDecideRespectsUserProfileServiceSave() + { + $userProfileServiceMock = $this->getMockBuilder(UserProfileServiceInterface::class) + ->getMock(); + + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock, null, null, $userProfileServiceMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + + // mock lookup to return null so that normal bucketing happens + $userProfileServiceMock->expects($this->once()) + ->method('lookup') + ->willReturn(null); + + + // assert that save is called with expected user profile map. + $storedUserProfile = array( + 'user_id' => 'test_user', + 'experiment_bucket_map' => array( + '122238' => array( + 'variation_id' => '122239' + ) + ) + ); + $userProfileServiceMock->expects($this->once()) + ->method('save') + ->with($storedUserProfile); + + //Verify that sendNotifications is called with expected params + $arrayParam = array( + DecisionNotificationTypes::FLAG, + 'test_user', + ['device_type' => 'iPhone'], + (object) array( + 'flagKey'=>'double_single_variable_feature', + 'enabled'=> true, + 'variables'=> ["double_variable" => 42.42], + 'variationKey' => 'control', + 'ruleKey' => 'test_experiment_double_feature', + 'reasons' => [], + 'decisionEventDispatched' => true + ) + ); + + $this->notificationCenterMock->expects($this->exactly(1)) + ->method('sendNotifications') + ->with( + NotificationType::DECISION, + $arrayParam + ); + + //assert that sendImpressionEvent is called with expected params + $optimizelyMock->expects($this->exactly(1)) + ->method('sendImpressionEvent') + ->with( + $this->projectConfig, + 'test_experiment_double_feature', + 'control', + 'double_single_variable_feature', + 'test_experiment_double_feature', + FeatureDecision::DECISION_SOURCE_FEATURE_TEST, + true, + 'test_user', + ['device_type' => 'iPhone'] + ); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $optimizelyDecision = $optimizelyMock->decide($userContext, 'double_single_variable_feature', []); + } + + public function testDecideOptionIgnoreUserProfileService() + { + $userProfileServiceMock = $this->getMockBuilder(UserProfileServiceInterface::class) + ->getMock(); + + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock, null, null, $userProfileServiceMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + + // mock userProfileServiceMock to return stored variation 'variation' rather than 'control'. + $storedUserProfile = array( + 'user_id' => 'test_user', + 'experiment_bucket_map' => array( + '122238' => array( + 'variation_id' => '122240' + ) + ) + ); + + // assert lookup isn't called. + $userProfileServiceMock->expects($this->never()) + ->method('lookup'); + + // assert save isn't called. + $userProfileServiceMock->expects($this->never()) + ->method('save'); + + + //Verify that sendNotifications is called with expected params + $arrayParam = array( + DecisionNotificationTypes::FLAG, + 'test_user', + ['device_type' => 'iPhone'], + (object) array( + 'flagKey'=>'double_single_variable_feature', + 'enabled'=> true, + 'variables'=> ["double_variable" => 42.42], + 'variationKey' => 'control', + 'ruleKey' => 'test_experiment_double_feature', + 'reasons' => [], + 'decisionEventDispatched' => true + ) + ); + + $this->notificationCenterMock->expects($this->exactly(1)) + ->method('sendNotifications') + ->with( + NotificationType::DECISION, + $arrayParam + ); + + //assert that sendImpressionEvent is called with expected params + $optimizelyMock->expects($this->exactly(1)) + ->method('sendImpressionEvent') + ->with( + $this->projectConfig, + 'test_experiment_double_feature', + 'control', + 'double_single_variable_feature', + 'test_experiment_double_feature', + FeatureDecision::DECISION_SOURCE_FEATURE_TEST, + true, + 'test_user', + ['device_type' => 'iPhone'] + ); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $optimizelyDecision = $optimizelyMock->decide($userContext, 'double_single_variable_feature', ['IGNORE_USER_PROFILE_SERVICE']); + } + + public function testDecideOptionIgnoreUserProfileServiceWhenPassedInDefaultOptions() + { + $userProfileServiceMock = $this->getMockBuilder(UserProfileServiceInterface::class) + ->getMock(); + + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, + null, $this->loggerMock, null, null, $userProfileServiceMock, null, null, null, ['IGNORE_USER_PROFILE_SERVICE'] + )) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + + // mock userProfileServiceMock to return stored variation 'variation' rather than 'control'. + $storedUserProfile = array( + 'user_id' => 'test_user', + 'experiment_bucket_map' => array( + '122238' => array( + 'variation_id' => '122240' + ) + ) + ); + + // assert lookup isn't called. + $userProfileServiceMock->expects($this->never()) + ->method('lookup'); + + // assert save isn't called. + $userProfileServiceMock->expects($this->never()) + ->method('save'); + + + //Verify that sendNotifications is called with expected params + $arrayParam = array( + DecisionNotificationTypes::FLAG, + 'test_user', + ['device_type' => 'iPhone'], + (object) array( + 'flagKey'=>'double_single_variable_feature', + 'enabled'=> true, + 'variables'=> ["double_variable" => 42.42], + 'variationKey' => 'control', + 'ruleKey' => 'test_experiment_double_feature', + 'reasons' => [], + 'decisionEventDispatched' => true + ) + ); + + $this->notificationCenterMock->expects($this->exactly(1)) + ->method('sendNotifications') + ->with( + NotificationType::DECISION, + $arrayParam + ); + + //assert that sendImpressionEvent is called with expected params + $optimizelyMock->expects($this->exactly(1)) + ->method('sendImpressionEvent') + ->with( + $this->projectConfig, + 'test_experiment_double_feature', + 'control', + 'double_single_variable_feature', + 'test_experiment_double_feature', + FeatureDecision::DECISION_SOURCE_FEATURE_TEST, + true, + 'test_user', + ['device_type' => 'iPhone'] + ); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $optimizelyDecision = $optimizelyMock->decide($userContext, 'double_single_variable_feature', []); + } + + public function testDecideOptionExcludeVariables() + { + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock)) + ->setMethods(array('sendImpressionEvent', 'dispatchEvent')) + ->getMock(); + + + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + + $decisionServiceMock = $this->getMockBuilder(DecisionService::class) + ->setConstructorArgs(array($this->loggerMock)) + ->setMethods(array('getVariationForFeature')) + ->getMock(); + + $decisionService = new \ReflectionProperty(Optimizely::class, '_decisionService'); + $decisionService->setAccessible(true); + $decisionService->setValue($optimizelyMock, $decisionServiceMock); + + $experiment = $this->projectConfig->getExperimentFromKey('test_experiment_double_feature'); + $variation = $this->projectConfig->getVariationFromKey('test_experiment_double_feature', 'control'); + + $expected_decision = new FeatureDecision( + $experiment, + $variation, + FeatureDecision::DECISION_SOURCE_FEATURE_TEST + ); + + $decisionServiceMock->expects($this->exactly(1)) + ->method('getVariationForFeature') + ->will($this->returnValue($expected_decision)); + + //Verify that sendNotifications is called with expected params + $arrayParam = array( + DecisionNotificationTypes::FLAG, + 'test_user', + ['device_type' => 'iPhone'], + (object) array( + 'flagKey'=>'double_single_variable_feature', + 'enabled'=> true, + 'variables'=> [], + 'variationKey' => 'control', + 'ruleKey' => 'test_experiment_double_feature', + 'reasons' => [], + 'decisionEventDispatched' => true + ) + ); + + $this->notificationCenterMock->expects($this->exactly(1)) + ->method('sendNotifications') + ->with( + NotificationType::DECISION, + $arrayParam + ); + + //assert that sendImpressionEvent is called with expected params + $optimizelyMock->expects($this->exactly(1)) + ->method('sendImpressionEvent') + ->with( + $this->projectConfig, + 'test_experiment_double_feature', + 'control', + 'double_single_variable_feature', + 'test_experiment_double_feature', + FeatureDecision::DECISION_SOURCE_FEATURE_TEST, + true, + 'test_user', + ['device_type' => 'iPhone'] + ); + + $expectedOptimizelyDecision = new OptimizelyDecision( + 'control', + true, + [], + 'test_experiment_double_feature', + 'double_single_variable_feature', + $userContext, + [] + ); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $optimizelyDecision = $optimizelyMock->decide($userContext, 'double_single_variable_feature', ['EXCLUDE_VARIABLES']); + $this->compareOptimizelyDecisions($expectedOptimizelyDecision, $optimizelyDecision); + } + + public function testDecideOptionExcludeVariablesWhenPassedInDefaultOptions() + { + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array( + $this->datafile, null, $this->loggerMock, null, null, null, null, null, null, ['EXCLUDE_VARIABLES'] + )) + ->setMethods(array('sendImpressionEvent', 'dispatchEvent')) + ->getMock(); + + + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + + $decisionServiceMock = $this->getMockBuilder(DecisionService::class) + ->setConstructorArgs(array($this->loggerMock)) + ->setMethods(array('getVariationForFeature')) + ->getMock(); + + $decisionService = new \ReflectionProperty(Optimizely::class, '_decisionService'); + $decisionService->setAccessible(true); + $decisionService->setValue($optimizelyMock, $decisionServiceMock); + + $experiment = $this->projectConfig->getExperimentFromKey('test_experiment_double_feature'); + $variation = $this->projectConfig->getVariationFromKey('test_experiment_double_feature', 'control'); + + $expected_decision = new FeatureDecision( + $experiment, + $variation, + FeatureDecision::DECISION_SOURCE_FEATURE_TEST + ); + + $decisionServiceMock->expects($this->exactly(1)) + ->method('getVariationForFeature') + ->will($this->returnValue($expected_decision)); + + //Verify that sendNotifications is called with expected params + $arrayParam = array( + DecisionNotificationTypes::FLAG, + 'test_user', + ['device_type' => 'iPhone'], + (object) array( + 'flagKey'=>'double_single_variable_feature', + 'enabled'=> true, + 'variables'=> [], + 'variationKey' => 'control', + 'ruleKey' => 'test_experiment_double_feature', + 'reasons' => [], + 'decisionEventDispatched' => true + ) + ); + + $this->notificationCenterMock->expects($this->exactly(1)) + ->method('sendNotifications') + ->with( + NotificationType::DECISION, + $arrayParam + ); + + //assert that sendImpressionEvent is called with expected params + $optimizelyMock->expects($this->exactly(1)) + ->method('sendImpressionEvent') + ->with( + $this->projectConfig, + 'test_experiment_double_feature', + 'control', + 'double_single_variable_feature', + 'test_experiment_double_feature', + FeatureDecision::DECISION_SOURCE_FEATURE_TEST, + true, + 'test_user', + ['device_type' => 'iPhone'] + ); + + $expectedOptimizelyDecision = new OptimizelyDecision( + 'control', + true, + [], + 'test_experiment_double_feature', + 'double_single_variable_feature', + $userContext, + [] + ); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $optimizelyDecision = $optimizelyMock->decide($userContext, 'double_single_variable_feature'); + $this->compareOptimizelyDecisions($expectedOptimizelyDecision, $optimizelyDecision); + } + + public function testDecideOptionEnabledFlagsOnly() + { + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + $decideOptions = ['ENABLED_FLAGS_ONLY']; + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + + $this->notificationCenterMock->expects($this->exactly(9)) + ->method('sendNotifications'); + + //assert that sendImpressionEvent is called with expected params + $optimizelyMock->expects($this->exactly(5)) + ->method('sendImpressionEvent'); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $keys = ['boolean_feature', + 'double_single_variable_feature', + 'integer_single_variable_feature', + 'boolean_single_variable_feature', + 'string_single_variable_feature', + 'multiple_variables_feature', + 'multi_variate_feature', + 'mutex_group_feature', + 'empty_feature' + ]; + + $optimizelyDecisions = $optimizelyMock->decideForKeys($userContext, $keys, $decideOptions); + $this->assertEquals(count($optimizelyDecisions), 6); + } + + public function testDecideOptionEnabledFlagsOnlyWhenPassedInDefaultOptions() + { + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array( + $this->datafile, null, $this->loggerMock, null, null, null, null, null, null, ['ENABLED_FLAGS_ONLY'] + )) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + + $this->notificationCenterMock->expects($this->exactly(9)) + ->method('sendNotifications'); + + //assert that sendImpressionEvent is called with expected params + $optimizelyMock->expects($this->exactly(5)) + ->method('sendImpressionEvent'); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $keys = ['boolean_feature', + 'double_single_variable_feature', + 'integer_single_variable_feature', + 'boolean_single_variable_feature', + 'string_single_variable_feature', + 'multiple_variables_feature', + 'multi_variate_feature', + 'mutex_group_feature', + 'empty_feature' + ]; + + $optimizelyDecisions = $optimizelyMock->decideForKeys($userContext, $keys); + $this->assertEquals(count($optimizelyDecisions), 6); + } + + public function testDecideOptionIncludeReasons() + { + $optimizely = new Optimizely($this->datafile); + $userContext = $optimizely->createUserContext('test_user', ['device_type' => 'iPhone']); + + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + $expectedReasons = [ + 'Audiences for experiment "test_experiment_double_feature" collectively evaluated to TRUE.', + 'Assigned bucket 4513 to user "test_user" with bucketing ID "test_user".', + 'User "test_user" is in variation control of experiment test_experiment_double_feature.', + "The user 'test_user' is bucketed into experiment 'test_experiment_double_feature' of feature 'double_single_variable_feature'." + ]; + + // Verify that sendNotifications is called with expected params + $arrayParam = array( + DecisionNotificationTypes::FLAG, + 'test_user', + ['device_type' => 'iPhone'], + (object) array( + 'flagKey'=>'double_single_variable_feature', + 'enabled'=> true, + 'variables'=> ["double_variable" => 42.42], + 'variationKey' => 'control', + 'ruleKey' => 'test_experiment_double_feature', + 'reasons' => $expectedReasons, + 'decisionEventDispatched' => true + ) + ); + + $this->notificationCenterMock->expects($this->once()) + ->method('sendNotifications') + ->with( + NotificationType::DECISION, + $arrayParam + ); + + //assert that sendImpressionEvent is called with expected params + $optimizelyMock->expects($this->exactly(1)) + ->method('sendImpressionEvent') + ->with( + $this->projectConfig, + 'test_experiment_double_feature', + 'control', + 'double_single_variable_feature', + 'test_experiment_double_feature', + FeatureDecision::DECISION_SOURCE_FEATURE_TEST, + true, + 'test_user', + ['device_type' => 'iPhone'] + ); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $optimizelyDecision = $optimizelyMock->decide($userContext, 'double_single_variable_feature', ['INCLUDE_REASONS']); + $expectedOptimizelyDecision = new OptimizelyDecision( + 'control', + true, + ['double_variable' => 42.42], + 'test_experiment_double_feature', + 'double_single_variable_feature', + $userContext, + [] + ); + + $this->compareOptimizelyDecisions($expectedOptimizelyDecision, $optimizelyDecision); + $this->assertEquals($expectedReasons, $optimizelyDecision->getReasons()); + } + + public function testDecideLogsWhenAudienceEvalFails() + { + $optimizely = new Optimizely($this->datafile); + $userContext = $optimizely->createUserContext('test_user'); + + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + $expectedReasons = [ + 'Audiences for experiment "test_experiment_multivariate" collectively evaluated to FALSE.', + 'User "test_user" does not meet conditions to be in experiment "test_experiment_multivariate".', + "The user 'test_user' is not bucketed into any of the experiments using the feature 'multi_variate_feature'.", + "Feature flag 'multi_variate_feature' is not used in a rollout.", + "User 'test_user' is not bucketed into rollout for feature flag 'multi_variate_feature'." + ]; + + $optimizelyDecision = $optimizelyMock->decide($userContext, 'multi_variate_feature', ['INCLUDE_REASONS']); + $this->assertEquals($expectedReasons, $optimizelyDecision->getReasons()); + } + + public function testDecideOptionIncludeReasonsWhenPassedInDefaultOptions() + { + $optimizely = new Optimizely($this->datafile); + $userContext = $optimizely->createUserContext('test_user', ['device_type' => 'iPhone']); + + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array( + $this->datafile, null, $this->loggerMock, null, null, null, null, null, null, ['INCLUDE_REASONS'] + )) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + $expectedReasons = [ + "The feature flag 'empty_feature' is not used in any experiments.", + "Feature flag 'empty_feature' is not used in a rollout.", + "User 'test_user' is not bucketed into rollout for feature flag 'empty_feature'." + ]; + + // Verify that sendNotifications is called with expected params + $arrayParam = array( + DecisionNotificationTypes::FLAG, + 'test_user', + ['device_type' => 'iPhone'], + (object) array( + 'flagKey'=>'empty_feature', + 'enabled'=> false, + 'variables'=> [], + 'variationKey' => null, + 'ruleKey' => null, + 'reasons' => $expectedReasons, + 'decisionEventDispatched' => false + ) + ); + + $this->notificationCenterMock->expects($this->once()) + ->method('sendNotifications') + ->with( + NotificationType::DECISION, + $arrayParam + ); + + //assert that sendImpressionEvent is called with expected params + $optimizelyMock->expects($this->never()) + ->method('sendImpressionEvent'); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $optimizelyDecision = $optimizelyMock->decide($userContext, 'empty_feature'); + $expectedOptimizelyDecision = new OptimizelyDecision( + null, + false, + [], + null, + 'empty_feature', + $userContext, + [] + ); + + $this->compareOptimizelyDecisions($expectedOptimizelyDecision, $optimizelyDecision); + $this->assertEquals($expectedReasons, $optimizelyDecision->getReasons()); + } + + public function testDecideParamOptionsWorkTogetherWithDefaultOptions() + { + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array( + $this->datafile, null, $this->loggerMock, null, null, null, null, null, null, ['EXCLUDE_VARIABLES'] + )) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + + $decisionServiceMock = $this->getMockBuilder(DecisionService::class) + ->setConstructorArgs(array($this->loggerMock)) + ->setMethods(array('getVariationForFeature')) + ->getMock(); + + $decisionService = new \ReflectionProperty(Optimizely::class, '_decisionService'); + $decisionService->setAccessible(true); + $decisionService->setValue($optimizelyMock, $decisionServiceMock); + + $experiment = $this->projectConfig->getExperimentFromKey('test_experiment_double_feature'); + $variation = $this->projectConfig->getVariationFromKey('test_experiment_double_feature', 'control'); + + $expected_decision = new FeatureDecision( + $experiment, + $variation, + FeatureDecision::DECISION_SOURCE_FEATURE_TEST + ); + + $decisionServiceMock->expects($this->exactly(1)) + ->method('getVariationForFeature') + ->will($this->returnValue($expected_decision)); + + //Verify that sendNotifications is called with expected params + $arrayParam = array( + DecisionNotificationTypes::FLAG, + 'test_user', + ['device_type' => 'iPhone'], + (object) array( + 'flagKey'=>'double_single_variable_feature', + 'enabled'=> true, + 'variables'=> [], + 'variationKey' => 'control', + 'ruleKey' => 'test_experiment_double_feature', + 'reasons' => [], + 'decisionEventDispatched' => false + ) + ); + + $this->notificationCenterMock->expects($this->exactly(1)) + ->method('sendNotifications') + ->with( + NotificationType::DECISION, + $arrayParam + ); + + //assert that sendImpressionEvent is not called + $optimizelyMock->expects($this->never()) + ->method('sendImpressionEvent'); + + $expectedOptimizelyDecision = new OptimizelyDecision( + 'control', + true, + [], + 'test_experiment_double_feature', + 'double_single_variable_feature', + $userContext, + [] + ); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $optimizelyDecision = $optimizelyMock->decide($userContext, 'double_single_variable_feature', ['DISABLE_DECISION_EVENT']); + $this->compareOptimizelyDecisions($expectedOptimizelyDecision, $optimizelyDecision); + } + + public function testDecideForKeysParamOptionsWorkTogetherWithDefaultOptions() + { + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array( + $this->datafile, null, $this->loggerMock, null, null, null, null, null, null, ['ENABLED_FLAGS_ONLY'] + )) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + + $this->notificationCenterMock->expects($this->exactly(9)) + ->method('sendNotifications'); + + //assert that sendImpressionEvent is called with expected params + $optimizelyMock->expects($this->never()) + ->method('sendImpressionEvent'); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $keys = ['boolean_feature', + 'double_single_variable_feature', + 'integer_single_variable_feature', + 'boolean_single_variable_feature', + 'string_single_variable_feature', + 'multiple_variables_feature', + 'multi_variate_feature', + 'mutex_group_feature', + 'empty_feature' + ]; + + $optimizelyDecisions = $optimizelyMock->decideForKeys($userContext, $keys, ['DISABLE_DECISION_EVENT']); + $this->assertEquals(count($optimizelyDecisions), 6); + } + + public function testDecidewithAllDecideOptionsSet() + { + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + + $decisionServiceMock = $this->getMockBuilder(DecisionService::class) + ->setConstructorArgs(array($this->loggerMock)) + ->setMethods(array('getVariationForFeature')) + ->getMock(); + + $decisionService = new \ReflectionProperty(Optimizely::class, '_decisionService'); + $decisionService->setAccessible(true); + $decisionService->setValue($optimizelyMock, $decisionServiceMock); + + $experiment = $this->projectConfig->getExperimentFromKey('test_experiment_double_feature'); + $variation = $this->projectConfig->getVariationFromKey('test_experiment_double_feature', 'control'); + + $expected_decision = new FeatureDecision( + $experiment, + $variation, + FeatureDecision::DECISION_SOURCE_FEATURE_TEST + ); + + $decisionServiceMock->expects($this->exactly(1)) + ->method('getVariationForFeature') + ->will($this->returnValue($expected_decision)); + + //Verify that sendNotifications is called with expected params + $arrayParam = array( + DecisionNotificationTypes::FLAG, + 'test_user', + ['device_type' => 'iPhone'], + (object) array( + 'flagKey'=>'double_single_variable_feature', + 'enabled'=> true, + 'variables'=> [], + 'variationKey' => 'control', + 'ruleKey' => 'test_experiment_double_feature', + 'reasons' => [], + 'decisionEventDispatched' => false + ) + ); + + $this->notificationCenterMock->expects($this->exactly(1)) + ->method('sendNotifications') + ->with( + NotificationType::DECISION, + $arrayParam + ); + + //assert that sendImpressionEvent is not called. + $optimizelyMock->expects($this->never()) + ->method('sendImpressionEvent'); + + $expectedOptimizelyDecision = new OptimizelyDecision( + 'control', + true, + [], + 'test_experiment_double_feature', + 'double_single_variable_feature', + $userContext, + [] + ); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $optimizelyDecision = $optimizelyMock->decide($userContext, 'double_single_variable_feature', ['DISABLE_DECISION_EVENT', 'ENABLED_FLAGS_ONLY', 'IGNORE_USER_PROFILE_SERVICE', 'INCLUDE_REASONS', 'EXCLUDE_VARIABLES']); + $this->compareOptimizelyDecisions($expectedOptimizelyDecision, $optimizelyDecision); + } + + public function testDecideAll() + { + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + + $this->notificationCenterMock->expects($this->exactly(9)) + ->method('sendNotifications'); + + //assert that sendImpressionEvent is called with expected params + $optimizelyMock->expects($this->exactly(5)) + ->method('sendImpressionEvent'); + + $optimizelyMock->notificationCenter = $this->notificationCenterMock; + + $expectedOptimizelyDecision1 = new OptimizelyDecision( + 'control', + true, + ['double_variable' => 42.42], + 'test_experiment_double_feature', + 'double_single_variable_feature', + $userContext, + [] + ); + + $expectedOptimizelyDecision2 = new OptimizelyDecision( + 'test_variation_2', + true, + [], + 'test_experiment_2', + 'boolean_feature', + $userContext, + [] + ); + + + $optimizelyDecisions = $optimizelyMock->decideAll($userContext); + $this->assertEquals(count($optimizelyDecisions), 9); + + $this->compareOptimizelyDecisions($expectedOptimizelyDecision1, $optimizelyDecisions['double_single_variable_feature']); + $this->compareOptimizelyDecisions($expectedOptimizelyDecision2, $optimizelyDecisions['boolean_feature']); + } + + public function testDecideAllCallsDecideForKeysAndReturnsItsResponse() + { + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock)) + ->setMethods(array('decideForKeys')) + ->getMock(); + + $decideOptions = ['ENABLED_FLAGS_ONLY']; + $keys = ['boolean_feature', + 'double_single_variable_feature', + 'integer_single_variable_feature', + 'boolean_single_variable_feature', + 'string_single_variable_feature', + 'multiple_variables_feature', + 'multi_variate_feature', + 'mutex_group_feature', + 'empty_feature' + ]; + + $userContext = $optimizelyMock->createUserContext('test_user', ['device_type' => 'iPhone']); + + //assert that decideForKeys is called with expected params + $optimizelyMock->expects($this->exactly(1)) + ->method('decideForKeys') + ->with($userContext, $keys, $decideOptions) + ->willReturn('Response from decideForKeys'); + + $optimizelyDecisions = $optimizelyMock->decideAll($userContext, $decideOptions); + $this->assertEquals('Response from decideForKeys', $optimizelyDecisions); + } + public function testActivateInvalidOptimizelyObject() { $optimizelyMock = $this->getMockBuilder(Optimizely::class) diff --git a/tests/OptimizelyUserContextTests.php b/tests/OptimizelyUserContextTests.php new file mode 100644 index 00000000..671b9ae1 --- /dev/null +++ b/tests/OptimizelyUserContextTests.php @@ -0,0 +1,240 @@ +datafile = DATAFILE; + + // Mock Logger + $this->loggerMock = $this->getMockBuilder(NoOpLogger::class) + ->setMethods(array('log')) + ->getMock(); + + + $this->optimizelyObject = new Optimizely($this->datafile, null, $this->loggerMock); + } + public function testOptimizelyUserContextIsCreatedWithExpectedValues() + { + $userId = 'test_user'; + $attributes = [ "browser" => "chrome"]; + $optUserContext = new OptimizelyUserContext($this->optimizelyObject, $userId, $attributes); + + $this->assertEquals($userId, $optUserContext->getUserId()); + $this->assertEquals($attributes, $optUserContext->getAttributes()); + $this->assertSame($this->optimizelyObject, $optUserContext->getOptimizely()); + } + + public function testOptimizelyUserContextThrowsErrorWhenNonArrayPassedAsAttributes() + { + $userId = 'test_user'; + + try { + $optUserContext = new OptimizelyUserContext($this->optimizelyObject, $userId, 'HelloWorld'); + } catch (Exception $exception) { + return; + } catch (TypeError $exception) { + return; + } + + $this->fail('Unexpected behavior. UserContext should have thrown an error.'); + } + + public function testSetAttribute() + { + $userId = 'test_user'; + $attributes = [ "browser" => "chrome"]; + $optUserContext = new OptimizelyUserContext($this->optimizelyObject, $userId, $attributes); + + $this->assertEquals($attributes, $optUserContext->getAttributes()); + + $optUserContext->setAttribute('color', 'red'); + $this->assertEquals([ + "browser" => "chrome", + "color" => "red" + ], $optUserContext->getAttributes()); + } + + public function testSetAttributeOverridesValueOfExistingKey() + { + $userId = 'test_user'; + $attributes = [ "browser" => "chrome"]; + $optUserContext = new OptimizelyUserContext($this->optimizelyObject, $userId, $attributes); + + $this->assertEquals($attributes, $optUserContext->getAttributes()); + + $optUserContext->setAttribute('browser', 'firefox'); + $this->assertEquals(["browser" => "firefox"], $optUserContext->getAttributes()); + } + + public function testSetAttributeWhenNoAttributesProvidedInConstructor() + { + $userId = 'test_user'; + $optUserContext = new OptimizelyUserContext($this->optimizelyObject, $userId); + + $this->assertEquals([], $optUserContext->getAttributes()); + + $optUserContext->setAttribute('browser', 'firefox'); + $this->assertEquals(["browser" => "firefox"], $optUserContext->getAttributes()); + } + + public function testDecideCallsAndReturnsOptimizelyDecideAPI() + { + $userId = 'test_user'; + $attributes = [ "browser" => "chrome"]; + + + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock)) + ->setMethods(array('decide')) + ->getMock(); + + + $optUserContext = new OptimizelyUserContext($optimizelyMock, $userId, $attributes); + + //assert that decide is called with expected params + $optimizelyMock->expects($this->exactly(1)) + ->method('decide') + ->with( + $optUserContext, + 'test_feature', + ['DISABLE_DECISION_EVENT', 'ENABLED_FLAGS_ONLY'] + ) + ->will($this->returnValue('Mocked return value')); + + $this->assertEquals( + 'Mocked return value', + $optUserContext->decide('test_feature', ['DISABLE_DECISION_EVENT', 'ENABLED_FLAGS_ONLY']) + ); + } + + public function testDecideAllCallsAndReturnsOptimizelyDecideAllAPI() + { + $userId = 'test_user'; + $attributes = [ "browser" => "chrome"]; + + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock)) + ->setMethods(array('decideAll')) + ->getMock(); + + + $optUserContext = new OptimizelyUserContext($optimizelyMock, $userId, $attributes); + + //assert that decideAll is called with expected params + $optimizelyMock->expects($this->exactly(1)) + ->method('decideAll') + ->with( + $optUserContext, + ['ENABLED_FLAGS_ONLY'] + ) + ->will($this->returnValue('Mocked return value')); + + $this->assertEquals( + 'Mocked return value', + $optUserContext->decideAll(['ENABLED_FLAGS_ONLY']) + ); + } + + public function testDecideForKeysCallsAndReturnsOptimizelyDecideForKeysAPI() + { + $userId = 'test_user'; + $attributes = [ "browser" => "chrome"]; + + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock)) + ->setMethods(array('decideForKeys')) + ->getMock(); + + + $optUserContext = new OptimizelyUserContext($optimizelyMock, $userId, $attributes); + + //assert that decideForKeys is called with expected params + $optimizelyMock->expects($this->exactly(1)) + ->method('decideForKeys') + ->with( + $optUserContext, + ['test_feature', 'test_experiment'], + ['DISABLE_DECISION_EVENT'] + ) + ->will($this->returnValue('Mocked return value')); + + $this->assertEquals( + 'Mocked return value', + $optUserContext->decideForKeys(['test_feature', 'test_experiment'], ['DISABLE_DECISION_EVENT']) + ); + } + + public function testTrackEventCallsAndReturnsOptimizelyTrackAPI() + { + $userId = 'test_user'; + $attributes = []; + $eventKey = "test_event"; + $eventTags = [ "revenue" => 50]; + + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock)) + ->setMethods(array('track')) + ->getMock(); + + + $optUserContext = new OptimizelyUserContext($optimizelyMock, $userId, $attributes); + + //assert that track is called with expected params + $optimizelyMock->expects($this->exactly(1)) + ->method('track') + ->with( + $eventKey, + $userId, + $attributes, + $eventTags + ) + ->will($this->returnValue('Mocked return value')); + + $this->assertEquals( + 'Mocked return value', + $optUserContext->trackEvent($eventKey, $eventTags) + ); + } + + public function testJsonSerialize() + { + $userId = 'test_user'; + $attributes = [ "browser" => "chrome"]; + $optUserContext = new OptimizelyUserContext($this->optimizelyObject, $userId, $attributes); + + $this->assertEquals([ + 'userId' => $userId, + 'attributes' => $attributes + ], json_decode(json_encode($optUserContext), true)); + } +} diff --git a/tests/TestData.php b/tests/TestData.php index 1c5a78ab..9543af48 100644 --- a/tests/TestData.php +++ b/tests/TestData.php @@ -1,6 +1,6 @@ method('log') ->with(Logger::DEBUG, "Evaluating audiences for experiment \"test_experiment\": []."); + $evalCombinedAudienceMessage = "Audiences for experiment \"test_experiment\" collectively evaluated to TRUE."; $this->loggerMock->expects($this->at(1)) ->method('log') - ->with(Logger::INFO, "Audiences for experiment \"test_experiment\" collectively evaluated to TRUE."); + ->with(Logger::INFO, $evalCombinedAudienceMessage); - $this->assertTrue(Validator::doesUserMeetAudienceConditions($this->config, $experiment, [], $this->loggerMock)); + list($evalResult, $reasons) = Validator::doesUserMeetAudienceConditions($this->config, $experiment, [], $this->loggerMock); + $this->assertTrue($evalResult); + $this->assertContains($evalCombinedAudienceMessage, $reasons); + $this->assertCount(1, $reasons); } public function testdoesUserMeetAudienceConditionsEvaluatesAudienceIds() @@ -94,7 +98,7 @@ public function testIsUserInExperimenEvaluatesAudienceConditions() ->method('log') ->will($this->returnCallback($this->collectLogsForAssertion)); - Validator::doesUserMeetAudienceConditions($this->typedConfig, $experiment, ["house" => "I am in Slytherin"], $this->loggerMock); + list($result, $reasons) = Validator::doesUserMeetAudienceConditions($this->typedConfig, $experiment, ["house" => "I am in Slytherin"], $this->loggerMock); $this->assertContains( [Logger::DEBUG, "Evaluating audiences for experiment \"audience_combinations_experiment\": [\"or\",[\"or\",\"3468206642\",\"3988293898\"]]."], @@ -116,6 +120,11 @@ public function testIsUserInExperimenEvaluatesAudienceConditions() [Logger::DEBUG, "Audience \"3988293898\" evaluated to TRUE."], $this->collectedLogs ); - $this->assertContains([Logger::INFO, "Audiences for experiment \"audience_combinations_experiment\" collectively evaluated to TRUE."], $this->collectedLogs); + + $evalCombinedAudienceMessage = "Audiences for experiment \"audience_combinations_experiment\" collectively evaluated to TRUE."; + + $this->assertContains([Logger::INFO, $evalCombinedAudienceMessage], $this->collectedLogs); + $this->assertContains($evalCombinedAudienceMessage, $reasons); + $this->assertCount(1, $reasons); } } diff --git a/tests/UtilsTests/ValidatorTest.php b/tests/UtilsTests/ValidatorTest.php index a71f46b0..5939242c 100644 --- a/tests/UtilsTests/ValidatorTest.php +++ b/tests/UtilsTests/ValidatorTest.php @@ -1,6 +1,6 @@ loggerMock - ) + )[0] ); $this->assertTrue( @@ -227,21 +227,21 @@ public function testDoesUserMeetAudienceConditionsAudienceUsedInExperimentNoAttr $experiment, [], $this->loggerMock - ) + )[0] ); } public function testDoesUserMeetAudienceConditionsAudienceMatch() { $config = new DatafileProjectConfig(DATAFILE, new NoOpLogger(), new NoOpErrorHandler()); - $this->assertTrue( - Validator::doesUserMeetAudienceConditions( - $config, - $config->getExperimentFromKey('test_experiment'), - ['device_type' => 'iPhone', 'location' => 'San Francisco'], - $this->loggerMock - ) + $result = Validator::doesUserMeetAudienceConditions( + $config, + $config->getExperimentFromKey('test_experiment'), + ['device_type' => 'iPhone', 'location' => 'San Francisco'], + $this->loggerMock ); + + $this->assertTrue($result[0]); } public function testDoesUserMeetAudienceConditionsAudienceNoMatch() @@ -253,7 +253,7 @@ public function testDoesUserMeetAudienceConditionsAudienceNoMatch() $config->getExperimentFromKey('test_experiment'), ['device_type' => 'Android', 'location' => 'San Francisco'], $this->loggerMock - ) + )[0] ); } @@ -272,7 +272,7 @@ public function testDoesUserMeetAudienceConditionsNoAudienceUsedInExperiment() $experiment, [], $this->loggerMock - ) + )[0] ); // Audience Ids exist but audience conditions is empty. @@ -284,7 +284,7 @@ public function testDoesUserMeetAudienceConditionsNoAudienceUsedInExperiment() $experiment, [], $this->loggerMock - ) + )[0] ); // Audience Ids is empty and audience conditions is null. @@ -296,7 +296,7 @@ public function testDoesUserMeetAudienceConditionsNoAudienceUsedInExperiment() $experiment, [], $this->loggerMock - ) + )[0] ); } @@ -317,7 +317,7 @@ public function testDoesUserMeetAudienceConditionsSomeAudienceUsedInExperiment() $experiment, ['device_type' => 'Android', 'location' => 'San Francisco'], $this->loggerMock - ) + )[0] ); // Audience Ids exist and audience conditions is null. @@ -331,7 +331,7 @@ public function testDoesUserMeetAudienceConditionsSomeAudienceUsedInExperiment() $experiment, ['device_type' => 'iPhone', 'location' => 'San Francisco'], $this->loggerMock - ) + )[0] ); } @@ -351,7 +351,7 @@ public function testDoesUserMeetAudienceConditionsWithAudienceConditionsSetToAud $experiment, ['device_type' => 'iPhone', 'location' => 'San Francisco'], $this->loggerMock - ) + )[0] ); } @@ -371,7 +371,7 @@ public function testDoesUserMeetAudienceConditionsWithUnknownAudienceId() $experiment, ['device_type' => 'iPhone', 'location' => 'San Francisco'], $this->loggerMock - ) + )[0] ); } @@ -404,7 +404,7 @@ public function testDoesUserMeetAudienceConditionsWithSimpleAudience() $experiment, ['device_type' => 'iPhone', 'location' => 'San Francisco'], $this->loggerMock - ) + )[0] ); } @@ -461,7 +461,7 @@ public function testDoesUserMeetAudienceConditionsWithComplexAudience() $experiment, ['should_do_it' => true, 'house' => 'foo'], $this->loggerMock - ) + )[0] ); }