feat: added laravel reverb and websocket, also emote throwing added

This commit is contained in:
2026-08-16 17:10:42 +02:00
parent 50e9fbd0b0
commit 6f47ed0674
30 changed files with 2244 additions and 34 deletions
+174
View File
@@ -0,0 +1,174 @@
<?php
use App\Enums\RoomStateChange;
use App\Events\RoomStateChanged;
use App\Jobs\CloseInactiveRooms;
use App\Models\Room;
use App\Models\User;
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 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
);
});
+101
View File
@@ -0,0 +1,101 @@
<?php
use App\Events\PaperBallThrown;
use App\Models\Room;
use Illuminate\Support\Facades\Event;
function createEmoteRoom(array $attributes = []): Room
{
return Room::create([
'title' => 'Playful Planning',
'deck_config' => [
'name' => 'Fibonacci',
'cards' => [
['key' => '3', 'label' => '3'],
['key' => '5', 'label' => '5'],
],
],
...$attributes,
]);
}
test('a participant can throw a realtime paper ball at another player', function () {
Event::fake([PaperBallThrown::class]);
$room = createEmoteRoom(['last_activity_at' => now()->subDay()]);
$sender = $room->players()->create([
'name' => 'Eric',
'is_moderator' => true,
]);
$target = $room->players()->create([
'name' => 'Anna',
'is_moderator' => false,
]);
$room->refresh();
$activity = $room->last_activity_at;
$stateVersion = $room->state_version;
$this->withSession(["room_players.{$room->id}" => $sender->id])
->postJson(route('room.emote.paper-ball.store', $room), [
'target_player_id' => $target->id,
])
->assertNoContent();
Event::assertDispatched(PaperBallThrown::class, function (PaperBallThrown $event) use ($room, $sender, $target): bool {
return $event->roomId === $room->id
&& $event->fromPlayerId === $sender->id
&& $event->targetPlayerId === $target->id
&& $event->broadcastOn()->name === "rooms.{$room->id}"
&& $event->broadcastAs() === 'room.emote.thrown'
&& array_keys($event->broadcastWith()) === [
'id',
'from_player_id',
'target_player_id',
];
});
$room->refresh();
expect($room->last_activity_at->equalTo($activity))->toBeTrue()
->and($room->state_version)->toBe($stateVersion);
});
test('a participant cannot throw a paper ball at themselves', function () {
Event::fake([PaperBallThrown::class]);
$room = createEmoteRoom();
$player = $room->players()->create([
'name' => 'Eric',
'is_moderator' => true,
]);
$this->withSession(["room_players.{$room->id}" => $player->id])
->postJson(route('room.emote.paper-ball.store', $room), [
'target_player_id' => $player->id,
])
->assertUnprocessable()
->assertJsonValidationErrors('target_player_id');
Event::assertNotDispatched(PaperBallThrown::class);
});
test('a participant cannot target a player from another room', function () {
Event::fake([PaperBallThrown::class]);
$room = createEmoteRoom();
$sender = $room->players()->create([
'name' => 'Eric',
'is_moderator' => true,
]);
$otherRoom = createEmoteRoom();
$otherPlayer = $otherRoom->players()->create([
'name' => 'Nina',
'is_moderator' => true,
]);
$this->withSession(["room_players.{$room->id}" => $sender->id])
->postJson(route('room.emote.paper-ball.store', $room), [
'target_player_id' => $otherPlayer->id,
])
->assertUnprocessable()
->assertJsonValidationErrors('target_player_id');
Event::assertNotDispatched(PaperBallThrown::class);
});