diff --git a/php-server-sdk-shared-tests/.circleci/config.yml b/php-server-sdk-shared-tests/.circleci/config.yml new file mode 100644 index 0000000..90e20f1 --- /dev/null +++ b/php-server-sdk-shared-tests/.circleci/config.yml @@ -0,0 +1,37 @@ +version: 2.1 + +workflows: + workflow: + jobs: + - linux-test: + name: PHP 7.3 + docker-image: cimg/php:7.3 + - linux-test: + name: PHP 7.4 + docker-image: cimg/php:7.4 + - linux-test: + name: PHP 8.0 + docker-image: cimg/php:8.0 + +jobs: + linux-test: + parameters: + docker-image: + type: string + + docker: + - image: <> + + steps: + - checkout + - run: + name: install dependencies + command: composer install --no-progress + - run: mkdir -p ./phpunit + - run: + name: run tests + command: php vendor/bin/phpunit + - store_test_results: + path: ./phpunit + - store_artifacts: + path: ./phpunit diff --git a/php-server-sdk-shared-tests/.gitignore b/php-server-sdk-shared-tests/.gitignore new file mode 100644 index 0000000..6de1c14 --- /dev/null +++ b/php-server-sdk-shared-tests/.gitignore @@ -0,0 +1,4 @@ +vendor/ +composer.lock +phpunit/ +.phpunit.result.cache diff --git a/php-server-sdk-shared-tests/LICENSE.txt b/php-server-sdk-shared-tests/LICENSE.txt new file mode 100644 index 0000000..c27e062 --- /dev/null +++ b/php-server-sdk-shared-tests/LICENSE.txt @@ -0,0 +1,13 @@ +Copyright 2021 Catamorphic, Co. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/php-server-sdk-shared-tests/README.md b/php-server-sdk-shared-tests/README.md new file mode 100644 index 0000000..bdc7071 --- /dev/null +++ b/php-server-sdk-shared-tests/README.md @@ -0,0 +1,31 @@ +# LaunchDarkly Server-Side PHP SDK Shared Test Code + +[![CircleCI](https://circleci.com/gh/launchdarkly/php-server-sdk-shared-tests.svg?style=svg)](https://circleci.com/gh/launchdarkly/php-server-sdk-shared-tests) + +This project provides support code for testing LaunchDarkly PHP SDK integrations. Feature store implementations, etc., should use this code whenever possible to ensure consistent test coverage and avoid repetition. An example of a project using this code is [php-server-sdk-redis](https://github.com/launchdarkly/php-server-sdk-redis). + +The code is not published to Packagist, since it isn't of any use in any non-test context. Instead, it's meant to be used as a Git subtree. Add the subtree to your project like this: + + git remote add php-server-sdk-shared-tests git@github.com:launchdarkly/php-server-sdk-shared-tests.git + git subtree add --squash --prefix=php-server-sdk-shared-tests/ php-server-sdk-shared-tests master + +Then, in your project that uses the shared tests, add this to `composer.json`: + +```json + "repositories": [ + { + "type": "path", + "url": "php-server-sdk-shared-tests" + }, + ], +``` + +And add this dependency: + +```json + "launchdarkly/server-sdk-shared-tests": "dev-master" +``` + +To update the copy of `php-server-sdk-shared-tests` in your repository to reflect changes in this one: + + git subtree pull --squash --prefix=php-server-sdk-shared-tests/ php-server-sdk-shared-tests master diff --git a/php-server-sdk-shared-tests/composer.json b/php-server-sdk-shared-tests/composer.json new file mode 100644 index 0000000..8d407fd --- /dev/null +++ b/php-server-sdk-shared-tests/composer.json @@ -0,0 +1,34 @@ +{ + "name": "launchdarkly/server-sdk-shared-tests", + "description": "Shared unit test code for LaunchDarkly PHP SDK", + "license": "Apache-2.0", + "authors": [ + { + "name": "LaunchDarkly ", + "homepage": "http://launchdarkly.com/" + } + ], + "require": { + "php": ">=7.3" + }, + "require-dev": { + "launchdarkly/server-sdk": "4.*", + "phpunit/phpunit": "^9" + }, + "autoload": { + "psr-4": { + "LaunchDarkly\\SharedTest\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "LaunchDarkly\\SharedTest\\Tests\\": "tests/" + } + }, + "config": { + "sort-packages": true + }, + "scripts": { + "cs": "vendor/bin/php-cs-fixer fix --diff --verbose" + } +} diff --git a/php-server-sdk-shared-tests/phpunit.xml b/php-server-sdk-shared-tests/phpunit.xml new file mode 100644 index 0000000..9ca7909 --- /dev/null +++ b/php-server-sdk-shared-tests/phpunit.xml @@ -0,0 +1,13 @@ + + + + + + + + + + tests + + + diff --git a/php-server-sdk-shared-tests/src/DatabaseFeatureRequesterTestBase.php b/php-server-sdk-shared-tests/src/DatabaseFeatureRequesterTestBase.php new file mode 100644 index 0000000..bb3a65f --- /dev/null +++ b/php-server-sdk-shared-tests/src/DatabaseFeatureRequesterTestBase.php @@ -0,0 +1,300 @@ +clearExistingData($prefix); + $fr = $this->makeRequester($prefix); + + $flagKey = 'foo'; + $flagVersion = 10; + $flagJson = self::makeFlagJson($flagKey, $flagVersion); + $this->putSerializedItem($prefix, 'features', $flagKey, $flagVersion, $flagJson); + + $fr = $this->makeRequester($prefix); + $flag = $fr->getFeature($flagKey); + + $this->assertInstanceOf(FeatureFlag::class, $flag); + $this->assertEquals($flagVersion, $flag->getVersion()); + } + + /** + * @dataProvider prefixParameters + */ + public function testGetMissingFeature(?string $prefix) + { + $this->clearExistingData($prefix); + $fr = $this->makeRequester($prefix); + + $flag = $fr->getFeature('unavailable'); + $this->assertNull($flag); + } + + /** + * @dataProvider prefixParameters + */ + public function testGetDeletedFeature(?string $prefix) + { + $this->clearExistingData($prefix); + $fr = $this->makeRequester($prefix); + + $flagKey = 'foo'; + $flagVersion = 10; + $flagJson = self::makeFlagJson($flagKey, $flagVersion, true); + $this->putSerializedItem($prefix, 'features', $flagKey, $flagVersion, $flagJson); + + $flag = $fr->getFeature($flagKey); + + $this->assertNull($flag); + } + + /** + * @dataProvider prefixParameters + */ + public function testGetAllFeatures(?string $prefix) + { + $this->clearExistingData($prefix); + $fr = $this->makeRequester($prefix); + + $flagKey1 = 'foo'; + $flagKey2 = 'bar'; + $flagKey3 = 'deleted'; + $flagVersion = 10; + $flagJson1 = self::makeFlagJson($flagKey1, $flagVersion); + $flagJson2 = self::makeFlagJson($flagKey2, $flagVersion); + $flagJson3 = self::makeFlagJson($flagKey3, $flagVersion, true); + + $this->putSerializedItem($prefix, 'features', $flagKey1, $flagVersion, $flagJson1); + $this->putSerializedItem($prefix, 'features', $flagKey2, $flagVersion, $flagJson2); + $this->putSerializedItem($prefix, 'features', $flagKey3, $flagVersion, $flagJson3); + + $flags = $fr->getAllFeatures(); + + $this->assertEquals(2, count($flags)); + $flag1 = $flags[$flagKey1]; + $this->assertEquals($flagKey1, $flag1->getKey()); + $this->assertEquals($flagVersion, $flag1->getVersion()); + $flag2 = $flags[$flagKey2]; + $this->assertEquals($flagKey2, $flag2->getKey()); + $this->assertEquals($flagVersion, $flag2->getVersion()); + } + + /** + * @dataProvider prefixParameters + */ + public function testAllFeaturesWithEmptyStore(?string $prefix) + { + $this->clearExistingData($prefix); + $fr = $this->makeRequester($prefix); + + $flags = $fr->getAllFeatures(); + $this->assertEquals(array(), $flags); + } + + /** + * @dataProvider prefixParameters + */ + public function testGetSegment(?string $prefix) + { + $this->clearExistingData($prefix); + $fr = $this->makeRequester($prefix); + + $segKey = 'foo'; + $segVersion = 10; + $segJson = self::makeSegmentJson($segKey, $segVersion); + $this->putSerializedItem($prefix, 'segments', $segKey, $segVersion, $segJson); + + $segment = $fr->getSegment($segKey); + + $this->assertInstanceOf(Segment::class, $segment); + $this->assertEquals($segVersion, $segment->getVersion()); + } + + /** + * @dataProvider prefixParameters + */ + public function testGetMissingSegment(?string $prefix) + { + $this->clearExistingData($prefix); + $fr = $this->makeRequester($prefix); + + $segment = $fr->getSegment('unavailable'); + $this->assertNull($segment); + } + + /** + * @dataProvider prefixParameters + */ + public function testGetDeletedSegment(?string $prefix) + { + $this->clearExistingData($prefix); + $fr = $this->makeRequester($prefix); + + $segKey = 'foo'; + $segVersion = 10; + $segJson = self::makeSegmentJson($segKey, $segVersion, true); + $this->putSerializedItem($prefix, 'segments', $segKey, $segVersion, $segJson); + + $segment = $fr->getSegment($segKey); + + $this->assertNull($segment); + } + + public function testPrefixIndependence() + { + $prefix1 = 'prefix1'; + $prefix2 = 'prefix2'; + + $this->clearExistingData(null); + $this->clearExistingData($prefix1); + $this->clearExistingData($prefix2); + + $flagKey = 'my-flag'; + $segmentKey = 'my-segment'; + $version0 = 10; + $version1 = 11; + $version2 = 12; + $this->setupForPrefix(null, $flagKey, $segmentKey, $version0); + $this->setupForPrefix($prefix1, $flagKey, $segmentKey, $version1); + $this->setupForPrefix($prefix2, $flagKey, $segmentKey, $version2); + + $this->verifyForPrefix($this->makeRequester(null), $flagKey, $segmentKey, $version0); + $this->verifyForPrefix($this->makeRequester(''), $flagKey, $segmentKey, $version0); + $this->verifyForPrefix($this->makeRequester($prefix1), $flagKey, $segmentKey, $version1); + $this->verifyForPrefix($this->makeRequester($prefix2), $flagKey, $segmentKey, $version2); + } + + private function setupForPrefix(?string $prefix, string $flagKey, string $segmentKey, int $flagVersion) + { + $segmentVersion = $flagVersion * 2; + $this->putSerializedItem($prefix, 'features', $flagKey, $flagVersion, + self::makeFlagJson($flagKey, $flagVersion)); + $this->putSerializedItem($prefix, 'segments', $segmentKey, $segmentVersion, + self::makeSegmentJson($flagKey, $segmentVersion)); + } + + private function verifyForPrefix(FeatureRequester $fr, string $flagKey, string $segmentKey, int $flagVersion) + { + $segmentVersion = $flagVersion * 2; + + $flag = $fr->getFeature($flagKey); + $this->assertNotNull($flag); + $this->assertEquals($flagVersion, $flag->getVersion()); + + $flags = $fr->getAllFeatures(); + $this->assertEquals(1, count($flags)); + $this->assertEquals($flagVersion, $flags[$flagKey]->getVersion()); + + $segment = $fr->getSegment($segmentKey); + $this->assertNotNull($segment); + $this->assertEquals($segmentVersion, $segment->getVersion()); + } + + public function prefixParameters() + { + return [ + [ self::TEST_PREFIX ], + [ '' ], + [ null ] + ]; + } + + private static function makeFlagJson(string $key, int $version, bool $deleted = false) + { + return json_encode(array( + 'key' => $key, + 'version' => $version, + 'on' => true, + 'prerequisites' => [], + 'salt' => '', + 'targets' => [], + 'rules' => [], + 'fallthrough' => [ + 'variation' => 0, + ], + 'offVariation' => null, + 'variations' => [ + true, + false, + ], + 'deleted' => $deleted + )); + } + + private static function makeSegmentJson(string $key, int $version, bool $deleted = false) + { + return json_encode(array( + 'key' => $key, + 'version' => $version, + 'included' => array(), + 'excluded' => array(), + 'rules' => [], + 'salt' => '', + 'deleted' => $deleted + )); + } +} + +?> \ No newline at end of file diff --git a/php-server-sdk-shared-tests/tests/DatabaseFeatureRequesterTestBaseTest.php b/php-server-sdk-shared-tests/tests/DatabaseFeatureRequesterTestBaseTest.php new file mode 100644 index 0000000..96a75ad --- /dev/null +++ b/php-server-sdk-shared-tests/tests/DatabaseFeatureRequesterTestBaseTest.php @@ -0,0 +1,123 @@ + $json) { + $itemsOut[$key] = json_decode($json, true); + } + return $itemsOut; + } + + public static function putSerializedItem(string $prefix, string $namespace, string $key, string $json): void + { + if (!isset(self::$data[$prefix])) { + self::$data[$prefix] = []; + } + if (!isset(self::$data[$prefix][$namespace])) { + self::$data[$prefix][$namespace] = []; + } + self::$data[$prefix][$namespace][$key] = $json; + } +} + +class FakeDatabaseFeatureRequester implements \LaunchDarkly\FeatureRequester +{ + private $prefix; + + public function __construct($prefix) + { + $this->prefix = $prefix; + } + + public function getFeature(string $key): ?FeatureFlag + { + $json = FakeDatabase::getItem($this->prefix, 'features', $key); + if ($json) { + $flag = FeatureFlag::decode($json); + return $flag->isDeleted() ? null : $flag; + } + return null; + } + + public function getSegment(string $key): ?Segment + { + $json = FakeDatabase::getItem($this->prefix, 'segments', $key); + if ($json) { + $segment = Segment::decode($json); + return $segment->isDeleted() ? null : $segment; + } + return null; + } + + public function getAllFeatures(): array + { + $jsonList = FakeDatabase::getAllItems($this->prefix, 'features'); + $itemsOut = []; + foreach ($jsonList as $json) { + $flag = FeatureFlag::decode($json); + if ($flag && !$flag->isDeleted()) { + $itemsOut[$flag->getKey()] = $flag; + } + } + return $itemsOut; + } +} + +class DatabaseFeatureRequesterTestBaseTest extends DatabaseFeatureRequesterTestBase +{ + const DEFAULT_PREFIX = 'defaultprefix'; + + protected function clearExistingData(?string $prefix): void + { + FakeDatabase::$data[$this->actualPrefix($prefix)] = [ 'features' => [], 'segments' => [] ]; + } + + protected function makeRequester(?string $prefix): FeatureRequester + { + return new FakeDatabaseFeatureRequester($this->actualPrefix($prefix)); + } + + protected function putSerializedItem( + ?string $prefix, + string $namespace, + string $key, + int $version, + string $json): void + { + FakeDatabase::putSerializedItem($this->actualPrefix($prefix), $namespace, $key, $json); + } + + private function actualPrefix(?string $prefix): string + { + return ($prefix === null || $prefix === '') ? self::DEFAULT_PREFIX : $prefix; + } +} + +?> \ No newline at end of file