diff --git a/composer.json b/composer.json index 7a672af0..607d2c41 100644 --- a/composer.json +++ b/composer.json @@ -11,15 +11,15 @@ ], "require": { "php": ">=5.5", - "justinrainbow/json-schema": "^1.6 || ^2.0 || ^4.0", + "justinrainbow/json-schema": "^1.6 || ^2.0 || ^4.0 || ^5.0", "lastguest/murmurhash": "1.3.0", "guzzlehttp/guzzle": "~5.3|~6.2", "monolog/monolog": "~1.21", "icecave/parity": "^1.0" }, "require-dev": { - "phpunit/phpunit": "~4.8|~5.0", - "satooshi/php-coveralls": "v1.0.1" + "phpunit/phpunit": "^4.8|^5.0", + "php-coveralls/php-coveralls": "v2.0.0" }, "autoload": { "psr-4": { diff --git a/src/Optimizely/Event/Builder/EventBuilder.php b/src/Optimizely/Event/Builder/EventBuilder.php index 8ed82999..1eaceff7 100644 --- a/src/Optimizely/Event/Builder/EventBuilder.php +++ b/src/Optimizely/Event/Builder/EventBuilder.php @@ -180,45 +180,50 @@ private function getImpressionParams(Experiment $experiment, $variationId) private function getConversionParams($config, $eventKey, $experimentVariationMap, $eventTags) { $conversionParams = []; + $singleSnapshot = []; + $decisions = []; + foreach ($experimentVariationMap as $experimentId => $variationId) { - $singleSnapshot = []; + + $experiment = $config->getExperimentFromId($experimentId); $eventEntity = $config->getEvent($eventKey); - $singleSnapshot[DECISIONS] = [ - [ - CAMPAIGN_ID => $experiment->getLayerId(), - EXPERIMENT_ID => $experiment->getId(), - VARIATION_ID => $variationId - ] - ]; - - $singleSnapshot[EVENTS] = [ - [ - ENTITY_ID => $eventEntity->getId(), - TIMESTAMP => time()*1000, - UUID => GeneratorUtils::getRandomUuid(), - KEY => $eventKey - ] + $decision = [ + CAMPAIGN_ID => $experiment->getLayerId(), + EXPERIMENT_ID => $experiment->getId(), + VARIATION_ID => $variationId ]; + $decisions [] = $decision; + } - if (!is_null($eventTags)) { - $revenue = EventTagUtils::getRevenueValue($eventTags, $this->_logger); - if (!is_null($revenue)) { - $singleSnapshot[EVENTS][0][EventTagUtils::REVENUE_EVENT_METRIC_NAME] = $revenue; - } + $eventDict = [ + ENTITY_ID => $eventEntity->getId(), + TIMESTAMP => time()*1000, + UUID => GeneratorUtils::getRandomUuid(), + KEY => $eventKey + ]; - $eventValue = EventTagUtils::getNumericValue($eventTags, $this->_logger); - if (!is_null($eventValue)) { - $singleSnapshot[EVENTS][0][EventTagUtils::NUMERIC_EVENT_METRIC_NAME] = $eventValue; - } + if (!is_null($eventTags)) { + $revenue = EventTagUtils::getRevenueValue($eventTags, $this->_logger); + if (!is_null($revenue)) { + $eventDict[EventTagUtils::REVENUE_EVENT_METRIC_NAME] = $revenue; + } - $singleSnapshot[EVENTS][0]['tags'] = $eventTags; + $eventValue = EventTagUtils::getNumericValue($eventTags, $this->_logger); + if (!is_null($eventValue)) { + $eventDict[EventTagUtils::NUMERIC_EVENT_METRIC_NAME] = $eventValue; } - $conversionParams [] = $singleSnapshot; + if(count($eventTags) > 0) { + $eventDict['tags'] = $eventTags; + } } + $singleSnapshot[DECISIONS] = $decisions; + $singleSnapshot[EVENTS] [] = $eventDict; + $conversionParams [] = $singleSnapshot; + return $conversionParams; } diff --git a/src/Optimizely/Exceptions/InvalidDatafileVersionException.php b/src/Optimizely/Exceptions/InvalidDatafileVersionException.php new file mode 100644 index 00000000..a5681b90 --- /dev/null +++ b/src/Optimizely/Exceptions/InvalidDatafileVersionException.php @@ -0,0 +1,22 @@ +_config = new ProjectConfig($datafile, $this->_logger, $this->_errorHandler); - } catch (Throwable $exception) { - $this->_isValid = false; - $this->_logger = new DefaultLogger(); - $this->_logger->log(Logger::ERROR, 'Provided "datafile" is in an invalid format.'); - return; } catch (Exception $exception) { $this->_isValid = false; - $this->_logger = new DefaultLogger(); - $this->_logger->log(Logger::ERROR, 'Provided "datafile" is in an invalid format.'); + $defaultLogger = new DefaultLogger(); + $errorMsg = $exception->getCode() == InvalidDatafileVersionException::class ? $exception->getMessage() : sprintf(Errors::INVALID_FORMAT, 'datafile'); + $errorToHandle = $exception->getCode() == InvalidDatafileVersionException::class ? new InvalidDatafileVersionException($errorMsg) : new InvalidInputException($errorMsg); + $defaultLogger->log(Logger::ERROR, $errorMsg); + $this->_logger->log(Logger::ERROR, $errorMsg); + $this->_errorHandler->handleError($errorToHandle); return; } @@ -728,4 +730,17 @@ public function getFeatureVariableString($featureFlagKey, $variableKey, $userId, return $variableValue; } + + /** + * Determine if the instance of the Optimizely client is valid. + * An instance can be deemed invalid if it was not initialized + * properly due to an invalid datafile being passed in. + * + * @return True if the Optimizely instance is valid. + * False if the Optimizely instance is not valid. + */ + public function isValid() + { + return $this->_isValid; + } } diff --git a/src/Optimizely/ProjectConfig.php b/src/Optimizely/ProjectConfig.php index 757b28c4..256da1c4 100644 --- a/src/Optimizely/ProjectConfig.php +++ b/src/Optimizely/ProjectConfig.php @@ -31,6 +31,7 @@ use Optimizely\ErrorHandler\ErrorHandlerInterface; use Optimizely\Exceptions\InvalidAttributeException; use Optimizely\Exceptions\InvalidAudienceException; +use Optimizely\Exceptions\InvalidDatafileVersionException; use Optimizely\Exceptions\InvalidEventException; use Optimizely\Exceptions\InvalidExperimentException; use Optimizely\Exceptions\InvalidFeatureFlagException; @@ -50,6 +51,9 @@ class ProjectConfig { const RESERVED_ATTRIBUTE_PREFIX = '$opt_'; + const V2 = '2'; + const V3 = '3'; + const V4 = '4'; /** * @var string Version of the datafile. @@ -185,10 +189,17 @@ class ProjectConfig */ public function __construct($datafile, $logger, $errorHandler) { + $supportedVersions = array(self::V2, self::V3, self::V4); $config = json_decode($datafile, true); $this->_logger = $logger; $this->_errorHandler = $errorHandler; $this->_version = $config['version']; + if(!in_array($this->_version, $supportedVersions)){ + throw new InvalidDatafileVersionException( + "This version of the PHP SDK does not support the given datafile version: {$this->_version}." + ); + } + $this->_accountId = $config['accountId']; $this->_projectId = $config['projectId']; $this->_anonymizeIP = isset($config['anonymizeIP'])? $config['anonymizeIP'] : false; diff --git a/src/Optimizely/Utils/Errors.php b/src/Optimizely/Utils/Errors.php new file mode 100644 index 00000000..ec0d1d33 --- /dev/null +++ b/src/Optimizely/Utils/Errors.php @@ -0,0 +1,21 @@ +areLogEventsEqual($expectedLogEvent, $logEvent); $this->assertTrue($result[0], $result[1]); } + + public function testCreateConversionEventWhenEventUsedInMultipleExp() + { + $eventTags = [ + 'revenue' => 4200, + 'value' => 1.234, + 'non-revenue' => 'abc' + ]; + $this->expectedEventParams['visitors'][0]['snapshots'][0]['decisions'][] = [ + 'campaign_id' => '4', + 'experiment_id' => '122230', + 'variation_id' => '122234' + ]; + + $this->expectedEventParams['visitors'][0]['snapshots'][0]['events'][0] = [ + 'entity_id' => '7718020065', + 'timestamp' => $this->timestamp, + 'uuid' => $this->uuid, + 'key' => 'multi_exp_event', + 'revenue' => 4200, + 'value' => 1.234, + 'tags' => $eventTags, + ]; + + array_unshift($this->expectedEventParams['visitors'][0]['attributes'], + [ + 'entity_id' => '7723280020', + 'key' => 'device_type', + 'type' => 'custom', + 'value' => 'iPhone' + ],[ + 'entity_id' => '7723340004', + 'key' => 'location', + 'type' => 'custom', + 'value' => 'SF' + ]); + + $decisions = [ + '7716830082' => '7721010009', + '122230' => '122234' + ]; + $userAttributes = [ + 'device_type' => 'iPhone', + 'location' => 'SF' + ]; + + $logEvent = $this->eventBuilder->createConversionEvent( + $this->config, + 'multi_exp_event', + $decisions, + $this->testUserId, + $userAttributes, + $eventTags + ); + $expectedLogEvent = new LogEvent( + $this->expectedEventUrl, + $this->expectedEventParams, + $this->expectedEventHttpVerb, + $this->expectedEventHeaders + ); + + $logEvent = $this->fakeParamsToReconcile($logEvent); + $result = $this->areLogEventsEqual($expectedLogEvent, $logEvent); + $this->assertTrue($result[0], $result[1]); + + } } diff --git a/tests/OptimizelyTest.php b/tests/OptimizelyTest.php index 077a34d2..c81515e1 100644 --- a/tests/OptimizelyTest.php +++ b/tests/OptimizelyTest.php @@ -23,7 +23,9 @@ use Optimizely\ErrorHandler\NoOpErrorHandler; use Optimizely\Event\LogEvent; use Optimizely\Exceptions\InvalidAttributeException; +use Optimizely\Exceptions\InvalidDatafileVersionException; use Optimizely\Exceptions\InvalidEventTagException; +use Optimizely\Exceptions\InvalidInputException; use Optimizely\Logger\NoOpLogger; use Optimizely\Notification\NotificationCenter; use Optimizely\Notification\NotificationType; @@ -74,6 +76,18 @@ public function setUp() ->getMock(); } + public function testIsValidForInvalidOptimizelyObject() + { + $optlyObject = new Optimizely('Random datafile'); + $this->assertFalse($optlyObject->isValid()); + } + + public function testIsValidForValidOptimizelyObject() + { + $optlyObject = new Optimizely($this->datafile); + $this->assertTrue($optlyObject->isValid()); + } + public function testInitValidEventDispatcher() { $validDispatcher = new ValidEventDispatcher(); @@ -134,6 +148,42 @@ public function testInitInvalidErrorHandler() $this->fail('Unexpected behavior. Invalid error handler went through.'); } + public function testInitUnSupportedDatafileVersion() + { + $errorHandlerMock = $this->getMockBuilder(NoOpErrorHandler::class) + ->setMethods(array('handleError')) + ->getMock(); + $errorHandlerMock->expects($this->once()) + ->method('handleError') + ->with(new InvalidDatafileVersionException('This version of the PHP SDK does not support the given datafile version: 5.')); + $optlyObject = new Optimizely( + UNSUPPORTED_DATAFILE, + null, + new DefaultLogger(Logger::INFO, self::OUTPUT_STREAM), + $errorHandlerMock, + true + ); + $this->expectOutputRegex("/This version of the PHP SDK does not support the given datafile version: 5./"); + } + + public function testInitDatafileInvalidFormat() + { + $errorHandlerMock = $this->getMockBuilder(NoOpErrorHandler::class) + ->setMethods(array('handleError')) + ->getMock(); + $errorHandlerMock->expects($this->once()) + ->method('handleError') + ->with(new InvalidInputException('Provided datafile is in an invalid format.')); + $optlyObject = new Optimizely( + '{"version": "2"}', + null, + new DefaultLogger(Logger::INFO, self::OUTPUT_STREAM), + $errorHandlerMock, + true + ); + $this->expectOutputRegex('/Provided datafile is in an invalid format./'); + } + public function testValidateDatafileInvalidFileJsonValidationNotSkipped() { $validateInputsMethod = new \ReflectionMethod('Optimizely\Optimizely', 'validateDatafile'); diff --git a/tests/ProjectConfigTest.php b/tests/ProjectConfigTest.php index b2f4cd89..377a49f2 100644 --- a/tests/ProjectConfigTest.php +++ b/tests/ProjectConfigTest.php @@ -32,6 +32,7 @@ use Optimizely\ErrorHandler\NoOpErrorHandler; use Optimizely\Exceptions\InvalidAttributeException; use Optimizely\Exceptions\InvalidAudienceException; +use Optimizely\Exceptions\InvalidDatafileVersionException; use Optimizely\Exceptions\InvalidEventException; use Optimizely\Exceptions\InvalidExperimentException; use Optimizely\Exceptions\InvalidFeatureFlagException; @@ -140,7 +141,8 @@ public function testInit() $this->assertEquals( [ 'purchase' => $this->config->getEvent('purchase'), - 'unlinked_event' => $this->config->getEvent('unlinked_event') + 'unlinked_event' => $this->config->getEvent('unlinked_event'), + 'multi_exp_event' => $this->config->getEvent('multi_exp_event') ], $eventKeyMap->getValue($this->config) ); @@ -327,6 +329,21 @@ public function testInit() $this->assertEquals($expectedVariation, $actualVariation); } + public function testExceptionThrownForUnsupportedVersion() + { + // Verify that an exception is thrown when given datafile version is unsupported // + $this->setExpectedException( + InvalidDatafileVersionException::class, + 'This version of the PHP SDK does not support the given datafile version: 5.' + ); + + $this->config = new ProjectConfig( + UNSUPPORTED_DATAFILE, + $this->loggerMock, + $this->errorHandlerMock + ); + } + public function testVariationParsingWithoutFeatureEnabledProp() { $variables = [ diff --git a/tests/TestData.php b/tests/TestData.php index 8ebabe60..da38fb69 100644 --- a/tests/TestData.php +++ b/tests/TestData.php @@ -483,6 +483,14 @@ "experimentIds": [], "id": "7718020064", "key": "unlinked_event" + }, + { + "experimentIds":[ + "7716830082", + "122230" + ], + "id": "7718020065", + "key": "multi_exp_event" } ], "anonymizeIP": false, @@ -765,6 +773,60 @@ }' ); +define( + 'UNSUPPORTED_DATAFILE', + '{ + "version": "5", + "rollouts": [], + "anonymizeIP": true, + "projectId": "10431130345", + "variables": [], + "featureFlags": [], + "experiments": [ + { + "status": "Running", + "key": "ab_running_exp_untargeted", + "layerId": "10417730432", + "trafficAllocation": [ + { + "entityId": "10418551353", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "variations": [ + { + "variables": [], + "id": "10418551353", + "key": "all_traffic_variation" + }, + { + "variables": [], + "id": "10418510624", + "key": "no_traffic_variation" + } + ], + "forcedVariations": {}, + "id": "10420810910" + } + ], + "audiences": [], + "groups": [], + "attributes": [], + "accountId": "10367498574", + "events": [ + { + "experimentIds": [ + "10420810910" + ], + "id": "10404198134", + "key": "winning" + } + ], + "revision": "1337" + }' +); + /** * Class TestBucketer * Extending Bucketer for the sake of tests.