From c34952f328739f4ec5d33ee7b3fece6676cb311e Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 6 Nov 2023 22:09:01 +0100 Subject: [PATCH] Add broadcasting channel prefixing bootstrapper (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Rename old broadcast bootstrapper, add new one * Add broadcast tenancy bootstrapper + tests * Fix code style (php-cs-fixer) * Fix prefixing * Work on th bootstrapper's tests (wip – problem with events) * Fix bootstrapper * Test that auth closures of channels work correctly * Fix bootstrapper * Fix code style (php-cs-fixer) * Delete channel cloning bootstrapper * Add bootstrapper that prefixes broadcastOn channels under the hood * Add broadcast channel registering helpers * Update prefixing tests (WIP) * Fix code style (php-cs-fixer) * Improve comment * Fix code style (php-cs-fixer) * Allow customization of Pusher/Ably broadcaster extension * Fix code style (php-cs-fixer) * Implement prefix bootstrapper logic, test channel prefixing using a closure * Work on the prefixing bootstrapper and tests * Fix code style (php-cs-fixer) * Add optional $options param to broadcasting helpers * Test broadcasting helpers * Fix code style (php-cs-fixer) * Broadcasting channel prefixing + testing progress * Improve helper methods * Fix and improve test * Fix extending in bootstrap() * Fix code style (php-cs-fixer) * Add docblocks, name things more accurately * Fix code style (php-cs-fixer) * Delete redundant method from testing broadcaster * Test Pusher channel prefixing (probabaly redundant?) * Test if channels get prefixed correctly when switching tenants * Work with the current broadcast manager instead of overriding it * Give the original channels to the overriden broadcasters * Fix code style (php-cs-fixer) * Simplify channel prefix bootstrapper * Fix code style (php-cs-fixer) * Fix comment * Fix test * Delete annotation * Delete unused classes from test * Delete outdated test * Move broadcasting bootstrapper test to BootstrapperTest * Improve bootstrapper test, delete unused event * Add annotations to the bootstrapper * Fix code style (php-cs-fixer) * Improve wording * Improve comment * Update src/Bootstrappers/BroadcastChannelPrefixBootstrapper.php * Apply suggestions from code review * Optionally skip prefixing of specific channels * Add and test central channel helper, update formatChannels overrides and tests * Fix code style (php-cs-fixer) * minor fixes * Improve annotation * Use "global__" prefix instead of "central__", add comments * Correct tests --------- Co-authored-by: PHP CS Fixer Co-authored-by: Samuel Štancl --- .../BroadcastChannelPrefixBootstrapper.php | 160 ++++++++++++++++++ ...php => BroadcastingConfigBootstrapper.php} | 2 +- src/helpers.php | 29 ++++ tests/BootstrapperTest.php | 154 +++++++++++++++-- tests/BroadcastingTest.php | 121 ++++++++++++- tests/Etc/TestingBroadcaster.php | 2 +- tests/TestCase.php | 6 +- 7 files changed, 450 insertions(+), 24 deletions(-) create mode 100644 src/Bootstrappers/BroadcastChannelPrefixBootstrapper.php rename src/Bootstrappers/{BroadcastTenancyBootstrapper.php => BroadcastingConfigBootstrapper.php} (97%) diff --git a/src/Bootstrappers/BroadcastChannelPrefixBootstrapper.php b/src/Bootstrappers/BroadcastChannelPrefixBootstrapper.php new file mode 100644 index 00000000..8c916d28 --- /dev/null +++ b/src/Bootstrappers/BroadcastChannelPrefixBootstrapper.php @@ -0,0 +1,160 @@ +extend()) + * so that the channel names they actually use to broadcast events get prefixed. + * + * Channels you return in the broadcastOn() methods of the events are passed to the formatChannels() method. + * Broadcasters use that method to format the names of the channels on which the event will broadcast, + * so we override it to prefix the final channel names the broadcasters use for event broadcasting. + */ +class BroadcastChannelPrefixBootstrapper implements TenancyBootstrapper +{ + /** + * Closures overriding broadcasters with custom broadcasters that prefix the channel names with the tenant keys. + * + * The key is the broadcaster's name, and the value is a closure that should prefix the broadcaster's channels. + * $broadcasterOverrides['custom'] = fn () => ...; // Custom override closure + * + * For more info, see the default override methods in this class (pusher() and ably()). + */ + public static array $broadcasterOverrides = []; + + protected array $originalBroadcasters = []; + + public function __construct( + protected Application $app, + protected BroadcastManager $broadcastManager, + ) { + } + + public function bootstrap(Tenant $tenant): void + { + foreach (static::$broadcasterOverrides as $broadcaster => $broadcasterOverride) { + // Save the original broadcaster, so that we can revert to it later + $this->originalBroadcasters[$broadcaster] = $this->broadcastManager->driver($broadcaster); + + // Delete the cached broadcaster, so that the manager uses the new one + $this->broadcastManager->purge($broadcaster); + + $broadcasterOverride(); + + // Get the overriden broadcaster + $newBroadcaster = $this->broadcastManager->driver($broadcaster); + + // Register the original broadcaster's channels in the new broadcaster + foreach ($this->originalBroadcasters[$broadcaster]->getChannels() as $channel => $callback) { + $newBroadcaster->channel($channel, $callback); + } + } + } + + public function revert(): void + { + // Revert to the original broadcasters + foreach ($this->originalBroadcasters as $name => $broadcaster) { + // Delete the cached (overriden) broadcaster + $this->broadcastManager->purge($name); + + // Make manager return the original broadcaster instance + // Whenever the broadcaster is requested + $this->broadcastManager->extend($name, fn ($app, $config) => $broadcaster); + } + } + + /** + * Set the closure that overrides the 'pusher' broadcaster. + * + * By default, override the 'pusher' broadcaster with a broadcaster that + * extends PusherBroadcaster, and overrides the formatChannels() method, + * such that e.g. 'private-channel' becomes 'private-tenantKey.channel'. + */ + public static function pusher(Closure|null $override = null): void + { + static::$broadcasterOverrides['pusher'] = $override ?? function () { + $broadcastManager = app(BroadcastManager::class); + + return $broadcastManager->extend('pusher', function ($app, $config) use ($broadcastManager) { + return new class($broadcastManager->pusher($config)) extends PusherBroadcaster { + protected function formatChannels(array $channels) + { + $formatChannel = function (string $channel) { + $prefixes = ['private-', 'presence-', 'private-encrypted-']; + $defaultPrefix = ''; + + foreach ($prefixes as $prefix) { + if (str($channel)->startsWith($prefix)) { + $defaultPrefix = $prefix; + break; + } + } + + // Give the tenant prefix to channels that aren't flagged as central + if (! str($channel)->startsWith('global__')) { + $channel = str($channel)->after($defaultPrefix)->prepend($defaultPrefix . tenant()->getTenantKey() . '.'); + } + + return (string) $channel; + }; + + return array_map($formatChannel, $channels); + } + }; + }); + }; + } + + /** + * Set the closure that overrides the 'ably' broadcaster. + * + * By default, override the 'ably' broadcaster with a broadcaster that + * Extends AblyBroadcaster, and overrides the formatChannels() method + * such that e.g. 'private-channel' becomes 'private:tenantKey.channel'. + */ + public static function ably(Closure|null $override = null): void + { + static::$broadcasterOverrides['ably'] = $override ?? function () { + $broadcastManager = app(BroadcastManager::class); + + return $broadcastManager->extend('ably', function ($app, $config) use ($broadcastManager) { + return new class($broadcastManager->ably($config)) extends AblyBroadcaster { + protected function formatChannels(array $channels) + { + $formatChannel = function (string $channel) { + $prefixes = ['private:', 'presence:']; + $defaultPrefix = ''; + + foreach ($prefixes as $prefix) { + if (str($channel)->startsWith($prefix)) { + $defaultPrefix = $prefix; + break; + } + } + + // Give the tenant prefix to channels that aren't flagged as central + if (! str($channel)->startsWith('global__')) { + $channel = str($channel)->after($defaultPrefix)->prepend($defaultPrefix . tenant()->getTenantKey() . '.'); + } + + return (string) $channel; + }; + + return array_map($formatChannel, parent::formatChannels($channels)); + } + }; + }); + }; + } +} diff --git a/src/Bootstrappers/BroadcastTenancyBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php similarity index 97% rename from src/Bootstrappers/BroadcastTenancyBootstrapper.php rename to src/Bootstrappers/BroadcastingConfigBootstrapper.php index 6bfc5c67..b06a15e8 100644 --- a/src/Bootstrappers/BroadcastTenancyBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -12,7 +12,7 @@ use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Overrides\TenancyBroadcastManager; -class BroadcastTenancyBootstrapper implements TenancyBootstrapper +class BroadcastingConfigBootstrapper implements TenancyBootstrapper { /** * Tenant properties to be mapped to config (similarly to the TenantConfig feature). diff --git a/src/helpers.php b/src/helpers.php index d38e6e53..14ae54b9 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Illuminate\Support\Facades\Broadcast; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Tenancy; @@ -106,3 +107,31 @@ if (! function_exists('tenant_route')) { return (string) str($url)->replace($hostname, $domain); } } + +if (! function_exists('tenant_channel')) { + function tenant_channel(string $channelName, Closure $callback, array $options = []): void + { + // Register '{tenant}.channelName' + Broadcast::channel('{tenant}.' . $channelName, fn ($user, $tenantKey, ...$args) => $callback($user, ...$args), $options); + } +} + +if (! function_exists('global_channel')) { + function global_channel(string $channelName, Closure $callback, array $options = []): void + { + // Register 'global__channelName' + // Global channels are available in both the central and tenant contexts + Broadcast::channel('global__' . $channelName, fn ($user, ...$args) => $callback($user, ...$args), $options); + } +} + +if (! function_exists('universal_channel')) { + function universal_channel(string $channelName, Closure $callback, array $options = []): void + { + // Register 'channelName' + Broadcast::channel($channelName, $callback, $options); + + // Register '{tenant}.channelName' + tenant_channel($channelName, $callback, $options); + } +} diff --git a/tests/BootstrapperTest.php b/tests/BootstrapperTest.php index f4e20874..7491a3bd 100644 --- a/tests/BootstrapperTest.php +++ b/tests/BootstrapperTest.php @@ -6,20 +6,23 @@ use Illuminate\Support\Str; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\URL; use Stancl\JobPipeline\JobPipeline; +use Illuminate\Broadcasting\Channel; use Illuminate\Support\Facades\File; use Stancl\Tenancy\Tests\Etc\Tenant; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Route; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Storage; use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Jobs\CreateDatabase; -use Stancl\Tenancy\Overrides\TenancyUrlGenerator; use Stancl\Tenancy\Events\TenantCreated; use Stancl\Tenancy\Events\TenantDeleted; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Broadcast; use Stancl\Tenancy\Events\DeletingTenant; -use Stancl\Tenancy\Overrides\TenancyBroadcastManager; +use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Broadcasting\BroadcastManager; use Stancl\Tenancy\Events\TenancyInitialized; @@ -29,20 +32,24 @@ use Stancl\Tenancy\Jobs\RemoveStorageSymlinks; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Tests\Etc\TestingBroadcaster; use Stancl\Tenancy\Listeners\DeleteTenantStorage; +use Stancl\Tenancy\Overrides\TenancyUrlGenerator; use Stancl\Tenancy\Listeners\RevertToCentralContext; +use Stancl\Tenancy\Overrides\TenancyBroadcastManager; +use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper; +use Stancl\Tenancy\Bootstrappers\UrlBindingBootstrapper; use Stancl\Tenancy\Bootstrappers\UrlTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper; -use Stancl\Tenancy\Bootstrappers\UrlBindingBootstrapper; use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; -use Stancl\Tenancy\Bootstrappers\BroadcastTenancyBootstrapper; +use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData; +use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; +use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper; use Stancl\Tenancy\Bootstrappers\PrefixCacheTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\Integrations\FortifyRouteTenancyBootstrapper; -use Stancl\Tenancy\Middleware\InitializeTenancyByPath; -use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData; beforeEach(function () { $this->mockConsoleOutput = false; @@ -50,7 +57,7 @@ beforeEach(function () { config(['cache.default' => $cacheDriver = 'redis']); PrefixCacheTenancyBootstrapper::$tenantCacheStores = [$cacheDriver]; // Reset static properties of classes used in this test file to their default values - BroadcastTenancyBootstrapper::$credentialsMap = []; + BroadcastingConfigBootstrapper::$credentialsMap = []; TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably']; UrlTenancyBootstrapper::$rootUrlOverride = null; @@ -70,7 +77,7 @@ afterEach(function () { UrlTenancyBootstrapper::$rootUrlOverride = null; PrefixCacheTenancyBootstrapper::$tenantCacheStores = []; TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably']; - BroadcastTenancyBootstrapper::$credentialsMap = []; + BroadcastingConfigBootstrapper::$credentialsMap = []; TenancyUrlGenerator::$prefixRouteNames = false; }); @@ -365,8 +372,8 @@ test('local storage public urls are generated correctly', function() { expect(File::isDirectory($tenantStoragePath))->toBeFalse(); }); -test('BroadcastTenancyBootstrapper binds TenancyBroadcastManager to BroadcastManager and reverts the binding when tenancy is ended', function() { - config(['tenancy.bootstrappers' => [BroadcastTenancyBootstrapper::class]]); +test('BroadcastingConfigBootstrapper binds TenancyBroadcastManager to BroadcastManager and reverts the binding when tenancy is ended', function() { + config(['tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class]]); expect(app(BroadcastManager::class))->toBeInstanceOf(BroadcastManager::class); @@ -379,14 +386,14 @@ test('BroadcastTenancyBootstrapper binds TenancyBroadcastManager to BroadcastMan expect(app(BroadcastManager::class))->toBeInstanceOf(BroadcastManager::class); }); -test('BroadcastTenancyBootstrapper maps tenant broadcaster credentials to config as specified in the $credentialsMap property and reverts the config after ending tenancy', function() { +test('BroadcastingConfigBootstrapper maps tenant broadcaster credentials to config as specified in the $credentialsMap property and reverts the config after ending tenancy', function() { config([ 'broadcasting.connections.testing.driver' => 'testing', 'broadcasting.connections.testing.message' => $defaultMessage = 'default', - 'tenancy.bootstrappers' => [BroadcastTenancyBootstrapper::class], + 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], ]); - BroadcastTenancyBootstrapper::$credentialsMap = [ + BroadcastingConfigBootstrapper::$credentialsMap = [ 'broadcasting.connections.testing.message' => 'testing_broadcaster_message', ]; @@ -407,16 +414,16 @@ test('BroadcastTenancyBootstrapper maps tenant broadcaster credentials to config expect(config('broadcasting.connections.testing.message'))->toBe($defaultMessage); }); -test('BroadcastTenancyBootstrapper makes the app use broadcasters with the correct credentials', function() { +test('BroadcastingConfigBootstrapper makes the app use broadcasters with the correct credentials', function() { config([ 'broadcasting.default' => 'testing', 'broadcasting.connections.testing.driver' => 'testing', 'broadcasting.connections.testing.message' => $defaultMessage = 'default', - 'tenancy.bootstrappers' => [BroadcastTenancyBootstrapper::class], + 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class], ]); TenancyBroadcastManager::$tenantBroadcasters[] = 'testing'; - BroadcastTenancyBootstrapper::$credentialsMap = [ + BroadcastingConfigBootstrapper::$credentialsMap = [ 'broadcasting.connections.testing.message' => 'testing_broadcaster_message', ]; @@ -608,7 +615,7 @@ test('url binding tenancy bootstrapper changes route helper behavior correctly', pest()->get("http://localhost/central/home")->assertSee($centralRouteUrl); pest()->get("http://localhost/$tenantKey/home")->assertSee($tenantRouteUrl); pest()->get("http://localhost/query-string?tenant=$tenantKey")->assertSee($queryStringTenantUrl); -})->group('string'); +}); test('fortify route tenancy bootstrapper updates fortify config correctly', function() { config(['tenancy.bootstrappers' => [FortifyRouteTenancyBootstrapper::class]]); @@ -649,6 +656,119 @@ test('database tenancy bootstrapper throws an exception if DATABASE_URL is set', expect(true)->toBe(true); })->with(['abc.us-east-1.rds.amazonaws.com', null]); +test('BroadcastChannelPrefixBootstrapper prefixes the channels events are broadcast on while tenancy is initialized', function() { + config([ + 'broadcasting.default' => $driver = 'testing', + 'broadcasting.connections.testing.driver' => $driver, + ]); + + // Use custom broadcaster + app(BroadcastManager::class)->extend($driver, fn () => new TestingBroadcaster('original broadcaster')); + + config(['tenancy.bootstrappers' => [BroadcastChannelPrefixBootstrapper::class, DatabaseTenancyBootstrapper::class]]); + + Schema::create('users', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->string('email')->unique(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + universal_channel('users.{userId}', function ($user, $userId) { + return User::find($userId)->is($user); + }); + + $broadcaster = app(BroadcastManager::class)->driver(); + + $tenant = Tenant::create(); + $tenant2 = Tenant::create(); + + pest()->artisan('tenants:migrate'); + + // Set up the 'testing' broadcaster override + // Identical to the default Pusher override (BroadcastChannelPrefixBootstrapper::pusher()) + // Except for the parent class (TestingBroadcaster instead of PusherBroadcaster) + BroadcastChannelPrefixBootstrapper::$broadcasterOverrides['testing'] = function () { + return app(BroadcastManager::class)->extend('testing', function ($app, $config) { + return new class('tenant broadcaster') extends TestingBroadcaster { + protected function formatChannels(array $channels) + { + $formatChannel = function (string $channel) { + $prefixes = ['private-', 'presence-']; + $defaultPrefix = ''; + + foreach ($prefixes as $prefix) { + if (str($channel)->startsWith($prefix)) { + $defaultPrefix = $prefix; + break; + } + } + + // Skip prefixing channels flagged with the central channel prefix + if (! str($channel)->startsWith('global__')) { + $channel = str($channel)->after($defaultPrefix)->prepend($defaultPrefix . tenant()->getTenantKey() . '.'); + } + + return (string) $channel; + }; + + return array_map($formatChannel, parent::formatChannels($channels)); + } + }; + }); + }; + + auth()->login($user = User::create(['name' => 'central', 'email' => 'test@central.cz', 'password' => 'test'])); + + // The channel names used for testing the formatChannels() method (not real channels) + $channelNames = [ + 'channel', + 'global__channel', // Channels prefixed with 'global__' shouldn't get prefixed with the tenant key + 'private-user.' . $user->id, + ]; + + // formatChannels doesn't prefix the channel names until tenancy is initialized + expect(invade(app(BroadcastManager::class)->driver())->formatChannels($channelNames))->toEqual($channelNames); + + tenancy()->initialize($tenant); + + $tenantBroadcaster = app(BroadcastManager::class)->driver(); + + auth()->login($tenantUser = User::create(['name' => 'tenant', 'email' => 'test@tenant.cz', 'password' => 'test'])); + + // The current (tenant) broadcaster isn't the same as the central one + expect($tenantBroadcaster->message)->not()->toBe($broadcaster->message); + // Tenant broadcaster has the same channels as the central broadcaster + expect($tenantBroadcaster->getChannels())->toEqualCanonicalizing($broadcaster->getChannels()); + // formatChannels prefixes the channel names now + expect(invade($tenantBroadcaster)->formatChannels($channelNames))->toEqualCanonicalizing([ + 'global__channel', + $tenant->getTenantKey() . '.channel', + 'private-' . $tenant->getTenantKey() . '.user.' . $tenantUser->id, + ]); + + // Initialize another tenant + tenancy()->initialize($tenant2); + + auth()->login($tenantUser = User::create(['name' => 'tenant', 'email' => 'test2@tenant.cz', 'password' => 'test'])); + + // formatChannels prefixes channels with the second tenant's key now + expect(invade(app(BroadcastManager::class)->driver())->formatChannels($channelNames))->toEqualCanonicalizing([ + 'global__channel', + $tenant2->getTenantKey() . '.channel', + 'private-' . $tenant2->getTenantKey() . '.user.' . $tenantUser->id, + ]); + + // The bootstrapper reverts to the tenant context – the channel names won't be prefixed anymore + tenancy()->end(); + + // The current broadcaster is the same as the central one again + expect(app(BroadcastManager::class)->driver())->toBe($broadcaster); + expect(invade(app(BroadcastManager::class)->driver())->formatChannels($channelNames))->toEqual($channelNames); +}); + function getDiskPrefix(string $disk): string { /** @var FilesystemAdapter $disk */ diff --git a/tests/BroadcastingTest.php b/tests/BroadcastingTest.php index 78f7f677..115f7e9f 100644 --- a/tests/BroadcastingTest.php +++ b/tests/BroadcastingTest.php @@ -2,22 +2,27 @@ declare(strict_types=1); +use Illuminate\Broadcasting\Broadcasters\NullBroadcaster; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Schema; use Stancl\Tenancy\Events\TenancyEnded; +use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Broadcast; -use Stancl\Tenancy\Overrides\TenancyBroadcastManager; use Illuminate\Broadcasting\BroadcastManager; use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Tests\Etc\TestingBroadcaster; use Stancl\Tenancy\Listeners\RevertToCentralContext; +use Stancl\Tenancy\Overrides\TenancyBroadcastManager; +use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; +use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper; use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract; -use Stancl\Tenancy\Bootstrappers\BroadcastTenancyBootstrapper; +use Illuminate\Support\Collection; beforeEach(function () { withTenantDatabases(); - config(['tenancy.bootstrappers' => [BroadcastTenancyBootstrapper::class]]); TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably']; + Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class); }); @@ -27,6 +32,7 @@ afterEach(function () { }); test('bound broadcaster instance is the same before initializing tenancy and after ending it', function() { + config(['tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class]]); config(['broadcasting.default' => 'null']); TenancyBroadcastManager::$tenantBroadcasters[] = 'null'; @@ -45,6 +51,7 @@ test('bound broadcaster instance is the same before initializing tenancy and aft }); test('new broadcasters get the channels from the previously bound broadcaster', function() { + config(['tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class]]); config([ 'broadcasting.default' => $driver = 'testing', 'broadcasting.connections.testing.driver' => $driver, @@ -70,3 +77,111 @@ test('new broadcasters get the channels from the previously bound broadcaster', expect($channel)->toBeIn($getCurrentChannels()); }); + +test('broadcasting channel helpers register channels correctly', function() { + config([ + 'broadcasting.default' => $driver = 'testing', + 'broadcasting.connections.testing.driver' => $driver, + ]); + + config(['tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class]]); + + Schema::create('users', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->string('email')->unique(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + $centralUser = User::create(['name' => 'central', 'email' => 'test@central.cz', 'password' => 'test']); + $tenant = Tenant::create(); + + migrateTenants(); + + tenancy()->initialize($tenant); + + // Same ID as $centralUser + $tenantUser = User::create(['name' => 'tenant', 'email' => 'test@tenant.cz', 'password' => 'test']); + + tenancy()->end(); + + /** @var BroadcastManager $broadcastManager */ + $broadcastManager = app(BroadcastManager::class); + + // Use a driver with no channels + $broadcastManager->extend($driver, fn () => new NullBroadcaster); + + $getChannels = fn (): Collection => $broadcastManager->driver($driver)->getChannels(); + + expect($getChannels())->toBeEmpty(); + + // Basic channel registration + Broadcast::channel($channelName = 'user.{userName}', $channelClosure = function ($user, $userName) { + return User::firstWhere('name', $userName)?->is($user) ?? false; + }); + + // Check if the channel is registered + $centralChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === $channelName); + expect($centralChannelClosure)->not()->toBeNull(); + + // Channel closures work as expected (running in central context) + expect($centralChannelClosure($centralUser, $centralUser->name))->toBeTrue(); + expect($centralChannelClosure($centralUser, $tenantUser->name))->toBeFalse(); + + // Register a tenant broadcasting channel (almost identical to the original channel, just able to accept the tenant key) + tenant_channel($channelName, $channelClosure); + + // Tenant channel registered – its name is correctly prefixed ("{tenant}.user.{userId}") + $tenantChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === "{tenant}.$channelName"); + expect($tenantChannelClosure) + ->not()->toBeNull() // Channel registered + ->not()->toBe($centralChannelClosure); // The tenant channel closure is different – after the auth user, it accepts the tenant ID + + // The tenant channels are prefixed with '{tenant}.' + // They accept the tenant key, but their closures only run in tenant context when tenancy is initialized + // The regular channels don't accept the tenant key, but they also respect the current context + // The tenant key is used solely for the name prefixing – the closures can still run in the central context + expect($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $centralUser->name))->toBeTrue(); + expect($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $tenantUser->name))->toBeFalse(); + + tenancy()->initialize($tenant); + + // The channel closure runs in the central context + // Only the central user is available + expect($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $tenantUser->name))->toBeFalse(); + expect($tenantChannelClosure($tenantUser, $tenant->getTenantKey(), $tenantUser->name))->toBeTrue(); + + // Use a new channel instance to delete the previously registered channels before testing the univeresal_channel helper + $broadcastManager->purge($driver); + $broadcastManager->extend($driver, fn () => new NullBroadcaster); + + expect($getChannels())->toBeEmpty(); + + // universal_channel helper registers both the unprefixed and the prefixed broadcasting channel correctly + // Using the tenant_channel helper + basic channel registration (Broadcast::channel()) + universal_channel($channelName, $channelClosure); + + // Regular channel registered correctly + $centralChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === $channelName); + expect($centralChannelClosure)->not()->toBeNull(); + + // Tenant channel registered correctly + $tenantChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === "{tenant}.$channelName"); + expect($tenantChannelClosure) + ->not()->toBeNull() // Channel registered + ->not()->toBe($centralChannelClosure); // The tenant channel callback is different – after the auth user, it accepts the tenant ID + + $broadcastManager->purge($driver); + $broadcastManager->extend($driver, fn () => new NullBroadcaster); + + expect($getChannels())->toBeEmpty(); + + // Central channel prefixes the channel name with 'global__' + global_channel($channelName, $channelClosure); + + // Channel prefixed with 'global__' found + $foundChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === 'global__' . $channelName); + expect($foundChannelClosure)->not()->toBeNull(); +}); diff --git a/tests/Etc/TestingBroadcaster.php b/tests/Etc/TestingBroadcaster.php index 23efb74c..1a225887 100644 --- a/tests/Etc/TestingBroadcaster.php +++ b/tests/Etc/TestingBroadcaster.php @@ -6,7 +6,7 @@ use Illuminate\Broadcasting\Broadcasters\Broadcaster; class TestingBroadcaster extends Broadcaster { public function __construct( - public string $message + public string $message = 'nothing' ) {} public function auth($request) diff --git a/tests/TestCase.php b/tests/TestCase.php index 64581c04..854b6da3 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -10,6 +10,7 @@ use Stancl\Tenancy\Tenancy; use Stancl\Tenancy\Tests\Etc\Tenant; use Illuminate\Support\Facades\Redis; use Illuminate\Foundation\Application; +use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper; use Stancl\Tenancy\Facades\GlobalCache; use Stancl\Tenancy\TenancyServiceProvider; use Stancl\Tenancy\Facades\Tenancy as TenancyFacade; @@ -17,7 +18,7 @@ use Stancl\Tenancy\Bootstrappers\UrlTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; -use Stancl\Tenancy\Bootstrappers\BroadcastTenancyBootstrapper; +use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\PrefixCacheTenancyBootstrapper; @@ -124,7 +125,8 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase $app->singleton(RedisTenancyBootstrapper::class); // todo (Samuel) use proper approach eg config for singleton registration $app->singleton(PrefixCacheTenancyBootstrapper::class); // todo (Samuel) use proper approach eg config for singleton registration - $app->singleton(BroadcastTenancyBootstrapper::class); + $app->singleton(BroadcastingConfigBootstrapper::class); + $app->singleton(BroadcastChannelPrefixBootstrapper::class); $app->singleton(MailTenancyBootstrapper::class); $app->singleton(UrlTenancyBootstrapper::class); }