# SPDX-License-Identifier: GPL-3.0-or-later """ Tests for game lifecycle logging — ensuring games_v2 rows never leak as stranded 'active' when a room is removed from memory without the game transitioning to GAME_OVER. Context: on staging we observed 42 games stuck in status='active' because handle_player_leave and handle_end_game removed the room from memory without updating games_v2. The periodic cleanup only scans in-memory rooms, so those rows were orphaned forever. These tests pin down the fix: 1. GameLogger.log_game_abandoned_async calls event_store.update_game_abandoned 2. handle_end_game marks the game abandoned when the host ends the game """ import asyncio import pytest from unittest.mock import AsyncMock, MagicMock, patch from game import GameOptions from room import Room, RoomManager from services.game_logger import GameLogger from handlers import ConnectionContext, handle_end_game, handle_start_game from test_handlers import MockWebSocket, make_ctx # --------------------------------------------------------------------------- # GameLogger.log_game_abandoned — unit # --------------------------------------------------------------------------- class TestLogGameAbandoned: @pytest.mark.asyncio async def test_calls_update_game_abandoned(self): """log_game_abandoned_async delegates to event_store.update_game_abandoned.""" event_store = MagicMock() event_store.update_game_abandoned = AsyncMock() logger = GameLogger(event_store) await logger.log_game_abandoned_async("game-uuid-123") event_store.update_game_abandoned.assert_awaited_once_with("game-uuid-123") @pytest.mark.asyncio async def test_sync_wrapper_fires_task(self): """Sync log_game_abandoned fires an async task in async context.""" event_store = MagicMock() event_store.update_game_abandoned = AsyncMock() logger = GameLogger(event_store) logger.log_game_abandoned("game-uuid-456") # Let the fire-and-forget task run await asyncio.sleep(0) await asyncio.sleep(0) event_store.update_game_abandoned.assert_awaited_once_with("game-uuid-456") def test_sync_wrapper_noop_on_empty_id(self): """Empty game_id is a no-op (nothing to abandon).""" event_store = MagicMock() event_store.update_game_abandoned = AsyncMock() logger = GameLogger(event_store) logger.log_game_abandoned("") logger.log_game_abandoned(None) event_store.update_game_abandoned.assert_not_called() @pytest.mark.asyncio async def test_swallows_db_exceptions(self): """DB errors are logged, not re-raised (fire-and-forget guarantee).""" event_store = MagicMock() event_store.update_game_abandoned = AsyncMock(side_effect=Exception("db down")) logger = GameLogger(event_store) # Must not raise await logger.log_game_abandoned_async("game-uuid-789") # --------------------------------------------------------------------------- # handle_end_game integration # --------------------------------------------------------------------------- class TestHandleEndGameMarksAbandoned: @pytest.mark.asyncio async def test_marks_game_abandoned_before_room_removal(self): """When host ends the game, games_v2 must be marked abandoned.""" rm = RoomManager() room = rm.create_room() host_ws = MockWebSocket() room.add_player("host", "Host", host_ws) room.get_player("host").is_host = True room.game_log_id = "game-uuid-end" mock_logger = MagicMock() mock_logger.log_game_abandoned = MagicMock() ctx = make_ctx(websocket=host_ws, player_id="host", room=room) with patch("handlers.get_logger", return_value=mock_logger): await handle_end_game( {}, ctx, room_manager=rm, cleanup_room_profiles=lambda _code: None, ) mock_logger.log_game_abandoned.assert_called_once_with("game-uuid-end") assert room.code not in rm.rooms # room still removed @pytest.mark.asyncio async def test_no_log_when_game_log_id_missing(self): """If the game never logged a start, there's nothing to mark abandoned.""" rm = RoomManager() room = rm.create_room() host_ws = MockWebSocket() room.add_player("host", "Host", host_ws) room.get_player("host").is_host = True # room.game_log_id stays None mock_logger = MagicMock() mock_logger.log_game_abandoned = MagicMock() ctx = make_ctx(websocket=host_ws, player_id="host", room=room) with patch("handlers.get_logger", return_value=mock_logger): await handle_end_game( {}, ctx, room_manager=rm, cleanup_room_profiles=lambda _code: None, ) mock_logger.log_game_abandoned.assert_not_called() @pytest.mark.asyncio async def test_non_host_cannot_trigger_abandonment(self): """Only the host ends games — non-host requests are rejected unchanged.""" rm = RoomManager() room = rm.create_room() room.add_player("host", "Host", MockWebSocket()) room.get_player("host").is_host = True joiner_ws = MockWebSocket() room.add_player("joiner", "Joiner", joiner_ws) room.game_log_id = "game-uuid-untouchable" mock_logger = MagicMock() mock_logger.log_game_abandoned = MagicMock() ctx = make_ctx(websocket=joiner_ws, player_id="joiner", room=room) with patch("handlers.get_logger", return_value=mock_logger): await handle_end_game( {}, ctx, room_manager=rm, cleanup_room_profiles=lambda _code: None, ) mock_logger.log_game_abandoned.assert_not_called() assert room.code in rm.rooms class TestLogGameStartPopulatesMetadata: """ create_game only writes id/room_code/host_id/options. update_game_started (which fills in started_at, num_players, num_rounds, player_ids) existed but had zero callers — 100% of staging's 289 games had those fields NULL. log_game_start must call both so the row is complete after start_game. """ @pytest.mark.asyncio async def test_start_calls_create_and_update(self): event_store = MagicMock() event_store.create_game = AsyncMock() event_store.update_game_started = AsyncMock() logger = GameLogger(event_store) options = GameOptions(initial_flips=0) await logger.log_game_start_async( room_code="ABCD", num_players=3, num_rounds=9, player_ids=["p1", "p2", "p3"], options=options, ) event_store.create_game.assert_awaited_once() event_store.update_game_started.assert_awaited_once() call = event_store.update_game_started.await_args assert call.kwargs.get("num_players", call.args[1] if len(call.args) > 1 else None) == 3 assert call.kwargs.get("num_rounds", call.args[2] if len(call.args) > 2 else None) == 9 assert call.kwargs.get("player_ids", call.args[3] if len(call.args) > 3 else None) == ["p1", "p2", "p3"] @pytest.mark.asyncio async def test_update_uses_same_game_id_as_create(self): event_store = MagicMock() event_store.create_game = AsyncMock() event_store.update_game_started = AsyncMock() logger = GameLogger(event_store) await logger.log_game_start_async( room_code="XYZW", num_players=2, num_rounds=1, player_ids=["a", "b"], options=GameOptions(initial_flips=0), ) create_args = event_store.create_game.await_args update_args = event_store.update_game_started.await_args created_id = create_args.kwargs.get("game_id", create_args.args[0] if create_args.args else None) updated_id = update_args.args[0] if update_args.args else update_args.kwargs.get("game_id") assert created_id == updated_id assert created_id # non-empty @pytest.mark.asyncio async def test_create_failure_skips_update(self): """If the row never landed, don't try to update a non-existent id.""" event_store = MagicMock() event_store.create_game = AsyncMock(side_effect=Exception("db down")) event_store.update_game_started = AsyncMock() logger = GameLogger(event_store) await logger.log_game_start_async( room_code="ABCD", num_players=2, num_rounds=1, player_ids=["a", "b"], options=GameOptions(initial_flips=0), ) event_store.update_game_started.assert_not_awaited() """Only the host ends games — non-host requests are rejected unchanged.""" rm = RoomManager() room = rm.create_room() room.add_player("host", "Host", MockWebSocket()) room.get_player("host").is_host = True joiner_ws = MockWebSocket() room.add_player("joiner", "Joiner", joiner_ws) room.game_log_id = "game-uuid-untouchable" mock_logger = MagicMock() mock_logger.log_game_abandoned = MagicMock() ctx = make_ctx(websocket=joiner_ws, player_id="joiner", room=room) with patch("handlers.get_logger", return_value=mock_logger): await handle_end_game( {}, ctx, room_manager=rm, cleanup_room_profiles=lambda _code: None, ) mock_logger.log_game_abandoned.assert_not_called() assert room.code in rm.rooms # room still exists