diff --git a/config/feature-flags.php b/config/feature-flags.php index 766c68a..0ffc3ea 100644 --- a/config/feature-flags.php +++ b/config/feature-flags.php @@ -2,6 +2,29 @@ return [ + /* + |-------------------------------------------------------------------------- + | Features + |-------------------------------------------------------------------------- + | + | Declare features that are managed by the app with the Feature + | Flag package. The format is ['name' => FeatureState::on()]. + */ + + '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 @@ -20,8 +43,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..a26cdee --- /dev/null +++ b/src/Actions/SyncFeaturesAction.php @@ -0,0 +1,28 @@ +map(fn ($state, $name) => [ + 'name' => $name, + 'state' => app()->environment(config('feature-flags.always_on', [])) + ? FeatureState::on() + : $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/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/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'); +}); 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..33396ba 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(); }); @@ -24,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); @@ -146,7 +150,7 @@ ->and(FeatureFlag::getState('some-on-feature'))->toBe(FeatureState::on()); }); -it('can update a features state', function () { +it('can turn on a feature', function () { Event::fake(); Feature::factory()->create([ @@ -156,69 +160,72 @@ cache()->store('array')->set('testing.some-feature', 'off'); - FeatureFlag::updateFeatureState('some-feature', FeatureState::on()); + FeatureFlag::turnOn('some-feature'); - 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); }); -it('uses the default cache store when cache store has not been set', function () { - config(['cache.default' => 'file']); - - config(['feature-flags.cache_store' => env('FEATURES_CACHE_STORE', config('cache.default'))]); +it('can turn off a feature', function () { + Event::fake(); - expect(config('feature-flags.cache_store'))->toBe('file'); -}); + Feature::factory()->create([ + 'name' => 'some-feature', + 'state' => FeatureState::on() + ]); -it('returns a 500 status when a feature does not exist', function () { - $this->withoutExceptionHandling(); + cache()->store('array')->set('testing.some-feature', 'on'); - $this->expectException(MissingFeatureException::class); + FeatureFlag::turnOff('some-feature'); - $this->get('test-middleware') - ->assertStatus(500); + 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('returns a 404 status when a feature is off', function () { +it('can make a feature dynamic', function () { + Event::fake(); + Feature::factory()->create([ 'name' => 'some-feature', - 'state' => FeatureState::off() + 'state' => FeatureState::on() ]); - $this->get('test-middleware') - ->assertStatus(404); -}); + cache()->store('array')->set('testing.some-feature', 'on'); -it('returns a 404 status when a feature is dynamic', function () { - Feature::factory()->create([ - 'name' => 'some-feature', - 'state' => FeatureState::dynamic() - ]); + FeatureFlag::makeDynamic('some-feature'); - $this->get('test-middleware') - ->assertStatus(404); + 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('returns an ok status when a feature is dynamic and enabled', function () { +it('can update a features state', function () { + Event::fake(); + Feature::factory()->create([ 'name' => 'some-feature', - 'state' => FeatureState::dynamic() + 'state' => FeatureState::off() ]); - FeatureFlag::registerDynamicHandler('some-feature', fn ($feature) => true); + cache()->store('array')->set('testing.some-feature', 'off'); - $this->get('test-middleware') - ->assertOk(); + FeatureFlag::updateFeatureState('some-feature', FeatureState::on()); + + 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('returns an ok status when a feature is on', function () { - Feature::factory()->create([ - 'name' => 'some-feature', - 'state' => FeatureState::on() - ]); +it('uses the default cache store when cache store has not been set', function () { + config(['cache.default' => 'file']); - $this->get('test-middleware') - ->assertOk(); + config(['feature-flags.cache_store' => env('FEATURES_CACHE_STORE', config('cache.default'))]); + + expect(config('feature-flags.cache_store'))->toBe('file'); }); 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(); +}); diff --git a/tests/SyncFeaturesActionTest.php b/tests/SyncFeaturesActionTest.php new file mode 100644 index 0000000..2f54226 --- /dev/null +++ b/tests/SyncFeaturesActionTest.php @@ -0,0 +1,176 @@ + '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', + ]); +}); + +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(), + ]); +});