kick forever
CI/CD / test_and_check_app (push) Successful in 2m19s
CI/CD / Publish immutable image (push) Successful in 1m16s

This commit is contained in:
2026-09-24 14:38:18 +02:00
parent b190ff719e
commit 4dfe79ce5c
22 changed files with 271 additions and 113 deletions
+94
View File
@@ -0,0 +1,94 @@
<?php
use App\Enums\RoomStateChange;
use App\Events\RoomStateChanged;
use App\Jobs\RemoveAbsentPlayers;
use App\Models\Room;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
test('the player table no longer stores an inactive status', function () {
expect(Schema::hasColumn('players', 'inactive_at'))->toBeFalse();
});
test('the migration removes old inactive and absent players while keeping present players', function () {
Schema::table('players', function (Blueprint $table): void {
$table->timestamp('inactive_at')->nullable();
});
$room = Room::create([
'title' => 'Planning',
'deck_config' => ['name' => 'Fibonacci', 'cards' => []],
]);
$previouslyInactive = $room->players()->create([
'name' => 'Inactive',
'last_seen_at' => now(),
]);
$absent = $room->players()->create([
'name' => 'Nico',
'last_seen_at' => now()->subHours(2),
]);
$present = $room->players()->create([
'name' => 'Anna',
'last_seen_at' => now(),
]);
DB::table('players')->where('id', $previouslyInactive->id)->update(['inactive_at' => now()]);
$migration = require database_path('migrations/2026_09_24_123048_remove_previously_inactive_players.php');
$migration->up();
$this->assertModelMissing($previouslyInactive);
$this->assertModelMissing($absent);
$this->assertModelExists($present);
expect(Schema::hasColumn('players', 'inactive_at'))->toBeFalse();
});
test('the timeout removes absent players and their votes but keeps recent players', function () {
$room = Room::create([
'title' => 'Planning',
'current_round' => 1,
'deck_config' => ['name' => 'Fibonacci', 'cards' => []],
]);
$absentPlayer = $room->players()->create([
'name' => 'Nico',
'last_seen_at' => now()->subHour(),
]);
$recentPlayer = $room->players()->create([
'name' => 'Anna',
'last_seen_at' => now()->subHour()->addSecond(),
]);
$vote = $absentPlayer->votes()->create([
'room_id' => $room->id,
'round' => $room->current_round,
'value' => '3',
]);
$previousVersion = $room->fresh()->state_version;
Event::fake([RoomStateChanged::class]);
app()->call([new RemoveAbsentPlayers, 'handle']);
app()->call([new RemoveAbsentPlayers, 'handle']);
$this->assertModelMissing($absentPlayer);
$this->assertModelMissing($vote);
$this->assertModelExists($recentPlayer);
expect($room->fresh()->state_version)->toBe($previousVersion + 1);
Event::assertDispatched(
RoomStateChanged::class,
fn (RoomStateChanged $event): bool => $event->change === RoomStateChange::PlayerKicked
&& $event->affectedPlayerId === $absentPlayer->id
&& collect($event->state['players'])->doesntContain('id', $absentPlayer->id),
);
Event::assertDispatchedTimes(RoomStateChanged::class, 1);
$this->post(route('room.code'), [
'join_code' => $room->join_code,
'player_name' => 'Nico',
])->assertRedirect(route('room.show', $room));
expect($room->players()->where('name', 'Nico')->sole()->id)->not->toBe($absentPlayer->id);
});
+2 -6
View File
@@ -46,7 +46,6 @@ test('embedded guest access reuses the session player and applies a changed disp
$room = createEmbeddedRoom();
$player = $room->players()->create([
'name' => 'Old name',
'inactive_at' => now(),
]);
$this->withSession(["room_players.{$room->id}" => $player->id])
@@ -57,8 +56,7 @@ test('embedded guest access reuses the session player and applies a changed disp
->assertOk();
expect($room->players()->count())->toBe(1)
->and($player->fresh()->name)->toBe('New name')
->and($player->fresh()->inactive_at)->toBeNull();
->and($player->fresh()->name)->toBe('New name');
});
test('an authenticated user automatically joins the embedded room', function () {
@@ -92,7 +90,6 @@ test('embedded access reuses the user player and applies a changed display name'
$player = $room->players()->create([
'user_id' => $user->id,
'name' => 'Old name',
'inactive_at' => now(),
]);
$this->actingAs($user)
@@ -103,8 +100,7 @@ test('embedded access reuses the user player and applies a changed display name'
->assertOk();
expect($room->players()->count())->toBe(1)
->and($player->fresh()->name)->toBe('New name')
->and($player->fresh()->inactive_at)->toBeNull();
->and($player->fresh()->name)->toBe('New name');
});
test('an embedded guest keeps the same player after logging in separately', function () {
+1 -7
View File
@@ -20,11 +20,6 @@ test('favorited rooms are presented on the dashboard with player counts', functi
'name' => 'Active player',
'last_seen_at' => now(),
]);
$room->players()->create([
'name' => 'Inactive player',
'last_seen_at' => now()->subHour(),
'inactive_at' => now(),
]);
$this->actingAs($user)
->post(route('room.favorite', $room))
@@ -39,8 +34,7 @@ test('favorited rooms are presented on the dashboard with player counts', functi
->assertInertia(fn (Assert $page) => $page
->has('favoriteRooms', 1)
->where('favoriteRooms.0.id', $room->id)
->where('favoriteRooms.0.players_count', 2)
->where('favoriteRooms.0.active_player_count', 1));
->where('favoriteRooms.0.players_count', 1));
});
test('the room resource exposes favorite and owner state', function () {
+5 -4
View File
@@ -216,15 +216,16 @@ test('an active guest with a room session skips the join form', function () {
->assertRedirect(route('room.show', $room));
});
test('an inactive guest is sent through join and reuses the existing player', function () {
test('a removed guest joins again with a new player identity', function () {
$room = createLifecycleRoom();
$player = $room->players()->create([
'name' => 'Returning guest',
'last_seen_at' => now()->subHours(2),
'inactive_at' => now()->subHour(),
]);
$session = ["room_players.{$room->id}" => $player->id];
$player->delete();
$this->withSession($session)
->get(route('room.show', $room))
->assertRedirect(route('room.join', ['join_code' => $room->join_code]));
@@ -233,7 +234,7 @@ test('an inactive guest is sent through join and reuses the existing player', fu
->get(route('room.join', ['join_code' => $room->join_code]))
->assertInertia(fn (Assert $page) => $page
->component('rooms/Join')
->where('defaultPlayerName', 'Returning guest'));
->where('defaultPlayerName', null));
$this->withSession($session)
->post(route('room.code'), [
@@ -243,7 +244,7 @@ test('an inactive guest is sent through join and reuses the existing player', fu
->assertRedirect(route('room.show', $room));
expect($room->players()->count())->toBe(1)
->and($player->fresh()->inactive_at)->toBeNull();
->and($room->players()->sole()->id)->not->toBe($player->id);
});
test('showing or polling a room does not count as room activity', function () {
+39 -10
View File
@@ -44,7 +44,6 @@ test('the room owner sees active room players on the dashboard', function () {
$room = createRoomForPlayerKick($owner);
$moderator = addPlayerToKickRoom($room, 'Moderator', $owner, true);
$participant = addPlayerToKickRoom($room, 'Anna');
addPlayerToKickRoom($room, 'Inactive')->update(['inactive_at' => now()]);
$this->actingAs($owner)
->get(route('dashboard'))
@@ -77,8 +76,8 @@ test('the room owner can kick an active participant', function () {
->delete(route('room.player.destroy', [$room, $participant]))
->assertRedirect();
expect($participant->fresh()->inactive_at)->not->toBeNull()
->and($room->fresh()->state_version)->toBe($previousVersion + 1);
$this->assertModelMissing($participant);
expect($room->fresh()->state_version)->toBe($previousVersion + 1);
Event::assertDispatched(
RoomStateChanged::class,
@@ -104,6 +103,36 @@ test('the room owner can kick an active participant', function () {
Event::assertDispatchedTimes(RoomStateChanged::class, 1);
});
test('a kicked guest loses their votes and their name becomes available', function () {
$owner = User::factory()->create();
$room = createRoomForPlayerKick($owner);
addPlayerToKickRoom($room, 'Moderator', $owner, true);
$guest = addPlayerToKickRoom($room, 'Nico');
$vote = $guest->votes()->create([
'room_id' => $room->id,
'round' => $room->current_round,
'value' => '3',
]);
$this->actingAs($owner)
->delete(route('room.player.destroy', [$room, $guest]))
->assertRedirect();
$this->assertModelMissing($guest);
$this->assertModelMissing($vote);
auth()->logout();
$this->withSession(["room_players.{$room->id}" => $guest->id])
->post(route('room.code'), [
'join_code' => $room->join_code,
'player_name' => 'Nico',
])
->assertRedirect(route('room.show', $room));
expect($room->players()->where('name', 'Nico')->sole()->id)->not->toBe($guest->id);
});
test('the room moderator can kick an active participant without shared management', function () {
$owner = User::factory()->create();
$room = createRoomForPlayerKick($owner, ['owner_id' => null]);
@@ -116,7 +145,7 @@ test('the room moderator can kick an active participant without shared managemen
->delete(route('room.player.destroy', [$room, $target]))
->assertRedirect();
expect($target->fresh()->inactive_at)->not->toBeNull();
$this->assertModelMissing($target);
Event::assertDispatchedTimes(RoomStateChanged::class, 1);
});
@@ -135,7 +164,7 @@ test('a participant cannot kick another player when participant management is di
->delete(route('room.player.destroy', [$room, $target]))
->assertForbidden();
expect($target->fresh()->inactive_at)->toBeNull();
$this->assertModelExists($target);
Event::assertNotDispatched(RoomStateChanged::class);
});
@@ -154,7 +183,7 @@ test('a participant can kick another player when participant management is enabl
->delete(route('room.player.destroy', [$room, $target]))
->assertRedirect();
expect($target->fresh()->inactive_at)->not->toBeNull();
$this->assertModelMissing($target);
Event::assertDispatched(RoomStateChanged::class);
});
@@ -171,7 +200,7 @@ test('a participant cannot kick themselves when participant management is enable
->delete(route('room.player.destroy', [$room, $participant]))
->assertForbidden();
expect($participant->fresh()->inactive_at)->toBeNull();
$this->assertModelExists($participant);
Event::assertNotDispatched(RoomStateChanged::class);
});
@@ -189,7 +218,7 @@ test('an anonymous visitor without a room session cannot kick from an ownerless
$this->delete(route('room.player.destroy', [$room, $target]))
->assertForbidden();
expect($target->fresh()->inactive_at)->toBeNull();
$this->assertModelExists($target);
Event::assertNotDispatched(RoomStateChanged::class);
});
@@ -204,7 +233,7 @@ test('the room owner cannot kick their own player', function () {
->delete(route('room.player.destroy', [$room, $moderator]))
->assertForbidden();
expect($moderator->fresh()->inactive_at)->toBeNull();
$this->assertModelExists($moderator);
Event::assertNotDispatched(RoomStateChanged::class);
});
@@ -221,6 +250,6 @@ test('the room owner cannot kick a player from another room', function () {
->delete(route('room.player.destroy', [$room, $target]))
->assertForbidden();
expect($target->fresh()->inactive_at)->toBeNull();
$this->assertModelExists($target);
Event::assertNotDispatched(RoomStateChanged::class);
});