From 0e3a1ef405a38d6a45d5252623c820a42a5b17c2 Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Sun, 15 Dec 2024 19:26:43 +0000 Subject: [PATCH] test: Initial rework of unit tests --- composer.json | 13 +- infection.json5 | 16 ++ src/Concerns/HandlesServiceOverrides.php | 34 +-- src/Http/Resolvers/CookieIdentityResolver.php | 10 + src/Http/Resolvers/PathIdentityResolver.php | 5 +- .../Resolvers/SubdomainIdentityResolver.php | 27 ++- src/Managers/IdentityResolverManager.php | 4 +- src/Managers/ProviderManager.php | 4 +- src/Managers/TenancyManager.php | 4 +- src/Support/BaseFactory.php | 35 +++- src/Support/BaseIdentityResolver.php | 10 + .../Resolvers/CookieIdentityResolverTest.php | 131 ++++++++++++ .../Resolvers/HeaderIdentityResolverTest.php | 124 +++++++++++ .../Resolvers/PathIdentityResolverTest.php | 163 +++++++++++++++ .../Resolvers/SessionIdentityResolverTest.php | 96 +++++++++ .../SubdomainIdentityResolverTest.php | 145 +++++++++++++ .../Managers/IdentityResolverManagerTest.php | 179 ++++++++++++++++ tests/Unit/Managers/ProviderManagerTest.php | 194 ++++++++++++++++++ tests/Unit/Managers/TenancyManager.php | 64 ++++++ 19 files changed, 1217 insertions(+), 41 deletions(-) create mode 100644 infection.json5 create mode 100644 tests/Unit/Http/Resolvers/CookieIdentityResolverTest.php create mode 100644 tests/Unit/Http/Resolvers/HeaderIdentityResolverTest.php create mode 100644 tests/Unit/Http/Resolvers/PathIdentityResolverTest.php create mode 100644 tests/Unit/Http/Resolvers/SessionIdentityResolverTest.php create mode 100644 tests/Unit/Http/Resolvers/SubdomainIdentityResolverTest.php create mode 100644 tests/Unit/Managers/IdentityResolverManagerTest.php create mode 100644 tests/Unit/Managers/ProviderManagerTest.php create mode 100644 tests/Unit/Managers/TenancyManager.php diff --git a/composer.json b/composer.json index a8895f1..2cba323 100644 --- a/composer.json +++ b/composer.json @@ -3,14 +3,15 @@ "description" : "A flexible, seamless and easy to use multitenancy solution for Laravel", "type" : "library", "require" : { - "php" : "^8.2", - "laravel/framework": "^11.32", + "php" : "^8.2", + "laravel/framework" : "^11.32", "league/flysystem-path-prefixing": "^3.0" }, "require-dev" : { "phpunit/phpunit" : "^11.0.1", "orchestra/testbench": "^9.4", - "larastan/larastan" : "^2.9" + "larastan/larastan" : "^2.9", + "infection/infection": "^0.29.8" }, "license" : "MIT", "autoload" : { @@ -64,6 +65,12 @@ "@prepare", "@build", "@php vendor/bin/phpunit --testsuite=Unit,Feature" + ], + "mutation" : [ + "@clear", + "@prepare", + "@build", + "@php vendor/bin/infection --threads=12" ] }, "extra" : { diff --git a/infection.json5 b/infection.json5 new file mode 100644 index 0000000..d7b22fd --- /dev/null +++ b/infection.json5 @@ -0,0 +1,16 @@ +{ + "$schema": "vendor/infection/infection/resources/schema.json", + "source": { + "directories": [ + "src" + ] + }, + "logs": { + "text": "build/infection.log", + "html": "build/infection.html" + }, + "mutators": { + "@default": true + }, + "testFrameworkOptions": "--testsuite=Unit,Feature" +} diff --git a/src/Concerns/HandlesServiceOverrides.php b/src/Concerns/HandlesServiceOverrides.php index fbcdc95..2859761 100644 --- a/src/Concerns/HandlesServiceOverrides.php +++ b/src/Concerns/HandlesServiceOverrides.php @@ -50,25 +50,25 @@ trait HandlesServiceOverrides /** * Register a service override * - * @param class-string<\Sprout\Contracts\ServiceOverride> $overrideClass + * @param class-string<\Sprout\Contracts\ServiceOverride> $class * * @return static * * @throws \Illuminate\Contracts\Container\BindingResolutionException */ - public function registerOverride(string $overrideClass): static + public function registerOverride(string $class): static { - if (! is_subclass_of($overrideClass, ServiceOverride::class)) { - throw new InvalidArgumentException('Provided service override [' . $overrideClass . '] does not implement ' . ServiceOverride::class); + if (! is_subclass_of($class, ServiceOverride::class)) { + throw new InvalidArgumentException('Provided service override [' . $class . '] does not implement ' . ServiceOverride::class); } // Flag the service override as being registered - $this->registeredOverrides[] = $overrideClass; + $this->registeredOverrides[] = $class; - if (is_subclass_of($overrideClass, DeferrableServiceOverride::class)) { - $this->registerDeferrableOverride($overrideClass); + if (is_subclass_of($class, DeferrableServiceOverride::class)) { + $this->registerDeferrableOverride($class); } else { - $this->processOverride($overrideClass); + $this->processOverride($class); } return $this; @@ -145,13 +145,13 @@ protected function registerDeferrableOverride(string $overrideClass): static /** * Check if a service override is bootable * - * @param class-string<\Sprout\Contracts\ServiceOverride> $overrideClass + * @param class-string<\Sprout\Contracts\ServiceOverride> $class * * @return bool */ - public function isBootableOverride(string $overrideClass): bool + public function isBootableOverride(string $class): bool { - return isset($this->bootableOverrides[$overrideClass]); + return isset($this->bootableOverrides[$class]); } /** @@ -160,13 +160,13 @@ public function isBootableOverride(string $overrideClass): bool * This method returns true if the service override has been booted, or * false if either it hasn't, or it isn't bootable. * - * @param class-string<\Sprout\Contracts\ServiceOverride> $overrideClass + * @param class-string<\Sprout\Contracts\ServiceOverride> $class * * @return bool */ - public function hasBootedOverride(string $overrideClass): bool + public function hasBootedOverride(string $class): bool { - return $this->bootedOverrides[$overrideClass] ?? false; + return $this->bootedOverrides[$class] ?? false; } /** @@ -224,13 +224,13 @@ protected function bootOverride(string $overrideClass): void * Check if a service override has been set up * * @param \Sprout\Contracts\Tenancy<*> $tenancy - * @param class-string<\Sprout\Contracts\ServiceOverride> $overrideClass + * @param class-string<\Sprout\Contracts\ServiceOverride> $class * * @return bool */ - public function hasSetupOverride(Tenancy $tenancy, string $overrideClass): bool + public function hasSetupOverride(Tenancy $tenancy, string $class): bool { - return $this->setupOverrides[$tenancy->getName()][$overrideClass] ?? false; + return $this->setupOverrides[$tenancy->getName()][$class] ?? false; } /** diff --git a/src/Http/Resolvers/CookieIdentityResolver.php b/src/Http/Resolvers/CookieIdentityResolver.php index 4e9831d..107ed3d 100644 --- a/src/Http/Resolvers/CookieIdentityResolver.php +++ b/src/Http/Resolvers/CookieIdentityResolver.php @@ -64,6 +64,16 @@ public function getCookieName(): string return $this->cookie; } + /** + * Get the extra cookie options + * + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + /** * Get the cookie name with replacements * diff --git a/src/Http/Resolvers/PathIdentityResolver.php b/src/Http/Resolvers/PathIdentityResolver.php index efc4670..86b5e37 100644 --- a/src/Http/Resolvers/PathIdentityResolver.php +++ b/src/Http/Resolvers/PathIdentityResolver.php @@ -103,10 +103,9 @@ public function routes(Router $router, Closure $groupRoutes, Tenancy $tenancy): { return $this->applyParameterPatternMapping( $router->middleware([TenantRoutes::ALIAS . ':' . $this->getName() . ',' . $tenancy->getName()]) - ->prefix($this->getRoutePrefix($tenancy)) - ->group($groupRoutes), + ->prefix($this->getRoutePrefix($tenancy)), $tenancy - ); + )->group($groupRoutes); } /** diff --git a/src/Http/Resolvers/SubdomainIdentityResolver.php b/src/Http/Resolvers/SubdomainIdentityResolver.php index 07b46ca..9856948 100644 --- a/src/Http/Resolvers/SubdomainIdentityResolver.php +++ b/src/Http/Resolvers/SubdomainIdentityResolver.php @@ -41,11 +41,11 @@ final class SubdomainIdentityResolver extends BaseIdentityResolver implements Id /** * Create a new instance * - * @param string $name - * @param string $domain - * @param string|null $pattern - * @param string|null $parameter - * @param array<\Sprout\Support\ResolutionHook> $hooks + * @param string $name + * @param string $domain + * @param string|null $pattern + * @param string|null $parameter + * @param array<\Sprout\Support\ResolutionHook> $hooks */ public function __construct(string $name, string $domain, ?string $pattern = null, ?string $parameter = null, array $hooks = []) { @@ -79,6 +79,16 @@ public function resolveFromRequest(Request $request, Tenancy $tenancy): ?string return null; } + /** + * Get the domain the subdomains belong to + * + * @return string + */ + public function getDomain(): string + { + return $this->domain; + } + /** * Get the domain name with parameter for the route definition * @@ -88,7 +98,7 @@ public function resolveFromRequest(Request $request, Tenancy $tenancy): ?string * * @return string */ - protected function getRouteDomain(Tenancy $tenancy): string + public function getRouteDomain(Tenancy $tenancy): string { return $this->getRouteParameter($tenancy) . '.' . $this->domain; } @@ -111,10 +121,9 @@ public function routes(Router $router, Closure $groupRoutes, Tenancy $tenancy): { return $this->applyParameterPatternMapping( $router->domain($this->getRouteDomain($tenancy)) - ->middleware([TenantRoutes::ALIAS . ':' . $this->getName() . ',' . $tenancy->getName()]) - ->group($groupRoutes), + ->middleware([TenantRoutes::ALIAS . ':' . $this->getName() . ',' . $tenancy->getName()]), $tenancy - ); + )->group($groupRoutes); } /** diff --git a/src/Managers/IdentityResolverManager.php b/src/Managers/IdentityResolverManager.php index 5035374..ae6a7d7 100644 --- a/src/Managers/IdentityResolverManager.php +++ b/src/Managers/IdentityResolverManager.php @@ -28,7 +28,7 @@ final class IdentityResolverManager extends BaseFactory * * @return string */ - protected function getFactoryName(): string + public function getFactoryName(): string { return 'resolver'; } @@ -40,7 +40,7 @@ protected function getFactoryName(): string * * @return string */ - protected function getConfigKey(string $name): string + public function getConfigKey(string $name): string { return 'multitenancy.resolvers.' . $name; } diff --git a/src/Managers/ProviderManager.php b/src/Managers/ProviderManager.php index 9bc152d..804a51f 100644 --- a/src/Managers/ProviderManager.php +++ b/src/Managers/ProviderManager.php @@ -28,7 +28,7 @@ final class ProviderManager extends BaseFactory * * @return string */ - protected function getFactoryName(): string + public function getFactoryName(): string { return 'provider'; } @@ -40,7 +40,7 @@ protected function getFactoryName(): string * * @return string */ - protected function getConfigKey(string $name): string + public function getConfigKey(string $name): string { return 'multitenancy.providers.' . $name; } diff --git a/src/Managers/TenancyManager.php b/src/Managers/TenancyManager.php index 46d2e23..a8462e3 100644 --- a/src/Managers/TenancyManager.php +++ b/src/Managers/TenancyManager.php @@ -40,7 +40,7 @@ public function __construct(Application $app, ProviderManager $providerManager) * * @return string */ - protected function getFactoryName(): string + public function getFactoryName(): string { return 'tenancy'; } @@ -52,7 +52,7 @@ protected function getFactoryName(): string * * @return string */ - protected function getConfigKey(string $name): string + public function getConfigKey(string $name): string { return 'multitenancy.tenancies.' . $name; } diff --git a/src/Support/BaseFactory.php b/src/Support/BaseFactory.php index 2d4035a..d2dd4e2 100644 --- a/src/Support/BaseFactory.php +++ b/src/Support/BaseFactory.php @@ -69,12 +69,25 @@ public function __construct(Application $app) $this->app = $app; } + /** + * Check if a factory has a driver + * + * @param string $name + * + * @return bool + */ + public function hasDriver(string $name): bool + { + return isset(static::$customCreators[$name]) + || method_exists($this, 'create' . ucfirst($name) . ucfirst($this->getFactoryName())); + } + /** * Get the name used by this factory * * @return string */ - abstract protected function getFactoryName(): string; + abstract public function getFactoryName(): string; /** * Get the config key for the given name @@ -83,7 +96,7 @@ abstract protected function getFactoryName(): string; * * @return string */ - abstract protected function getConfigKey(string $name): string; + abstract public function getConfigKey(string $name): string; /** * Get the default name @@ -92,7 +105,7 @@ abstract protected function getConfigKey(string $name): string; * * @throws \Sprout\Exceptions\MisconfigurationException */ - protected function getDefaultName(): string + public function getDefaultName(): string { /** @var \Illuminate\Config\Repository $config */ $config = app('config'); @@ -230,4 +243,20 @@ public function flushResolved(): static return $this; } + + /** + * Check if a driver has already been resolved + * + * @param string|null $name + * + * @return bool + */ + public function hasResolved(?string $name = null): bool + { + if ($name === null) { + return ! empty($this->objects); + } + + return isset($this->objects[$name]); + } } diff --git a/src/Support/BaseIdentityResolver.php b/src/Support/BaseIdentityResolver.php index a1e957d..ac7bf49 100644 --- a/src/Support/BaseIdentityResolver.php +++ b/src/Support/BaseIdentityResolver.php @@ -50,6 +50,16 @@ public function getName(): string return $this->name; } + /** + * Get the hooks this resolver uses + * + * @return array<\Sprout\Support\ResolutionHook> + */ + public function getHooks(): array + { + return $this->hooks; + } + /** * Perform setup actions for the tenant * diff --git a/tests/Unit/Http/Resolvers/CookieIdentityResolverTest.php b/tests/Unit/Http/Resolvers/CookieIdentityResolverTest.php new file mode 100644 index 0000000..6050ebf --- /dev/null +++ b/tests/Unit/Http/Resolvers/CookieIdentityResolverTest.php @@ -0,0 +1,131 @@ +set('multitenancy.providers.tenants.model', TenantModel::class); + }); + } + + protected function withCustomCookieName(Application $app): void + { + tap($app['config'], static function ($config) { + $config->set('multitenancy.resolvers.cookie.cookie', 'Custom-Cookie-Name'); + }); + } + + protected function withCustomOptions(Application $app): void + { + tap($app['config'], static function ($config) { + $config->set('multitenancy.resolvers.cookie.options', [ + 'httpOnly' => true, + 'secure' => true, + 'sameSite' => true, + ]); + }); + } + + protected function withCustomCookieNamePattern(Application $app): void + { + tap($app['config'], static function ($config) { + $config->set('multitenancy.resolvers.cookie.cookie', '{Tenancy}-{tenancy}-{Resolver}-{resolver}'); + }); + } + + #[Test] + public function isRegisteredAndCanBeAccessed(): void + { + $resolver = resolver('cookie'); + + $this->assertInstanceOf(CookieIdentityResolver::class, $resolver); + $this->assertSame('{Tenancy}-Identifier', $resolver->getCookieName()); + $this->assertEmpty($resolver->getOptions()); + $this->assertSame([ResolutionHook::Routing], $resolver->getHooks()); + } + + #[Test] + public function replacesPlaceholdersInCookieName(): void + { + $resolver = resolver('cookie'); + $tenancy = tenancy(); + + $this->assertInstanceOf(CookieIdentityResolver::class, $resolver); + $this->assertSame('{Tenancy}-Identifier', $resolver->getCookieName()); + $this->assertSame(ucfirst($tenancy->getName()) . '-Identifier', $resolver->getRequestCookieName($tenancy)); + } + + #[Test, DefineEnvironment('withCustomCookieName')] + public function acceptsCustomCookieName(): void + { + $resolver = resolver('cookie'); + + $this->assertInstanceOf(CookieIdentityResolver::class, $resolver); + $this->assertNotSame('{Tenancy}-Identifier', $resolver->getCookieName()); + $this->assertSame('Custom-Cookie-Name', $resolver->getCookieName()); + $this->assertEmpty($resolver->getOptions()); + $this->assertSame([ResolutionHook::Routing], $resolver->getHooks()); + } + + #[Test, DefineEnvironment('withCustomCookieName')] + public function replacesAllPlaceholders(): void + { + $resolver = resolver('cookie'); + + $this->assertInstanceOf(CookieIdentityResolver::class, $resolver); + $this->assertNotSame('{Tenancy}-Identifier', $resolver->getCookieName()); + $this->assertSame('Custom-Cookie-Name', $resolver->getCookieName()); + $this->assertEmpty($resolver->getOptions()); + $this->assertSame([ResolutionHook::Routing], $resolver->getHooks()); + } + + #[Test, DefineEnvironment('withCustomOptions')] + public function acceptsCustomOptions(): void + { + $resolver = resolver('cookie'); + $options = $resolver->getOptions(); + + $this->assertInstanceOf(CookieIdentityResolver::class, $resolver); + $this->assertSame('{Tenancy}-Identifier', $resolver->getCookieName()); + $this->assertNotEmpty($options); + $this->assertArrayHasKey('httpOnly', $options); + $this->assertArrayHasKey('secure', $options); + $this->assertArrayHasKey('sameSite', $options); + $this->assertTrue($options['httpOnly']); + $this->assertTrue($options['secure']); + $this->assertTrue($options['sameSite']); + $this->assertSame([ResolutionHook::Routing], $resolver->getHooks()); + } + + #[Test, DefineEnvironment('withCustomCookieNamePattern')] + public function replacesAllPlaceholdersInCookieName(): void + { + $resolver = resolver('cookie'); + $tenancy = tenancy(); + + $this->assertInstanceOf(CookieIdentityResolver::class, $resolver); + $this->assertSame('{Tenancy}-{tenancy}-{Resolver}-{resolver}', $resolver->getCookieName()); + $this->assertSame( + ucfirst($tenancy->getName()) . '-' . $tenancy->getName() . '-' . ucfirst($resolver->getName()) . '-' . $resolver->getName(), + $resolver->getRequestCookieName($tenancy) + ); + $this->assertEmpty($resolver->getOptions()); + $this->assertSame([ResolutionHook::Routing], $resolver->getHooks()); + } +} diff --git a/tests/Unit/Http/Resolvers/HeaderIdentityResolverTest.php b/tests/Unit/Http/Resolvers/HeaderIdentityResolverTest.php new file mode 100644 index 0000000..c1c8403 --- /dev/null +++ b/tests/Unit/Http/Resolvers/HeaderIdentityResolverTest.php @@ -0,0 +1,124 @@ +set('multitenancy.resolvers.header.header', 'Custom-Header-Name'); + }); + } + + protected function defineRoutes($router) + { + $router->tenanted(function () { + Route::get('/tenant', function () { + })->name('tenant-route'); + }, 'header'); + } + + protected function withCustomOptions(Application $app): void + { + tap($app['config'], static function ($config) { + $config->set('multitenancy.resolvers.header.options', [ + 'httpOnly' => true, + 'secure' => true, + 'sameSite' => true, + ]); + }); + } + + protected function withCustomHeaderNamePattern(Application $app): void + { + tap($app['config'], static function ($config) { + $config->set('multitenancy.resolvers.header.header', '{Tenancy}-{tenancy}-{Resolver}-{resolver}'); + }); + } + + #[Test] + public function isRegisteredAndCanBeAccessed(): void + { + $resolver = resolver('header'); + + $this->assertInstanceOf(HeaderIdentityResolver::class, $resolver); + $this->assertSame('{Tenancy}-Identifier', $resolver->getHeaderName()); + $this->assertSame([ResolutionHook::Routing], $resolver->getHooks()); + } + + #[Test] + public function replacesPlaceholdersInHeaderName(): void + { + $resolver = resolver('header'); + $tenancy = tenancy(); + + $this->assertInstanceOf(HeaderIdentityResolver::class, $resolver); + $this->assertSame('{Tenancy}-Identifier', $resolver->getHeaderName()); + $this->assertSame(ucfirst($tenancy->getName()) . '-Identifier', $resolver->getRequestHeaderName($tenancy)); + } + + #[Test, DefineEnvironment('withCustomHeaderName')] + public function acceptsCustomHeaderName(): void + { + $resolver = resolver('header'); + + $this->assertInstanceOf(HeaderIdentityResolver::class, $resolver); + $this->assertNotSame('{Tenancy}-Identifier', $resolver->getHeaderName()); + $this->assertSame('Custom-Header-Name', $resolver->getHeaderName()); + $this->assertSame([ResolutionHook::Routing], $resolver->getHooks()); + } + + #[Test, DefineEnvironment('withCustomHeaderName')] + public function replacesAllPlaceholders(): void + { + $resolver = resolver('header'); + + $this->assertInstanceOf(HeaderIdentityResolver::class, $resolver); + $this->assertNotSame('{Tenancy}-Identifier', $resolver->getHeaderName()); + $this->assertSame('Custom-Header-Name', $resolver->getHeaderName()); + $this->assertSame([ResolutionHook::Routing], $resolver->getHooks()); + } + + #[Test, DefineEnvironment('withCustomHeaderNamePattern')] + public function acceptsCustomOptions(): void + { + $resolver = resolver('header'); + $tenancy = tenancy(); + + $this->assertInstanceOf(HeaderIdentityResolver::class, $resolver); + $this->assertSame('{Tenancy}-{tenancy}-{Resolver}-{resolver}', $resolver->getHeaderName()); + $this->assertSame( + ucfirst($tenancy->getName()) . '-' . $tenancy->getName() . '-' . ucfirst($resolver->getName()) . '-' . $resolver->getName(), + $resolver->getRequestHeaderName($tenancy) + ); + $this->assertSame([ResolutionHook::Routing], $resolver->getHooks()); + } + + #[Test] + public function addsTenantHeaderResponseMiddlewareToRoutes(): void + { + $resolver = resolver('header'); + $tenancy = tenancy(); + $routes = app(Router::class)->getRoutes(); + + $this->assertTrue($routes->hasNamedRoute('tenant-route')); + + $middleware = $routes->getByName('tenant-route')->middleware(); + + $this->assertContains(AddTenantHeaderToResponse::class . ':' . $resolver->getName() . ',' . $tenancy->getName(), $middleware); + } +} diff --git a/tests/Unit/Http/Resolvers/PathIdentityResolverTest.php b/tests/Unit/Http/Resolvers/PathIdentityResolverTest.php new file mode 100644 index 0000000..2680d1c --- /dev/null +++ b/tests/Unit/Http/Resolvers/PathIdentityResolverTest.php @@ -0,0 +1,163 @@ +set('multitenancy.providers.tenants.model', TenantModel::class); + }); + } + + protected function withCustomSegment(Application $app): void + { + tap($app['config'], static function ($config) { + $config->set('multitenancy.resolvers.path.segment', 2); + }); + } + + protected function withCustomParameterPattern(Application $app): void + { + tap($app['config'], static function ($config) { + $config->set('multitenancy.resolvers.path.pattern', '.*'); + }); + } + + protected function withCustomParameterName(Application $app): void + { + tap($app['config'], static function ($config) { + $config->set('multitenancy.resolvers.path.parameter', 'custom_parameter_name'); + }); + } + + protected function withCustomParameterNamePattern(Application $app): void + { + tap($app['config'], static function ($config) { + $config->set('multitenancy.resolvers.path.parameter', '{resolver}_{tenancy}'); + }); + } + + protected function withCustomPathNamePattern(Application $app): void + { + tap($app['config'], static function ($config) { + $config->set('multitenancy.resolvers.path.path', '{Tenancy}-{tenancy}-{Resolver}-{resolver}'); + }); + } + + protected function withTenantedRoute(Router $router): void + { + Route::tenanted(static function () { + Route::get('/tenant', function () { + })->name('tenant-route'); + }, 'path'); + } + + #[Test] + public function isRegisteredAndCanBeAccessed(): void + { + $resolver = resolver('path'); + + $this->assertInstanceOf(PathIdentityResolver::class, $resolver); + $this->assertSame('{tenancy}_{resolver}', $resolver->getParameter()); + $this->assertSame(1, $resolver->getSegment()); + $this->assertNull($resolver->getPattern()); + $this->assertSame([ResolutionHook::Routing], $resolver->getHooks()); + } + + #[Test] + public function replacesPlaceholdersInPathName(): void + { + $resolver = resolver('path'); + $tenancy = tenancy(); + + $this->assertInstanceOf(PathIdentityResolver::class, $resolver); + $this->assertSame('{tenancy}_{resolver}', $resolver->getParameter()); + $this->assertSame(1, $resolver->getSegment()); + $this->assertSame($tenancy->getName() . '_' . $resolver->getName(), $resolver->getRouteParameterName($tenancy)); + $this->assertSame('{' . $tenancy->getName() . '_' . $resolver->getName() . '}', $resolver->getRoutePrefix($tenancy)); + $this->assertNull($resolver->getPattern()); + $this->assertSame([ResolutionHook::Routing], $resolver->getHooks()); + } + + #[Test, DefineEnvironment('withCustomSegment')] + public function acceptsCustomSegment(): void + { + $resolver = resolver('path'); + + $this->assertInstanceOf(PathIdentityResolver::class, $resolver); + $this->assertSame(2, $resolver->getSegment()); + $this->assertSame('{tenancy}_{resolver}', $resolver->getParameter()); + $this->assertNull($resolver->getPattern()); + $this->assertSame([ResolutionHook::Routing], $resolver->getHooks()); + } + + #[Test, DefineEnvironment('withCustomParameterPattern')] + public function acceptsCustomParameterPattern(): void + { + $resolver = resolver('path'); + + $this->assertInstanceOf(PathIdentityResolver::class, $resolver); + $this->assertSame('{tenancy}_{resolver}', $resolver->getParameter()); + $this->assertSame(1, $resolver->getSegment()); + $this->assertSame('.*', $resolver->getPattern()); + $this->assertSame([ResolutionHook::Routing], $resolver->getHooks()); + } + + #[Test, DefineEnvironment('withCustomParameterName')] + public function acceptsCustomPathName(): void + { + $resolver = resolver('path'); + + $this->assertInstanceOf(PathIdentityResolver::class, $resolver); + $this->assertNotSame('{tenancy}_{resolver}', $resolver->getParameter()); + $this->assertSame('custom_parameter_name', $resolver->getParameter()); + $this->assertSame([ResolutionHook::Routing], $resolver->getHooks()); + } + + #[Test, DefineEnvironment('withCustomParameterNamePattern')] + public function replacesAllPlaceholdersInPathName(): void + { + $resolver = resolver('path'); + $tenancy = tenancy(); + + $this->assertInstanceOf(PathIdentityResolver::class, $resolver); + $this->assertSame('{resolver}_{tenancy}', $resolver->getParameter()); + $this->assertSame(1, $resolver->getSegment()); + $this->assertSame($resolver->getName() . '_' . $tenancy->getName(), $resolver->getRouteParameterName($tenancy)); + $this->assertNull($resolver->getPattern()); + $this->assertSame([ResolutionHook::Routing], $resolver->getHooks()); + } + + #[Test, DefineEnvironment('withCustomParameterPattern'), DefineRoute('withTenantedRoute')] + public function setsUpRouteProperly(): void + { + $resolver = resolver('path'); + $tenancy = tenancy(); + $routes = app(Router::class)->getRoutes(); + + $this->assertTrue($resolver->hasPattern()); + $this->assertTrue($routes->hasNamedRoute('tenant-route')); + + $route = $routes->getByName('tenant-route'); + + $this->assertContains($tenancy->getName() . '_' . $resolver->getName(), $route->parameterNames()); + $this->assertArrayHasKey($tenancy->getName() . '_' . $resolver->getName(), $route->wheres); + $this->assertSame('.*', $route->wheres[$tenancy->getName() . '_' . $resolver->getName()]); + } +} diff --git a/tests/Unit/Http/Resolvers/SessionIdentityResolverTest.php b/tests/Unit/Http/Resolvers/SessionIdentityResolverTest.php new file mode 100644 index 0000000..8bfb9f8 --- /dev/null +++ b/tests/Unit/Http/Resolvers/SessionIdentityResolverTest.php @@ -0,0 +1,96 @@ +set('multitenancy.providers.tenants.model', TenantModel::class); + }); + } + + protected function withCustomSessionName(Application $app): void + { + tap($app['config'], static function ($config) { + $config->set('multitenancy.resolvers.session.session', 'Custom-Session-Name'); + }); + } + + protected function withCustomSessionNamePattern(Application $app): void + { + tap($app['config'], static function ($config) { + $config->set('multitenancy.resolvers.session.session', '{Tenancy}-{tenancy}-{Resolver}-{resolver}'); + }); + } + + #[Test] + public function isRegisteredAndCanBeAccessed(): void + { + $resolver = resolver('session'); + + $this->assertInstanceOf(SessionIdentityResolver::class, $resolver); + $this->assertSame('multitenancy.{tenancy}', $resolver->getSessionName()); + $this->assertSame([ResolutionHook::Middleware], $resolver->getHooks()); + } + + #[Test] + public function replacesPlaceholdersInSessionName(): void + { + $resolver = resolver('session'); + $tenancy = tenancy(); + + $this->assertInstanceOf(SessionIdentityResolver::class, $resolver); + $this->assertSame('multitenancy.{tenancy}', $resolver->getSessionName()); + $this->assertSame('multitenancy.' . $tenancy->getName(), $resolver->getRequestSessionName($tenancy)); + } + + #[Test, DefineEnvironment('withCustomSessionName')] + public function acceptsCustomSessionName(): void + { + $resolver = resolver('session'); + + $this->assertInstanceOf(SessionIdentityResolver::class, $resolver); + $this->assertNotSame('multitenancy.{tenancy}', $resolver->getSessionName()); + $this->assertSame('Custom-Session-Name', $resolver->getSessionName()); + $this->assertSame([ResolutionHook::Middleware], $resolver->getHooks()); + } + + #[Test, DefineEnvironment('withCustomSessionName')] + public function replacesAllPlaceholders(): void + { + $resolver = resolver('session'); + + $this->assertInstanceOf(SessionIdentityResolver::class, $resolver); + $this->assertNotSame('multitenancy.{tenancy}', $resolver->getSessionName()); + $this->assertSame('Custom-Session-Name', $resolver->getSessionName()); + $this->assertSame([ResolutionHook::Middleware], $resolver->getHooks()); + } + + #[Test, DefineEnvironment('withCustomSessionNamePattern')] + public function replacesAllPlaceholdersInSessionName(): void + { + $resolver = resolver('session'); + $tenancy = tenancy(); + + $this->assertInstanceOf(SessionIdentityResolver::class, $resolver); + $this->assertSame('{Tenancy}-{tenancy}-{Resolver}-{resolver}', $resolver->getSessionName()); + $this->assertSame( + ucfirst($tenancy->getName()) . '-' . $tenancy->getName() . '-' . ucfirst($resolver->getName()) . '-' . $resolver->getName(), + $resolver->getRequestSessionName($tenancy) + ); + $this->assertSame([ResolutionHook::Middleware], $resolver->getHooks()); + } +} diff --git a/tests/Unit/Http/Resolvers/SubdomainIdentityResolverTest.php b/tests/Unit/Http/Resolvers/SubdomainIdentityResolverTest.php new file mode 100644 index 0000000..bdade30 --- /dev/null +++ b/tests/Unit/Http/Resolvers/SubdomainIdentityResolverTest.php @@ -0,0 +1,145 @@ +set('multitenancy.providers.tenants.model', TenantModel::class); + $config->set('multitenancy.resolvers.subdomain.domain', 'localhost'); + }); + } + + protected function withCustomParameterPattern(Application $app): void + { + tap($app['config'], static function ($config) { + $config->set('multitenancy.resolvers.subdomain.pattern', '.*'); + }); + } + + protected function withCustomParameterName(Application $app): void + { + tap($app['config'], static function ($config) { + $config->set('multitenancy.resolvers.subdomain.parameter', 'custom_parameter_name'); + }); + } + + protected function withCustomParameterNamePattern(Application $app): void + { + tap($app['config'], static function ($config) { + $config->set('multitenancy.resolvers.subdomain.parameter', '{resolver}_{tenancy}'); + }); + } + + protected function withCustomSubdomainPattern(Application $app): void + { + tap($app['config'], static function ($config) { + $config->set('multitenancy.resolvers.subdomain.subdomain', '{Tenancy}-{tenancy}-{Resolver}-{resolver}'); + }); + } + + protected function withTenantedRoute(Router $router): void + { + Route::tenanted(static function () { + Route::get('/tenant', function () { + })->name('tenant-route'); + }, 'subdomain'); + } + + #[Test] + public function isRegisteredAndCanBeAccessed(): void + { + $resolver = resolver('subdomain'); + + $this->assertInstanceOf(SubdomainIdentityResolver::class, $resolver); + $this->assertSame('{tenancy}_{resolver}', $resolver->getParameter()); + $this->assertSame('localhost', $resolver->getDomain()); + $this->assertSame('.*', $resolver->getPattern()); + $this->assertSame([ResolutionHook::Routing], $resolver->getHooks()); + } + + #[Test] + public function replacesPlaceholdersInSubdomainName(): void + { + $resolver = resolver('subdomain'); + $tenancy = tenancy(); + + $this->assertInstanceOf(SubdomainIdentityResolver::class, $resolver); + $this->assertSame('{tenancy}_{resolver}', $resolver->getParameter()); + $this->assertSame('localhost', $resolver->getDomain()); + $this->assertSame($tenancy->getName() . '_' . $resolver->getName(), $resolver->getRouteParameterName($tenancy)); + $this->assertSame('.*', $resolver->getPattern()); + $this->assertSame([ResolutionHook::Routing], $resolver->getHooks()); + } + + #[Test, DefineEnvironment('withCustomParameterPattern')] + public function acceptsCustomParameterPattern(): void + { + $resolver = resolver('subdomain'); + + $this->assertInstanceOf(SubdomainIdentityResolver::class, $resolver); + $this->assertSame('{tenancy}_{resolver}', $resolver->getParameter()); + $this->assertSame('localhost', $resolver->getDomain()); + $this->assertSame('.*', $resolver->getPattern()); + $this->assertSame([ResolutionHook::Routing], $resolver->getHooks()); + } + + #[Test, DefineEnvironment('withCustomParameterName')] + public function acceptsCustomSubdomainName(): void + { + $resolver = resolver('subdomain'); + + $this->assertInstanceOf(SubdomainIdentityResolver::class, $resolver); + $this->assertNotSame('{tenancy}_{resolver}', $resolver->getParameter()); + $this->assertSame('custom_parameter_name', $resolver->getParameter()); + $this->assertSame([ResolutionHook::Routing], $resolver->getHooks()); + } + + #[Test, DefineEnvironment('withCustomParameterNamePattern')] + public function replacesAllPlaceholdersInSubdomainName(): void + { + $resolver = resolver('subdomain'); + $tenancy = tenancy(); + + $this->assertInstanceOf(SubdomainIdentityResolver::class, $resolver); + $this->assertSame('{resolver}_{tenancy}', $resolver->getParameter()); + $this->assertSame('localhost', $resolver->getDomain()); + $this->assertSame($resolver->getName() . '_' . $tenancy->getName(), $resolver->getRouteParameterName($tenancy)); + $this->assertSame('{' . $resolver->getName() . '_' . $tenancy->getName() . '}.localhost', $resolver->getRouteDomain($tenancy)); + $this->assertSame('.*', $resolver->getPattern()); + $this->assertSame([ResolutionHook::Routing], $resolver->getHooks()); + } + + #[Test, DefineEnvironment('withCustomParameterPattern'), DefineRoute('withTenantedRoute')] + public function setsUpRouteProperly(): void + { + $resolver = resolver('subdomain'); + $tenancy = tenancy(); + $routes = app(Router::class)->getRoutes(); + + $this->assertTrue($resolver->hasPattern()); + $this->assertTrue($routes->hasNamedRoute('tenant-route')); + + $route = $routes->getByName('tenant-route'); + + $this->assertContains($tenancy->getName() . '_' . $resolver->getName(), $route->parameterNames()); + $this->assertArrayHasKey($tenancy->getName() . '_' . $resolver->getName(), $route->wheres); + $this->assertSame('.*', $route->wheres[$tenancy->getName() . '_' . $resolver->getName()]); + } +} diff --git a/tests/Unit/Managers/IdentityResolverManagerTest.php b/tests/Unit/Managers/IdentityResolverManagerTest.php new file mode 100644 index 0000000..d7d0bfd --- /dev/null +++ b/tests/Unit/Managers/IdentityResolverManagerTest.php @@ -0,0 +1,179 @@ +set('multitenancy.resolvers.subdomain.domain', 'localhost'); + }); + } + + #[Test] + public function isNamedCorrectly(): void + { + $manager = sprout()->resolvers(); + + $this->assertSame('resolver', $manager->getFactoryName()); + } + + #[Test] + public function getsTheDefaultNameFromTheConfig(): void + { + $manager = sprout()->resolvers(); + + $this->assertSame('subdomain', $manager->getDefaultName()); + + config()->set('multitenancy.defaults.resolver', 'path'); + + $this->assertSame('path', $manager->getDefaultName()); + } + + #[Test] + public function generatesConfigKeys(): void + { + $manager = sprout()->resolvers(); + + $this->assertSame('multitenancy.resolvers.test-config', $manager->getConfigKey('test-config')); + } + + #[Test] + public function hasDefaultFirstPartyDrivers(): void + { + $manager = sprout()->resolvers(); + + $this->assertFalse($manager->hasResolved()); + + $this->assertTrue($manager->hasDriver('subdomain')); + $this->assertTrue($manager->hasDriver('path')); + $this->assertTrue($manager->hasDriver('header')); + $this->assertTrue($manager->hasDriver('cookie')); + $this->assertTrue($manager->hasDriver('session')); + + $this->assertFalse($manager->hasResolved()); + + $this->assertInstanceOf(SubdomainIdentityResolver::class, $manager->get('subdomain')); + $this->assertInstanceOf(PathIdentityResolver::class, $manager->get('path')); + $this->assertInstanceOf(HeaderIdentityResolver::class, $manager->get('header')); + $this->assertInstanceOf(CookieIdentityResolver::class, $manager->get('cookie')); + $this->assertInstanceOf(SessionIdentityResolver::class, $manager->get('session')); + + $this->assertTrue($manager->hasResolved('subdomain')); + $this->assertTrue($manager->hasResolved('path')); + $this->assertTrue($manager->hasResolved('header')); + $this->assertTrue($manager->hasResolved('cookie')); + $this->assertTrue($manager->hasResolved('session')); + } + + #[Test] + public function canFlushResolvedInstances(): void + { + $manager = sprout()->resolvers(); + + $this->assertFalse($manager->hasResolved()); + + $this->assertTrue($manager->hasDriver('subdomain')); + $this->assertTrue($manager->hasDriver('path')); + $this->assertTrue($manager->hasDriver('header')); + $this->assertTrue($manager->hasDriver('cookie')); + $this->assertTrue($manager->hasDriver('session')); + + $this->assertFalse($manager->hasResolved()); + + $this->assertInstanceOf(SubdomainIdentityResolver::class, $manager->get('subdomain')); + $this->assertInstanceOf(PathIdentityResolver::class, $manager->get('path')); + $this->assertInstanceOf(HeaderIdentityResolver::class, $manager->get('header')); + $this->assertInstanceOf(CookieIdentityResolver::class, $manager->get('cookie')); + $this->assertInstanceOf(SessionIdentityResolver::class, $manager->get('session')); + + $this->assertTrue($manager->hasResolved('subdomain')); + $this->assertTrue($manager->hasResolved('path')); + $this->assertTrue($manager->hasResolved('header')); + $this->assertTrue($manager->hasResolved('cookie')); + $this->assertTrue($manager->hasResolved('session')); + + $manager->flushResolved(); + + $this->assertFalse($manager->hasResolved('subdomain')); + $this->assertFalse($manager->hasResolved('path')); + $this->assertFalse($manager->hasResolved('header')); + $this->assertFalse($manager->hasResolved('cookie')); + $this->assertFalse($manager->hasResolved('session')); + } + + #[Test] + public function errorsIfTheresNoConfigCanBeFoundForADriver(): void + { + $manager = sprout()->resolvers(); + + $this->expectException(MisconfigurationException::class); + $this->expectExceptionMessage('The config for [resolver::missing] could not be found'); + + $manager->get('missing'); + } + + #[Test] + public function errorsIfTheresNoCreatorForADriver(): void + { + $manager = sprout()->resolvers(); + + config()->set('multitenancy.resolvers.missing', []); + + $this->expectException(MisconfigurationException::class); + $this->expectExceptionMessage('The creator for [resolver::missing] could not be found'); + + $manager->get('missing'); + } + + #[Test] + public function errorsIfNoSubdomainDomainWasProvided(): void + { + config()->set('multitenancy.resolvers.subdomain.domain', null); + + $manager = sprout()->resolvers(); + + $this->expectException(MisconfigurationException::class); + $this->expectExceptionMessage('The resolver [subdomain] is missing a required value for \'domain\''); + + $manager->get('subdomain'); + } + + #[Test] + public function errorsIfPathSegmentIsInvalid(): void + { + config()->set('multitenancy.resolvers.path.segment', -7); + + $manager = sprout()->resolvers(); + + $this->expectException(MisconfigurationException::class); + $this->expectExceptionMessage('The provided value for \'segment\' is not valid for resolver [path]'); + + $manager->get('path'); + } + + #[Test] + public function allowsCustomCreators(): void + { + config()->set('multitenancy.resolvers.path.driver', 'hello-there'); + + IdentityResolverManager::register('hello-there', static function () { + return new SubdomainIdentityResolver('hello-there', 'somedomain.local'); + }); + + $manager = sprout()->resolvers(); + } +} diff --git a/tests/Unit/Managers/ProviderManagerTest.php b/tests/Unit/Managers/ProviderManagerTest.php new file mode 100644 index 0000000..e9cd2c3 --- /dev/null +++ b/tests/Unit/Managers/ProviderManagerTest.php @@ -0,0 +1,194 @@ +set('multitenancy.providers.tenants.model', TenantModel::class); + $config->set('multitenancy.providers.backup', ['driver' => 'database', 'table' => 'tenants']); + }); + } + + #[Test] + public function isNamedCorrectly(): void + { + $manager = sprout()->providers(); + + $this->assertSame('provider', $manager->getFactoryName()); + } + + #[Test] + public function getsTheDefaultNameFromTheConfig(): void + { + $manager = sprout()->providers(); + + $this->assertSame('tenants', $manager->getDefaultName()); + + config()->set('multitenancy.defaults.provider', 'backup'); + + $this->assertSame('backup', $manager->getDefaultName()); + } + + #[Test] + public function generatesConfigKeys(): void + { + $manager = sprout()->providers(); + + $this->assertSame('multitenancy.providers.test-config', $manager->getConfigKey('test-config')); + } + + #[Test] + public function hasDefaultFirstPartyDrivers(): void + { + $manager = sprout()->providers(); + + $this->assertFalse($manager->hasResolved()); + + $this->assertTrue($manager->hasDriver('eloquent')); + $this->assertTrue($manager->hasDriver('database')); + + $this->assertFalse($manager->hasResolved()); + + $this->assertInstanceOf(EloquentTenantProvider::class, $manager->get('tenants')); + $this->assertInstanceOf(DatabaseTenantProvider::class, $manager->get('backup')); + + $this->assertTrue($manager->hasResolved('tenants')); + $this->assertTrue($manager->hasResolved('backup')); + } + + #[Test] + public function canFlushResolvedInstances(): void + { + $manager = sprout()->providers(); + + $this->assertFalse($manager->hasResolved()); + + $this->assertTrue($manager->hasDriver('eloquent')); + $this->assertTrue($manager->hasDriver('database')); + + $this->assertFalse($manager->hasResolved()); + + $this->assertInstanceOf(EloquentTenantProvider::class, $manager->get('tenants')); + $this->assertInstanceOf(DatabaseTenantProvider::class, $manager->get('backup')); + + $this->assertTrue($manager->hasResolved('tenants')); + $this->assertTrue($manager->hasResolved('backup')); + + $manager->flushResolved(); + + $this->assertFalse($manager->hasResolved('tenants')); + $this->assertFalse($manager->hasResolved('backup')); + } + + #[Test] + public function errorsIfTheresNoConfigCanBeFoundForADriver(): void + { + $manager = sprout()->providers(); + + $this->expectException(MisconfigurationException::class); + $this->expectExceptionMessage('The config for [provider::missing] could not be found'); + + $manager->get('missing'); + } + + #[Test] + public function errorsIfTheresNoCreatorForADriver(): void + { + $manager = sprout()->providers(); + + config()->set('multitenancy.providers.missing', []); + + $this->expectException(MisconfigurationException::class); + $this->expectExceptionMessage('The creator for [provider::missing] could not be found'); + + $manager->get('missing'); + } + + #[Test] + public function errorsIfNoEloquentModelIsProvided(): void + { + config()->set('multitenancy.providers.tenants.model', null); + + $manager = sprout()->providers(); + + $this->expectException(MisconfigurationException::class); + $this->expectExceptionMessage('The provider [tenants] is missing a required value for \'model\''); + + $manager->get('tenants'); + } + + #[Test] + public function errorsIfTheEloquentModelConfigIsInvalid(): void + { + config()->set('multitenancy.providers.tenants.model', stdClass::class); + + $manager = sprout()->providers(); + + $this->expectException(MisconfigurationException::class); + $this->expectExceptionMessage('The provided value for \'model\' is not valid for provider [tenants]'); + + $manager->get('tenants'); + } + + #[Test] + public function errorsIfTheDatabaseEntityConfigIsInvalid(): void + { + config()->set('multitenancy.providers.backup.entity', stdClass::class); + + $manager = sprout()->providers(); + + $this->expectException(MisconfigurationException::class); + $this->expectExceptionMessage('The provided value for \'entity\' is not valid for provider [backup]'); + + $manager->get('backup'); + } + + #[Test] + public function errorsIfNoDatabaseTableIsProvided(): void + { + config()->set('multitenancy.providers.backup.table', null); + + $manager = sprout()->providers(); + + $this->expectException(MisconfigurationException::class); + $this->expectExceptionMessage('The provider [backup] is missing a required value for \'table\''); + + $manager->get('backup'); + } + + #[Test] + public function errorsIfTheDatabaseTableIsNotAStringOrModel(): void + { + config()->set('multitenancy.providers.backup.table', stdClass::class); + + $manager = sprout()->providers(); + + $this->expectException(MisconfigurationException::class); + $this->expectExceptionMessage('The provided value for \'table\' is not valid for provider [backup]'); + + $manager->get('backup'); + } + + #[Test] + public function canUseAModelInsteadOfTableNameForDatabaseProviders(): void + { + config()->set('multitenancy.providers.backup.table', TenantModel::class); + + $manager = sprout()->providers(); + + $this->assertSame((new TenantModel())->getTable(), $manager->get('backup')->getTable()); + } +} diff --git a/tests/Unit/Managers/TenancyManager.php b/tests/Unit/Managers/TenancyManager.php new file mode 100644 index 0000000..7359410 --- /dev/null +++ b/tests/Unit/Managers/TenancyManager.php @@ -0,0 +1,64 @@ +tenancies(); + + $this->assertSame('tenancy', $manager->getFactoryName()); + } + + #[Test] + public function getsTheDefaultNameFromTheConfig(): void + { + $manager = sprout()->tenancies(); + + $this->assertSame('tenants', $manager->getDefaultName()); + + config()->set('multitenancy.defaults.tenancy', 'backup'); + + $this->assertSame('backup', $manager->getDefaultName()); + } + + #[Test] + public function generatesConfigKeys(): void + { + $manager = sprout()->tenancies(); + + $this->assertSame('multitenancy.tenancies.test-config', $manager->getConfigKey('test-config')); + } + + #[Test] + public function errorsIfTheresNoConfigCanBeFoundForADriver(): void + { + $manager = sprout()->tenancies(); + + $this->expectException(MisconfigurationException::class); + $this->expectExceptionMessage('The config for [tenancy::missing] could not be found'); + + $manager->get('missing'); + } + + #[Test] + public function errorsIfTheresNoCreatorForADriver(): void + { + $manager = sprout()->tenancies(); + + config()->set('multitenancy.tenancies.missing', ['driver' => 'missing']); + + $this->expectException(MisconfigurationException::class); + $this->expectExceptionMessage('The creator for [tenancy::missing] could not be found'); + + $manager->get('missing'); + } +}