mirror of
https://github.com/archtechx/tenancy.git
synced 2025-12-13 07:14:04 +00:00
Merge branch 'master' into add-log-bootstrapper
This commit is contained in:
commit
412c1d04d6
33 changed files with 1171 additions and 163 deletions
|
|
@ -56,7 +56,7 @@ test('tags separate cache properly', function () {
|
|||
$tenant1 = Tenant::create();
|
||||
tenancy()->initialize($tenant1);
|
||||
|
||||
cache()->put('foo', 'bar', 1);
|
||||
cache()->put('foo', 'bar');
|
||||
expect(cache()->get('foo'))->toBe('bar');
|
||||
|
||||
$tenant2 = Tenant::create();
|
||||
|
|
@ -64,7 +64,7 @@ test('tags separate cache properly', function () {
|
|||
|
||||
expect(cache('foo'))->not()->toBe('bar');
|
||||
|
||||
cache()->put('foo', 'xyz', 1);
|
||||
cache()->put('foo', 'xyz');
|
||||
expect(cache()->get('foo'))->toBe('xyz');
|
||||
});
|
||||
|
||||
|
|
@ -72,7 +72,7 @@ test('invoking the cache helper works', function () {
|
|||
$tenant1 = Tenant::create();
|
||||
tenancy()->initialize($tenant1);
|
||||
|
||||
cache(['foo' => 'bar'], 1);
|
||||
cache(['foo' => 'bar']);
|
||||
expect(cache('foo'))->toBe('bar');
|
||||
|
||||
$tenant2 = Tenant::create();
|
||||
|
|
@ -80,7 +80,7 @@ test('invoking the cache helper works', function () {
|
|||
|
||||
expect(cache('foo'))->not()->toBe('bar');
|
||||
|
||||
cache(['foo' => 'xyz'], 1);
|
||||
cache(['foo' => 'xyz']);
|
||||
expect(cache('foo'))->toBe('xyz');
|
||||
});
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ test('cache is persisted', function () {
|
|||
$tenant1 = Tenant::create();
|
||||
tenancy()->initialize($tenant1);
|
||||
|
||||
cache(['foo' => 'bar'], 10);
|
||||
cache(['foo' => 'bar']);
|
||||
expect(cache('foo'))->toBe('bar');
|
||||
|
||||
tenancy()->end();
|
||||
|
|
@ -102,7 +102,7 @@ test('cache is persisted when reidentification is used', function () {
|
|||
$tenant2 = Tenant::create();
|
||||
tenancy()->initialize($tenant1);
|
||||
|
||||
cache(['foo' => 'bar'], 10);
|
||||
cache(['foo' => 'bar']);
|
||||
expect(cache('foo'))->toBe('bar');
|
||||
|
||||
tenancy()->initialize($tenant2);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<?php
|
||||
|
||||
use Stancl\Tenancy\Enums\Context;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
|
@ -9,6 +8,8 @@ use Stancl\Tenancy\Events\TenancyInitialized;
|
|||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Stancl\Tenancy\Bootstrappers\Integrations\FortifyRouteBootstrapper;
|
||||
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
|
||||
use Stancl\Tenancy\Resolvers\PathTenantResolver;
|
||||
|
||||
beforeEach(function () {
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
|
|
@ -26,16 +27,25 @@ afterEach(function () {
|
|||
test('fortify route tenancy bootstrapper updates fortify config correctly', function() {
|
||||
config(['tenancy.bootstrappers' => [FortifyRouteBootstrapper::class]]);
|
||||
|
||||
config([
|
||||
// Parameter name (RequestDataTenantResolver::queryParameterName())
|
||||
'tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.query_parameter' => 'team_query',
|
||||
// Parameter value (RequestDataTenantResolver::payloadValue() gets the tenant model column value)
|
||||
'tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.tenant_model_column' => 'company',
|
||||
]);
|
||||
|
||||
config([
|
||||
// Parameter name (PathTenantResolver::tenantParameterName())
|
||||
'tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'team_path',
|
||||
// Parameter value (PathTenantResolver::tenantParameterValue() gets the tenant model column value)
|
||||
'tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_model_column' => 'name',
|
||||
]);
|
||||
|
||||
$originalFortifyHome = config('fortify.home');
|
||||
$originalFortifyRedirects = config('fortify.redirects');
|
||||
|
||||
Route::get('/home', function () {
|
||||
return true;
|
||||
})->name($homeRouteName = 'home');
|
||||
|
||||
Route::get('/welcome', function () {
|
||||
return true;
|
||||
})->name($welcomeRouteName = 'welcome');
|
||||
Route::get('/home', fn () => true)->name($homeRouteName = 'home');
|
||||
Route::get('/welcome', fn () => true)->name($welcomeRouteName = 'welcome');
|
||||
|
||||
FortifyRouteBootstrapper::$fortifyHome = $homeRouteName;
|
||||
FortifyRouteBootstrapper::$fortifyRedirectMap['login'] = $welcomeRouteName;
|
||||
|
|
@ -43,21 +53,53 @@ test('fortify route tenancy bootstrapper updates fortify config correctly', func
|
|||
expect(config('fortify.home'))->toBe($originalFortifyHome);
|
||||
expect(config('fortify.redirects'))->toBe($originalFortifyRedirects);
|
||||
|
||||
FortifyRouteBootstrapper::$passTenantParameter = true;
|
||||
tenancy()->initialize($tenant = Tenant::create());
|
||||
expect(config('fortify.home'))->toBe('http://localhost/home?tenant=' . $tenant->getTenantKey());
|
||||
expect(config('fortify.redirects'))->toEqual(['login' => 'http://localhost/welcome?tenant=' . $tenant->getTenantKey()]);
|
||||
/**
|
||||
* When $passQueryParameter is true, the bootstrapper uses
|
||||
* the RequestDataTenantResolver config for generating the Fortify URLs
|
||||
* - tenant parameter is 'team_query'
|
||||
* - parameter value is the tenant's company ('Acme')
|
||||
*
|
||||
* When $passQueryParameter is false, the bootstrapper uses
|
||||
* the PathTenantResolver config for generating the Fortify URLs
|
||||
* - tenant parameter is 'team_path'
|
||||
* - parameter value is the tenant's name ('Foo')
|
||||
*/
|
||||
$tenant = Tenant::create([
|
||||
'company' => 'Acme',
|
||||
'name' => 'Foo',
|
||||
]);
|
||||
|
||||
tenancy()->end();
|
||||
expect(config('fortify.home'))->toBe($originalFortifyHome);
|
||||
expect(config('fortify.redirects'))->toBe($originalFortifyRedirects);
|
||||
// The bootstrapper generates and overrides the URLs in the Fortify config correctly
|
||||
// (= the generated URLs have the correct tenant parameter + parameter value)
|
||||
// The bootstrapper should use RequestDataTenantResolver while generating the URLs (default)
|
||||
FortifyRouteBootstrapper::$passQueryParameter = true;
|
||||
|
||||
FortifyRouteBootstrapper::$passTenantParameter = false;
|
||||
tenancy()->initialize($tenant);
|
||||
expect(config('fortify.home'))->toBe('http://localhost/home');
|
||||
expect(config('fortify.redirects'))->toEqual(['login' => 'http://localhost/welcome']);
|
||||
|
||||
expect(config('fortify.home'))->toBe('http://localhost/home?team_query=Acme');
|
||||
expect(config('fortify.redirects'))->toBe(['login' => 'http://localhost/welcome?team_query=Acme']);
|
||||
|
||||
// The bootstrapper restores the original Fortify config when ending tenancy
|
||||
tenancy()->end();
|
||||
|
||||
expect(config('fortify.home'))->toBe($originalFortifyHome);
|
||||
expect(config('fortify.redirects'))->toBe($originalFortifyRedirects);
|
||||
|
||||
// The bootstrapper should use PathTenantResolver while generating the URLs now
|
||||
FortifyRouteBootstrapper::$passQueryParameter = false;
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
expect(config('fortify.home'))->toBe('http://localhost/home?team_path=Foo');
|
||||
expect(config('fortify.redirects'))->toBe(['login' => 'http://localhost/welcome?team_path=Foo']);
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
// The bootstrapper can override the home and redirects config without the tenant parameter being passed
|
||||
FortifyRouteBootstrapper::$passTenantParameter = false;
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
expect(config('fortify.home'))->toBe('http://localhost/home');
|
||||
expect(config('fortify.redirects'))->toBe(['login' => 'http://localhost/welcome']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,71 +5,95 @@ declare(strict_types=1);
|
|||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Routing\Route;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use Stancl\Tenancy\Resolvers\PathTenantResolver;
|
||||
use Stancl\Tenancy\Resolvers\DomainTenantResolver;
|
||||
use Illuminate\Support\Facades\Route as RouteFacade;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException;
|
||||
use Stancl\Tenancy\Events\TenancyEnded;
|
||||
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
|
||||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
|
||||
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
use function Stancl\Tenancy\Tests\withCacheTables;
|
||||
use function Stancl\Tenancy\Tests\withTenantDatabases;
|
||||
|
||||
test('tenants can be resolved using cached resolvers', function (string $resolver) {
|
||||
$tenant = Tenant::create(['id' => $tenantKey = 'acme']);
|
||||
beforeEach($cleanup = function () {
|
||||
Tenant::$extraCustomColumns = [];
|
||||
});
|
||||
|
||||
$tenant->domains()->create(['domain' => $tenantKey]);
|
||||
afterEach($cleanup);
|
||||
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenantKey))))->toBeTrue();
|
||||
test('tenants can be resolved using cached resolvers', function (string $resolver, bool $configureTenantModelColumn) {
|
||||
$tenant = Tenant::create([$tenantModelColumn = tenantModelColumn($configureTenantModelColumn) => 'acme']);
|
||||
|
||||
$tenant->createDomain($tenant->{$tenantModelColumn});
|
||||
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
|
||||
})->with([
|
||||
DomainTenantResolver::class,
|
||||
PathTenantResolver::class,
|
||||
RequestDataTenantResolver::class,
|
||||
])->with([
|
||||
'tenant model column is id (default)' => false,
|
||||
'tenant model column is name (custom)' => true,
|
||||
]);
|
||||
|
||||
test('the underlying resolver is not touched when using the cached resolver', function (string $resolver) {
|
||||
$tenant = Tenant::create(['id' => $tenantKey = 'acme']);
|
||||
test('the underlying resolver is not touched when using the cached resolver', function (string $resolver, bool $configureTenantModelColumn) {
|
||||
$tenant = Tenant::create([$tenantModelColumn = tenantModelColumn($configureTenantModelColumn) => 'acme']);
|
||||
|
||||
$tenant->createDomain($tenantKey);
|
||||
$tenant->createDomain($tenant->{$tenantModelColumn});
|
||||
|
||||
DB::enableQueryLog();
|
||||
|
||||
config(['tenancy.identification.resolvers.' . $resolver . '.cache' => false]);
|
||||
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenantKey))))->toBeTrue();
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
|
||||
DB::flushQueryLog();
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenantKey))))->toBeTrue();
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
|
||||
|
||||
pest()->assertNotEmpty(DB::getQueryLog()); // not empty
|
||||
|
||||
config(['tenancy.identification.resolvers.' . $resolver . '.cache' => true]);
|
||||
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenantKey))))->toBeTrue();
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
|
||||
DB::flushQueryLog();
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenantKey))))->toBeTrue();
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
|
||||
expect(DB::getQueryLog())->toBeEmpty(); // empty
|
||||
})->with([
|
||||
DomainTenantResolver::class,
|
||||
PathTenantResolver::class,
|
||||
RequestDataTenantResolver::class,
|
||||
])->with([
|
||||
'tenant model column is id (default)' => false,
|
||||
'tenant model column is name (custom)' => true,
|
||||
]);
|
||||
|
||||
test('cache is invalidated when the tenant is updated', function (string $resolver) {
|
||||
$tenant = Tenant::create(['id' => $tenantKey = 'acme']);
|
||||
$tenant->createDomain($tenantKey);
|
||||
test('cache is invalidated when the tenant is updated', function (string $resolver, bool $configureTenantModelColumn) {
|
||||
$tenant = Tenant::create([$tenantModelColumn = tenantModelColumn($configureTenantModelColumn) => 'acme']);
|
||||
|
||||
$tenant->createDomain($tenant->{$tenantModelColumn});
|
||||
|
||||
DB::enableQueryLog();
|
||||
|
||||
config(['tenancy.identification.resolvers.' . $resolver . '.cache' => true]);
|
||||
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenantKey))))->toBeTrue();
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
|
||||
expect(DB::getQueryLog())->not()->toBeEmpty();
|
||||
|
||||
DB::flushQueryLog();
|
||||
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenantKey))))->toBeTrue();
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
|
||||
expect(DB::getQueryLog())->toBeEmpty();
|
||||
|
||||
// Tenant cache gets invalidated when the tenant is updated
|
||||
|
|
@ -77,41 +101,101 @@ test('cache is invalidated when the tenant is updated', function (string $resolv
|
|||
|
||||
DB::flushQueryLog();
|
||||
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenantKey))))->toBeTrue();
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
|
||||
|
||||
expect(DB::getQueryLog())->not()->toBeEmpty(); // Cache was invalidated, so the tenant was retrieved from the DB
|
||||
})->with([
|
||||
DomainTenantResolver::class,
|
||||
PathTenantResolver::class,
|
||||
RequestDataTenantResolver::class,
|
||||
])->with([
|
||||
'tenant model column is id (default)' => false,
|
||||
'tenant model column is name (custom)' => true,
|
||||
]);
|
||||
|
||||
test('cache is invalidated when the tenant is deleted', function (string $resolver) {
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=0;'); // allow deleting the tenant
|
||||
$tenant = Tenant::create(['id' => $tenantKey = 'acme']);
|
||||
$tenant->createDomain($tenantKey);
|
||||
// Only testing update here - presumably if this works, deletes (and other things we test here)
|
||||
// will work as well. The main unique thing about this test is that it makes the change from
|
||||
// *within* the tenant context.
|
||||
test('cache is invalidated when tenant is updated from within the tenant context', function (string $cacheStore, array $bootstrappers) {
|
||||
config([
|
||||
'cache.default' => $cacheStore,
|
||||
'tenancy.bootstrappers' => $bootstrappers,
|
||||
]);
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||
|
||||
if ($cacheStore === 'database') {
|
||||
withCacheTables();
|
||||
withTenantDatabases();
|
||||
}
|
||||
|
||||
$resolver = PathTenantResolver::class;
|
||||
|
||||
$tenant = Tenant::create([$tenantModelColumn = tenantModelColumn(true) => 'acme']);
|
||||
|
||||
DB::enableQueryLog();
|
||||
|
||||
config(['tenancy.identification.resolvers.' . $resolver . '.cache' => true]);
|
||||
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenantKey))))->toBeTrue();
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
|
||||
expect(DB::getQueryLog())->not()->toBeEmpty();
|
||||
DB::flushQueryLog();
|
||||
|
||||
// Different cache context
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
// Tenant cache gets invalidated when the tenant is updated
|
||||
$tenant->touch();
|
||||
|
||||
// If we use 'globalCache' in CachedTenantResolver's constructor (correct) this line has no effect - in either case the global cache is used.
|
||||
// If we use 'cache' (regression) this line DOES have an effect. If we don't end tenancy, the tenant's cache is used, which
|
||||
// wasn't populated before, so the test succeeds - a query is made. But if we do end tenancy, the central cache is used,
|
||||
// which was populated BUT NOT INVALIDATED which makes the test fail.
|
||||
//
|
||||
// The actual resolver would only ever run in the central context, but this detail makes it easier to reason about how this test
|
||||
// confirms the fix.
|
||||
tenancy()->end();
|
||||
|
||||
DB::flushQueryLog();
|
||||
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
|
||||
|
||||
expect(DB::getQueryLog())->not()->toBeEmpty(); // Cache was invalidated, so the tenant was retrieved from the DB
|
||||
})->with([
|
||||
['redis', [CacheTenancyBootstrapper::class]],
|
||||
['redis', [CacheTagsBootstrapper::class]],
|
||||
['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]],
|
||||
]);
|
||||
|
||||
test('cache is invalidated when the tenant is deleted', function (string $resolver, bool $configureTenantModelColumn) {
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=0;'); // allow deleting the tenant
|
||||
$tenant = Tenant::create([$tenantModelColumn = tenantModelColumn($configureTenantModelColumn) => 'acme']);
|
||||
$tenant->createDomain($tenant->{$tenantModelColumn});
|
||||
|
||||
DB::enableQueryLog();
|
||||
|
||||
config(['tenancy.identification.resolvers.' . $resolver . '.cache' => true]);
|
||||
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
|
||||
expect(DB::getQueryLog())->not()->toBeEmpty();
|
||||
|
||||
DB::flushQueryLog();
|
||||
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenantKey))))->toBeTrue();
|
||||
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn))))->toBeTrue();
|
||||
expect(DB::getQueryLog())->toBeEmpty();
|
||||
|
||||
$tenant->delete();
|
||||
DB::flushQueryLog();
|
||||
|
||||
expect(fn () => app($resolver)->resolve(getResolverArgument($resolver, $tenantKey)))->toThrow(TenantCouldNotBeIdentifiedException::class);
|
||||
expect(fn () => app($resolver)->resolve(getResolverArgument($resolver, $tenant, $tenantModelColumn)))->toThrow(TenantCouldNotBeIdentifiedException::class);
|
||||
expect(DB::getQueryLog())->not()->toBeEmpty(); // Cache was invalidated, so the DB was queried
|
||||
})->with([
|
||||
DomainTenantResolver::class,
|
||||
PathTenantResolver::class,
|
||||
RequestDataTenantResolver::class,
|
||||
])->with([
|
||||
'tenant model column is id (default)' => false,
|
||||
'tenant model column is name (custom)' => true,
|
||||
]);
|
||||
|
||||
test('cache is invalidated when a tenants domain is changed', function () {
|
||||
|
|
@ -307,23 +391,49 @@ test('PathTenantResolver properly separates cache for each tenant column', funct
|
|||
pest()->get("/x/bar/b")->assertSee('foo');
|
||||
expect(count(DB::getRawQueryLog()))->toBe(4); // unchanged
|
||||
expect(count($redisKeys()))->toBe(4); // unchanged
|
||||
|
||||
Tenant::$extraCustomColumns = []; // reset
|
||||
});
|
||||
|
||||
/**
|
||||
* Return the argument for the resolver – tenant key, or a route instance with the tenant parameter.
|
||||
* This method is used in generic tests to ensure that caching works correctly both with default and custom resolver config.
|
||||
*
|
||||
* PathTenantResolver uses a route instance with the tenant parameter as the argument,
|
||||
* unlike other resolvers which use a tenant key as the argument.
|
||||
*
|
||||
* This method is used in the tests where we test all the resolvers
|
||||
* to make getting the resolver arguments less repetitive (primarily because of the PathTenantResolver).
|
||||
* If $configureTenantModelColumn is false, the tenant model column is 'id' (default) -- don't configure anything, keep the defaults.
|
||||
* If $configureTenantModelColumn is true, the tenant model column should be 'name' (custom) -- configure tenant_model_column in the resolvers.
|
||||
*/
|
||||
function getResolverArgument(string $resolver, string $tenantKey): string|Route
|
||||
function tenantModelColumn(bool $configureTenantModelColumn): string {
|
||||
// Default tenant model column is 'id'
|
||||
$tenantModelColumn = 'id';
|
||||
|
||||
if ($configureTenantModelColumn) {
|
||||
// Use 'name' as the custom tenant model column
|
||||
$tenantModelColumn = 'name';
|
||||
|
||||
Tenant::$extraCustomColumns = [$tenantModelColumn];
|
||||
|
||||
Schema::table('tenants', function (Blueprint $table) use ($tenantModelColumn) {
|
||||
$table->string($tenantModelColumn)->unique();
|
||||
});
|
||||
|
||||
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_model_column' => $tenantModelColumn]);
|
||||
config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.tenant_model_column' => $tenantModelColumn]);
|
||||
}
|
||||
|
||||
return $tenantModelColumn;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is used in generic tests where we test all the resolvers
|
||||
* to make getting the resolver arguments less repetitive (primarily because of PathTenantResolver).
|
||||
*
|
||||
* For PathTenantResolver, return a route instance with the value retrieved using $tenant->{$parameterColumn} as the parameter.
|
||||
* For RequestDataTenantResolver and DomainTenantResolver, return the value retrieved using $tenant->{$parameterColumn}.
|
||||
*
|
||||
* Tenant model column is 'id' by default, but in the generic tests,
|
||||
* we also configure that to 'name' to ensure everything works both with default and custom config.
|
||||
*/
|
||||
function getResolverArgument(string $resolver, Tenant $tenant, string $parameterColumn = 'id'): string|Route
|
||||
{
|
||||
// PathTenantResolver uses a route instance as the argument
|
||||
if ($resolver === PathTenantResolver::class) {
|
||||
// PathTenantResolver uses a route instance as the argument
|
||||
$routeName = 'tenant-route';
|
||||
|
||||
// Find or create a route instance for the resolver
|
||||
|
|
@ -332,17 +442,21 @@ function getResolverArgument(string $resolver, string $tenantKey): string|Route
|
|||
->prefix('{tenant}')
|
||||
->middleware(InitializeTenancyByPath::class);
|
||||
|
||||
// To make the tenant available on the route instance
|
||||
// Make the 'tenant' route parameter the tenant key
|
||||
// Setting the parameter on the $route->parameters property is required
|
||||
// Because $route->setParameter() throws an exception when $route->parameters is not set yet
|
||||
$route->parameters[PathTenantResolver::tenantParameterName()] = $tenantKey;
|
||||
/**
|
||||
* To make the tenant available on the route instance,
|
||||
* set the 'tenant' route parameter to the tenant model column value ('id' or 'name').
|
||||
*
|
||||
* Setting the parameter on the $route->parameters property is required
|
||||
* because $route->setParameter() throws an exception when $route->parameters isn't set yet.
|
||||
*/
|
||||
$route->parameters['tenant'] = $tenant->{$parameterColumn};
|
||||
|
||||
// Return the route instance with the tenant key as the 'tenant' parameter
|
||||
// Return the route instance with 'id' or 'name' as the 'tenant' parameter
|
||||
return $route;
|
||||
}
|
||||
|
||||
// Resolvers other than PathTenantResolver use the tenant key as the argument
|
||||
// Return the tenant key
|
||||
return $tenantKey;
|
||||
// Assuming that:
|
||||
// - with RequestDataTenantResolver, the tenant model column value is the payload value
|
||||
// - with DomainTenantResolver, the tenant has a domain with name equal to the tenant model column value (see the createDomain() calls in various tests)
|
||||
return $tenant->{$parameterColumn};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use Stancl\Tenancy\Actions\CloneRoutesAsTenant;
|
|||
use Stancl\Tenancy\Resolvers\PathTenantResolver;
|
||||
use Illuminate\Support\Facades\Route as RouteFacade;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
use Illuminate\Routing\Exceptions\UrlGenerationException;
|
||||
|
||||
test('CloneRoutesAsTenant action clones routes with clone middleware by default', function () {
|
||||
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'team']);
|
||||
|
|
@ -337,3 +338,54 @@ test('clone action can be used fluently', function() {
|
|||
expect(collect(RouteFacade::getRoutes()->get())->map->getName())
|
||||
->toContain('tenant.foo', 'tenant.bar', 'tenant.baz');
|
||||
});
|
||||
|
||||
test('the cloned route can be scoped to a specified domain', function () {
|
||||
RouteFacade::domain('foo.localhost')->get('/foo', fn () => in_array('tenant', request()->route()->middleware()) ? 'tenant' : 'central')->name('foo')->middleware('clone');
|
||||
|
||||
// Importantly, we CANNOT add a domain to the cloned route *if the original route didn't have a domain*.
|
||||
// This is due to the route registration order - the more strongly scoped route (= route with a domain)
|
||||
// must be registered first, so that Laravel tries that route first and only moves on if the domain check fails.
|
||||
$cloneAction = app(CloneRoutesAsTenant::class);
|
||||
// To keep the test simple we don't even need a tenant parameter
|
||||
$cloneAction->domain('bar.localhost')->addTenantParameter(false)->handle();
|
||||
|
||||
expect(route('foo'))->toBe('http://foo.localhost/foo');
|
||||
expect(route('tenant.foo'))->toBe('http://bar.localhost/foo');
|
||||
});
|
||||
|
||||
test('tenant parameter addition can be controlled by setting addTenantParameter', function (bool $addTenantParameter) {
|
||||
RouteFacade::domain('central.localhost')
|
||||
->get('/foo', fn () => in_array('tenant', request()->route()->middleware()) ? 'tenant' : 'central')
|
||||
->name('foo')
|
||||
->middleware('clone');
|
||||
|
||||
// By default this action also removes the domain
|
||||
$cloneAction = app(CloneRoutesAsTenant::class);
|
||||
$cloneAction->addTenantParameter($addTenantParameter)->handle();
|
||||
|
||||
$clonedRoute = RouteFacade::getRoutes()->getByName('tenant.foo');
|
||||
|
||||
// We only use the route() helper here, since once a request is made
|
||||
// the URL generator caches the request's domain and it affects route
|
||||
// generation for routes that do not have domain() specified (tenant.foo)
|
||||
expect(route('foo'))->toBe('http://central.localhost/foo');
|
||||
if ($addTenantParameter)
|
||||
expect(route('tenant.foo', ['tenant' => 'abc']))->toBe('http://localhost/abc/foo');
|
||||
else
|
||||
expect(route('tenant.foo'))->toBe('http://localhost/foo');
|
||||
|
||||
// Original route still works
|
||||
$this->withoutExceptionHandling()->get(route('foo'))->assertSee('central');
|
||||
|
||||
if ($addTenantParameter) {
|
||||
expect($clonedRoute->uri())->toContain('{tenant}');
|
||||
|
||||
$this->withoutExceptionHandling()->get('http://localhost/abc/foo')->assertSee('tenant');
|
||||
$this->withoutExceptionHandling()->get('http://central.localhost/foo')->assertSee('central');
|
||||
} else {
|
||||
expect($clonedRoute->uri())->not()->toContain('{tenant}');
|
||||
|
||||
$this->withoutExceptionHandling()->get('http://localhost/foo')->assertSee('tenant');
|
||||
$this->withoutExceptionHandling()->get('http://central.localhost/foo')->assertSee('central');
|
||||
}
|
||||
})->with([true, false]);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
|||
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
use Stancl\Tenancy\Events\MigratingDatabase;
|
||||
|
||||
beforeEach(function () {
|
||||
if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) {
|
||||
|
|
@ -95,6 +96,60 @@ test('migrate command works with tenants option', function () {
|
|||
expect(Schema::hasTable('users'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('migrate command uses the passed database option as the template tenant connection', function () {
|
||||
$originalTemplateConnection = config('tenancy.database.template_tenant_connection');
|
||||
|
||||
// Add a custom connection that will be used as the template for the tenant connection
|
||||
// Identical to the default (mysql), just with different charset and collation
|
||||
config(['database.connections.custom_connection' => [
|
||||
"driver" => "mysql",
|
||||
"url" => "",
|
||||
"host" => "mysql",
|
||||
"port" => "3306",
|
||||
"database" => "main",
|
||||
"username" => "root",
|
||||
"password" => "password",
|
||||
"unix_socket" => "",
|
||||
"charset" => "latin1", // Different from the default (utf8mb4)
|
||||
"collation" => "latin1_swedish_ci", // Different from the default (utf8mb4_unicode_ci)
|
||||
"prefix" => "",
|
||||
"prefix_indexes" => true,
|
||||
"strict" => true,
|
||||
"engine" => null,
|
||||
"options" => []
|
||||
]]);
|
||||
|
||||
$templateConnectionDuringMigration = null;
|
||||
$tenantConnectionDuringMigration = null;
|
||||
|
||||
Event::listen(MigratingDatabase::class, function() use (&$templateConnectionDuringMigration, &$tenantConnectionDuringMigration) {
|
||||
$templateConnectionDuringMigration = config('tenancy.database.template_tenant_connection');
|
||||
$tenantConnectionDuringMigration = DB::connection('tenant')->getConfig();
|
||||
});
|
||||
|
||||
// The original tenant template connection config remains default
|
||||
expect(config('tenancy.database.template_tenant_connection'))->toBe($originalTemplateConnection);
|
||||
|
||||
Tenant::create();
|
||||
|
||||
// The original template connection is used when the --database option is not passed
|
||||
pest()->artisan('tenants:migrate');
|
||||
expect($templateConnectionDuringMigration)->toBe($originalTemplateConnection);
|
||||
|
||||
Tenant::create();
|
||||
|
||||
// The migrate command temporarily uses the connection passed in the --database option
|
||||
pest()->artisan('tenants:migrate', ['--database' => 'custom_connection']);
|
||||
expect($templateConnectionDuringMigration)->toBe('custom_connection');
|
||||
|
||||
// The tenant connection during migration actually used custom_connection's config
|
||||
expect($tenantConnectionDuringMigration['charset'])->toBe('latin1');
|
||||
expect($tenantConnectionDuringMigration['collation'])->toBe('latin1_swedish_ci');
|
||||
|
||||
// The tenant template connection config is restored to the original after migrating
|
||||
expect(config('tenancy.database.template_tenant_connection'))->toBe($originalTemplateConnection);
|
||||
});
|
||||
|
||||
test('migrate command only throws exceptions if skip-failing is not passed', function() {
|
||||
Tenant::create();
|
||||
|
||||
|
|
@ -311,6 +366,21 @@ test('migrate fresh command works', function () {
|
|||
expect(DB::table('users')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('migrate fresh command respects force option in production', function () {
|
||||
// Set environment to production
|
||||
app()->detectEnvironment(fn() => 'production');
|
||||
|
||||
Tenant::create();
|
||||
|
||||
// Without --force in production, command should prompt for confirmation
|
||||
pest()->artisan('tenants:migrate-fresh')
|
||||
->expectsConfirmation('Are you sure you want to run this command?');
|
||||
|
||||
// With --force, command should succeed without prompting
|
||||
pest()->artisan('tenants:migrate-fresh', ['--force' => true])
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
test('run command with array of tenants works', function () {
|
||||
$tenantId1 = Tenant::create()->getTenantKey();
|
||||
$tenantId2 = Tenant::create()->getTenantKey();
|
||||
|
|
|
|||
193
tests/DatabaseCacheBootstrapperTest.php
Normal file
193
tests/DatabaseCacheBootstrapperTest.php
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
use function Stancl\Tenancy\Tests\withBootstrapping;
|
||||
use function Stancl\Tenancy\Tests\withCacheTables;
|
||||
use function Stancl\Tenancy\Tests\withTenantDatabases;
|
||||
|
||||
beforeEach(function () {
|
||||
withBootstrapping();
|
||||
withCacheTables();
|
||||
withTenantDatabases(true);
|
||||
|
||||
DatabaseCacheBootstrapper::$stores = null;
|
||||
|
||||
config([
|
||||
'cache.stores.database.connection' => 'central', // Explicitly set cache DB connection name in config
|
||||
'cache.stores.database.lock_connection' => 'central', // Also set lock connection name
|
||||
'cache.default' => 'database',
|
||||
'tenancy.bootstrappers' => [
|
||||
DatabaseTenancyBootstrapper::class,
|
||||
DatabaseCacheBootstrapper::class, // Used instead of CacheTenancyBootstrapper
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
DatabaseCacheBootstrapper::$stores = null;
|
||||
});
|
||||
|
||||
test('DatabaseCacheBootstrapper switches the database cache store connections correctly', function () {
|
||||
expect(config('cache.stores.database.connection'))->toBe('central');
|
||||
expect(config('cache.stores.database.lock_connection'))->toBe('central');
|
||||
expect(Cache::store()->getConnection()->getName())->toBe('central');
|
||||
expect(Cache::lock('foo')->getConnectionName())->toBe('central');
|
||||
|
||||
tenancy()->initialize(Tenant::create());
|
||||
|
||||
expect(config('cache.stores.database.connection'))->toBe('tenant');
|
||||
expect(config('cache.stores.database.lock_connection'))->toBe('tenant');
|
||||
expect(Cache::store()->getConnection()->getName())->toBe('tenant');
|
||||
expect(Cache::lock('foo')->getConnectionName())->toBe('tenant');
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
expect(config('cache.stores.database.connection'))->toBe('central');
|
||||
expect(config('cache.stores.database.lock_connection'))->toBe('central');
|
||||
expect(Cache::store()->getConnection()->getName())->toBe('central');
|
||||
expect(Cache::lock('foo')->getConnectionName())->toBe('central');
|
||||
});
|
||||
|
||||
test('cache is separated correctly when using DatabaseCacheBootstrapper', function() {
|
||||
// We need the prefix later for lower-level assertions. Let's store it
|
||||
// once now and reuse this variable rather than re-fetching it to make
|
||||
// it clear that the scoping does NOT come from a prefix change.
|
||||
|
||||
$cachePrefix = config('cache.prefix');
|
||||
$getCacheUsingDbQuery = fn (string $cacheKey) =>
|
||||
DB::selectOne("SELECT * FROM `cache` WHERE `key` = '{$cachePrefix}{$cacheKey}'")?->value;
|
||||
|
||||
$tenant = Tenant::create();
|
||||
$tenant2 = Tenant::create();
|
||||
|
||||
// Write to cache in central context
|
||||
cache()->set('foo', 'central');
|
||||
expect(Cache::get('foo'))->toBe('central');
|
||||
// The value retrieved by the DB query is formatted like "s:7:"central";".
|
||||
// We use toContain() because of this formatting instead of just toBe().
|
||||
expect($getCacheUsingDbQuery('foo'))->toContain('central');
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
// Central cache doesn't leak to tenant context
|
||||
expect(Cache::has('foo'))->toBeFalse();
|
||||
expect($getCacheUsingDbQuery('foo'))->toBeNull();
|
||||
|
||||
cache()->set('foo', 'bar');
|
||||
expect(Cache::get('foo'))->toBe('bar');
|
||||
expect($getCacheUsingDbQuery('foo'))->toContain('bar');
|
||||
|
||||
tenancy()->initialize($tenant2);
|
||||
|
||||
// Assert one tenant's cache doesn't leak to another tenant
|
||||
expect(Cache::has('foo'))->toBeFalse();
|
||||
expect($getCacheUsingDbQuery('foo'))->toBeNull();
|
||||
|
||||
cache()->set('foo', 'xyz');
|
||||
expect(Cache::get('foo'))->toBe('xyz');
|
||||
expect($getCacheUsingDbQuery('foo'))->toContain('xyz');
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
// Assert cache didn't leak to the original tenant
|
||||
expect(Cache::get('foo'))->toBe('bar');
|
||||
expect($getCacheUsingDbQuery('foo'))->toContain('bar');
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
// Assert central 'foo' cache is still the same ('central')
|
||||
expect(Cache::get('foo'))->toBe('central');
|
||||
expect($getCacheUsingDbQuery('foo'))->toContain('central');
|
||||
});
|
||||
|
||||
test('DatabaseCacheBootstrapper auto-detects all database driver stores by default', function() {
|
||||
config([
|
||||
'cache.stores.database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => 'central',
|
||||
'table' => 'cache',
|
||||
],
|
||||
'cache.stores.sessions' => [
|
||||
'driver' => 'database',
|
||||
'connection' => 'central',
|
||||
'table' => 'sessions_cache',
|
||||
],
|
||||
'cache.stores.redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => 'default',
|
||||
],
|
||||
'cache.stores.file' => [
|
||||
'driver' => 'file',
|
||||
'path' => '/foo/bar',
|
||||
],
|
||||
]);
|
||||
|
||||
// Here, we're using auto-detection (default behavior)
|
||||
expect(config('cache.stores.database.connection'))->toBe('central');
|
||||
expect(config('cache.stores.sessions.connection'))->toBe('central');
|
||||
expect(config('cache.stores.redis.connection'))->toBe('default');
|
||||
expect(config('cache.stores.file.path'))->toBe('/foo/bar');
|
||||
|
||||
tenancy()->initialize(Tenant::create());
|
||||
|
||||
// Using auto-detection (default behavior),
|
||||
// all database driver stores should be configured,
|
||||
// and stores with non-database drivers are ignored.
|
||||
expect(config('cache.stores.database.connection'))->toBe('tenant');
|
||||
expect(config('cache.stores.sessions.connection'))->toBe('tenant');
|
||||
expect(config('cache.stores.redis.connection'))->toBe('default'); // unchanged
|
||||
expect(config('cache.stores.file.path'))->toBe('/foo/bar'); // unchanged
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
// All database stores should be reverted, others unchanged
|
||||
expect(config('cache.stores.database.connection'))->toBe('central');
|
||||
expect(config('cache.stores.sessions.connection'))->toBe('central');
|
||||
expect(config('cache.stores.redis.connection'))->toBe('default');
|
||||
expect(config('cache.stores.file.path'))->toBe('/foo/bar');
|
||||
});
|
||||
|
||||
test('manual $stores configuration takes precedence over auto-detection', function() {
|
||||
// Configure multiple database stores
|
||||
config([
|
||||
'cache.stores.sessions' => [
|
||||
'driver' => 'database',
|
||||
'connection' => 'central',
|
||||
'table' => 'sessions_cache',
|
||||
],
|
||||
'cache.stores.redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => 'default',
|
||||
],
|
||||
]);
|
||||
|
||||
// Specific store overrides (including non-database stores)
|
||||
DatabaseCacheBootstrapper::$stores = ['sessions', 'redis']; // Note: excludes 'database'
|
||||
|
||||
expect(config('cache.stores.database.connection'))->toBe('central');
|
||||
expect(config('cache.stores.sessions.connection'))->toBe('central');
|
||||
expect(config('cache.stores.redis.connection'))->toBe('default');
|
||||
|
||||
tenancy()->initialize(Tenant::create());
|
||||
|
||||
// Manual config takes precedence: only 'sessions' is configured
|
||||
// - redis filtered out by driver check
|
||||
// - database store not included in $stores
|
||||
expect(config('cache.stores.database.connection'))->toBe('central'); // Excluded in manual config
|
||||
expect(config('cache.stores.sessions.connection'))->toBe('tenant'); // Included and is database driver
|
||||
expect(config('cache.stores.redis.connection'))->toBe('default'); // Included but filtered out (not database driver)
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
// Only the manually configured stores' config will be reverted
|
||||
expect(config('cache.stores.database.connection'))->toBe('central');
|
||||
expect(config('cache.stores.sessions.connection'))->toBe('central');
|
||||
expect(config('cache.stores.redis.connection'))->toBe('default');
|
||||
});
|
||||
|
|
@ -9,7 +9,6 @@ use Stancl\Tenancy\Events\TenantCreated;
|
|||
use Stancl\Tenancy\Jobs\CreateDatabase;
|
||||
use Stancl\Tenancy\Jobs\MigrateDatabase;
|
||||
use Stancl\Tenancy\Jobs\SeedDatabase;
|
||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\MySQLDatabaseManager;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use Illuminate\Foundation\Auth\User as Authenticable;
|
||||
use Stancl\Tenancy\Tests\Etc\TestSeeder;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
|||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||
|
||||
use function Stancl\Tenancy\Tests\withCacheTables;
|
||||
use function Stancl\Tenancy\Tests\withTenantDatabases;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
|
|
@ -20,26 +25,40 @@ beforeEach(function () {
|
|||
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||
|
||||
withCacheTables();
|
||||
});
|
||||
|
||||
test('global cache manager stores data in global cache', function (string $bootstrapper) {
|
||||
config(['tenancy.bootstrappers' => [$bootstrapper]]);
|
||||
test('global cache manager stores data in global cache', function (string $store, array $bootstrappers) {
|
||||
config([
|
||||
'cache.default' => $store,
|
||||
'tenancy.bootstrappers' => $bootstrappers,
|
||||
]);
|
||||
|
||||
if ($store === 'database') withTenantDatabases(true);
|
||||
|
||||
expect(cache('foo'))->toBe(null);
|
||||
GlobalCache::put(['foo' => 'bar'], 1);
|
||||
GlobalCache::put('foo', 'bar');
|
||||
expect(GlobalCache::get('foo'))->toBe('bar');
|
||||
|
||||
$tenant1 = Tenant::create();
|
||||
tenancy()->initialize($tenant1);
|
||||
expect(GlobalCache::get('foo'))->toBe('bar');
|
||||
|
||||
GlobalCache::put(['abc' => 'xyz'], 1);
|
||||
cache(['def' => 'ghi'], 10);
|
||||
GlobalCache::put('abc', 'xyz');
|
||||
cache(['def' => 'ghi']);
|
||||
expect(cache('def'))->toBe('ghi');
|
||||
|
||||
// different stores, same underlying connection. the prefix is set ON THE STORE
|
||||
expect(cache()->store()->getStore() === GlobalCache::store()->getStore())->toBeFalse();
|
||||
expect(cache()->store()->getStore()->connection() === GlobalCache::store()->getStore()->connection())->toBeTrue();
|
||||
// different stores
|
||||
expect(cache()->store()->getStore() !== GlobalCache::store()->getStore())->toBeTrue();
|
||||
if ($store === 'redis') {
|
||||
// same underlying connection. the prefix is set ON THE STORE
|
||||
expect(cache()->store()->getStore()->connection() === GlobalCache::store()->getStore()->connection())->toBeTrue();
|
||||
} else {
|
||||
// different connections
|
||||
expect(cache()->store()->getStore()->getConnection()->getName())->toBe('tenant');
|
||||
expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
}
|
||||
|
||||
tenancy()->end();
|
||||
expect(GlobalCache::get('abc'))->toBe('xyz');
|
||||
|
|
@ -51,25 +70,129 @@ test('global cache manager stores data in global cache', function (string $boots
|
|||
expect(GlobalCache::get('abc'))->toBe('xyz');
|
||||
expect(GlobalCache::get('foo'))->toBe('bar');
|
||||
expect(cache('def'))->toBe(null);
|
||||
cache(['def' => 'xxx'], 1);
|
||||
cache(['def' => 'xxx']);
|
||||
expect(cache('def'))->toBe('xxx');
|
||||
|
||||
tenancy()->initialize($tenant1);
|
||||
expect(cache('def'))->toBe('ghi');
|
||||
})->with([
|
||||
CacheTagsBootstrapper::class,
|
||||
CacheTenancyBootstrapper::class,
|
||||
['redis', [CacheTagsBootstrapper::class]],
|
||||
['redis', [CacheTenancyBootstrapper::class]],
|
||||
['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]],
|
||||
]);
|
||||
|
||||
test('the global_cache helper supports the same syntax as the cache helper', function (string $bootstrapper) {
|
||||
config(['tenancy.bootstrappers' => [$bootstrapper]]);
|
||||
test('global cache facade is not persistent', function () {
|
||||
$oldId = spl_object_id(GlobalCache::getFacadeRoot());
|
||||
|
||||
$_ = new class {};
|
||||
|
||||
expect(spl_object_id(GlobalCache::getFacadeRoot()))->not()->toBe($oldId);
|
||||
});
|
||||
|
||||
test('global cache is always central', function (string $store, array $bootstrappers, string $initialCentralCall) {
|
||||
config([
|
||||
'cache.default' => $store,
|
||||
'tenancy.bootstrappers' => $bootstrappers,
|
||||
]);
|
||||
|
||||
if ($store === 'database') {
|
||||
withTenantDatabases(true);
|
||||
}
|
||||
|
||||
// This tells us which "accessor" for the global cache should be instantiated first, before we go
|
||||
// into the tenant context. We make sure to not touch the other one here. This tests that whether
|
||||
// a particular accessor is used "early" makes no difference in the later behavior.
|
||||
if ($initialCentralCall === 'helper') {
|
||||
if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
global_cache()->put('central-helper', true);
|
||||
} else if ($initialCentralCall === 'facade') {
|
||||
if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
GlobalCache::put('central-facade', true);
|
||||
} else if ($initialCentralCall === 'both') {
|
||||
if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
global_cache()->put('central-helper', true);
|
||||
if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
GlobalCache::put('central-facade', true);
|
||||
}
|
||||
|
||||
$tenant = Tenant::create();
|
||||
$tenant->enter();
|
||||
|
||||
// different stores, same underlying connection. the prefix is set ON THE STORE
|
||||
expect(cache()->store()->getStore() === global_cache()->store()->getStore())->toBeFalse();
|
||||
expect(cache()->store()->getStore()->connection() === global_cache()->store()->getStore()->connection())->toBeTrue();
|
||||
// Here we use both the helper and the facade to ensure the value is accessible via either one
|
||||
if ($initialCentralCall === 'helper') {
|
||||
if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
expect(global_cache('central-helper'))->toBe(true);
|
||||
expect(GlobalCache::get('central-helper'))->toBe(true);
|
||||
} else if ($initialCentralCall === 'facade') {
|
||||
if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
expect(global_cache('central-facade'))->toBe(true);
|
||||
expect(GlobalCache::get('central-facade'))->toBe(true);
|
||||
} else if ($initialCentralCall === 'both') {
|
||||
if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
expect(global_cache('central-helper'))->toBe(true);
|
||||
expect(GlobalCache::get('central-helper'))->toBe(true);
|
||||
expect(global_cache('central-facade'))->toBe(true);
|
||||
expect(GlobalCache::get('central-facade'))->toBe(true);
|
||||
}
|
||||
|
||||
global_cache()->put('tenant-helper', true);
|
||||
GlobalCache::put('tenant-facade', true);
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
if ($store === 'database') expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
if ($store === 'database') expect(GlobalCache::store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
|
||||
expect(global_cache('tenant-helper'))->toBe(true);
|
||||
expect(GlobalCache::get('tenant-helper'))->toBe(true);
|
||||
expect(global_cache('tenant-facade'))->toBe(true);
|
||||
expect(GlobalCache::get('tenant-facade'))->toBe(true);
|
||||
|
||||
if ($initialCentralCall === 'helper') {
|
||||
expect(GlobalCache::get('central-helper'))->toBe(true);
|
||||
} else if ($initialCentralCall === 'facade') {
|
||||
expect(global_cache('central-facade'))->toBe(true);
|
||||
} else if ($initialCentralCall === 'both') {
|
||||
expect(global_cache('central-helper'))->toBe(true);
|
||||
expect(GlobalCache::get('central-helper'))->toBe(true);
|
||||
expect(global_cache('central-facade'))->toBe(true);
|
||||
expect(GlobalCache::get('central-facade'))->toBe(true);
|
||||
}
|
||||
})->with([
|
||||
['redis', [CacheTagsBootstrapper::class]],
|
||||
['redis', [CacheTenancyBootstrapper::class]],
|
||||
['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]],
|
||||
])->with([
|
||||
'helper',
|
||||
'facade',
|
||||
'both',
|
||||
'none',
|
||||
]);
|
||||
|
||||
test('the global_cache helper supports the same syntax as the cache helper', function (string $store, array $bootstrappers) {
|
||||
config([
|
||||
'cache.default' => $store,
|
||||
'tenancy.bootstrappers' => $bootstrappers,
|
||||
]);
|
||||
|
||||
if ($store === 'database') withTenantDatabases(true);
|
||||
|
||||
$tenant = Tenant::create();
|
||||
$tenant->enter();
|
||||
|
||||
// different stores
|
||||
expect(cache()->store()->getStore() !== GlobalCache::store()->getStore())->toBeTrue();
|
||||
if ($store === 'redis') {
|
||||
// same underlying connection. the prefix is set ON THE STORE
|
||||
expect(cache()->store()->getStore()->connection() === global_cache()->store()->getStore()->connection())->toBeTrue();
|
||||
} else {
|
||||
// different connections
|
||||
expect(cache()->store()->getStore()->getConnection()->getName())->toBe('tenant');
|
||||
expect(global_cache()->store()->getStore()->getConnection()->getName())->toBe('central');
|
||||
}
|
||||
|
||||
expect(cache('foo'))->toBe(null); // tenant cache is empty
|
||||
|
||||
|
|
@ -81,6 +204,7 @@ test('the global_cache helper supports the same syntax as the cache helper', fun
|
|||
|
||||
expect(cache('foo'))->toBe(null); // tenant cache is not affected
|
||||
})->with([
|
||||
CacheTagsBootstrapper::class,
|
||||
CacheTenancyBootstrapper::class,
|
||||
['redis', [CacheTagsBootstrapper::class]],
|
||||
['redis', [CacheTenancyBootstrapper::class]],
|
||||
['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]],
|
||||
]);
|
||||
|
|
|
|||
87
tests/InitializedBootstrappersTest.php
Normal file
87
tests/InitializedBootstrappersTest.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
|
||||
use Stancl\Tenancy\Contracts\Tenant;
|
||||
use Stancl\Tenancy\Events\TenancyEnded;
|
||||
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant as TenantModel;
|
||||
|
||||
test('only bootstrappers that have been initialized are reverted', function () {
|
||||
config(['tenancy.bootstrappers' => [
|
||||
Initialized_DummyBootstrapperFoo::class,
|
||||
Initialized_DummyBootstrapperBar::class,
|
||||
Initialized_DummyBootstrapperBaz::class,
|
||||
]]);
|
||||
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||
|
||||
// Only needs to be done in tests
|
||||
app()->singleton(Initialized_DummyBootstrapperFoo::class);
|
||||
app()->singleton(Initialized_DummyBootstrapperBar::class);
|
||||
app()->singleton(Initialized_DummyBootstrapperBaz::class);
|
||||
|
||||
$tenant = TenantModel::create();
|
||||
|
||||
try {
|
||||
$tenant->run(fn() => null);
|
||||
// Should throw an exception
|
||||
expect(true)->toBe(false);
|
||||
} catch (Exception $e) {
|
||||
// NOT 'baz fail in revert' as was the behavior before
|
||||
// the commit that added this test
|
||||
expect($e->getMessage())->toBe('bar fail in bootstrap');
|
||||
}
|
||||
|
||||
expect(tenancy()->initializedBootstrappers)->toBe([
|
||||
Initialized_DummyBootstrapperFoo::class,
|
||||
]);
|
||||
});
|
||||
|
||||
class Initialized_DummyBootstrapperFoo implements TenancyBootstrapper
|
||||
{
|
||||
public string $bootstrapped = 'uninitialized';
|
||||
|
||||
public function bootstrap(Tenant $tenant): void
|
||||
{
|
||||
$this->bootstrapped = 'bootstrapped';
|
||||
}
|
||||
|
||||
public function revert(): void
|
||||
{
|
||||
$this->bootstrapped = 'reverted';
|
||||
}
|
||||
}
|
||||
|
||||
class Initialized_DummyBootstrapperBar implements TenancyBootstrapper
|
||||
{
|
||||
public string $bootstrapped = 'uninitialized';
|
||||
|
||||
public function bootstrap(Tenant $tenant): void
|
||||
{
|
||||
throw new Exception('bar fail in bootstrap');
|
||||
}
|
||||
|
||||
public function revert(): void
|
||||
{
|
||||
$this->bootstrapped = 'reverted';
|
||||
}
|
||||
}
|
||||
|
||||
class Initialized_DummyBootstrapperBaz implements TenancyBootstrapper
|
||||
{
|
||||
public string $bootstrapped = 'uninitialized';
|
||||
|
||||
public function bootstrap(Tenant $tenant): void
|
||||
{
|
||||
$this->bootstrapped = 'bootstrapped';
|
||||
}
|
||||
|
||||
public function revert(): void
|
||||
{
|
||||
throw new Exception('baz fail in revert');
|
||||
}
|
||||
}
|
||||
|
|
@ -2,21 +2,52 @@
|
|||
|
||||
namespace Stancl\Tenancy\Tests;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Stancl\Tenancy\Tests\TestCase;
|
||||
use Stancl\JobPipeline\JobPipeline;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Stancl\Tenancy\Events\TenancyEnded;
|
||||
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||
use Stancl\Tenancy\Jobs\CreateDatabase;
|
||||
use Stancl\Tenancy\Events\TenantCreated;
|
||||
use Stancl\Tenancy\Jobs\MigrateDatabase;
|
||||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||
|
||||
uses(TestCase::class)->in(__DIR__);
|
||||
|
||||
function withTenantDatabases()
|
||||
function withBootstrapping()
|
||||
{
|
||||
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
|
||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||
}
|
||||
|
||||
function withTenantDatabases(bool $migrate = false)
|
||||
{
|
||||
Event::listen(TenantCreated::class, JobPipeline::make($migrate
|
||||
? [CreateDatabase::class, MigrateDatabase::class]
|
||||
: [CreateDatabase::class]
|
||||
)->send(function (TenantCreated $event) {
|
||||
return $event->tenant;
|
||||
})->toListener());
|
||||
}
|
||||
|
||||
function withCacheTables()
|
||||
{
|
||||
Schema::create('cache', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->mediumText('value');
|
||||
$table->integer('expiration');
|
||||
});
|
||||
|
||||
Schema::create('cache_locks', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->string('owner');
|
||||
$table->integer('expiration');
|
||||
});
|
||||
}
|
||||
|
||||
function pest(): TestCase
|
||||
{
|
||||
return \Pest\TestSuite::getInstance()->test;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
|
|||
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
use Illuminate\Cookie\CookieValuePrefix;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
|
|
@ -88,6 +89,11 @@ test('cookie identification works', function (string|null $tenantModelColumn) {
|
|||
// Default cookie name
|
||||
$this->withoutExceptionHandling()->withUnencryptedCookie('tenant', $payload)->get('test')->assertSee($tenant->id);
|
||||
|
||||
// Manually encrypted cookie (encrypt the cookie exactly like MakesHttpRequests does in prepareCookiesForRequest())
|
||||
$encryptedPayload = encrypt(CookieValuePrefix::create('tenant', app('encrypter')->getKey()) . $payload, false);
|
||||
|
||||
$this->withoutExceptionHandling()->withUnencryptedCookie('tenant', $encryptedPayload)->get('test')->assertSee($tenant->id);
|
||||
|
||||
// Custom cookie name
|
||||
config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.cookie' => 'custom_tenant_id']);
|
||||
$this->withoutExceptionHandling()->withUnencryptedCookie('custom_tenant_id', $payload)->get('test')->assertSee($tenant->id);
|
||||
|
|
@ -97,10 +103,7 @@ test('cookie identification works', function (string|null $tenantModelColumn) {
|
|||
expect(fn () => $this->withoutExceptionHandling()->withUnencryptedCookie('tenant', $payload)->get('test'))->toThrow(TenantCouldNotBeIdentifiedByRequestDataException::class);
|
||||
})->with([null, 'slug']);
|
||||
|
||||
// todo@tests encrypted cookie
|
||||
|
||||
test('an exception is thrown when no tenant data is provided in the request', function () {
|
||||
pest()->expectException(TenantCouldNotBeIdentifiedByRequestDataException::class);
|
||||
$this->withoutExceptionHandling()->get('test');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -23,8 +23,10 @@ use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper;
|
|||
use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
use Stancl\Tenancy\Bootstrappers\LogTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper;
|
||||
|
||||
use function Stancl\Tenancy\Tests\pest;
|
||||
|
||||
abstract class TestCase extends \Orchestra\Testbench\TestCase
|
||||
{
|
||||
|
|
@ -39,6 +41,10 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
|
|||
|
||||
ini_set('memory_limit', '1G');
|
||||
|
||||
TenancyServiceProvider::$registerForgetTenantParameterListener = true;
|
||||
TenancyServiceProvider::$migrateFreshOverride = true;
|
||||
TenancyServiceProvider::$adjustCacheManagerUsing = null;
|
||||
|
||||
Redis::connection('default')->flushdb();
|
||||
Redis::connection('cache')->flushdb();
|
||||
Artisan::call('cache:clear memcached'); // flush memcached
|
||||
|
|
@ -181,6 +187,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
|
|||
// to manually register bootstrappers as singletons here.
|
||||
$app->singleton(RedisTenancyBootstrapper::class);
|
||||
$app->singleton(CacheTenancyBootstrapper::class);
|
||||
$app->singleton(DatabaseCacheBootstrapper::class);
|
||||
$app->singleton(BroadcastingConfigBootstrapper::class);
|
||||
$app->singleton(BroadcastChannelPrefixBootstrapper::class);
|
||||
$app->singleton(PostgresRLSBootstrapper::class);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue