'Realtime Planning', 'deck_config' => [ 'name' => 'Fibonacci', 'cards' => [ ['key' => '3', 'label' => '3'], ['key' => '5', 'label' => '5'], ], ], ...$attributes, ]); } test('room state broadcasts expose only invalidation metadata', function () { $event = new RoomStateChanged( '019b8f80-37c4-71a3-b415-5f2697332c16', 7, RoomStateChange::VoteChanged, ); expect($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', ]) ->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) ->post(route('room.vote.store', $room), ['value' => '5']) ->assertRedirect(); $this->withSession($session) ->post(route('room.reveal', $room)) ->assertRedirect(); $this->withSession($session) ->post(route('room.next-round', $room)) ->assertRedirect(); $this->withSession($session) ->post(route('room.vote.store', $room), ['value' => '3']) ->assertRedirect(); $this->withSession($session) ->post(route('room.reset', $room)) ->assertRedirect(); foreach ([ RoomStateChange::VoteChanged, RoomStateChange::CardsRevealed, RoomStateChange::NextRound, RoomStateChange::VotesReset, ] as $change) { Event::assertDispatched( RoomStateChanged::class, fn (RoomStateChanged $event): bool => $event->change === $change, ); } Event::assertDispatchedTimes(RoomStateChanged::class, 5); }); 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 ); });