toBeFalse(); }); test('migrations remove absent players and reconcile guest rooms', 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(), ]); $emptyGuestRoom = Room::create([ 'title' => 'Empty guest room', 'deck_config' => ['name' => 'Fibonacci', 'cards' => []], ]); $owner = User::factory()->create(); $emptyAccountRoom = Room::create([ 'owner_id' => $owner->id, 'is_persistent' => true, 'title' => 'Persistent account room', 'deck_config' => ['name' => 'Fibonacci', 'cards' => []], ]); 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(); $guestRoomMigration = require database_path('migrations/2026_09_24_125529_reconcile_guest_rooms_after_player_cleanup.php'); $guestRoomMigration->up(); $this->assertModelMissing($previouslyInactive); $this->assertModelMissing($absent); $this->assertModelExists($present); $this->assertSoftDeleted($emptyGuestRoom); $this->assertModelExists($emptyAccountRoom); expect($present->fresh()->is_moderator)->toBeTrue(); 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', 'is_moderator' => true, '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); expect($recentPlayer->fresh()->is_moderator)->toBeTrue(); Event::assertDispatched( RoomStateChanged::class, fn (RoomStateChanged $event): bool => $event->change === RoomStateChange::PlayerKicked && $event->affectedPlayerId === $absentPlayer->id && collect($event->state['players'])->doesntContain('id', $absentPlayer->id) && collect($event->state['players'])->firstWhere('id', $recentPlayer->id)['is_moderator'] === true, ); 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); }); test('the timeout deletes a guest room when its last player is removed', function () { $room = Room::create([ 'title' => 'Guest planning', 'deck_config' => ['name' => 'Fibonacci', 'cards' => []], ]); $moderator = $room->players()->create([ 'name' => 'Nico', 'is_moderator' => true, 'last_seen_at' => now()->subHour(), ]); Event::fake([RoomStateChanged::class]); app()->call([new RemoveAbsentPlayers, 'handle']); app()->call([new RemoveAbsentPlayers, 'handle']); $this->assertModelMissing($moderator); $this->assertSoftDeleted($room); Event::assertDispatched( RoomStateChanged::class, fn (RoomStateChanged $event): bool => $event->roomId === $room->id && $event->change === RoomStateChange::RoomDeleted, ); Event::assertDispatchedTimes(RoomStateChanged::class, 1); $this->post(route('room.code'), [ 'join_code' => $room->join_code, 'player_name' => 'Nico', ])->assertSessionHasErrors('join_code'); }); test('the owner regains moderation when joining a persistent room after a timeout', function () { $owner = User::factory()->create(); $room = Room::create([ 'owner_id' => $owner->id, 'is_persistent' => true, 'title' => 'Permanent planning', 'deck_config' => ['name' => 'Fibonacci', 'cards' => []], ]); $oldModerator = $room->players()->create([ 'user_id' => $owner->id, 'name' => 'Nico', 'is_moderator' => true, 'last_seen_at' => now()->subHour(), ]); $guest = $room->players()->create([ 'name' => 'Anna', 'last_seen_at' => now(), ]); app()->call([new RemoveAbsentPlayers, 'handle']); $this->assertModelMissing($oldModerator); $this->assertModelExists($room); expect($guest->fresh()->is_moderator)->toBeFalse(); $this->actingAs($owner) ->withSession(["room_players.{$room->id}" => $oldModerator->id]) ->post(route('room.code'), [ 'join_code' => $room->join_code, 'player_name' => 'Nico', ]) ->assertRedirect(route('room.show', $room)); $newModerator = $room->players()->where('user_id', $owner->id)->sole(); expect($newModerator->id)->not->toBe($oldModerator->id) ->and($newModerator->is_moderator)->toBeTrue(); });