diff --git a/src/Optimizely/Exceptions/InvalidCallbackArgumentCountException.php b/src/Optimizely/Exceptions/InvalidCallbackArgumentCountException.php new file mode 100644 index 00000000..9813dfe7 --- /dev/null +++ b/src/Optimizely/Exceptions/InvalidCallbackArgumentCountException.php @@ -0,0 +1,23 @@ +_notificationId = 1; + $this->_notifications = []; + foreach (array_values(NotificationType::getAll()) as $type) { + $this->_notifications[$type] = []; + } + + $this->_logger = $logger; + $this->_errorHandler = $errorHandler; + } + + public function getNotifications() + { + return $this->_notifications; + } + + /** + * Adds a notification callback for a notification type to the notification center + * @param string $notification_type One of the constants defined in NotificationType + * @param string $notification_callback A valid PHP callback + * + * @return null Given invalid notification type/callback + * -1 Given callback has been already added + * int Notification ID for the added callback + */ + public function addNotificationListener($notification_type, $notification_callback) + { + if (!NotificationType::isNotificationTypeValid($notification_type)) { + $this->_logger->log(Logger::ERROR, "Invalid notification type."); + $this->_errorHandler->handleError(new InvalidNotificationTypeException('Invalid notification type.')); + return null; + } + + if (!is_callable($notification_callback)) { + $this->_logger->log(Logger::ERROR, "Invalid notification callback."); + return null; + } + + foreach (array_values($this->_notifications[$notification_type]) as $callback) { + if ($notification_callback == $callback) { + // Note: anonymous methods sent with the same body will be re-added. + // Only variable and object methods can be checked for duplication + $this->_logger->log(Logger::DEBUG, "Callback already added for notification type '{$notification_type}'."); + return -1; + } + } + + $this->_notifications[$notification_type][$this->_notificationId] = $notification_callback; + $this->_logger->log(Logger::INFO, "Callback added for notification type '{$notification_type}'."); + $returnVal = $this->_notificationId++; + return $returnVal; + } + + /** + * Removes notification callback from the notification center + * @param int $notification_id notification IT + * + * @return true When callback removed + * false When no callback found for the given notification ID + */ + public function removeNotificationListener($notification_id) + { + foreach ($this->_notifications as $notification_type => $notifications) { + foreach (array_keys($notifications) as $id) { + if ($notification_id == $id) { + unset($this->_notifications[$notification_type][$id]); + $this->_logger->log(Logger::INFO, "Callback with notification ID '{$notification_id}' has been removed."); + return true; + } + } + } + + $this->_logger->log(Logger::DEBUG, "No Callback found with notification ID '{$notification_id}'."); + return false; + } + + /** + * Removes all notification callbacks for the given notification type + * @param string $notification_type One of the constants defined in NotificationType + * + */ + public function clearNotifications($notification_type) + { + if (!NotificationType::isNotificationTypeValid($notification_type)) { + $this->_logger->log(Logger::ERROR, "Invalid notification type."); + $this->_errorHandler->handleError(new InvalidNotificationTypeException('Invalid notification type.')); + return; + } + + $this->_notifications[$notification_type] = []; + $this->_logger->log(Logger::INFO, "All callbacks for notification type '{$notification_type}' have been removed."); + } + + /** + * Removes all notifications for all notification types + * from the notification center + * + */ + public function cleanAllNotifications() + { + foreach (array_values(NotificationType::getAll()) as $type) { + $this->_notifications[$type] = []; + } + } + + /** + * Executes all registered callbacks for the given notification type + * @param [type] $notification_type One of the constants defined in NotificationType + * @param array $args Array of items to pass as arguments to the callback + * + */ + public function sendNotifications($notification_type, array $args = []) + { + if (!isset($this->_notifications[$notification_type])) { + // No exception thrown and error logged since this method will be called from + // within the SDK + return; + } + + /** + * Note: Before PHP 7, if the callback in call_user_func is called with less number of arguments than expected, + * a warning is issued but the method is still executed with assigning null to the remaining + * arguments. From PHP 7, ArgumentCountError is thrown in such case and the method isn't executed. + * Therefore, we set error handler for warnings so that we raise an exception and notify the + * user that the registered callback has more number of arguments than + * expected. This should be done to keep a consistent behavior across all PHP versions. + */ + + set_error_handler(array($this, 'reportArgumentCountError'), E_WARNING); + + foreach (array_values($this->_notifications[$notification_type]) as $callback) { + try { + call_user_func_array($callback, $args); + } catch (ArgumentCountError $e) { + $this->reportArgumentCountError(); + } catch (Exception $e) { + $this->_logger->log(Logger::ERROR, "Problem calling notify callback."); + } + } + + restore_error_handler(); + } + + /** + * Logs and raises an exception when registered callback expects more number of arguments when executed + * + */ + public function reportArgumentCountError() + { + $this->_logger->log(Logger::ERROR, "Problem calling notify callback."); + $this->_errorHandler->handleError( + new InvalidCallbackArgumentCountException('Registered callback expects more number of arguments than the actual number') + ); + } +} diff --git a/src/Optimizely/Notification/NotificationType.php b/src/Optimizely/Notification/NotificationType.php new file mode 100644 index 00000000..8227aa6e --- /dev/null +++ b/src/Optimizely/Notification/NotificationType.php @@ -0,0 +1,38 @@ +getConstants()); + + return in_array($notification_type, $notificationTypeList); + } + + public static function getAll() + { + $oClass = new \ReflectionClass(__CLASS__); + return $oClass->getConstants(); + } +} diff --git a/src/Optimizely/Optimizely.php b/src/Optimizely/Optimizely.php index f52ab574..882dba66 100644 --- a/src/Optimizely/Optimizely.php +++ b/src/Optimizely/Optimizely.php @@ -35,6 +35,8 @@ use Optimizely\Event\Dispatcher\EventDispatcherInterface; use Optimizely\Logger\LoggerInterface; use Optimizely\Logger\NoOpLogger; +use Optimizely\Notification\NotificationCenter; +use Optimizely\Notification\NotificationType; use Optimizely\UserProfile\UserProfileServiceInterface; use Optimizely\Utils\EventTagUtils; use Optimizely\Utils\Validator; @@ -82,6 +84,11 @@ class Optimizely */ private $_logger; + /** + * @var NotificationCenter + */ + private $_notificationCenter; + /** * Optimizely constructor for managing Full Stack PHP projects. * @@ -129,6 +136,7 @@ public function __construct($datafile, $this->_eventBuilder = new EventBuilder(); $this->_decisionService = new DecisionService($this->_logger, $this->_config, $userProfileService); + $this->_notificationCenter = new NotificationCenter($this->_logger, $this->_errorHandler); } /** @@ -169,6 +177,7 @@ private function validateUserInputs($attributes, $eventTags = null) { $this->_errorHandler->handleError( new InvalidEventTagException('Provided event tags are in an invalid format.') ); + return false; } } @@ -180,7 +189,7 @@ private function validateUserInputs($attributes, $eventTags = null) { * is one that is in "Running" state and into which the user has been bucketed. * * @param $event string Event key representing the event which needs to be recorded. - * @param $userId string ID for user. + * @param $user string ID for user. * @param $attributes array Attributes of the user. * * @return Array Of objects where each object contains the ID of the experiment to track and the ID of the variation the user is bucketed into. @@ -238,6 +247,17 @@ protected function sendImpressionEvent($experimentKey, $variationKey, $userId, $ $exception->getMessage() )); } + + $this->_notificationCenter->sendNotifications( + NotificationType::ACTIVATE, + array( + $this->_config->getExperimentFromKey($experimentKey), + $userId, + $attributes, + $this->_config->getVariationFromKey($experimentKey, $variationKey), + $impressionEvent + ) + ); } /** @@ -334,6 +354,17 @@ public function track($eventKey, $userId, $attributes = null, $eventTags = null) 'Unable to dispatch conversion event. Error %s', $exception->getMessage())); } + $this->_notificationCenter->sendNotifications( + NotificationType::TRACK, + array( + $eventKey, + $userId, + $attributes, + $eventTags, + $conversionEvent + ) + ); + } else { $this->_logger->log( Logger::INFO, @@ -452,18 +483,21 @@ public function isFeatureEnabled($featureFlagKey, $userId, $attributes = null) return false; } + $experiment_id = $decision->getExperimentId(); + $variation_id = $decision->getVariationId(); + if ($decision->getSource() == FeatureDecision::DECISION_SOURCE_EXPERIMENT) { - $experiment_id = $decision->getExperimentId(); - $variation_id = $decision->getVariationId(); $experiment = $this->_config->getExperimentFromId($experiment_id); $variation = $this->_config->getVariationFromId($experiment->getKey(), $variation_id); $this->sendImpressionEvent($experiment->getKey(), $variation->getKey(), $userId, $attributes); + } else { $this->_logger->log(Logger::INFO, "The user '{$userId}' is not being experimented on Feature Flag '{$featureFlagKey}'."); } $this->_logger->log(Logger::INFO, "Feature Flag '{$featureFlagKey}' is enabled for user '{$userId}'."); + return true; } diff --git a/src/Optimizely/ProjectConfig.php b/src/Optimizely/ProjectConfig.php index 4abb830e..c2ec5dd7 100644 --- a/src/Optimizely/ProjectConfig.php +++ b/src/Optimizely/ProjectConfig.php @@ -514,19 +514,8 @@ public function getForcedVariation($experimentKey, $userId) } $variationId = $experimentToVariationMap[$experimentId]; - // check for null and empty string variation ID - if (strlen($variationId) == 0) { - $this->_logger->log(Logger::DEBUG, sprintf('No variation mapped to experiment "%s" in the forced variation map.', $experimentKey)); - return null; - } - $variation = $this->getVariationFromId($experimentKey, $variationId); $variationKey = $variation->getKey(); - // check if the variation exists in the datafile (a new variation is returned if it is not in the datafile) - if (strlen($variationKey) == 0) { - // this case is logged in getVariationFromId - return null; - } $this->_logger->log(Logger::DEBUG, sprintf('Variation "%s" is mapped to experiment "%s" and user "%s" in the forced variation map', $variationKey, $experimentKey, $userId)); diff --git a/tests/NotificationTests/NotificationCenterTest.php b/tests/NotificationTests/NotificationCenterTest.php new file mode 100644 index 00000000..a2dde1ce --- /dev/null +++ b/tests/NotificationTests/NotificationCenterTest.php @@ -0,0 +1,668 @@ +loggerMock = $this->getMockBuilder(NoOpLogger::class) + ->setMethods(array('log')) + ->getMock(); + + $this->errorHandlerMock = $this->getMockBuilder(NoOpErrorHandler::class) + ->setMethods(array('handleError')) + ->getMock(); + + $this->notificationCenterObj = new NotificationCenter($this->loggerMock, $this->errorHandlerMock); + } + + public function testAddNotificationWithInvalidParams() + { + // should log, throw an exception and return null if invalid notification type given + $invalid_type = "HelloWorld"; + + $this->errorHandlerMock->expects($this->at(0)) + ->method('handleError') + ->with(new InvalidNotificationTypeException('Invalid notification type.')); + + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::ERROR, "Invalid notification type."); + + $this->assertSame( + null, + $this->notificationCenterObj->addNotificationListener($invalid_type, function () { + }) + ); + + // should log and return null if invalid callable given + $invalid_callable = "HelloWorld"; + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::ERROR, "Invalid notification callback."); + + $this->assertSame( + null, + $this->notificationCenterObj->addNotificationListener(NotificationType::ACTIVATE, $invalid_callable) + ); + } + + public function testAddNotificationWithValidTypeAndCallback() + { + $notificationType = NotificationType::ACTIVATE; + $this->notificationCenterObj->cleanAllNotifications(); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + // === should add, log and return notification ID when a plain function is passed as an argument === // + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + $simple_method = function () { + }; + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::INFO, "Callback added for notification type '{$notificationType}'."); + $this->assertSame( + 1, + $this->notificationCenterObj->addNotificationListener($notificationType, $simple_method) + ); + // verify that notifications length has incremented by 1 + $this->assertSame( + 1, + sizeof($this->notificationCenterObj->getNotifications()[$notificationType]) + ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + // === should add, log and return notification ID when an anonymous function is passed as an argument === // + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::INFO, "Callback added for notification type '{$notificationType}'."); + $this->assertSame( + 2, + $this->notificationCenterObj->addNotificationListener($notificationType, function () { + }) + ); + // verify that notifications length has incremented by 1 + $this->assertSame( + 2, + sizeof($this->notificationCenterObj->getNotifications()[$notificationType]) + ); + + /////////////////////////////////////////////////////////////////////////////////////////////////////// + // === should add, log and return notification ID when an object method is passed as an argument === // + /////////////////////////////////////////////////////////////////////////////////////////////////////// + $eBuilder = new EventBuilder; + $callbackInput = array($eBuilder, 'createImpressionEvent'); + + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::INFO, "Callback added for notification type '{$notificationType}'."); + $this->assertSame( + 3, + $this->notificationCenterObj->addNotificationListener($notificationType, $callbackInput) + ); + // verify that notifications length has incremented by 1 + $this->assertSame( + 3, + sizeof($this->notificationCenterObj->getNotifications()[$notificationType]) + ); + } + + public function testAddNotificationForMultipleNotificationTypes() + { + $this->notificationCenterObj->cleanAllNotifications(); + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // === should add, log and return notification ID when a valid callback is added for each notification type === // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::INFO, sprintf("Callback added for notification type '%s'.", NotificationType::ACTIVATE)); + $this->assertSame( + 1, + $this->notificationCenterObj->addNotificationListener(NotificationType::ACTIVATE, function () { + }) + ); + + // verify that notifications length for NotificationType::ACTIVATE has incremented by 1 + $this->assertSame( + 1, + sizeof($this->notificationCenterObj->getNotifications()[NotificationType::ACTIVATE]) + ); + + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::INFO, sprintf("Callback added for notification type '%s'.", NotificationType::TRACK)); + $this->assertSame( + 2, + $this->notificationCenterObj->addNotificationListener(NotificationType::TRACK, function () { + }) + ); + + // verify that notifications length for NotificationType::TRACK has incremented by 1 + $this->assertSame( + 1, + sizeof($this->notificationCenterObj->getNotifications()[NotificationType::TRACK]) + ); + } + + public function testAddNotificationForMultipleCallbacksForASingleNotificationType() + { + $this->notificationCenterObj->cleanAllNotifications(); + + /////////////////////////////////////////////////////////////////////////////////////// + // === should add, log and return notification ID when multiple valid callbacks + // are added for a single notification type === // + /////////////////////////////////////////////////////////////////////////////////////// + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::INFO, sprintf("Callback added for notification type '%s'.", NotificationType::ACTIVATE)); + $this->assertSame( + 1, + $this->notificationCenterObj->addNotificationListener(NotificationType::ACTIVATE, function () { + }) + ); + + // verify that notifications length for NotificationType::ACTIVATE has incremented by 1 + $this->assertSame( + 1, + sizeof($this->notificationCenterObj->getNotifications()[NotificationType::ACTIVATE]) + ); + + $this->assertSame( + 2, + $this->notificationCenterObj->addNotificationListener(NotificationType::ACTIVATE, function () { + echo "HelloWorld"; + }) + ); + + // verify that notifications length for NotificationType::ACTIVATE has incremented by 1 + $this->assertSame( + 2, + sizeof($this->notificationCenterObj->getNotifications()[NotificationType::ACTIVATE]) + ); + + $this->assertSame( + 3, + $this->notificationCenterObj->addNotificationListener(NotificationType::ACTIVATE, function () { + $a = 1; + }) + ); + + // verify that notifications length for NotificationType::ACTIVATE has incremented by 1 + $this->assertSame( + 3, + sizeof($this->notificationCenterObj->getNotifications()[NotificationType::ACTIVATE]) + ); + } + + public function testAddNotificationThatAlreadyAddedCallbackIsNotReAdded() + { + // Note: anonymous methods sent with the same body will be re-added. + // Only variable and object methods can be checked for duplication + + $functionToSend = function () { + }; + $this->notificationCenterObj->cleanAllNotifications(); + + /////////////////////////////////////////////////////////////////////////// + // ===== verify that a variable method with same body isn't re-added ===== // + /////////////////////////////////////////////////////////////////////////// + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::INFO, sprintf("Callback added for notification type '%s'.", NotificationType::ACTIVATE)); + + // verify that notification ID 1 is returned + $this->assertSame( + 1, + $this->notificationCenterObj->addNotificationListener(NotificationType::ACTIVATE, $functionToSend) + ); + + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::DEBUG, sprintf("Callback already added for notification type '%s'.", NotificationType::ACTIVATE)); + + // verify that -1 is returned when adding the same callback + $this->assertSame( + -1, + $this->notificationCenterObj->addNotificationListener(NotificationType::ACTIVATE, $functionToSend) + ); + + // verify that same method is added for a different notification type + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::INFO, sprintf("Callback added for notification type '%s'.", NotificationType::TRACK)); + $this->assertSame( + 2, + $this->notificationCenterObj->addNotificationListener(NotificationType::TRACK, $functionToSend) + ); + + ///////////////////////////////////////////////////////////////////////// + // ===== verify that an object method with same body isn't re-added ===== // + ///////////////////////////////////////////////////////////////////////// + $eBuilder = new EventBuilder; + $callbackInput = array($eBuilder, 'createImpressionEvent'); + + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::INFO, sprintf("Callback added for notification type '%s'.", NotificationType::ACTIVATE)); + $this->assertSame( + 3, + $this->notificationCenterObj->addNotificationListener(NotificationType::ACTIVATE, $callbackInput) + ); + + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::DEBUG, sprintf("Callback already added for notification type '%s'.", NotificationType::ACTIVATE)); + + // verify that -1 is returned when adding the same callback + $this->assertSame( + -1, + $this->notificationCenterObj->addNotificationListener(NotificationType::ACTIVATE, $callbackInput) + ); + + // verify that same method is added for a different notification type + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::INFO, sprintf("Callback added for notification type '%s'.", NotificationType::TRACK)); + $this->assertSame( + 4, + $this->notificationCenterObj->addNotificationListener(NotificationType::TRACK, $callbackInput) + ); + } + + public function testRemoveNotification() + { + $this->notificationCenterObj->cleanAllNotifications(); + + // add a callback for multiple notification types + $this->assertSame( + 1, + $this->notificationCenterObj->addNotificationListener(NotificationType::ACTIVATE, function () { + }) + ); + $this->assertSame( + 2, + $this->notificationCenterObj->addNotificationListener(NotificationType::TRACK, function () { + }) + ); + // add another callback for NotificationType::ACTIVATE + $this->assertSame( + 3, + $this->notificationCenterObj->addNotificationListener(NotificationType::ACTIVATE, function () { + //doSomething + }) + ); + + // Verify that notifications length for NotificationType::ACTIVATE is 2 + $this->assertSame( + 2, + sizeof($this->notificationCenterObj->getNotifications()[NotificationType::ACTIVATE]) + ); + + // Verify that notifications length for NotificationType::TRACK is 1 + $this->assertSame( + 1, + sizeof($this->notificationCenterObj->getNotifications()[NotificationType::TRACK]) + ); + + + /////////////////////////////////////////////////////////////////////////////// + // === Verify that no callback is removed for an invalid notification ID === // + /////////////////////////////////////////////////////////////////////////////// + $invalid_id = 4; + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::DEBUG, sprintf("No Callback found with notification ID '%s'.", $invalid_id)); + $this->assertSame( + false, + $this->notificationCenterObj->removeNotificationListener($invalid_id) + ); + + ///////////////////////////////////////////////////////////////////// + // === Verify that callback is removed for a valid notification ID // + ///////////////////////////////////////////////////////////////////// + + $valid_id = 3; + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::INFO, sprintf("Callback with notification ID '%s' has been removed.", $valid_id)); + $this->assertSame( + true, + $this->notificationCenterObj->removeNotificationListener($valid_id) + ); + + // verify that notifications length for NotificationType::ACTIVATE is now 1 + $this->assertSame( + 1, + sizeof($this->notificationCenterObj->getNotifications()[NotificationType::ACTIVATE]) + ); + + //verify that notifications length for NotificationType::TRACK remains same + $this->assertSame( + 1, + sizeof($this->notificationCenterObj->getNotifications()[NotificationType::TRACK]) + ); + + ///////////////////////////////////////////////////////////////////////////////////////////////// + // === Verify that no callback is removed once a callback has been already removed against a notification ID === // + ///////////////////////////////////////////////////////////////////////////////////////////////// + $valid_id = 3; + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::DEBUG, sprintf("No Callback found with notification ID '%s'.", $valid_id)); + $this->assertSame( + false, + $this->notificationCenterObj->removeNotificationListener($valid_id) + ); + + //verify that notifications lengths for NotificationType::ACTIVATE and NotificationType::TRACK remain same + $this->assertSame( + 1, + sizeof($this->notificationCenterObj->getNotifications()[NotificationType::ACTIVATE]) + ); + $this->assertSame( + 1, + sizeof($this->notificationCenterObj->getNotifications()[NotificationType::TRACK]) + ); + } + + public function testClearNotifications() + { + // ensure that notifications length is zero for each notification type + $this->notificationCenterObj->cleanAllNotifications(); + + // add a callback for multiple notification types + $this->notificationCenterObj->addNotificationListener(NotificationType::ACTIVATE, function () { + }); + $this->notificationCenterObj->addNotificationListener(NotificationType::TRACK, function () { + }); + + // add another callback for NotificationType::ACTIVATE + $this->notificationCenterObj->addNotificationListener(NotificationType::ACTIVATE, function () { + }); + + // Verify that notifications length for NotificationType::ACTIVATE is 2 + $this->assertSame( + 2, + sizeof($this->notificationCenterObj->getNotifications()[NotificationType::ACTIVATE]) + ); + + // Verify that notifications length for NotificationType::TRACK is 1 + $this->assertSame( + 1, + sizeof($this->notificationCenterObj->getNotifications()[NotificationType::TRACK]) + ); + + ///////////////////////////////////////////////////////////////////////////////////////// + // === Verify that no notifications are removed given an invalid notification type === // + ///////////////////////////////////////////////////////////////////////////////////////// + + $invalid_type = "HelloWorld"; + + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::ERROR, "Invalid notification type."); + + $this->errorHandlerMock->expects($this->at(0)) + ->method('handleError') + ->with(new InvalidNotificationTypeException('Invalid notification type.')); + + $this->assertSame( + null, + $this->notificationCenterObj->clearNotifications($invalid_type) + ); + + // Verify that notifications length for NotificationType::ACTIVATE is still 2 + $this->assertSame( + 2, + sizeof($this->notificationCenterObj->getNotifications()[NotificationType::ACTIVATE]) + ); + + // Verify that notifications length for NotificationType::TRACK is still 1 + $this->assertSame( + 1, + sizeof($this->notificationCenterObj->getNotifications()[NotificationType::TRACK]) + ); + + /////////////////////////////////////////////////////////////////////////////////////// + // === Verify that all notifications are removed given a valid notification type === // + /////////////////////////////////////////////////////////////////////////////////////// + + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with( + Logger::INFO, + sprintf("All callbacks for notification type '%s' have been removed.", NotificationType::ACTIVATE) + ); + + $this->notificationCenterObj->clearNotifications(NotificationType::ACTIVATE); + + // Verify that notifications length for NotificationType::ACTIVATE is now 0 + $this->assertSame( + 0, + sizeof($this->notificationCenterObj->getNotifications()[NotificationType::ACTIVATE]) + ); + + // Verify that notifications length for NotificationType::TRACK is still 1 + $this->assertSame( + 1, + sizeof($this->notificationCenterObj->getNotifications()[NotificationType::TRACK]) + ); + + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + // == Verify that no error is thrown when clearNotification is called again for the same notification type === // + /////////////////////////////////////////////////////////////////////////////////////////////////////////// + $this->notificationCenterObj->clearNotifications(NotificationType::ACTIVATE); + } + + + public function testCleanAllNotifications() + { + // using a new notification center object to avoid using the method being tested, + // to reset notifications list + $notificationCenterA = new NotificationCenter($this->loggerMock, $this->errorHandlerMock); + + // verify that for each of the notification types, the notifications length is zero + $this->assertSame( + 0, + sizeof($notificationCenterA->getNotifications()[NotificationType::ACTIVATE]) + ); + $this->assertSame( + 0, + sizeof($notificationCenterA->getNotifications()[NotificationType::TRACK]) + ); + + // add a callback for multiple notification types + $notificationCenterA->addNotificationListener(NotificationType::ACTIVATE, function () { + }); + $notificationCenterA->addNotificationListener(NotificationType::ACTIVATE, function () { + }); + $notificationCenterA->addNotificationListener(NotificationType::ACTIVATE, function () { + }); + + $notificationCenterA->addNotificationListener(NotificationType::TRACK, function () { + }); + $notificationCenterA->addNotificationListener(NotificationType::TRACK, function () { + }); + + // verify that notifications length for each type reflects the just added callbacks + $this->assertSame( + 3, + sizeof($notificationCenterA->getNotifications()[NotificationType::ACTIVATE]) + ); + $this->assertSame( + 2, + sizeof($notificationCenterA->getNotifications()[NotificationType::TRACK]) + ); + + //////////////////////////////////////////////////////////////////////////////////////////////////// + // === verify that cleanAllNotifications removes all notifications for each notification type === // + //////////////////////////////////////////////////////////////////////////////////////////////////// + + $notificationCenterA->cleanAllNotifications(); + + // verify that notifications length for each type is now set to 0 + $this->assertSame( + 0, + sizeof($notificationCenterA->getNotifications()[NotificationType::ACTIVATE]) + ); + $this->assertSame( + 0, + sizeof($notificationCenterA->getNotifications()[NotificationType::TRACK]) + ); + + /////////////////////////////////////////////////////////////////////////////////////// + //=== verify that cleanAllNotifications doesn't throw an error when called again === // + /////////////////////////////////////////////////////////////////////////////////////// + $notificationCenterA->cleanAllNotifications(); + } + + public function testsendNotificationsGivenLessThanExpectedNumberOfArguments() + { + $clientObj = new FireNotificationTester; + $this->notificationCenterObj->cleanAllNotifications(); + + // add a notification callback with arguments + $this->notificationCenterObj->addNotificationListener( + NotificationType::ACTIVATE, + array($clientObj, 'decision_callback_with_args') + ); + + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // === Verify that an exception is thrown and message logged when less number of args passed than expected === // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////// + $this->errorHandlerMock->expects($this->at(0)) + ->method('handleError') + ->with(new InvalidCallbackArgumentCountException('Registered callback expects more number of arguments than the actual number')); + + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::ERROR, "Problem calling notify callback."); + + $this->notificationCenterObj->sendNotifications(NotificationType::ACTIVATE, array("HelloWorld")); + } + + public function testsendNotificationsAndVerifyThatAllCallbacksWithoutArgsAreCalled() + { + $clientMock = $this->getMockBuilder(FireNotificationTester::class) + ->setMethods(array('decision_callback_no_args', 'decision_callback_no_args_2', 'track_callback_no_args')) + ->getMock(); + + $this->notificationCenterObj->cleanAllNotifications(); + + //add notification callbacks + $this->notificationCenterObj->addNotificationListener( + NotificationType::ACTIVATE, + array($clientMock, 'decision_callback_no_args') + ); + $this->notificationCenterObj->addNotificationListener( + NotificationType::ACTIVATE, + array($clientMock, 'decision_callback_no_args_2') + ); + $this->notificationCenterObj->addNotificationListener( + NotificationType::TRACK, + array($clientMock, 'track_callback_no_args') + ); + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // === Verify that all callbacks for NotificationType::ACTIVATE are called and no other callbacks are called === // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + $clientMock->expects($this->exactly(1)) + ->method('decision_callback_no_args'); + $clientMock->expects($this->exactly(1)) + ->method('decision_callback_no_args_2'); + + $clientMock->expects($this->never()) + ->method('track_callback_no_args'); + + $this->notificationCenterObj->sendNotifications(NotificationType::ACTIVATE); + + //////////////////////////////////////////////////////////////////////////////////////////// + // === Verify that none of the callbacks are called given an invalid NotificationType === // + //////////////////////////////////////////////////////////////////////////////////////////// + + $clientMock->expects($this->never()) + ->method('decision_callback_no_args'); + $clientMock->expects($this->never()) + ->method('decision_callback_no_args_2'); + + $clientMock->expects($this->never()) + ->method('track_callback_no_args'); + + $this->notificationCenterObj->sendNotifications("abacada"); + } + + public function testsendNotificationsAndVerifyThatAllCallbacksWithArgsAreCalled() + { + $clientMock = $this->getMockBuilder(FireNotificationTester::class) + ->setMethods(array('decision_callback_with_args', 'decision_callback_with_args_2', 'track_callback_no_args')) + ->getMock(); + + $this->notificationCenterObj->cleanAllNotifications(); + + //add notification callbacks with args + $this->notificationCenterObj->addNotificationListener( + NotificationType::ACTIVATE, + array($clientMock, 'decision_callback_with_args') + ); + $this->notificationCenterObj->addNotificationListener( + NotificationType::ACTIVATE, + array($clientMock, 'decision_callback_with_args_2') + ); + $this->notificationCenterObj->addNotificationListener( + NotificationType::TRACK, + array($clientMock, 'track_callback_no_args') + ); + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // === Verify that all callbacks for NotificationType::ACTIVATE are called and no other callbacks are called === // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + $clientMock->expects($this->exactly(1)) + ->method('decision_callback_with_args') + ->with(5, 5.5, 'string', array(5,6), function () { + }); + $clientMock->expects($this->exactly(1)) + ->method('decision_callback_with_args_2') + ->with(5, 5.5, 'string', array(5,6), function () { + }); + $clientMock->expects($this->never()) + ->method('track_callback_no_args'); + + $this->notificationCenterObj->sendNotifications( + NotificationType::ACTIVATE, + array(5, 5.5, 'string', array(5,6), function () { + }) + ); + } +} diff --git a/tests/OptimizelyTest.php b/tests/OptimizelyTest.php index 060b2785..865c15b4 100644 --- a/tests/OptimizelyTest.php +++ b/tests/OptimizelyTest.php @@ -23,7 +23,10 @@ use Optimizely\ErrorHandler\NoOpErrorHandler; use Optimizely\Event\LogEvent; use Optimizely\Exceptions\InvalidAttributeException; +use Optimizely\Exceptions\InvalidEventTagException; use Optimizely\Logger\NoOpLogger; +use Optimizely\Notification\NotificationCenter; +use Optimizely\Notification\NotificationType; use Optimizely\ProjectConfig; use TypeError; use Optimizely\ErrorHandler\DefaultErrorHandler; @@ -62,6 +65,11 @@ public function setUp() $this->eventBuilderMock = $this->getMockBuilder(EventBuilder::class) ->setMethods(array('createImpressionEvent', 'createConversionEvent')) ->getMock(); + + $this->notificationCenterMock = $this->getMockBuilder(NotificationCenter::class) + ->setConstructorArgs(array($this->loggerMock, new NoOpErrorHandler)) + ->setMethods(array('sendNotifications')) + ->getMock(); } public function testInitValidEventDispatcher() @@ -151,8 +159,16 @@ public function testValidateDatafileInvalidFileJsonValidationSkipped() public function testActivateInvalidOptimizelyObject() { - $optlyObject = new Optimizely('Random datafile'); - $optlyObject->activate('some_experiment', 'some_user'); + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array('Random datafile', null, $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + // verify that sendImpression isn't called + $optimizelyMock->expects($this->never()) + ->method('sendImpressionEvent'); + + $optimizelyMock->activate('some_experiment', 'some_user'); $this->expectOutputRegex('/Datafile has invalid format. Failing "activate"./'); } @@ -174,12 +190,17 @@ public function testActivateInvalidAttributes() ->method('handleError') ->with(new InvalidAttributeException('Provided attributes are in an invalid format.')); - $optlyObject = new Optimizely( - $this->datafile, new ValidEventDispatcher(), $this->loggerMock, $errorHandlerMock - ); + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, new ValidEventDispatcher(), $this->loggerMock, $errorHandlerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + // verify that sendImpression isn't called + $optimizelyMock->expects($this->never()) + ->method('sendImpressionEvent'); // Call activate - $this->assertNull($optlyObject->activate('test_experiment', 'test_user', 42)); + $this->assertNull($optimizelyMock->activate('test_experiment', 'test_user', 42)); } public function testActivateUserInNoVariation() @@ -210,32 +231,32 @@ public function testActivateUserInNoVariation() ->method('log') ->with(Logger::INFO, 'Not activating user "not_in_variation_user".'); - $optlyObject = new Optimizely($this->datafile, new ValidEventDispatcher(), $this->loggerMock); + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, new ValidEventDispatcher(), $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); $eventBuilder = new \ReflectionProperty(Optimizely::class, '_eventBuilder'); $eventBuilder->setAccessible(true); - $eventBuilder->setValue($optlyObject, $this->eventBuilderMock); + $eventBuilder->setValue($optimizelyMock, $this->eventBuilderMock); + + // verify that sendImpression isn't called + $optimizelyMock->expects($this->never()) + ->method('sendImpressionEvent'); // Call activate - $this->assertNull($optlyObject->activate('test_experiment', 'not_in_variation_user', $userAttributes)); + $this->assertNull($optimizelyMock->activate('test_experiment', 'not_in_variation_user', $userAttributes)); } public function testActivateNoAudienceNoAttributes() { - $this->eventBuilderMock->expects($this->once()) - ->method('createImpressionEvent') - ->with( - $this->projectConfig, - 'group_experiment_1', - 'group_exp_1_var_2', 'user_1', null - ) - ->willReturn(new LogEvent( - 'logx.optimizely.com/decision', - ['param1' => 'val1', 'param2' => 'val2'], 'POST', []) - ); + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, new ValidEventDispatcher(), $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); $callIndex = 0; - $this->loggerMock->expects($this->exactly(7)) + $this->loggerMock->expects($this->exactly(5)) ->method('log'); $this->loggerMock->expects($this->at($callIndex++)) ->method('log') @@ -255,22 +276,14 @@ public function testActivateNoAudienceNoAttributes() ->method('log') ->with(Logger::INFO, 'User "user_1" is in variation group_exp_1_var_2 of experiment group_experiment_1.'); - $this->loggerMock->expects($this->at($callIndex++)) - ->method('log') - ->with(Logger::INFO, - 'Activating user "user_1" in experiment "group_experiment_1".'); - $this->loggerMock->expects($this->at($callIndex++)) - ->method('log') - ->with(Logger::DEBUG, - 'Dispatching impression event to URL logx.optimizely.com/decision with params param1=val1¶m2=val2.'); - - $optlyObject = new Optimizely($this->datafile, new ValidEventDispatcher(), $this->loggerMock); - $eventBuilder = new \ReflectionProperty(Optimizely::class, '_eventBuilder'); - $eventBuilder->setAccessible(true); - $eventBuilder->setValue($optlyObject, $this->eventBuilderMock); + + // Verify that sendImpression is called with expected params + $optimizelyMock->expects($this->exactly(1)) + ->method('sendImpressionEvent') + ->with('group_experiment_1', 'group_exp_1_var_2', 'user_1', null); // Call activate - $this->assertEquals('group_exp_1_var_2', $optlyObject->activate('group_experiment_1', 'user_1')); + $this->assertSame('group_exp_1_var_2', $optimizelyMock->activate('group_experiment_1', 'user_1')); } public function testActivateNoAudienceNoAttributesAfterSetForcedVariation() @@ -281,20 +294,13 @@ public function testActivateNoAudienceNoAttributesAfterSetForcedVariation() $variationKey = 'control'; $variationId = '7722370027'; - $this->eventBuilderMock->expects($this->once()) - ->method('createImpressionEvent') - ->with( - $this->projectConfig, - 'group_experiment_1', - 'group_exp_1_var_2', 'user_1', null - ) - ->willReturn(new LogEvent( - 'logx.optimizely.com/decision', - ['param1' => 'val1', 'param2' => 'val2'], 'POST', []) - ); + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, new ValidEventDispatcher(), $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); $callIndex = 0; - $this->loggerMock->expects($this->exactly(8)) + $this->loggerMock->expects($this->exactly(6)) ->method('log'); $this->loggerMock->expects($this->at($callIndex++)) ->method('log') @@ -317,31 +323,29 @@ public function testActivateNoAudienceNoAttributesAfterSetForcedVariation() ->method('log') ->with(Logger::INFO, 'User "user_1" is in variation group_exp_1_var_2 of experiment group_experiment_1.'); - $this->loggerMock->expects($this->at($callIndex++)) - ->method('log') - ->with(Logger::INFO, - 'Activating user "user_1" in experiment "group_experiment_1".'); - $this->loggerMock->expects($this->at($callIndex++)) - ->method('log') - ->with(Logger::DEBUG, - 'Dispatching impression event to URL logx.optimizely.com/decision with params param1=val1¶m2=val2.'); - - $optlyObject = new Optimizely($this->datafile, new ValidEventDispatcher(), $this->loggerMock); - $eventBuilder = new \ReflectionProperty(Optimizely::class, '_eventBuilder'); - $eventBuilder->setAccessible(true); - $eventBuilder->setValue($optlyObject, $this->eventBuilderMock); + + // Verify that sendImpression is called with expected params + $optimizelyMock->expects($this->exactly(1)) + ->method('sendImpressionEvent') + ->with('group_experiment_1', 'group_exp_1_var_2', 'user_1', null); // set forced variation - $this->assertTrue($this->optimizelyObject->setForcedVariation($experimentKey, $userId, $variationKey), 'Set variation for paused experiment should have failed.'); + $this->assertTrue($optimizelyMock->setForcedVariation($experimentKey, $userId, $variationKey), 'Set variation for paused experiment should have failed.'); // Call activate - $this->assertEquals('group_exp_1_var_2', $optlyObject->activate('group_experiment_1', 'user_1')); + $this->assertEquals('group_exp_1_var_2', $optimizelyMock->activate('group_experiment_1', 'user_1')); } public function testActivateAudienceNoAttributes() { - $this->eventBuilderMock->expects($this->never()) - ->method('createImpressionEvent'); + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + // Verify that sendImpressionEvent is not called + $optimizelyMock->expects($this->never()) + ->method('sendImpressionEvent'); $callIndex = 0; $this->loggerMock->expects($this->exactly(3)) @@ -357,14 +361,8 @@ public function testActivateAudienceNoAttributes() ->with(Logger::INFO, 'Not activating user "test_user".'); - $optlyObject = new Optimizely($this->datafile, new ValidEventDispatcher(), $this->loggerMock); - - $eventBuilder = new \ReflectionProperty(Optimizely::class, '_eventBuilder'); - $eventBuilder->setAccessible(true); - $eventBuilder->setValue($optlyObject, $this->eventBuilderMock); - // Call activate - $this->assertNull($optlyObject->activate('test_experiment', 'test_user')); + $this->assertNull($optimizelyMock->activate('test_experiment', 'test_user')); } public function testActivateWithAttributes() @@ -375,16 +373,13 @@ public function testActivateWithAttributes() 'location' => 'San Francisco' ]; - $this->eventBuilderMock->expects($this->once()) - ->method('createImpressionEvent') - ->with( - $this->projectConfig, - 'test_experiment', - 'control', 'test_user', $userAttributes - ) - ->willReturn(new LogEvent('logx.optimizely.com/decision', ['param1' => 'val1'], 'POST', [])); + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + $callIndex = 0; - $this->loggerMock->expects($this->exactly(5)) + $this->loggerMock->expects($this->exactly(3)) ->method('log'); $this->loggerMock->expects($this->at($callIndex++)) ->method('log') @@ -396,28 +391,26 @@ public function testActivateWithAttributes() ->method('log') ->with(Logger::INFO, 'User "test_user" is in variation control of experiment test_experiment.'); - $this->loggerMock->expects($this->at($callIndex++)) - ->method('log') - ->with(Logger::INFO, 'Activating user "test_user" in experiment "test_experiment".'); - $this->loggerMock->expects($this->at($callIndex++)) - ->method('log') - ->with(Logger::DEBUG, - 'Dispatching impression event to URL logx.optimizely.com/decision with params param1=val1.'); - - $optlyObject = new Optimizely($this->datafile, new ValidEventDispatcher(), $this->loggerMock); - - $eventBuilder = new \ReflectionProperty(Optimizely::class, '_eventBuilder'); - $eventBuilder->setAccessible(true); - $eventBuilder->setValue($optlyObject, $this->eventBuilderMock); + + // Verify that sendImpressionEvent is called with expected attributes + $optimizelyMock->expects($this->exactly(1)) + ->method('sendImpressionEvent') + ->with('test_experiment', 'control', 'test_user', $userAttributes); // Call activate - $this->assertEquals('control', $optlyObject->activate('test_experiment', 'test_user', $userAttributes)); + $this->assertEquals('control', $optimizelyMock->activate('test_experiment', 'test_user', $userAttributes)); } public function testActivateExperimentNotRunning() { - $this->eventBuilderMock->expects($this->never()) - ->method('createImpressionEvent'); + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock)) + ->setMethods(array('sendImpressionEvent')) + ->getMock(); + + // Verify that sendImpressionEvent is not called + $optimizelyMock->expects($this->never()) + ->method('sendImpressionEvent'); $this->loggerMock->expects($this->exactly(2)) ->method('log'); @@ -429,14 +422,8 @@ public function testActivateExperimentNotRunning() ->with(Logger::INFO, 'Not activating user "test_user".'); - $optlyObject = new Optimizely($this->datafile, new ValidEventDispatcher(), $this->loggerMock); - - $eventBuilder = new \ReflectionProperty(Optimizely::class, '_eventBuilder'); - $eventBuilder->setAccessible(true); - $eventBuilder->setValue($optlyObject, $this->eventBuilderMock); - - // Call activate - $this->assertNull($optlyObject->activate('paused_experiment', 'test_user', null)); + // Call activate + $this->assertNull($optimizelyMock->activate('paused_experiment', 'test_user', null)); } public function testGetVariationInvalidOptimizelyObject() @@ -650,6 +637,15 @@ public function testValidatePreconditionsUserNotInForcedVariationInExperiment() public function testTrackInvalidOptimizelyObject() { $optlyObject = new Optimizely('Random datafile'); + + $notificationCenter = new \ReflectionProperty(Optimizely::class, '_notificationCenter'); + $notificationCenter->setAccessible(true); + $notificationCenter->setValue($optlyObject, $this->notificationCenterMock); + + // Verify that sendNotifications isn't called + $this->notificationCenterMock->expects($this->never()) + ->method('sendNotifications'); + $optlyObject->track('some_event', 'some_user'); $this->expectOutputRegex('/Datafile has invalid format. Failing "track"./'); } @@ -671,10 +667,98 @@ public function testTrackInvalidAttributes() $this->datafile, new ValidEventDispatcher(), $this->loggerMock, $errorHandlerMock ); - // Call activate + $notificationCenter = new \ReflectionProperty(Optimizely::class, '_notificationCenter'); + $notificationCenter->setAccessible(true); + $notificationCenter->setValue($optlyObject, $this->notificationCenterMock); + + // Verify that sendNotifications isn't called + $this->notificationCenterMock->expects($this->never()) + ->method('sendNotifications'); + + // Call track $this->assertNull($optlyObject->track('purchase', 'test_user', 42)); } + public function testTrackInvalidEventTags() + { + $this->loggerMock->expects($this->once()) + ->method('log') + ->with(Logger::ERROR, 'Provided event tags are in an invalid format.'); + + $errorHandlerMock = $this->getMockBuilder(NoOpErrorHandler::class) + ->setMethods(array('handleError')) + ->getMock(); + $errorHandlerMock->expects($this->once()) + ->method('handleError') + ->with(new InvalidEventTagException('Provided event tags are in an invalid format.')); + + $optlyObject = new Optimizely( + $this->datafile, null, $this->loggerMock, $errorHandlerMock + ); + + $optlyObject->track('purchase', 'test_user', [], [1=>2]); + } + + public function testTrackUnknownEventKey() + { + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::ERROR, 'Event key "unknown_key" is not in datafile.'); + + + $this->loggerMock->expects($this->at(1)) + ->method('log') + ->with(Logger::ERROR, 'Not tracking user "test_user" for event "unknown_key".'); + + $this->optimizelyObject->track('unknown_key', 'test_user'); + } + + public function testActivateGivenEventKeyWithNoExperiments() + { + $this->loggerMock->expects($this->once()) + ->method('log') + ->with(Logger::INFO, 'There are no valid experiments for event "unlinked_event" to track.'); + + $this->optimizelyObject->track('unlinked_event', 'test_user'); + } + + public function testTrackEventDispatchFailure(){ + + $eventDispatcherMock = $this->getMockBuilder(DefaultEventDispatcher::class) + ->setMethods(array('dispatchEvent')) + ->getMock(); + + $this->eventBuilderMock->expects($this->once()) + ->method('createConversionEvent') + ->with( + $this->projectConfig, + 'purchase', + ["7718750065" => "7725250007"], + 'test_user', + null, + null + ) + ->willReturn(new LogEvent('logx.optimizely.com/track', ['param1' => 'val1'], 'POST', [])); + + $eventDispatcher = new \ReflectionProperty(Optimizely::class, '_eventDispatcher'); + $eventDispatcher->setAccessible(true); + $eventDispatcher->setValue($this->optimizelyObject, $eventDispatcherMock); + + $eventBuilder = new \ReflectionProperty(Optimizely::class, '_eventBuilder'); + $eventBuilder->setAccessible(true); + $eventBuilder->setValue($this->optimizelyObject, $this->eventBuilderMock); + + $eventDispatcherMock->expects($this->once()) + ->method('dispatchEvent') + ->will($this->throwException(new Exception)); + + $this->loggerMock->expects($this->at(16)) + ->method('log') + ->with(Logger::ERROR, 'Unable to dispatch conversion event. Error '); + + $this->optimizelyObject->track('purchase', 'test_user'); + } + public function testTrackNoAttributesNoEventValue() { $this->eventBuilderMock->expects($this->once()) @@ -755,6 +839,26 @@ public function testTrackNoAttributesNoEventValue() $optlyObject = new Optimizely($this->datafile, new ValidEventDispatcher(), $this->loggerMock); + $notificationCenter = new \ReflectionProperty(Optimizely::class, '_notificationCenter'); + $notificationCenter->setAccessible(true); + $notificationCenter->setValue($optlyObject, $this->notificationCenterMock); + + // Verify that sendNotifications is called with expected params + $arrayParam = array( + 'purchase', + 'test_user', + null, + null, + new LogEvent('logx.optimizely.com/track', ['param1' => 'val1'], 'POST', []) + ); + + $this->notificationCenterMock->expects($this->once()) + ->method('sendNotifications') + ->with( + NotificationType::TRACK, + $arrayParam + ); + $eventBuilder = new \ReflectionProperty(Optimizely::class, '_eventBuilder'); $eventBuilder->setAccessible(true); $eventBuilder->setValue($optlyObject, $this->eventBuilderMock); @@ -852,6 +956,26 @@ public function testTrackNoAttributesNoEventValueAfterSetForcedVariation() $optlyObject = new Optimizely($this->datafile, new ValidEventDispatcher(), $this->loggerMock); + $notificationCenter = new \ReflectionProperty(Optimizely::class, '_notificationCenter'); + $notificationCenter->setAccessible(true); + $notificationCenter->setValue($optlyObject, $this->notificationCenterMock); + + // Verify that sendNotifications is called with expected params + $arrayParam = array( + 'purchase', + 'test_user', + null, + null, + new LogEvent('logx.optimizely.com/track', ['param1' => 'val1'], 'POST', []) + ); + + $this->notificationCenterMock->expects($this->once()) + ->method('sendNotifications') + ->with( + NotificationType::TRACK, + $arrayParam + ); + $this->assertTrue($this->optimizelyObject->setForcedVariation($experimentKey, $userId, $variationKey), 'Set variation for paused experiment should have failed.'); $eventBuilder = new \ReflectionProperty(Optimizely::class, '_eventBuilder'); @@ -951,6 +1075,26 @@ public function testTrackWithAttributesNoEventValue() $optlyObject = new Optimizely($this->datafile, new ValidEventDispatcher(), $this->loggerMock); + $notificationCenter = new \ReflectionProperty(Optimizely::class, '_notificationCenter'); + $notificationCenter->setAccessible(true); + $notificationCenter->setValue($optlyObject, $this->notificationCenterMock); + + // Verify that sendNotifications is called with expected params + $arrayParam = array( + 'purchase', + 'test_user', + $userAttributes, + null, + new LogEvent('logx.optimizely.com/track', ['param1' => 'val1'], 'POST', []) + ); + + $this->notificationCenterMock->expects($this->once()) + ->method('sendNotifications') + ->with( + NotificationType::TRACK, + $arrayParam + ); + $eventBuilder = new \ReflectionProperty(Optimizely::class, '_eventBuilder'); $eventBuilder->setAccessible(true); $eventBuilder->setValue($optlyObject, $this->eventBuilderMock); @@ -1043,6 +1187,26 @@ public function testTrackNoAttributesWithDeprecatedEventValue() $optlyObject = new Optimizely($this->datafile, new ValidEventDispatcher(), $this->loggerMock); + $notificationCenter = new \ReflectionProperty(Optimizely::class, '_notificationCenter'); + $notificationCenter->setAccessible(true); + $notificationCenter->setValue($optlyObject, $this->notificationCenterMock); + + // Verify that sendNotifications is called with expected params + $arrayParam = array( + 'purchase', + 'test_user', + null, + array('revenue' => 42), + new LogEvent('logx.optimizely.com/track', ['param1' => 'val1'], 'POST', []) + ); + + $this->notificationCenterMock->expects($this->once()) + ->method('sendNotifications') + ->with( + NotificationType::TRACK, + $arrayParam + ); + $eventBuilder = new \ReflectionProperty(Optimizely::class, '_eventBuilder'); $eventBuilder->setAccessible(true); $eventBuilder->setValue($optlyObject, $this->eventBuilderMock); @@ -1130,6 +1294,26 @@ public function testTrackNoAttributesWithEventValue() $optlyObject = new Optimizely($this->datafile, new ValidEventDispatcher(), $this->loggerMock); + $notificationCenter = new \ReflectionProperty(Optimizely::class, '_notificationCenter'); + $notificationCenter->setAccessible(true); + $notificationCenter->setValue($optlyObject, $this->notificationCenterMock); + + // Verify that sendNotifications is called with expected params + $arrayParam = array( + 'purchase', + 'test_user', + null, + array('revenue' => 42), + new LogEvent('logx.optimizely.com/track', ['param1' => 'val1'], 'POST', []) + ); + + $this->notificationCenterMock->expects($this->once()) + ->method('sendNotifications') + ->with( + NotificationType::TRACK, + $arrayParam + ); + $eventBuilder = new \ReflectionProperty(Optimizely::class, '_eventBuilder'); $eventBuilder->setAccessible(true); $eventBuilder->setValue($optlyObject, $this->eventBuilderMock); @@ -1206,6 +1390,26 @@ public function testTrackNoAttributesWithInvalidEventValue() $optlyObject = new Optimizely($this->datafile, new ValidEventDispatcher(), $this->loggerMock); + $notificationCenter = new \ReflectionProperty(Optimizely::class, '_notificationCenter'); + $notificationCenter->setAccessible(true); + $notificationCenter->setValue($optlyObject, $this->notificationCenterMock); + + // Verify that sendNotifications is called with expected params + $arrayParam = array( + 'purchase', + 'test_user', + null, + array('revenue' => '4200'), + new LogEvent('logx.optimizely.com/track', ['param1' => 'val1'], 'POST', []) + ); + + $this->notificationCenterMock->expects($this->once()) + ->method('sendNotifications') + ->with( + NotificationType::TRACK, + $arrayParam + ); + $eventBuilder = new \ReflectionProperty(Optimizely::class, '_eventBuilder'); $eventBuilder->setAccessible(true); $eventBuilder->setValue($optlyObject, $this->eventBuilderMock); @@ -1308,6 +1512,26 @@ public function testTrackWithAttributesWithDeprecatedEventValue() $optlyObject = new Optimizely($this->datafile, new ValidEventDispatcher(), $this->loggerMock); + $notificationCenter = new \ReflectionProperty(Optimizely::class, '_notificationCenter'); + $notificationCenter->setAccessible(true); + $notificationCenter->setValue($optlyObject, $this->notificationCenterMock); + + // Verify that sendNotifications is called with expected params + $arrayParam = array( + 'purchase', + 'test_user', + $userAttributes, + array('revenue' => 42), + new LogEvent('logx.optimizely.com/track', ['param1' => 'val1'], 'POST', []) + ); + + $this->notificationCenterMock->expects($this->once()) + ->method('sendNotifications') + ->with( + NotificationType::TRACK, + $arrayParam + ); + $eventBuilder = new \ReflectionProperty(Optimizely::class, '_eventBuilder'); $eventBuilder->setAccessible(true); $eventBuilder->setValue($optlyObject, $this->eventBuilderMock); @@ -1405,6 +1629,26 @@ public function testTrackWithAttributesWithEventValue() $optlyObject = new Optimizely($this->datafile, new ValidEventDispatcher(), $this->loggerMock); + $notificationCenter = new \ReflectionProperty(Optimizely::class, '_notificationCenter'); + $notificationCenter->setAccessible(true); + $notificationCenter->setValue($optlyObject, $this->notificationCenterMock); + + // Verify that sendNotifications is called with expected params + $arrayParam = array( + 'purchase', + 'test_user', + $userAttributes, + array('revenue' => 42), + new LogEvent('logx.optimizely.com/track', ['param1' => 'val1'], 'POST', []) + ); + + $this->notificationCenterMock->expects($this->once()) + ->method('sendNotifications') + ->with( + NotificationType::TRACK, + $arrayParam + ); + $eventBuilder = new \ReflectionProperty(Optimizely::class, '_eventBuilder'); $eventBuilder->setAccessible(true); $eventBuilder->setValue($optlyObject, $this->eventBuilderMock); @@ -1828,6 +2072,14 @@ public function testIsFeatureEnabledGivenFeatureFlagIsNotEnabledForUser() $optimizelyMock->expects($this->never()) ->method('sendImpressionEvent'); + $notificationCenter = new \ReflectionProperty(Optimizely::class, '_notificationCenter'); + $notificationCenter->setAccessible(true); + $notificationCenter->setValue($optimizelyMock, $this->notificationCenterMock); + + // verify that sendNotifications isn't called + $this->notificationCenterMock->expects($this->never()) + ->method('sendNotifications'); + $this->loggerMock->expects($this->at(0)) ->method('log') ->with(Logger::INFO, "Feature Flag 'double_single_variable_feature' is not enabled for user 'user_id'."); @@ -2305,4 +2557,165 @@ public function testGetFeatureVariableString() '59abc0p' ); } + + public function testGetFeatureVariableMethodsReturnNullWhenGetVariableValueForTypeReturnsNull(){ + $optimizelyMock = $this->getMockBuilder(Optimizely::class) + ->setConstructorArgs(array($this->datafile, null, $this->loggerMock)) + ->setMethods(array('getFeatureVariableValueForType')) + ->getMock(); + $optimizelyMock->expects($this->exactly(4)) + ->method('getFeatureVariableValueForType') + ->willReturn(null); + + $this->assertNull( + $optimizelyMock->getFeatureVariableBoolean( + 'boolean_single_variable_feature', 'boolean_variable', 'user_id', []) + ); + $this->assertNull( + $optimizelyMock->getFeatureVariableString( + 'string_single_variable_feature', 'string_variable', 'user_id', []) + ); + $this->assertNull( + $optimizelyMock->getFeatureVariableDouble( + 'double_single_variable_feature', 'double_variable', 'user_id', []) + ); + $this->assertNull( + $optimizelyMock->getFeatureVariableInteger( + 'integer_single_variable_feature', 'integer_variable', 'user_id', []) + ); + + } + + public function testSendImpressionEventWithNoAttributes(){ + $optlyObject = new OptimizelyTester($this->datafile, new ValidEventDispatcher(), $this->loggerMock); + + // verify that createImpressionEvent is called + $this->eventBuilderMock->expects($this->once()) + ->method('createImpressionEvent') + ->with( + $this->projectConfig, + 'group_experiment_1', + 'group_exp_1_var_2', 'user_1', null + ) + ->willReturn(new LogEvent( + 'logx.optimizely.com/decision', + ['param1' => 'val1', 'param2' => 'val2'], 'POST', []) + ); + + // verify that sendNotifications is called with expected params + $arrayParam = array( + $this->projectConfig->getExperimentFromKey('group_experiment_1'), + 'user_1', + null, + $this->projectConfig->getVariationFromKey('group_experiment_1', 'group_exp_1_var_2'), + new LogEvent( + 'logx.optimizely.com/decision', + ['param1' => 'val1', 'param2' => 'val2'], 'POST', []) + ); + + $this->notificationCenterMock->expects($this->once()) + ->method('sendNotifications') + ->with( + NotificationType::ACTIVATE, + $arrayParam + ); + + $eventBuilder = new \ReflectionProperty(Optimizely::class, '_eventBuilder'); + $eventBuilder->setAccessible(true); + $eventBuilder->setValue($optlyObject, $this->eventBuilderMock); + + $notificationCenter = new \ReflectionProperty(Optimizely::class, '_notificationCenter'); + $notificationCenter->setAccessible(true); + $notificationCenter->setValue($optlyObject, $this->notificationCenterMock); + + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::INFO, + 'Activating user "user_1" in experiment "group_experiment_1".'); + $this->loggerMock->expects($this->at(1)) + ->method('log') + ->with(Logger::DEBUG, + 'Dispatching impression event to URL logx.optimizely.com/decision with params param1=val1¶m2=val2.'); + + $optlyObject->sendImpressionEvent('group_experiment_1', 'group_exp_1_var_2', 'user_1', null); + } + + public function testSendImpressionEventDispatchFailure() + { + $optlyObject = new OptimizelyTester($this->datafile, new ValidEventDispatcher(), $this->loggerMock); + + $eventDispatcherMock = $this->getMockBuilder(DefaultEventDispatcher::class) + ->setMethods(array('dispatchEvent')) + ->getMock(); + + $eventDispatcher = new \ReflectionProperty(Optimizely::class, '_eventDispatcher'); + $eventDispatcher->setAccessible(true); + $eventDispatcher->setValue($optlyObject, $eventDispatcherMock); + + $eventDispatcherMock->expects($this->once()) + ->method('dispatchEvent') + ->will($this->throwException(new Exception)); + + $this->loggerMock->expects($this->at(2)) + ->method('log') + ->with(Logger::ERROR, 'Unable to dispatch impression event. Error '); + + $optlyObject->sendImpressionEvent('test_experiment', 'control', 'test_user', []); + } + + public function testSendImpressionEventWithAttributes(){ + $optlyObject = new OptimizelyTester($this->datafile, new ValidEventDispatcher(), $this->loggerMock); + + $userAttributes = [ + 'device_type' => 'iPhone', + 'company' => 'Optimizely', + 'location' => 'San Francisco' + ]; + + // verify that createImpressionEvent is called + $this->eventBuilderMock->expects($this->once()) + ->method('createImpressionEvent') + ->with( + $this->projectConfig, + 'test_experiment', + 'control', 'test_user', $userAttributes + ) + ->willReturn(new LogEvent('logx.optimizely.com/decision', ['param1' => 'val1'], 'POST', [])); + + // verify that sendNotifications is called with expected params + $arrayParam = array( + $this->projectConfig->getExperimentFromKey('test_experiment'), + 'test_user', + $userAttributes, + $this->projectConfig->getVariationFromKey('test_experiment', 'control'), + new LogEvent( + 'logx.optimizely.com/decision', + ['param1' => 'val1'], 'POST', []) + ); + + $this->notificationCenterMock->expects($this->once()) + ->method('sendNotifications') + ->with( + NotificationType::ACTIVATE, + $arrayParam + ); + + $this->loggerMock->expects($this->at(0)) + ->method('log') + ->with(Logger::INFO, 'Activating user "test_user" in experiment "test_experiment".'); + $this->loggerMock->expects($this->at(1)) + ->method('log') + ->with(Logger::DEBUG, + 'Dispatching impression event to URL logx.optimizely.com/decision with params param1=val1.'); + + $eventBuilder = new \ReflectionProperty(Optimizely::class, '_eventBuilder'); + $eventBuilder->setAccessible(true); + $eventBuilder->setValue($optlyObject, $this->eventBuilderMock); + + $notificationCenter = new \ReflectionProperty(Optimizely::class, '_notificationCenter'); + $notificationCenter->setAccessible(true); + $notificationCenter->setValue($optlyObject, $this->notificationCenterMock); + + $optlyObject->sendImpressionEvent('test_experiment', 'control', 'test_user', $userAttributes); + } } diff --git a/tests/ProjectConfigTest.php b/tests/ProjectConfigTest.php index 8a8e207c..7de044e1 100644 --- a/tests/ProjectConfigTest.php +++ b/tests/ProjectConfigTest.php @@ -122,7 +122,8 @@ public function testInit() $eventKeyMap = new \ReflectionProperty(ProjectConfig::class, '_eventKeyMap'); $eventKeyMap->setAccessible(true); $this->assertEquals([ - 'purchase' => $this->config->getEvent('purchase') + 'purchase' => $this->config->getEvent('purchase'), + 'unlinked_event' => $this->config->getEvent('unlinked_event') ], $eventKeyMap->getValue($this->config)); // Check attribute key map diff --git a/tests/TestData.php b/tests/TestData.php index 0559b072..74dc994a 100644 --- a/tests/TestData.php +++ b/tests/TestData.php @@ -20,6 +20,7 @@ use Optimizely\Bucketer; use Optimizely\Event\Dispatcher\EventDispatcherInterface; use Optimizely\Event\LogEvent; +use Optimizely\Optimizely; define('DATAFILE','{ "experiments": [ @@ -457,6 +458,11 @@ ], "id": "7718020063", "key": "purchase" + }, + { + "experimentIds": [], + "id": "7718020064", + "key": "unlinked_event" } ], "anonymizeIP": false, @@ -754,6 +760,29 @@ public function generateBucketValue($bucketingId) } } +/** + * Class OptimizelyTester + * Extending Optimizely for the sake of tests. + */ +class OptimizelyTester extends Optimizely +{ + public function sendImpressionEvent($experimentKey, $variationKey, $userId, $attributes){ + parent::sendImpressionEvent($experimentKey, $variationKey, $userId, $attributes); + } +} + +class FireNotificationTester{ + public function decision_callback_no_args(){} + + public function decision_callback_no_args_2(){} + + public function decision_callback_with_args($anInt, $aDouble, $aString, $anArray, $aFunction){} + + public function decision_callback_with_args_2($anInt, $aDouble, $aString, $anArray, $aFunction){} + + public function track_callback_no_args(){} +} + class ValidEventDispatcher implements EventDispatcherInterface {