Files
scrum-poker-internal/tests/Feature/RoomBroadcastingTest.php
T

221 lines
7.0 KiB
PHP

<?php
use App\Enums\RoomStateChange;
use App\Events\RoomStateChanged;
use App\Jobs\CloseInactiveRooms;
use App\Models\Room;
use App\Models\User;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
function createBroadcastRoom(array $attributes = []): Room
{
return Room::create([
'title' => '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
);
});