Handle authenticated room instance takeover
CI/CD / test_and_check_app (push) Failing after 2m7s
CI/CD / Publish immutable image (push) Skipped

This commit is contained in:
2026-09-10 16:33:13 +02:00
parent 2ab95cebdf
commit 640a2970c3
18 changed files with 351 additions and 22 deletions
+1
View File
@@ -7,6 +7,7 @@ enum RoomStateChange: string
case PlayerJoined = 'player_joined'; case PlayerJoined = 'player_joined';
case PlayerUpdated = 'player_updated'; case PlayerUpdated = 'player_updated';
case PlayerKicked = 'player_kicked'; case PlayerKicked = 'player_kicked';
case PlayerInstanceReplaced = 'player_instance_replaced';
case VoteChanged = 'vote_changed'; case VoteChanged = 'vote_changed';
case CardsRevealed = 'cards_revealed'; case CardsRevealed = 'cards_revealed';
case VotesReset = 'votes_reset'; case VotesReset = 'votes_reset';
+6 -1
View File
@@ -22,6 +22,7 @@ class RoomStateChanged implements ShouldBroadcastNow
public readonly RoomStateChange $change, public readonly RoomStateChange $change,
public readonly ?array $state = null, public readonly ?array $state = null,
public readonly ?int $affectedPlayerId = null, public readonly ?int $affectedPlayerId = null,
public readonly ?string $affectedInstanceId = null,
) {} ) {}
public function broadcastOn(): Channel public function broadcastOn(): Channel
@@ -35,7 +36,7 @@ class RoomStateChanged implements ShouldBroadcastNow
} }
/** /**
* @return array{version: int, change: string, state: array<string, mixed>|null, affected_player_id?: int} * @return array{version: int, change: string, state: array<string, mixed>|null, affected_player_id?: int, affected_instance_id?: string}
*/ */
public function broadcastWith(): array public function broadcastWith(): array
{ {
@@ -49,6 +50,10 @@ class RoomStateChanged implements ShouldBroadcastNow
$payload['affected_player_id'] = $this->affectedPlayerId; $payload['affected_player_id'] = $this->affectedPlayerId;
} }
if ($this->affectedInstanceId !== null) {
$payload['affected_instance_id'] = $this->affectedInstanceId;
}
return $payload; return $payload;
} }
} }
@@ -8,6 +8,7 @@ use App\Http\Resources\RoomResource;
use App\Models\Room; use App\Models\Room;
use App\Services\Rooms\RoomPageService; use App\Services\Rooms\RoomPageService;
use App\Services\Rooms\RoomParticipationService; use App\Services\Rooms\RoomParticipationService;
use App\Services\Rooms\RoomPlayerInstanceService;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -16,6 +17,7 @@ class EmbeddedRoomController extends Controller
public function __construct( public function __construct(
private readonly RoomParticipationService $roomParticipation, private readonly RoomParticipationService $roomParticipation,
private readonly RoomPageService $roomPage, private readonly RoomPageService $roomPage,
private readonly RoomPlayerInstanceService $playerInstances,
) {} ) {}
public function __invoke(ShowEmbeddedRoomRequest $request): Response public function __invoke(ShowEmbeddedRoomRequest $request): Response
@@ -33,6 +35,11 @@ class EmbeddedRoomController extends Controller
$validated['player_name'], $validated['player_name'],
$request->session()->get("room_players.{$room->id}"), $request->session()->get("room_players.{$room->id}"),
); );
$player = $this->roomParticipation->activateInstance(
$room,
$player,
$this->playerInstances->for($request),
);
$request->session()->put("room_players.{$room->id}", $player->id); $request->session()->put("room_players.{$room->id}", $player->id);
+5
View File
@@ -10,6 +10,7 @@ use App\Models\Player;
use App\Models\Room; use App\Models\Room;
use App\Services\Rooms\RoomCreationService; use App\Services\Rooms\RoomCreationService;
use App\Services\Rooms\RoomPageService; use App\Services\Rooms\RoomPageService;
use App\Services\Rooms\RoomPlayerInstanceService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
@@ -20,6 +21,7 @@ class RoomController extends Controller
public function __construct( public function __construct(
private readonly RoomCreationService $roomCreation, private readonly RoomCreationService $roomCreation,
private readonly RoomPageService $roomPage, private readonly RoomPageService $roomPage,
private readonly RoomPlayerInstanceService $playerInstances,
) {} ) {}
public function create(Request $request): Response public function create(Request $request): Response
@@ -40,6 +42,9 @@ class RoomController extends Controller
$validated, $validated,
$request->user(), $request->user(),
); );
$player->update([
'active_instance_id' => $this->playerInstances->for($request),
]);
$request->session()->put( $request->session()->put(
"room_players.{$room->id}", "room_players.{$room->id}",
@@ -4,12 +4,14 @@ namespace App\Http\Controllers;
use App\Http\Requests\JoinRoomRequest; use App\Http\Requests\JoinRoomRequest;
use App\Services\Rooms\RoomParticipationService; use App\Services\Rooms\RoomParticipationService;
use App\Services\Rooms\RoomPlayerInstanceService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
class RoomPlayerController extends Controller class RoomPlayerController extends Controller
{ {
public function __construct( public function __construct(
private readonly RoomParticipationService $roomParticipation, private readonly RoomParticipationService $roomParticipation,
private readonly RoomPlayerInstanceService $playerInstances,
) {} ) {}
public function join(JoinRoomRequest $request): RedirectResponse public function join(JoinRoomRequest $request): RedirectResponse
@@ -23,6 +25,11 @@ class RoomPlayerController extends Controller
$request->user(), $request->user(),
is_array($sessionPlayerIds) ? $sessionPlayerIds : [], is_array($sessionPlayerIds) ? $sessionPlayerIds : [],
); );
$player = $this->roomParticipation->activateInstance(
$room,
$player,
$this->playerInstances->for($request),
);
$request->session()->put( $request->session()->put(
"room_players.{$room->id}", "room_players.{$room->id}",
+44 -2
View File
@@ -3,6 +3,8 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\Room; use App\Models\Room;
use App\Services\Rooms\RoomParticipationService;
use App\Services\Rooms\RoomPlayerInstanceService;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
@@ -10,6 +12,11 @@ use Symfony\Component\HttpFoundation\Response;
class EnsureRoomParticipant class EnsureRoomParticipant
{ {
public function __construct(
private readonly RoomParticipationService $roomParticipation,
private readonly RoomPlayerInstanceService $playerInstances,
) {}
/** /**
* Handle an incoming request. * Handle an incoming request.
* *
@@ -44,13 +51,43 @@ class EnsureRoomParticipant
} }
$playerId = $request->session()->get($sessionKey); $playerId = $request->session()->get($sessionKey);
$hasSessionPlayer = is_numeric($playerId);
$instanceId = $this->playerInstances->for($request);
$player = is_numeric($playerId) $player = $hasSessionPlayer
? $room->players()->find((int) $playerId) ? $room->players()->find((int) $playerId)
: null; : null;
if ($player === null && $playerId) { if ($player === null && $hasSessionPlayer) {
$request->session()->forget($sessionKey); $request->session()->forget($sessionKey);
return to_route('room.join', [
'join_code' => $room->join_code,
])->withErrors([
'join_code' => 'Diese Spieler-Instanz ist nicht mehr aktiv.',
]);
}
if (
$player !== null
&& $player->active_instance_id !== null
&& $player->active_instance_id !== $instanceId
) {
$request->session()->forget($sessionKey);
return to_route('room.join', [
'join_code' => $room->join_code,
])->withErrors([
'join_code' => 'Dein Account ist diesem Raum in einer anderen Instanz beigetreten.',
]);
}
if ($player !== null) {
$player = $this->roomParticipation->activateInstance(
$room,
$player,
$instanceId,
);
} }
if ($player === null && $request->user() !== null) { if ($player === null && $request->user() !== null) {
@@ -59,6 +96,11 @@ class EnsureRoomParticipant
->first(); ->first();
if ($player !== null) { if ($player !== null) {
$player = $this->roomParticipation->activateInstance(
$room,
$player,
$instanceId,
);
$request->session()->put($sessionKey, $player->id); $request->session()->put($sessionKey, $player->id);
} }
} }
+19 -6
View File
@@ -13,6 +13,8 @@ use Inertia\Inertia;
class ShowEmbeddedRoomRequest extends FormRequest class ShowEmbeddedRoomRequest extends FormRequest
{ {
private const string PLAYER_NAME_TAKEN_MESSAGE = 'Dieser Anzeigename ist in dem Raum bereits vorhanden.';
/** /**
* Determine if the user is authorized to make this request. * Determine if the user is authorized to make this request.
*/ */
@@ -72,16 +74,18 @@ class ShowEmbeddedRoomRequest extends FormRequest
} }
$sessionPlayerId = $this->session()->get("room_players.{$room->id}"); $sessionPlayerId = $this->session()->get("room_players.{$room->id}");
$existingPlayer = is_numeric($sessionPlayerId) $existingPlayer = null;
? $room->players()->find((int) $sessionPlayerId)
: null;
if ($existingPlayer === null && $this->user() !== null) { if ($this->user() !== null) {
$existingPlayer = $room->players() $existingPlayer = $room->players()
->where('user_id', $this->user()->id) ->where('user_id', $this->user()->id)
->first(); ->first();
} }
if ($existingPlayer === null && is_numeric($sessionPlayerId)) {
$existingPlayer = $room->players()->find((int) $sessionPlayerId);
}
$nameIsTaken = Player::query() $nameIsTaken = Player::query()
->where('room_id', $room->id) ->where('room_id', $room->id)
->where('name', $this->input('player_name')) ->where('name', $this->input('player_name'))
@@ -94,7 +98,7 @@ class ShowEmbeddedRoomRequest extends FormRequest
if ($nameIsTaken) { if ($nameIsTaken) {
$validator->errors()->add( $validator->errors()->add(
'player_name', 'player_name',
'Dieser Anzeigename ist in dem Raum bereits vorhanden.', self::PLAYER_NAME_TAKEN_MESSAGE,
); );
} }
}, },
@@ -112,9 +116,18 @@ class ShowEmbeddedRoomRequest extends FormRequest
protected function failedValidation(Validator $validator): never protected function failedValidation(Validator $validator): never
{ {
$message = $validator->errors()->first();
$showLoginAction = $this->user() === null
&& $message === self::PLAYER_NAME_TAKEN_MESSAGE;
if ($showLoginAction) {
$this->session()->put('url.intended', $this->fullUrl());
}
throw new HttpResponseException( throw new HttpResponseException(
Inertia::render('embedded/Error', [ Inertia::render('embedded/Error', [
'message' => $validator->errors()->first(), 'message' => $message,
'showLoginAction' => $showLoginAction,
])->toResponse($this)->setStatusCode(422), ])->toResponse($this)->setStatusCode(422),
); );
} }
@@ -19,6 +19,7 @@ class CurrentPlayerResource extends JsonResource
return [ return [
'id' => $player->id, 'id' => $player->id,
'instance_id' => $player->active_instance_id,
'name' => $player->name, 'name' => $player->name,
'is_moderator' => $player->is_moderator, 'is_moderator' => $player->is_moderator,
'current_vote' => $pageData->currentVote, 'current_vote' => $pageData->currentVote,
+2
View File
@@ -12,6 +12,7 @@ use Illuminate\Support\Carbon;
* @property int $id * @property int $id
* @property string $room_id * @property string $room_id
* @property int|null $user_id * @property int|null $user_id
* @property string|null $active_instance_id
* @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
@@ -25,6 +26,7 @@ class Player extends Model
protected $fillable = [ protected $fillable = [
'room_id', 'room_id',
'user_id', 'user_id',
'active_instance_id',
'name', 'name',
'is_moderator', 'is_moderator',
'last_seen_at', 'last_seen_at',
@@ -205,6 +205,17 @@ class RoomParticipationService
?User $user, ?User $user,
mixed $sessionPlayerId, mixed $sessionPlayerId,
): ?Player { ): ?Player {
if ($user !== null) {
$userPlayer = $room->players()
->where('user_id', $user->id)
->lockForUpdate()
->first();
if ($userPlayer !== null) {
return $userPlayer;
}
}
if (is_numeric($sessionPlayerId)) { if (is_numeric($sessionPlayerId)) {
$sessionPlayer = $room->players() $sessionPlayer = $room->players()
->whereKey((int) $sessionPlayerId) ->whereKey((int) $sessionPlayerId)
@@ -216,14 +227,53 @@ class RoomParticipationService
} }
} }
if ($user === null) { return null;
return null; }
public function activateInstance(Room $room, Player $player, string $instanceId): Player
{
[$activePlayer, $replacedInstanceId, $stateVersion] = DB::transaction(function () use ($room, $player, $instanceId): array {
$lockedRoom = Room::query()
->lockForUpdate()
->findOrFail($room->id);
$lockedPlayer = $lockedRoom->players()
->lockForUpdate()
->findOrFail($player->id);
if ($lockedPlayer->active_instance_id === $instanceId) {
return [$lockedPlayer, null, null];
}
$replacedInstanceId = $lockedPlayer->active_instance_id;
$lockedPlayer->update([
'active_instance_id' => $instanceId,
'last_seen_at' => now(),
]);
if ($replacedInstanceId === null) {
return [$lockedPlayer, null, null];
}
$lockedRoom->update([
'last_activity_at' => now(),
'state_version' => $lockedRoom->state_version + 1,
]);
return [$lockedPlayer, $replacedInstanceId, $lockedRoom->state_version];
});
if (is_string($replacedInstanceId) && is_int($stateVersion)) {
RoomStateChanged::dispatch(
$room->id,
$stateVersion,
RoomStateChange::PlayerInstanceReplaced,
$this->realtimeState->for($room),
$activePlayer->id,
$replacedInstanceId,
);
} }
return $room->players() return $activePlayer;
->where('user_id', $user->id)
->lockForUpdate()
->first();
} }
public function kick( public function kick(
@@ -0,0 +1,25 @@
<?php
namespace App\Services\Rooms;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class RoomPlayerInstanceService
{
private const string SESSION_KEY = 'room_player_instance_id';
public function for(Request $request): string
{
$instanceId = $request->session()->get(self::SESSION_KEY);
if (is_string($instanceId) && Str::isUuid($instanceId)) {
return $instanceId;
}
$instanceId = (string) Str::uuid();
$request->session()->put(self::SESSION_KEY, $instanceId);
return $instanceId;
}
}
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('players', function (Blueprint $table) {
$table->uuid('active_instance_id')->nullable()->after('user_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('players', function (Blueprint $table) {
$table->dropColumn('active_instance_id');
});
}
};
@@ -42,6 +42,16 @@ export function useRoomRealtime(
return; return;
} }
if (
payload.change === 'player_instance_replaced' &&
payload.affected_player_id === currentPlayer.value.id &&
payload.affected_instance_id === currentPlayer.value.instance_id
) {
router.visit(home().url, { replace: true });
return;
}
if (payload.change === 'room_closed') { if (payload.change === 'room_closed') {
router.visit( router.visit(
join({ query: { join_code: room.value.join_code } }).url, join({ query: { join_code: room.value.join_code } }).url,
+11 -1
View File
@@ -1,9 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3'; import { Head, Link } from '@inertiajs/vue3';
import { CircleAlert } from '@lucide/vue'; import { CircleAlert } from '@lucide/vue';
import { login } from '@/routes';
defineProps<{ defineProps<{
message: string; message: string;
showLoginAction: boolean;
}>(); }>();
</script> </script>
@@ -19,6 +22,13 @@ defineProps<{
<CircleAlert class="size-10 text-amber-300" /> <CircleAlert class="size-10 text-amber-300" />
<h1 class="text-2xl font-bold">Raum kann nicht geöffnet werden</h1> <h1 class="text-2xl font-bold">Raum kann nicht geöffnet werden</h1>
<p class="text-slate-300">{{ message }}</p> <p class="text-slate-300">{{ message }}</p>
<Link
v-if="showLoginAction"
:href="login()"
class="inline-flex rounded-lg bg-white px-4 py-2 font-semibold text-slate-900 transition hover:bg-slate-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
>
Anmelden
</Link>
<p class="text-sm text-slate-400"> <p class="text-sm text-slate-400">
Bitte ändere Raumcode oder Anzeigename auf der Home-Seite der Bitte ändere Raumcode oder Anzeigename auf der Home-Seite der
Fillibri Hub App und öffne Scrum Poker erneut. Fillibri Hub App und öffne Scrum Poker erneut.
+3
View File
@@ -30,6 +30,7 @@ export type RoomPlayer = {
export type CurrentRoomPlayer = { export type CurrentRoomPlayer = {
id: number; id: number;
instance_id: string;
name: string; name: string;
is_moderator: boolean; is_moderator: boolean;
current_vote: string | null; current_vote: string | null;
@@ -163,6 +164,7 @@ export type RoomStateChange =
| 'player_joined' | 'player_joined'
| 'player_updated' | 'player_updated'
| 'player_kicked' | 'player_kicked'
| 'player_instance_replaced'
| 'vote_changed' | 'vote_changed'
| 'cards_revealed' | 'cards_revealed'
| 'votes_reset' | 'votes_reset'
@@ -177,6 +179,7 @@ export type RoomStateChangedPayload = {
change: RoomStateChange; change: RoomStateChange;
state: RoomRealtimeState | null; state: RoomRealtimeState | null;
affected_player_id?: number; affected_player_id?: number;
affected_instance_id?: string;
}; };
export type RoomRealtimeState = Pick< export type RoomRealtimeState = Pick<
+74 -6
View File
@@ -1,7 +1,10 @@
<?php <?php
use App\Enums\RoomStateChange;
use App\Events\RoomStateChanged;
use App\Models\Room; use App\Models\Room;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Event;
use Inertia\Testing\AssertableInertia as Assert; use Inertia\Testing\AssertableInertia as Assert;
function createEmbeddedRoom(array $attributes = []): Room function createEmbeddedRoom(array $attributes = []): Room
@@ -130,17 +133,81 @@ test('an embedded guest keeps the same player after logging in separately', func
}); });
test('embedded guest access shows a dedicated error when the display name is taken', function () { test('embedded guest access shows a dedicated error when the display name is taken', function () {
$user = User::factory()->create([
'email' => 'nico@example.com',
]);
$room = createEmbeddedRoom(); $room = createEmbeddedRoom();
$room->players()->create(['name' => 'Nico']); $room->players()->create([
'user_id' => $user->id,
$this->get(route('embedded.room', [ 'name' => 'Nico',
]);
$url = route('embedded.room', [
'join_code' => $room->join_code, 'join_code' => $room->join_code,
'player_name' => 'Nico', 'player_name' => 'Nico',
])) ]);
$this->get($url)
->assertUnprocessable() ->assertUnprocessable()
->assertInertia(fn (Assert $page) => $page ->assertInertia(fn (Assert $page) => $page
->component('embedded/Error') ->component('embedded/Error')
->where('message', 'Dieser Anzeigename ist in dem Raum bereits vorhanden.')); ->where('message', 'Dieser Anzeigename ist in dem Raum bereits vorhanden.')
->where('showLoginAction', true))
->assertSessionHas('url.intended', $url);
$this->post(route('login.store'), [
'email' => 'nico@example.com',
'password' => 'password',
])->assertRedirect($url);
$this->get($url)
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('embedded/Room')
->where('currentPlayer.name', 'Nico'));
});
test('a new account instance takes over the embedded player and kicks the previous instance', function () {
Event::fake([RoomStateChanged::class]);
$user = User::factory()->create();
$room = createEmbeddedRoom();
$player = $room->players()->create([
'user_id' => $user->id,
'name' => 'Nico',
'active_instance_id' => '019b8f80-37c4-71a3-b415-5f2697332c16',
]);
$url = route('embedded.room', [
'join_code' => $room->join_code,
'player_name' => 'Nico',
]);
$this->actingAs($user)
->withSession([
'room_player_instance_id' => '019b8f80-37c4-71a3-b415-5f2697332c17',
])
->get($url)
->assertOk()
->assertInertia(fn (Assert $page) => $page
->where('currentPlayer.id', $player->id)
->where('currentPlayer.instance_id', '019b8f80-37c4-71a3-b415-5f2697332c17'));
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
&& $event->affectedInstanceId === '019b8f80-37c4-71a3-b415-5f2697332c16',
);
$this->withSession([
"room_players.{$room->id}" => $player->id,
'room_player_instance_id' => '019b8f80-37c4-71a3-b415-5f2697332c16',
])
->post(route('room.vote.store', $room), ['value' => '5'])
->assertRedirect(route('room.join', ['join_code' => $room->join_code]));
$this->assertDatabaseCount('votes', 0);
}); });
test('embedded access shows a dedicated error for an unknown room code', function () { test('embedded access shows a dedicated error for an unknown room code', function () {
@@ -151,7 +218,8 @@ test('embedded access shows a dedicated error for an unknown room code', functio
->assertUnprocessable() ->assertUnprocessable()
->assertInertia(fn (Assert $page) => $page ->assertInertia(fn (Assert $page) => $page
->component('embedded/Error') ->component('embedded/Error')
->where('message', 'Dieser Raumcode existiert nicht.')); ->where('message', 'Dieser Raumcode existiert nicht.')
->where('showLoginAction', false));
}); });
test('embedded logout returns to the same room as a guest without duplicating the player', function () { test('embedded logout returns to the same room as a guest without duplicating the player', function () {
+19
View File
@@ -45,6 +45,25 @@ test('room state broadcasts synchronously expose sanitized state', function () {
]); ]);
}); });
test('player instance replacement broadcasts the displaced instance', function () {
$event = new RoomStateChanged(
'019b8f80-37c4-71a3-b415-5f2697332c16',
8,
RoomStateChange::PlayerInstanceReplaced,
null,
42,
'019b8f80-37c4-71a3-b415-5f2697332c17',
);
expect($event->broadcastWith())->toBe([
'version' => 8,
'change' => 'player_instance_replaced',
'state' => null,
'affected_player_id' => 42,
'affected_instance_id' => '019b8f80-37c4-71a3-b415-5f2697332c17',
]);
});
test('joining a room broadcasts the new room version', function () { test('joining a room broadcasts the new room version', function () {
Event::fake([RoomStateChanged::class]); Event::fake([RoomStateChanged::class]);
$room = createBroadcastRoom(); $room = createBroadcastRoom();
+33
View File
@@ -1,11 +1,14 @@
<?php <?php
use App\Enums\RoomStateChange;
use App\Events\RoomStateChanged;
use App\Jobs\CloseInactiveRooms; use App\Jobs\CloseInactiveRooms;
use App\Models\Deck; use App\Models\Deck;
use App\Models\Room; use App\Models\Room;
use App\Models\User; use App\Models\User;
use App\Models\Vote; use App\Models\Vote;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
use Inertia\Testing\AssertableInertia as Assert; use Inertia\Testing\AssertableInertia as Assert;
function createLifecycleRoom(array $attributes = []): Room function createLifecycleRoom(array $attributes = []): Room
@@ -156,6 +159,36 @@ test('an authenticated player rejoins with the existing room identity', function
$response->assertSessionHas("room_players.{$room->id}", $player->id); $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 () { test('an authenticated player can restore room access without the previous session', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$room = createLifecycleRoom(['owner_id' => $user->id]); $room = createLifecycleRoom(['owner_id' => $user->id]);