mirror of
https://github.com/archtechx/tenancy.git
synced 2025-12-12 10:54:04 +00:00
* Add &Model to docblock * Fix code style (php-cs-fixer) * Only delete synced resource if the central resource shouldSync * Add central resource detached event and listener * Add SyncedTenant interface * Use the event & listener in the test file * Add getGlobalIdentifierKey(Name) to TenantMorphPivot * Refactor TriggerSyncingEvents * Fix code style (php-cs-fixer) * Test queueing the detaching listener * Move finding the central resource into the event, naming changes * Fix code style (php-cs-fixer) * Simplify listener code * Refactor detaching logic * Create tenant resource after attaching central to tenant, test queueing related listener * Delete dd() * Fix code style (php-cs-fixer) * Move triggerAttachEvent from SyncMaster * Update attach event-related code * Move findResource from SyncedTenant to the pivot trait * Add annotation * Update annotation * Simplify getAttributesForCreation in CreateTenantResourceFromSyncMaster * Update naming * Add tenant trait for attaching/detaching resources * Update test names * Move creation attribute parsing method to trait * Rename variable * Fix code style (php-cs-fixer) * Delete complete to-do * Delete event comment * Rename event property * Find tenant resource in detach listener * Use global ID key of tenant resource in cascade deletes listener * Use global ID key name of the central resource while creating/deleting tenant resources * Add getSyncedCreationAttributes example in the annotation * Fix inconsistencies in SyncedTenant methods * Improve annotation * Don't return the query in `$scopeGetModelQuery` Co-authored-by: Samuel Štancl <samuel.stancl@gmail.com> * Fix code style (php-cs-fixer) * Update scoping getModel query * Only use detach event instead of using both detach and delete events, refactor code * Test that detaching tenant from a central resource doesn't affect other tenants * Delete extra imports * Fix code style (php-cs-fixer) * Add PivotWithRelation, test attaching/detaching resources without polymorphic relations * Refactor TriggerSyncingEvents to work with non-polymorphic relations too * Fix code style (php-cs-fixer) * Rename synced resource changed event, fix tests * Enforce passing Tenant&Model to attach/detach events * Prevent firing saved event automatically in CreateTenantResource * Improve TriggerSyncingEvents trait * Delete unused import * Make TriggerSyncingEvents methods non-static, improve annotations * Pass saved model to event * Move attach/detach queueing tests to ResourceSyncingTest, pass models instead of IDs to attach/detach * Move events to ResourceSyncing\Events * Fix code style (php-cs-fixer) * Use SerializesModels in queueable listeners instead of events * Delete redundant $shouldQueue setting * Rename listener, test cascade deletes from both sides of many-to-many * Move creation attributes-related code to a separate test file, improve comments (wip) * Improve comments, fix variable name capitalization * Delete tracing comma * Extract duplicate code into a trait * Don't accept nullable tenant in SyncMasterDeleted * Fix annotation * Fix code style (php-cs-fixer) * Update annotation * Fix PHPStan error * Fix annotation * Update comments and test naming * Move triggerDeleteEvent to CascadeDeletes interface * Rename test file * Import TenantPivot in Tenant class (tests/Etc) * Add central resource not available in pivot exception * Rename SaveSyncedResource to UpdateOrCreateSyncedResource * Add new events and listeners to TSP stub * Improve comments and naming * Only keep SerializesModels in classes that utilize it * Use tenant->run() * Import events in stub * Move RS listeners to separate namespace, use `Event/`Listener/` in stub for consistency * Fix code style (php-cs-fixer) * Fix namespace changes * Use cursor instead of get * Update src/ResourceSyncing/ParsesCreationAttributes.php Co-authored-by: Samuel Štancl <samuel.stancl@gmail.com> * Update naming, structure (discussed on Discord) * Update uses in in test file * remove double ;; * Add comments * Test if static properties work fine with queueable listeners * Update $shouldQuery test * Update creation attributes * Work on updating the tests * Make synced attributes configurable using static properties * Update resource syncing tests * Get rid of mixed attribute classes * Get rid of TenantUserWIthCreationAttributes * Fix imports * Get rid of the conditionally synced classes, improve tests * Simplify resource creation tests (only test the more complex cases instead of each case - if the complex case works, the simpler cases work too) * Clean up ResourceSyncingTest (mostly duplicate tests that were already in AutomaticResourceCreationTest) * Simplify class naming in polymorhpic tests * Move automatic resource creation tests to ResourceSyncingTest * Test that the sync event doesn't get triggered excessively * Only trigger the sync event if the synced attributes were changed or if the resource was recently created * Update synced attribute instead of unsynced in test * Fix sync event test * Update static property redefining test * Use getGlobalIdentifierKeyName() instead of hardcoding the key name * Delete static properties from the ResourceSyncing trait * Reuse user classes in polymorphic tests * Update tests/ResourceSyncingTest.php Co-authored-by: Samuel Štancl <samuel.stancl@gmail.com> * Use the default tenants() method in central user, override the default in ResourceSyncingTest * Use BelongsToMany as tenants() return type * Fix code style (php-cs-fixer) * Delete extra static property from trait * Delete duplicate events/listeners from TSP stub * Delete weird expectation, use $model->trashed() * Change ResourceUser to TenantUser * Add defaults for getGlobalIdentifierKey(Name) * Use singular tenant in DeleteResourceInTenants name * Rename getSyncedCreationAttributes to getCreationAttributes * Fix comma position in comment * minor fixes in traits and interfaces * Fix code style (php-cs-fixer) * Correct comment * Use $tenant->run() * Update scopeGetModelQuery annotation * Use static property for testing shouldSync * Improve test * Get rid of datasets * Add trashed assertions * Always merge synced attributes with the creation attributes during parsing * Update creation attributes in test's beforeEach * Use only the necessary creation attributes (no need to include the synced attributes because they get merged automatically) * Rename ResourceTenant to MorphTenant * Add TriggerSyncingEvents docblock Co-authored-by: Samuel Štancl <samuel.stancl@gmail.com> * Add force deletes test * Fix code style (php-cs-fixer) * Delete pivot if it can't access the resource class * Make parseCreationAttributes more readable * Comment out setting $scopeGetModelQuery in the stub * Add @var annotations to bootTriggerSyncingEvents * Fix attach()/detach() exception test * Interrupt creation of broken pivots instead of deleting the pivots at a later point * Add more comments * Update CreateTenantResource comment * Assert that forceDelete() doesn't affect other tenant resources * Rename test * Correct with() array formatting * Expand test with soft deletes * Merge SyncedResourceSaved tests * Improve naming, comments and minor details in the assertions * Move test * Fix failing test * Delete duplicate test * Minor test improvement * Delete duplicate test * Improve old test * Minor test improvement * Improve event test * Improve tests (naming, code, comments) * Delete extra test, add comments to the larger test * Refactor central -> tenant attach() test * Apply changes from central -> tenant attach() test on tenant -> central test * Fix assertions in central -> tenant * Correct comment and assertion * Refactor tenant -> central attach() test * Fix inconsistency * Delete unused import * Add comments * Update polymorphic test names * Rename polymorphic tests * Update listener test name * Delete redundant tenant ID assignments * Improve test names * Move polymorphic tests to ResourceSyncingTest * Mention alternative solutions in CentralResourceNotAvailableInPivotException * Add comments * Update test comments * minor changes to tests + review comments * Delete extra tests, update comments * Remove unneeded part of test * Fix comment * Improve comments * Add test for companies() realationship accessibility * Update test name * Complete to-do, add comment * Improve naming and comments (resolve some priority reviews) * Move test * Comment, resolve to-dos * Add low-level pivot assertions * Restore trashed resources if the central resource got restored, try improving tests * Fix code style (php-cs-fixer) * Dekete redundnat unsynced comments * Add to-do, test WIP * Fix restoring logic * Update todo * Add todo to fix phpdoc * Fix code style (php-cs-fixer) * PHPStan error fix wip * Fix PHPStan error * Add regression test * Delete unused trait * Add and test restoring WIP * Fix code style (php-cs-fixer) * Add to-do * Delete comment from test * Focus on restoring in the restore test * Improve maming * Fix stub * Delete redundant part of test * Delete incorrect test leftover * Add triggerRestoredEvent * Fix restore test * Correct tests and restore(() logic * Fix code style (php-cs-fixer) * Check if SoftDeletes are used before firing SyncMasterRestored * Fix comment * Revert restore action changes (phpstan errors) * Delete CascadeDeletes interface * Remove CascadeDeletes from most of the tests * Fix code style (php-cs-fixer) * Rename tests * Fix restoring + tests WIP * Fix restoring * Fix restoring tests * Fix code style (php-cs-fixer) * Test that detaching force deletes the tenant resources * Implement cacscade force deleting * Delete redundant changes * Fix typo * Fix SyncMaster * Improve test * Add force deleting logic back and fix tests * Improve comment * Delete extra assertion * Improve restoring test * Simplify assertion * Delete redundant query scoping from test * Test restore listener queueing * use strict in_array() checks * fix phpstan errors --------- Co-authored-by: lukinovec <lukinovec@gmail.com> Co-authored-by: PHP CS Fixer <phpcsfixer@example.com>
1313 lines
42 KiB
PHP
1313 lines
42 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;
|
||
|
||
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;
|
||
|
||
$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;
|
||
});
|
||
|
||
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 = MorphTenant::create();
|
||
$tenant2 = MorphTenant::create();
|
||
|
||
migrateUsersTableForTenants();
|
||
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 = MorphTenant::create();
|
||
migrateUsersTableForTenants();
|
||
|
||
// 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
|
||
$tenant2 = MorphTenant::create();
|
||
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 table 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 () {
|
||
[$tenant1] = createTenantsAndRunMigrations();
|
||
|
||
$syncMaster = CentralUser::create([
|
||
'global_id' => 'cascade_user',
|
||
'name' => 'Central user',
|
||
'email' => 'central@localhost',
|
||
'password' => 'password',
|
||
'role' => 'cascade_user',
|
||
]);
|
||
|
||
$syncMaster->tenants()->attach($tenant1);
|
||
|
||
$syncMaster->delete();
|
||
|
||
tenancy()->initialize($tenant1);
|
||
|
||
expect(TenantUser::firstWhere('global_id', 'cascade_user'))->toBeNull(); // Delete cascaded
|
||
});
|
||
|
||
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');
|
||
});
|
||
|
||
/**
|
||
* Create two tenants and run migrations for those tenants.
|
||
*
|
||
* @return Tenant[]
|
||
*/
|
||
function createTenantsAndRunMigrations(): array
|
||
{
|
||
[$tenant1, $tenant2] = [Tenant::create(), Tenant::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 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',
|
||
];
|
||
}
|
||
}
|