diff --git a/.gitignore b/.gitignore index 35aa62f..4711b72 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /vendor /.idea +/build .phpunit.result.cache phpunit.xml diff --git a/README.md b/README.md index 2ecb479..2c62658 100644 --- a/README.md +++ b/README.md @@ -23,84 +23,96 @@ Documentation for configuration can be found in [config/unleash.php](https://git ## Usage -```php -use \MikeFrancis\LaravelUnleash\Unleash; - -$unleash = app(Unleash::class); +This package provides a `Unleash` facade for quick and easy usage. Alternatively, you can use the aliased `Feature` facade instead. -if ($unleash->isFeatureEnabled('myAwesomeFeature')) { +```php +if (\Unleash::enabled('myAwesomeFeature')) { // Congratulations, you can see this awesome feature! } -if ($unleash->isFeatureDisabled('myAwesomeFeature')) { +if (\Unleash::disabled('myAwesomeFeature')) { // Check back later for more features! } -$feature = $unleash->getFeature('myAwesomeFeature'); +$feature = \Unleash::get('myAwesomeFeature'); -$allFeatures = $unleash->getFeatures(); +$allFeatures = \Unleash::all(); ``` -### Facades +### Unleash Client Consistency -You can use the `Unleash` facade: +Both the `Unleash` and `Feature` facade support methods consistent with standard Unleash clients: ```php -use Unleash; - -if (Unleash::isFeatureEnabled('myAwesomeFeature')) { +if (\Unleash::isFeatureEnabled('myAwesomeFeature')) { // Congratulations, you can see this awesome feature! } -if (Unleash::isFeatureDisabled('myAwesomeFeature')) { +if (\Unleash::isFeatureDisabled('myAwesomeFeature')) { // Check back later for more features! } -$feature = Unleash::getFeature('myAwesomeFeature'); +$feature = \Unleash::getFeature('myAwesomeFeature'); -$allFeatures = Unleash::getFeatures(); +$allFeatures = \Unleash::getFeatures(); ``` -or use the generically named `Feature` facade: +## Strategies -```php -use Feature; +To enable or disable strategies, add or remove from `unleash.strategies` config in your `unleash.php` config file. -if (Feature::enabled('myAwesomeFeature')) { - // Congratulations, you can see this awesome feature! -} +### Custom Strategies -if (Feature::disabled('myAwesomeFeature')) { - // Check back later for more features! -} +Custom strategies must implement `\MikeFrancis\LaravelUnleash\Strategies\Contracts\Strategy` or if your strategy relies on dynamic data at runtime it should implement `\MikeFrancis\LaravelUnleash\Strategies\Contracts\DynamicStrategy`. -$feature = Feature::get('myAwesomeFeature'); +```php +use \MikeFrancis\LaravelUnleash\Strategies\Contracts\Strategy; +use \Illuminate\Http\Request; -$allFeatures = Feature::all(); +class CustomStrategy implements Strategy { + public function isEnabled(array $params, Request $request) : bool { + // logic here + return true || false; + } +} ``` -### Dynamic Arguments +### Dynamic Strategies -If your strategy relies on dynamic data at runtime, you can pass additional arguments to the feature check functions: +When implementing `DynamicStrategy` you can pass additional arguments to the feature check functions which will be passed as extra arguments to the `isEnabled()` method: ```php -use \MikeFrancis\LaravelUnleash\Unleash; -use Config; - -$unleash = app(Unleash::class); - $allowList = config('app.allow_list'); -if ($unleash->isFeatureEnabled('myAwesomeFeature', $allowList)) { +if (Unleash::enabled('myAwesomeFeature', $allowList)) { // Congratulations, you can see this awesome feature! } -if ($unleash->isFeatureDisabled('myAwesomeFeature', $allowList)) { +if (Unleash::disabled('myAwesomeFeature', $allowList)) { // Check back later for more features! } ``` -### Blade +## Variants + +To use variant support, define your variants on the feature and use: + +```php +$color = \Unleash::variant('title-color', '#000')->payload->value; +``` + +This will return the correct variant for the user, or the default if the feature flag is disabled or no valid variant is found. + +The variant payload will be one of the following, depending on the variant type: + +- `\MikeFrancis\LaravelUnleash\Values\Variant\PayloadCSV` +- `\MikeFrancis\LaravelUnleash\Values\Variant\PayloadJSON` +- `\MikeFrancis\LaravelUnleash\Values\Variant\PayloadString` +- `\MikeFrancis\LaravelUnleash\Values\Variant\PayloadDefault` — when no variant is found and the default is used instead + +> **Note:** You _can_ combine variants with strategies. + +## Blade Templates Blade directive for checking if a feature is **enabled**: @@ -118,9 +130,9 @@ Check back later for more features! @endfeatureDisabled ``` -You cannot currently use dynamic strategy arguments with Blade template directives. +> **Note:** You cannot currently use dynamic strategy arguments with Blade template directives. -### Middleware +## Middleware This package includes middleware that will deny routes depending on whether a feature is enabled or not. @@ -160,4 +172,115 @@ class ExampleController extends Controller } ``` -You cannot currently use dynamic strategy arguments with Middleware. \ No newline at end of file +> **Note:** You cannot currently use dynamic strategy arguments with Middleware. + +## Mocking + +If you are writing tests against code utilizing feature flags you can mock feature flags using the `Unleash::fake()` method. + +As with Unleash itself, the default behavior is for all flags to be considered disabled: + +```php +\Unleash::fake(); + +$this->assertFalse(\Unleash::enabled('any-flag')); +``` + +To consider all flags enabled, you can set the default to `true` using `withDefaultStatus()`: + +```php +\Unleash::fake()->withDefaultStatus(true); + +$this->assertTrue(\Unleash::enabled('any-flag')); +``` + +You may also dynamically return the default status using `withDefaultUsing()`: + +```php +\Unleash::fake()->withDefaultStatusUsing(function($flagName, $flagStatus, ... $args) { + return !$args[0]; +}); + +$this->assertTrue(\Unleash::enabled('any-flag', false)); + +$this->assertFalse(\Unleash::enabled('any-flag', true)); +``` + +Additionaly, there are several ways to set specific Feature Flags to enabled. + +To always enable one or more feature flags, you can pass in an array of flag names: + +```php +\Unleash::fake(['flag-name-to-enable', 'another-enabled-flag']); + +$this->assertTrue(\Unleash::enabled('flag-name-to-enable')); +$this->assertTrue(\Unleash::enabled('another-enabled-flag')); + +$this->assertFalse(\Unleash::enabled('an-unknown-flag')); +``` + +> **Note:** You can call `Unleash::fake()` multiple times in a single test to set additional flags + +Alternatively, for more advanced scenarios, you can pass in a variable number of `\MikeFrancis\LaravelUnleash\Values\FeatureFlag` instances. + +If you wish to only enable the feature with the correct `DynamicStrategy` arguments without executing the strategy, you can use `withTestArgs()`: + +```php +use \MikeFrancis\LaravelUnleash\Values\FeatureFlag; + +\Unleash::fake( + (new FeatureFlag('flag-name', true))->withTestArgs(1, 2, 3); +) + +$this->assertTrue(\Unleash::enabled('flag-name', 1, 2, 3)); + +$this->assertFalse(\Unleash::enabled('flag-name')); +$this->assertFalse(\Unleash::enabled('flag-name', 2, 4, 6)); +``` + +If you need to validate the arguments dynamically, you can instead use `withTestArgsUsing()` which takes a callback that returns a boolean on whether the arguments are accepted or not: + +```php +use \MikeFrancis\LaravelUnleash\Values\FeatureFlag; + +\Unleash::fake( + (new FeatureFlag('flag-name', true))->withTestArgsUsing(function(int $int) { + return $int % 2 === 0; + }); +) + +$this->assertTrue(\Unleash::enabled('flag-name', 2)); +$this->assertTrue(\Unleash::enabled('flag-name', 4)); + +$this->assertFalse(\Unleash::enabled('flag-name')); +$this->assertFalse(\Unleash::enabled('flag-name', 1)); +$this->assertFalse(\Unleash::enabled('flag-name', 3)); +``` + +One final option is the `withTestArgsAny()` which will allow any arguments. This is an alias for the following: + +```php +use \MikeFrancis\LaravelUnleash\Values\FeatureFlag; + +\Unleash::fake( + (new FeatureFlag('flag-name', true))->withTestArgsUsing(function() { + return true; + }); +) +``` + +We recommend using the `Unleash::fake(['flag-name'])` option instead. + +Lastly, you may pass in both Strategies and Variants to the `FeatureFlag` and both will execute as normal: + +```php +use \MikeFrancis\LaravelUnleash\Values\FeatureFlag; + +\Unleash::fake( + (new FeatureFlag('flag-name', true, '', 'default', false, 'release', [ + 'myStrategy' => MyStrategyClass::class, + ]))->withTestArgsUsing(function() { + return true; + }); +) +``` \ No newline at end of file diff --git a/composer.json b/composer.json index 44d9628..3452405 100644 --- a/composer.json +++ b/composer.json @@ -3,14 +3,19 @@ "description": "An Unleash client for Laravel", "type": "library", "require": { + "php": "^8.1|8.2|8.3", + "ext-json": "*", "guzzlehttp/guzzle": "^6.3|^7.0", - "illuminate/support": "^5.8|^6|^7|^8", - "illuminate/http": "^5.8|^6|^7|^8", - "illuminate/contracts": "^5.8|^6|^7|^8" + "illuminate/support": "^5.8|^6|^7|^8|^9|^10|^11", + "illuminate/config": "^5.8|^6|^7|^8|^9|^10|^11", + "illuminate/http": "^5.8|^6|^7|^8|^9|^10|^11", + "illuminate/contracts": "^5.8|^6|^7|^8|^9|^10|^11", + "lastguest/murmurhash": "^2.1" }, "require-dev": { - "phpunit/phpunit": "^8.3", - "squizlabs/php_codesniffer": "^3.5" + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3.5", + "orchestra/testbench": "^v7.19.0" }, "autoload": { "psr-4": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 211562a..e86a37c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,24 +1,16 @@ - - - - ./tests - - - - - ./src - - ./src/ServiceProvider.php - - - + + + + ./src + + + ./src/ServiceProvider.php + + + + + ./tests + + diff --git a/src/Exception/UnknownVariantTypeException.php b/src/Exception/UnknownVariantTypeException.php new file mode 100644 index 0000000..2e37295 --- /dev/null +++ b/src/Exception/UnknownVariantTypeException.php @@ -0,0 +1,7 @@ +fake(... $features); + return static::getFacadeRoot(); + } + + $fake = new UnleashFake(static::getFacadeRoot(), ... $features); + static::swap($fake); + return $fake; + } + protected static function getFacadeAccessor() { return 'unleash'; diff --git a/src/ReadonlyArray.php b/src/ReadonlyArray.php new file mode 100644 index 0000000..ba20a3c --- /dev/null +++ b/src/ReadonlyArray.php @@ -0,0 +1,17 @@ + $client]); - return $unleash->isFeatureEnabled($feature); + return $unleash->enabled($feature); } ); @@ -53,7 +51,7 @@ function (string $feature) { $client = app(Client::class); $unleash = app(Unleash::class, ['client' => $client]); - return !$unleash->isFeatureEnabled($feature); + return !$unleash->enabled($feature); } ); } diff --git a/src/Strategies/Contracts/DynamicStrategy.php b/src/Strategies/Contracts/DynamicStrategy.php index 0e8150e..f3f7d58 100644 --- a/src/Strategies/Contracts/DynamicStrategy.php +++ b/src/Strategies/Contracts/DynamicStrategy.php @@ -9,7 +9,7 @@ interface DynamicStrategy /** * @param array $params Strategy Configuration from Unleash * @param Request $request Current Request - * @param mixed $args An arbitrary number of arguments passed to isFeatureEnabled/Disabled + * @param mixed $args An arbitrary number of arguments passed to FeatureFlag::enabled/disabled * @return bool */ public function isEnabled(array $params, Request $request, ...$args): bool; diff --git a/src/Testing/Fakes/UnleashFake.php b/src/Testing/Fakes/UnleashFake.php new file mode 100644 index 0000000..b06e72e --- /dev/null +++ b/src/Testing/Fakes/UnleashFake.php @@ -0,0 +1,295 @@ +unleash = $unleash; + $this->fakeFeatures = $this->getFakeFeatures($features); + + $this->calls = [ + 'enabled' => collect(), + 'disabled' => collect(), + 'get' => collect(), + 'all' => collect(), + ]; + } + + public function enabled($name, ... $args) + { + $this->calls['enabled'][$name] = Collection::wrap($this->calls['enabled'][$name] ?? [])->add($name); + return $this->_enabled($name, $args); + } + + public function isFeatureEnabled($name, ... $args) + { + return $this->enabled($name, ... $args); + } + + public function disabled($name, ... $args) + { + $this->calls['disabled'][$name] = Collection::wrap($this->calls['disabled'][$name] ?? [])->add($name); + return !$this->_enabled($name, $args); + } + + public function isFeatureDisabled($name, ... $args) + { + return $this->disabled($name, ... $args); + } + + public function get($name) + { + $this->calls['get'][$name] = Collection::wrap($this->calls['get'][$name] ?? [])->add($name); + return $this->getFakeFeature($name); + } + + public function getFeature($name) + { + return $this->get($name); + } + + public function all() + { + $this->calls['all']->add(true); + + $features = $this->unleash->getFeatures(); + + $fakeFeatures = new Collection(); + $this->fakeFeatures->map(function($item) use ($fakeFeatures) { + $name = $item->name; + if (!$fakeFeatures->contains(function($item) use($name) { + return $item->name === $name; + })) { + $fakeFeatures->push($item); + } + }); + + return $features->merge($fakeFeatures); + } + + public function getFeatures() + { + return $this->all(); + } + + public function fake(... $features) + { + $this->fakeFeatures = $this->fakeFeatures->concat($this->getFakeFeatures($features) ?? []); + } + + public function withDefaultStatus(bool $status = false) + { + $this->defaultStatus = $status; + return $this; + } + + public function withDefaultStatusUsing(callable $callable) + { + $this->defaultStatusUsing = $callable; + + return $this; + } + + public function assertCalledFeatureEnabled(string $name) + { + PHPUnit::assertTrue($this->calls['enabled']->has($name), sprintf('Feature flag enabled called for %s 0 times, expected at least 1', $name)); + } + + public function assertCalledFeatureDisabled(string $name) + { + PHPUnit::assertTrue($this->calls['disabled']->has($name), sprintf('Feature flag disabled called for %s 0 times, expected at least 1', $name)); + } + + public function assertCalledFeatureGet(string $name) + { + PHPUnit::assertTrue($this->calls['get']->has($name), sprintf('Feature flag get called for %s 0 times, expected at least 1', $name)); + } + + public function assertCalledFeatureAll() + { + PHPUnit::assertTrue($this->calls['all']->isNotEmpty(), 'Get all feature flags called 0 times, expected at least 1'); + } + + public function assertCalledFeatureEnabledTimes(string $name, int $times) + { + $calls = 0; + if ($this->calls['enabled']->has($name)) { + $calls = $this->calls['enabled'][$name]->count(); + } + + PHPUnit::assertEquals($times, $calls, sprintf('Feature flag enabled called for %s %d times, expected %d', $name, $calls, $times)); + } + + public function assertCalledFeatureDisabledTimes(string $name, int $times) + { + $calls = 0; + if ($this->calls['disabled']->has($name)) { + $calls = $this->calls['disabled'][$name]->count(); + } + + PHPUnit::assertEquals($times, $calls, sprintf('Feature flag disabled called for %s %d times, expected %d', $name, $calls, $times)); + } + + public function assertCalledFeatureGetTimes(string $name, int $times) + { + $calls = 0; + if ($this->calls['get']->has($name)) { + $calls = $this->calls['get'][$name]->count(); + } + + PHPUnit::assertEquals($times, $calls, sprintf('Feature flag get called for %s %d times, expected %d', $name, $calls, $times)); + } + + public function assertCalledFeatureAllTimes(int $times) + { + $calls = 0; + $calls = $this->calls['all']->count(); + + PHPUnit::assertEquals($times, $calls, sprintf('Get all feature flags called %d times, expected %d', $calls, $times)); + } + + public function assertNotCalledFeatureEnabled(string $name) + { + PHPUnit::assertFalse($this->calls['enabled']->has($name), sprintf('Feature flag enabled called for %s %d times, expected 0', $name, $this->calls['enabled']->get($name, Collection::empty())->count())); + } + + public function assertNotCalledFeatureDisabled(string $name) + { + PHPUnit::assertFalse($this->calls['disabled']->has($name), sprintf('Feature flag disabled called for %s %d times, expected 0', $name, $this->calls['disabled']->get($name, Collection::empty())->count())); + } + + public function assertNotCalledFeatureGet(string $name) + { + PHPUnit::assertFalse($this->calls['get']->has($name), sprintf('Feature flag get called for %s %d times, expected 0', $name, $this->calls['get']->get($name, Collection::empty())->count())); + } + + public function assertNotCalledFeatureAll() + { + PHPUnit::assertTrue($this->calls['all']->isEmpty(), sprintf('Get all feature flags called %d times, expected 0', $this->calls['all']->count())); + } + + public function __call($method, $args) + { + return $this->unleash->$method(... $args); + } + + protected function getFakeFeature($feature, ?array $args = []) + { + if ($this->fakeFeatures->isEmpty()) { + return new FeatureFlag($feature, $this->getDefaultStatus($feature, $this->defaultStatus, ... $args)); + } + + $featureFound = $this->fakeFeatures->first(function($item) use ($feature, $args) { + if ($item->name === $feature) { + $testUsing = $item->testArgsUsing; + return $item->testArgs === $args || is_callable($testUsing); + } + return false; + }, false); + + if ($featureFound !== false) { + if (!is_callable($featureFound->testArgsUsing)) { + return $featureFound; + } + $testUsing = $featureFound->testArgsUsing; + if ($testUsing(... $args)) { + return $featureFound; + } + $status = $this->defaultStatus; + if (is_callable($this->defaultStatusUsing)) { + $using = $this->defaultStatusUsing; + $status = $using($feature, $this->defaultStatus, ... $args); + } + return new FeatureFlag($feature, $this->getDefaultStatus($feature, $status, ... $args)); + } + + $featureFound = $this->fakeFeatures->first(function($item) use ($feature, $args) { + return $item->name === $feature; + }, false); + + if ($featureFound === false) { + $featureFound = new FeatureFlag($feature, $this->getDefaultStatus($feature, $this->defaultStatus, ... $args)); + } + + $flag = new FeatureFlag( + $featureFound->name, + $this->getDefaultStatus($featureFound->name, $featureFound->enabled, ... $args), + $featureFound->description, + $featureFound->project, + $featureFound->stale, + $featureFound->type, + $featureFound->strategies->toArray(), + $featureFound->variants->toArray() + ); + if ($featureFound ->testArgs !== null) { + $flag->withTestArgs(... $featureFound->testArgs); + } + return $flag; + } + + protected function getDefaultStatus($feature, $status, ... $args) { + if (is_callable($this->defaultStatusUsing)) { + $statusUsing = $this->defaultStatusUsing; + return $statusUsing($feature, $status, ... $args); + } + + return $status ?? false; + } + + protected function _enabled($name, array $args): bool + { + $flag = $this->getFakeFeature($name, $args); + $status = $flag->enabled(... $args); + if (!is_callable($flag->testArgsUsing) && $flag->testArgs !== null && $flag->testArgs !== $args) { + $status = $this->getDefaultStatus($name, $this->defaultStatus, ... $args); + } + + return $status === true; + } + + protected function getFakeFeatures(array $features): FeatureFlagCollection + { + if (!isset($features[0])) { + return FeatureFlagCollection::empty(); + } + + if ($features[0] instanceof FeatureFlag) { + $features = FeatureFlagCollection::wrap($features); + } elseif ($features[0] instanceof FeatureFlagCollection) { + $features = $features[0]; + } elseif (is_array($features[0]) && is_string($features[0][0])) { + $collection = new FeatureFlagCollection(); + foreach ($features[0] as $feature) { + $collection->add((new FeatureFlag($feature, true))->withTestArgsAny()); + } + $features = $collection; + } else { + throw new \RuntimeException("Unknown feature values. Features must be a list of FeatureFlags, a FeatureFlagCollection, or an array of feature flag names"); + } + return $features; + } +} \ No newline at end of file diff --git a/src/Unleash.php b/src/Unleash.php index 3701775..feec4e3 100644 --- a/src/Unleash.php +++ b/src/Unleash.php @@ -9,8 +9,9 @@ use Illuminate\Contracts\Config\Repository as Config; use Illuminate\Http\Request; use Illuminate\Support\Arr; -use MikeFrancis\LaravelUnleash\Strategies\Contracts\DynamicStrategy; -use MikeFrancis\LaravelUnleash\Strategies\Contracts\Strategy; +use MikeFrancis\LaravelUnleash\Unleash\Context; +use MikeFrancis\LaravelUnleash\Values\FeatureFlag; +use MikeFrancis\LaravelUnleash\Values\FeatureFlagCollection; use Symfony\Component\HttpFoundation\Exception\JsonException; use function GuzzleHttp\json_decode; @@ -32,104 +33,124 @@ public function __construct(ClientInterface $client, Cache $cache, Config $confi $this->request = $request; } - public function getFeatures(): array + public function all(): FeatureFlagCollection { try { $features = $this->getCachedFeatures(); - // Always store the failover cache, in case it is turned on during failure scenarios. - $this->cache->forever('unleash.features.failover', $features); - return $features; } catch (TransferException | JsonException $e) { if ($this->config->get('unleash.cache.failover') === true) { - return $this->cache->get('unleash.features.failover', []); + return $this->cache->get('unleash.features.failover', FeatureFlagCollection::empty()); } } - return []; + return FeatureFlagCollection::empty(); } - public function getFeature(string $name) + public function get(string $name): ?FeatureFlag { - $features = $this->getFeatures(); - - return Arr::first( - $features, - function (array $unleashFeature) use ($name) { - return $name === $unleashFeature['name']; - } + return $this->all()->first( + function (FeatureFlag $unleashFeature) use ($name) { + return $name === $unleashFeature->name; + }, + null ); } - public function isFeatureEnabled(string $name, ...$args): bool + public function enabled(string $name, ...$args): bool { - $feature = $this->getFeature($name); - $isEnabled = Arr::get($feature, 'enabled', false); - - if (!$isEnabled) { + $feature = $this->get($name); + if ($feature === null) { return false; } - $strategies = Arr::get($feature, 'strategies', []); - $allStrategies = $this->config->get('unleash.strategies', []); - - if (count($strategies) === 0) { - return $isEnabled; - } + return $feature->enabled(...$args); + } - foreach ($strategies as $strategyData) { - $className = $strategyData['name']; + public function disabled(string $name, ...$args): bool + { + return !$this->enabled($name, ...$args); + } - if (!array_key_exists($className, $allStrategies)) { - continue; - } + public function variant(string $name, $default = null, ?Context $context = null) + { + $feature = $this->get($name); + if (!$feature) { + return $default; + } + return $feature->variant($default, $context); + } - if (is_callable($allStrategies[$className])) { - $strategy = $allStrategies[$className](); - } else { - $strategy = new $allStrategies[$className]; - } + /** + * @codeCoverageIgnore + */ + public function isFeatureEnabled(string $feature, ...$args): bool + { + return static::enabled($feature, ...$args); + } - if (!$strategy instanceof Strategy && !$strategy instanceof DynamicStrategy) { - throw new \Exception("${$className} does not implement base Strategy/DynamicStrategy."); - } + /** + * @codeCoverageIgnore + */ + public function isFeatureDisabled(string $feature, ...$args): bool + { + return static::disabled($feature, ...$args); + } - $params = Arr::get($strategyData, 'parameters', []); + /** + * @codeCoverageIgnore + */ + public function getFeatures(): FeatureFlagCollection + { + return static::all(); + } - if ($strategy->isEnabled($params, $this->request, ...$args)) { - return true; - } - } + /** + * @codeCoverageIgnore + */ + public function getFeature(string $name) + { + return static::get($name); + } - return false; + /** + * @codeCoverageIgnore + */ + public function __isset($name) + { + return isset($this->{$name}); } - public function isFeatureDisabled(string $name, ...$args): bool + public function __get($name) { - return !$this->isFeatureEnabled($name, ...$args); + return $this->{$name}; } - protected function getCachedFeatures(): array + protected function getCachedFeatures(): FeatureFlagCollection { if (!$this->config->get('unleash.isEnabled')) { - return []; + return FeatureFlagCollection::empty(); } if ($this->config->get('unleash.cache.isEnabled')) { - return $this->cache->remember( + $features = $this->features ?? $this->cache->remember( 'unleash', $this->config->get('unleash.cache.ttl', self::DEFAULT_CACHE_TTL), function () { return $this->fetchFeatures(); } ); + if ($features instanceof FeatureFlagCollection) { + return $this->features ?? $this->features = $features; + } + $this->cache->forget('unleash'); } return $this->features ?? $this->features = $this->fetchFeatures(); } - protected function fetchFeatures(): array + protected function fetchFeatures(): FeatureFlagCollection { $response = $this->client->get($this->config->get('unleash.featuresEndpoint')); @@ -139,11 +160,27 @@ protected function fetchFeatures(): array throw new JsonException('Could not decode unleash response body.', $e->getCode(), $e); } - return $this->formatResponse($data); + $response = $this->formatResponse($data); + + // Always store the failover cache, in case it is turned on during failure scenarios. + $this->cache->forever('unleash.features.failover', $response); + + return $response; } - protected function formatResponse($data): array + protected function formatResponse($data): FeatureFlagCollection { - return Arr::get($data, 'features', []); + return FeatureFlagCollection::wrap(Arr::get($data, 'features', []))->map(function ($item) { + return new FeatureFlag( + $item['name'], + $item['enabled'], + $item['description'] ?? '', + $item['project'] ?? 'default', + $item['stale'] ?? false, + $item['type'] ?? 'release', + $item['strategies'] ?? [], + $item['variants'] ?? [] + ); + }); } } diff --git a/src/Unleash/Context.php b/src/Unleash/Context.php new file mode 100644 index 0000000..e42941e --- /dev/null +++ b/src/Unleash/Context.php @@ -0,0 +1,99 @@ +user(); + if ($user !== null) { + $this->userId = $user->getAuthIdentifier(); + } + try { + $session = $request->session(); + if ($session !== null) { + $this->sessionId = $session->getId(); + } + } catch (\RuntimeException $e) { } + $this->ipAddress = $request->getClientIp(); + $this->environment = app()->environment(); + $this->appName = config('app.name'); + } + + public function getUserId(): ?string + { + return $this->userId; + } + + public function getIpAddress(): ?string + { + return $this->ipAddress; + } + + public function getSessionId(): ?string + { + return $this->sessionId; + } + + public function getEnvironment(): ?string + { + return $this->environment; + } + + public function getAppName(): ?string + { + return $this->appName; + } + + public function getContextValue($name) + { + switch ($name) { + case 'userId': + return $this->getUserId(); + case 'ipAddress': + return $this->getIpAddress(); + case 'sessionId': + return $this->getSessionId(); + case 'environment': + return $this->getEnvironment(); + case 'appName': + return $this->getAppName(); + default: + if (isset($this->customProperties[$name])) { + return $this->customProperties[$name]; + } + } + + return null; + } + + public function __isset($name) + { + return isset($this->customProperties[$name]); + } + + public function __get($name) + { + return $this->customProperties[$name] ?? null; + } + + public function __set($name, $value) + { + $this->customProperties[$name] = $value; + } + + public function __unset($name) + { + unset($this->customProperties[$name]); + } +} \ No newline at end of file diff --git a/src/Values/FeatureFlag.php b/src/Values/FeatureFlag.php new file mode 100644 index 0000000..26f60ea --- /dev/null +++ b/src/Values/FeatureFlag.php @@ -0,0 +1,274 @@ +unleash = resolve(Unleash::class); + + $this->strategies = Collection::wrap($strategies)->map(function($item) { + if ($item instanceof Strategy) { + return $item; + } + + return new Strategy($item['name'], $item['parameters'] ?? null); + }); + $this->enabled = $enabled; + $this->name = $name; + $this->description = $description; + $this->project = $project; + $this->stale = $stale; + $this->type = $type; + $this->variants = Collection::wrap($variants)->map(function ($item) { + return new Variant( + $item['name'], + $item['weight'], + $item['weightType'], + $item['stickiness'], + $item['payload'], + $item['overrides'] ?? [] + ); + }); + } + + /** + * @internal + */ + public function withTestArgs(... $args) + { + $this->testArgs = $args; + return $this; + } + + /** + * @internal + */ + public function withTestArgsUsing(callable $callable) + { + $this->testArgsUsing = $callable; + return $this; + } + + /** + * @internal + */ + public function withTestArgsAny() + { + $this->withTestArgsUsing(function() { return true; }); + return $this; + } + + public function __isset($name) + { + return isset($this->{$name}); + } + + public function __get($name) + { + return $this->{$name}; + } + + public function offsetExists($offset) + { + return isset($this->{$offset}); + } + + public function offsetGet($offset) + { + return $this->{$offset}; + } + + public function enabled(... $args): bool + { + if (!$this->enabled) { + return false; + } + + return $this->applyStrategies($args); + } + + public function disabled(): bool + { + return !$this->enabled; + } + + public function variant($default, ?Context $context): Variant + { + if (!$this->enabled() || $this->variants->count() === 0) { + return new Variant('default', 0, 'fixed', 'default', ['type' => 'default', 'value' => $default], []); + } + + try { + if ($context === null) { + $context = new Context($this->unleash->request); + } + + return $this->selectVariant($context); + } catch (VariantNotFoundException $e) { + return new Variant('default', 0, 'fixed', 'default', ['type' => 'default', 'value' => $default], []); + } + } + + protected function applyStrategies($args): bool + { + if ($this->strategies->isEmpty()) { + return true; + } + + $allStrategies = Collection::wrap($this->unleash->config->get('unleash.strategies')); + + foreach ($this->strategies as $strategyData) { + $className = $strategyData->name; + + if (!$allStrategies->has($className)) { + continue; + } + + if (is_callable($allStrategies[$className])) { + $strategy = $allStrategies[$className](); + } else { + $strategy = new $allStrategies[$className]; + } + + if (!$strategy instanceof StdStrategy && !$strategy instanceof DynamicStrategy) { + throw new \Exception("${$className} does not implement base Strategy/DynamicStrategy."); + } + + $params = $strategyData->parameters ?? []; + + if ($strategy->isEnabled($params, $this->unleash->request, ...$args)) { + return true; + } + } + + return false; + } + + protected function selectVariant(Context $context) + { + $totalWeight = $this->variants->reduce(function(int $carry, Variant $item) { + return $carry += $item->weight; + }, 0); + + if ($totalWeight <= 0) { + throw new VariantNotFoundException(); + } + + try { + return $this->findOverride($context); + } catch (ItemNotFoundException $e) { + $stickiness = $this->calculateStickiness($context, $totalWeight); + + return $this->findVariant($stickiness); + } + } + + protected function findOverride(Context $context) + { + return $this->variants->firstOrFail(function (Variant $item) use ($context) { + if (!isset($item->overrides) || $item->overrides->isEmpty()) { + return false; + } + + return $item->overrides->first(function($item) use ($context) { + return in_array( + $context->getContextValue($item->contextName), + $item->values + ); + }, false); + }); + } + + protected function calculateStickiness(Context $context, int $totalWeight): int + { + $stickUsing = $this->variants->first()->stickiness; + if ($stickUsing !== 'default') { + $seed = $context->getContextValue($stickUsing) ?? $this->randomString(); + } else { + $seed = $context->getUserId() ?? $context->getSessionId() ?? $context->getIpAddress() ?? $this->randomString(); + } + + return Murmur::hash3_int("{$this->name}:{$seed}") % $totalWeight + 1; + } + + protected function findVariant(int $stickiness) + { + $threshold = 0; + $variant = $this->variants->first(function ($item) use (&$threshold, $stickiness) { + if ($item->overrides->count() > 0 || $item->weight <= 0) { + return false; + } + $threshold += $item->weight; + if ($threshold >= $stickiness) { + return true; + } + return false; + }, false); + + if (!$variant) { + throw new VariantNotFoundException(); + } + + return $variant; + } + + protected function randomString(): string + { + return bin2hex(random_bytes(random_int(1, 100000))); + } + + public function __serialize(): array + { + return [ + 'enabled' => $this->enabled, + 'name' => $this->name, + 'description' => $this->description, + 'project' => $this->project, + 'stale' => $this->stale, + 'type' => $this->type, + 'strategies' => $this->strategies->toArray(), + 'variants' => $this->variants->toArray(), + ]; + } + + public function __unserialize(array $data): void + { + $this->unleash = resolve(Unleash::class); + $this->enabled = $data['enabled']; + $this->name = $data['name']; + $this->description = $data['description']; + $this->project = $data['project']; + $this->stale = $data['stale']; + $this->type = $data['type']; + + $this->strategies = Collection::make($data['strategies']); + $this->variants = Collection::make($data['variants']); + } +} \ No newline at end of file diff --git a/src/Values/FeatureFlagCollection.php b/src/Values/FeatureFlagCollection.php new file mode 100644 index 0000000..7b0b559 --- /dev/null +++ b/src/Values/FeatureFlagCollection.php @@ -0,0 +1,9 @@ +name = $name; + $this->parameters = $parameters; + } + + public function __isset($name) + { + return isset($this->{$name}); + } + + public function __get($name) + { + return $this->{$name}; + } +} \ No newline at end of file diff --git a/src/Values/Variant.php b/src/Values/Variant.php new file mode 100644 index 0000000..99443f3 --- /dev/null +++ b/src/Values/Variant.php @@ -0,0 +1,39 @@ +name = $name; + $this->weight = $weight; + $this->weightType = $weightType; + $this->stickiness = $stickiness; + $this->payload = Payload::factory($payload['type'], $payload['value']); + $this->overrides = Collection::wrap($overrides)->map(function ($item, $key) { + return new Override($item['contextName'], $item['values']); + }); + } + + public function __isset($name) + { + return isset($this->{$name}); + } + + public function __get($name) + { + return $this->{$name}; + } +} \ No newline at end of file diff --git a/src/Values/Variant/Override.php b/src/Values/Variant/Override.php new file mode 100644 index 0000000..267c548 --- /dev/null +++ b/src/Values/Variant/Override.php @@ -0,0 +1,25 @@ +contextName = $contextName; + $this->values = $values; + } + + public function __isset($name) + { + return isset($this->{$name}); + } + + public function __get($name) + { + return $this->{$name}; + } +} \ No newline at end of file diff --git a/src/Values/Variant/Payload.php b/src/Values/Variant/Payload.php new file mode 100644 index 0000000..0645318 --- /dev/null +++ b/src/Values/Variant/Payload.php @@ -0,0 +1,34 @@ +{$name}); + } + + public function __get($name) + { + return $this->{$name}; + } +} \ No newline at end of file diff --git a/src/Values/Variant/PayloadCSV.php b/src/Values/Variant/PayloadCSV.php new file mode 100644 index 0000000..3595d6c --- /dev/null +++ b/src/Values/Variant/PayloadCSV.php @@ -0,0 +1,37 @@ +values = str_getcsv($value); + } + + public function __isset($name) + { + return isset($this->{$name}); + } + + public function __get($name) + { + return $this->{$name}; + } + + public function offsetExists($offset) + { + return isset($this->values[$offset]); + } + + public function offsetGet($offset) + { + return $this->values[$offset]; + } +} \ No newline at end of file diff --git a/src/Values/Variant/PayloadDefault.php b/src/Values/Variant/PayloadDefault.php new file mode 100644 index 0000000..3532180 --- /dev/null +++ b/src/Values/Variant/PayloadDefault.php @@ -0,0 +1,13 @@ +value = $value; + } +} \ No newline at end of file diff --git a/src/Values/Variant/PayloadJSON.php b/src/Values/Variant/PayloadJSON.php new file mode 100644 index 0000000..a1623ce --- /dev/null +++ b/src/Values/Variant/PayloadJSON.php @@ -0,0 +1,25 @@ +values = json_decode($value); + } + + public function __isset($name) + { + return isset($this->values->{$name}); + } + + public function __get($name) + { + return $this->values->{$name}; + } +} \ No newline at end of file diff --git a/src/Values/Variant/PayloadString.php b/src/Values/Variant/PayloadString.php new file mode 100644 index 0000000..5de859c --- /dev/null +++ b/src/Values/Variant/PayloadString.php @@ -0,0 +1,13 @@ +value = $value; + } +} \ No newline at end of file diff --git a/tests/ContextTest.php b/tests/ContextTest.php new file mode 100644 index 0000000..0fba94d --- /dev/null +++ b/tests/ContextTest.php @@ -0,0 +1,93 @@ +createMock(Store::class); + $sessionMock->expects($this->once())->method('getId')->willReturn('test_session_id'); + $userMock = $this->createMock(Authenticatable::class); + $userMock->expects($this->once())->method('getAuthIdentifier')->willReturn(1); + $request = $this->createMock(Request::class); + $request->expects($this->once()) + ->method('user') + ->willReturn($userMock); + $request->expects($this->once()) + ->method('getClientIp') + ->willReturn('127.0.0.1'); + $request->expects($this->once()) + ->method('session') + ->willReturn($sessionMock); + + $context = new Context($request); + + $this->assertEquals('test_session_id', $context->getSessionId()); + $this->assertEquals('127.0.0.1', $context->getIpAddress()); + $this->assertEquals('1', $context->getUserId()); + $this->assertEquals('Laravel', $context->getAppName()); + $this->assertEquals('testing', $context->getEnvironment()); + } + + public function testGetContextValue() + { + $sessionMock = $this->createMock(Store::class); + $sessionMock->expects($this->once())->method('getId')->willReturn('test_session_id'); + $userMock = $this->createMock(Authenticatable::class); + $userMock->expects($this->once())->method('getAuthIdentifier')->willReturn(1); + $request = $this->createMock(Request::class); + $request->expects($this->once()) + ->method('user') + ->willReturn($userMock); + $request->expects($this->once()) + ->method('getClientIp') + ->willReturn('127.0.0.1'); + $request->expects($this->once()) + ->method('session') + ->willReturn($sessionMock); + + $context = new Context($request); + + $this->assertEquals('test_session_id', $context->getContextValue('sessionId')); + $this->assertEquals('127.0.0.1', $context->getContextValue('ipAddress')); + $this->assertEquals('1', $context->getContextValue('userId')); + $this->assertEquals('Laravel', $context->getContextValue('appName')); + $this->assertEquals('testing', $context->getContextValue('environment')); + } + + public function testCustomerProperties() + { + $request = $this->createMock(Request::class); + $request->expects($this->once()) + ->method('user') + ->willReturn(null); + $request->expects($this->once()) + ->method('getClientIp') + ->willReturn(null); + $request->expects($this->once()) + ->method('session') + ->willReturn(null); + + $context = new Context($request); + $context->foo = 'bar'; + + $this->assertEquals('bar', $context->getContextValue('foo')); + $this->assertEquals('bar', $context->foo); + $this->assertTrue(isset($context->foo)); + unset($context->foo); + $this->assertFalse(isset($context->foo)); + + + $this->assertEquals(null, $context->getContextValue('non_existant')); + $this->assertEquals(null, $context->non_existant); + $this->assertFalse(isset($context->non_existant)); + } +} \ No newline at end of file diff --git a/tests/Facades/FeatureTest.php b/tests/Facades/FeatureTest.php new file mode 100644 index 0000000..5c61f99 --- /dev/null +++ b/tests/Facades/FeatureTest.php @@ -0,0 +1,58 @@ +assertEquals( + new FeatureFlagCollection([ + new FeatureFlag('active-flag', true), + new FeatureFlag('inactive-flag', false), + ]), + Feature::all() + ); + + $this->assertTrue(Feature::enabled('active-flag')); + $this->assertFalse(Feature::disabled('active-flag')); + + $this->assertTrue(Feature::disabled('inactive-flag')); + $this->assertFalse(Feature::enabled('inactive-flag')); + + $this->assertTrue(Feature::disabled('unknown-flag')); + $this->assertFalse(Feature::enabled('unknown-flag')); + + $this->assertEquals(Feature::all(), Feature::all()); + + $this->assertEquals(Feature::get('active-flag'), Feature::get('active-flag')); + $this->assertEquals(Feature::enabled('active-flag'), Feature::enabled('active-flag')); + $this->assertEquals(Feature::disabled('active-flag'), Feature::disabled('active-flag')); + + $this->assertEquals(Feature::get('inactive-flag'), Feature::get('inactive-flag')); + $this->assertEquals(Feature::enabled('inactive-flag'), Feature::enabled('inactive-flag')); + $this->assertEquals(Feature::disabled('inactive-flag'), Feature::disabled('inactive-flag')); + + $this->assertEquals(Feature::get('unknown-flag'), Feature::get('unknown-flag')); + $this->assertEquals(Feature::enabled('unknown-flag'), Feature::enabled('unknown-flag')); + $this->assertEquals(Feature::disabled('unknown-flag'), Feature::disabled('unknown-flag')); + } + + protected function getPackageProviders($app) + { + return [ + ServiceProvider::class, + ]; + } +} \ No newline at end of file diff --git a/tests/Facades/UnleashTest.php b/tests/Facades/UnleashTest.php new file mode 100644 index 0000000..317e9e5 --- /dev/null +++ b/tests/Facades/UnleashTest.php @@ -0,0 +1,418 @@ +assertEquals( + new FeatureFlagCollection([ + new FeatureFlag('active-flag', true), + new FeatureFlag('inactive-flag', false), + ]), + Unleash::all() + ); + + $this->assertTrue(Unleash::enabled('active-flag')); + $this->assertFalse(Unleash::disabled('active-flag')); + + $this->assertTrue(Unleash::disabled('inactive-flag')); + $this->assertFalse(Unleash::enabled('inactive-flag')); + + $this->assertTrue(Unleash::disabled('unknown-flag')); + $this->assertFalse(Unleash::enabled('unknown-flag')); + + $this->assertEquals(Unleash::all(), Unleash::all()); + + $this->assertEquals(Unleash::get('active-flag'), Unleash::get('active-flag')); + $this->assertEquals(Unleash::enabled('active-flag'), Unleash::enabled('active-flag')); + $this->assertEquals(Unleash::disabled('active-flag'), Unleash::disabled('active-flag')); + + $this->assertEquals(Unleash::get('inactive-flag'), Unleash::get('inactive-flag')); + $this->assertEquals(Unleash::enabled('inactive-flag'), Unleash::enabled('inactive-flag')); + $this->assertEquals(Unleash::disabled('inactive-flag'), Unleash::disabled('inactive-flag')); + + $this->assertEquals(Unleash::get('unknown-flag'), Unleash::get('unknown-flag')); + $this->assertEquals(Unleash::enabled('unknown-flag'), Unleash::enabled('unknown-flag')); + $this->assertEquals(Unleash::disabled('unknown-flag'), Unleash::disabled('unknown-flag')); + } + + public function testBasicMock() + { + Unleash::shouldReceive('enabled')->with('active')->andReturnTrue(); + $this->assertTrue(Unleash::enabled('active')); + + Unleash::shouldReceive('disabled')->with('inactive')->andReturnTrue(); + $this->assertTrue(Unleash::disabled('inactive')); + } + + public function testEnabledFake() + { + Unleash::fake(new FeatureFlagCollection([ + new FeatureFlag('active-flag', true) + ])); + + $this->assertTrue(Unleash::enabled('active-flag')); + + $this->assertFalse(Unleash::disabled('active-flag')); + + $this->assertEquals(new FeatureFlag('active-flag', true), Unleash::get('active-flag')); + + $this->assertEquals([new FeatureFlag('active-flag', true)], Unleash::all()->all()); + } + + public function testDisabledFake() + { + Unleash::fake(new FeatureFlagCollection([ + new FeatureFlag('inactive-flag', false) + ])); + + $this->assertTrue(Unleash::disabled('inactive-flag')); + + $this->assertFalse(Unleash::enabled('inactive-flag')); + + $this->assertEquals(new FeatureFlag('inactive-flag', false), Unleash::get('inactive-flag')); + + $this->assertEquals([new FeatureFlag('inactive-flag', false)], Unleash::all()->all()); + } + + public function testMixedFake() + { + Unleash::fake(new FeatureFlagCollection([ + new FeatureFlag('active-flag', true), + new FeatureFlag('inactive-flag', false), + ])); + + $this->assertTrue(Unleash::enabled('active-flag')); + $this->assertFalse(Unleash::disabled('active-flag')); + + $this->assertTrue(Unleash::disabled('inactive-flag')); + $this->assertFalse(Unleash::enabled('inactive-flag')); + + $this->assertTrue(Unleash::disabled('unknown-flag')); + $this->assertFalse(Unleash::enabled('unknown-flag')); + } + + public function testEnabledWithArgsFake() + { + Unleash::fake(new FeatureFlagCollection([ + (new FeatureFlag('active-flag', true))->withTestArgs('foo'), + (new FeatureFlag('active-flag', true))->withTestArgs('foo', 'bar'), + ])); + + $this->assertTrue(Unleash::enabled('active-flag', 'foo')); + $this->assertFalse(Unleash::disabled('active-flag', 'foo')); + + $this->assertTrue(Unleash::enabled('active-flag', 'foo', 'bar')); + $this->assertFalse(Unleash::disabled('active-flag', 'foo', 'bar')); + + $this->assertFalse(Unleash::enabled('active-flag')); + $this->assertTrue(Unleash::disabled('active-flag')); + + + $this->assertFalse(Unleash::enabled('active-flag', 'foo', 'bar', 'baz')); + $this->assertTrue(Unleash::disabled('active-flag', 'foo', 'bar', 'baz')); + } + + public function testEnabledWithArgsUsingFake() + { + Unleash::fake( + (new FeatureFlag('active-flag', true))->withTestArgsUsing(function (bool $arg) { + return !$arg; + }) + ); + + $this->assertTrue(Unleash::enabled('active-flag', false)); + $this->assertFalse(Unleash::disabled('active-flag', false)); + + $this->assertTrue(Unleash::disabled('active-flag', true)); + $this->assertFalse(Unleash::enabled('active-flag', true)); + } + + public function testEnabledNotFake() + { + $this->assertFalse(Unleash::enabled('unknown-flag')); + $this->assertTrue(Unleash::disabled('unknown-flag')); + } + + public function testDisabledNotFake() + { + $this->assertFalse(Unleash::enabled('unknown-flag')); + $this->assertTrue(Unleash::disabled('unknown-flag')); + } + + public function testGetWithFake() + { + Unleash::fake(new FeatureFlagCollection([ + new FeatureFlag('active-flag', true), + new FeatureFlag('inactive-flag', false), + ])); + + $this->assertEquals(new FeatureFlag('active-flag', true), Unleash::get('active-flag')); + + $this->assertEquals(new FeatureFlag('inactive-flag', false), Unleash::get('inactive-flag')); + } + + public function testGetWithArgsFake() + { + Unleash::fake(new FeatureFlagCollection([ + (new FeatureFlag('active-flag', true))->withTestArgs('foo'), + (new FeatureFlag('active-flag', false))->withTestArgs('foo', 'bar'), + (new FeatureFlag('inactive-flag', false))->withTestArgs('foo'), + (new FeatureFlag('inactive-flag', true))->withTestArgs('foo', 'bar'), + ])); + + $this->assertEquals((new FeatureFlag('active-flag', true))->withTestArgs('foo'), Unleash::get('active-flag')); + + $this->assertEquals((new FeatureFlag('inactive-flag', false))->withTestArgs('foo'), Unleash::get('inactive-flag')); + } + + public function testAllWithFake() + { + Unleash::fake(new FeatureFlagCollection([ + new FeatureFlag('active-flag', true), + new FeatureFlag('inactive-flag', false), + ])); + + $this->assertEquals( + new FeatureFlagCollection([ + new FeatureFlag('active-flag', true), + new FeatureFlag('inactive-flag', false), + ]), + Unleash::all() + ); + } + + public function testFakeAll() + { + Unleash::fake(); + $this->assertTrue(Unleash::disabled('active-flag')); + $this->assertTrue(Unleash::disabled('inactive-flag')); + $this->assertTrue(Unleash::disabled('unknown-flag')); + } + + public function testFakeAllWithDefaultStatusTrue() + { + Unleash::fake()->withDefaultStatus(true); + $this->assertTrue(Unleash::enabled('active-flag')); + $this->assertTrue(Unleash::enabled('inactive-flag')); + $this->assertTrue(Unleash::enabled('unknown-flag')); + } + + public function testFakeAllWithDefaultStatusFalse() + { + Unleash::fake()->withDefaultStatus(false); + $this->assertTrue(Unleash::disabled('active-flag')); + $this->assertTrue(Unleash::disabled('inactive-flag')); + $this->assertTrue(Unleash::disabled('unknown-flag')); + } + + public function testFakeAllWithDefaultStatusUsing() + { + Unleash::fake()->withDefaultStatusUsing(function ($feature, $status, ... $args) { + if (count($args) == 0) { + return true; + } + return !$args[0]; + }); + + $this->assertTrue(Unleash::enabled('active-flag', false)); + $this->assertTrue(Unleash::disabled('inactive-flag', true)); + $this->assertTrue(Unleash::enabled('unknown-flag')); + } + + public function testFakeMixedWithArgsUsing() + { + Unleash::fake( + (new FeatureFlag('active-flag', true))->withTestArgsUsing(function($arg) { + return !$arg; + }) + )->withDefaultStatus(false); + + $this->assertTrue(Unleash::enabled('active-flag', false)); + $this->assertTrue(Unleash::disabled('active-flag', true)); + $this->assertTrue(Unleash::disabled('unknown-flag')); + } + + public function testFakeWithVariadicFeatureFlags() + { + Unleash::fake( + new FeatureFlag('active-flag', true), + new FeatureFlag('inactive-flag', false) + ); + + $this->assertTrue(Unleash::enabled('active-flag')); + $this->assertTrue(Unleash::disabled('inactive-flag')); + } + + public function testFakeMultipleCalls() + { + Unleash::fake(new FeatureFlag('active-flag', true)); + Unleash::fake(new FeatureFlag('inactive-flag', false)); + + $this->assertTrue(Unleash::enabled('active-flag')); + $this->assertTrue(Unleash::disabled('inactive-flag')); + $this->assertTrue(Unleash::disabled('unknown-flag')); + } + + public function testFakeWithArray() + { + Unleash::fake(['active-flag', 'another-flag']); + + $this->assertTrue(Unleash::enabled('active-flag')); + $this->assertTrue(Unleash::enabled('active-flag', 'foo', 'bar')); + $this->assertTrue(Unleash::enabled('another-flag')); + $this->assertTrue(Unleash::enabled('another-flag', 'foo', 'bar')); + $this->assertTrue(Unleash::disabled('inactive-flag')); + $this->assertTrue(Unleash::disabled('unknown-flag')); + } + + public function testAssertCalledFeatureEnabled() + { + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Feature flag enabled called for someFlag 0 times, expected at least 1'); + + $fake = Unleash::fake(); + $fake->assertCalledFeatureEnabled('someFlag'); + $fake->enabled('someFlag'); + $fake->assertCalledFeatureEnabled('someFlag'); + } + + public function testAssertCalledFeatureDisabled() + { + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Feature flag disabled called for someFlag 0 times, expected at least 1'); + + $fake = Unleash::fake(); + $fake->assertCalledFeatureDisabled('someFlag'); + $fake->disabled('someFlag'); + $fake->assertCalledFeatureDisabled('someFlag'); + } + + public function testAssertCalledFeatureGet() + { + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Feature flag get called for someFlag 0 times, expected at least 1'); + + $fake = Unleash::fake(); + $fake->assertCalledFeatureGet('someFlag'); + $fake->get('someFlag'); + $fake->assertCalledFeatureGet('someFlag'); + } + + public function testAssertCalledFeatureAll() + { + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Get all feature flags called 0 times, expected at least 1'); + + $fake = Unleash::fake(); + $fake->assertCalledFeatureAll(); + $fake->all(); + $fake->assertCalledFeatureAll(); + } + + public function testAssertCalledFeatureEnabledTimes() + { + $fake = Unleash::fake(); + $fake->assertNotCalledFeatureEnabled('someFlag'); + $fake->enabled('someFlag'); + $fake->enabled('someFlag'); + $fake->enabled('someFlag'); + $fake->assertCalledFeatureEnabled('someFlag'); + $fake->assertCalledFeatureEnabledTimes('someFlag', 3); + } + + public function testAssertCalledFeatureDisabledTimes() + { + $fake = Unleash::fake(); + $fake->assertNotCalledFeatureDisabled('someFlag'); + $fake->disabled('someFlag'); + $fake->disabled('someFlag'); + $fake->disabled('someFlag'); + $fake->assertCalledFeatureDisabled('someFlag'); + $fake->assertCalledFeatureDisabledTimes('someFlag', 3); + } + + public function testAssertCalledFeatureGetTimes() + { + $fake = Unleash::fake(); + $fake->assertNotCalledFeatureGet('someFlag'); + $fake->get('someFlag'); + $fake->get('someFlag'); + $fake->get('someFlag'); + $fake->assertCalledFeatureGet('someFlag'); + $fake->assertCalledFeatureGetTimes('someFlag', 3); + } + + public function testAssertCalledFeatureAllTimes() + { + $fake = Unleash::fake(); + $fake->assertNotCalledFeatureAll(); + $fake->all(); + $fake->all(); + $fake->all(); + $fake->assertCalledFeatureAll(); + $fake->assertCalledFeatureAllTimes(3); + } + + public function testAssertCalledFeatureEnabledNoSideEffects() + { + $fake = Unleash::fake(); + $fake->enabled('someFlag'); + $fake->assertNotCalledFeatureAll(); + $fake->assertNotCalledFeatureGet('someFlag'); + $fake->assertNotCalledFeatureDisabled('someFlag'); + } + + public function testAssertCalledFeatureDisabledNoSideEffects() + { + $fake = Unleash::fake(); + $fake->disabled('someFlag'); + $fake->assertNotCalledFeatureAll(); + $fake->assertNotCalledFeatureGet('someFlag'); + $fake->assertNotCalledFeatureEnabled('someFlag'); + } + + public function testAssertCalledFeatureGetNoSideEffects() + { + $fake = Unleash::fake(); + $fake->get('someFlag'); + $fake->assertNotCalledFeatureAll(); + $fake->assertNotCalledFeatureEnabled('someFlag'); + $fake->assertNotCalledFeatureDisabled('someFlag'); + } + + public function testAssertCalledFeatureAllNoSideEffects() + { + $fake = Unleash::fake(); + $fake->all(); + $fake->assertNotCalledFeatureGet('someFlag'); + $fake->assertNotCalledFeatureEnabled('someFlag'); + $fake->assertNotCalledFeatureDisabled('someFlag'); + } + + protected function getPackageProviders($app) + { + return [ + ServiceProvider::class, + ]; + } + + protected function tearDown(): void + { + Unleash::clearResolvedInstances(); + } +} \ No newline at end of file diff --git a/tests/FeatureTest.php b/tests/FeatureTest.php new file mode 100644 index 0000000..7b70cbf --- /dev/null +++ b/tests/FeatureTest.php @@ -0,0 +1,94 @@ +mockHandler->append( + new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => 'someFeature', + 'enabled' => true, + ], + ], + ] + ) + ) + ); + + $cache = $this->createMock(Cache::class); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + + $request = $this->createMock(Request::class); + + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + + $feature = $unleash->get('someFeature'); + $this->assertInstanceOf(FeatureFlag::class, $feature); + $this->assertEquals(new FeatureFlag('someFeature', true), $feature); + $this->assertArrayHasKey('enabled', $feature); + $this->assertTrue(isset($feature['enabled'])); + $this->assertTrue(isset($feature->enabled)); + $this->assertTrue($feature['enabled']); + $this->assertTrue($feature->enabled()); + $this->assertFalse($feature->disabled()); + } + + public function testFeatureDisabled() + { + $this->mockHandler->append( + new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => 'someFeature', + 'enabled' => false, + ], + ], + ] + ) + ) + ); + + $cache = $this->createMock(Cache::class); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + + $request = $this->createMock(Request::class); + + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + + $feature = $unleash->get('someFeature'); + $this->assertInstanceOf(FeatureFlag::class, $feature); + $this->assertEquals(new FeatureFlag('someFeature', false), $feature); + $this->assertArrayHasKey('enabled', $feature); + $this->assertFalse($feature['enabled']); + $this->assertFalse($feature->enabled()); + $this->assertTrue($feature->disabled()); + } +} \ No newline at end of file diff --git a/tests/Middleware/FeatureDisabledTest.php b/tests/Middleware/FeatureDisabledTest.php new file mode 100644 index 0000000..d1b6c79 --- /dev/null +++ b/tests/Middleware/FeatureDisabledTest.php @@ -0,0 +1,58 @@ +handle($request, function (Request $request) { + $this->assertTrue(true); + }, 'inactive-flag'); + } + + public function testFeatureIsEnabled() + { + $this->expectException(NotFoundHttpException::class); + + Unleash::fake(new FeatureFlagCollection([ + new FeatureFlag('active-flag', true), + ])); + + $request = Request::create('/', 'GET'); + + $middleware = new FeatureDisabled(); + $middleware->handle($request, function (Request $request) { + // should not run + $this->assertTrue(false); + }, 'active-flag'); + } + + protected function getPackageProviders($app) + { + return [ + ServiceProvider::class, + ]; + } + + protected function tearDown(): void + { + Unleash::clearResolvedInstances(); + } +} diff --git a/tests/Middleware/FeatureEnabledTest.php b/tests/Middleware/FeatureEnabledTest.php new file mode 100644 index 0000000..38150e4 --- /dev/null +++ b/tests/Middleware/FeatureEnabledTest.php @@ -0,0 +1,58 @@ +expectException(NotFoundHttpException::class); + + Unleash::fake(new FeatureFlagCollection([ + new FeatureFlag('inactive-flag', false), + ])); + + $request = Request::create('/', 'GET'); + + $middleware = new FeatureEnabled(); + $middleware->handle($request, function (Request $request) { + // should not run + $this->assertTrue(false); + }, 'inactive-flag'); + } + + public function testFeatureIsEnabled() + { + Unleash::fake(new FeatureFlagCollection([ + new FeatureFlag('active-flag', true), + ])); + + $request = Request::create('/', 'GET'); + + $middleware = new FeatureEnabled(); + $middleware->handle($request, function (Request $request) { + $this->assertTrue(true); + }, 'active-flag'); + } + + protected function getPackageProviders($app) + { + return [ + ServiceProvider::class, + ]; + } + + protected function tearDown(): void + { + Unleash::clearResolvedInstances(); + } +} diff --git a/tests/MockClient.php b/tests/MockClient.php new file mode 100644 index 0000000..056685a --- /dev/null +++ b/tests/MockClient.php @@ -0,0 +1,45 @@ +setupMockClient(); + } + + protected function setupMockClient(): void + { + $this->mockHandler = new MockHandler(); + + $this->client = new Client( + [ + 'handler' => $this->mockHandler, + ] + ); + } + + protected function getPackageProviders($app) + { + return [ + ServiceProvider::class, + ]; + } +} \ No newline at end of file diff --git a/tests/Strategies/ApplicationHostnameStrategyTest.php b/tests/Strategies/ApplicationHostnameStrategyTest.php index ee8c527..73c11ed 100644 --- a/tests/Strategies/ApplicationHostnameStrategyTest.php +++ b/tests/Strategies/ApplicationHostnameStrategyTest.php @@ -4,7 +4,7 @@ use Illuminate\Http\Request; use MikeFrancis\LaravelUnleash\Strategies\ApplicationHostnameStrategy; -use PHPUnit\Framework\TestCase; +use Orchestra\Testbench\TestCase; class ApplicationHostnameStrategyTest extends TestCase { diff --git a/tests/Strategies/DefaultStrategyTest.php b/tests/Strategies/DefaultStrategyTest.php index 3b0f58e..cc3d748 100644 --- a/tests/Strategies/DefaultStrategyTest.php +++ b/tests/Strategies/DefaultStrategyTest.php @@ -2,11 +2,9 @@ namespace MikeFrancis\LaravelUnleash\Tests\Strategies; -use Illuminate\Contracts\Auth\Guard; use Illuminate\Http\Request; use MikeFrancis\LaravelUnleash\Strategies\DefaultStrategy; -use PHPUnit\Framework\TestCase; -use stdClass; +use Orchestra\Testbench\TestCase; class DefaultStrategyTest extends TestCase { diff --git a/tests/Strategies/DynamicStrategyTest.php b/tests/Strategies/DynamicStrategyTest.php index 56e4dda..707618c 100644 --- a/tests/Strategies/DynamicStrategyTest.php +++ b/tests/Strategies/DynamicStrategyTest.php @@ -2,21 +2,18 @@ namespace MikeFrancis\LaravelUnleash\Tests\Strategies; -use GuzzleHttp\Client; -use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\Psr7\Response; use Illuminate\Contracts\Cache\Repository as Cache; -use Illuminate\Contracts\Config\Repository as Config; +use Illuminate\Support\Facades\Config; use Illuminate\Http\Request; +use MikeFrancis\LaravelUnleash\Tests\MockClient; use MikeFrancis\LaravelUnleash\Tests\Stubs\ImplementedStrategy; use MikeFrancis\LaravelUnleash\Unleash; -use PHPUnit\Framework\TestCase; +use Orchestra\Testbench\TestCase; class DynamicStrategyTest extends TestCase { - protected $mockHandler; - - protected $client; + use MockClient; public function testWithoutArgs() { @@ -33,12 +30,13 @@ public function testWithoutArgs() ->with([], $request) ->willReturn(true); - $config = $this->getMockConfig($strategy); + $this->setMockConfig($strategy); - $unleash = new Unleash($this->client, $cache, $config, $request); + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->instance(Unleash::class, $unleash); - $this->assertTrue($unleash->isFeatureEnabled($featureName)); - $this->assertFalse($unleash->isFeatureDisabled($featureName)); + $this->assertTrue($unleash->enabled($featureName)); + $this->assertFalse($unleash->disabled($featureName)); } public function testWithArg() @@ -56,12 +54,13 @@ public function testWithArg() ->with([], $request, true) ->willReturn(true); - $config = $this->getMockConfig($strategy); + $this->setMockConfig($strategy); - $unleash = new Unleash($this->client, $cache, $config, $request); + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->instance(Unleash::class, $unleash); - $this->assertTrue($unleash->isFeatureEnabled($featureName, true)); - $this->assertFalse($unleash->isFeatureDisabled($featureName, true)); + $this->assertTrue($unleash->enabled($featureName, true)); + $this->assertFalse($unleash->disabled($featureName, true)); } public function testWithArgs() @@ -79,63 +78,29 @@ public function testWithArgs() ->with([], $request, 'foo', 'bar', 'baz') ->willReturn(true); - $config = $this->getMockConfig($strategy); + $this->setMockConfig($strategy); - $unleash = new Unleash($this->client, $cache, $config, $request); + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->instance(Unleash::class, $unleash); - $this->assertTrue($unleash->isFeatureEnabled($featureName, 'foo', 'bar', 'baz')); - $this->assertFalse($unleash->isFeatureDisabled($featureName, 'foo', 'bar', 'baz')); + $this->assertTrue($unleash->enabled($featureName, 'foo', 'bar', 'baz')); + $this->assertFalse($unleash->disabled($featureName, 'foo', 'bar', 'baz')); } /** * @param \PHPUnit\Framework\MockObject\MockObject $strategy * @return Config|\PHPUnit\Framework\MockObject\MockObject */ - protected function getMockConfig(\PHPUnit\Framework\MockObject\MockObject $strategy) + protected function setMockConfig(\PHPUnit\Framework\MockObject\MockObject $strategy) { - $config = $this->createMock(Config::class); - - $config->expects($this->at(0)) - ->method('get') - ->with('unleash.isEnabled') - ->willReturn(true); - $config->expects($this->at(1)) - ->method('get') - ->with('unleash.cache.isEnabled') - ->willReturn(false); - $config->expects($this->at(2)) - ->method('get') - ->with('unleash.featuresEndpoint') - ->willReturn('/api/client/features'); - $config->expects($this->at(3)) - ->method('get') - ->with('unleash.strategies') - ->willReturn( - [ - 'testStrategy' => function () use ($strategy) { - return $strategy; - }, - ] - ); - $config->expects($this->at(4)) - ->method('get') - ->with('unleash.isEnabled') - ->willReturn(true); - $config->expects($this->at(5)) - ->method('get') - ->with('unleash.cache.isEnabled') - ->willReturn(false); - $config->expects($this->at(6)) - ->method('get') - ->with('unleash.strategies') - ->willReturn( - [ - 'testStrategy' => function () use ($strategy) { - return $strategy; - }, - ] - ); - return $config; + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + Config::set('unleash.strategies', [ + 'testStrategy' => function () use ($strategy) { + return $strategy; + }, + ]); } /** @@ -165,17 +130,4 @@ protected function setMockHandler(string $featureName): void ) ); } - - protected function setUp(): void - { - parent::setUp(); - - $this->mockHandler = new MockHandler(); - - $this->client = new Client( - [ - 'handler' => $this->mockHandler, - ] - ); - } } diff --git a/tests/Strategies/RemoteAddressStrategyTest.php b/tests/Strategies/RemoteAddressStrategyTest.php index 348a239..3d2e0f9 100644 --- a/tests/Strategies/RemoteAddressStrategyTest.php +++ b/tests/Strategies/RemoteAddressStrategyTest.php @@ -4,7 +4,7 @@ use Illuminate\Http\Request; use MikeFrancis\LaravelUnleash\Strategies\RemoteAddressStrategy; -use PHPUnit\Framework\TestCase; +use Orchestra\Testbench\TestCase; class RemoteAddressStrategyTest extends TestCase { diff --git a/tests/Strategies/UserWithIdStrategyTest.php b/tests/Strategies/UserWithIdStrategyTest.php index 77e8487..67ea33e 100644 --- a/tests/Strategies/UserWithIdStrategyTest.php +++ b/tests/Strategies/UserWithIdStrategyTest.php @@ -5,7 +5,7 @@ use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Http\Request; use MikeFrancis\LaravelUnleash\Strategies\UserWithIdStrategy; -use PHPUnit\Framework\TestCase; +use Orchestra\Testbench\TestCase; class UserWithIdStrategyTest extends TestCase { diff --git a/tests/UnleashCachingTest.php b/tests/UnleashCachingTest.php new file mode 100644 index 0000000..854116d --- /dev/null +++ b/tests/UnleashCachingTest.php @@ -0,0 +1,374 @@ +mockHandler->append( + new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => $featureName, + 'enabled' => true, + ], + ], + ] + ) + ) + ); + + $cache = $this->createMock(Cache::class); + $cache->expects($this->exactly(2)) + ->method('remember') + ->willReturn( + FeatureFlagCollection::make()->add(new FeatureFlag($featureName, true)) + ); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', true); + Config::set('unleash.cache.ttl', null); + + $request = $this->createMock(Request::class); + + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->assertTrue($unleash->enabled($featureName)); + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->assertTrue($unleash->enabled($featureName)); + } + + public function testFeaturesCacheFailoverEnabled() + { + $featureName = 'someFeature'; + + $this->mockHandler->append( + new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => $featureName, + 'enabled' => true, + ], + ], + ] + ) + ) + ); + $this->mockHandler->append( + new Response(200, [], '{"broken" json]') + ); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', true); + Config::set('unleash.cache.ttl', 0.1); + Config::set('unleash.cache.failover', true); + + $request = $this->createMock(Request::class); + + + $unleash = new Unleash($this->client, resolve(Cache::class), Config::getFacadeRoot(), $request); + $this->assertTrue($unleash->enabled($featureName), "Uncached Request"); + + usleep(2000); + + $unleash = new Unleash($this->client, resolve(Cache::class), Config::getFacadeRoot(), $request); + $this->assertTrue($unleash->enabled($featureName), "Cached Request"); + } + + public function testFeaturesCacheFailoverDisabled() + { + $featureName = 'someFeature'; + + $this->mockHandler->append( + new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => $featureName, + 'enabled' => true, + ], + ], + ] + ) + ) + ); + $this->mockHandler->append( + new Response(200, [], '{"broken" json]') + ); + + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', true); + Config::set('unleash.cache.ttl', 0.1); + Config::set('unleash.cache.failover', false); + + $request = $this->createMock(Request::class); + + $unleash = new Unleash($this->client, resolve(Cache::class), Config::getFacadeRoot(), $request); + $this->assertTrue($unleash->enabled($featureName), "Uncached Request"); + + usleep(2000); + + $unleash = new Unleash($this->client, resolve(Cache::class), Config::getFacadeRoot(), $request); + $this->assertFalse($unleash->enabled($featureName), "Cached Request"); + } + + public function testFeaturesCacheFailoverEnabledIndependently() + { + $featureName = 'someFeature'; + + $this->mockHandler->append( + new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => $featureName, + 'enabled' => true, + ], + ], + ] + ) + ) + ); + $this->mockHandler->append( + new Response(500) + ); + + $cache = $this->mock(Cache::class, function (MockInterface $cache) use ($featureName) { + $cache->expects('forever') + ->once() + ->withSomeOfArgs('unleash.features.failover') + ->andReturn( + new FeatureFlagCollection([ + new FeatureFlag($featureName, true) + ]) + ); + + $cache->expects('get')->once()->withSomeOfArgs('unleash.features.failover')->andReturn(new FeatureFlagCollection([ + new FeatureFlag($featureName, true) + ])); + }); + + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + Config::set('unleash.cache.failover', true); + + $request = $this->createMock(Request::class); + + $unleash = new Unleash($this->client, $cache, resolve(Repository::class), $request); + $this->assertTrue($unleash->enabled($featureName), "Uncached Request"); + + $unleash = new Unleash($this->client, $cache, resolve(Repository::class), $request); + $this->assertTrue($unleash->enabled($featureName), "Cached Request"); + } + + public function testFeaturesCacheIgnoreInvalidCacheData() + { + $featureName = 'someFeature'; + + $response = new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => $featureName, + 'enabled' => true, + ], + ], + ] + ) + ); + + $this->mockHandler->append($response); + $this->mockHandler->append($response); + + $cache = $this->createMock(Cache::class); + $cache->expects($this->at(0)) + ->method('remember') + ->willReturn([]); + $cache->expects($this->at(1)) + ->method('forget') + ->with('unleash'); + $cache->expects($this->at(2)) + ->method('remember') + ->willReturn( + new FeatureFlagCollection([ + new FeatureFlag($featureName, true) + ]) + ); + $cache->expects($this->at(3)) + ->method('remember') + ->willReturn( + new FeatureFlagCollection([ + new FeatureFlag($featureName, true) + ]) + ); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', true); + Config::set('unleash.cache.ttl', 3600); + Config::set('unleash.cache.failover', false); + + $request = $this->createMock(Request::class); + + $unleash = new Unleash($this->client, $cache, resolve(Repository::class), $request); + $this->assertTrue($unleash->enabled($featureName)); + + $unleash = new Unleash($this->client, $cache, resolve(Repository::class), $request); + $this->assertTrue($unleash->enabled($featureName)); + + $unleash = new Unleash($this->client, $cache, resolve(Repository::class), $request); + $this->assertTrue($unleash->enabled($featureName)); + $this->assertEquals(0, $this->mockHandler->count()); + } + + public function testCanHandleErrorsFromUnleashWithFailover() + { + $featureName = 'someFeature'; + + $this->mockHandler->append(new Response(200, [], 'lol')); + + $cache = $this->createMock(Cache::class); + $cache->expects($this->once()) + ->method('get') + ->with('unleash.features.failover') + ->willReturn(FeatureFlagCollection::empty()); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + + $request = $this->createMock(Request::class); + + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + + $this->assertTrue($unleash->disabled($featureName)); + } + + public function testCanHandleErrorsFromUnleashWithoutFailover() + { + $featureName = 'someFeature'; + + $this->mockHandler->append(new Response(200, [], 'lol')); + $this->mockHandler->append(new Response(200, [], 'lol')); + + $cache = $this->createMock(Cache::class); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.cache.failover', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + + $request = $this->createMock(Request::class); + + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + + $this->assertFalse($unleash->enabled($featureName)); + $this->assertTrue($unleash->disabled($featureName)); + } + + public function testCacheFailoverOnce() + { + $featureName = 'someFeature'; + + $this->mockHandler->append( + new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => $featureName, + 'enabled' => true, + ], + ], + ] + ) + ) + ); + $this->mockHandler->append( + new Response(500) + ); + $this->mockHandler->append( + new Response(500) + ); + $this->mockHandler->append( + new Response(500) + ); + $this->mockHandler->append( + new Response(500) + ); + $this->mockHandler->append( + new Response(500) + ); + + $cache = $this->createMock(Cache::class); + $cache->expects($this->exactly(1)) + ->method('forever') + ->with('unleash.features.failover', new FeatureFlagCollection([ + new FeatureFlag($featureName, true) + ])); + $cache->expects($this->exactly(5)) + ->method('get') + ->with('unleash.features.failover') + ->willReturn( + new FeatureFlagCollection([ + new FeatureFlag($featureName, true) + ]) + ); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + Config::set('unleash.cache.failover', true); + + $request = $this->createMock(Request::class); + + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->assertTrue($unleash->enabled($featureName), "Uncached Request"); + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->assertTrue($unleash->enabled($featureName), "Cached Request #1"); + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->assertTrue($unleash->enabled($featureName), "Cached Request #2"); + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->assertTrue($unleash->enabled($featureName), "Cached Request #3"); + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->assertTrue($unleash->enabled($featureName), "Cached Request #4"); + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->assertTrue($unleash->enabled($featureName), "Cached Request #5"); + } +} \ No newline at end of file diff --git a/tests/UnleashTest.php b/tests/UnleashTest.php index 364d598..4345585 100644 --- a/tests/UnleashTest.php +++ b/tests/UnleashTest.php @@ -2,229 +2,39 @@ namespace MikeFrancis\LaravelUnleash\Tests; -use Exception; -use GuzzleHttp\Client; -use GuzzleHttp\Handler\MockHandler; +use ErrorException; use GuzzleHttp\Psr7\Response; use Illuminate\Contracts\Cache\Repository as Cache; -use Illuminate\Contracts\Config\Repository as Config; +use Illuminate\Support\Facades\Config; use Illuminate\Http\Request; use MikeFrancis\LaravelUnleash\Tests\Stubs\ImplementedStrategy; use MikeFrancis\LaravelUnleash\Tests\Stubs\ImplementedStrategyThatIsDisabled; use MikeFrancis\LaravelUnleash\Tests\Stubs\NonImplementedStrategy; use MikeFrancis\LaravelUnleash\Unleash; -use PHPUnit\Framework\TestCase; +use MikeFrancis\LaravelUnleash\Values\FeatureFlag; +use MikeFrancis\LaravelUnleash\Values\FeatureFlagCollection; +use Orchestra\Testbench\TestCase; use Symfony\Component\HttpFoundation\Exception\JsonException; class UnleashTest extends TestCase { - protected $mockHandler; + use MockClient; - protected $client; - - protected function setUp(): void - { - parent::setUp(); - - $this->mockHandler = new MockHandler(); - - $this->client = new Client( - [ - 'handler' => $this->mockHandler, - ] - ); - } - - public function testFeaturesCanBeCached() - { - $featureName = 'someFeature'; - - $this->mockHandler->append( - new Response( - 200, - [], - json_encode( - [ - 'features' => [ - [ - 'name' => $featureName, - 'enabled' => true, - ], - ], - ] - ) - ) - ); - - $cache = $this->createMock(Cache::class); - $cache->expects($this->exactly(2)) - ->method('remember') - ->willReturn( - [ - [ - 'name' => $featureName, - 'enabled' => true, - ], - ] - ); - - $config = $this->createMock(Config::class); - $config->expects($this->at(0)) - ->method('get') - ->with('unleash.isEnabled') - ->willReturn(true); - $config->expects($this->at(1)) - ->method('get') - ->with('unleash.cache.isEnabled') - ->willReturn(true); - $config->expects($this->at(2)) - ->method('get') - ->with('unleash.cache.ttl') - ->willReturn(null); - $config->expects($this->at(3)) - ->method('get') - ->with('unleash.strategies') - ->willReturn( - [ - 'testStrategy' => ImplementedStrategy::class, - ] - ); - $config->expects($this->at(4)) - ->method('get') - ->with('unleash.isEnabled') - ->willReturn(true); - $config->expects($this->at(5)) - ->method('get') - ->with('unleash.cache.isEnabled') - ->willReturn(true); - $config->expects($this->at(6)) - ->method('get') - ->with('unleash.cache.ttl') - ->willReturn(null); - - $request = $this->createMock(Request::class); - - $unleash = new Unleash($this->client, $cache, $config, $request); - $this->assertTrue($unleash->isFeatureEnabled($featureName)); - $this->assertTrue($unleash->isFeatureEnabled($featureName)); - } - - public function testFeaturesCacheFailoverEnabled() + public function testFeatureDetectionCanBeDisabled() { - $featureName = 'someFeature'; - - $this->mockHandler->append( - new Response( - 200, - [], - json_encode( - [ - 'features' => [ - [ - 'name' => $featureName, - 'enabled' => true, - ], - ], - ] - ) - ) - ); - $this->mockHandler->append( - new Response(500) - ); - - $config = $this->createMock(Config::class); $cache = $this->createMock(Cache::class); - $config->expects($this->at(0)) - ->method('get') - ->with('unleash.isEnabled') - ->willReturn(true); - $config->expects($this->at(1)) - ->method('get') - ->with('unleash.cache.isEnabled') - ->willReturn(true); - $config->expects($this->at(2)) - ->method('get') - ->with('unleash.cache.ttl') - ->willReturn(0.1); - - $cache->expects($this->at(0)) - ->method('remember') - ->willReturn( - [ - [ - 'name' => $featureName, - 'enabled' => true, - ], - ] - ); - $cache->expects($this->at(1)) - ->method('forever') - ->with('unleash.features.failover', [ - [ - 'name' => $featureName, - 'enabled' => true, - ], - ]); - - $config->expects($this->at(3)) - ->method('get') - ->with('unleash.strategies') - ->willReturn( - [ - 'testStrategy' => ImplementedStrategy::class, - ] - ); - - - // Request 2 - $config->expects($this->at(4)) - ->method('get') - ->with('unleash.isEnabled') - ->willReturn(true); - $config->expects($this->at(5)) - ->method('get') - ->with('unleash.cache.isEnabled') - ->willReturn(true); - $config->expects($this->at(6)) - ->method('get') - ->with('unleash.cache.ttl') - ->willReturn(0.1); - - $cache->expects($this->at(2)) - ->method('remember') - ->willThrowException(new JsonException("Expected Failure: Testing")); - - $config->expects($this->at(7)) - ->method('get') - ->with('unleash.cache.failover') - ->willReturn(true); - $cache->expects($this->at(3)) - ->method('get') - ->with('unleash.features.failover') - ->willReturn( - [ - [ - 'name' => $featureName, - 'enabled' => true, - ], - ] - ); + Config::set('unleash.isEnabled', false); $request = $this->createMock(Request::class); - $unleash = new Unleash($this->client, $cache, $config, $request); + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); - $this->assertTrue($unleash->isFeatureEnabled($featureName), "Uncached Request"); - usleep(200); - $this->assertTrue($unleash->isFeatureEnabled($featureName), "Cached Request"); + $this->assertFalse($unleash->enabled('someFeature')); } - public function testFeaturesCacheFailoverDisabled() + public function testAll() { - $featureName = 'someFeature'; - $this->mockHandler->append( new Response( 200, @@ -233,99 +43,38 @@ public function testFeaturesCacheFailoverDisabled() [ 'features' => [ [ - 'name' => $featureName, + 'name' => 'someFeature', 'enabled' => true, ], + [ + 'name' => 'anotherFeature', + 'enabled' => false, + ], ], ] ) ) ); - $this->mockHandler->append( - new Response(500) - ); - $config = $this->createMock(Config::class); $cache = $this->createMock(Cache::class); - $config->expects($this->at(0)) - ->method('get') - ->with('unleash.isEnabled') - ->willReturn(true); - $config->expects($this->at(1)) - ->method('get') - ->with('unleash.cache.isEnabled') - ->willReturn(true); - $config->expects($this->at(2)) - ->method('get') - ->with('unleash.cache.ttl') - ->willReturn(0.1); - - $cache->expects($this->at(0)) - ->method('remember') - ->willReturn( - [ - [ - 'name' => $featureName, - 'enabled' => true, - ], - ] - ); - $cache->expects($this->at(1)) - ->method('forever') - ->with('unleash.features.failover', [ - [ - 'name' => $featureName, - 'enabled' => true, - ], - ]); - - $config->expects($this->at(3)) - ->method('get') - ->with('unleash.strategies') - ->willReturn( - [ - 'testStrategy' => ImplementedStrategy::class, - ] - ); - - - // Request 2 - $config->expects($this->at(4)) - ->method('get') - ->with('unleash.isEnabled') - ->willReturn(true); - $config->expects($this->at(5)) - ->method('get') - ->with('unleash.cache.isEnabled') - ->willReturn(true); - $config->expects($this->at(6)) - ->method('get') - ->with('unleash.cache.ttl') - ->willReturn(0.1); - - $cache->expects($this->at(2)) - ->method('remember') - ->willThrowException(new JsonException("Expected Failure: Testing")); - - $config->expects($this->at(7)) - ->method('get') - ->with('unleash.cache.failover') - ->willReturn(false); + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); $request = $this->createMock(Request::class); - $unleash = new Unleash($this->client, $cache, $config, $request); + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); - $this->assertTrue($unleash->isFeatureEnabled($featureName), "Uncached Request"); - usleep(200); - $this->assertFalse($unleash->isFeatureEnabled($featureName), "Cached Request"); + $features = $unleash->all(); + $this->assertInstanceOf(FeatureFlagCollection::class, $features); + $this->assertCount(2, $features); + $this->assertEquals(new FeatureFlag('someFeature', true), $features[0]); + $this->assertEquals(new FeatureFlag('anotherFeature', false), $features[1]); } - public function testFeaturesCacheFailoverEnabledIndependently() + public function testGet() { - $featureName = 'someFeature'; - $this->mockHandler->append( new Response( 200, @@ -334,7 +83,7 @@ public function testFeaturesCacheFailoverEnabledIndependently() [ 'features' => [ [ - 'name' => $featureName, + 'name' => 'someFeature', 'enabled' => true, ], ], @@ -342,130 +91,25 @@ public function testFeaturesCacheFailoverEnabledIndependently() ) ) ); - $this->mockHandler->append( - new Response(500) - ); - $config = $this->createMock(Config::class); $cache = $this->createMock(Cache::class); - $config->expects($this->at(0)) - ->method('get') - ->with('unleash.isEnabled') - ->willReturn(true); - $config->expects($this->at(1)) - ->method('get') - ->with('unleash.cache.isEnabled') - ->willReturn(false); - $config->expects($this->at(2)) - ->method('get') - ->with('unleash.featuresEndpoint') - ->willReturn('/api/client/features'); - - $cache->expects($this->at(0)) - ->method('forever') - ->with('unleash.features.failover', [ - [ - 'name' => $featureName, - 'enabled' => true, - ], - ]); - - $config->expects($this->at(3)) - ->method('get') - ->with('unleash.strategies') - ->willReturn( - [ - 'testStrategy' => ImplementedStrategy::class, - ] - ); - - - // Request 2 - $config->expects($this->at(4)) - ->method('get') - ->with('unleash.isEnabled') - ->willReturn(true); - $config->expects($this->at(5)) - ->method('get') - ->with('unleash.cache.isEnabled') - ->willReturn(false); - $config->expects($this->at(6)) - ->method('get') - ->with('unleash.featuresEndpoint') - ->willReturn('/api/client/features'); - $config->expects($this->at(7)) - ->method('get') - ->with('unleash.cache.failover') - ->willReturn(true); - - $cache->expects($this->at(1)) - ->method('get') - ->with('unleash.features.failover') - ->willReturn( - [ - [ - 'name' => $featureName, - 'enabled' => true, - ], - ] - ); - - $request = $this->createMock(Request::class); - - $unleash = new Unleash($this->client, $cache, $config, $request); - $this->assertTrue($unleash->isFeatureEnabled($featureName), "Uncached Request"); - - $unleash = new Unleash($this->client, $cache, $config, $request); - $this->assertTrue($unleash->isFeatureEnabled($featureName), "Cached Request"); - } - - public function testFeatureDetectionCanBeDisabled() - { - $cache = $this->createMock(Cache::class); - - $config = $this->createMock(Config::class); - $config->expects($this->at(0)) - ->method('get') - ->with('unleash.isEnabled') - ->willReturn(false); - - $request = $this->createMock(Request::class); - - $unleash = new Unleash($this->client, $cache, $config, $request); - - $this->assertFalse($unleash->isFeatureEnabled('someFeature')); - } - - public function testCanHandleErrorsFromUnleash() - { - $featureName = 'someFeature'; - - $this->mockHandler->append(new Response(200, [], 'lol')); - - $cache = $this->createMock(Cache::class); - - $config = $this->createMock(Config::class); - $config->expects($this->at(0)) - ->method('get') - ->with('unleash.isEnabled') - ->willReturn(true); - $config->expects($this->at(1)) - ->method('get') - ->with('unleash.cache.isEnabled')->willReturn(false); - $config->expects($this->at(2)) - ->method('get') - ->with('unleash.featuresEndpoint') - ->willReturn('/api/client/features'); + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); $request = $this->createMock(Request::class); - $unleash = new Unleash($this->client, $cache, $config, $request); + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); - $this->assertTrue($unleash->isFeatureDisabled($featureName)); + $feature = $unleash->get('someFeature'); + $this->assertInstanceOf(FeatureFlag::class, $feature); + $this->assertEquals(new FeatureFlag('someFeature', true), $feature); + $this->assertArrayHasKey('enabled', $feature); + $this->assertTrue($feature['enabled']); } - public function testIsFeatureEnabled() + public function testEnabled() { $featureName = 'someFeature'; @@ -488,28 +132,18 @@ public function testIsFeatureEnabled() $cache = $this->createMock(Cache::class); - $config = $this->createMock(Config::class); - $config->expects($this->at(0)) - ->method('get') - ->with('unleash.isEnabled') - ->willReturn(true); - $config->expects($this->at(1)) - ->method('get') - ->with('unleash.cache.isEnabled') - ->willReturn(false); - $config->expects($this->at(2)) - ->method('get') - ->with('unleash.featuresEndpoint') - ->willReturn('/api/client/features'); + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); $request = $this->createMock(Request::class); - $unleash = new Unleash($this->client, $cache, $config, $request); + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); - $this->assertTrue($unleash->isFeatureEnabled($featureName)); + $this->assertTrue($unleash->enabled($featureName)); } - public function testIsFeatureEnabledWithValidStrategy() + public function testEnabledWithValidStrategy() { $featureName = 'someFeature'; @@ -537,33 +171,18 @@ public function testIsFeatureEnabledWithValidStrategy() $cache = $this->createMock(Cache::class); - $config = $this->createMock(Config::class); - $config->expects($this->at(0)) - ->method('get') - ->with('unleash.isEnabled') - ->willReturn(true); - $config->expects($this->at(1)) - ->method('get') - ->with('unleash.cache.isEnabled') - ->willReturn(false); - $config->expects($this->at(2)) - ->method('get') - ->with('unleash.featuresEndpoint') - ->willReturn('/api/client/features'); - $config->expects($this->at(3)) - ->method('get') - ->with('unleash.strategies') - ->willReturn( - [ - 'testStrategy' => ImplementedStrategy::class, - ] - ); + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + Config::set('unleash.strategies', [ + 'testStrategy' => ImplementedStrategy::class, + ]); $request = $this->createMock(Request::class); - $unleash = new Unleash($this->client, $cache, $config, $request); + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); - $this->assertTrue($unleash->isFeatureEnabled($featureName)); + $this->assertTrue($unleash->enabled($featureName)); } public function testIsFeatureEnabledWithMultipleStrategies() @@ -597,39 +216,23 @@ public function testIsFeatureEnabledWithMultipleStrategies() $cache = $this->createMock(Cache::class); - $config = $this->createMock(Config::class); - $config->expects($this->at(0)) - ->method('get') - ->with('unleash.isEnabled') - ->willReturn(true); - $config->expects($this->at(1)) - ->method('get') - ->with('unleash.cache.isEnabled') - ->willReturn(false); - $config->expects($this->at(2)) - ->method('get') - ->with('unleash.featuresEndpoint') - ->willReturn('/api/client/features'); - $config->expects($this->at(3)) - ->method('get') - ->with('unleash.strategies') - ->willReturn( - [ - 'testStrategy' => ImplementedStrategy::class, - ], - [ - 'testStrategyThatIsDisabled' => ImplementedStrategyThatIsDisabled::class, - ] - ); + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + Config::set('unleash.strategies', [ + 'testStrategy' => ImplementedStrategy::class, + 'testStrategyThatIsDisabled' => ImplementedStrategyThatIsDisabled::class, + ]); $request = $this->createMock(Request::class); - $unleash = new Unleash($this->client, $cache, $config, $request); + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->instance(Unleash::class, $unleash); - $this->assertTrue($unleash->isFeatureEnabled($featureName)); + $this->assertTrue($unleash->enabled($featureName)); } - public function testIsFeatureDisabledWithInvalidStrategy() + public function testEnabledWithInvalidStrategy() { $featureName = 'someFeature'; @@ -657,36 +260,22 @@ public function testIsFeatureDisabledWithInvalidStrategy() $cache = $this->createMock(Cache::class); - $config = $this->createMock(Config::class); - $config->expects($this->at(0)) - ->method('get') - ->with('unleash.isEnabled') - ->willReturn(true); - $config->expects($this->at(1)) - ->method('get') - ->with('unleash.cache.isEnabled') - ->willReturn(false); - $config->expects($this->at(2)) - ->method('get') - ->with('unleash.featuresEndpoint') - ->willReturn('/api/client/features'); - $config->expects($this->at(3)) - ->method('get') - ->with('unleash.strategies') - ->willReturn( - [ - 'invalidTestStrategy' => ImplementedStrategy::class, - ] - ); + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + Config::set('unleash.strategies', [ + 'invalidTestStrategy' => ImplementedStrategy::class, + ]); $request = $this->createMock(Request::class); - $unleash = new Unleash($this->client, $cache, $config, $request); + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->instance(Unleash::class, $unleash); - $this->assertFalse($unleash->isFeatureEnabled($featureName)); + $this->assertFalse($unleash->enabled($featureName)); } - public function testIsFeatureDisabledWithStrategyThatDoesNotImplementBaseStrategy() + public function testEnabledWithStrategyThatDoesNotImplementBaseStrategy() { $featureName = 'someFeature'; @@ -714,40 +303,22 @@ public function testIsFeatureDisabledWithStrategyThatDoesNotImplementBaseStrateg $cache = $this->createMock(Cache::class); - $config = $this->createMock(Config::class); - $config->expects($this->at(0)) - ->method('get') - ->with('unleash.isEnabled') - ->willReturn(true); - $config->expects($this->at(1)) - ->method('get') - ->with('unleash.cache.isEnabled') - ->willReturn(false); - $config->expects($this->at(2)) - ->method('get') - ->with('unleash.featuresEndpoint') - ->willReturn('/api/client/features'); - $config->expects($this->at(3)) - ->method('get') - ->with('unleash.strategies') - ->willReturn( - [ - 'testStrategy' => NonImplementedStrategy::class, - ] - ); + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + Config::set('unleash.strategies', [ + 'testStrategy' => NonImplementedStrategy::class, + ]); $request = $this->createMock(Request::class); - $unleash = new Unleash($this->client, $cache, $config, $request); - - try { - $unleash->isFeatureEnabled($featureName); - } catch (Exception $e) { - $this->assertNotEmpty($e); - } + $this->expectException(ErrorException::class); + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->instance(Unleash::class, $unleash); + $unleash->enabled($featureName); } - public function testIsFeatureDisabled() + public function testDisabled() { $featureName = 'someFeature'; @@ -770,24 +341,14 @@ public function testIsFeatureDisabled() $cache = $this->createMock(Cache::class); - $config = $this->createMock(Config::class); - $config->expects($this->at(0)) - ->method('get') - ->with('unleash.isEnabled') - ->willReturn(true); - $config->expects($this->at(1)) - ->method('get') - ->with('unleash.cache.isEnabled') - ->willReturn(false); - $config->expects($this->at(2)) - ->method('get') - ->with('unleash.featuresEndpoint') - ->willReturn('/api/client/features'); + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); $request = $this->createMock(Request::class); - $unleash = new Unleash($this->client, $cache, $config, $request); + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); - $this->assertTrue($unleash->isFeatureDisabled($featureName)); + $this->assertTrue($unleash->disabled($featureName)); } } diff --git a/tests/VariantTest.php b/tests/VariantTest.php new file mode 100644 index 0000000..997a314 --- /dev/null +++ b/tests/VariantTest.php @@ -0,0 +1,1152 @@ + [ + [ + 'name' => $featureName, + 'enabled' => true, + 'strategies' => [], + 'variants' => [ + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'string', + 'value' => 'testing', + ], + 'overrides' => [] + ], + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'string', + 'value' => 'testing 2', + ], + 'overrides' => [] + ], + ], + ], + ], + ] + ) + ); + + $this->mockHandler->append($response); + $this->mockHandler->append($response); + $this->mockHandler->append($response); + + $cache = $this->createMock(Cache::class); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + + $userMock = $this->createMock(Authenticatable::class); + $userMock->expects($this->exactly(3))->method('getAuthIdentifier')->willReturn(1, 2, 3); + $request = $this->createMock(Request::class); + $request->expects($this->exactly(3))->method('user')->willReturn($userMock); + + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + + $variant = $unleash->variant($featureName, null, new Context($request)); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadString::class, $variant->payload); + $this->assertTrue(isset($variant->payload->value)); + $this->assertEquals('testing', $variant->payload->value); + $variant = $unleash->variant($featureName, null, new Context($request)); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadString::class, $variant->payload); + $this->assertTrue(isset($variant->payload->value)); + $this->assertEquals('testing', $variant->payload->value); + $variant = $unleash->variant($featureName, null, new Context($request)); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadString::class, $variant->payload); + $this->assertTrue(isset($variant->payload->value)); + $this->assertEquals('testing 2', $variant->payload->value); + } + + public function testFeatureEnabledWithVariantJSON() + { + $featureName = 'someFeature'; + + $response = new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => $featureName, + 'enabled' => true, + 'strategies' => [], + 'variants' => [ + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'json', + 'value' => '{"foo": "bar", "baz": "bat"}', + ], + 'overrides' => [] + ], + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'json', + 'value' => '{"bar": "foo", "bat": "baz"}', + ], + 'overrides' => [] + ], + ], + ], + ], + ] + ) + ); + + $this->mockHandler->append($response); + $this->mockHandler->append($response); + $this->mockHandler->append($response); + + $cache = $this->createMock(Cache::class); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + + $userMock = $this->createMock(Authenticatable::class); + $userMock->expects($this->exactly(3))->method('getAuthIdentifier')->willReturn(1, 2, 3); + $request = $this->createMock(Request::class); + $request->expects($this->exactly(3))->method('user')->willReturn($userMock); + + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + + $variant = $unleash->variant($featureName, null, new Context($request)); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadJSON::class, $variant->payload); + $this->assertTrue(isset($variant->payload->foo)); + $this->assertTrue(isset($variant->payload->baz)); + $this->assertEquals('bar', $variant->payload->foo); + $this->assertEquals('bat', $variant->payload->baz); + + $variant = $unleash->variant($featureName, null, new Context($request)); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadJSON::class, $variant->payload); + $this->assertTrue(isset($variant->payload->foo)); + $this->assertTrue(isset($variant->payload->baz)); + $this->assertEquals('bar', $variant->payload->foo); + $this->assertEquals('bat', $variant->payload->baz); + + $variant = $unleash->variant($featureName, null, new Context($request)); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadJSON::class, $variant->payload); + $this->assertTrue(isset($variant->payload->bar)); + $this->assertTrue(isset($variant->payload->bat)); + $this->assertEquals('foo', $variant->payload->bar); + $this->assertEquals('baz', $variant->payload->bat); + } + + public function testFeatureEnabledWithVariantCSV() + { + $featureName = 'someFeature'; + + $response = new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => $featureName, + 'enabled' => true, + 'strategies' => [], + 'variants' => [ + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'csv', + 'value' => '1,2,3', + ], + 'overrides' => [] + ], + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'csv', + 'value' => '4,5,6', + ], + 'overrides' => [] + ], + ], + ], + ], + ] + ) + ); + + $this->mockHandler->append($response); + $this->mockHandler->append($response); + $this->mockHandler->append($response); + + $cache = $this->createMock(Cache::class); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + + $userMock = $this->createMock(Authenticatable::class); + $userMock->expects($this->exactly(3))->method('getAuthIdentifier')->willReturn(1, 2, 3); + $request = $this->createMock(Request::class); + $request->expects($this->exactly(3))->method('user')->willReturn($userMock); + + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + + $variant = $unleash->variant($featureName, null, new Context($request)); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadCSV::class, $variant->payload); + $this->assertEquals([1,2,3], $variant->payload->values); + $this->assertEquals(1, $variant->payload[0]); + $this->assertEquals(2, $variant->payload[1]); + $this->assertEquals(3, $variant->payload[2]); + + $variant = $unleash->variant($featureName, null, new Context($request)); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadCSV::class, $variant->payload); + $this->assertEquals([1,2,3], $variant->payload->values); + $this->assertEquals(1, $variant->payload[0]); + $this->assertEquals(2, $variant->payload[1]); + $this->assertEquals(3, $variant->payload[2]); + + $variant = $unleash->variant($featureName, null, new Context($request)); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadCSV::class, $variant->payload); + $this->assertEquals([4,5,6], $variant->payload->values); + $this->assertEquals(4, $variant->payload[0]); + $this->assertEquals(5, $variant->payload[1]); + $this->assertEquals(6, $variant->payload[2]); + } + + public function testFeatureEnabledWithVariantCSVReadOnly() + { + $featureName = 'someFeature'; + + $response = new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => $featureName, + 'enabled' => true, + 'strategies' => [], + 'variants' => [ + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'csv', + 'value' => '1,2,3', + ], + 'overrides' => [] + ], + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'csv', + 'value' => '4,5,6', + ], + 'overrides' => [] + ], + ], + ], + ], + ] + ) + ); + + $this->mockHandler->append($response); + + $cache = $this->createMock(Cache::class); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + + $userMock = $this->createMock(Authenticatable::class); + $userMock->expects($this->once())->method('getAuthIdentifier')->willReturn(1); + $request = $this->createMock(Request::class); + $request->expects($this->once())->method('user')->willReturn($userMock); + + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + + $variant = $unleash->variant($featureName, null, new Context($request)); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertTrue(isset($variant->payload)); + $this->assertInstanceOf(PayloadCSV::class, $variant->payload); + $this->assertTrue(isset($variant->payload->values)); + $this->assertEquals([1,2,3], $variant->payload->values); + $this->assertTrue(isset($variant->payload[0])); + $this->assertEquals(1, $variant->payload[0]); + $this->assertEquals(2, $variant->payload[1]); + $this->assertEquals(3, $variant->payload[2]); + + try { + $variant->payload[0] = 2; + } catch (\Exception $e) { + $this->assertInstanceOf(\BadMethodCallException::class, $e); + } + + try { + $variant->payload[] = 2; + } catch (\Exception $e) { + $this->assertInstanceOf(\BadMethodCallException::class, $e); + } + + try { + unset($variant->payload[0]); + } catch (\Exception $e) { + $this->assertInstanceOf(\BadMethodCallException::class, $e); + } + } + + public function testFeatureEnabledWithVariantUnknown() + { + $featureName = 'someFeature'; + + $response = new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => $featureName, + 'enabled' => true, + 'strategies' => [], + 'variants' => [ + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'unknown', + 'value' => 'testing', + ], + 'overrides' => [] + ], + ], + ], + ], + ] + ) + ); + + $this->mockHandler->append($response); + + $cache = $this->createMock(Cache::class); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + + $userMock = $this->createMock(Authenticatable::class); + $userMock->expects($this->once())->method('getAuthIdentifier')->willReturn(1); + $request = $this->createMock(Request::class); + $request->expects($this->once())->method('user')->willReturn($userMock); + + $this->expectException(UnknownVariantTypeException::class); + + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $unleash->variant($featureName, null, new Context($request)); + } + + public function testFeatureEnabledWithVariantNoContext() + { + $featureName = 'someFeature'; + + $response = new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => $featureName, + 'enabled' => true, + 'strategies' => [], + 'variants' => [ + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'string', + 'value' => 'testing', + ], + 'overrides' => [] + ], + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'string', + 'value' => 'testing 2', + ], + 'overrides' => [] + ], + ], + ], + ], + ] + ) + ); + + $this->mockHandler->append($response); + $this->mockHandler->append($response); + $this->mockHandler->append($response); + + $cache = $this->createMock(Cache::class); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + + $userMock = $this->createMock(Authenticatable::class); + $userMock->expects($this->exactly(3))->method('getAuthIdentifier')->willReturn(1, 2, 3); + $request = $this->createMock(Request::class); + $request->expects($this->exactly(3))->method('user')->willReturn($userMock); + + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->instance(Unleash::class, $unleash); + + $variant = $unleash->variant($featureName); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadString::class, $variant->payload); + $this->assertTrue(isset($variant->payload->value)); + $this->assertEquals('testing', $variant->payload->value); + $variant = $unleash->variant($featureName); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadString::class, $variant->payload); + $this->assertTrue(isset($variant->payload->value)); + $this->assertEquals('testing', $variant->payload->value); + $variant = $unleash->variant($featureName); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadString::class, $variant->payload); + $this->assertTrue(isset($variant->payload->value)); + $this->assertEquals('testing 2', $variant->payload->value); + } + + public function testFeatureEnabledWithVariantWithStickiness() + { + $featureName = 'someFeature'; + + $response = new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => $featureName, + 'enabled' => true, + 'strategies' => [], + 'variants' => [ + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'userId', + 'payload' => [ + 'type' => 'string', + 'value' => 'testing', + ], + 'overrides' => [] + ], + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'userId', + 'payload' => [ + 'type' => 'string', + 'value' => 'testing 2', + ], + 'overrides' => [] + ], + ], + ], + ], + ] + ) + ); + + $this->mockHandler->append($response); + $this->mockHandler->append($response); + $this->mockHandler->append($response); + + $cache = $this->createMock(Cache::class); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + + $userMock = $this->createMock(Authenticatable::class); + $userMock->expects($this->exactly(3))->method('getAuthIdentifier')->willReturn(1, 2, 3); + $request = $this->createMock(Request::class); + $request->expects($this->exactly(3))->method('user')->willReturn($userMock); + + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->instance(Unleash::class, $unleash); + + $variant = $unleash->variant($featureName); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadString::class, $variant->payload); + $this->assertTrue(isset($variant->payload->value)); + $this->assertEquals('testing', $variant->payload->value); + $variant = $unleash->variant($featureName); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadString::class, $variant->payload); + $this->assertTrue(isset($variant->payload->value)); + $this->assertEquals('testing', $variant->payload->value); + $variant = $unleash->variant($featureName); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadString::class, $variant->payload); + $this->assertTrue(isset($variant->payload->value)); + $this->assertEquals('testing 2', $variant->payload->value); + } + + public function testFeatureEnabledWithVariantWithNullStickiness() + { + $featureName = 'someFeature'; + + $response = new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => $featureName, + 'enabled' => true, + 'strategies' => [], + 'variants' => [ + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'sessionId', + 'payload' => [ + 'type' => 'string', + 'value' => 'testing', + ], + 'overrides' => [] + ], + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'sessionId', + 'payload' => [ + 'type' => 'string', + 'value' => 'testing 2', + ], + 'overrides' => [] + ], + ], + ], + ], + ] + ) + ); + + $this->mockHandler->append($response); + $this->mockHandler->append($response); + $this->mockHandler->append($response); + + $cache = $this->createMock(Cache::class); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + + $userMock = $this->createMock(Authenticatable::class); + $userMock->expects($this->exactly(3))->method('getAuthIdentifier')->willReturn(1, 2, 3); + $request = $this->createMock(Request::class); + $request->expects($this->exactly(3))->method('user')->willReturn($userMock); + + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->instance(Unleash::class, $unleash); + + $variant = $unleash->variant($featureName); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadString::class, $variant->payload); + $this->assertTrue(isset($variant->payload->value)); + $this->assertTrue(in_array($variant->payload->value, ['testing', 'testing 2'])); + $variant = $unleash->variant($featureName); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadString::class, $variant->payload); + $this->assertTrue(isset($variant->payload->value)); + $this->assertTrue(in_array($variant->payload->value, ['testing', 'testing 2'])); + $variant = $unleash->variant($featureName); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadString::class, $variant->payload); + $this->assertTrue(isset($variant->payload->value)); + $this->assertTrue(in_array($variant->payload->value, ['testing', 'testing 2'])); + } + + public function testFeatureEnabledWithVariantStringWithOverrides() + { + $featureName = 'someFeature'; + + $response = new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => $featureName, + 'enabled' => true, + 'strategies' => [], + 'variants' => [ + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'string', + 'value' => 'testing', + ], + 'overrides' => [ + [ + 'contextName' => 'userId', + 'values' => ['3'], + ], + ] + ], + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'string', + 'value' => 'testing 2', + ], + 'overrides' => [ + [ + 'contextName' => 'userId', + 'values' => ['1', '2'], + ] + ] + ], + ], + ], + ], + ] + ) + ); + + $this->mockHandler->append($response); + $this->mockHandler->append($response); + $this->mockHandler->append($response); + + $cache = $this->createMock(Cache::class); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + + $userMock = $this->createMock(Authenticatable::class); + $userMock->expects($this->exactly(4))->method('getAuthIdentifier')->willReturn(1, 2, 3, 4); + $request = $this->createMock(Request::class); + $request->expects($this->exactly(4))->method('user')->willReturn($userMock); + + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + + $variant = $unleash->variant($featureName, null, new Context($request)); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadString::class, $variant->payload); + $this->assertEquals('testing 2', $variant->payload->value); + $variant = $unleash->variant($featureName, null, new Context($request)); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadString::class, $variant->payload); + $this->assertEquals('testing 2', $variant->payload->value); + $variant = $unleash->variant($featureName, null, new Context($request)); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadString::class, $variant->payload); + $this->assertEquals('testing', $variant->payload->value); + $this->assertTrue(isset($variant->overrides)); + $this->assertTrue(isset($variant->overrides[0]->contextName)); + + $variant = $unleash->variant($featureName, null, new Context($request)); + $this->assertInstanceOf(Variant::class, $variant); + $this->assertInstanceOf(PayloadDefault::class, $variant->payload); + $this->assertNull($variant->payload->value); + } + + public function testFeatureEnabledWithStrategyWithVariant() + { + $featureName = 'someFeature'; + + $response = new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => $featureName, + 'enabled' => true, + 'strategies' => [ + [ + "name" => "userWithId", + "constraints" => [], + "parameters" => [ + "userIds" => "1,3", + ], + ], + ], + 'variants' => [ + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'string', + 'value' => 'testing', + ], + 'overrides' => [] + ], + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'string', + 'value' => 'testing 2', + ], + 'overrides' => [] + ], + ], + ], + ], + ] + ) + ); + + $this->mockHandler->append($response); + $this->mockHandler->append($response); + $this->mockHandler->append($response); + + $cache = $this->createMock(Cache::class); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + Config::set('unleash.strategies', [ + 'userWithId' => UserWithIdStrategy::class, + ]); + + $userMock = $this->createMock(Authenticatable::class); + $userMock->expects($this->exactly(9))->method('getAuthIdentifier')->willReturn(1, 1, 1, 2, 2, 2, 3, 3, 3); + $request = $this->createMock(Request::class); + $request->expects($this->exactly(9))->method('user')->willReturn($userMock); + + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->instance(Unleash::class, $unleash); + + $feature = $unleash->get($featureName); + $this->assertTrue($feature->enabled()); + $this->assertEquals("testing", $feature->variant(null, new Context($request))->payload->value); + $feature = $unleash->get($featureName); + $this->assertFalse($feature->enabled()); + $this->assertEquals(null, $feature->variant(null, new Context($request))->payload->value); + $feature = $unleash->get($featureName); + $this->assertTrue($feature->enabled()); + $this->assertEquals("testing 2", $feature->variant(null, new Context($request))->payload->value); + } + + public function testFeatureEnabledWithStrategyWithVariantWithOverrides() + { + $featureName = 'someFeature'; + + $response = new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => $featureName, + 'enabled' => true, + 'strategies' => [ + [ + "name" => "userWithId", + "constraints" => [], + "parameters" => [ + "userIds" => "1,3", + ], + ], + ], + 'variants' => [ + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'string', + 'value' => 'testing', + ], + 'overrides' => [ + [ + 'contextName' => 'userId', + 'values' => ['3'] + ] + ] + ], + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'string', + 'value' => 'testing 2', + ], + 'overrides' => [ + [ + 'contextName' => 'userId', + 'values' => ['1', '2'] + ] + ] + ], + ], + ], + ], + ] + ) + ); + + $this->mockHandler->append($response); + $this->mockHandler->append($response); + $this->mockHandler->append($response); + + $cache = $this->createMock(Cache::class); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + Config::set('unleash.strategies', [ + 'userWithId' => UserWithIdStrategy::class, + ]); + + $userMock = $this->createMock(Authenticatable::class); + $userMock->expects($this->exactly(9))->method('getAuthIdentifier')->willReturn(1, 1, 1, 2, 2, 2, 3, 3, 3); + $request = $this->createMock(Request::class); + $request->expects($this->exactly(9))->method('user')->willReturn($userMock); + + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->instance(Unleash::class, $unleash); + + $feature = $unleash->get($featureName); + $this->assertTrue($feature->enabled()); + $this->assertEquals("testing 2", $feature->variant(null, new Context($request))->payload->value); + $feature = $unleash->get($featureName); + $this->assertFalse($feature->enabled()); + $this->assertEquals(null, $feature->variant(null, new Context($request))->payload->value); + $feature = $unleash->get($featureName); + $this->assertTrue($feature->enabled()); + $this->assertEquals("testing", $feature->variant(null, new Context($request))->payload->value); + } + + public function testFeatureEnabledWithVariantNotFound() + { + $featureName = 'someFeature'; + + $response = new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => $featureName, + 'enabled' => true, + 'strategies' => [], + 'variants' => [ + [ + 'name' => 'testVariant', + 'weight' => 0, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'string', + 'value' => 'testing', + ], + 'overrides' => [] + ], + ], + ], + ], + ] + ) + ); + + $this->mockHandler->append($response); + + $cache = $this->createMock(Cache::class); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + + $userMock = $this->createMock(Authenticatable::class); + $userMock->expects($this->exactly(3))->method('getAuthIdentifier')->willReturn(1, 2, 3); + $request = $this->createMock(Request::class); + $request->expects($this->exactly(3))->method('user')->willReturn($userMock); + + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $variant = $unleash->variant($featureName, null, new Context($request)); + $this->assertInstanceOf(PayloadDefault::class, $variant->payload); + $this->assertNull($variant->payload->value); + $variant = $unleash->variant($featureName, true, new Context($request)); + $this->assertInstanceOf(PayloadDefault::class, $variant->payload); + $this->assertTrue($variant->payload->value); + $variant = $unleash->variant($featureName, ['bing' => 'qux'], new Context($request)); + $this->assertInstanceOf(PayloadDefault::class, $variant->payload); + $this->assertEquals(['bing' => 'qux'], $variant->payload->value); + } + + public function testFeatureEnabledWithVariantUnweighted() + { + $featureName = 'someFeature'; + + $response = new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => $featureName, + 'enabled' => true, + 'strategies' => [], + 'variants' => [ + [ + 'name' => 'testVariant', + 'weight' => 100, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'string', + 'value' => 'testing', + ], + 'overrides' => [ + [ + 'contextName' => 'userId', + 'values' => ['3'] + ] + ] + ], + [ + 'name' => 'testVariant', + 'weight' => 0, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'string', + 'value' => 'testing 2', + ], + 'overrides' => [] + ], + ], + ], + ], + ] + ) + ); + + $this->mockHandler->append($response); + + $cache = $this->createMock(Cache::class); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + + $userMock = $this->createMock(Authenticatable::class); + $userMock->expects($this->exactly(3))->method('getAuthIdentifier')->willReturn(1, 2, 3); + $request = $this->createMock(Request::class); + $request->expects($this->exactly(3))->method('user')->willReturn($userMock); + + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->assertNull($unleash->variant($featureName, null, new Context($request))->payload->value); + $this->assertTrue($unleash->variant($featureName, true, new Context($request))->payload->value); + $this->assertEquals('testing', $unleash->variant($featureName, ['bing' => 'qux'], new Context($request))->payload->value); + } + + public function testFeatureDisabledWithVariant() + { + $featureName = 'someFeature'; + + $response = new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => $featureName, + 'enabled' => false, + 'strategies' => [], + 'variants' => [ + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'string', + 'value' => 'testing', + ], + 'overrides' => [] + ], + ], + ], + ], + ] + ) + ); + + $this->mockHandler->append($response); + + $cache = $this->createMock(Cache::class); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + + $userMock = $this->createMock(Authenticatable::class); + $userMock->expects($this->exactly(3))->method('getAuthIdentifier')->willReturn(1, 2, 3); + $request = $this->createMock(Request::class); + $request->expects($this->exactly(3))->method('user')->willReturn($userMock); + + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->assertNull($unleash->variant($featureName, null, new Context($request))->payload->value); + $this->assertTrue($unleash->variant($featureName, true, new Context($request))->payload->value); + $this->assertEquals(['bing' => 'qux'], $unleash->variant($featureName, ['bing' => 'qux'], new Context($request))->payload->value); + } + + public function testFeatureDisabledWithVariantWithOverride() + { + $featureName = 'someFeature'; + + $response = new Response( + 200, + [], + json_encode( + [ + 'features' => [ + [ + 'name' => $featureName, + 'enabled' => false, + 'strategies' => [], + 'variants' => [ + [ + 'name' => 'testVariant', + 'weight' => 500, + 'weightType' => 'fixed', + 'stickiness' => 'default', + 'payload' => [ + 'type' => 'string', + 'value' => 'testing', + ], + 'overrides' => [] + ], + ], + ], + ], + ] + ) + ); + + $this->mockHandler->append($response); + + $cache = $this->createMock(Cache::class); + + Config::set('unleash.isEnabled', true); + Config::set('unleash.cache.isEnabled', false); + Config::set('unleash.featuresEndpoint', '/api/client/features'); + + $userMock = $this->createMock(Authenticatable::class); + $userMock->expects($this->exactly(3))->method('getAuthIdentifier')->willReturn(1, 2, 3); + $request = $this->createMock(Request::class); + $request->expects($this->exactly(3))->method('user')->willReturn($userMock); + + $unleash = new Unleash($this->client, $cache, Config::getFacadeRoot(), $request); + $this->assertNull($unleash->variant($featureName, null, new Context($request))->payload->value); + $this->assertTrue($unleash->variant($featureName, true, new Context($request))->payload->value); + $this->assertEquals(['bing' => 'qux'], $unleash->variant($featureName, ['bing' => 'qux'], new Context($request))->payload->value); + } +} \ No newline at end of file