fix(server): populate games_v2 metadata on game start
update_game_started (started_at, num_players, num_rounds, player_ids) was defined in event_store but had zero callers. 289/289 staging games had those fields NULL — queries that joined on them returned garbage, and the denormalized player_ids GIN index was dead weight. log_game_start now calls create_game THEN update_game_started in one async task. If create fails, update is skipped (row doesn't exist). handlers.py passes num_rounds and player_ids through at call time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ 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 handlers import ConnectionContext, handle_end_game, handle_start_game
|
||||
from test_handlers import MockWebSocket, make_ctx
|
||||
|
||||
|
||||
@@ -151,6 +151,102 @@ class TestHandleEndGameMarksAbandoned:
|
||||
|
||||
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(
|
||||
{},
|
||||
|
||||
Reference in New Issue
Block a user