mirror of
https://github.com/ericasch6/scrum-poker-internal.git
synced 2026-10-13 20:52:08 +00:00
207 lines
6.5 KiB
PHP
207 lines
6.5 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)
|
|
->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,
|
|
] 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::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
|
|
);
|
|
});
|