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