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>
364 lines
12 KiB
Python
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")
|