Files
Twyco 4dfe79ce5c
CI/CD / test_and_check_app (push) Successful in 2m19s
CI/CD / Publish immutable image (push) Successful in 1m16s
kick forever
2026-09-24 14:38:18 +02:00

416 lines
14 KiB
PHP

<?php
use App\Enums\RoomStateChange;
use App\Events\RoomStateChanged;
use App\Jobs\CloseInactiveRooms;
use App\Models\Deck;
use App\Models\Room;
use App\Models\User;
use App\Models\Vote;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
use Inertia\Testing\AssertableInertia as Assert;
function createLifecycleRoom(array $attributes = []): Room
{
return Room::create([
'title' => 'Sprint Planning',
'deck_config' => [
'name' => 'Fibonacci',
'cards' => [
['key' => '3', 'label' => '3'],
['key' => '5', 'label' => '5'],
],
],
...$attributes,
]);
}
function createSystemDeck(): Deck
{
return Deck::query()->forceCreate([
'name' => 'Fibonacci',
'cards' => [
['key' => '3', 'label' => '3'],
['key' => '5', 'label' => '5'],
],
'is_system' => true,
]);
}
test('an authenticated room creator owns the room and sees it on the dashboard', function () {
$user = User::factory()->create();
$deck = createSystemDeck();
$this->actingAs($user)
->post(route('room.store'), [
'title' => 'Team Alpha',
'player_name' => 'Eric',
'deck_id' => $deck->id,
])
->assertRedirect();
$room = Room::query()->sole();
expect($room->owner_id)->toBe($user->id)
->and($room->last_activity_at)->not->toBeNull();
$this->assertDatabaseHas('players', [
'room_id' => $room->id,
'user_id' => $user->id,
'is_moderator' => true,
]);
$this->get(route('dashboard'))
->assertInertia(fn (Assert $page) => $page
->component('Dashboard')
->has('rooms', 1)
->where('rooms.0.id', $room->id)
->where('rooms.0.title', 'Team Alpha'));
});
test('a guest creator receives the one-time account notice', function () {
$deck = createSystemDeck();
$this->post(route('room.store'), [
'title' => 'Guest room',
'player_name' => 'Guest',
'deck_id' => $deck->id,
])->assertRedirect();
$room = Room::query()->sole();
$this->get(route('room.show', $room))
->assertInertia(fn (Assert $page) => $page
->where('roomNotice.guest_retention_days', 14)
->where('roomNotice.account_retention_days', 30));
$this->get(route('room.show', $room))
->assertInertia(fn (Assert $page) => $page->where('roomNotice', null));
});
test('guest rooms from the current browser are claimed after login', function () {
$user = User::factory()->create([
'email' => 'eric@example.com',
]);
$room = createLifecycleRoom();
$moderator = $room->players()->create([
'name' => 'Eric als Gast',
'is_moderator' => true,
]);
$this->withSession(["room_players.{$room->id}" => $moderator->id])
->post(route('login.store'), [
'email' => 'eric@example.com',
'password' => 'password',
])
->assertRedirect();
expect($room->fresh()->owner_id)->toBe($user->id)
->and($moderator->fresh()->user_id)->toBe($user->id);
});
test('joined rooms are not listed as owned rooms', function () {
$user = User::factory()->create();
$owner = User::factory()->create();
$ownedRoom = createLifecycleRoom(['owner_id' => $user->id, 'title' => 'Owned']);
$joinedRoom = createLifecycleRoom(['owner_id' => $owner->id, 'title' => 'Joined']);
$ownedRoom->players()->create([
'user_id' => $user->id,
'name' => 'Owner',
'is_moderator' => true,
]);
$joinedRoom->players()->create([
'user_id' => $user->id,
'name' => 'Participant',
'is_moderator' => false,
]);
$this->actingAs($user)
->get(route('dashboard'))
->assertInertia(fn (Assert $page) => $page
->has('rooms', 1)
->where('rooms.0.id', $ownedRoom->id));
});
test('an authenticated player rejoins with the existing room identity', function () {
$user = User::factory()->create();
$room = createLifecycleRoom();
$player = $room->players()->create([
'user_id' => $user->id,
'name' => 'Room nickname',
'is_moderator' => false,
]);
$room->update(['last_activity_at' => now()->subDay()]);
$previousActivity = $room->last_activity_at;
$response = $this->actingAs($user)
->post(route('room.code'), [
'join_code' => $room->join_code,
'player_name' => 'Ignored new name',
])
->assertRedirect(route('room.show', $room));
expect($room->players()->count())->toBe(1)
->and($player->fresh()->name)->toBe('Room nickname')
->and($room->fresh()->last_activity_at->greaterThan($previousActivity))->toBeTrue();
$response->assertSessionHas("room_players.{$room->id}", $player->id);
});
test('a new account instance takes over the player during a regular room join', function () {
$user = User::factory()->create();
$room = createLifecycleRoom();
$player = $room->players()->create([
'user_id' => $user->id,
'name' => 'Room nickname',
'active_instance_id' => '019b8f80-37c4-71a3-b415-5f2697332c16',
]);
Event::fake([RoomStateChanged::class]);
$this->actingAs($user)
->withSession([
'room_player_instance_id' => '019b8f80-37c4-71a3-b415-5f2697332c17',
])
->post(route('room.code'), [
'join_code' => $room->join_code,
'player_name' => 'Room nickname',
])
->assertRedirect(route('room.show', $room));
expect($player->fresh()->active_instance_id)
->toBe('019b8f80-37c4-71a3-b415-5f2697332c17');
Event::assertDispatched(
RoomStateChanged::class,
fn (RoomStateChanged $event): bool => $event->change === RoomStateChange::PlayerInstanceReplaced
&& $event->affectedPlayerId === $player->id,
);
});
test('an authenticated player can restore room access without the previous session', function () {
$user = User::factory()->create();
$room = createLifecycleRoom(['owner_id' => $user->id]);
$moderator = $room->players()->create([
'user_id' => $user->id,
'name' => 'Eric',
'is_moderator' => true,
]);
$this->actingAs($user)
->get(route('room.show', $room))
->assertOk()
->assertSessionHas("room_players.{$room->id}", $moderator->id);
});
test('an active guest with a room session skips the join form', function () {
$room = createLifecycleRoom();
$player = $room->players()->create([
'name' => 'Guest',
'last_seen_at' => now(),
]);
$this->withSession(["room_players.{$room->id}" => $player->id])
->get(route('room.join', ['join_code' => $room->join_code]))
->assertRedirect(route('room.show', $room));
});
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),
]);
$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]));
$this->withSession($session)
->get(route('room.join', ['join_code' => $room->join_code]))
->assertInertia(fn (Assert $page) => $page
->component('rooms/Join')
->where('defaultPlayerName', null));
$this->withSession($session)
->post(route('room.code'), [
'join_code' => $room->join_code,
'player_name' => 'Returning guest',
])
->assertRedirect(route('room.show', $room));
expect($room->players()->count())->toBe(1)
->and($room->players()->sole()->id)->not->toBe($player->id);
});
test('showing or polling a room does not count as room activity', function () {
$room = createLifecycleRoom(['last_activity_at' => now()->subDays(5)]);
$moderator = $room->players()->create([
'name' => 'Eric',
'is_moderator' => true,
]);
$activity = $room->last_activity_at;
$this->withSession(["room_players.{$room->id}" => $moderator->id])
->get(route('room.show', $room))
->assertOk();
expect($room->fresh()->last_activity_at->equalTo($activity))->toBeTrue();
});
test('meaningful game actions update the explicit activity timestamp', function () {
Carbon::setTestNow('2026-08-16 12:00:00');
$room = createLifecycleRoom(['last_activity_at' => now()->subDay()]);
$moderator = $room->players()->create([
'name' => 'Eric',
'is_moderator' => true,
]);
$this->withSession(["room_players.{$room->id}" => $moderator->id])
->post(route('room.vote.store', $room), ['value' => '5'])
->assertNoContent();
expect($room->fresh()->last_activity_at->equalTo(now()))->toBeTrue();
});
test('the cleanup job closes expired guest and account rooms only once', function () {
Carbon::setTestNow('2026-08-16 12:00:00');
$owner = User::factory()->create();
$expiredGuest = createLifecycleRoom(['last_activity_at' => now()->subDays(14)]);
$freshGuest = createLifecycleRoom(['last_activity_at' => now()->subDays(14)->addSecond()]);
$expiredAccount = createLifecycleRoom([
'owner_id' => $owner->id,
'last_activity_at' => now()->subDays(30),
]);
$freshAccount = createLifecycleRoom([
'owner_id' => $owner->id,
'last_activity_at' => now()->subDays(30)->addSecond(),
]);
$alreadyClosed = createLifecycleRoom([
'last_activity_at' => now()->subDays(40),
'closed_at' => now()->subDay(),
'state_version' => 7,
]);
$job = new CloseInactiveRooms;
$job->handle();
$job->handle();
expect($expiredGuest->fresh()->closed_at)->not->toBeNull()
->and($expiredGuest->fresh()->state_version)->toBe(2)
->and($expiredAccount->fresh()->closed_at)->not->toBeNull()
->and($freshGuest->fresh()->closed_at)->toBeNull()
->and($freshAccount->fresh()->closed_at)->toBeNull()
->and($alreadyClosed->fresh()->state_version)->toBe(7);
});
test('closed rooms reject joins and participant actions', function () {
$room = createLifecycleRoom(['closed_at' => now()]);
$moderator = $room->players()->create([
'name' => 'Eric',
'is_moderator' => true,
]);
$this->post(route('room.code'), [
'join_code' => $room->join_code,
'player_name' => 'Anna',
])->assertSessionHasErrors('join_code');
$this->get(route('room.join', ['join_code' => $room->join_code]))
->assertInertia(fn (Assert $page) => $page
->component('rooms/Join')
->where('closedRoom.join_code', $room->join_code));
$this->withSession(["room_players.{$room->id}" => $moderator->id])
->post(route('room.vote.store', $room), ['value' => '5'])
->assertRedirect(route('room.join', ['join_code' => $room->join_code]));
expect(Vote::query()->count())->toBe(0);
});
test('only the owner can reopen a room into a clean new round', function () {
$owner = User::factory()->create();
$otherUser = User::factory()->create();
$room = createLifecycleRoom([
'owner_id' => $owner->id,
'current_round' => 3,
'revealed_at' => now()->subDay(),
'closed_at' => now(),
'last_activity_at' => now()->subDays(2),
]);
$moderator = $room->players()->create([
'user_id' => $owner->id,
'name' => 'Owner',
'is_moderator' => true,
]);
$vote = $moderator->votes()->create([
'room_id' => $room->id,
'round' => 3,
'value' => '5',
]);
$joinCode = $room->join_code;
$this->actingAs($otherUser)
->post(route('room.reopen', $room))
->assertForbidden();
$this->actingAs($owner)
->post(route('room.reopen', $room))
->assertRedirect(route('room.show', $room));
$room->refresh();
expect($room->current_round)->toBe(4)
->and($room->revealed_at)->toBeNull()
->and($room->closed_at)->toBeNull()
->and($room->last_activity_at->isToday())->toBeTrue()
->and($room->join_code)->toBe($joinCode)
->and($vote->fresh())->not->toBeNull()
->and($room->votes()->where('round', 4)->exists())->toBeFalse();
});
test('only the owner can delete a room', function () {
$owner = User::factory()->create();
$otherUser = User::factory()->create();
$room = createLifecycleRoom(['owner_id' => $owner->id]);
$room->players()->create([
'user_id' => $owner->id,
'name' => 'Owner',
'is_moderator' => true,
]);
$this->actingAs($otherUser)
->delete(route('room.destroy', $room))
->assertForbidden();
$this->actingAs($owner)
->delete(route('room.destroy', $room))
->assertRedirect(route('dashboard'));
$this->assertSoftDeleted('rooms', ['id' => $room->id]);
$this->get(route('dashboard'))
->assertInertia(fn (Assert $page) => $page->has('rooms', 0));
});
test('participants are redirected when an open room is deleted', function () {
$room = createLifecycleRoom();
$player = $room->players()->create([
'name' => 'Participant',
'is_moderator' => false,
]);
$sessionKey = "room_players.{$room->id}";
$room->delete();
$this->withSession([$sessionKey => $player->id])
->get(route('room.show', $room))
->assertRedirect(route('home'))
->assertSessionMissing($sessionKey);
});