1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2025-12-12 16:14:02 +00:00

Add broadcasting channel prefixing bootstrapper (#12)

* 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 <phpcsfixer@example.com>
Co-authored-by: Samuel Štancl <samuel.stancl@gmail.com>
This commit is contained in:
lukinovec 2023-11-06 22:09:01 +01:00 committed by GitHub
parent b503dbf33d
commit c34952f328
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 450 additions and 24 deletions

View file

@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Closure;
use Illuminate\Broadcasting\Broadcasters\AblyBroadcaster;
use Illuminate\Broadcasting\Broadcasters\PusherBroadcaster;
use Illuminate\Broadcasting\BroadcastManager;
use Illuminate\Contracts\Foundation\Application;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
/**
* Overrides broadcasters (by default, using $broadcasterManager->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));
}
};
});
};
}
}

View file

@ -12,7 +12,7 @@ use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Overrides\TenancyBroadcastManager; 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). * Tenant properties to be mapped to config (similarly to the TenantConfig feature).

View file

@ -2,6 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use Illuminate\Support\Facades\Broadcast;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Tenancy; use Stancl\Tenancy\Tenancy;
@ -106,3 +107,31 @@ if (! function_exists('tenant_route')) {
return (string) str($url)->replace($hostname, $domain); 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);
}
}

View file

@ -6,20 +6,23 @@ use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Stancl\JobPipeline\JobPipeline; use Stancl\JobPipeline\JobPipeline;
use Illuminate\Broadcasting\Channel;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Jobs\CreateDatabase; use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
use Stancl\Tenancy\Events\TenantCreated; use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Events\TenantDeleted; use Stancl\Tenancy\Events\TenantDeleted;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Broadcast;
use Stancl\Tenancy\Events\DeletingTenant; use Stancl\Tenancy\Events\DeletingTenant;
use Stancl\Tenancy\Overrides\TenancyBroadcastManager; use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Broadcasting\BroadcastManager; use Illuminate\Broadcasting\BroadcastManager;
use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Events\TenancyInitialized;
@ -29,20 +32,24 @@ use Stancl\Tenancy\Jobs\RemoveStorageSymlinks;
use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Tests\Etc\TestingBroadcaster; use Stancl\Tenancy\Tests\Etc\TestingBroadcaster;
use Stancl\Tenancy\Listeners\DeleteTenantStorage; use Stancl\Tenancy\Listeners\DeleteTenantStorage;
use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
use Stancl\Tenancy\Listeners\RevertToCentralContext; 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\CacheTagsBootstrapper;
use Stancl\Tenancy\Bootstrappers\UrlBindingBootstrapper;
use Stancl\Tenancy\Bootstrappers\UrlTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\UrlTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\UrlBindingBootstrapper;
use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain; use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; 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\FilesystemTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper;
use Stancl\Tenancy\Bootstrappers\PrefixCacheTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\PrefixCacheTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\Integrations\FortifyRouteTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\Integrations\FortifyRouteTenancyBootstrapper;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
beforeEach(function () { beforeEach(function () {
$this->mockConsoleOutput = false; $this->mockConsoleOutput = false;
@ -50,7 +57,7 @@ beforeEach(function () {
config(['cache.default' => $cacheDriver = 'redis']); config(['cache.default' => $cacheDriver = 'redis']);
PrefixCacheTenancyBootstrapper::$tenantCacheStores = [$cacheDriver]; PrefixCacheTenancyBootstrapper::$tenantCacheStores = [$cacheDriver];
// Reset static properties of classes used in this test file to their default values // Reset static properties of classes used in this test file to their default values
BroadcastTenancyBootstrapper::$credentialsMap = []; BroadcastingConfigBootstrapper::$credentialsMap = [];
TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably']; TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably'];
UrlTenancyBootstrapper::$rootUrlOverride = null; UrlTenancyBootstrapper::$rootUrlOverride = null;
@ -70,7 +77,7 @@ afterEach(function () {
UrlTenancyBootstrapper::$rootUrlOverride = null; UrlTenancyBootstrapper::$rootUrlOverride = null;
PrefixCacheTenancyBootstrapper::$tenantCacheStores = []; PrefixCacheTenancyBootstrapper::$tenantCacheStores = [];
TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably']; TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably'];
BroadcastTenancyBootstrapper::$credentialsMap = []; BroadcastingConfigBootstrapper::$credentialsMap = [];
TenancyUrlGenerator::$prefixRouteNames = false; TenancyUrlGenerator::$prefixRouteNames = false;
}); });
@ -365,8 +372,8 @@ test('local storage public urls are generated correctly', function() {
expect(File::isDirectory($tenantStoragePath))->toBeFalse(); expect(File::isDirectory($tenantStoragePath))->toBeFalse();
}); });
test('BroadcastTenancyBootstrapper binds TenancyBroadcastManager to BroadcastManager and reverts the binding when tenancy is ended', function() { test('BroadcastingConfigBootstrapper binds TenancyBroadcastManager to BroadcastManager and reverts the binding when tenancy is ended', function() {
config(['tenancy.bootstrappers' => [BroadcastTenancyBootstrapper::class]]); config(['tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class]]);
expect(app(BroadcastManager::class))->toBeInstanceOf(BroadcastManager::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); 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([ config([
'broadcasting.connections.testing.driver' => 'testing', 'broadcasting.connections.testing.driver' => 'testing',
'broadcasting.connections.testing.message' => $defaultMessage = 'default', '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', '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); 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([ config([
'broadcasting.default' => 'testing', 'broadcasting.default' => 'testing',
'broadcasting.connections.testing.driver' => 'testing', 'broadcasting.connections.testing.driver' => 'testing',
'broadcasting.connections.testing.message' => $defaultMessage = 'default', 'broadcasting.connections.testing.message' => $defaultMessage = 'default',
'tenancy.bootstrappers' => [BroadcastTenancyBootstrapper::class], 'tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class],
]); ]);
TenancyBroadcastManager::$tenantBroadcasters[] = 'testing'; TenancyBroadcastManager::$tenantBroadcasters[] = 'testing';
BroadcastTenancyBootstrapper::$credentialsMap = [ BroadcastingConfigBootstrapper::$credentialsMap = [
'broadcasting.connections.testing.message' => 'testing_broadcaster_message', '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/central/home")->assertSee($centralRouteUrl);
pest()->get("http://localhost/$tenantKey/home")->assertSee($tenantRouteUrl); pest()->get("http://localhost/$tenantKey/home")->assertSee($tenantRouteUrl);
pest()->get("http://localhost/query-string?tenant=$tenantKey")->assertSee($queryStringTenantUrl); pest()->get("http://localhost/query-string?tenant=$tenantKey")->assertSee($queryStringTenantUrl);
})->group('string'); });
test('fortify route tenancy bootstrapper updates fortify config correctly', function() { test('fortify route tenancy bootstrapper updates fortify config correctly', function() {
config(['tenancy.bootstrappers' => [FortifyRouteTenancyBootstrapper::class]]); 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); expect(true)->toBe(true);
})->with(['abc.us-east-1.rds.amazonaws.com', null]); })->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 function getDiskPrefix(string $disk): string
{ {
/** @var FilesystemAdapter $disk */ /** @var FilesystemAdapter $disk */

View file

@ -2,22 +2,27 @@
declare(strict_types=1); declare(strict_types=1);
use Illuminate\Broadcasting\Broadcasters\NullBroadcaster;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Events\TenancyEnded;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Broadcast; use Illuminate\Support\Facades\Broadcast;
use Stancl\Tenancy\Overrides\TenancyBroadcastManager;
use Illuminate\Broadcasting\BroadcastManager; use Illuminate\Broadcasting\BroadcastManager;
use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Tests\Etc\TestingBroadcaster; use Stancl\Tenancy\Tests\Etc\TestingBroadcaster;
use Stancl\Tenancy\Listeners\RevertToCentralContext; 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 Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract;
use Stancl\Tenancy\Bootstrappers\BroadcastTenancyBootstrapper; use Illuminate\Support\Collection;
beforeEach(function () { beforeEach(function () {
withTenantDatabases(); withTenantDatabases();
config(['tenancy.bootstrappers' => [BroadcastTenancyBootstrapper::class]]);
TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably']; TenancyBroadcastManager::$tenantBroadcasters = ['pusher', 'ably'];
Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::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() { test('bound broadcaster instance is the same before initializing tenancy and after ending it', function() {
config(['tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class]]);
config(['broadcasting.default' => 'null']); config(['broadcasting.default' => 'null']);
TenancyBroadcastManager::$tenantBroadcasters[] = '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() { test('new broadcasters get the channels from the previously bound broadcaster', function() {
config(['tenancy.bootstrappers' => [BroadcastingConfigBootstrapper::class]]);
config([ config([
'broadcasting.default' => $driver = 'testing', 'broadcasting.default' => $driver = 'testing',
'broadcasting.connections.testing.driver' => $driver, 'broadcasting.connections.testing.driver' => $driver,
@ -70,3 +77,111 @@ test('new broadcasters get the channels from the previously bound broadcaster',
expect($channel)->toBeIn($getCurrentChannels()); 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();
});

View file

@ -6,7 +6,7 @@ use Illuminate\Broadcasting\Broadcasters\Broadcaster;
class TestingBroadcaster extends Broadcaster { class TestingBroadcaster extends Broadcaster {
public function __construct( public function __construct(
public string $message public string $message = 'nothing'
) {} ) {}
public function auth($request) public function auth($request)

View file

@ -10,6 +10,7 @@ use Stancl\Tenancy\Tenancy;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
use Stancl\Tenancy\Facades\GlobalCache; use Stancl\Tenancy\Facades\GlobalCache;
use Stancl\Tenancy\TenancyServiceProvider; use Stancl\Tenancy\TenancyServiceProvider;
use Stancl\Tenancy\Facades\Tenancy as TenancyFacade; 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\MailTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\BroadcastTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\PrefixCacheTenancyBootstrapper; 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(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(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(MailTenancyBootstrapper::class);
$app->singleton(UrlTenancyBootstrapper::class); $app->singleton(UrlTenancyBootstrapper::class);
} }