strat-gameplay-webapp/backend/tests/unit/core/test_exceptions.py
Cal Corum 27b502a7ad CLAUDE: Add exception tests and enhance stop-services script
New Tests:
- test_exceptions.py: Comprehensive tests for custom GameEngineError
  hierarchy including AuthorizationError, DatabaseError, etc.

Scripts:
- stop-services.sh: Enhanced to kill entire process trees and
  clean up orphan processes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 12:10:59 -06:00

364 lines
12 KiB
Python

"""
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")