Compare commits

1 Commits
Author SHA1 Message Date
Twyco 4dfe79ce5c kick forever
CI/CD / test_and_check_app (push) Successful in 2m19s
CI/CD / Publish immutable image (push) Successful in 1m16s
2026-09-24 14:38:18 +02:00
22 changed files with 271 additions and 113 deletions
@@ -32,7 +32,6 @@ class RoomJoinController extends Controller
$room !== null $room !== null
&& ! $room->isClosed() && ! $room->isClosed()
&& $existingPlayer instanceof Player && $existingPlayer instanceof Player
&& $existingPlayer->inactive_at === null
) { ) {
return to_route('room.show', $room); return to_route('room.show', $room);
} }
@@ -113,12 +113,6 @@ class EnsureRoomParticipant
]); ]);
} }
if ($player->inactive_at !== null) {
return to_route('room.join', [
'join_code' => $room->join_code,
]);
}
$player->markAsSeen(); $player->markAsSeen();
$request->attributes->set('currentPlayer', $player); $request->attributes->set('currentPlayer', $player);
+1 -1
View File
@@ -48,7 +48,7 @@ class KickRoomPlayerRequest extends FormRequest
return null; return null;
} }
return $room->activePlayers()->find((int) $playerId); return $room->players()->find((int) $playerId);
} }
/** /**
+2 -3
View File
@@ -18,9 +18,8 @@ class DashboardRoomResource extends JsonResource
'join_code' => $this->resource->join_code, 'join_code' => $this->resource->join_code,
'current_round' => $this->resource->current_round, 'current_round' => $this->resource->current_round,
'players_count' => $this->resource->players_count, 'players_count' => $this->resource->players_count,
'active_player_count' => $this->resource->active_player_count, 'players' => $this->resource->relationLoaded('players')
'players' => $this->resource->relationLoaded('activePlayers') ? $this->resource->players
? $this->resource->activePlayers
->map(fn ($player): array => [ ->map(fn ($player): array => [
'id' => $player->id, 'id' => $player->id,
'name' => $player->name, 'name' => $player->name,
-22
View File
@@ -1,22 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\Player;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class MarkInactivePlayers implements ShouldQueue
{
use Queueable;
public function handle(): void
{
Player::query()
->whereNull('inactive_at')
->where('last_seen_at', '<=', now()->subHour())
->update([
'inactive_at' => now(),
]);
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace App\Jobs;
use App\Enums\RoomStateChange;
use App\Events\RoomStateChanged;
use App\Models\Player;
use App\Models\Room;
use App\Services\Rooms\RoomRealtimeStateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
class RemoveAbsentPlayers implements ShouldQueue
{
use Queueable;
public function handle(RoomRealtimeStateService $realtimeState): void
{
$cutoff = now()->subHour();
Player::query()
->where(function ($query) use ($cutoff): void {
$query->where('last_seen_at', '<=', $cutoff)
->orWhere(function ($query) use ($cutoff): void {
$query->whereNull('last_seen_at')
->where('created_at', '<=', $cutoff);
});
})
->select(['id', 'room_id'])
->chunkById(100, function ($players) use ($cutoff, $realtimeState): void {
foreach ($players as $player) {
$room = DB::transaction(function () use ($player, $cutoff): ?Room {
$room = Room::withTrashed()
->lockForUpdate()
->find($player->room_id);
if ($room === null) {
return null;
}
$currentPlayer = $room->players()
->lockForUpdate()
->find($player->id);
if ($currentPlayer === null) {
return null;
}
$lastActivity = $currentPlayer->last_seen_at ?? $currentPlayer->created_at;
if ($lastActivity === null || $lastActivity->greaterThan($cutoff)) {
return null;
}
$currentPlayer->delete();
$room->update([
'state_version' => $room->state_version + 1,
]);
return $room;
});
if ($room !== null && ! $room->trashed()) {
RoomStateChanged::dispatch(
$room->id,
$room->state_version,
RoomStateChange::PlayerKicked,
$realtimeState->for($room),
$player->id,
);
}
}
});
}
}
-14
View File
@@ -2,7 +2,6 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -16,7 +15,6 @@ use Illuminate\Support\Carbon;
* @property string $name * @property string $name
* @property bool $is_moderator * @property bool $is_moderator
* @property Carbon|null $last_seen_at * @property Carbon|null $last_seen_at
* @property Carbon|null $inactive_at
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property-read Room $room * @property-read Room $room
@@ -30,7 +28,6 @@ class Player extends Model
'name', 'name',
'is_moderator', 'is_moderator',
'last_seen_at', 'last_seen_at',
'inactive_at',
]; ];
protected function casts(): array protected function casts(): array
@@ -38,7 +35,6 @@ class Player extends Model
return [ return [
'is_moderator' => 'boolean', 'is_moderator' => 'boolean',
'last_seen_at' => 'datetime', 'last_seen_at' => 'datetime',
'inactive_at' => 'datetime',
]; ];
} }
@@ -85,14 +81,4 @@ class Player extends Model
'last_seen_at' => now(), 'last_seen_at' => now(),
]); ]);
} }
/**
* @param Builder<Player> $query
* @return Builder<Player>
*/
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('inactive_at');
}
} }
-6
View File
@@ -93,12 +93,6 @@ class Room extends Model
} }
/** @return HasMany<Player, $this> */
public function activePlayers(): HasMany
{
return $this->hasMany(Player::class)->whereNull('inactive_at');
}
/** @return BelongsTo<User, $this> */ /** @return BelongsTo<User, $this> */
public function owner(): BelongsTo public function owner(): BelongsTo
{ {
+1 -3
View File
@@ -16,13 +16,12 @@ class DashboardRoomService
$ownedRooms = $user $ownedRooms = $user
->ownedRooms() ->ownedRooms()
->with([ ->with([
'activePlayers' => fn ($query) => $query 'players' => fn ($query) => $query
->select(['id', 'room_id', 'user_id', 'name', 'is_moderator', 'created_at']) ->select(['id', 'room_id', 'user_id', 'name', 'is_moderator', 'created_at'])
->orderByDesc('is_moderator') ->orderByDesc('is_moderator')
->orderBy('created_at'), ->orderBy('created_at'),
]) ])
->withCount('players') ->withCount('players')
->withCount(['activePlayers as active_player_count'])
->orderByRaw('closed_at IS NOT NULL') ->orderByRaw('closed_at IS NOT NULL')
->orderByDesc('last_activity_at') ->orderByDesc('last_activity_at')
->get(); ->get();
@@ -30,7 +29,6 @@ class DashboardRoomService
$favoriteRooms = $user $favoriteRooms = $user
->favoriteRooms() ->favoriteRooms()
->withCount('players') ->withCount('players')
->withCount(['activePlayers as active_player_count'])
->orderByRaw('closed_at IS NOT NULL') ->orderByRaw('closed_at IS NOT NULL')
->orderByDesc('last_activity_at') ->orderByDesc('last_activity_at')
->get(); ->get();
-1
View File
@@ -20,7 +20,6 @@ class RoomPageService
$room->load([ $room->load([
'players' => fn ($query) => $query 'players' => fn ($query) => $query
->active()
->select(['id', 'room_id', 'user_id', 'name', 'is_moderator', 'created_at']) ->select(['id', 'room_id', 'user_id', 'name', 'is_moderator', 'created_at'])
->orderByDesc('is_moderator') ->orderByDesc('is_moderator')
->orderBy('created_at'), ->orderBy('created_at'),
@@ -80,7 +80,6 @@ class RoomParticipationService
} else { } else {
$player->update([ $player->update([
'last_seen_at' => now(), 'last_seen_at' => now(),
'inactive_at' => null,
]); ]);
} }
@@ -150,16 +149,13 @@ class RoomParticipationService
]); ]);
$change = RoomStateChange::PlayerJoined; $change = RoomStateChange::PlayerJoined;
} else { } else {
if ($player->inactive_at !== null) { if ($player->name !== $playerName) {
$change = RoomStateChange::PlayerJoined;
} elseif ($player->name !== $playerName) {
$change = RoomStateChange::PlayerUpdated; $change = RoomStateChange::PlayerUpdated;
} }
$updates = [ $updates = [
'name' => $playerName, 'name' => $playerName,
'last_seen_at' => now(), 'last_seen_at' => now(),
'inactive_at' => null,
]; ];
if ( if (
@@ -282,14 +278,14 @@ class RoomParticipationService
?User $user, ?User $user,
?Player $currentPlayer, ?Player $currentPlayer,
): void { ): void {
$stateVersion = DB::transaction(function () use ($room, $player, $user, $currentPlayer): ?int { $stateVersion = DB::transaction(function () use ($room, $player, $user, $currentPlayer): int {
$lockedRoom = Room::query() $lockedRoom = Room::query()
->lockForUpdate() ->lockForUpdate()
->findOrFail($room->id); ->findOrFail($room->id);
$lockedCurrentPlayer = $currentPlayer === null $lockedCurrentPlayer = $currentPlayer === null
? null ? null
: $lockedRoom->activePlayers() : $lockedRoom->players()
->lockForUpdate() ->lockForUpdate()
->find($currentPlayer->id); ->find($currentPlayer->id);
$isOwner = $user !== null && $lockedRoom->owner_id === $user->id; $isOwner = $user !== null && $lockedRoom->owner_id === $user->id;
@@ -312,13 +308,7 @@ class RoomParticipationService
403, 403,
); );
if ($lockedPlayer->inactive_at !== null) { $lockedPlayer->delete();
return null;
}
$lockedPlayer->update([
'inactive_at' => now(),
]);
$lockedRoom->update([ $lockedRoom->update([
'last_activity_at' => now(), 'last_activity_at' => now(),
'state_version' => $lockedRoom->state_version + 1, 'state_version' => $lockedRoom->state_version + 1,
@@ -327,10 +317,6 @@ class RoomParticipationService
return $lockedRoom->state_version; return $lockedRoom->state_version;
}); });
if ($stateVersion === null) {
return;
}
RoomStateChanged::dispatch( RoomStateChanged::dispatch(
$room->id, $room->id,
$stateVersion, $stateVersion,
@@ -32,7 +32,6 @@ class RoomRealtimeStateService
$room->refresh(); $room->refresh();
$room->load([ $room->load([
'players' => fn ($query) => $query 'players' => fn ($query) => $query
->active()
->select(['id', 'room_id', 'user_id', 'name', 'is_moderator', 'created_at']) ->select(['id', 'room_id', 'user_id', 'name', 'is_moderator', 'created_at'])
->orderByDesc('is_moderator') ->orderByDesc('is_moderator')
->orderBy('created_at'), ->orderBy('created_at'),
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$cutoff = now()->subHour();
DB::table('players')
->where(function ($query) use ($cutoff): void {
$query->whereNotNull('inactive_at')
->orWhere('last_seen_at', '<=', $cutoff)
->orWhere(function ($query) use ($cutoff): void {
$query->whereNull('last_seen_at')
->where('created_at', '<=', $cutoff);
});
})
->delete();
Schema::table('players', function (Blueprint $table): void {
$table->dropColumn('inactive_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('players', function (Blueprint $table): void {
$table->timestamp('inactive_at')->nullable()->after('last_seen_at');
});
}
};
@@ -47,8 +47,7 @@ function formatDate(value: string | null): string {
<div class="mt-5 grid grid-cols-2 gap-3 text-sm text-muted-foreground"> <div class="mt-5 grid grid-cols-2 gap-3 text-sm text-muted-foreground">
<span class="inline-flex items-center gap-2"> <span class="inline-flex items-center gap-2">
<Users class="size-4" /> <Users class="size-4" />
{{ room.players_count }} Spieler / {{ room.players_count }} Spieler
{{ room.active_player_count }} aktiv
</span> </span>
<span>Runde {{ room.current_round }}</span> <span>Runde {{ room.current_round }}</span>
<span class="col-span-2 inline-flex items-center gap-2"> <span class="col-span-2 inline-flex items-center gap-2">
+1 -5
View File
@@ -85,11 +85,7 @@ defineProps<{
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Label for="remember" class="flex items-center space-x-3"> <Label for="remember" class="flex items-center space-x-3">
<Checkbox <Checkbox id="remember" name="remember" :tabindex="3" />
id="remember"
name="remember"
:tabindex="3"
/>
<span>Remember me</span> <span>Remember me</span>
</Label> </Label>
</div> </div>
-1
View File
@@ -11,7 +11,6 @@ export type DashboardRoom = {
join_code: string; join_code: string;
current_round: number; current_round: number;
players_count: number; players_count: number;
active_player_count: number;
players: DashboardRoomPlayer[]; players: DashboardRoomPlayer[];
last_activity_at: string | null; last_activity_at: string | null;
closed_at: string | null; closed_at: string | null;
+2 -2
View File
@@ -1,7 +1,7 @@
<?php <?php
use App\Jobs\CloseInactiveRooms; use App\Jobs\CloseInactiveRooms;
use App\Jobs\MarkInactivePlayers; use App\Jobs\RemoveAbsentPlayers;
use Illuminate\Foundation\DevCommands; use Illuminate\Foundation\DevCommands;
use Illuminate\Support\Facades\Schedule; use Illuminate\Support\Facades\Schedule;
@@ -11,6 +11,6 @@ Schedule::job(new CloseInactiveRooms)
->weeklyOn(1, '02:00') ->weeklyOn(1, '02:00')
->withoutOverlapping(); ->withoutOverlapping();
Schedule::job(new MarkInactivePlayers) Schedule::job(new RemoveAbsentPlayers)
->everyFiveMinutes() ->everyFiveMinutes()
->withoutOverlapping(); ->withoutOverlapping();
+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(); $room = createEmbeddedRoom();
$player = $room->players()->create([ $player = $room->players()->create([
'name' => 'Old name', 'name' => 'Old name',
'inactive_at' => now(),
]); ]);
$this->withSession(["room_players.{$room->id}" => $player->id]) $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(); ->assertOk();
expect($room->players()->count())->toBe(1) expect($room->players()->count())->toBe(1)
->and($player->fresh()->name)->toBe('New name') ->and($player->fresh()->name)->toBe('New name');
->and($player->fresh()->inactive_at)->toBeNull();
}); });
test('an authenticated user automatically joins the embedded room', function () { 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([ $player = $room->players()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'name' => 'Old name', 'name' => 'Old name',
'inactive_at' => now(),
]); ]);
$this->actingAs($user) $this->actingAs($user)
@@ -103,8 +100,7 @@ test('embedded access reuses the user player and applies a changed display name'
->assertOk(); ->assertOk();
expect($room->players()->count())->toBe(1) expect($room->players()->count())->toBe(1)
->and($player->fresh()->name)->toBe('New name') ->and($player->fresh()->name)->toBe('New name');
->and($player->fresh()->inactive_at)->toBeNull();
}); });
test('an embedded guest keeps the same player after logging in separately', function () { 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', 'name' => 'Active player',
'last_seen_at' => now(), 'last_seen_at' => now(),
]); ]);
$room->players()->create([
'name' => 'Inactive player',
'last_seen_at' => now()->subHour(),
'inactive_at' => now(),
]);
$this->actingAs($user) $this->actingAs($user)
->post(route('room.favorite', $room)) ->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 ->assertInertia(fn (Assert $page) => $page
->has('favoriteRooms', 1) ->has('favoriteRooms', 1)
->where('favoriteRooms.0.id', $room->id) ->where('favoriteRooms.0.id', $room->id)
->where('favoriteRooms.0.players_count', 2) ->where('favoriteRooms.0.players_count', 1));
->where('favoriteRooms.0.active_player_count', 1));
}); });
test('the room resource exposes favorite and owner state', function () { 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)); ->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(); $room = createLifecycleRoom();
$player = $room->players()->create([ $player = $room->players()->create([
'name' => 'Returning guest', 'name' => 'Returning guest',
'last_seen_at' => now()->subHours(2), 'last_seen_at' => now()->subHours(2),
'inactive_at' => now()->subHour(),
]); ]);
$session = ["room_players.{$room->id}" => $player->id]; $session = ["room_players.{$room->id}" => $player->id];
$player->delete();
$this->withSession($session) $this->withSession($session)
->get(route('room.show', $room)) ->get(route('room.show', $room))
->assertRedirect(route('room.join', ['join_code' => $room->join_code])); ->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])) ->get(route('room.join', ['join_code' => $room->join_code]))
->assertInertia(fn (Assert $page) => $page ->assertInertia(fn (Assert $page) => $page
->component('rooms/Join') ->component('rooms/Join')
->where('defaultPlayerName', 'Returning guest')); ->where('defaultPlayerName', null));
$this->withSession($session) $this->withSession($session)
->post(route('room.code'), [ ->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)); ->assertRedirect(route('room.show', $room));
expect($room->players()->count())->toBe(1) 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 () { 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); $room = createRoomForPlayerKick($owner);
$moderator = addPlayerToKickRoom($room, 'Moderator', $owner, true); $moderator = addPlayerToKickRoom($room, 'Moderator', $owner, true);
$participant = addPlayerToKickRoom($room, 'Anna'); $participant = addPlayerToKickRoom($room, 'Anna');
addPlayerToKickRoom($room, 'Inactive')->update(['inactive_at' => now()]);
$this->actingAs($owner) $this->actingAs($owner)
->get(route('dashboard')) ->get(route('dashboard'))
@@ -77,8 +76,8 @@ test('the room owner can kick an active participant', function () {
->delete(route('room.player.destroy', [$room, $participant])) ->delete(route('room.player.destroy', [$room, $participant]))
->assertRedirect(); ->assertRedirect();
expect($participant->fresh()->inactive_at)->not->toBeNull() $this->assertModelMissing($participant);
->and($room->fresh()->state_version)->toBe($previousVersion + 1); expect($room->fresh()->state_version)->toBe($previousVersion + 1);
Event::assertDispatched( Event::assertDispatched(
RoomStateChanged::class, RoomStateChanged::class,
@@ -104,6 +103,36 @@ test('the room owner can kick an active participant', function () {
Event::assertDispatchedTimes(RoomStateChanged::class, 1); 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 () { test('the room moderator can kick an active participant without shared management', function () {
$owner = User::factory()->create(); $owner = User::factory()->create();
$room = createRoomForPlayerKick($owner, ['owner_id' => null]); $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])) ->delete(route('room.player.destroy', [$room, $target]))
->assertRedirect(); ->assertRedirect();
expect($target->fresh()->inactive_at)->not->toBeNull(); $this->assertModelMissing($target);
Event::assertDispatchedTimes(RoomStateChanged::class, 1); 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])) ->delete(route('room.player.destroy', [$room, $target]))
->assertForbidden(); ->assertForbidden();
expect($target->fresh()->inactive_at)->toBeNull(); $this->assertModelExists($target);
Event::assertNotDispatched(RoomStateChanged::class); 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])) ->delete(route('room.player.destroy', [$room, $target]))
->assertRedirect(); ->assertRedirect();
expect($target->fresh()->inactive_at)->not->toBeNull(); $this->assertModelMissing($target);
Event::assertDispatched(RoomStateChanged::class); 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])) ->delete(route('room.player.destroy', [$room, $participant]))
->assertForbidden(); ->assertForbidden();
expect($participant->fresh()->inactive_at)->toBeNull(); $this->assertModelExists($participant);
Event::assertNotDispatched(RoomStateChanged::class); 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])) $this->delete(route('room.player.destroy', [$room, $target]))
->assertForbidden(); ->assertForbidden();
expect($target->fresh()->inactive_at)->toBeNull(); $this->assertModelExists($target);
Event::assertNotDispatched(RoomStateChanged::class); 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])) ->delete(route('room.player.destroy', [$room, $moderator]))
->assertForbidden(); ->assertForbidden();
expect($moderator->fresh()->inactive_at)->toBeNull(); $this->assertModelExists($moderator);
Event::assertNotDispatched(RoomStateChanged::class); 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])) ->delete(route('room.player.destroy', [$room, $target]))
->assertForbidden(); ->assertForbidden();
expect($target->fresh()->inactive_at)->toBeNull(); $this->assertModelExists($target);
Event::assertNotDispatched(RoomStateChanged::class); Event::assertNotDispatched(RoomStateChanged::class);
}); });