diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2932450..9ece740 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -35,9 +35,6 @@ jobs: - name: Prepare testbench run: composer clear && composer prepare && composer build - - name: Publish required assets - run: vendor/bin/testbench vendor:publish --provider="Sprout\\SproutServiceProvider" - - name: Execute tests run: composer test diff --git a/composer.json b/composer.json index 013db51..0bd998d 100644 --- a/composer.json +++ b/composer.json @@ -63,7 +63,7 @@ "@clear", "@prepare", "@build", - "@php vendor/bin/phpunit" + "@php vendor/bin/phpunit --testsuite=Unit,Feature" ] }, "extra" : { diff --git a/phpunit.xml b/phpunit.xml index 36e7c26..7e3a02d 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -15,8 +15,14 @@ testdox="true" > - - tests + + tests/_Original + + + ./tests/Feature + + + ./tests/Unit diff --git a/src/Concerns/OverridesCookieSettings.php b/src/Concerns/OverridesCookieSettings.php index 3ff4e86..b992b35 100644 --- a/src/Concerns/OverridesCookieSettings.php +++ b/src/Concerns/OverridesCookieSettings.php @@ -39,7 +39,7 @@ public static function setDomain(?string $domain): void */ public static function setPath(?string $path): void { - self::$settings['path'] = '/' . ltrim($path, '/'); + self::$settings['path'] = $path ? '/' . ltrim($path, '/') : null; } // @codeCoverageIgnoreStart diff --git a/src/Http/Middleware/AddTenantHeaderToResponse.php b/src/Http/Middleware/AddTenantHeaderToResponse.php index 057d56f..bbda4c2 100644 --- a/src/Http/Middleware/AddTenantHeaderToResponse.php +++ b/src/Http/Middleware/AddTenantHeaderToResponse.php @@ -5,11 +5,10 @@ use Closure; use Illuminate\Http\Request; -use Illuminate\Http\Response; use Sprout\Http\Resolvers\HeaderIdentityResolver; use Sprout\Sprout; use Sprout\Support\ResolutionHelper; -use Sprout\Support\ResolutionHook; +use Symfony\Component\HttpFoundation\Response; /** * Add Tenant Header to Response @@ -43,9 +42,10 @@ public function __construct(Sprout $sprout) * @param \Closure $next * @param string ...$options * - * @return \Illuminate\Http\Response + * @return \Symfony\Component\HttpFoundation\Response * - * @throws \Sprout\Exceptions\NoTenantFound + * @throws \Illuminate\Contracts\Container\BindingResolutionException + * @throws \Sprout\Exceptions\MisconfigurationException */ public function handle(Request $request, Closure $next, string ...$options): Response { @@ -68,7 +68,7 @@ public function handle(Request $request, Closure $next, string ...$options): Res } return $response->withHeaders([ - $resolver->getRequestHeaderName($tenancy) => $tenancy->identifier() + $resolver->getRequestHeaderName($tenancy) => $tenancy->identifier(), ]); } } diff --git a/src/Http/Middleware/TenantRoutes.php b/src/Http/Middleware/TenantRoutes.php index 941d501..1bc981f 100644 --- a/src/Http/Middleware/TenantRoutes.php +++ b/src/Http/Middleware/TenantRoutes.php @@ -48,7 +48,7 @@ public function __construct(Sprout $sprout) * @param \Closure $next * @param string ...$options * - * @return \Illuminate\Http\Response + * @return \Symfony\Component\HttpFoundation\Response * * @throws \Sprout\Exceptions\NoTenantFound * @throws \Illuminate\Contracts\Container\BindingResolutionException diff --git a/src/Overrides/AuthOverride.php b/src/Overrides/AuthOverride.php index c7d41da..fcfdeee 100644 --- a/src/Overrides/AuthOverride.php +++ b/src/Overrides/AuthOverride.php @@ -21,7 +21,7 @@ * * @package Overrides */ -final class AuthOverride implements ServiceOverride, BootableServiceOverride, DeferrableServiceOverride +final class AuthOverride implements BootableServiceOverride, DeferrableServiceOverride { /** * @var \Illuminate\Auth\AuthManager diff --git a/src/Sprout.php b/src/Sprout.php index f8beb82..c853f13 100644 --- a/src/Sprout.php +++ b/src/Sprout.php @@ -118,18 +118,6 @@ public function getAllCurrentTenancies(): array return $this->tenancies; } - /** - * Should Sprout listen for the routing event - * - * @return bool - * - * @throws \Illuminate\Contracts\Container\BindingResolutionException - */ - public function shouldListenForRouting(): bool - { - return (bool)$this->config('listen_for_routing', true); - } - /** * Get the identity resolver manager * @@ -200,7 +188,7 @@ public function markAsInContext(): self * * @return static */ - public function maskAsOutsideContext(): self + public function markAsOutsideContext(): self { $this->withinContext = false; diff --git a/src/SproutServiceProvider.php b/src/SproutServiceProvider.php index f6c1604..a3e3368 100644 --- a/src/SproutServiceProvider.php +++ b/src/SproutServiceProvider.php @@ -31,6 +31,7 @@ public function register(): void $this->registerManagers(); $this->registerMiddleware(); $this->registerRouteMixin(); + $this->registerServiceOverrideBooting(); } private function registerSprout(): void @@ -78,13 +79,17 @@ protected function registerRouteMixin(): void Router::mixin(new RouterMethods()); } + protected function registerServiceOverrideBooting(): void + { + $this->app->booted($this->sprout->bootOverrides(...)); + } + public function boot(): void { $this->publishConfig(); $this->registerServiceOverrides(); $this->registerEventListeners(); $this->registerTenancyBootstrappers(); - $this->bootServiceOverrides(); } private function publishConfig(): void @@ -128,9 +133,4 @@ private function registerTenancyBootstrappers(): void $events->listen(CurrentTenantChanged::class, $bootstrapper); } } - - private function bootServiceOverrides(): void - { - $this->sprout->bootOverrides(); - } } diff --git a/src/Support/DefaultTenancy.php b/src/Support/DefaultTenancy.php index f88b381..c30dd8b 100644 --- a/src/Support/DefaultTenancy.php +++ b/src/Support/DefaultTenancy.php @@ -257,6 +257,11 @@ public function setTenant(?Tenant $tenant): static event(new CurrentTenantChanged($this, $previousTenant, $tenant)); } + if ($tenant === null) { + $this->resolver = null; + $this->hook = null; + } + return $this; } diff --git a/src/Support/GenericTenant.php b/src/Support/GenericTenant.php index 305f510..9b627c1 100644 --- a/src/Support/GenericTenant.php +++ b/src/Support/GenericTenant.php @@ -13,6 +13,8 @@ * as the tenant entity. * * @pacakge Core + * + * @codeCoverageIgnore */ class GenericTenant implements Tenant { diff --git a/tests/Feature/.gitkeep b/tests/Feature/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Unit/Attributes/CurrentTenantTest.php b/tests/Unit/Attributes/CurrentTenantTest.php new file mode 100644 index 0000000..631ddd8 --- /dev/null +++ b/tests/Unit/Attributes/CurrentTenantTest.php @@ -0,0 +1,82 @@ +set('multitenancy.providers.tenants.model', TenantModel::class); + }); + } + + protected function setupSecondTenancy($app): void + { + tap($app['config'], static function (Repository $config) { + $config->set('multitenancy.providers.backup', [ + 'driver' => 'database', + 'table' => 'tenants', + ]); + + $config->set('multitenancy.tenancies.backup', [ + 'provider' => 'backup', + ]); + }); + } + + #[Test] + public function resolvesCurrentTenant(): void + { + /** @var \Sprout\Contracts\Tenancy $tenancy */ + $tenancy = tenancy('tenants'); + + sprout()->setCurrentTenancy($tenancy); + + $tenant = TenantModel::factory()->createOne(); + + $tenancy->setTenant($tenant); + + $callback = static function (#[CurrentTenant] TenantModel $tenant) { + return $tenant; + }; + + $currentTenant = $this->app->call($callback); + + $this->assertSame($tenant, $currentTenant); + $this->assertSame($tenancy->tenant(), $currentTenant); + } + + #[Test, DefineEnvironment('setupSecondTenancy')] + public function resolvesCurrentTenantForSpecificTenancy(): void + { + /** @var \Sprout\Contracts\Tenancy $tenancy */ + $tenancy = tenancy('backup'); + + sprout()->setCurrentTenancy($tenancy); + + $tenant = new GenericTenant(TenantModel::factory()->createOne()->toArray()); + + $tenancy->setTenant($tenant); + + $callback = static function (#[CurrentTenant('backup')] GenericTenant $tenant) { + return $tenant; + }; + + $currentTenant = $this->app->call($callback); + + $this->assertSame($tenant, $currentTenant); + $this->assertSame($tenancy->tenant(), $currentTenant); + } +} diff --git a/tests/Unit/Providers/DatabaseProviderTest.php b/tests/Unit/Providers/DatabaseProviderTest.php new file mode 100644 index 0000000..3ac615e --- /dev/null +++ b/tests/Unit/Providers/DatabaseProviderTest.php @@ -0,0 +1,120 @@ +set('multitenancy.providers.tenants.driver', 'database'); + $config->set('multitenancy.providers.tenants.table', 'tenants'); + }); + } + + protected function withCustomTenantEntity($app): void + { + tap($app['config'], static function ($config) { + $config->set('multitenancy.providers.tenants.entity', CustomTenantEntity::class); + }); + } + + #[Test] + public function hasARegisteredName(): void + { + $provider = provider('tenants'); + + $this->assertInstanceOf(DatabaseTenantProvider::class, $provider); + $this->assertSame('tenants', $provider->getName()); + } + + #[Test] + public function hasATable(): void + { + $provider = provider('tenants'); + + $this->assertInstanceOf(DatabaseTenantProvider::class, $provider); + $this->assertSame('tenants', $provider->getTable()); + } + + #[Test] + public function hasATenantEntity(): void + { + $provider = provider('tenants'); + + $this->assertInstanceOf(DatabaseTenantProvider::class, $provider); + $this->assertSame(GenericTenant::class, $provider->getEntityClass()); + } + + #[Test] + public function retrievesTenantsByTheirIdentifier(): void + { + $provider = provider('tenants'); + + $tenantData = [ + 'name' => 'Test Tenant', + 'identifier' => 'tenant-test', + 'active' => true, + ]; + + $tenantData['id'] = DB::table('tenants')->insertGetId($tenantData); + + $found = $provider->retrieveByIdentifier($tenantData['identifier']); + + $this->assertNotNull($found); + $this->assertInstanceOf(GenericTenant::class, $found); + $this->assertSame($tenantData['identifier'], $found->getTenantIdentifier()); + $this->assertSame($tenantData['id'], $found->getTenantKey()); + + $this->assertNull($provider->retrieveByIdentifier('fake-identifier')); + } + + #[Test] + public function retrievesTenantsByTheirKey(): void + { + $provider = provider('tenants'); + + $tenantData = [ + 'name' => 'Test Tenant', + 'identifier' => 'tenant-test', + 'active' => true, + ]; + + $tenantData['id'] = DB::table('tenants')->insertGetId($tenantData); + + $found = $provider->retrieveByKey($tenantData['id']); + + $this->assertNotNull($found); + $this->assertInstanceOf(GenericTenant::class, $found); + $this->assertSame($tenantData['identifier'], $found->getTenantIdentifier()); + $this->assertSame($tenantData['id'], $found->getTenantKey()); + + $this->assertNull($provider->retrieveByKey(-999)); + } + + #[Test, DefineEnvironment('withCustomTenantEntity')] + public function canHaveCustomTenantEntity(): void + { + // This is necessary as the provider has already been resolved + sprout()->providers()->flushResolved(); + + $provider = provider('tenants'); + + $this->assertInstanceOf(DatabaseTenantProvider::class, $provider); + $this->assertSame(CustomTenantEntity::class, $provider->getEntityClass()); + } +} diff --git a/tests/Unit/Providers/EloquentProviderTest.php b/tests/Unit/Providers/EloquentProviderTest.php new file mode 100644 index 0000000..0689aa8 --- /dev/null +++ b/tests/Unit/Providers/EloquentProviderTest.php @@ -0,0 +1,71 @@ +set('multitenancy.providers.tenants.model', TenantModel::class); + }); + } + + #[Test] + public function hasARegisteredName(): void + { + $provider = provider('tenants'); + + $this->assertInstanceOf(EloquentTenantProvider::class, $provider); + $this->assertSame('tenants', $provider->getName()); + } + + #[Test] + public function hasAModelClass(): void + { + $provider = provider('tenants'); + + $this->assertInstanceOf(EloquentTenantProvider::class, $provider); + $this->assertSame(TenantModel::class, $provider->getModelClass()); + } + + #[Test] + public function retrievesTenantsByTheirIdentifier(): void + { + $provider = provider('tenants'); + + $tenant = TenantModel::factory()->createOne(); + + $found = $provider->retrieveByIdentifier($tenant->getTenantIdentifier()); + + $this->assertNotNull($found); + $this->assertTrue($tenant->is($found)); + + $this->assertNull($provider->retrieveByIdentifier('fake-identifier')); + } + + #[Test] + public function retrievesTenantsByTheirKey(): void + { + $provider = provider('tenants'); + + $tenant = TenantModel::factory()->createOne(); + + $found = $provider->retrieveByKey($tenant->getTenantKey()); + + $this->assertNotNull($found); + $this->assertTrue($tenant->is($found)); + + $this->assertNull($provider->retrieveByKey(-999)); + } +} diff --git a/tests/Unit/SproutServiceProviderTest.php b/tests/Unit/SproutServiceProviderTest.php new file mode 100644 index 0000000..7bf8914 --- /dev/null +++ b/tests/Unit/SproutServiceProviderTest.php @@ -0,0 +1,181 @@ +assertTrue(app()->providerIsLoaded(SproutServiceProvider::class)); + } + + #[Test] + public function serviceProviderIsDiscovered(): void + { + $manifest = app(PackageManifest::class); + + $this->assertContains(SproutServiceProvider::class, $manifest->providers()); + } + + #[Test] + public function sproutIsRegistered(): void + { + $this->assertTrue(app()->has(Sprout::class)); + $this->assertTrue(app()->has('sprout')); + $this->assertTrue(app()->isShared(Sprout::class)); + $this->assertFalse(app()->isShared('sprout')); + + $this->assertSame(app()->make(Sprout::class), app()->make(Sprout::class)); + $this->assertSame(app()->make('sprout'), app()->make('sprout')); + $this->assertSame(app()->make(Sprout::class), app()->make('sprout')); + $this->assertSame(app()->make('sprout'), app()->make(Sprout::class)); + $this->assertSame(sprout(), sprout()); + $this->assertSame(app()->make(Sprout::class), sprout()); + } + + #[Test] + public function providerManagerIsRegistered(): void + { + $this->assertTrue(app()->has(ProviderManager::class)); + $this->assertTrue(app()->has('sprout.providers')); + $this->assertTrue(app()->isShared(ProviderManager::class)); + $this->assertFalse(app()->isShared('sprout.providers')); + + $this->assertSame(app()->make(ProviderManager::class), app()->make(ProviderManager::class)); + $this->assertSame(app()->make('sprout.providers'), app()->make('sprout.providers')); + $this->assertSame(app()->make(ProviderManager::class), app()->make('sprout.providers')); + $this->assertSame(app()->make('sprout.providers'), app()->make(ProviderManager::class)); + $this->assertSame(app()->make(Sprout::class)->providers(), app()->make('sprout.providers')); + $this->assertSame(app()->make(Sprout::class)->providers(), app()->make(ProviderManager::class)); + $this->assertSame(sprout()->providers(), sprout()->providers()); + $this->assertSame(app()->make(Sprout::class)->providers(), sprout()->providers()); + } + + #[Test] + public function identityResolverManagerIsRegistered(): void + { + $this->assertTrue(app()->has(IdentityResolverManager::class)); + $this->assertTrue(app()->has('sprout.resolvers')); + $this->assertTrue(app()->isShared(IdentityResolverManager::class)); + $this->assertFalse(app()->isShared('sprout.resolvers')); + + $this->assertSame(app()->make(IdentityResolverManager::class), app()->make(IdentityResolverManager::class)); + $this->assertSame(app()->make('sprout.resolvers'), app()->make('sprout.resolvers')); + $this->assertSame(app()->make(IdentityResolverManager::class), app()->make('sprout.resolvers')); + $this->assertSame(app()->make('sprout.resolvers'), app()->make(IdentityResolverManager::class)); + $this->assertSame(app()->make(Sprout::class)->resolvers(), app()->make('sprout.resolvers')); + $this->assertSame(app()->make(Sprout::class)->resolvers(), app()->make(IdentityResolverManager::class)); + $this->assertSame(sprout()->resolvers(), sprout()->resolvers()); + $this->assertSame(app()->make(Sprout::class)->resolvers(), sprout()->resolvers()); + } + + #[Test] + public function tenancyManagerIsRegistered(): void + { + $this->assertTrue(app()->has(TenancyManager::class)); + $this->assertTrue(app()->has('sprout.tenancies')); + $this->assertTrue(app()->isShared(TenancyManager::class)); + $this->assertFalse(app()->isShared('sprout.tenancies')); + + $this->assertSame(app()->make(TenancyManager::class), app()->make(TenancyManager::class)); + $this->assertSame(app()->make('sprout.tenancies'), app()->make('sprout.tenancies')); + $this->assertSame(app()->make(TenancyManager::class), app()->make('sprout.tenancies')); + $this->assertSame(app()->make('sprout.tenancies'), app()->make(TenancyManager::class)); + $this->assertSame(app()->make(Sprout::class)->tenancies(), app()->make('sprout.tenancies')); + $this->assertSame(app()->make(Sprout::class)->tenancies(), app()->make(TenancyManager::class)); + $this->assertSame(sprout()->tenancies(), sprout()->tenancies()); + $this->assertSame(app()->make(Sprout::class)->tenancies(), sprout()->tenancies()); + } + + #[Test] + public function registersTenantRoutesMiddleware(): void + { + $router = $this->app->make(Router::class); + $middleware = $router->getMiddleware(); + + $this->assertTrue(isset($middleware[TenantRoutes::ALIAS])); + $this->assertSame(TenantRoutes::class, $middleware[TenantRoutes::ALIAS]); + $this->assertContains(TenantRoutes::class, $middleware); + } + + #[Test] + public function registersRouterMixinMethods(): void + { + $this->assertTrue(Router::hasMacro('tenanted')); + } + + #[Test] + public function publishesConfig(): void + { + $paths = ServiceProvider::pathsToPublish(SproutServiceProvider::class, 'config'); + + $key = realpath(__DIR__ . '/../../src'); + + $this->assertArrayHasKey($key . '/../resources/config/multitenancy.php', $paths); + $this->assertContains(config_path('multitenancy.php'), $paths); + } + + #[Test] + public function coreSproutConfigExists(): void + { + $this->assertTrue(app()['config']->has('sprout')); + $this->assertIsArray(app()['config']->get('sprout')); + $this->assertTrue(app()['config']->has('sprout.hooks')); + } + + #[Test] + public function registersServiceOverrides(): void + { + $overrides = config('sprout.services'); + + foreach ($overrides as $override) { + $this->assertTrue(sprout()->hasRegisteredOverride($override)); + } + } + + #[Test] + public function registersEventHandlers(): void + { + $dispatcher = app()->make(Dispatcher::class); + + $this->assertTrue($dispatcher->hasListeners(RouteMatched::class)); + + $listeners = $dispatcher->getRawListeners(); + + $this->assertContains(IdentifyTenantOnRouting::class, $listeners[RouteMatched::class]); + } + + #[Test] + public function registersTenancyBootstrappers(): void + { + $bootstrappers = config('sprout.bootstrappers'); + + $dispatcher = app()->make(Dispatcher::class); + + $this->assertTrue($dispatcher->hasListeners(RouteMatched::class)); + + $listeners = $dispatcher->getRawListeners(); + + foreach ($bootstrappers as $bootstrapper) { + $this->assertContains($bootstrapper, $listeners[CurrentTenantChanged::class]); + } + } +} diff --git a/tests/Unit/SproutTest.php b/tests/Unit/SproutTest.php new file mode 100644 index 0000000..a24499a --- /dev/null +++ b/tests/Unit/SproutTest.php @@ -0,0 +1,120 @@ +set('multitenancy.providers.backup', [ + 'driver' => 'database', + 'table' => 'tenants', + ]); + + $config->set('multitenancy.tenancies.backup', [ + 'provider' => 'backup', + ]); + }); + } + + #[Test] + public function allowsAccessToCoreConfig(): void + { + $this->assertSame(sprout()->config('hooks'), config('sprout.hooks')); + + config()->set('sprout.hooks', []); + + $this->assertSame(sprout()->config('hooks'), config('sprout.hooks')); + } + + #[Test] + public function hasNoCurrentTenancyByDefault(): void + { + $this->assertFalse(sprout()->hasCurrentTenancy()); + } + + #[Test] + public function isNotWithinMultitenantedContextByDefault(): void + { + $this->assertFalse(sprout()->withinContext()); + } + + #[Test] + public function setsCurrentTenancy(): void + { + $tenancy = sprout()->tenancies()->get(); + + $this->assertFalse(sprout()->hasCurrentTenancy()); + $this->assertNull(sprout()->getCurrentTenancy()); + $this->assertFalse(sprout()->withinContext()); + + sprout()->setCurrentTenancy($tenancy); + + $this->assertTrue(sprout()->hasCurrentTenancy()); + $this->assertSame($tenancy, sprout()->getCurrentTenancy()); + $this->assertTrue(sprout()->withinContext()); + } + + #[Test, DefineEnvironment('setupSecondTenancy')] + public function canStackCurrentTenancies(): void + { + $tenancy1 = sprout()->tenancies()->get(); + $tenancy2 = sprout()->tenancies()->get('backup'); + + $this->assertFalse(sprout()->hasCurrentTenancy()); + $this->assertNull(sprout()->getCurrentTenancy()); + $this->assertFalse(sprout()->withinContext()); + + sprout()->setCurrentTenancy($tenancy1); + + $this->assertTrue(sprout()->hasCurrentTenancy()); + $this->assertSame($tenancy1, sprout()->getCurrentTenancy()); + $this->assertTrue(sprout()->withinContext()); + + sprout()->setCurrentTenancy($tenancy2); + + $this->assertTrue(sprout()->hasCurrentTenancy()); + $this->assertSame($tenancy2, sprout()->getCurrentTenancy()); + $this->assertTrue(sprout()->withinContext()); + + $this->assertContains($tenancy1, sprout()->getAllCurrentTenancies()); + $this->assertContains($tenancy2, sprout()->getAllCurrentTenancies()); + } + + #[Test] + public function isAwareOfHooksToSupport(): void + { + $hooks = config('sprout.hooks'); + + foreach ($hooks as $hook) { + $this->assertTrue(sprout()->supportsHook($hook)); + } + + config()->set('sprout.hooks', []); + + foreach ($hooks as $hook) { + $this->assertFalse(sprout()->supportsHook($hook)); + } + } + + #[Test] + public function canManuallyMarkAsInOrOutOfContext(): void + { + $this->assertFalse(sprout()->withinContext()); + + sprout()->markAsInContext(); + + $this->assertTrue(sprout()->withinContext()); + + sprout()->markAsOutsideContext(); + + $this->assertFalse(sprout()->withinContext()); + } +} diff --git a/tests/Unit/Support/DefaultTenancyTest.php b/tests/Unit/Support/DefaultTenancyTest.php new file mode 100644 index 0000000..f152494 --- /dev/null +++ b/tests/Unit/Support/DefaultTenancyTest.php @@ -0,0 +1,172 @@ +set('multitenancy.defaults.resolver', 'path'); + $config->set('multitenancy.providers.tenants.model', TenantModel::class); + }); + } + + #[Test] + public function hasName(): void + { + /** @var \Sprout\Contracts\Tenancy $tenancy */ + $tenancy = sprout()->tenancies()->get(); + + $this->assertInstanceOf(DefaultTenancy::class, $tenancy); + $this->assertSame('tenants', $tenancy->getName()); + } + + #[Test] + public function hasNoCurrentTenantByDefault(): void + { + /** @var \Sprout\Contracts\Tenancy $tenancy */ + $tenancy = sprout()->tenancies()->get(); + + $this->assertFalse($tenancy->check()); + } + + #[Test] + public function storesCurrentTenantForAccess(): void + { + /** @var \Sprout\Contracts\Tenancy $tenancy */ + $tenancy = sprout()->tenancies()->get(); + + $this->assertFalse($tenancy->check()); + + $tenant = TenantModel::factory()->createOne(); + + $tenancy->setTenant($tenant); + + $this->assertTrue($tenancy->check()); + $this->assertSame($tenant, $tenancy->tenant()); + $this->assertSame($tenant->getTenantKey(), $tenancy->key()); + $this->assertSame($tenant->getTenantIdentifier(), $tenancy->identifier()); + } + + #[Test] + public function identifiesTenant(): void + { + /** @var \Sprout\Contracts\Tenancy $tenancy */ + $tenancy = sprout()->tenancies()->get(); + + $this->assertFalse($tenancy->check()); + + $tenant = TenantModel::factory()->createOne(); + + $this->assertFalse($tenancy->identify('non-existent')); + + Event::fake([TenantIdentified::class]); + + $this->assertTrue($tenancy->identify($tenant->getTenantIdentifier())); + + Event::assertDispatched(TenantIdentified::class); + } + + #[Test] + public function loadsTenant(): void + { + /** @var \Sprout\Contracts\Tenancy $tenancy */ + $tenancy = sprout()->tenancies()->get(); + + $this->assertFalse($tenancy->check()); + + $tenant = TenantModel::factory()->createOne(); + + $this->assertFalse($tenancy->load(-99999)); + + Event::fake([TenantLoaded::class]); + + $this->assertTrue($tenancy->load($tenant->getTenantKey())); + + Event::assertDispatched(TenantLoaded::class); + } + + #[Test] + public function hasATenantProvider(): void + { + /** @var \Sprout\Contracts\Tenancy $tenancy */ + $tenancy = sprout()->tenancies()->get(); + + $provider = $tenancy->provider(); + + $this->assertNotNull($provider); + $this->assertInstanceOf(EloquentTenantProvider::class, $provider); + } + + #[Test] + public function storesHowAndWhenTheTenantWasResolved(): void + { + /** @var \Sprout\Contracts\Tenancy $tenancy */ + $tenancy = sprout()->tenancies()->get(); + + $this->assertFalse($tenancy->wasResolved()); + $this->assertNull($tenancy->resolver()); + $this->assertNull($tenancy->hook()); + + $tenant = TenantModel::factory()->createOne(); + + $tenancy->setTenant($tenant); + + $this->assertFalse($tenancy->wasResolved()); + $this->assertNull($tenancy->resolver()); + $this->assertNull($tenancy->hook()); + + $tenancy->resolvedVia(sprout()->resolvers()->get()); + $tenancy->resolvedAt(ResolutionHook::Booting); + + $this->assertTrue($tenancy->wasResolved()); + $this->assertNotNull($tenancy->resolver()); + $this->assertSame(sprout()->resolvers()->get(), $tenancy->resolver()); + $this->assertNotNull($tenancy->hook()); + $this->assertSame(ResolutionHook::Booting, $tenancy->hook()); + } + + #[Test] + public function hasOptions(): void + { + /** @var \Sprout\Contracts\Tenancy $tenancy */ + $tenancy = sprout()->tenancies()->get(); + + $this->assertSame(config('multitenancy.tenancies.tenants.options'), $tenancy->options()); + + $this->assertTrue($tenancy->hasOption(TenancyOptions::hydrateTenantRelation())); + $this->assertTrue($tenancy->hasOption(TenancyOptions::throwIfNotRelated())); + + $tenancy->removeOption(TenancyOptions::hydrateTenantRelation()); + + $this->assertFalse($tenancy->hasOption(TenancyOptions::hydrateTenantRelation())); + $this->assertTrue($tenancy->hasOption(TenancyOptions::throwIfNotRelated())); + + $tenancy->removeOption(TenancyOptions::throwIfNotRelated()); + + $this->assertFalse($tenancy->hasOption(TenancyOptions::hydrateTenantRelation())); + $this->assertFalse($tenancy->hasOption(TenancyOptions::throwIfNotRelated())); + + $tenancy->addOption(TenancyOptions::hydrateTenantRelation()); + + $this->assertTrue($tenancy->hasOption(TenancyOptions::hydrateTenantRelation())); + $this->assertFalse($tenancy->hasOption(TenancyOptions::throwIfNotRelated())); + } +} diff --git a/tests/Unit/Support/ResolutionHelperTest.php b/tests/Unit/Support/ResolutionHelperTest.php new file mode 100644 index 0000000..91875b5 --- /dev/null +++ b/tests/Unit/Support/ResolutionHelperTest.php @@ -0,0 +1,347 @@ +set('multitenancy.defaults.resolver', 'path'); + $config->set('multitenancy.providers.tenants.model', TenantModel::class); + $config->set('multitenancy.resolvers.subdomain.domain', 'localhost'); + }); + } + + #[Test] + public function parsesMiddlewareOptions(): void + { + [$resolverName, $tenancyName] = ResolutionHelper::parseOptions([]); + + $this->assertNull($resolverName); + $this->assertNull($tenancyName); + + [$resolverName, $tenancyName] = ResolutionHelper::parseOptions(['test']); + + $this->assertNotNull($resolverName); + $this->assertSame('test', $resolverName); + $this->assertNull($tenancyName); + + [$resolverName, $tenancyName] = ResolutionHelper::parseOptions(['test', 'more']); + + $this->assertNotNull($resolverName); + $this->assertSame('test', $resolverName); + $this->assertNotNull($tenancyName); + $this->assertSame('more', $tenancyName); + } + + #[Test] + public function throwsExceptionWhenHandlingResolutionForUnsupportedHook(): void + { + $this->expectException(MisconfigurationException::class); + $this->expectExceptionMessage('The resolution hook [Booting] is not supported'); + + /** @var \Illuminate\Http\Request $fakeRequest */ + $fakeRequest = $this->mock(Request::class); + + ResolutionHelper::handleResolution($fakeRequest, ResolutionHook::Booting); + } + + #[Test] + public function returnsFalseIfThereIsAlreadyATenant(): void + { + /** @var \Sprout\Contracts\Tenancy $tenancy */ + $tenancy = tenancy(); + + $tenancy->setTenant(TenantModel::factory()->createOne()); + + /** @var \Sprout\Contracts\IdentityResolver $resolver */ + $resolver = resolver('path'); + + /** @var \Illuminate\Http\Request $fakeRequest */ + $fakeRequest = $this->mock(Request::class); + + $this->assertTrue($tenancy->check()); + $this->assertTrue($resolver->canResolve($fakeRequest, $tenancy, ResolutionHook::Routing)); + $this->assertFalse(ResolutionHelper::handleResolution($fakeRequest, ResolutionHook::Routing, $resolver->getName(), $tenancy->getName())); + } + + #[Test] + public function returnsFalseIfTheResolverCannotResolve(): void + { + /** @var \Sprout\Contracts\Tenancy $tenancy */ + $tenancy = tenancy(); + + /** @var \Sprout\Contracts\IdentityResolver $resolver */ + $resolver = resolver('path'); + + $tenancy->setTenant(TenantModel::factory()->createOne()) + ->resolvedVia($resolver) + ->resolvedAt(ResolutionHook::Routing); + + /** @var \Illuminate\Http\Request $fakeRequest */ + $fakeRequest = $this->mock(Request::class); + + $this->assertTrue($tenancy->check()); + $this->assertFalse($resolver->canResolve($fakeRequest, $tenancy, ResolutionHook::Routing)); + $this->assertFalse(ResolutionHelper::handleResolution($fakeRequest, ResolutionHook::Routing, $resolver->getName(), $tenancy->getName())); + } + + #[Test] + public function resolvesTenantUsingRouteParameters(): void + { + $tenant = TenantModel::factory()->createOne(); + + /** @var \Sprout\Contracts\Tenancy $tenancy */ + $tenancy = tenancy(); + + /** @var \Sprout\Contracts\IdentityResolver&\Sprout\Contracts\IdentityResolverUsesParameters $resolver */ + $resolver = resolver('path'); + + /** @var \Illuminate\Routing\Route $fakeRoute */ + $fakeRoute = $this->mock(Route::class, function (MockInterface $mock) use ($tenant, $tenancy, $resolver) { + $parameterName = $resolver->getRouteParameterName($tenancy); + + $mock->shouldReceive('hasParameter') + ->with($parameterName) + ->andReturn(true); + + $mock->shouldReceive('parameter') + ->with($parameterName) + ->andReturn($tenant->getTenantIdentifier()); + + $mock->shouldReceive('forgetParameter') + ->with($parameterName); + }); + + /** @var \Illuminate\Http\Request $fakeRequest */ + $fakeRequest = $this->mock(Request::class, function (MockInterface $mock) use ($fakeRoute) { + $mock->shouldReceive('route')->andReturn($fakeRoute); + }); + + $this->assertTrue(ResolutionHelper::handleResolution($fakeRequest, ResolutionHook::Routing, $resolver->getName(), $tenancy->getName())); + $this->assertSame($tenancy, sprout()->getCurrentTenancy()); + $this->assertTrue($tenancy->check()); + $this->assertTrue($tenant->is($tenancy->tenant())); + $this->assertTrue($tenancy->wasResolved()); + $this->assertSame($resolver, $tenancy->resolver()); + $this->assertSame(ResolutionHook::Routing, $tenancy->hook()); + + $tenancy->setTenant(null); + + $this->assertTrue(ResolutionHelper::handleResolution($fakeRequest, ResolutionHook::Routing)); + $this->assertSame($tenancy, sprout()->getCurrentTenancy()); + $this->assertTrue($tenancy->check()); + $this->assertTrue($tenant->is($tenancy->tenant())); + $this->assertTrue($tenancy->wasResolved()); + $this->assertSame($resolver, $tenancy->resolver()); + $this->assertSame(ResolutionHook::Routing, $tenancy->hook()); + } + + #[Test] + public function throwsAnExceptionWhenUnableToIdentifyATenantFromTheRoute(): void + { + /** @var \Sprout\Contracts\Tenancy $tenancy */ + $tenancy = tenancy(); + + /** @var \Sprout\Contracts\IdentityResolver&\Sprout\Contracts\IdentityResolverUsesParameters $resolver */ + $resolver = resolver('path'); + + /** @var \Illuminate\Routing\Route $fakeRoute */ + $fakeRoute = $this->mock(Route::class, function (MockInterface $mock) use ($tenancy, $resolver) { + $parameterName = $resolver->getRouteParameterName($tenancy); + + $mock->shouldReceive('hasParameter') + ->with($parameterName) + ->andReturn(true); + + $mock->shouldReceive('parameter') + ->with($parameterName) + ->andReturn('fake-identifier'); + + $mock->shouldReceive('forgetParameter') + ->with($parameterName); + }); + + /** @var \Illuminate\Http\Request $fakeRequest */ + $fakeRequest = $this->mock(Request::class, function (MockInterface $mock) use ($fakeRoute) { + $mock->shouldReceive('route')->andReturn($fakeRoute); + }); + + $this->expectException(NoTenantFound::class); + $this->expectExceptionMessage('No valid tenant [' . $tenancy->getName() . '] found [' . $resolver->getName() . ']'); + + ResolutionHelper::handleResolution($fakeRequest, ResolutionHook::Routing, $resolver->getName(), $tenancy->getName()); + + $this->expectException(NoTenantFound::class); + $this->expectExceptionMessage('No valid tenant [' . $tenancy->getName() . '] found [subdomain]'); + + ResolutionHelper::handleResolution($fakeRequest, ResolutionHook::Routing); + } + + #[Test] + public function returnsFalseWhenUnableToIdentifyATenantFromTheRouteAndToldNotToThrow(): void + { + /** @var \Sprout\Contracts\Tenancy $tenancy */ + $tenancy = tenancy(); + + /** @var \Sprout\Contracts\IdentityResolver&\Sprout\Contracts\IdentityResolverUsesParameters $resolver */ + $resolver = resolver('path'); + + /** @var \Illuminate\Routing\Route $fakeRoute */ + $fakeRoute = $this->mock(Route::class, function (MockInterface $mock) use ($tenancy, $resolver) { + $parameterName = $resolver->getRouteParameterName($tenancy); + + $mock->shouldReceive('hasParameter') + ->with($parameterName) + ->andReturn(true); + + $mock->shouldReceive('parameter') + ->with($parameterName) + ->andReturn('fake-identifier'); + + $mock->shouldReceive('forgetParameter') + ->with($parameterName); + }); + + /** @var \Illuminate\Http\Request $fakeRequest */ + $fakeRequest = $this->mock(Request::class, function (MockInterface $mock) use ($fakeRoute) { + $mock->shouldReceive('route')->andReturn($fakeRoute); + }); + + $this->assertFalse(ResolutionHelper::handleResolution($fakeRequest, ResolutionHook::Routing, $resolver->getName(), $tenancy->getName(), false)); + $this->assertSame($tenancy, sprout()->getCurrentTenancy()); + $this->assertFalse($tenancy->check()); + $this->assertFalse($tenancy->wasResolved()); + $this->assertNull($tenancy->resolver()); + $this->assertNull($tenancy->hook()); + + $this->assertFalse(ResolutionHelper::handleResolution($fakeRequest, ResolutionHook::Routing, throw: false)); + $this->assertSame($tenancy, sprout()->getCurrentTenancy()); + $this->assertFalse($tenancy->check()); + $this->assertFalse($tenancy->wasResolved()); + $this->assertNull($tenancy->resolver()); + $this->assertNull($tenancy->hook()); + } + + #[Test] + public function resolvesTenantWithoutRouteParameters(): void + { + $tenant = TenantModel::factory()->createOne(); + + /** @var \Sprout\Contracts\Tenancy $tenancy */ + $tenancy = tenancy(); + + /** @var \Sprout\Contracts\IdentityResolver&\Sprout\Contracts\IdentityResolverUsesParameters $resolver */ + $resolver = resolver('path'); + + /** @var \Illuminate\Http\Request $fakeRequest */ + $fakeRequest = $this->mock(Request::class, function (MockInterface $mock) use ($tenant) { + $mock->shouldReceive('route')->andReturnNull(); + + $mock->shouldReceive('segment') + ->with(1) + ->andReturn($tenant->getTenantIdentifier()); + }); + + $this->assertTrue(ResolutionHelper::handleResolution($fakeRequest, ResolutionHook::Routing, $resolver->getName(), $tenancy->getName())); + $this->assertSame($tenancy, sprout()->getCurrentTenancy()); + $this->assertTrue($tenancy->check()); + $this->assertTrue($tenant->is($tenancy->tenant())); + $this->assertTrue($tenancy->wasResolved()); + $this->assertSame($resolver, $tenancy->resolver()); + $this->assertSame(ResolutionHook::Routing, $tenancy->hook()); + + $tenancy->setTenant(null); + + $this->assertTrue(ResolutionHelper::handleResolution($fakeRequest, ResolutionHook::Routing)); + $this->assertSame($tenancy, sprout()->getCurrentTenancy()); + $this->assertTrue($tenancy->check()); + $this->assertTrue($tenant->is($tenancy->tenant())); + $this->assertTrue($tenancy->wasResolved()); + $this->assertSame($resolver, $tenancy->resolver()); + $this->assertSame(ResolutionHook::Routing, $tenancy->hook()); + } + + #[Test] + public function throwsAnExceptionWhenUnableToIdentifyATenantFromTheRequest(): void + { + /** @var \Sprout\Contracts\Tenancy $tenancy */ + $tenancy = tenancy(); + + /** @var \Sprout\Contracts\IdentityResolver&\Sprout\Contracts\IdentityResolverUsesParameters $resolver */ + $resolver = resolver('path'); + + /** @var \Illuminate\Http\Request $fakeRequest */ + $fakeRequest = $this->mock(Request::class, function (MockInterface $mock) { + $mock->shouldReceive('route')->andReturnNull(); + + $mock->shouldReceive('segment') + ->with(1) + ->andReturn('fake-identifier'); + }); + + $this->expectException(NoTenantFound::class); + $this->expectExceptionMessage('No valid tenant [' . $tenancy->getName() . '] found [' . $resolver->getName() . ']'); + + ResolutionHelper::handleResolution($fakeRequest, ResolutionHook::Routing, $resolver->getName(), $tenancy->getName()); + + $this->expectException(NoTenantFound::class); + $this->expectExceptionMessage('No valid tenant [' . $tenancy->getName() . '] found [subdomain]'); + + ResolutionHelper::handleResolution($fakeRequest, ResolutionHook::Routing); + } + + #[Test] + public function returnsFalseWhenUnableToIdentifyATenantFromTheRequestAndToldNotToThrow(): void + { + /** @var \Sprout\Contracts\Tenancy $tenancy */ + $tenancy = tenancy(); + + /** @var \Sprout\Contracts\IdentityResolver&\Sprout\Contracts\IdentityResolverUsesParameters $resolver */ + $resolver = resolver('path'); + + /** @var \Illuminate\Http\Request $fakeRequest */ + $fakeRequest = $this->mock(Request::class, function (MockInterface $mock) { + $mock->shouldReceive('route')->andReturnNull(); + + $mock->shouldReceive('segment') + ->with(1) + ->andReturn('fake-identifier'); + }); + + $this->assertFalse(ResolutionHelper::handleResolution($fakeRequest, ResolutionHook::Routing, $resolver->getName(), $tenancy->getName(), false)); + $this->assertSame($tenancy, sprout()->getCurrentTenancy()); + $this->assertFalse($tenancy->check()); + $this->assertFalse($tenancy->wasResolved()); + $this->assertNull($tenancy->resolver()); + $this->assertNull($tenancy->hook()); + + $tenancy->setTenant(null); + + $this->assertFalse(ResolutionHelper::handleResolution($fakeRequest, ResolutionHook::Routing, throw: false)); + $this->assertSame($tenancy, sprout()->getCurrentTenancy()); + $this->assertFalse($tenancy->check()); + $this->assertFalse($tenancy->wasResolved()); + $this->assertNull($tenancy->resolver()); + $this->assertNull($tenancy->hook()); + } +} diff --git a/tests/Unit/TenancyOptionsTest.php b/tests/Unit/TenancyOptionsTest.php new file mode 100644 index 0000000..9ee73df --- /dev/null +++ b/tests/Unit/TenancyOptionsTest.php @@ -0,0 +1,73 @@ +set('multitenancy.providers.backup', [ + 'driver' => 'database', + 'table' => 'tenants', + ]); + + $config->set('multitenancy.tenancies.backup', [ + 'provider' => 'backup', + ]); + }); + } + + #[Test] + public function hydrateTenantRelationOption(): void + { + $this->assertSame('tenant-relation.hydrate', TenancyOptions::hydrateTenantRelation()); + } + + #[Test] + public function throwIfNotRelatedOption(): void + { + $this->assertSame('tenant-relation.strict', TenancyOptions::throwIfNotRelated()); + } + + #[Test, DefineEnvironment('setupSecondTenancy')] + public function correctlyReportsHydrateTenantRelationOptionPresence(): void + { + $tenancy = tenancy('tenants'); + $tenancy->removeOption(TenancyOptions::hydrateTenantRelation()); + + $this->assertFalse(TenancyOptions::shouldHydrateTenantRelation($tenancy)); + + $tenancy->addOption(TenancyOptions::hydrateTenantRelation()); + + $this->assertTrue(TenancyOptions::shouldHydrateTenantRelation($tenancy)); + + $tenancy = tenancy('backup'); + + $this->assertFalse(TenancyOptions::shouldHydrateTenantRelation($tenancy)); + } + + #[Test, DefineEnvironment('setupSecondTenancy')] + public function correctlyReportsThrowIfNotRelatedOptionPresence(): void + { + $tenancy = tenancy('tenants'); + $tenancy->removeOption(TenancyOptions::throwIfNotRelated()); + + $this->assertFalse(TenancyOptions::shouldThrowIfNotRelated($tenancy)); + + $tenancy->addOption(TenancyOptions::throwIfNotRelated()); + + $this->assertTrue(TenancyOptions::shouldThrowIfNotRelated($tenancy)); + + $tenancy = tenancy('backup'); + + $this->assertFalse(TenancyOptions::shouldThrowIfNotRelated($tenancy)); + } +} diff --git a/tests/Unit/UnitTestCase.php b/tests/Unit/UnitTestCase.php new file mode 100644 index 0000000..049d89c --- /dev/null +++ b/tests/Unit/UnitTestCase.php @@ -0,0 +1,14 @@ +assertArrayHasKey($key . '/../resources/config/multitenancy.php', $paths); $this->assertContains(config_path('multitenancy.php'), $paths); diff --git a/tests/SproutTest.php b/tests/_Original/SproutTest.php similarity index 97% rename from tests/SproutTest.php rename to tests/_Original/SproutTest.php index 43760f2..26d126b 100644 --- a/tests/SproutTest.php +++ b/tests/_Original/SproutTest.php @@ -1,7 +1,7 @@ + */ + protected array $attributes; + + /** + * Create a new generic User object. + * + * @param array $attributes + * + * @return void + */ + public function __construct(array $attributes = []) + { + $this->attributes = $attributes; + } + + /** + * Get the tenant identifier + * + * Retrieve the identifier used to publicly identify the tenant. + * + * @return string + */ + public function getTenantIdentifier(): string + { + /** @phpstan-ignore-next-line */ + return $this->attributes[$this->getTenantIdentifierName()]; + } + + /** + * Get the name of the tenant identifier + * + * Retrieve the storage name for the tenant identifier, whether that's an + * attribute, column name, array key or something else. + * Used primarily by {@see \Sprout\Contracts\TenantProvider}. + * + * @return string + */ + public function getTenantIdentifierName(): string + { + return 'identifier'; + } + + /** + * Get the tenant key + * + * Retrieve the key used to identify a tenant internally. + * + * @return int|string + */ + public function getTenantKey(): int|string + { + /** @phpstan-ignore-next-line */ + return $this->attributes[$this->getTenantKeyName()]; + } + + /** + * Get the name of the tenant key + * + * Retrieve the storage name for the tenant key, whether that's an + * attribute, column name, array key or something else. + * Used primarily by {@see \Sprout\Contracts\TenantProvider}. + * + * @return string + */ + public function getTenantKeyName(): string + { + return 'id'; + } + + /** + * Dynamically access the tenant's attributes. + * + * @param string $key + * + * @return mixed + */ + public function __get(string $key): mixed + { + return $this->attributes[$key]; + } + + /** + * Dynamically set an attribute on the tenant. + * + * @param string $key + * @param mixed $value + * + * @return void + */ + public function __set(string $key, mixed $value): void + { + $this->attributes[$key] = $value; + } + + /** + * Dynamically check if a value is set on the tenant. + * + * @param string $key + * + * @return bool + */ + public function __isset(string $key): bool + { + return isset($this->attributes[$key]); + } + + /** + * Dynamically unset a value on the tenant. + * + * @param string $key + * + * @return void + */ + public function __unset(string $key): void + { + unset($this->attributes[$key]); + } +}