""" Tests for custom exception classes in app.core.exceptions. Tests verify: - Exception hierarchy (all inherit from GameEngineError) - Exception attributes are properly set - String representations are informative - Exceptions can be caught by parent class Author: Claude Date: 2025-11-27 """ from uuid import uuid4 import pytest from app.core.exceptions import ( AuthorizationError, DatabaseError, DecisionTimeoutError, ExternalAPIError, GameEngineError, GameNotFoundError, InvalidGameStateError, LineupError, PlayerDataError, SubstitutionError, ) class TestGameEngineErrorHierarchy: """ Test that all custom exceptions inherit from GameEngineError. Why: Allows catching all game-specific errors with a single except clause while still handling specific errors differently when needed. """ def test_game_not_found_error_inherits_from_base(self): """GameNotFoundError should be catchable as GameEngineError.""" exc = GameNotFoundError(uuid4()) assert isinstance(exc, GameEngineError) assert isinstance(exc, Exception) def test_invalid_game_state_error_inherits_from_base(self): """InvalidGameStateError should be catchable as GameEngineError.""" exc = InvalidGameStateError("test message") assert isinstance(exc, GameEngineError) def test_substitution_error_inherits_from_base(self): """SubstitutionError should be catchable as GameEngineError.""" exc = SubstitutionError("test message") assert isinstance(exc, GameEngineError) def test_authorization_error_inherits_from_base(self): """AuthorizationError should be catchable as GameEngineError.""" exc = AuthorizationError("test message") assert isinstance(exc, GameEngineError) def test_decision_timeout_error_inherits_from_base(self): """DecisionTimeoutError should be catchable as GameEngineError.""" exc = DecisionTimeoutError(uuid4(), "defensive", 30) assert isinstance(exc, GameEngineError) def test_database_error_inherits_from_base(self): """DatabaseError should be catchable as GameEngineError.""" exc = DatabaseError("save_play") assert isinstance(exc, GameEngineError) def test_lineup_error_inherits_from_base(self): """LineupError should be catchable as GameEngineError.""" exc = LineupError(123, "test message") assert isinstance(exc, GameEngineError) def test_external_api_error_inherits_from_base(self): """ExternalAPIError should be catchable as GameEngineError.""" exc = ExternalAPIError("SBA API", "test message") assert isinstance(exc, GameEngineError) def test_player_data_error_inherits_from_external_api_error(self): """PlayerDataError should be catchable as ExternalAPIError and GameEngineError.""" exc = PlayerDataError(12345) assert isinstance(exc, ExternalAPIError) assert isinstance(exc, GameEngineError) class TestGameNotFoundError: """ Test GameNotFoundError exception. Why: This is the most common error - game not in memory or database. """ def test_stores_game_id_as_uuid(self): """Game ID should be accessible from exception.""" game_id = uuid4() exc = GameNotFoundError(game_id) assert exc.game_id == game_id def test_stores_game_id_as_string(self): """Should accept string game_id too.""" game_id = "test-game-id" exc = GameNotFoundError(game_id) assert exc.game_id == game_id def test_string_representation(self): """String representation should include game_id.""" game_id = uuid4() exc = GameNotFoundError(game_id) assert str(game_id) in str(exc) assert "not found" in str(exc).lower() class TestInvalidGameStateError: """ Test InvalidGameStateError exception. Why: Helps debugging when game is in wrong state for an operation. """ def test_stores_message(self): """Message should be accessible via str().""" exc = InvalidGameStateError("Game already started") assert "Game already started" in str(exc) def test_stores_current_and_expected_state(self): """Current and expected state should be accessible.""" exc = InvalidGameStateError( "Invalid transition", current_state="completed", expected_state="active", ) assert exc.current_state == "completed" assert exc.expected_state == "active" def test_optional_state_fields(self): """State fields should be optional.""" exc = InvalidGameStateError("Just a message") assert exc.current_state is None assert exc.expected_state is None class TestSubstitutionError: """ Test SubstitutionError exception. Why: Provides error codes for frontend to display appropriate messages. """ def test_default_error_code(self): """Should have default error code.""" exc = SubstitutionError("Player not found") assert exc.error_code == "SUBSTITUTION_ERROR" def test_custom_error_code(self): """Should accept custom error code.""" exc = SubstitutionError("Already in game", error_code="ALREADY_ACTIVE") assert exc.error_code == "ALREADY_ACTIVE" def test_message_accessible(self): """Message should be accessible via str().""" exc = SubstitutionError("Player not found") assert "Player not found" in str(exc) class TestDecisionTimeoutError: """ Test DecisionTimeoutError exception. Why: Provides context about which decision timed out. """ def test_stores_all_attributes(self): """All attributes should be accessible.""" game_id = uuid4() exc = DecisionTimeoutError(game_id, "defensive", 30) assert exc.game_id == game_id assert exc.decision_type == "defensive" assert exc.timeout_seconds == 30 def test_string_includes_context(self): """String representation should include all context.""" game_id = uuid4() exc = DecisionTimeoutError(game_id, "offensive", 45) msg = str(exc) assert str(game_id) in msg assert "offensive" in msg assert "45" in msg class TestDatabaseError: """ Test DatabaseError exception. Why: Wraps SQLAlchemy exceptions with operation context. """ def test_stores_operation(self): """Operation should be accessible.""" exc = DatabaseError("save_play") assert exc.operation == "save_play" def test_stores_original_error(self): """Original error should be accessible.""" original = ValueError("test error") exc = DatabaseError("create_substitution", original) assert exc.original_error is original def test_string_includes_operation(self): """String representation should include operation.""" exc = DatabaseError("save_rolls_batch") assert "save_rolls_batch" in str(exc) def test_string_includes_original_error(self): """String representation should include original error message.""" original = ValueError("connection refused") exc = DatabaseError("update_game", original) assert "connection refused" in str(exc) class TestLineupError: """ Test LineupError exception. Why: Provides team context for lineup issues. """ def test_stores_team_id(self): """Team ID should be accessible.""" exc = LineupError(123, "No active pitcher") assert exc.team_id == 123 def test_string_includes_team_and_message(self): """String representation should include team ID and message.""" exc = LineupError(456, "Incomplete batting order") msg = str(exc) assert "456" in msg assert "Incomplete batting order" in msg class TestExternalAPIError: """ Test ExternalAPIError exception. Why: Distinguishes external service failures from internal errors. """ def test_stores_service_name(self): """Service name should be accessible.""" exc = ExternalAPIError("SBA API", "timeout") assert exc.service == "SBA API" def test_stores_status_code(self): """HTTP status code should be accessible.""" exc = ExternalAPIError("PD API", "not found", status_code=404) assert exc.status_code == 404 def test_optional_status_code(self): """Status code should be optional.""" exc = ExternalAPIError("Test API", "connection failed") assert exc.status_code is None def test_string_includes_service(self): """String representation should include service name.""" exc = ExternalAPIError("SBA API", "server error") assert "SBA API" in str(exc) class TestPlayerDataError: """ Test PlayerDataError exception. Why: Common error for player data lookups. """ def test_stores_player_id(self): """Player ID should be accessible.""" exc = PlayerDataError(12345) assert exc.player_id == 12345 def test_default_service(self): """Should default to SBA API.""" exc = PlayerDataError(12345) assert exc.service == "SBA API" def test_custom_service(self): """Should accept custom service name.""" exc = PlayerDataError(12345, service="PD API") assert exc.service == "PD API" def test_string_includes_player_id(self): """String representation should include player ID.""" exc = PlayerDataError(98765) assert "98765" in str(exc) class TestExceptionCatching: """ Test that exception hierarchy allows proper catching patterns. Why: Verify the practical use of catching exceptions at different levels. """ def test_catch_specific_before_generic(self): """ Verify that specific exceptions can be caught before generic ones. This is the expected pattern in handlers: try: ... except GameNotFoundError: # Handle missing game except GameEngineError: # Handle other game errors """ game_id = uuid4() try: raise GameNotFoundError(game_id) except GameNotFoundError as e: assert e.game_id == game_id caught_specific = True except GameEngineError: caught_specific = False assert caught_specific def test_catch_generic_for_all_game_errors(self): """ Verify that GameEngineError catches all game exceptions. This allows handlers to have a fallback for unexpected game errors. """ exceptions = [ GameNotFoundError(uuid4()), InvalidGameStateError("test"), SubstitutionError("test"), DatabaseError("test"), LineupError(123, "test"), PlayerDataError(123), ] for exc in exceptions: try: raise exc except GameEngineError: pass # All should be caught else: pytest.fail(f"{type(exc).__name__} was not caught by GameEngineError") def test_generic_exception_does_not_catch_game_errors(self): """ Verify that regular Exception catches game errors (as expected). This confirms game errors are still Exceptions for ultimate fallback. """ try: raise GameNotFoundError(uuid4()) except Exception: pass # Should be caught else: pytest.fail("GameNotFoundError was not caught by Exception")