strat-gameplay-webapp/backend/tests/unit/services/test_pd_api_client.py
Cal Corum 0ebe72c09d CLAUDE: Phase 3F - Substitution System Testing Complete
This commit completes all Phase 3 work with comprehensive test coverage:

Test Coverage:
- 31 unit tests for SubstitutionRules (all validation paths)
- 10 integration tests for SubstitutionManager (DB + state sync)
- 679 total tests in test suite (609/609 unit tests passing - 100%)

Testing Scope:
- Pinch hitter validation and execution
- Defensive replacement validation and execution
- Pitching change validation and execution (min batters, force changes)
- Double switch validation
- Multiple substitutions in sequence
- Batting order preservation
- Database persistence verification
- State sync verification
- Lineup cache updates

All substitution system components are now production-ready:
 Core validation logic (SubstitutionRules)
 Orchestration layer (SubstitutionManager)
 Database operations
 WebSocket event handlers
 Comprehensive test coverage
 Complete documentation

Phase 3 Overall: 100% Complete
- Phase 3A-D (X-Check Core): 100%
- Phase 3E (Position Ratings + Redis): 100%
- Phase 3F (Substitutions): 100%

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 15:25:53 -06:00

538 lines
19 KiB
Python

"""
Unit tests for PD API client with mocked HTTP responses.
Tests the PdApiClient class that fetches position ratings from the PD API,
using mocked httpx responses to avoid external dependencies.
"""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
import httpx
from app.services.pd_api_client import PdApiClient
@pytest.fixture
def api_client():
"""Create PdApiClient instance for testing"""
return PdApiClient(base_url="https://test.api.com")
@pytest.fixture
def mock_position_data():
"""Sample position rating data from API"""
return {
"position": "SS",
"innings": 1452,
"range": 4,
"error": 12,
"arm": 3,
"pb": None,
"overthrow": 2
}
@pytest.fixture
def mock_multiple_positions():
"""Multiple position ratings for a player"""
return [
{
"position": "2B",
"innings": 800,
"range": 3,
"error": 8,
"arm": 2,
"pb": None,
"overthrow": 1
},
{
"position": "SS",
"innings": 600,
"range": 4,
"error": 12,
"arm": 3,
"pb": None,
"overthrow": 2
},
{
"position": "3B",
"innings": 150,
"range": 3,
"error": 15,
"arm": 2,
"pb": None,
"overthrow": 1
}
]
def setup_mock_http_client(mock_client_class, response_data=None, exception=None):
"""
Helper to properly setup httpx.AsyncClient mock with async context manager.
Args:
mock_client_class: The mocked httpx.AsyncClient class
response_data: Data to return from response.json(), or None
exception: Exception to raise from client.get(), or None
Returns:
mock_client: The mock client instance for additional assertions
"""
if exception:
# Setup client that raises exception
mock_client = MagicMock()
mock_client.get = AsyncMock(side_effect=exception)
else:
# Setup successful response
mock_response = MagicMock()
mock_response.json.return_value = response_data or []
mock_response.raise_for_status = MagicMock()
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
# Setup async context manager protocol
mock_client_instance = MagicMock()
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_instance.__aexit__ = AsyncMock(return_value=None)
mock_client_class.return_value = mock_client_instance
return mock_client
class TestPdApiClientInitialization:
"""Tests for PdApiClient initialization"""
def test_init_with_default_url(self):
"""Test initialization with default production URL"""
client = PdApiClient()
assert client.base_url == "https://pd.manticorum.com"
assert client.timeout.connect == 5.0
assert client.timeout.read == 10.0
def test_init_with_custom_url(self):
"""Test initialization with custom base URL"""
client = PdApiClient(base_url="https://custom.api.com")
assert client.base_url == "https://custom.api.com"
class TestGetPositionRatingsSuccess:
"""Tests for successful position rating retrieval"""
@pytest.mark.asyncio
@patch('app.services.pd_api_client.httpx.AsyncClient')
async def test_get_single_position(self, mock_client_class, api_client, mock_position_data):
"""Test fetching single position rating"""
# Setup mock response
mock_response = MagicMock()
mock_response.json.return_value = [mock_position_data]
mock_response.raise_for_status = MagicMock()
# Setup mock client
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
# Setup async context manager
mock_client_instance = MagicMock()
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_instance.__aexit__ = AsyncMock(return_value=None)
mock_client_class.return_value = mock_client_instance
# Execute
ratings = await api_client.get_position_ratings(8807)
# Verify
assert len(ratings) == 1
assert ratings[0].position == "SS"
assert ratings[0].range == 4
assert ratings[0].error == 12
assert ratings[0].innings == 1452
# Verify API call
mock_client.get.assert_called_once()
call_args = mock_client.get.call_args
assert "player_id" in call_args[1]['params']
assert call_args[1]['params']['player_id'] == 8807
@pytest.mark.asyncio
@patch('httpx.AsyncClient')
async def test_get_multiple_positions(self, mock_client_class, api_client, mock_multiple_positions):
"""Test fetching multiple position ratings"""
# Setup mock response
mock_response = MagicMock()
mock_response.json.return_value = mock_multiple_positions
mock_response.raise_for_status = MagicMock()
# Setup mock client
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
# Setup async context manager
mock_client_instance = MagicMock()
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_instance.__aexit__ = AsyncMock(return_value=None)
mock_client_class.return_value = mock_client_instance
# Execute
ratings = await api_client.get_position_ratings(8807)
# Verify
assert len(ratings) == 3
assert ratings[0].position == "2B"
assert ratings[1].position == "SS"
assert ratings[2].position == "3B"
assert all(r.range in range(1, 6) for r in ratings)
@pytest.mark.asyncio
@patch('httpx.AsyncClient')
async def test_get_positions_with_filter(self, mock_client_class, api_client, mock_multiple_positions):
"""Test fetching positions with filter parameter"""
# Setup mock response
mock_response = MagicMock()
mock_response.json.return_value = mock_multiple_positions[:2] # Return filtered results
mock_response.raise_for_status = MagicMock()
# Setup mock client
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
# Setup async context manager
mock_client_instance = MagicMock()
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_instance.__aexit__ = AsyncMock(return_value=None)
mock_client_class.return_value = mock_client_instance
# Execute
ratings = await api_client.get_position_ratings(8807, positions=['SS', '2B'])
# Verify
assert len(ratings) == 2
# Verify filter was passed to API
mock_client.get.assert_called_once()
call_args = mock_client.get.call_args
assert 'position' in call_args[1]['params']
assert call_args[1]['params']['position'] == ['SS', '2B']
@pytest.mark.asyncio
@patch('httpx.AsyncClient')
async def test_get_positions_wrapped_in_positions_key(self, mock_client_class, api_client, mock_multiple_positions):
"""Test handling API response wrapped in 'positions' key"""
# Setup mock response - API returns dict with 'positions' key
mock_response = MagicMock()
mock_response.json.return_value = {'positions': mock_multiple_positions}
mock_response.raise_for_status = MagicMock()
# Setup mock client
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
# Setup async context manager
mock_client_instance = MagicMock()
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_instance.__aexit__ = AsyncMock(return_value=None)
mock_client_class.return_value = mock_client_instance
# Execute
ratings = await api_client.get_position_ratings(8807)
# Verify - should handle both response formats
assert len(ratings) == 3
@pytest.mark.asyncio
@patch('httpx.AsyncClient')
async def test_get_empty_positions_list(self, mock_client_class, api_client):
"""Test fetching positions when player has none (empty list)"""
# Setup mock response
mock_response = MagicMock()
mock_response.json.return_value = []
mock_response.raise_for_status = MagicMock()
# Setup mock client
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
# Setup async context manager
mock_client_instance = MagicMock()
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_instance.__aexit__ = AsyncMock(return_value=None)
mock_client_class.return_value = mock_client_instance
# Execute
ratings = await api_client.get_position_ratings(9999)
# Verify
assert len(ratings) == 0
class TestGetPositionRatingsErrors:
"""Tests for error handling in position rating retrieval"""
@pytest.mark.asyncio
@patch('httpx.AsyncClient')
async def test_http_404_error(self, mock_client_class, api_client):
"""Test handling 404 Not Found error"""
# Setup mock response to raise 404
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"404 Not Found",
request=MagicMock(),
response=MagicMock(status_code=404)
)
# Setup mock client
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
# Setup async context manager
mock_client_instance = MagicMock()
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_instance.__aexit__ = AsyncMock(return_value=None)
mock_client_class.return_value = mock_client_instance
# Execute and verify exception
with pytest.raises(httpx.HTTPStatusError):
await api_client.get_position_ratings(9999)
@pytest.mark.asyncio
@patch('httpx.AsyncClient')
async def test_http_500_error(self, mock_client_class, api_client):
"""Test handling 500 Internal Server Error"""
# Setup mock response to raise 500
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"500 Internal Server Error",
request=MagicMock(),
response=MagicMock(status_code=500)
)
# Setup mock client
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
# Setup async context manager
mock_client_instance = MagicMock()
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_instance.__aexit__ = AsyncMock(return_value=None)
mock_client_class.return_value = mock_client_instance
# Execute and verify exception
with pytest.raises(httpx.HTTPStatusError):
await api_client.get_position_ratings(8807)
@pytest.mark.asyncio
@patch('httpx.AsyncClient')
async def test_timeout_error(self, mock_client_class, api_client):
"""Test handling timeout"""
# Setup mock client to raise timeout
mock_client = MagicMock()
mock_client.get = AsyncMock(side_effect=httpx.TimeoutException("Request timeout"))
# Setup async context manager
mock_client_instance = MagicMock()
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_instance.__aexit__ = AsyncMock(return_value=None)
mock_client_class.return_value = mock_client_instance
# Execute and verify exception
with pytest.raises(httpx.TimeoutException):
await api_client.get_position_ratings(8807)
@pytest.mark.asyncio
@patch('httpx.AsyncClient')
async def test_connection_error(self, mock_client_class, api_client):
"""Test handling connection error"""
# Setup mock client to raise connection error
mock_client = MagicMock()
mock_client.get = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
# Setup async context manager
mock_client_instance = MagicMock()
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_instance.__aexit__ = AsyncMock(return_value=None)
mock_client_class.return_value = mock_client_instance
# Execute and verify exception
with pytest.raises(httpx.ConnectError):
await api_client.get_position_ratings(8807)
@pytest.mark.asyncio
@patch('httpx.AsyncClient')
async def test_malformed_json_response(self, mock_client_class, api_client):
"""Test handling malformed JSON in response"""
# Setup mock response to raise JSON decode error
mock_response = MagicMock()
mock_response.json.side_effect = ValueError("Invalid JSON")
mock_response.raise_for_status = MagicMock()
# Setup mock client
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
# Setup async context manager
mock_client_instance = MagicMock()
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_instance.__aexit__ = AsyncMock(return_value=None)
mock_client_class.return_value = mock_client_instance
# Execute and verify exception
with pytest.raises(Exception): # Will raise ValueError
await api_client.get_position_ratings(8807)
class TestAPIRequestConstruction:
"""Tests for proper API request construction"""
@pytest.mark.asyncio
@patch('httpx.AsyncClient')
async def test_correct_url_construction(self, mock_client_class, api_client, mock_position_data):
"""Test that correct URL is constructed"""
# Setup mock response
mock_response = MagicMock()
mock_response.json.return_value = [mock_position_data]
mock_response.raise_for_status = MagicMock()
# Setup mock client
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
# Setup async context manager
mock_client_instance = MagicMock()
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_instance.__aexit__ = AsyncMock(return_value=None)
mock_client_class.return_value = mock_client_instance
# Execute
await api_client.get_position_ratings(8807)
# Verify URL
mock_client.get.assert_called_once()
call_args = mock_client.get.call_args
assert call_args[0][0] == "https://test.api.com/api/v2/cardpositions"
@pytest.mark.asyncio
@patch('httpx.AsyncClient')
async def test_timeout_configuration(self, mock_client_class, api_client, mock_position_data):
"""Test that timeout is configured correctly"""
# Setup mock response
mock_response = MagicMock()
mock_response.json.return_value = [mock_position_data]
mock_response.raise_for_status = MagicMock()
# Setup mock client
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
# Setup async context manager
mock_client_instance = MagicMock()
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_instance.__aexit__ = AsyncMock(return_value=None)
mock_client_class.return_value = mock_client_instance
# Execute
await api_client.get_position_ratings(8807)
# Verify timeout was passed to AsyncClient
mock_client_class.assert_called_once()
call_kwargs = mock_client_class.call_args[1]
assert 'timeout' in call_kwargs
assert call_kwargs['timeout'] == api_client.timeout
class TestPositionRatingModelParsing:
"""Tests for parsing API response into PositionRating models"""
@pytest.mark.asyncio
@patch('httpx.AsyncClient')
async def test_all_fields_parsed(self, mock_client_class, api_client):
"""Test that all PositionRating fields are parsed correctly"""
# Setup mock with all fields
full_data = {
"position": "C",
"innings": 1200,
"range": 2,
"error": 5,
"arm": 1,
"pb": 3,
"overthrow": 1
}
mock_response = MagicMock()
mock_response.json.return_value = [full_data]
mock_response.raise_for_status = MagicMock()
# Setup mock client
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
# Setup async context manager
mock_client_instance = MagicMock()
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_instance.__aexit__ = AsyncMock(return_value=None)
mock_client_class.return_value = mock_client_instance
# Execute
ratings = await api_client.get_position_ratings(8807)
# Verify all fields
assert len(ratings) == 1
rating = ratings[0]
assert rating.position == "C"
assert rating.innings == 1200
assert rating.range == 2
assert rating.error == 5
assert rating.arm == 1
assert rating.pb == 3
assert rating.overthrow == 1
@pytest.mark.asyncio
@patch('httpx.AsyncClient')
async def test_optional_fields_none(self, mock_client_class, api_client):
"""Test that optional fields can be None"""
# Setup mock with minimal fields
minimal_data = {
"position": "LF",
"innings": 800,
"range": 3,
"error": 10,
"arm": None,
"pb": None,
"overthrow": None
}
mock_response = MagicMock()
mock_response.json.return_value = [minimal_data]
mock_response.raise_for_status = MagicMock()
# Setup mock client
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
# Setup async context manager
mock_client_instance = MagicMock()
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_instance.__aexit__ = AsyncMock(return_value=None)
mock_client_class.return_value = mock_client_instance
# Execute
ratings = await api_client.get_position_ratings(8807)
# Verify
assert len(ratings) == 1
rating = ratings[0]
assert rating.position == "LF"
assert rating.arm is None
assert rating.pb is None
assert rating.overthrow is None