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>
This commit is contained in:
parent
9c90893b5d
commit
27b502a7ad
363
backend/tests/unit/core/test_exceptions.py
Normal file
363
backend/tests/unit/core/test_exceptions.py
Normal file
@ -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")
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user