fix(server): mark games abandoned on room teardown + staging leaderboard
When handle_player_leave emptied a room or handle_end_game was invoked,
the room was removed from memory without touching games_v2. Periodic
cleanup only scans in-memory rooms, so those rows were stranded as
status='active' forever — staging had 42 orphans accumulated over 5h.
- event_store.update_game_abandoned: guarded UPDATE (status='active' only)
- GameLogger.log_game_abandoned{,_async}: fire-and-forget wrapper
- handle_end_game + handle_player_leave: flip status before remove_room
- LEADERBOARD_INCLUDE_TEST_DEFAULT: env override so staging can show
soak-harness accounts by default; prod keeps them hidden
Verified on staging: 42 orphans swept on restart, soak accounts now
visible on /api/stats/leaderboard (rank 1-4).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
163
server/test_game_lifecycle_logging.py
Normal file
163
server/test_game_lifecycle_logging.py
Normal file
@@ -0,0 +1,163 @@
|
||||
# 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
|
||||
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 # room still exists
|
||||
Reference in New Issue
Block a user