From cbee84353e2357f320dc529d158ba7823517d85e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:46:16 +0200 Subject: [PATCH] refactor(events): broadcast team events to an explicit team Move the shared team id and team channel logic of 15 broadcast events into a BroadcastsToTeam trait. Events no longer fall back to the session team: the session team can differ from the resource team, and queued jobs have no session. Fix StopDatabaseProxy, which dispatched DatabaseProxyStopped without a team, so the event was never broadcast from queued jobs. Co-Authored-By: Claude Opus 5.5 --- app/Actions/Database/StopDatabaseProxy.php | 3 +- .../ApplicationConfigurationChanged.php | 25 +--- app/Events/ApplicationStatusChanged.php | 25 +--- app/Events/BackupCreated.php | 25 +--- app/Events/CloudflareTunnelConfigured.php | 25 +--- app/Events/DatabaseProxyStopped.php | 25 +--- app/Events/FileStorageChanged.php | 25 +--- app/Events/ProxyStatusChangedUI.php | 22 +--- app/Events/ScheduledTaskDone.php | 25 +--- app/Events/SentinelRestarted.php | 17 +-- app/Events/SentinelSynchronized.php | 17 +-- app/Events/ServerPackageUpdated.php | 25 +--- app/Events/ServerValidated.php | 22 +--- app/Events/ServiceChecked.php | 25 +--- app/Events/ServiceStatusChanged.php | 24 +--- app/Events/TestEvent.php | 24 +--- app/Http/Controllers/Controller.php | 2 +- app/Traits/BroadcastsToTeam.php | 34 +++++ tests/Feature/TeamBroadcastEventsTest.php | 124 ++++++++++++++++++ 19 files changed, 192 insertions(+), 322 deletions(-) create mode 100644 app/Traits/BroadcastsToTeam.php create mode 100644 tests/Feature/TeamBroadcastEventsTest.php diff --git a/app/Actions/Database/StopDatabaseProxy.php b/app/Actions/Database/StopDatabaseProxy.php index e6789202f3..bb53538b7d 100644 --- a/app/Actions/Database/StopDatabaseProxy.php +++ b/app/Actions/Database/StopDatabaseProxy.php @@ -31,7 +31,6 @@ class StopDatabaseProxy $database->save(); - DatabaseProxyStopped::dispatch(); - + DatabaseProxyStopped::dispatch($server->team_id); } } diff --git a/app/Events/ApplicationConfigurationChanged.php b/app/Events/ApplicationConfigurationChanged.php index 3dd532b19d..522bc22348 100644 --- a/app/Events/ApplicationConfigurationChanged.php +++ b/app/Events/ApplicationConfigurationChanged.php @@ -2,34 +2,13 @@ namespace App\Events; +use App\Traits\BroadcastsToTeam; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; class ApplicationConfigurationChanged implements ShouldBroadcast { - use Dispatchable, InteractsWithSockets, SerializesModels; - - public ?int $teamId = null; - - public function __construct($teamId = null) - { - if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) { - $teamId = auth()->user()->currentTeam()->id; - } - $this->teamId = $teamId; - } - - public function broadcastOn(): array - { - if (is_null($this->teamId)) { - return []; - } - - return [ - new PrivateChannel("team.{$this->teamId}"), - ]; - } + use BroadcastsToTeam, Dispatchable, InteractsWithSockets, SerializesModels; } diff --git a/app/Events/ApplicationStatusChanged.php b/app/Events/ApplicationStatusChanged.php index a20abac0f5..9e867497d2 100644 --- a/app/Events/ApplicationStatusChanged.php +++ b/app/Events/ApplicationStatusChanged.php @@ -2,34 +2,13 @@ namespace App\Events; +use App\Traits\BroadcastsToTeam; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; class ApplicationStatusChanged implements ShouldBroadcast { - use Dispatchable, InteractsWithSockets, SerializesModels; - - public ?int $teamId = null; - - public function __construct($teamId = null) - { - if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) { - $teamId = auth()->user()->currentTeam()->id; - } - $this->teamId = $teamId; - } - - public function broadcastOn(): array - { - if (is_null($this->teamId)) { - return []; - } - - return [ - new PrivateChannel("team.{$this->teamId}"), - ]; - } + use BroadcastsToTeam, Dispatchable, InteractsWithSockets, SerializesModels; } diff --git a/app/Events/BackupCreated.php b/app/Events/BackupCreated.php index 9670f5c3c6..5717dbcce0 100644 --- a/app/Events/BackupCreated.php +++ b/app/Events/BackupCreated.php @@ -2,8 +2,8 @@ namespace App\Events; +use App\Traits\BroadcastsToTeam; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; @@ -11,26 +11,5 @@ use Laravel\Horizon\Contracts\Silenced; class BackupCreated implements ShouldBroadcast, Silenced { - use Dispatchable, InteractsWithSockets, SerializesModels; - - public ?int $teamId = null; - - public function __construct($teamId = null) - { - if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) { - $teamId = auth()->user()->currentTeam()->id; - } - $this->teamId = $teamId; - } - - public function broadcastOn(): array - { - if (is_null($this->teamId)) { - return []; - } - - return [ - new PrivateChannel("team.{$this->teamId}"), - ]; - } + use BroadcastsToTeam, Dispatchable, InteractsWithSockets, SerializesModels; } diff --git a/app/Events/CloudflareTunnelConfigured.php b/app/Events/CloudflareTunnelConfigured.php index b40c7d070d..e75b15201b 100644 --- a/app/Events/CloudflareTunnelConfigured.php +++ b/app/Events/CloudflareTunnelConfigured.php @@ -2,34 +2,13 @@ namespace App\Events; +use App\Traits\BroadcastsToTeam; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; class CloudflareTunnelConfigured implements ShouldBroadcast { - use Dispatchable, InteractsWithSockets, SerializesModels; - - public ?int $teamId = null; - - public function __construct($teamId = null) - { - if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) { - $teamId = auth()->user()->currentTeam()->id; - } - $this->teamId = $teamId; - } - - public function broadcastOn(): array - { - if (is_null($this->teamId)) { - return []; - } - - return [ - new PrivateChannel("team.{$this->teamId}"), - ]; - } + use BroadcastsToTeam, Dispatchable, InteractsWithSockets, SerializesModels; } diff --git a/app/Events/DatabaseProxyStopped.php b/app/Events/DatabaseProxyStopped.php index 8099b080dc..ed997eb148 100644 --- a/app/Events/DatabaseProxyStopped.php +++ b/app/Events/DatabaseProxyStopped.php @@ -2,34 +2,13 @@ namespace App\Events; +use App\Traits\BroadcastsToTeam; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; class DatabaseProxyStopped implements ShouldBroadcast { - use Dispatchable, InteractsWithSockets, SerializesModels; - - public ?int $teamId = null; - - public function __construct($teamId = null) - { - if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) { - $teamId = auth()->user()->currentTeam()->id; - } - $this->teamId = $teamId; - } - - public function broadcastOn(): array - { - if (is_null($this->teamId)) { - return []; - } - - return [ - new PrivateChannel("team.{$this->teamId}"), - ]; - } + use BroadcastsToTeam, Dispatchable, InteractsWithSockets, SerializesModels; } diff --git a/app/Events/FileStorageChanged.php b/app/Events/FileStorageChanged.php index 756cb1352d..1fd923e55a 100644 --- a/app/Events/FileStorageChanged.php +++ b/app/Events/FileStorageChanged.php @@ -2,34 +2,13 @@ namespace App\Events; +use App\Traits\BroadcastsToTeam; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; class FileStorageChanged implements ShouldBroadcast { - use Dispatchable, InteractsWithSockets, SerializesModels; - - public ?int $teamId = null; - - public function __construct($teamId = null) - { - if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) { - $teamId = auth()->user()->currentTeam()->id; - } - $this->teamId = $teamId; - } - - public function broadcastOn(): array - { - if (is_null($this->teamId)) { - return []; - } - - return [ - new PrivateChannel("team.{$this->teamId}"), - ]; - } + use BroadcastsToTeam, Dispatchable, InteractsWithSockets, SerializesModels; } diff --git a/app/Events/ProxyStatusChangedUI.php b/app/Events/ProxyStatusChangedUI.php index 3994dc0f8f..18af34d005 100644 --- a/app/Events/ProxyStatusChangedUI.php +++ b/app/Events/ProxyStatusChangedUI.php @@ -2,37 +2,21 @@ namespace App\Events; +use App\Traits\BroadcastsToTeam; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; class ProxyStatusChangedUI implements ShouldBroadcast { - use Dispatchable, InteractsWithSockets, SerializesModels; - - public ?int $teamId = null; + use BroadcastsToTeam, Dispatchable, InteractsWithSockets, SerializesModels; public ?int $activityId = null; - public function __construct(?int $teamId = null, ?int $activityId = null) + public function __construct(?int $teamId, ?int $activityId = null) { - if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) { - $teamId = auth()->user()->currentTeam()->id; - } $this->teamId = $teamId; $this->activityId = $activityId; } - - public function broadcastOn(): array - { - if (is_null($this->teamId)) { - return []; - } - - return [ - new PrivateChannel("team.{$this->teamId}"), - ]; - } } diff --git a/app/Events/ScheduledTaskDone.php b/app/Events/ScheduledTaskDone.php index 9884c278b4..594efba233 100644 --- a/app/Events/ScheduledTaskDone.php +++ b/app/Events/ScheduledTaskDone.php @@ -2,34 +2,13 @@ namespace App\Events; +use App\Traits\BroadcastsToTeam; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; class ScheduledTaskDone implements ShouldBroadcast { - use Dispatchable, InteractsWithSockets, SerializesModels; - - public ?int $teamId = null; - - public function __construct($teamId = null) - { - if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) { - $teamId = auth()->user()->currentTeam()->id; - } - $this->teamId = $teamId; - } - - public function broadcastOn(): array - { - if (is_null($this->teamId)) { - return []; - } - - return [ - new PrivateChannel("team.{$this->teamId}"), - ]; - } + use BroadcastsToTeam, Dispatchable, InteractsWithSockets, SerializesModels; } diff --git a/app/Events/SentinelRestarted.php b/app/Events/SentinelRestarted.php index 9ddc3a07f9..aba54be541 100644 --- a/app/Events/SentinelRestarted.php +++ b/app/Events/SentinelRestarted.php @@ -3,17 +3,15 @@ namespace App\Events; use App\Models\Server; +use App\Traits\BroadcastsToTeam; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; class SentinelRestarted implements ShouldBroadcast { - use Dispatchable, InteractsWithSockets, SerializesModels; - - public ?int $teamId = null; + use BroadcastsToTeam, Dispatchable, InteractsWithSockets, SerializesModels; public ?string $version = null; @@ -25,15 +23,4 @@ class SentinelRestarted implements ShouldBroadcast $this->serverUuid = $server->uuid; $this->version = $version; } - - public function broadcastOn(): array - { - if (is_null($this->teamId)) { - return []; - } - - return [ - new PrivateChannel("team.{$this->teamId}"), - ]; - } } diff --git a/app/Events/SentinelSynchronized.php b/app/Events/SentinelSynchronized.php index 3c39e46f8d..6e39fdb42c 100644 --- a/app/Events/SentinelSynchronized.php +++ b/app/Events/SentinelSynchronized.php @@ -3,17 +3,15 @@ namespace App\Events; use App\Models\Server; +use App\Traits\BroadcastsToTeam; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; class SentinelSynchronized implements ShouldBroadcast { - use Dispatchable, InteractsWithSockets, SerializesModels; - - public ?int $teamId = null; + use BroadcastsToTeam, Dispatchable, InteractsWithSockets, SerializesModels; public string $serverUuid; @@ -22,15 +20,4 @@ class SentinelSynchronized implements ShouldBroadcast $this->teamId = $server->team_id; $this->serverUuid = $server->uuid; } - - public function broadcastOn(): array - { - if (is_null($this->teamId)) { - return []; - } - - return [ - new PrivateChannel("team.{$this->teamId}"), - ]; - } } diff --git a/app/Events/ServerPackageUpdated.php b/app/Events/ServerPackageUpdated.php index 4bde14068d..88c8c52bb7 100644 --- a/app/Events/ServerPackageUpdated.php +++ b/app/Events/ServerPackageUpdated.php @@ -2,34 +2,13 @@ namespace App\Events; +use App\Traits\BroadcastsToTeam; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; class ServerPackageUpdated implements ShouldBroadcast { - use Dispatchable, InteractsWithSockets, SerializesModels; - - public ?int $teamId = null; - - public function __construct($teamId = null) - { - if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) { - $teamId = auth()->user()->currentTeam()->id; - } - $this->teamId = $teamId; - } - - public function broadcastOn(): array - { - if (is_null($this->teamId)) { - return []; - } - - return [ - new PrivateChannel("team.{$this->teamId}"), - ]; - } + use BroadcastsToTeam, Dispatchable, InteractsWithSockets, SerializesModels; } diff --git a/app/Events/ServerValidated.php b/app/Events/ServerValidated.php index 95a116ebea..8a7631f777 100644 --- a/app/Events/ServerValidated.php +++ b/app/Events/ServerValidated.php @@ -2,40 +2,24 @@ namespace App\Events; +use App\Traits\BroadcastsToTeam; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; class ServerValidated implements ShouldBroadcast { - use Dispatchable, InteractsWithSockets, SerializesModels; - - public ?int $teamId = null; + use BroadcastsToTeam, Dispatchable, InteractsWithSockets, SerializesModels; public ?string $serverUuid = null; - public function __construct(?int $teamId = null, ?string $serverUuid = null) + public function __construct(?int $teamId, ?string $serverUuid = null) { - if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) { - $teamId = auth()->user()->currentTeam()->id; - } $this->teamId = $teamId; $this->serverUuid = $serverUuid; } - public function broadcastOn(): array - { - if (is_null($this->teamId)) { - return []; - } - - return [ - new PrivateChannel("team.{$this->teamId}"), - ]; - } - public function broadcastAs(): string { return 'ServerValidated'; diff --git a/app/Events/ServiceChecked.php b/app/Events/ServiceChecked.php index 86a27a8927..a36daeda55 100644 --- a/app/Events/ServiceChecked.php +++ b/app/Events/ServiceChecked.php @@ -2,8 +2,8 @@ namespace App\Events; +use App\Traits\BroadcastsToTeam; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; @@ -11,26 +11,5 @@ use Laravel\Horizon\Contracts\Silenced; class ServiceChecked implements ShouldBroadcast, Silenced { - use Dispatchable, InteractsWithSockets, SerializesModels; - - public ?int $teamId = null; - - public function __construct($teamId = null) - { - if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) { - $teamId = auth()->user()->currentTeam()->id; - } - $this->teamId = $teamId; - } - - public function broadcastOn(): array - { - if (is_null($this->teamId)) { - return []; - } - - return [ - new PrivateChannel("team.{$this->teamId}"), - ]; - } + use BroadcastsToTeam, Dispatchable, InteractsWithSockets, SerializesModels; } diff --git a/app/Events/ServiceStatusChanged.php b/app/Events/ServiceStatusChanged.php index 97ca4b0f8c..6fc444e99e 100644 --- a/app/Events/ServiceStatusChanged.php +++ b/app/Events/ServiceStatusChanged.php @@ -2,33 +2,13 @@ namespace App\Events; +use App\Traits\BroadcastsToTeam; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\Auth; class ServiceStatusChanged implements ShouldBroadcast { - use Dispatchable, InteractsWithSockets, SerializesModels; - - public function __construct( - public ?int $teamId = null - ) { - if (is_null($this->teamId) && Auth::check() && Auth::user()->currentTeam()) { - $this->teamId = Auth::user()->currentTeam()->id; - } - } - - public function broadcastOn(): array - { - if (is_null($this->teamId)) { - return []; - } - - return [ - new PrivateChannel("team.{$this->teamId}"), - ]; - } + use BroadcastsToTeam, Dispatchable, InteractsWithSockets, SerializesModels; } diff --git a/app/Events/TestEvent.php b/app/Events/TestEvent.php index c6669c937c..8b9f4a5d2b 100644 --- a/app/Events/TestEvent.php +++ b/app/Events/TestEvent.php @@ -2,33 +2,13 @@ namespace App\Events; +use App\Traits\BroadcastsToTeam; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; class TestEvent implements ShouldBroadcast { - use Dispatchable, InteractsWithSockets, SerializesModels; - - public ?int $teamId = null; - - public function __construct() - { - if (auth()->check() && auth()->user()->currentTeam()) { - $this->teamId = auth()->user()->currentTeam()->id; - } - } - - public function broadcastOn(): array - { - if (is_null($this->teamId)) { - return []; - } - - return [ - new PrivateChannel("team.{$this->teamId}"), - ]; - } + use BroadcastsToTeam, Dispatchable, InteractsWithSockets, SerializesModels; } diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 9b6b315cee..8add69a697 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -33,7 +33,7 @@ class Controller extends BaseController if (auth()->user()?->currentTeam()->id !== 0) { return redirect(RouteServiceProvider::HOME); } - TestEvent::dispatch(); + TestEvent::dispatch(currentTeam()->id); return 'Look at your other tab.'; } diff --git a/app/Traits/BroadcastsToTeam.php b/app/Traits/BroadcastsToTeam.php new file mode 100644 index 0000000000..be0973e9bc --- /dev/null +++ b/app/Traits/BroadcastsToTeam.php @@ -0,0 +1,34 @@ +teamId = $teamId; + } + + /** + * @return array + */ + public function broadcastOn(): array + { + if (is_null($this->teamId)) { + return []; + } + + return [new PrivateChannel("team.{$this->teamId}")]; + } +} diff --git a/tests/Feature/TeamBroadcastEventsTest.php b/tests/Feature/TeamBroadcastEventsTest.php new file mode 100644 index 0000000000..c41abb7c99 --- /dev/null +++ b/tests/Feature/TeamBroadcastEventsTest.php @@ -0,0 +1,124 @@ + 'array', 'cache.default' => 'array']); + InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0])); + $this->team = Team::factory()->create(); +}); + +function teamBroadcastChannelNames(object $event): array +{ + return array_map(fn (PrivateChannel $channel) => $channel->name, $event->broadcastOn()); +} + +dataset('team broadcast events', [ + ApplicationConfigurationChanged::class, + ApplicationStatusChanged::class, + BackupCreated::class, + CloudflareTunnelConfigured::class, + DatabaseProxyStopped::class, + FileStorageChanged::class, + ProxyStatusChangedUI::class, + ScheduledTaskDone::class, + ServerPackageUpdated::class, + ServerValidated::class, + ServiceChecked::class, + ServiceStatusChanged::class, + TestEvent::class, +]); + +it('broadcasts on the given team channel, not the session team', function (string $eventClass) { + $otherTeam = Team::factory()->create(); + $user = User::factory()->create(); + $user->teams()->attach($otherTeam, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $otherTeam]); + + expect(teamBroadcastChannelNames(new $eventClass($this->team->id))) + ->toBe(["private-team.{$this->team->id}"]); +})->with('team broadcast events'); + +it('does not broadcast without a team', function (string $eventClass) { + expect((new $eventClass(null))->broadcastOn())->toBe([]); +})->with('team broadcast events'); + +it('broadcasts sentinel events on the server team channel', function () { + $server = new Server(['team_id' => $this->team->id]); + $server->uuid = 'server-uuid'; + + expect(teamBroadcastChannelNames(new SentinelRestarted($server, '1.0.0'))) + ->toBe(["private-team.{$this->team->id}"]) + ->and(teamBroadcastChannelNames(new SentinelSynchronized($server))) + ->toBe(["private-team.{$this->team->id}"]); +}); + +it('announces a stopped database proxy to the team that owns the server', function () { + Event::fake([DatabaseProxyStopped::class]); + Process::fake(fn () => Process::result(output: 'OK')); + + $keyId = DB::table('private_keys')->insertGetId([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Key', + 'private_key' => Crypt::encryptString('test-key'), + 'team_id' => $this->team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $keyId]); + $destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate( + ['server_id' => $server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'] + )); + $project = Project::create(['uuid' => (string) Str::uuid(), 'name' => 'Project', 'team_id' => $this->team->id]); + $environment = $project->environments()->first() ?? Environment::factory()->create(['project_id' => $project->id]); + $database = StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'db', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'db', + 'image' => 'postgres:17', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + // Runs as a queued job in production: there is no authenticated user. + StopDatabaseProxy::run($database); + + Event::assertDispatched(DatabaseProxyStopped::class, fn (DatabaseProxyStopped $event) => $event->teamId === $this->team->id); +});