'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('an inactive guest is sent through join and reuses the existing player', 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]; $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', 'Returning guest')); $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($player->fresh()->inactive_at)->toBeNull(); }); 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); });