diff --git a/composer.json b/composer.json index 0ca231c4..bb11040e 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,8 @@ "spatie/ignition": "^1.4", "ramsey/uuid": "^4.0", "stancl/jobpipeline": "^1.0", - "stancl/virtualcolumn": "^1.3" + "stancl/virtualcolumn": "^1.3", + "spatie/invade": "^1.1" }, "require-dev": { "laravel/framework": "^9.38", diff --git a/src/Bootstrappers/BroadcastTenancyBootstrapper.php b/src/Bootstrappers/BroadcastTenancyBootstrapper.php new file mode 100644 index 00000000..2f625437 --- /dev/null +++ b/src/Bootstrappers/BroadcastTenancyBootstrapper.php @@ -0,0 +1,95 @@ + 'tenant_property', + * ] + */ + public static array $credentialsMap = []; + + public static string|null $broadcaster = null; + + protected array $originalConfig = []; + protected BroadcastManager|null $originalBroadcastManager = null; + protected Broadcaster|null $originalBroadcaster = null; + + public static array $mapPresets = [ + 'pusher' => [ + 'broadcasting.connections.pusher.key' => 'pusher_key', + 'broadcasting.connections.pusher.secret' => 'pusher_secret', + 'broadcasting.connections.pusher.app_id' => 'pusher_app_id', + 'broadcasting.connections.pusher.options.cluster' => 'pusher_cluster', + ], + 'ably' => [ + 'broadcasting.connections.ably.key' => 'ably_key', + 'broadcasting.connections.ably.public' => 'ably_public', + ], + ]; + + public function __construct( + protected Repository $config, + protected Application $app + ) { + static::$broadcaster ??= $config->get('broadcasting.default'); + static::$credentialsMap = array_merge(static::$credentialsMap, static::$mapPresets[static::$broadcaster] ?? []); + } + + public function bootstrap(Tenant $tenant): void + { + $this->originalBroadcastManager = $this->app->make(BroadcastManager::class); + $this->originalBroadcaster = $this->app->make(Broadcaster::class); + + $this->setConfig($tenant); + + // Make BroadcastManager resolve to a custom BroadcastManager which makes the broadcasters use the tenant credentials + $this->app->extend(BroadcastManager::class, function (BroadcastManager $broadcastManager) { + return new TenancyBroadcastManager($this->app); + }); + } + + public function revert(): void + { + // Change the BroadcastManager and Broadcaster singletons back to what they were before initializing tenancy + $this->app->singleton(BroadcastManager::class, fn (Application $app) => $this->originalBroadcastManager); + $this->app->singleton(Broadcaster::class, fn (Application $app) => $this->originalBroadcaster); + + $this->unsetConfig(); + } + + protected function setConfig(Tenant $tenant): void + { + foreach (static::$credentialsMap as $configKey => $storageKey) { + $override = $tenant->$storageKey; + + if (array_key_exists($storageKey, $tenant->getAttributes())) { + $this->originalConfig[$configKey] ??= $this->config->get($configKey); + + $this->config->set($configKey, $override); + } + } + } + + protected function unsetConfig(): void + { + foreach ($this->originalConfig as $key => $value) { + $this->config->set($key, $value); + } + } +} diff --git a/src/TenancyBroadcastManager.php b/src/TenancyBroadcastManager.php new file mode 100644 index 00000000..59e30b57 --- /dev/null +++ b/src/TenancyBroadcastManager.php @@ -0,0 +1,65 @@ +resolve() (even when they're + * cached and available in the $broadcasters property). + * + * The reason for recreating the broadcasters is + * to make your app use the correct broadcaster credentials when tenancy is initialized. + */ + public static array $tenantBroadcasters = ['pusher', 'ably']; + + /** + * Override the get method so that the broadcasters in $tenantBroadcasters + * always get freshly resolved even when they're cached and available in the $broadcasters property, + * and that the resolved broadcaster will override the BroadcasterContract::class singleton. + * + * If there's a cached broadcaster with the same name as $name, + * give its channels to the newly resolved bootstrapper. + */ + protected function get($name) + { + if (in_array($name, static::$tenantBroadcasters)) { + /** @var Broadcaster|null $originalBroadcaster */ + $originalBroadcaster = $this->app->make(BroadcasterContract::class); + $newBroadcaster = $this->resolve($name); + + // If there is a current broadcaster, give its channels to the newly resolved one + // Broadcasters only have to implement the Illuminate\Contracts\Broadcasting\Broadcaster contract + // Which doesn't require the channels property + // So passing the channels is only needed for Illuminate\Broadcasting\Broadcasters\Broadcaster instances + if ($originalBroadcaster instanceof Broadcaster && $newBroadcaster instanceof Broadcaster) { + $this->passChannelsFromOriginalBroadcaster($originalBroadcaster, $newBroadcaster); + } + + $this->app->singleton(BroadcasterContract::class, fn (Application $app) => $newBroadcaster); + + return $newBroadcaster; + } + + return parent::get($name); + } + + // Because, unlike the original broadcaster, the newly resolved broadcaster won't have the channels registered using routes/channels.php + // Using it for broadcasting won't work, unless we make it have the original broadcaster's channels + protected function passChannelsFromOriginalBroadcaster(Broadcaster $originalBroadcaster, Broadcaster $newBroadcaster): void + { + // invade() because channels can't be retrieved through any of the broadcaster's public methods + $originalBroadcaster = invade($originalBroadcaster); + + foreach ($originalBroadcaster->channels as $channel => $callback) { + $newBroadcaster->channel($channel, $callback, $originalBroadcaster->retrieveChannelOptions($channel)); + } + } +} diff --git a/tests/BootstrapperTest.php b/tests/BootstrapperTest.php index fbc4f0b3..7350f0a8 100644 --- a/tests/BootstrapperTest.php +++ b/tests/BootstrapperTest.php @@ -18,11 +18,14 @@ use Stancl\Tenancy\Jobs\CreateDatabase; use Stancl\Tenancy\Events\TenantCreated; use Stancl\Tenancy\Events\TenantDeleted; use Stancl\Tenancy\Events\DeletingTenant; +use Stancl\Tenancy\TenancyBroadcastManager; use Illuminate\Filesystem\FilesystemAdapter; +use Illuminate\Broadcasting\BroadcastManager; use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Jobs\CreateStorageSymlinks; use Stancl\Tenancy\Jobs\RemoveStorageSymlinks; use Stancl\Tenancy\Listeners\BootstrapTenancy; +use Stancl\Tenancy\Tests\Etc\TestingBroadcaster; use Stancl\Tenancy\Listeners\DeleteTenantStorage; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Bootstrappers\UrlTenancyBootstrapper; @@ -31,6 +34,7 @@ use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; +use Stancl\Tenancy\Bootstrappers\BroadcastTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; beforeEach(function () { @@ -331,6 +335,82 @@ 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() { + expect(app(BroadcastManager::class))->toBeInstanceOf(BroadcastManager::class); + + tenancy()->initialize(Tenant::create()); + + expect(app(BroadcastManager::class))->toBeInstanceOf(TenancyBroadcastManager::class); + + tenancy()->end(); + + 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() { + config([ + 'broadcasting.connections.testing.driver' => 'testing', + 'broadcasting.connections.testing.message' => $defaultMessage = 'default', + ]); + + BroadcastTenancyBootstrapper::$credentialsMap = [ + 'broadcasting.connections.testing.message' => 'testing_broadcaster_message', + ]; + + $tenant = Tenant::create(['testing_broadcaster_message' => $tenantMessage = 'first testing']); + $tenant2 = Tenant::create(['testing_broadcaster_message' => $secondTenantMessage = 'second testing']); + + tenancy()->initialize($tenant); + + expect(array_key_exists('testing_broadcaster_message', tenant()->getAttributes()))->toBeTrue(); + expect(config('broadcasting.connections.testing.message'))->toBe($tenantMessage); + + tenancy()->initialize($tenant2); + + expect(config('broadcasting.connections.testing.message'))->toBe($secondTenantMessage); + + tenancy()->end(); + + expect(config('broadcasting.connections.testing.message'))->toBe($defaultMessage); +}); + +test('BroadcastTenancyBootstrapper 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', + ]); + + TenancyBroadcastManager::$tenantBroadcasters[] = 'testing'; + BroadcastTenancyBootstrapper::$credentialsMap = [ + 'broadcasting.connections.testing.message' => 'testing_broadcaster_message', + ]; + + $registerTestingBroadcaster = fn() => app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster($config['message'])); + + $registerTestingBroadcaster(); + + expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($defaultMessage); + + $tenant = Tenant::create(['testing_broadcaster_message' => $tenantMessage = 'first testing']); + $tenant2 = Tenant::create(['testing_broadcaster_message' => $secondTenantMessage = 'second testing']); + + tenancy()->initialize($tenant); + $registerTestingBroadcaster(); + + expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($tenantMessage); + + tenancy()->initialize($tenant2); + $registerTestingBroadcaster(); + + expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($secondTenantMessage); + + tenancy()->end(); + $registerTestingBroadcaster(); + + expect(invade(app(BroadcastManager::class)->driver())->message)->toBe($defaultMessage); +}); + test('MailTenancyBootstrapper maps tenant mail credentials to config as specified in the $credentialsMap property and makes the mailer use tenant credentials', function() { MailTenancyBootstrapper::$credentialsMap = [ 'mail.mailers.smtp.username' => 'smtp_username', diff --git a/tests/BroadcastingTest.php b/tests/BroadcastingTest.php new file mode 100644 index 00000000..aeb70de2 --- /dev/null +++ b/tests/BroadcastingTest.php @@ -0,0 +1,65 @@ + 'null']); + TenancyBroadcastManager::$tenantBroadcasters[] = 'null'; + + $originalBroadcaster = app(BroadcasterContract::class); + + tenancy()->initialize(Tenant::create()); + + // TenancyBroadcastManager binds new broadcaster + $tenantBroadcaster = app(BroadcastManager::class)->driver(); + + expect($tenantBroadcaster)->not()->toBe($originalBroadcaster); + + tenancy()->end(); + + expect($originalBroadcaster)->toBe(app(BroadcasterContract::class)); +}); + +test('new broadcasters get the channels from the previously bound broadcaster', function() { + config([ + 'broadcasting.default' => $driver = 'testing', + 'broadcasting.connections.testing.driver' => $driver, + ]); + + TenancyBroadcastManager::$tenantBroadcasters[] = $driver; + + $registerTestingBroadcaster = fn() => app(BroadcastManager::class)->extend('testing', fn($app, $config) => new TestingBroadcaster('testing')); + $getCurrentChannels = fn() => array_keys(invade(app(BroadcastManager::class)->driver())->channels); + + $registerTestingBroadcaster(); + Broadcast::channel($channel = 'testing-channel', fn() => true); + + expect($channel)->toBeIn($getCurrentChannels()); + + tenancy()->initialize(Tenant::create()); + $registerTestingBroadcaster(); + + expect($channel)->toBeIn($getCurrentChannels()); + + tenancy()->end(); + $registerTestingBroadcaster(); + + expect($channel)->toBeIn($getCurrentChannels()); +}); diff --git a/tests/Etc/TestingBroadcaster.php b/tests/Etc/TestingBroadcaster.php new file mode 100644 index 00000000..23efb74c --- /dev/null +++ b/tests/Etc/TestingBroadcaster.php @@ -0,0 +1,25 @@ + true, ], 'tenancy.bootstrappers.redis' => RedisTenancyBootstrapper::class, // todo1 change this to []? two tests in TenantDatabaseManagerTest are failing with that + 'tenancy.bootstrappers.broadcast' => BroadcastTenancyBootstrapper::class, // todo1 change this to []? two tests in TenantDatabaseManagerTest are failing with that 'tenancy.bootstrappers.mail' => MailTenancyBootstrapper::class, 'tenancy.bootstrappers.url' => UrlTenancyBootstrapper::class, 'queue.connections.central' => [ @@ -116,6 +118,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase ]); $app->singleton(RedisTenancyBootstrapper::class); // todo (Samuel) use proper approach eg config for singleton registration + $app->singleton(BroadcastTenancyBootstrapper::class); $app->singleton(MailTenancyBootstrapper::class); $app->singleton(UrlTenancyBootstrapper::class); }