'Realtime Planning', 'deck_config' => [ 'name' => 'Fibonacci', 'cards' => [ ['key' => '3', 'label' => '3'], ['key' => '5', 'label' => '5'], ], ], ...$attributes, ]); } test('room state broadcasts synchronously expose sanitized state', function () { $event = new RoomStateChanged( '019b8f80-37c4-71a3-b415-5f2697332c16', 7, RoomStateChange::VoteChanged, ); expect($event)->toBeInstanceOf(ShouldBroadcastNow::class) ->and($event->broadcastAs())->toBe('room.state.changed') ->and($event->broadcastOn()->name)->toBe('rooms.019b8f80-37c4-71a3-b415-5f2697332c16') ->and($event->broadcastWith())->toBe([ 'version' => 7, 'change' => 'vote_changed', 'state' => null, ]) ->and($event->broadcastWith())->not->toHaveKeys([ 'player_name', 'vote', ]); }); test('joining a room broadcasts the new room version', function () { Event::fake([RoomStateChanged::class]); $room = createBroadcastRoom(); $this->post(route('room.code'), [ 'join_code' => $room->join_code, 'player_name' => 'Anna', ])->assertRedirect(route('room.show', $room)); Event::assertDispatched(RoomStateChanged::class, fn (RoomStateChanged $event): bool => $event->roomId === $room->id && $event->version === $room->fresh()->state_version && $event->change === RoomStateChange::PlayerJoined ); }); test('voting and round actions broadcast their state changes', function () { Event::fake([RoomStateChanged::class]); $room = createBroadcastRoom(); $moderator = $room->players()->create([ 'name' => 'Eric', 'is_moderator' => true, ]); $session = ["room_players.{$room->id}" => $moderator->id]; $this->withSession($session) ->patch(route('room.permissions.update', $room), [ 'allow_participant_controls' => true, ]) ->assertNoContent(); $this->withSession($session) ->post(route('room.vote.store', $room), ['value' => '5']) ->assertNoContent(); $this->withSession($session) ->post(route('room.reveal', $room)) ->assertNoContent(); $this->withSession($session) ->post(route('room.next-round', $room)) ->assertNoContent(); $this->withSession($session) ->post(route('room.vote.store', $room), ['value' => '3']) ->assertNoContent(); $this->withSession($session) ->post(route('room.reset', $room)) ->assertNoContent(); foreach ([ RoomStateChange::VoteChanged, RoomStateChange::CardsRevealed, RoomStateChange::NextRound, RoomStateChange::VotesReset, RoomStateChange::PermissionsUpdated, ] as $change) { Event::assertDispatched( RoomStateChanged::class, fn (RoomStateChanged $event): bool => $event->change === $change, ); } Event::assertDispatched( RoomStateChanged::class, function (RoomStateChanged $event) use ($moderator): bool { if ($event->change !== RoomStateChange::VoteChanged || $event->state === null) { return false; } $player = collect($event->state['players'])->firstWhere('id', $moderator->id); return $event->state['revealed'] === false && $player['has_voted'] === true && $player['vote'] === null; }, ); Event::assertDispatched( RoomStateChanged::class, function (RoomStateChanged $event) use ($moderator): bool { if ($event->change !== RoomStateChange::CardsRevealed || $event->state === null) { return false; } $player = collect($event->state['players'])->firstWhere('id', $moderator->id); return $event->state['revealed'] === true && $player['vote'] === '5'; }, ); Event::assertDispatched( RoomStateChanged::class, fn (RoomStateChanged $event): bool => $event->change === RoomStateChange::PermissionsUpdated && $event->state !== null && $event->state['allow_participant_controls'] === true, ); Event::assertDispatchedTimes(RoomStateChanged::class, 6); }); test('failed room actions do not broadcast updates', function () { Event::fake([RoomStateChanged::class]); $room = createBroadcastRoom(); $player = $room->players()->create([ 'name' => 'Anna', 'is_moderator' => false, ]); $this->withSession(["room_players.{$room->id}" => $player->id]) ->post(route('room.reveal', $room)) ->assertForbidden(); $this->withSession(["room_players.{$room->id}" => $player->id]) ->post(route('room.vote.store', $room), ['value' => '100']) ->assertSessionHasErrors('value'); Event::assertNotDispatched(RoomStateChanged::class); }); test('reopening and deleting a room broadcast lifecycle changes', function () { Event::fake([RoomStateChanged::class]); $owner = User::factory()->create(); $room = createBroadcastRoom([ 'owner_id' => $owner->id, 'closed_at' => now(), ]); $room->players()->create([ 'user_id' => $owner->id, 'name' => 'Eric', 'is_moderator' => true, ]); $this->actingAs($owner) ->post(route('room.reopen', $room)) ->assertRedirect(route('room.show', $room)); $this->actingAs($owner) ->delete(route('room.destroy', $room)) ->assertRedirect(route('dashboard')); Event::assertDispatched( RoomStateChanged::class, fn (RoomStateChanged $event): bool => $event->change === RoomStateChange::RoomReopened, ); Event::assertDispatched( RoomStateChanged::class, fn (RoomStateChanged $event): bool => $event->change === RoomStateChange::RoomDeleted, ); Event::assertDispatchedTimes(RoomStateChanged::class, 2); }); test('the cleanup job broadcasts only rooms it actually closes', function () { Carbon::setTestNow('2026-08-16 12:00:00'); Event::fake([RoomStateChanged::class]); $expiredRoom = createBroadcastRoom([ 'last_activity_at' => now()->subDays(14), ]); createBroadcastRoom([ 'last_activity_at' => now()->subDays(14)->addSecond(), ]); $job = new CloseInactiveRooms; $job->handle(); $job->handle(); Event::assertDispatchedTimes(RoomStateChanged::class, 1); Event::assertDispatched(RoomStateChanged::class, fn (RoomStateChanged $event): bool => $event->roomId === $expiredRoom->id && $event->version === 2 && $event->change === RoomStateChange::RoomClosed ); });