mirror of
https://github.com/archtechx/tenancy.git
synced 2025-12-12 12:44:02 +00:00
* Add test for syncable models with global scopes * minor fixes * Make test clearer * Improve test name * Clarify scopeGetModelQuery test, document edge case * Fix assertion * Delete extra newline * Update the scopeGetModelQuery test so that it tests a realistic case * Clarify test * cleanup * Try simplifying the tests * Revert change to test adding unnecessary complexity * Make test clear, extensively commented and as simple as possible * Delete unused import * Make test clearer * Polish comments * Improve comment * Explicitly reset global scopes on models in beforeEach() * Simplify comments in test * Revert changes in test * add assertion * add global scope reset to afterEach --------- Co-authored-by: Samuel Štancl <samuel@archte.ch>
1448 lines
47 KiB
PHP
1448 lines
47 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use Illuminate\Support\Str;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Stancl\JobPipeline\JobPipeline;
|
||
use Illuminate\Support\Facades\Event;
|
||
use Illuminate\Support\Facades\Queue;
|
||
use Illuminate\Database\Eloquent\Model;
|
||
use Stancl\Tenancy\Events\TenancyEnded;
|
||
use Stancl\Tenancy\Jobs\CreateDatabase;
|
||
use Stancl\Tenancy\Events\TenantCreated;
|
||
use Illuminate\Events\CallQueuedListener;
|
||
use Stancl\Tenancy\Database\DatabaseConfig;
|
||
use Stancl\Tenancy\ResourceSyncing\Syncable;
|
||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||
use Stancl\Tenancy\Events\TenancyInitialized;
|
||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||
use Stancl\Tenancy\ResourceSyncing\SyncMaster;
|
||
use Illuminate\Contracts\Database\Eloquent\Builder;
|
||
use Stancl\Tenancy\ResourceSyncing\ResourceSyncing;
|
||
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||
use Stancl\Tenancy\ResourceSyncing\TenantMorphPivot;
|
||
use Stancl\Tenancy\Tests\Etc\ResourceSyncing\Tenant;
|
||
use Illuminate\Database\Eloquent\Relations\MorphToMany;
|
||
use Stancl\Tenancy\Database\Concerns\CentralConnection;
|
||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||
use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterDeleted;
|
||
use Stancl\Tenancy\ResourceSyncing\TenantPivot as BasePivot;
|
||
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||
use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterRestored;
|
||
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSaved;
|
||
use Stancl\Tenancy\ResourceSyncing\ModelNotSyncMasterException;
|
||
use Stancl\Tenancy\ResourceSyncing\Listeners\CreateTenantResource;
|
||
use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteResourceInTenant;
|
||
use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteResourcesInTenants;
|
||
use Stancl\Tenancy\ResourceSyncing\Listeners\RestoreResourcesInTenants;
|
||
use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceAttachedToTenant;
|
||
use Stancl\Tenancy\ResourceSyncing\Listeners\UpdateOrCreateSyncedResource;
|
||
use Stancl\Tenancy\Tests\Etc\ResourceSyncing\TenantUser as BaseTenantUser;
|
||
use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceDetachedFromTenant;
|
||
use Stancl\Tenancy\Tests\Etc\ResourceSyncing\CentralUser as BaseCentralUser;
|
||
use Stancl\Tenancy\ResourceSyncing\CentralResourceNotAvailableInPivotException;
|
||
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase;
|
||
use Illuminate\Database\Eloquent\Scope;
|
||
use Illuminate\Database\QueryException;
|
||
use function Stancl\Tenancy\Tests\pest;
|
||
|
||
beforeEach(function () {
|
||
config(['tenancy.bootstrappers' => [
|
||
DatabaseTenancyBootstrapper::class,
|
||
]]);
|
||
|
||
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
|
||
return $event->tenant;
|
||
})->toListener());
|
||
|
||
DatabaseConfig::generateDatabaseNamesUsing(function () {
|
||
return 'db' . Str::random(16);
|
||
});
|
||
|
||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||
|
||
// Global state cleanup
|
||
UpdateOrCreateSyncedResource::$shouldQueue = false;
|
||
DeleteResourcesInTenants::$shouldQueue = false;
|
||
CreateTenantResource::$shouldQueue = false;
|
||
DeleteResourceInTenant::$shouldQueue = false;
|
||
UpdateOrCreateSyncedResource::$scopeGetModelQuery = null;
|
||
|
||
// Reset global scopes on models (should happen automatically but to make this more explicit)
|
||
Model::clearBootedModels();
|
||
|
||
$syncedAttributes = [
|
||
'global_id',
|
||
'name',
|
||
'password',
|
||
'email',
|
||
];
|
||
|
||
TenantUser::$shouldSync = true;
|
||
CentralUser::$shouldSync = true;
|
||
|
||
TenantUser::$syncedAttributes = $syncedAttributes;
|
||
CentralUser::$syncedAttributes = $syncedAttributes;
|
||
|
||
$creationAttributes = ['role' => 'commenter'];
|
||
|
||
TenantUser::$creationAttributes = $creationAttributes;
|
||
CentralUser::$creationAttributes = $creationAttributes;
|
||
|
||
Event::listen(SyncedResourceSaved::class, UpdateOrCreateSyncedResource::class);
|
||
Event::listen(SyncMasterDeleted::class, DeleteResourcesInTenants::class);
|
||
Event::listen(SyncMasterRestored::class, RestoreResourcesInTenants::class);
|
||
Event::listen(CentralResourceAttachedToTenant::class, CreateTenantResource::class);
|
||
Event::listen(CentralResourceDetachedFromTenant::class, DeleteResourceInTenant::class);
|
||
|
||
// Run migrations on central connection
|
||
pest()->artisan('migrate', [
|
||
'--path' => [
|
||
__DIR__ . '/../assets/resource-syncing-migrations',
|
||
__DIR__ . '/Etc/synced_resource_migrations',
|
||
__DIR__ . '/Etc/synced_resource_migrations/users',
|
||
__DIR__ . '/Etc/synced_resource_migrations/companies',
|
||
],
|
||
'--realpath' => true,
|
||
])->assertExitCode(0);
|
||
});
|
||
|
||
afterEach(function () {
|
||
UpdateOrCreateSyncedResource::$scopeGetModelQuery = null;
|
||
|
||
// Reset global scopes on models (should happen automatically but to make this more explicit)
|
||
Model::clearBootedModels();
|
||
});
|
||
|
||
test('resources created with the same global id in different tenant dbs will be synced to a single central resource', function () {
|
||
$tenants = [Tenant::create(), Tenant::create(), Tenant::create()];
|
||
migrateUsersTableForTenants();
|
||
|
||
// Only a single central user is created since the same global_id is used for each tenant user
|
||
// Therefore all of these tenant users are synced to a single global user
|
||
tenancy()->runForMultiple($tenants, function () {
|
||
// Create a user with the same global_id in each tenant DB
|
||
TenantUser::create([
|
||
'global_id' => 'acme',
|
||
'name' => Str::random(),
|
||
'email' => 'john@localhost',
|
||
'password' => 'secret',
|
||
'role' => 'commenter',
|
||
]);
|
||
});
|
||
|
||
expect(CentralUser::all())->toHaveCount(1);
|
||
expect(CentralUser::first()->global_id)->toBe('acme');
|
||
});
|
||
|
||
test('SyncedResourceSaved event gets triggered when resource gets created or when its synced attributes get updated', function () {
|
||
Event::fake(SyncedResourceSaved::class);
|
||
|
||
// Create resource
|
||
$user = TenantUser::create([
|
||
'name' => 'Foo',
|
||
'email' => 'foo@email.com',
|
||
'password' => 'secret',
|
||
'global_id' => 'foo',
|
||
'role' => 'foo',
|
||
]);
|
||
|
||
Event::assertDispatched(SyncedResourceSaved::class, function (SyncedResourceSaved $event) use ($user) {
|
||
return $event->model === $user;
|
||
});
|
||
|
||
// Flush
|
||
Event::fake(SyncedResourceSaved::class);
|
||
|
||
// Update resource's synced attribute
|
||
$user->update(['name' => 'Bar']);
|
||
|
||
Event::assertDispatched(SyncedResourceSaved::class, function (SyncedResourceSaved $event) use ($user) {
|
||
return $event->model === $user;
|
||
});
|
||
|
||
// Flush
|
||
Event::fake(SyncedResourceSaved::class);
|
||
|
||
// Refetch the model to reset $user->wasRecentlyCreated
|
||
$user = TenantUser::firstWhere('name', 'Bar');
|
||
|
||
// Update resource's unsynced attribute
|
||
$user->update(['role' => 'bar']);
|
||
|
||
Event::assertNotDispatched(SyncedResourceSaved::class); // regression test for #1168
|
||
});
|
||
|
||
test('only synced columns get updated by the syncing logic', function () {
|
||
// Create user in central DB
|
||
$user = CentralUser::create([
|
||
'global_id' => 'acme',
|
||
'name' => 'John Doe',
|
||
'email' => 'john@localhost',
|
||
'password' => 'secret',
|
||
'role' => 'superadmin', // Unsynced
|
||
]);
|
||
|
||
$tenant = Tenant::create();
|
||
migrateUsersTableForTenants();
|
||
|
||
tenancy()->initialize($tenant);
|
||
|
||
// Create the same user in tenant DB
|
||
$user = TenantUser::create([
|
||
'global_id' => 'acme',
|
||
'name' => 'John Doe',
|
||
'email' => 'john@localhost',
|
||
'password' => 'secret',
|
||
'role' => 'commenter', // Unsynced
|
||
]);
|
||
|
||
// Update user in tenant DB
|
||
$user->update([
|
||
'name' => 'John Foo', // Synced
|
||
'email' => 'john@foreignhost', // Synced
|
||
'role' => 'admin', // Unsynced
|
||
]);
|
||
|
||
tenancy()->end();
|
||
|
||
// Assert changes bubbled up
|
||
pest()->assertEquals([
|
||
'id' => 1,
|
||
'global_id' => 'acme',
|
||
'name' => 'John Foo', // Synced
|
||
'email' => 'john@foreignhost', // Synced
|
||
'password' => 'secret',
|
||
'role' => 'superadmin', // Unsynced
|
||
], TenantUser::first()->getAttributes());
|
||
});
|
||
|
||
test('updating tenant resources from central context throws an exception', function () {
|
||
$tenant = Tenant::create();
|
||
migrateUsersTableForTenants();
|
||
|
||
tenancy()->initialize($tenant);
|
||
|
||
TenantUser::create([
|
||
'global_id' => 'foo',
|
||
'name' => 'John Doe',
|
||
'email' => 'john@localhost',
|
||
'password' => 'secret',
|
||
'role' => 'commenter',
|
||
]);
|
||
|
||
tenancy()->end();
|
||
|
||
pest()->expectException(ModelNotSyncMasterException::class);
|
||
TenantUser::first()->update(['password' => 'foobar']);
|
||
});
|
||
|
||
test('attaching central resources to tenants or vice versa creates synced tenant resource', function () {
|
||
$createCentralUser = fn () => CentralUser::create([
|
||
'name' => 'John Doe',
|
||
'email' => 'john@localhost',
|
||
'password' => 'secret',
|
||
'role' => 'commenter', // Unsynced
|
||
]);
|
||
|
||
$tenant = Tenant::create();
|
||
|
||
migrateUsersTableForTenants();
|
||
|
||
$tenant->run(function () {
|
||
expect(TenantUser::all())->toHaveCount(0);
|
||
});
|
||
|
||
// Attaching resources to tenants requires using a pivot that implements the PivotWithRelation interface
|
||
$tenant->customPivotUsers()->attach($createCentralUser());
|
||
$createCentralUser()->tenants()->attach($tenant);
|
||
|
||
$tenant->run(function () {
|
||
// two (separate) central users were created, so there are now two separate tenant users in the tenant's database
|
||
expect(TenantUser::all())->toHaveCount(2);
|
||
});
|
||
});
|
||
|
||
test('detaching central users from tenants or vice versa force deletes the synced tenant resource', function (bool $attachUserToTenant) {
|
||
$centralUser = CentralUser::create([
|
||
'global_id' => 'acme',
|
||
'name' => 'John Doe',
|
||
'email' => 'john@localhost',
|
||
'password' => 'secret',
|
||
'role' => 'commenter', // Unsynced
|
||
]);
|
||
|
||
$tenant = Tenant::create();
|
||
|
||
migrateUsersTableForTenants();
|
||
|
||
if ($attachUserToTenant) {
|
||
// Attaching resources to tenants requires using a pivot that implements the PivotWithRelation interface
|
||
$tenant->customPivotUsers()->attach($centralUser);
|
||
} else {
|
||
$centralUser->tenants()->attach($tenant);
|
||
}
|
||
|
||
$tenant->run(function () {
|
||
expect(TenantUser::all())->toHaveCount(1);
|
||
});
|
||
|
||
if ($attachUserToTenant) {
|
||
// Detaching resources from tenants requires using a pivot that implements the PivotWithRelation interface
|
||
$tenant->customPivotUsers()->detach($centralUser);
|
||
} else {
|
||
$centralUser->tenants()->detach($tenant);
|
||
}
|
||
|
||
$tenant->run(function () {
|
||
expect(TenantUser::all())->toHaveCount(0);
|
||
});
|
||
|
||
addExtraColumns(true);
|
||
|
||
// Detaching *force deletes* the tenant resource
|
||
CentralUserWithSoftDeletes::$creationAttributes = ['role' => 'commenter', 'foo' => 'bar'];
|
||
|
||
$centralUserWithSoftDeletes = CentralUserWithSoftDeletes::create([
|
||
'global_id' => 'bar',
|
||
'name' => 'John Doe',
|
||
'email' => 'john@localhost',
|
||
'password' => 'secret',
|
||
'role' => 'commenter', // Unsynced
|
||
'foo' => 'bar',
|
||
]);
|
||
|
||
if ($attachUserToTenant) {
|
||
$tenant->customPivotUsers()->attach($centralUserWithSoftDeletes);
|
||
} else {
|
||
$centralUserWithSoftDeletes->tenants()->attach($tenant);
|
||
}
|
||
|
||
$tenant->run(function () {
|
||
expect(TenantUserWithSoftDeletes::withTrashed()->count())->toBe(1);
|
||
});
|
||
|
||
if ($attachUserToTenant) {
|
||
// Detaching resources from tenants requires using a pivot that implements the PivotWithRelation interface
|
||
$tenant->customPivotUsers()->detach($centralUserWithSoftDeletes);
|
||
} else {
|
||
$centralUserWithSoftDeletes->tenants()->detach($tenant);
|
||
}
|
||
|
||
$tenant->run(function () {
|
||
expect(TenantUserWithSoftDeletes::withTrashed()->count())->toBe(0);
|
||
});
|
||
})->with([
|
||
true,
|
||
false,
|
||
]);
|
||
|
||
test('attaching tenant to central resource works correctly even when using a single pivot table for multiple models', function () {
|
||
config(['tenancy.models.tenant' => MorphTenant::class]);
|
||
|
||
[$tenant1, $tenant2] = createTenantsAndRunMigrations();
|
||
|
||
migrateCompaniesTableForTenants();
|
||
|
||
// Use BaseCentralUser and BaseTenantUser in this test
|
||
// These models use the polymorphic relationship for tenants(), which is the default
|
||
$centralUser = BaseCentralUser::create([
|
||
'global_id' => 'acme',
|
||
'name' => 'John Doe',
|
||
'email' => 'john@localhost',
|
||
'password' => 'password',
|
||
'role' => 'commenter',
|
||
]);
|
||
|
||
$tenant1->run(function () {
|
||
expect(BaseTenantUser::count())->toBe(0);
|
||
});
|
||
|
||
// Create tenant resource from central resource
|
||
// By attaching a tenant to the central resource
|
||
$centralUser->tenants()->attach($tenant1);
|
||
|
||
// Central users are accessible from the tenant using the relationship method
|
||
expect($tenant1->users()->count())->toBe(1);
|
||
|
||
// Tenants are accessible from the central resource using the relationship method
|
||
expect($centralUser->tenants()->count())->toBe(1);
|
||
|
||
// The tenant resource got created with the correct attributes
|
||
$tenant1->run(function () use ($centralUser) {
|
||
$tenantUser = BaseTenantUser::first()->getAttributes();
|
||
$centralUser = $centralUser->getAttributes();
|
||
|
||
expect($tenantUser)->toEqualCanonicalizing($centralUser);
|
||
});
|
||
|
||
// Test that the company resource can use the same pivot as the user resource
|
||
$centralCompany = CentralCompany::create([
|
||
'global_id' => 'acme',
|
||
'name' => 'ArchTech',
|
||
'email' => 'archtech@localhost',
|
||
]);
|
||
|
||
// Central company wasn't attached yet
|
||
$tenant2->run(function () {
|
||
expect(TenantCompany::count())->toBe(0);
|
||
});
|
||
|
||
$centralCompany->tenants()->attach($tenant2);
|
||
|
||
// Tenant company got created during the attaching
|
||
$tenant2->run(function () {
|
||
expect(TenantCompany::count())->toBe(1);
|
||
});
|
||
|
||
// The tenant companies are accessible from tenant using the relationship method
|
||
expect($tenant2->companies()->count())->toBe(1);
|
||
|
||
// Tenants are accessible from the central resource using the relationship method
|
||
expect($centralCompany->tenants()->count())->toBe(1);
|
||
|
||
// The TenantCompany resource got created with the correct attributes
|
||
$tenant2->run(function () use ($centralCompany) {
|
||
$tenantCompany = TenantCompany::first()->getAttributes();
|
||
$centralCompany = $centralCompany->getAttributes();
|
||
|
||
expect($tenantCompany)->toEqualCanonicalizing($centralCompany);
|
||
});
|
||
|
||
|
||
// Detaching tenant from a central resource deletes the resource of that tenant
|
||
$centralUser->tenants()->detach($tenant1);
|
||
$tenant1->run(function () {
|
||
expect(BaseTenantUser::count())->toBe(0);
|
||
});
|
||
|
||
$centralUser->tenants()->attach($tenant2);
|
||
|
||
// Detaching tenant from a central resource doesn't affect resources of other tenants
|
||
$tenant2->run(function () {
|
||
expect(BaseTenantUser::count())->toBe(1);
|
||
});
|
||
|
||
$centralCompany->tenants()->detach($tenant2);
|
||
|
||
$tenant1->run(function () {
|
||
expect(TenantCompany::count())->toBe(0);
|
||
});
|
||
});
|
||
|
||
test('attaching central resource to tenant works correctly even when using a single pivot table for multiple models', function () {
|
||
config(['tenancy.models.tenant' => MorphTenant::class]);
|
||
|
||
[$tenant1, $tenant2] = createTenantsAndRunMigrations();
|
||
|
||
// Use BaseCentralUser and BaseTenantUser in this test
|
||
// These models use the polymorphic relationship for tenants(), which is the default
|
||
$centralUser = BaseCentralUser::create([
|
||
'name' => 'John Doe',
|
||
'email' => 'john@localhost',
|
||
'password' => 'password',
|
||
'role' => 'commenter',
|
||
]);
|
||
|
||
expect(DB::table('tenant_resources')->count())->toBe(0);
|
||
|
||
// Create tenant resource from central resource
|
||
// By attaching a central resource to the tenant
|
||
$tenant1->users()->attach($centralUser);
|
||
|
||
// The tenant resource got created
|
||
// And with the correct attributes
|
||
$tenantUser = $tenant1->run(function () {
|
||
return BaseTenantUser::first();
|
||
});
|
||
|
||
expect($tenantUser?->getAttributes())->toEqualCanonicalizing($centralUser->getAttributes());
|
||
|
||
// Tenants are accessible from the central resource using the relationship method
|
||
expect($centralUser->tenants()->count())->toBe(1);
|
||
expect(DB::table('tenant_resources')->count())->toBe(1);
|
||
|
||
// Central users are accessible from the tenant using the relationship method
|
||
expect($tenant1->users()->count())->toBe(1);
|
||
|
||
$tenant1->users()->detach($centralUser);
|
||
|
||
$tenant1->run(function () {
|
||
expect(BaseTenantUser::count())->toBe(0);
|
||
});
|
||
|
||
expect(DB::table('tenant_resources')->count())->toBe(0);
|
||
|
||
// Test that the company resource can use the same pivot as the user resource
|
||
migrateCompaniesTableForTenants();
|
||
expect($tenant2->companies()->count())->toBe(0);
|
||
expect(DB::table('tenant_resources')->count())->toBe(0);
|
||
|
||
// Company resource uses the same pivot as the user resource
|
||
// Creating a tenant resource creates the central resource automatically if it doesn't exist
|
||
$tenantCompany = $tenant2->run(function () {
|
||
return TenantCompany::create([
|
||
'global_id' => 'acme',
|
||
'name' => 'tenant comp',
|
||
'email' => 'company@localhost',
|
||
]);
|
||
});
|
||
|
||
$centralCompany = CentralCompany::first();
|
||
|
||
// The central resource got created
|
||
// And with the correct attributes
|
||
expect($centralCompany?->getAttributes())->toEqualCanonicalizing($tenantCompany->getAttributes());
|
||
|
||
// The pivot record got created
|
||
expect(DB::table('tenant_resources')->count())->toBe(1);
|
||
|
||
// Tenants are accessible from the central resource using the relationship method
|
||
expect($centralCompany->tenants()->count())->toBe(1);
|
||
|
||
// Central companies are accessible from the tenant using the relationship method
|
||
expect($tenant2->companies()->count())->toBe(1);
|
||
|
||
// Detaching central resource from a tenant deletes the resource of that tenant
|
||
$tenant2->companies()->detach($centralCompany);
|
||
|
||
expect($tenant2->companies()->count())->toBe(0);
|
||
|
||
// Tenant resource got deleted
|
||
$tenant2->run(function () {
|
||
expect(TenantCompany::count())->toBe(0);
|
||
});
|
||
});
|
||
|
||
test('attaching or detaching users to or from tenant throws an exception when the pivot cannot access the central resource', function() {
|
||
$tenant = Tenant::create();
|
||
|
||
migrateUsersTableForTenants();
|
||
|
||
$centralUser = CentralUser::create([
|
||
'global_id' => 'acme',
|
||
'name' => 'John Doe',
|
||
'email' => 'john@localhost',
|
||
'password' => 'secret',
|
||
'role' => 'commenter',
|
||
]);
|
||
|
||
expect(TenantPivot::count())->toBe(0);
|
||
|
||
expect(fn () => $tenant->users()->attach($centralUser))->toThrow(CentralResourceNotAvailableInPivotException::class);
|
||
expect(TenantPivot::count())->toBe(0);
|
||
|
||
$centralUser->tenants()->attach($tenant); // central->tenants() direction works
|
||
expect(TenantPivot::count())->toBe(1);
|
||
|
||
expect(fn () => $tenant->users()->detach($centralUser))->toThrow(CentralResourceNotAvailableInPivotException::class);
|
||
expect(TenantPivot::count())->toBe(1);
|
||
});
|
||
|
||
test('resources only get created in tenant databases they were attached to', function () {
|
||
$centralUser = CentralUser::create([
|
||
'global_id' => 'acme',
|
||
'name' => 'John Doe',
|
||
'email' => 'john@localhost',
|
||
'password' => 'secret',
|
||
'role' => 'commenter', // Unsynced
|
||
]);
|
||
|
||
$t1 = Tenant::create();
|
||
$t2 = Tenant::create();
|
||
$t3 = Tenant::create();
|
||
|
||
migrateUsersTableForTenants();
|
||
|
||
$centralUser->tenants()->attach($t1);
|
||
$centralUser->tenants()->attach($t2);
|
||
// t3 is not attached
|
||
|
||
$t1->run(fn () => expect(TenantUser::count())->toBe(1)); // assert user exists
|
||
$t2->run(fn () => expect(TenantUser::count())->toBe(1)); // assert user exists
|
||
$t3->run(fn () => expect(TenantUser::count())->toBe(0)); // assert user does NOT exist
|
||
});
|
||
|
||
test('synced columns are updated in other tenant dbs where the resource exists', function () {
|
||
// Create central resource
|
||
$centralUser = CentralUser::create([
|
||
'global_id' => 'acme',
|
||
'name' => 'John Doe',
|
||
'email' => 'john@localhost',
|
||
'password' => 'secret',
|
||
'role' => 'commenter', // Unsynced
|
||
]);
|
||
|
||
$tenant1 = Tenant::create();
|
||
$tenant2 = Tenant::create();
|
||
$tenant3 = Tenant::create();
|
||
migrateUsersTableForTenants();
|
||
|
||
// Create tenant users by attaching tenants to the central user
|
||
$centralUser->tenants()->attach($tenant1);
|
||
$centralUser->tenants()->attach($tenant2);
|
||
$centralUser->tenants()->attach($tenant3);
|
||
|
||
// Update first tenant's resource
|
||
$tenant1->run(function () {
|
||
TenantUser::first()->update([
|
||
'name' => 'John 1',
|
||
'role' => 'employee', // Unsynced
|
||
]);
|
||
|
||
expect(TenantUser::first()->role)->toBe('employee');
|
||
});
|
||
|
||
// Check that the resources of the other tenants got updated too
|
||
tenancy()->runForMultiple([$tenant2, $tenant3], function () {
|
||
$user = TenantUser::first();
|
||
|
||
expect($user->name)->toBe('John 1');
|
||
expect($user->role)->toBe('commenter');
|
||
});
|
||
|
||
// Check that change bubbled up to central DB
|
||
expect(CentralUser::count())->toBe(1);
|
||
$centralUser = CentralUser::first();
|
||
expect($centralUser->name)->toBe('John 1'); // Synced
|
||
expect($centralUser->role)->toBe('commenter'); // Unsynced
|
||
|
||
// This works when the change comes from the central DB – all tenant resources get updated
|
||
$centralUser->update(['name' => 'John 0']);
|
||
|
||
tenancy()->runForMultiple([$tenant1, $tenant2, $tenant3], function () {
|
||
expect(TenantUser::first()->name)->toBe('John 0');
|
||
});
|
||
});
|
||
|
||
test('the global id is generated using the id generator when the global id is not supplied when creating the resource', function () {
|
||
$user = CentralUser::create([
|
||
'name' => 'John Doe',
|
||
'email' => 'john@doe',
|
||
'password' => 'secret',
|
||
'role' => 'employee',
|
||
]);
|
||
|
||
pest()->assertNotNull($user->global_id);
|
||
});
|
||
|
||
test('the update or create listener can be queued', function () {
|
||
Queue::fake();
|
||
UpdateOrCreateSyncedResource::$shouldQueue = true;
|
||
|
||
$tenant = Tenant::create();
|
||
|
||
migrateUsersTableForTenants();
|
||
|
||
Queue::assertNothingPushed();
|
||
|
||
$tenant->run(function () {
|
||
TenantUser::create([
|
||
'name' => 'John Doe',
|
||
'email' => 'john@doe',
|
||
'password' => 'secret',
|
||
'role' => 'employee',
|
||
]);
|
||
});
|
||
|
||
Queue::assertPushed(CallQueuedListener::class, function (CallQueuedListener $job) {
|
||
return $job->class === UpdateOrCreateSyncedResource::class;
|
||
});
|
||
|
||
UpdateOrCreateSyncedResource::$shouldQueue = false;
|
||
});
|
||
|
||
test('the cascade deletes and restore listeners can be queued', function () {
|
||
Queue::fake();
|
||
DeleteResourcesInTenants::$shouldQueue = true;
|
||
RestoreResourcesInTenants::$shouldQueue = true;
|
||
CentralUserWithSoftDeletes::$creationAttributes = [
|
||
'foo' => 'foo',
|
||
'role' => 'role',
|
||
];
|
||
|
||
$tenant = Tenant::create();
|
||
|
||
migrateUsersTableForTenants();
|
||
addExtraColumns(true);
|
||
|
||
Queue::assertNothingPushed();
|
||
|
||
$centralUser = fn () => CentralUserWithSoftDeletes::create([
|
||
'name' => 'John Doe',
|
||
'email' => 'john@doe',
|
||
'password' => 'secret',
|
||
'role' => 'employee',
|
||
'foo' => 'foo',
|
||
]);
|
||
|
||
[$user1, $user2] = [$centralUser(), $centralUser()];
|
||
|
||
$tenant->softDeletesUsers()->attach($user1); // Custom pivot method
|
||
$user2->tenants()->attach($tenant);
|
||
|
||
$user1->delete();
|
||
$user2->delete();
|
||
|
||
Queue::assertPushed(CallQueuedListener::class, function (CallQueuedListener $job) {
|
||
return $job->class === DeleteResourcesInTenants::class;
|
||
});
|
||
|
||
$user1->restore();
|
||
$user2->restore();
|
||
|
||
Queue::assertPushed(CallQueuedListener::class, function (CallQueuedListener $job) {
|
||
return $job->class === RestoreResourcesInTenants::class;
|
||
});
|
||
|
||
DeleteResourcesInTenants::$shouldQueue = false;
|
||
RestoreResourcesInTenants::$shouldQueue = false;
|
||
});
|
||
|
||
test('the attach and detach listeners can be queued', function () {
|
||
Queue::fake();
|
||
CreateTenantResource::$shouldQueue = true;
|
||
DeleteResourceInTenant::$shouldQueue = true;
|
||
|
||
$tenant = Tenant::create();
|
||
|
||
migrateUsersTableForTenants();
|
||
|
||
$centralUser = fn () => CentralUser::create([
|
||
'name' => 'John Doe',
|
||
'email' => 'john@doe',
|
||
'password' => 'secret',
|
||
'role' => 'employee',
|
||
]);
|
||
|
||
$user1 = $centralUser();
|
||
$user2 = $centralUser();
|
||
|
||
$tenant->customPivotUsers()->attach($user1);
|
||
$user2->tenants()->attach($tenant);
|
||
|
||
Queue::assertPushed(CallQueuedListener::class, function (CallQueuedListener $job) {
|
||
return $job->class === CreateTenantResource::class;
|
||
});
|
||
|
||
$tenant->customPivotUsers()->detach($user1);
|
||
$user2->tenants()->detach($tenant);
|
||
|
||
Queue::assertPushed(CallQueuedListener::class, function (CallQueuedListener $job) {
|
||
return $job->class === DeleteResourceInTenant::class;
|
||
});
|
||
|
||
CreateTenantResource::$shouldQueue = false;
|
||
DeleteResourceInTenant::$shouldQueue = false;
|
||
});
|
||
|
||
test('the SyncedResourceSavedInForeignDatabase event is fired for all touched resources', function () {
|
||
Event::fake([SyncedResourceSavedInForeignDatabase::class]);
|
||
|
||
// Create central resource
|
||
$centralUser = CentralUser::create([
|
||
'global_id' => 'acme',
|
||
'name' => 'John Doe',
|
||
'email' => 'john@localhost',
|
||
'password' => 'secret',
|
||
'role' => 'commenter', // Unsynced
|
||
]);
|
||
|
||
$t1 = Tenant::create(['id' => 't1']);
|
||
$t2 = Tenant::create(['id' => 't2']);
|
||
$t3 = Tenant::create(['id' => 't3']);
|
||
|
||
migrateUsersTableForTenants();
|
||
|
||
// Create tenant resources by attaching tenants to the central user
|
||
foreach ([$t1, $t2, $t3] as $tenant) {
|
||
$centralUser->tenants()->attach($tenant);
|
||
|
||
Event::assertDispatched(SyncedResourceSavedInForeignDatabase::class, function (SyncedResourceSavedInForeignDatabase $event) use ($tenant) {
|
||
return $event->tenant->getTenantKey() === $tenant->getTenantKey();
|
||
});
|
||
}
|
||
|
||
// Event wasn't dispatched in the central app (No tenant present in the event)
|
||
Event::assertNotDispatched(SyncedResourceSavedInForeignDatabase::class, function (SyncedResourceSavedInForeignDatabase $event) {
|
||
return $event->tenant === null;
|
||
});
|
||
|
||
// Flush event log
|
||
Event::fake([SyncedResourceSavedInForeignDatabase::class]);
|
||
|
||
$t3->run(function () {
|
||
TenantUser::first()->update(['name' => 'John 3']);
|
||
});
|
||
|
||
Event::assertDispatched(SyncedResourceSavedInForeignDatabase::class, function (SyncedResourceSavedInForeignDatabase $event) {
|
||
return $event->tenant?->getTenantKey() === 't1';
|
||
});
|
||
Event::assertDispatched(SyncedResourceSavedInForeignDatabase::class, function (SyncedResourceSavedInForeignDatabase $event) {
|
||
return $event->tenant?->getTenantKey() === 't2';
|
||
});
|
||
|
||
// Event wasn't dispatched for t3
|
||
Event::assertNotDispatched(SyncedResourceSavedInForeignDatabase::class, function (SyncedResourceSavedInForeignDatabase $event) {
|
||
return $event->tenant?->getTenantKey() === 't3';
|
||
});
|
||
|
||
// Event wasn't dispatched in the central app (No tenant present in the event)
|
||
Event::assertDispatched(SyncedResourceSavedInForeignDatabase::class, function (SyncedResourceSavedInForeignDatabase $event) {
|
||
return is_null($event->tenant);
|
||
});
|
||
|
||
// Flush
|
||
Event::fake([SyncedResourceSavedInForeignDatabase::class]);
|
||
|
||
$centralUser->update([
|
||
'name' => 'John Central',
|
||
]);
|
||
|
||
foreach ([$t1, $t2, $t3] as $tenant) {
|
||
Event::assertDispatched(SyncedResourceSavedInForeignDatabase::class, function (SyncedResourceSavedInForeignDatabase $event) use ($tenant) {
|
||
return $event->tenant->getTenantKey() === $tenant->getTenantKey();
|
||
});
|
||
}
|
||
|
||
// Event wasn't dispatched in the central app
|
||
Event::assertNotDispatched(SyncedResourceSavedInForeignDatabase::class, function (SyncedResourceSavedInForeignDatabase $event) {
|
||
return is_null($event->tenant);
|
||
});
|
||
});
|
||
|
||
test('resources are synced only when the shouldSync method returns true', function (bool $enabled) {
|
||
TenantUser::$shouldSync = $enabled;
|
||
CentralUser::$shouldSync = $enabled;
|
||
|
||
[$tenant1, $tenant2] = createTenantsAndRunMigrations();
|
||
migrateUsersTableForTenants();
|
||
|
||
tenancy()->initialize($tenant1);
|
||
|
||
TenantUser::create([
|
||
'global_id' => 'absd',
|
||
'name' => 'John Doe',
|
||
'email' => 'john@localhost',
|
||
'password' => 'password',
|
||
'role' => 'commenter',
|
||
]);
|
||
|
||
tenancy()->end();
|
||
|
||
expect(CentralUser::all())->toHaveCount($enabled ? 1 : 0);
|
||
expect(CentralUser::whereGlobalId('absd')->exists())->toBe($enabled);
|
||
|
||
$centralUser = CentralUser::create([
|
||
'global_id' => 'acme',
|
||
'name' => 'John Doe',
|
||
'email' => 'john@localhost',
|
||
'password' => 'password',
|
||
'role' => 'commenter',
|
||
]);
|
||
|
||
$centralUser->tenants()->attach($tenant2);
|
||
|
||
$tenant2->run(function () use ($enabled) {
|
||
expect(TenantUser::all())->toHaveCount($enabled ? 1 : 0);
|
||
expect(TenantUser::whereGlobalId('acme')->exists())->toBe($enabled);
|
||
});
|
||
})->with([
|
||
true,
|
||
false,
|
||
]);
|
||
|
||
test('deleting SyncMaster automatically deletes its Syncables', function (bool $morphPivot) {
|
||
$centralUserModel = CentralUser::class;
|
||
$tenantUserModel = TenantUser::class;
|
||
|
||
if ($morphPivot) {
|
||
config(['tenancy.models.tenant' => MorphTenant::class]);
|
||
// Use base models if the tenant model uses a polymorphic pivot (which is the default in a real app)
|
||
$centralUserModel = BaseCentralUser::class;
|
||
$tenantUserModel = BaseTenantUser::class;
|
||
}
|
||
|
||
[$tenant] = createTenantsAndRunMigrations();
|
||
|
||
$syncMaster = $centralUserModel::create([
|
||
'global_id' => 'cascade_user',
|
||
'name' => 'Central user',
|
||
'email' => 'central@localhost',
|
||
'password' => 'password',
|
||
'role' => 'cascade_user',
|
||
]);
|
||
|
||
$syncMaster->tenants()->attach($tenant);
|
||
|
||
$syncMaster->delete();
|
||
|
||
// Deleting SyncMaster deletes pivot records with the SyncMaster's global ID
|
||
expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0);
|
||
|
||
tenancy()->initialize($tenant);
|
||
|
||
expect($tenantUserModel::firstWhere('global_id', 'cascade_user'))->toBeNull(); // Delete has cascaded
|
||
})->with([
|
||
'polymorphic pivot' => true,
|
||
'basic pivot' => false,
|
||
]);
|
||
|
||
test('tenant pivot records are deleted along with the tenants to which they belong to', function() {
|
||
[$tenant] = createTenantsAndRunMigrations();
|
||
|
||
$syncMaster = CentralUser::create([
|
||
'global_id' => 'cascade_user',
|
||
'name' => 'Central user',
|
||
'email' => 'central@localhost',
|
||
'password' => 'password',
|
||
'role' => 'cascade_user',
|
||
]);
|
||
|
||
$syncMaster->tenants()->attach($tenant);
|
||
|
||
$tenant->delete();
|
||
|
||
// Deleting tenant deletes its pivot records
|
||
expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0);
|
||
});
|
||
|
||
test('trashed resources are synced correctly', function () {
|
||
[$tenant1, $tenant2] = createTenantsAndRunMigrations();
|
||
migrateUsersTableForTenants();
|
||
addExtraColumns(true);
|
||
|
||
// Include trashed resources in syncing queries
|
||
UpdateOrCreateSyncedResource::$scopeGetModelQuery = function (Builder $query) {
|
||
if ($query->hasMacro('withTrashed')) {
|
||
$query->withTrashed();
|
||
}
|
||
};
|
||
|
||
$centralUser = CentralUserWithSoftDeletes::create([
|
||
'global_id' => 'user',
|
||
'name' => 'Central user',
|
||
'email' => 'central@localhost',
|
||
'password' => 'password',
|
||
'role' => 'commenter',
|
||
'foo' => 'foo',
|
||
]);
|
||
|
||
$centralUser->tenants()->attach($tenant1);
|
||
$centralUser->tenants()->attach($tenant2);
|
||
|
||
tenancy()->initialize($tenant1);
|
||
|
||
// Synced resources aren't soft deleted from other tenants
|
||
TenantUserWithSoftDeletes::first()->delete();
|
||
|
||
tenancy()->initialize($tenant2);
|
||
|
||
expect(TenantUserWithSoftDeletes::withTrashed()->first()->trashed())->toBeFalse();
|
||
|
||
tenancy()->end();
|
||
|
||
// Synced resources are soft deleted from tenants when the central resource gets deleted
|
||
expect(CentralUserWithSoftDeletes::first())->delete();
|
||
|
||
tenancy()->initialize($tenant2);
|
||
|
||
expect(TenantUserWithSoftDeletes::withTrashed()->first()->trashed())->toBeTrue();
|
||
|
||
// Update soft deleted synced resource
|
||
TenantUserWithSoftDeletes::withTrashed()->first()->update(['name' => $newName = 'Updated name']);
|
||
|
||
tenancy()->initialize($tenant1);
|
||
|
||
$tenantResource = TenantUserWithSoftDeletes::withTrashed()->first();
|
||
expect($tenantResource->name)->toBe($newName); // Value synced
|
||
expect($tenantResource->trashed())->toBeTrue();
|
||
|
||
tenancy()->end();
|
||
|
||
$centralResource = CentralUserWithSoftDeletes::withTrashed()->first();
|
||
expect($centralResource->name)->toBe($newName); // Value synced
|
||
expect($centralResource->trashed())->toBeTrue();
|
||
|
||
tenancy()->initialize($tenant2);
|
||
|
||
$tenantResource = TenantUserWithSoftDeletes::withTrashed()->first();
|
||
expect($tenantResource->name)->toBe($newName);
|
||
// The trashed status is not synced even after updating another tenant resource
|
||
expect($tenantResource->trashed())->toBeTrue();
|
||
});
|
||
|
||
test('restoring soft deleted resources works', function () {
|
||
[$tenant1, $tenant2] = createTenantsAndRunMigrations();
|
||
migrateUsersTableForTenants();
|
||
addExtraColumns(true);
|
||
|
||
CentralUserWithSoftDeletes::create([
|
||
'global_id' => 'user',
|
||
'name' => 'Central user',
|
||
'email' => 'central@localhost',
|
||
'password' => 'password',
|
||
'role' => 'commenter',
|
||
'foo' => 'foo',
|
||
]);
|
||
|
||
tenancy()->initialize($tenant1);
|
||
|
||
TenantUserWithSoftDeletes::create([
|
||
'global_id' => 'user',
|
||
'name' => 'Tenant user',
|
||
'email' => 'tenant@localhost',
|
||
'password' => 'password',
|
||
'role' => 'commenter',
|
||
'foo' => 'foo',
|
||
]);
|
||
|
||
tenancy()->initialize($tenant2);
|
||
|
||
TenantUserWithSoftDeletes::create([
|
||
'global_id' => 'user',
|
||
'name' => 'Tenant user',
|
||
'email' => 'tenant@localhost',
|
||
'password' => 'password',
|
||
'role' => 'commenter',
|
||
'foo' => 'foo',
|
||
]);
|
||
|
||
tenancy()->end();
|
||
|
||
// Synced resources are deleted from all tenants if the central resource gets deleted
|
||
CentralUserWithSoftDeletes::first()->delete();
|
||
|
||
expect(CentralUserWithSoftDeletes::withTrashed()->first()->trashed())->toBeTrue();
|
||
|
||
tenancy()->runForMultiple([$tenant1, $tenant2], function () {
|
||
expect(TenantUserWithSoftDeletes::withTrashed()->first()->trashed())->toBeTrue();
|
||
});
|
||
|
||
// Restoring a central resource restores tenant resources
|
||
CentralUserWithSoftDeletes::withTrashed()->first()->restore();
|
||
|
||
tenancy()->runForMultiple([$tenant1, $tenant2], function () {
|
||
expect(TenantUserWithSoftDeletes::withTrashed()->first()->trashed())->toBeFalse();
|
||
});
|
||
});
|
||
|
||
test('using forceDelete on a central resource with soft deletes force deletes the tenant resources', function () {
|
||
[$tenant1, $tenant2] = createTenantsAndRunMigrations();
|
||
migrateUsersTableForTenants();
|
||
addExtraColumns(true);
|
||
|
||
$centralUser = CentralUserWithSoftDeletes::create([
|
||
'global_id' => 'user',
|
||
'name' => 'Central user',
|
||
'email' => 'central@localhost',
|
||
'password' => 'password',
|
||
'role' => 'commenter',
|
||
'foo' => 'foo',
|
||
]);
|
||
|
||
$centralUser->tenants()->attach($tenant1);
|
||
$centralUser->tenants()->attach($tenant2);
|
||
|
||
// Force deleting a tenant resource does not affect resources of other tenants
|
||
tenancy()->initialize($tenant1);
|
||
|
||
TenantUserWithSoftDeletes::firstWhere('global_id', 'user')->forceDelete();
|
||
|
||
tenancy()->initialize($tenant2);
|
||
|
||
expect(TenantUserWithSoftDeletes::firstWhere('global_id', 'user'))->not()->toBeNull();
|
||
|
||
tenancy()->end();
|
||
|
||
// Synced resources are deleted from all tenants if the central resource gets force deleted
|
||
CentralUserWithSoftDeletes::firstWhere('global_id', 'user')->forceDelete();
|
||
|
||
expect(CentralUserWithSoftDeletes::withTrashed()->firstWhere('global_id', 'user'))->toBeNull();
|
||
|
||
tenancy()->initialize($tenant2);
|
||
|
||
expect(TenantUserWithSoftDeletes::withTrashed()->firstWhere('global_id', 'user'))->toBeNull();
|
||
});
|
||
|
||
test('resource creation works correctly when tenant resource provides defaults in the creation attributes', function () {
|
||
[$tenant1, $tenant2] = createTenantsAndRunMigrations();
|
||
|
||
addExtraColumns();
|
||
|
||
// Attribute names
|
||
CentralUser::$creationAttributes = ['role'];
|
||
|
||
// Mixed (attribute name + defaults)
|
||
TenantUser::$creationAttributes = [
|
||
'name' => 'Default Name',
|
||
'email' => 'default@localhost',
|
||
'password' => 'password',
|
||
'role' => 'admin',
|
||
'foo' => 'bar',
|
||
];
|
||
|
||
$centralUser = CentralUser::create([
|
||
'global_id' => 'acme',
|
||
'name' => 'John Doe',
|
||
'email' => 'john@localhost',
|
||
'password' => 'secret',
|
||
'role' => 'commenter',
|
||
'foo' => 'bar', // foo does not exist in tenant resource
|
||
]);
|
||
|
||
$tenant1->run(function () {
|
||
expect(TenantUser::all())->toHaveCount(0);
|
||
});
|
||
|
||
// When central resource provides the attribute names in $creationAttributes
|
||
// The tenant resource will be created using them
|
||
$centralUser->tenants()->attach($tenant1);
|
||
|
||
$tenant1->run(function () {
|
||
$tenantUser = TenantUser::all();
|
||
expect($tenantUser)->toHaveCount(1);
|
||
expect($tenantUser->first()->global_id)->toBe('acme');
|
||
expect($tenantUser->first()->email)->toBe('john@localhost');
|
||
// 'foo' attribute is not provided by central model
|
||
expect($tenantUser->first()->foo)->toBeNull();
|
||
});
|
||
|
||
tenancy()->initialize($tenant2);
|
||
|
||
// Creating a tenant resource creates a central resource
|
||
// Using resource's creation attributes
|
||
TenantUser::create([
|
||
'global_id' => 'asdf',
|
||
'name' => 'John Doe',
|
||
'email' => 'john@localhost',
|
||
'password' => 'secret',
|
||
'role' => 'commenter',
|
||
]);
|
||
|
||
tenancy()->end();
|
||
|
||
// Central user was created using the defaults provided in the tenant resource
|
||
// Creating TenantUser created a CentralUser with the same global_id
|
||
$centralUser = CentralUser::whereGlobalId('asdf')->first();
|
||
expect($centralUser)->not()->toBeNull();
|
||
expect($centralUser->name)->toBe('Default Name');
|
||
expect($centralUser->email)->toBe('default@localhost');
|
||
expect($centralUser->password)->toBe('password');
|
||
expect($centralUser->role)->toBe('admin');
|
||
expect($centralUser->foo)->toBe('bar');
|
||
});
|
||
|
||
test('resource creation works correctly when central resource provides defaults in the creation attributes', function () {
|
||
[$tenant1, $tenant2] = createTenantsAndRunMigrations();
|
||
|
||
addExtraColumns();
|
||
|
||
CentralUser::$creationAttributes = [
|
||
'name' => 'Default User',
|
||
'email' => 'default@localhost',
|
||
'password' => 'password',
|
||
'role' => 'admin',
|
||
];
|
||
|
||
TenantUser::$creationAttributes = [
|
||
// Central user requires 'foo', but tenant user does not have it
|
||
// So we provide a default here in order for the central resource to be created when creating the tenant resource
|
||
'foo' => 'bar',
|
||
'role',
|
||
];
|
||
|
||
$centralUser = CentralUser::create([
|
||
'global_id' => 'acme',
|
||
'name' => 'John Doe',
|
||
'email' => 'john@localhost',
|
||
'password' => 'secret',
|
||
'role' => 'commenter',
|
||
'foo' => 'bar',
|
||
]);
|
||
|
||
$tenant1->run(function () {
|
||
expect(TenantUser::all())->toHaveCount(0);
|
||
});
|
||
|
||
$centralUser->tenants()->attach($tenant1);
|
||
|
||
$tenant1->run(function () {
|
||
// Assert tenant resource was created using the defaults provided in the central resource
|
||
$tenantUser = TenantUser::first();
|
||
expect($tenantUser)->not()->toBeNull();
|
||
expect($tenantUser->global_id)->toBe('acme');
|
||
expect($tenantUser->email)->toBe('default@localhost');
|
||
expect($tenantUser->password)->toBe('password');
|
||
expect($tenantUser->role)->toBe('admin');
|
||
});
|
||
|
||
tenancy()->initialize($tenant2);
|
||
|
||
// The creation attributes provided in the tenant resource will be used in the newly created central resource
|
||
TenantUser::create([
|
||
'global_id' => 'asdf',
|
||
'name' => 'John Doe',
|
||
'email' => 'john@localhost',
|
||
'password' => 'secret',
|
||
'role' => 'commenter',
|
||
]);
|
||
|
||
tenancy()->end();
|
||
|
||
// Assert central resource was created using the provided attributes
|
||
$centralUser = CentralUser::whereGlobalId('asdf')->first();
|
||
|
||
expect($centralUser)->not()->toBeNull();
|
||
expect($centralUser->email)->toBe('john@localhost');
|
||
expect($centralUser->password)->toBe('secret');
|
||
expect($centralUser->role)->toBe('commenter');
|
||
expect($centralUser->foo)->toBe('bar');
|
||
});
|
||
|
||
test('global scopes on syncable models can break resource syncing', function () {
|
||
[$tenant1, $tenant2] = createTenantsAndRunMigrations();
|
||
|
||
$centralUser = CentralUser::create([
|
||
'global_id' => 'foo',
|
||
'name' => 'foo',
|
||
'email' => 'foo@bar.com',
|
||
'password' => '*****',
|
||
'role' => 'admin', // not 'visible'
|
||
]);
|
||
|
||
// Create a tenant resource. The global id matches that of the central user created above,
|
||
// so the synced columns of the central record will be updated.
|
||
$tenant1->run(fn () => TenantUser::create([
|
||
'global_id' => 'foo',
|
||
'name' => 'tenant1 user',
|
||
'email' => 'tenant1@user.com',
|
||
'password' => 'tenant1_password',
|
||
'role' => 'user1',
|
||
]));
|
||
|
||
expect($centralUser->refresh()->name)->toBe('tenant1 user');
|
||
|
||
// While syncing a tenant resource with the same global id,
|
||
// the central resource will not be found due to this scope,
|
||
// leading to the syncing logic trying to create a new central resource with that same global id,
|
||
// triggering a unique constraint violation exception.
|
||
CentralUser::addGlobalScope(new VisibleScope());
|
||
|
||
expect(function () use ($tenant1) {
|
||
$tenant1->run(fn () => TenantUser::create([
|
||
'global_id' => 'foo',
|
||
'name' => 'tenant1new user',
|
||
'email' => 'tenant1new@user.com',
|
||
'password' => 'tenant1new_password',
|
||
'role' => 'user1new',
|
||
]));
|
||
})->toThrow(QueryException::class, "Duplicate entry 'foo' for key 'users.users_global_id_unique'");
|
||
|
||
// The central resource stays the same
|
||
expect($centralUser->refresh()->name)->toBe('tenant1 user');
|
||
|
||
// Use UpdateOrCreateSyncedResource::$scopeGetModelQuery to bypass the global scope.
|
||
UpdateOrCreateSyncedResource::$scopeGetModelQuery = function (Builder $query) {
|
||
$query->withoutGlobalScope(VisibleScope::class);
|
||
};
|
||
|
||
// Now, the central resource IS found, and no exception is thrown
|
||
$tenant2->run(fn () => TenantUser::create([
|
||
'global_id' => 'foo',
|
||
'name' => 'tenant2 user',
|
||
'email' => 'tenant2@user.com',
|
||
'password' => 'tenant2_password',
|
||
'role' => 'user2',
|
||
]));
|
||
|
||
// The central resource was updated
|
||
expect($centralUser->refresh()->name)->toBe('tenant2 user');
|
||
|
||
// The change was also synced to tenant1
|
||
expect($tenant1->run(fn () => TenantUser::first()->name))->toBe('tenant2 user');
|
||
});
|
||
|
||
/**
|
||
* Create two tenants and run migrations for those tenants.
|
||
*
|
||
* @return Tenant[]
|
||
*/
|
||
function createTenantsAndRunMigrations(): array
|
||
{
|
||
$tenantModel = tenancy()->model();
|
||
|
||
[$tenant1, $tenant2] = [$tenantModel::create(), $tenantModel::create()];
|
||
|
||
migrateUsersTableForTenants();
|
||
|
||
return [$tenant1, $tenant2];
|
||
}
|
||
|
||
function addExtraColumns(bool $tenantDbs = false): void
|
||
{
|
||
// Migrate extra column "foo" in central DB
|
||
pest()->artisan('migrate', [
|
||
'--path' => __DIR__ . '/Etc/synced_resource_migrations/users_extra',
|
||
'--realpath' => true,
|
||
])->assertExitCode(0);
|
||
|
||
if ($tenantDbs) {
|
||
pest()->artisan('tenants:migrate', [
|
||
'--path' => __DIR__ . '/Etc/synced_resource_migrations/users_extra',
|
||
'--realpath' => true,
|
||
])->assertExitCode(0);
|
||
}
|
||
}
|
||
|
||
function migrateUsersTableForTenants(): void
|
||
{
|
||
pest()->artisan('tenants:migrate', [
|
||
'--path' => __DIR__ . '/Etc/synced_resource_migrations/users',
|
||
'--realpath' => true,
|
||
])->assertExitCode(0);
|
||
}
|
||
|
||
function migrateCompaniesTableForTenants(): void
|
||
{
|
||
pest()->artisan('tenants:migrate', [
|
||
'--path' => __DIR__ . '/Etc/synced_resource_migrations/companies',
|
||
'--realpath' => true,
|
||
])->assertExitCode(0);
|
||
}
|
||
|
||
class CentralUser extends BaseCentralUser
|
||
{
|
||
public function tenants(): BelongsToMany
|
||
{
|
||
return $this->belongsToMany(Tenant::class, 'tenant_users', 'global_user_id', 'tenant_id', 'global_id')
|
||
->using(TenantPivot::class);
|
||
}
|
||
}
|
||
|
||
class TenantUser extends BaseTenantUser
|
||
{
|
||
public function getCentralModelName(): string
|
||
{
|
||
return CentralUser::class;
|
||
}
|
||
|
||
public function tenants(): BelongsToMany
|
||
{
|
||
return $this->belongsToMany(Tenant::class, 'tenant_users', 'global_user_id', 'tenant_id', 'global_id')
|
||
->using(TenantPivot::class);
|
||
}
|
||
}
|
||
|
||
class VisibleScope implements Scope
|
||
{
|
||
public function apply(Builder $builder, Model $model): void
|
||
{
|
||
$builder->where('role', 'visible');
|
||
}
|
||
}
|
||
|
||
class TenantPivot extends BasePivot
|
||
{
|
||
public $table = 'tenant_users';
|
||
}
|
||
|
||
class CentralUserWithSoftDeletes extends CentralUser
|
||
{
|
||
use SoftDeletes;
|
||
|
||
public function getTenantModelName(): string
|
||
{
|
||
return TenantUserWithSoftDeletes::class;
|
||
}
|
||
|
||
public function getCreationAttributes(): array
|
||
{
|
||
return array_merge($this->getSyncedAttributeNames(), [
|
||
'role' => 'role',
|
||
'foo' => 'foo', // extra column
|
||
]);
|
||
}
|
||
}
|
||
|
||
class TenantUserWithSoftDeletes extends TenantUser
|
||
{
|
||
use SoftDeletes;
|
||
|
||
public function getCentralModelName(): string
|
||
{
|
||
return CentralUserWithSoftDeletes::class;
|
||
}
|
||
}
|
||
|
||
class MorphTenant extends Tenant
|
||
{
|
||
public function users(): MorphToMany
|
||
{
|
||
return $this->morphedByMany(BaseCentralUser::class, 'tenant_resources', 'tenant_resources', 'tenant_id', 'resource_global_id', 'id', 'global_id')
|
||
->using(TenantMorphPivot::class);
|
||
}
|
||
|
||
public function companies(): MorphToMany
|
||
{
|
||
return $this->morphedByMany(CentralCompany::class, 'tenant_resources', 'tenant_resources', 'tenant_id', 'resource_global_id', 'id', 'global_id')
|
||
->using(TenantMorphPivot::class);
|
||
}
|
||
}
|
||
|
||
class CentralCompany extends Model implements SyncMaster
|
||
{
|
||
use ResourceSyncing, CentralConnection;
|
||
|
||
protected $guarded = [];
|
||
|
||
public $timestamps = false;
|
||
|
||
public $table = 'companies';
|
||
|
||
public function getTenantModelName(): string
|
||
{
|
||
return TenantCompany::class;
|
||
}
|
||
|
||
public function getCentralModelName(): string
|
||
{
|
||
return static::class;
|
||
}
|
||
|
||
public function getSyncedAttributeNames(): array
|
||
{
|
||
return [
|
||
'global_id',
|
||
'name',
|
||
'email',
|
||
];
|
||
}
|
||
}
|
||
|
||
class TenantCompany extends Model implements Syncable
|
||
{
|
||
use ResourceSyncing;
|
||
|
||
protected $table = 'companies';
|
||
|
||
protected $guarded = [];
|
||
|
||
public $timestamps = false;
|
||
|
||
public function getCentralModelName(): string
|
||
{
|
||
return CentralCompany::class;
|
||
}
|
||
|
||
public function getSyncedAttributeNames(): array
|
||
{
|
||
return [
|
||
'global_id',
|
||
'name',
|
||
'email',
|
||
];
|
||
}
|
||
}
|