From ab33924e5de0d4f93d5f424958421ba282780aca Mon Sep 17 00:00:00 2001 From: stevethomas Date: Mon, 29 Aug 2022 16:39:55 +1000 Subject: [PATCH 1/6] test blade directive --- tests/BladeTest.php | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/BladeTest.php diff --git a/tests/BladeTest.php b/tests/BladeTest.php new file mode 100644 index 0000000..dca781b --- /dev/null +++ b/tests/BladeTest.php @@ -0,0 +1,43 @@ + 'array', + 'feature-flags.cache_prefix' => 'testing', + ]); + + cache()->store('array')->clear(); +}); + +afterEach(function () { + FeatureFlag::reset(); +}); + +it('does not reveal things when feature is off', function () { + Feature::factory()->create([ + 'name' => 'some-feature', + 'state' => FeatureState::off() + ]); + + $view = $this->blade("@feature('some-feature') secret things @endfeature"); + + $view->assertDontSee('secret things'); +}); + +it('reveals things when feature is on ', function () { + Feature::factory()->create([ + 'name' => 'some-feature', + 'state' => FeatureState::on() + ]); + + $view = $this->blade("@feature('some-feature') secret things @endfeature"); + + $view->assertSee('secret things'); +}); From a0e14edfb3917745493e55133907a8e28f346771 Mon Sep 17 00:00:00 2001 From: stevethomas Date: Mon, 29 Aug 2022 16:55:53 +1000 Subject: [PATCH 2/6] reorganise tests --- .../{FeaturesTest.php => FeatureFlagTest.php} | 60 +-------------- tests/MiddlewareTest.php | 76 +++++++++++++++++++ 2 files changed, 78 insertions(+), 58 deletions(-) rename tests/{FeaturesTest.php => FeatureFlagTest.php} (78%) create mode 100644 tests/MiddlewareTest.php diff --git a/tests/FeaturesTest.php b/tests/FeatureFlagTest.php similarity index 78% rename from tests/FeaturesTest.php rename to tests/FeatureFlagTest.php index cb2ee50..e6d933b 100644 --- a/tests/FeaturesTest.php +++ b/tests/FeatureFlagTest.php @@ -1,10 +1,9 @@ 'testing', ]); - Route::get('test-middleware', function () { - return 'ok'; - })->middleware(VerifyFeatureIsOn::class . ':some-feature'); - cache()->store('array')->clear(); }); @@ -158,7 +153,7 @@ FeatureFlag::updateFeatureState('some-feature', FeatureState::on()); - Event::assertDispatched(\Codinglabs\FeatureFlags\Events\FeatureUpdatedEvent::class); + Event::assertDispatched(FeatureUpdatedEvent::class); expect(FeatureFlag::isOn('some-feature'))->toBeTrue() ->and(FeatureFlag::isOff('some-feature'))->toBeFalse() ->and(cache()->store('array')->get('testing.some-feature'))->toBe(FeatureState::on()->value); @@ -171,54 +166,3 @@ expect(config('feature-flags.cache_store'))->toBe('file'); }); - -it('returns a 500 status when a feature does not exist', function () { - $this->withoutExceptionHandling(); - - $this->expectException(MissingFeatureException::class); - - $this->get('test-middleware') - ->assertStatus(500); -}); - -it('returns a 404 status when a feature is off', function () { - Feature::factory()->create([ - 'name' => 'some-feature', - 'state' => FeatureState::off() - ]); - - $this->get('test-middleware') - ->assertStatus(404); -}); - -it('returns a 404 status when a feature is dynamic', function () { - Feature::factory()->create([ - 'name' => 'some-feature', - 'state' => FeatureState::dynamic() - ]); - - $this->get('test-middleware') - ->assertStatus(404); -}); - -it('returns an ok status when a feature is dynamic and enabled', function () { - Feature::factory()->create([ - 'name' => 'some-feature', - 'state' => FeatureState::dynamic() - ]); - - FeatureFlag::registerDynamicHandler('some-feature', fn ($feature) => true); - - $this->get('test-middleware') - ->assertOk(); -}); - -it('returns an ok status when a feature is on', function () { - Feature::factory()->create([ - 'name' => 'some-feature', - 'state' => FeatureState::on() - ]); - - $this->get('test-middleware') - ->assertOk(); -}); diff --git a/tests/MiddlewareTest.php b/tests/MiddlewareTest.php new file mode 100644 index 0000000..281ba8f --- /dev/null +++ b/tests/MiddlewareTest.php @@ -0,0 +1,76 @@ + 'array', + 'feature-flags.cache_prefix' => 'testing', + ]); + + Route::get('test-middleware', function () { + return 'ok'; + })->middleware(VerifyFeatureIsOn::class . ':some-feature'); + + cache()->store('array')->clear(); +}); + +afterEach(function () { + FeatureFlag::reset(); +}); + +it('returns a 500 status when a feature does not exist', function () { + $this->withoutExceptionHandling(); + + $this->expectException(MissingFeatureException::class); + + $this->get('test-middleware') + ->assertStatus(500); +}); + +it('returns a 404 status when a feature is off', function () { + Feature::factory()->create([ + 'name' => 'some-feature', + 'state' => FeatureState::off() + ]); + + $this->get('test-middleware') + ->assertStatus(404); +}); + +it('returns a 404 status when a feature is dynamic', function () { + Feature::factory()->create([ + 'name' => 'some-feature', + 'state' => FeatureState::dynamic() + ]); + + $this->get('test-middleware') + ->assertStatus(404); +}); + +it('returns an ok status when a feature is dynamic and enabled', function () { + Feature::factory()->create([ + 'name' => 'some-feature', + 'state' => FeatureState::dynamic() + ]); + + FeatureFlag::registerDynamicHandler('some-feature', fn ($feature) => true); + + $this->get('test-middleware') + ->assertOk(); +}); + +it('returns an ok status when a feature is on', function () { + Feature::factory()->create([ + 'name' => 'some-feature', + 'state' => FeatureState::on() + ]); + + $this->get('test-middleware') + ->assertOk(); +}); From 17491eee88f53090cf524a7a65cea20536951709 Mon Sep 17 00:00:00 2001 From: stevethomas Date: Mon, 29 Aug 2022 17:15:19 +1000 Subject: [PATCH 3/6] add sync features command and tests --- config/feature-flags.php | 15 +++- src/Actions/SyncFeaturesAction.php | 25 +++++++ tests/SyncFeaturesActionTest.php | 112 +++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 src/Actions/SyncFeaturesAction.php create mode 100644 tests/SyncFeaturesActionTest.php diff --git a/config/feature-flags.php b/config/feature-flags.php index 766c68a..8d3c0e3 100644 --- a/config/feature-flags.php +++ b/config/feature-flags.php @@ -2,6 +2,17 @@ return [ + /* + |-------------------------------------------------------------------------- + | Features + |-------------------------------------------------------------------------- + | + | Declare features that are managed by the app with the Feature + | Flag package. The format is ['name' => FeatureState::on()]. + */ + + 'features' => [], + /* |-------------------------------------------------------------------------- | Cache @@ -20,8 +31,8 @@ | Models |-------------------------------------------------------------------------- | - | If you need to customise any models used then you can swap them out by - | replacing the default models defined here. + | If you need to customise any models used then you can swap + | them out by replacing the default models defined here. */ 'feature_model' => \Codinglabs\FeatureFlags\Models\Feature::class, diff --git a/src/Actions/SyncFeaturesAction.php b/src/Actions/SyncFeaturesAction.php new file mode 100644 index 0000000..7273e19 --- /dev/null +++ b/src/Actions/SyncFeaturesAction.php @@ -0,0 +1,25 @@ +map(fn ($state, $name) => [ + 'name' => $name, + 'state' => $state + ]); + + $featureModels = Feature::all(); + + $featureModels->whereNotIn('name', $features->pluck('name')) + ->each(fn (Feature $feature) => $feature->delete()); + + $features->whereNotIn('name', $featureModels->pluck('name')) + ->each(fn (array $feature) => Feature::create($feature)); + } +} diff --git a/tests/SyncFeaturesActionTest.php b/tests/SyncFeaturesActionTest.php new file mode 100644 index 0000000..51df20d --- /dev/null +++ b/tests/SyncFeaturesActionTest.php @@ -0,0 +1,112 @@ + 'array', + 'feature-flags.cache_prefix' => 'testing', + ]); + + cache()->store('array')->clear(); +}); + +afterEach(function () { + FeatureFlag::reset(); +}); + +it('adds features that have no been synced', function () { + config([ + 'feature-flags.features' => [ + 'some-feature' => FeatureState::on(), + 'some-other-feature' => FeatureState::off(), + 'some-dynamic-feature' => FeatureState::dynamic(), + ], + ]); + + (new SyncFeaturesAction)->__invoke(); + + $this->assertDatabaseCount('features', 3); + + $this->assertDatabaseHas('features', [ + 'name' => 'some-feature', + 'state' => FeatureState::on(), + ]); + + $this->assertDatabaseHas('features', [ + 'name' => 'some-other-feature', + 'state' => FeatureState::off(), + ]); + + $this->assertDatabaseHas('features', [ + 'name' => 'some-dynamic-feature', + 'state' => FeatureState::dynamic(), + ]); +}); + +it('skips features that have already been synced even if the state has changed', function () { + Feature::factory()->create([ + 'name' => 'some-feature', + 'state' => FeatureState::off() + ]); + + Feature::factory()->create([ + 'name' => 'some-other-feature', + 'state' => FeatureState::on() + ]); + + config([ + 'feature-flags.features' => [ + 'some-feature' => FeatureState::on(), + 'some-other-feature' => FeatureState::on(), + ], + ]); + + (new SyncFeaturesAction)->__invoke(); + + $this->assertDatabaseCount('features', 2); + + $this->assertDatabaseHas('features', [ + 'name' => 'some-feature', + 'state' => FeatureState::off(), + ]); + + $this->assertDatabaseHas('features', [ + 'name' => 'some-other-feature', + 'state' => FeatureState::on(), + ]); +}); + +it('removes features that have been removed', function () { + Feature::factory()->create([ + 'name' => 'some-feature', + 'state' => FeatureState::off() + ]); + + Feature::factory()->create([ + 'name' => 'some-other-feature', + 'state' => FeatureState::on() + ]); + + config([ + 'feature-flags.features' => [ + 'some-feature' => FeatureState::off(), + ], + ]); + + (new SyncFeaturesAction)->__invoke(); + + $this->assertDatabaseCount('features', 1); + + $this->assertDatabaseHas('features', [ + 'name' => 'some-feature', + 'state' => FeatureState::off(), + ]); + + $this->assertDatabaseMissing('features', [ + 'name' => 'some-other-feature', + ]); +}); From 2fcf63e84ab53a3c2b93ea113bdd96acd4a8f7f8 Mon Sep 17 00:00:00 2001 From: stevethomas Date: Mon, 29 Aug 2022 17:28:08 +1000 Subject: [PATCH 4/6] always-on config item allows features to always be on --- config/feature-flags.php | 12 ++++++ src/Actions/SyncFeaturesAction.php | 5 ++- tests/SyncFeaturesActionTest.php | 64 ++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/config/feature-flags.php b/config/feature-flags.php index 8d3c0e3..0ffc3ea 100644 --- a/config/feature-flags.php +++ b/config/feature-flags.php @@ -13,6 +13,18 @@ 'features' => [], + /* + |-------------------------------------------------------------------------- + | Always On + |-------------------------------------------------------------------------- + | + | Declare the environments where features will be synced to the on + | state. This is useful if features should always be on locally. + | Note this only impacts the behaviour of the sync action. + */ + + 'always_on' => [], + /* |-------------------------------------------------------------------------- | Cache diff --git a/src/Actions/SyncFeaturesAction.php b/src/Actions/SyncFeaturesAction.php index 7273e19..a26cdee 100644 --- a/src/Actions/SyncFeaturesAction.php +++ b/src/Actions/SyncFeaturesAction.php @@ -3,6 +3,7 @@ namespace Codinglabs\FeatureFlags\Actions; use Codinglabs\FeatureFlags\Models\Feature; +use Codinglabs\FeatureFlags\Enums\FeatureState; class SyncFeaturesAction { @@ -11,7 +12,9 @@ public function __invoke(): void $features = collect(config('feature-flags.features')) ->map(fn ($state, $name) => [ 'name' => $name, - 'state' => $state + 'state' => app()->environment(config('feature-flags.always_on', [])) + ? FeatureState::on() + : $state ]); $featureModels = Feature::all(); diff --git a/tests/SyncFeaturesActionTest.php b/tests/SyncFeaturesActionTest.php index 51df20d..31b4f40 100644 --- a/tests/SyncFeaturesActionTest.php +++ b/tests/SyncFeaturesActionTest.php @@ -110,3 +110,67 @@ 'name' => 'some-other-feature', ]); }); + +it('overrides the state when the always on config is used and the environment matches', function () { + app()->detectEnvironment(fn () => 'staging'); + + config([ + 'feature-flags.features' => [ + 'some-feature' => FeatureState::on(), + 'some-other-feature' => FeatureState::off(), + 'some-dynamic-feature' => FeatureState::dynamic(), + ], + 'feature-flags.always_on' => ['staging'], + ]); + + (new SyncFeaturesAction)->__invoke(); + + $this->assertDatabaseCount('features', 3); + + $this->assertDatabaseHas('features', [ + 'name' => 'some-feature', + 'state' => FeatureState::on(), + ]); + + $this->assertDatabaseHas('features', [ + 'name' => 'some-other-feature', + 'state' => FeatureState::on(), + ]); + + $this->assertDatabaseHas('features', [ + 'name' => 'some-dynamic-feature', + 'state' => FeatureState::on(), + ]); +}); + +it('does not override the state when the always on environment does not match', function () { + app()->detectEnvironment(fn () => 'production'); + + config([ + 'feature-flags.features' => [ + 'some-feature' => FeatureState::on(), + 'some-other-feature' => FeatureState::off(), + 'some-dynamic-feature' => FeatureState::dynamic(), + ], + 'feature-flags.always_on' => ['local', 'staging'], + ]); + + (new SyncFeaturesAction)->__invoke(); + + $this->assertDatabaseCount('features', 3); + + $this->assertDatabaseHas('features', [ + 'name' => 'some-feature', + 'state' => FeatureState::on(), + ]); + + $this->assertDatabaseHas('features', [ + 'name' => 'some-other-feature', + 'state' => FeatureState::off(), + ]); + + $this->assertDatabaseHas('features', [ + 'name' => 'some-dynamic-feature', + 'state' => FeatureState::dynamic(), + ]); +}); From 3fd85152fd2627a07d70b718a90a847fb9b4ccfc Mon Sep 17 00:00:00 2001 From: stevethomas Date: Mon, 29 Aug 2022 17:29:03 +1000 Subject: [PATCH 5/6] wip it --- tests/SyncFeaturesActionTest.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/SyncFeaturesActionTest.php b/tests/SyncFeaturesActionTest.php index 31b4f40..2f54226 100644 --- a/tests/SyncFeaturesActionTest.php +++ b/tests/SyncFeaturesActionTest.php @@ -27,7 +27,7 @@ ], ]); - (new SyncFeaturesAction)->__invoke(); + (new SyncFeaturesAction())->__invoke(); $this->assertDatabaseCount('features', 3); @@ -65,7 +65,7 @@ ], ]); - (new SyncFeaturesAction)->__invoke(); + (new SyncFeaturesAction())->__invoke(); $this->assertDatabaseCount('features', 2); @@ -97,7 +97,7 @@ ], ]); - (new SyncFeaturesAction)->__invoke(); + (new SyncFeaturesAction())->__invoke(); $this->assertDatabaseCount('features', 1); @@ -123,7 +123,7 @@ 'feature-flags.always_on' => ['staging'], ]); - (new SyncFeaturesAction)->__invoke(); + (new SyncFeaturesAction())->__invoke(); $this->assertDatabaseCount('features', 3); @@ -155,7 +155,7 @@ 'feature-flags.always_on' => ['local', 'staging'], ]); - (new SyncFeaturesAction)->__invoke(); + (new SyncFeaturesAction())->__invoke(); $this->assertDatabaseCount('features', 3); From c48931dd2123191512d7f68d535b5ec4bb2f65fb Mon Sep 17 00:00:00 2001 From: stevethomas Date: Tue, 30 Aug 2022 10:07:18 +1000 Subject: [PATCH 6/6] increase test coverage --- src/Casts/FeatureStateCast.php | 4 +-- src/Models/Feature.php | 3 +- tests/FeatureFlagTest.php | 63 ++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/Casts/FeatureStateCast.php b/src/Casts/FeatureStateCast.php index 3a47947..a0d6913 100644 --- a/src/Casts/FeatureStateCast.php +++ b/src/Casts/FeatureStateCast.php @@ -8,12 +8,12 @@ class FeatureStateCast implements CastsAttributes { - public function get($model, string $key, $value, array $attributes) + public function get($model, string $key, $value, array $attributes): FeatureState { return FeatureState::from($attributes['state']); } - public function set($model, string $key, $value, array $attributes) + public function set($model, string $key, $value, array $attributes): array { if (! $value instanceof FeatureState) { throw new InvalidArgumentException('The given value is not an instance of FeatureState.'); diff --git a/src/Models/Feature.php b/src/Models/Feature.php index 67b10e8..7a05740 100644 --- a/src/Models/Feature.php +++ b/src/Models/Feature.php @@ -2,10 +2,11 @@ namespace Codinglabs\FeatureFlags\Models; +use Illuminate\Database\Eloquent\Model; use Codinglabs\FeatureFlags\Casts\FeatureStateCast; use Illuminate\Database\Eloquent\Factories\HasFactory; -class Feature extends \Illuminate\Database\Eloquent\Model +class Feature extends Model { use HasFactory; diff --git a/tests/FeatureFlagTest.php b/tests/FeatureFlagTest.php index e6d933b..33396ba 100644 --- a/tests/FeatureFlagTest.php +++ b/tests/FeatureFlagTest.php @@ -19,6 +19,15 @@ FeatureFlag::reset(); }); +it('throws an exception if casting to a feature state that does not exist', function () { + $this->expectException(\InvalidArgumentException::class); + + Feature::factory()->create([ + 'name' => 'some-feature', + 'state' => 'foo', + ]); +}); + it('throws an exception if calling isOn on a feature that does not exist', function () { $this->expectException(MissingFeatureException::class); @@ -141,6 +150,60 @@ ->and(FeatureFlag::getState('some-on-feature'))->toBe(FeatureState::on()); }); +it('can turn on a feature', function () { + Event::fake(); + + Feature::factory()->create([ + 'name' => 'some-feature', + 'state' => FeatureState::off() + ]); + + cache()->store('array')->set('testing.some-feature', 'off'); + + FeatureFlag::turnOn('some-feature'); + + Event::assertDispatched(FeatureUpdatedEvent::class); + expect(FeatureFlag::isOn('some-feature'))->toBeTrue() + ->and(FeatureFlag::isOff('some-feature'))->toBeFalse() + ->and(cache()->store('array')->get('testing.some-feature'))->toBe(FeatureState::on()->value); +}); + +it('can turn off a feature', function () { + Event::fake(); + + Feature::factory()->create([ + 'name' => 'some-feature', + 'state' => FeatureState::on() + ]); + + cache()->store('array')->set('testing.some-feature', 'on'); + + FeatureFlag::turnOff('some-feature'); + + Event::assertDispatched(FeatureUpdatedEvent::class); + expect(FeatureFlag::isOn('some-feature'))->toBeFalse() + ->and(FeatureFlag::isOff('some-feature'))->toBeTrue() + ->and(cache()->store('array')->get('testing.some-feature'))->toBe(FeatureState::off()->value); +}); + +it('can make a feature dynamic', function () { + Event::fake(); + + Feature::factory()->create([ + 'name' => 'some-feature', + 'state' => FeatureState::on() + ]); + + cache()->store('array')->set('testing.some-feature', 'on'); + + FeatureFlag::makeDynamic('some-feature'); + + Event::assertDispatched(FeatureUpdatedEvent::class); + expect(FeatureFlag::isOn('some-feature'))->toBeFalse() + ->and(FeatureFlag::isOff('some-feature'))->toBeTrue() + ->and(cache()->store('array')->get('testing.some-feature'))->toBe(FeatureState::dynamic()->value); +}); + it('can update a features state', function () { Event::fake();