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:
adlee-was-taken
2026-04-18 00:41:58 -04:00
parent d5f8eef6b3
commit 8030a3c171
3 changed files with 142 additions and 36 deletions

View File

@@ -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(
{},