diff --git a/backend/tests/unit/core/test_exceptions.py b/backend/tests/unit/core/test_exceptions.py new file mode 100644 index 0000000..c9b931a --- /dev/null +++ b/backend/tests/unit/core/test_exceptions.py @@ -0,0 +1,363 @@ +""" +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") diff --git a/stop-services.sh b/stop-services.sh index c29b515..c18f19d 100755 --- a/stop-services.sh +++ b/stop-services.sh @@ -72,16 +72,30 @@ echo "" # Aggressive cleanup: kill ALL related processes by pattern matching echo "🔍 Checking for any remaining processes..." -# Kill all uvicorn processes for this app -BACKEND_PIDS=$(pgrep -f "uvicorn.*app\.main" 2>/dev/null || true) -if [ -n "$BACKEND_PIDS" ]; then - echo " Found orphaned backend processes: $BACKEND_PIDS" - for pid in $BACKEND_PIDS; do +# Kill all backend processes for this app (uvicorn OR python -m app.main) +BACKEND_PIDS=$(pgrep -f "python.*app\.main" 2>/dev/null || true) +UVICORN_PIDS=$(pgrep -f "uvicorn.*app\.main" 2>/dev/null || true) +ALL_BACKEND_PIDS="$BACKEND_PIDS $UVICORN_PIDS" +ALL_BACKEND_PIDS=$(echo "$ALL_BACKEND_PIDS" | tr ' ' '\n' | sort -u | tr '\n' ' ' | xargs) + +if [ -n "$ALL_BACKEND_PIDS" ]; then + echo " Found orphaned backend processes: $ALL_BACKEND_PIDS" + for pid in $ALL_BACKEND_PIDS; do kill_tree "$pid" KILL done echo " ✓ Killed orphaned backend processes" fi +# Also kill any processes on port 8000 +PORT_8000_PIDS=$(lsof -ti :8000 2>/dev/null || true) +if [ -n "$PORT_8000_PIDS" ]; then + echo " Found processes on port 8000: $PORT_8000_PIDS" + for pid in $PORT_8000_PIDS; do + kill -9 "$pid" 2>/dev/null || true + done + echo " ✓ Killed processes on port 8000" +fi + # Kill all nuxt dev processes in this directory FRONTEND_PIDS=$(pgrep -f "nuxt.*dev" | while read pid; do if ps -p $pid -o args= | grep -q "$SCRIPT_DIR/frontend-sba"; then