"""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 testcontainers).""" @pytest.mark.asyncio async def test_save_and_load_with_real_redis(self, redis_url: str) -> None: """Test full save/load cycle with real Redis client. Verifies the complete flow works with actual Redis. Creates Redis client inside test to avoid event loop issues. """ import redis.asyncio as aioredis # Create client inside test (same event loop) client = aioredis.from_url(redis_url, decode_responses=True) try: # Clear test database await client.flushdb() # Create a helper that uses the test client class TestRedisHelper(RedisHelper): def __init__(self, redis_client: Any) -> None: self._client = redis_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