mantimon-tcg/backend/tests/services/test_game_state_manager.py
Cal Corum 29ab0b3d84 Add GameStateManager service with Redis/Postgres dual storage
- Implement GameStateManager with Redis-primary, Postgres-backup pattern
- Cache operations: save_to_cache, load_from_cache, delete_from_cache
- DB operations: persist_to_db, load_from_db, delete_from_db
- High-level: load_state (cache-first), delete_game, recover_active_games
- Query helpers: get_active_game_count, get_player_active_games
- Add 22 tests for GameStateManager (87% coverage)
- Add 6 __repr__ tests for all DB models (100% model coverage)
2026-01-27 10:59:58 -06:00

700 lines
23 KiB
Python

"""Tests for GameStateManager service.
This module tests the Redis-primary, Postgres-backup game state management:
- Cache operations (save/load/delete from Redis)
- Database operations (persist/load/delete from Postgres)
- High-level operations (load with fallback, recovery)
Prerequisites:
docker compose up -d # Postgres on 5433, Redis on 6380
"""
import json
from typing import Any
from uuid import uuid4
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.enums import TurnPhase
from app.core.models.game_state import GameState
from app.db.models import GameType
from app.db.redis import RedisHelper
from app.services.game_state_manager import (
GAME_KEY_PREFIX,
GameStateManager,
)
from tests.factories import UserFactory
from tests.services.conftest import create_test_game_state
# =============================================================================
# Mock Redis Helper for Unit Tests
# =============================================================================
class MockRedisHelper(RedisHelper):
"""In-memory mock Redis for unit testing without real Redis."""
def __init__(self) -> None:
self.store: dict[str, str] = {}
self.ttls: dict[str, int | None] = {}
async def get_json(self, key: str) -> dict[str, Any] | None:
"""Get JSON from mock store."""
value = self.store.get(key)
if value is None:
return None
return json.loads(value)
async def set_json(
self,
key: str,
value: dict[str, Any],
expire_seconds: int | None = None,
) -> None:
"""Store JSON in mock store."""
self.store[key] = json.dumps(value, default=str)
self.ttls[key] = expire_seconds
async def delete(self, key: str) -> bool:
"""Delete from mock store."""
if key in self.store:
del self.store[key]
self.ttls.pop(key, None)
return True
return False
async def exists(self, key: str) -> bool:
"""Check if key exists in mock store."""
return key in self.store
# =============================================================================
# Cache Operation Tests
# =============================================================================
class TestCacheOperations:
"""Tests for Redis cache operations."""
@pytest.mark.asyncio
async def test_save_to_cache(self, redis_client: Any) -> None:
"""Test saving game state to Redis cache.
Verifies that GameState is serialized to JSON and stored with
the correct key format.
"""
# Create a mock helper that uses our test redis client
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
game = create_test_game_state()
await manager.save_to_cache(game)
# Verify key exists
key = f"{GAME_KEY_PREFIX}{game.game_id}"
assert key in helper.store
# Verify data is valid JSON that can be deserialized
data = json.loads(helper.store[key])
assert data["game_id"] == game.game_id
assert data["turn_number"] == game.turn_number
@pytest.mark.asyncio
async def test_load_from_cache_hit(self) -> None:
"""Test loading game state from cache when it exists.
Verifies cache hit returns the correct GameState.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
game = create_test_game_state(turn_number=5)
# Save first
await manager.save_to_cache(game)
# Load and verify
loaded = await manager.load_from_cache(game.game_id)
assert loaded is not None
assert loaded.game_id == game.game_id
assert loaded.turn_number == 5
@pytest.mark.asyncio
async def test_load_from_cache_miss(self) -> None:
"""Test loading game state from cache when it doesn't exist.
Verifies cache miss returns None.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
loaded = await manager.load_from_cache("nonexistent-game")
assert loaded is None
@pytest.mark.asyncio
async def test_delete_from_cache(self) -> None:
"""Test deleting game state from cache.
Verifies game is removed and subsequent loads return None.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
game = create_test_game_state()
await manager.save_to_cache(game)
assert await manager.cache_exists(game.game_id)
deleted = await manager.delete_from_cache(game.game_id)
assert deleted is True
assert not await manager.cache_exists(game.game_id)
@pytest.mark.asyncio
async def test_delete_from_cache_not_found(self) -> None:
"""Test deleting game that doesn't exist in cache.
Verifies returns False when game not found.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
deleted = await manager.delete_from_cache("nonexistent-game")
assert deleted is False
@pytest.mark.asyncio
async def test_cache_exists(self) -> None:
"""Test checking if game exists in cache.
Verifies exists returns True for cached games, False otherwise.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
game = create_test_game_state()
assert not await manager.cache_exists(game.game_id)
await manager.save_to_cache(game)
assert await manager.cache_exists(game.game_id)
# =============================================================================
# Database Operation Tests
# =============================================================================
class TestDatabaseOperations:
"""Tests for Postgres database operations."""
@pytest.mark.asyncio
async def test_persist_to_db_creates_new(self, db_session: AsyncSession) -> None:
"""Test persisting game state creates new ActiveGame record.
Verifies a new game is properly stored in Postgres.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
# Create user for FK
user = await UserFactory.create(db_session)
game = create_test_game_state()
# Persist to database
active_game = await manager.persist_to_db(
game,
game_type=GameType.CAMPAIGN,
player1_id=user.id,
npc_id="test_npc",
session=db_session,
)
assert active_game.id == game.game_id
assert active_game.game_type == GameType.CAMPAIGN
assert active_game.player1_id == user.id
assert active_game.npc_id == "test_npc"
assert active_game.turn_number == game.turn_number
@pytest.mark.asyncio
async def test_persist_to_db_updates_existing(self, db_session: AsyncSession) -> None:
"""Test persisting game state updates existing ActiveGame record.
Verifies an existing game is updated with new state.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
user = await UserFactory.create(db_session)
game = create_test_game_state(turn_number=1)
# Create initial record
await manager.persist_to_db(
game,
game_type=GameType.CAMPAIGN,
player1_id=user.id,
session=db_session,
)
# Update game state
game = GameState.model_validate(
{**game.model_dump(), "turn_number": 10, "phase": TurnPhase.ATTACK.value}
)
# Persist again
active_game = await manager.persist_to_db(
game,
game_type=GameType.CAMPAIGN,
player1_id=user.id,
session=db_session,
)
# Verify update
assert active_game.turn_number == 10
@pytest.mark.asyncio
async def test_load_from_db_found(self, db_session: AsyncSession) -> None:
"""Test loading game state from database when it exists.
Verifies game state is correctly deserialized from Postgres.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
user = await UserFactory.create(db_session)
game = create_test_game_state(turn_number=7)
# Persist first
await manager.persist_to_db(
game,
game_type=GameType.CAMPAIGN,
player1_id=user.id,
session=db_session,
)
# Load and verify
loaded = await manager.load_from_db(game.game_id, session=db_session)
assert loaded is not None
assert loaded.game_id == game.game_id
assert loaded.turn_number == 7
@pytest.mark.asyncio
async def test_load_from_db_not_found(self, db_session: AsyncSession) -> None:
"""Test loading game state from database when it doesn't exist.
Verifies returns None for missing games.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
loaded = await manager.load_from_db(str(uuid4()), session=db_session)
assert loaded is None
@pytest.mark.asyncio
async def test_delete_from_db(self, db_session: AsyncSession) -> None:
"""Test deleting game from database.
Verifies game is removed from ActiveGame table.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
user = await UserFactory.create(db_session)
game = create_test_game_state()
# Create game
await manager.persist_to_db(
game,
game_type=GameType.CAMPAIGN,
player1_id=user.id,
session=db_session,
)
# Delete it
deleted = await manager.delete_from_db(game.game_id, session=db_session)
assert deleted is True
# Verify gone
loaded = await manager.load_from_db(game.game_id, session=db_session)
assert loaded is None
@pytest.mark.asyncio
async def test_delete_from_db_not_found(self, db_session: AsyncSession) -> None:
"""Test deleting game that doesn't exist in database.
Verifies returns False for missing games.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
deleted = await manager.delete_from_db(str(uuid4()), session=db_session)
assert deleted is False
# =============================================================================
# High-Level Operation Tests
# =============================================================================
class TestHighLevelOperations:
"""Tests for high-level game state operations."""
@pytest.mark.asyncio
async def test_load_state_cache_hit(self) -> None:
"""Test load_state returns cached data when available.
Verifies the fast path uses Redis when game is cached.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
game = create_test_game_state()
# Cache the game
await manager.save_to_cache(game)
# Load should use cache (fast path)
loaded = await manager.load_state(game.game_id)
assert loaded is not None
assert loaded.game_id == game.game_id
@pytest.mark.asyncio
async def test_load_state_cache_miss_db_hit(self, db_session: AsyncSession) -> None:
"""Test load_state falls back to database on cache miss.
Verifies database fallback and cache repopulation.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
user = await UserFactory.create(db_session)
game = create_test_game_state()
# Only in database, not in cache
await manager.persist_to_db(
game,
game_type=GameType.CAMPAIGN,
player1_id=user.id,
session=db_session,
)
# Load should fallback to DB (pass session for test)
loaded = await manager.load_state(game.game_id, session=db_session)
assert loaded is not None
assert loaded.game_id == game.game_id
# Should now be in cache
assert await manager.cache_exists(game.game_id)
@pytest.mark.asyncio
async def test_load_state_not_found(self, db_session: AsyncSession) -> None:
"""Test load_state returns None when game doesn't exist anywhere.
Verifies proper handling of missing games.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
loaded = await manager.load_state(str(uuid4()), session=db_session)
assert loaded is None
@pytest.mark.asyncio
async def test_delete_game(self, db_session: AsyncSession) -> None:
"""Test delete_game removes from both cache and database.
Verifies complete cleanup of game state.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
user = await UserFactory.create(db_session)
game = create_test_game_state()
# Create in both cache and DB
await manager.save_to_cache(game)
await manager.persist_to_db(
game,
game_type=GameType.CAMPAIGN,
player1_id=user.id,
session=db_session,
)
# Delete completely (pass session)
deleted = await manager.delete_game(game.game_id, session=db_session)
assert deleted is True
# Verify gone from both
assert not await manager.cache_exists(game.game_id)
loaded = await manager.load_from_db(game.game_id, session=db_session)
assert loaded is None
@pytest.mark.asyncio
async def test_recover_active_games(self, db_session: AsyncSession) -> None:
"""Test recovering active games from database to cache on startup.
Verifies all games are loaded from Postgres and cached in Redis.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
user = await UserFactory.create(db_session)
# Create multiple games in DB only
game1 = create_test_game_state()
game2 = create_test_game_state()
await manager.persist_to_db(
game1,
game_type=GameType.CAMPAIGN,
player1_id=user.id,
session=db_session,
)
await manager.persist_to_db(
game2,
game_type=GameType.FREEPLAY,
player1_id=user.id,
session=db_session,
)
# Clear cache
helper.store.clear()
# Recover (pass session)
count = await manager.recover_active_games(session=db_session)
assert count == 2
# Both should be in cache now
assert await manager.cache_exists(game1.game_id)
assert await manager.cache_exists(game2.game_id)
@pytest.mark.asyncio
async def test_get_player_active_games(self, db_session: AsyncSession) -> None:
"""Test getting all active games for a player.
Verifies correct games are returned for a specific player.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
user1 = await UserFactory.create(db_session)
user2 = await UserFactory.create(db_session)
# Game where user1 is player1
game1 = create_test_game_state()
await manager.persist_to_db(
game1,
game_type=GameType.CAMPAIGN,
player1_id=user1.id,
session=db_session,
)
# Game where user1 is player2
game2 = create_test_game_state()
await manager.persist_to_db(
game2,
game_type=GameType.FREEPLAY,
player1_id=user2.id,
player2_id=user1.id,
session=db_session,
)
# Game for different user
game3 = create_test_game_state()
await manager.persist_to_db(
game3,
game_type=GameType.CAMPAIGN,
player1_id=user2.id,
session=db_session,
)
# Get user1's games
games = await manager.get_player_active_games(user1.id, session=db_session)
game_ids = {str(g.id) for g in games}
assert game1.game_id in game_ids
assert game2.game_id in game_ids
assert game3.game_id not in game_ids
# =============================================================================
# Integration Tests with Real Redis
# =============================================================================
class TestRealRedisIntegration:
"""Integration tests using real Redis (from docker-compose)."""
@pytest.mark.asyncio
async def test_save_and_load_with_real_redis(self) -> None:
"""Test full save/load cycle with real Redis client.
Verifies the complete flow works with actual Redis.
Creates Redis connection inside the test to avoid event loop issues.
"""
import redis.asyncio as aioredis
# Create Redis client inside test (same event loop)
client = aioredis.from_url(
"redis://localhost:6380/3", # Use DB 3 for this specific test
decode_responses=True,
)
try:
# Clear test database
await client.flushdb()
# Create a helper that uses our client
class TestRedisHelper(RedisHelper):
def __init__(self, client: Any) -> None:
self._client = client
async def get_json(self, key: str) -> dict | None:
value = await self._client.get(key)
if value is None:
return None
return json.loads(value)
async def set_json(
self, key: str, value: dict, expire_seconds: int | None = None
) -> None:
json_str = json.dumps(value, default=str)
if expire_seconds:
await self._client.setex(key, expire_seconds, json_str)
else:
await self._client.set(key, json_str)
async def delete(self, key: str) -> bool:
result = await self._client.delete(key)
return result > 0
async def exists(self, key: str) -> bool:
result = await self._client.exists(key)
return result > 0
helper = TestRedisHelper(client)
manager = GameStateManager(redis=helper)
game = create_test_game_state(turn_number=42)
# Save to real Redis
await manager.save_to_cache(game)
# Load from real Redis
loaded = await manager.load_from_cache(game.game_id)
assert loaded is not None
assert loaded.game_id == game.game_id
assert loaded.turn_number == 42
# Clean up
await manager.delete_from_cache(game.game_id)
assert not await manager.cache_exists(game.game_id)
finally:
await client.flushdb()
await client.aclose()
# =============================================================================
# Additional Coverage Tests
# =============================================================================
class TestAdditionalCoverage:
"""Tests to fill coverage gaps identified in review."""
@pytest.mark.asyncio
async def test_get_active_game_count(self, db_session: AsyncSession) -> None:
"""Test counting active games in database.
Verifies the monitoring/admin dashboard helper works correctly.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
user = await UserFactory.create(db_session)
# Initially zero
count = await manager.get_active_game_count(session=db_session)
assert count == 0
# Create some games
game1 = create_test_game_state()
game2 = create_test_game_state()
await manager.persist_to_db(
game1,
game_type=GameType.CAMPAIGN,
player1_id=user.id,
session=db_session,
)
await manager.persist_to_db(
game2,
game_type=GameType.FREEPLAY,
player1_id=user.id,
session=db_session,
)
# Now should be 2
count = await manager.get_active_game_count(session=db_session)
assert count == 2
@pytest.mark.asyncio
async def test_recover_active_games_with_invalid_state(self, db_session: AsyncSession) -> None:
"""Test recovery handles corrupted game state gracefully.
Verifies the exception handler in recover_active_games() logs errors
but continues recovering other games.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
user = await UserFactory.create(db_session)
# Create a valid game
valid_game = create_test_game_state()
await manager.persist_to_db(
valid_game,
game_type=GameType.CAMPAIGN,
player1_id=user.id,
session=db_session,
)
# Manually insert a game with invalid/corrupted state
from sqlalchemy import text
invalid_game_id = str(uuid4())
await db_session.execute(
text("""
INSERT INTO active_games
(id, game_type, player1_id, rules_config, game_state, turn_number,
started_at, last_action_at, created_at, updated_at)
VALUES
(:id, 'CAMPAIGN', :player_id, '{}', '{"invalid": "not a valid game state"}',
1, NOW(), NOW(), NOW(), NOW())
"""),
{"id": invalid_game_id, "player_id": str(user.id)},
)
await db_session.flush()
# Clear cache
helper.store.clear()
# Recovery should handle the error and still recover the valid game
count = await manager.recover_active_games(session=db_session)
# Only the valid game should be recovered (invalid one fails validation)
assert count == 1
assert await manager.cache_exists(valid_game.game_id)
assert not await manager.cache_exists(invalid_game_id)
@pytest.mark.asyncio
async def test_delete_game_not_found_anywhere(self, db_session: AsyncSession) -> None:
"""Test delete_game when game doesn't exist in cache or DB.
Verifies the warning log path and returns False.
"""
helper = MockRedisHelper()
manager = GameStateManager(redis=helper)
# Try to delete a game that doesn't exist anywhere
nonexistent_id = str(uuid4())
deleted = await manager.delete_game(nonexistent_id, session=db_session)
# Should return False (nothing was deleted)
assert deleted is False