"""Unit tests for TMDB API rate limiting and error handling. This module tests the TMDBClient's rate limiting detection, exponential backoff retry logic, API quota handling, error response parsing, and timeout handling. """ import asyncio from unittest.mock import AsyncMock, MagicMock, patch import aiohttp import pytest from src.core.services.tmdb_client import TMDBAPIError, TMDBClient class TestTMDBRateLimiting: """Test TMDB API rate limit detection and handling.""" @pytest.mark.asyncio async def test_rate_limit_detection_429_response(self): """Test that 429 response triggers rate limit handling.""" client = TMDBClient(api_key="test_key") # Mock response with 429 status mock_response = AsyncMock() mock_response.status = 429 mock_response.headers = {'Retry-After': '2'} mock_session = AsyncMock() mock_session.closed = False client.session = mock_session with patch.object(mock_session, 'get') as mock_get: mock_get.return_value.__aenter__.return_value = mock_response # Should retry after rate limit with pytest.raises(TMDBAPIError): await client._request("test/endpoint", max_retries=1) await client.close() @pytest.mark.asyncio async def test_rate_limit_retry_after_header(self): """Test respecting Retry-After header on 429 response.""" client = TMDBClient(api_key="test_key") retry_after = 5 mock_response_429 = AsyncMock() mock_response_429.status = 429 mock_response_429.headers = {'Retry-After': str(retry_after)} mock_response_200 = AsyncMock() mock_response_200.status = 200 mock_response_200.json = AsyncMock(return_value={"success": True}) mock_response_200.raise_for_status = MagicMock() call_count = 0 async def mock_get_side_effect(*args, **kwargs): nonlocal call_count call_count += 1 if call_count == 1: mock_ctx = AsyncMock() mock_ctx.__aenter__.return_value = mock_response_429 mock_ctx.__aexit__.return_value = None return mock_ctx mock_ctx = AsyncMock() mock_ctx.__aenter__.return_value = mock_response_200 mock_ctx.__aexit__.return_value = None return mock_ctx # Mock session mock_session = AsyncMock() mock_session.closed = False mock_session.get.side_effect = mock_get_side_effect client.session = mock_session with patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep: result = await client._request("test/endpoint", max_retries=2) # Verify sleep was called with retry_after value mock_sleep.assert_called_once_with(retry_after) assert result == {"success": True} await client.close() @pytest.mark.asyncio async def test_rate_limit_default_backoff_no_retry_after(self): """Test default exponential backoff when Retry-After header missing.""" client = TMDBClient(api_key="test_key") mock_response_429 = AsyncMock() mock_response_429.status = 429 mock_response_429.headers = {} # No Retry-After header mock_response_200 = AsyncMock() mock_response_200.status = 200 mock_response_200.json = AsyncMock(return_value={"success": True}) mock_response_200.raise_for_status = MagicMock() call_count = 0 async def mock_get_side_effect(*args, **kwargs): nonlocal call_count call_count += 1 if call_count == 1: return mock_response_429 return mock_response_200 mock_session = AsyncMock() mock_session.closed = False client.session = mock_session with patch.object(mock_session, 'get') as mock_get, \ patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep: mock_get.side_effect = mock_get_side_effect result = await client._request("test/endpoint", max_retries=2) # Should use default backoff (delay * 2 = 1 * 2 = 2) mock_sleep.assert_called_once_with(2) assert result == {"success": True} await client.close() @pytest.mark.asyncio async def test_rate_limit_multiple_retries(self): """Test multiple 429 responses trigger increasing delays.""" client = TMDBClient(api_key="test_key") mock_response_429_1 = AsyncMock() mock_response_429_1.status = 429 mock_response_429_1.headers = {'Retry-After': '2'} mock_response_429_2 = AsyncMock() mock_response_429_2.status = 429 mock_response_429_2.headers = {'Retry-After': '4'} mock_response_200 = AsyncMock() mock_response_200.status = 200 mock_response_200.json = AsyncMock(return_value={"success": True}) mock_response_200.raise_for_status = MagicMock() responses = [mock_response_429_1, mock_response_429_2, mock_response_200] call_count = 0 async def mock_get_side_effect(*args, **kwargs): nonlocal call_count response = responses[call_count] call_count += 1 return response mock_session = AsyncMock() mock_session.closed = False client.session = mock_session with patch.object(mock_session, 'get') as mock_get, \ patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep: mock_get.side_effect = mock_get_side_effect result = await client._request("test/endpoint", max_retries=3) # Verify both retry delays were used assert mock_sleep.call_count == 2 assert result == {"success": True} await client.close() class TestTMDBExponentialBackoff: """Test exponential backoff retry logic.""" @pytest.mark.asyncio async def test_exponential_backoff_on_timeout(self): """Test exponential backoff delays on timeout errors.""" client = TMDBClient(api_key="test_key") mock_session = AsyncMock() mock_session.closed = False client.session = mock_session with patch.object(mock_session, 'get') as mock_get, \ patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep: # Mock timeout errors mock_get.side_effect = asyncio.TimeoutError() with pytest.raises(TMDBAPIError): await client._request("test/endpoint", max_retries=3) # Verify exponential backoff: 1s, 2s assert mock_sleep.call_count == 2 calls = [call[0][0] for call in mock_sleep.call_args_list] assert calls == [1, 2] # First retry waits 1s, second waits 2s await client.close() @pytest.mark.asyncio async def test_exponential_backoff_on_client_error(self): """Test exponential backoff on aiohttp ClientError.""" client = TMDBClient(api_key="test_key") mock_session = AsyncMock() mock_session.closed = False client.session = mock_session with patch.object(mock_session, 'get') as mock_get, \ patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep: mock_get.side_effect = aiohttp.ClientError("Connection failed") with pytest.raises(TMDBAPIError): await client._request("test/endpoint", max_retries=3) # Verify exponential backoff assert mock_sleep.call_count == 2 calls = [call[0][0] for call in mock_sleep.call_args_list] assert calls == [1, 2] await client.close() @pytest.mark.asyncio async def test_successful_retry_after_backoff(self): """Test successful request after exponential backoff retry.""" client = TMDBClient(api_key="test_key") call_count = 0 async def mock_get_side_effect(*args, **kwargs): nonlocal call_count call_count += 1 if call_count == 1: raise asyncio.TimeoutError() # Second attempt succeeds mock_response = AsyncMock() mock_response.status = 200 mock_response.json = AsyncMock(return_value={"data": "success"}) mock_response.raise_for_status = MagicMock() return mock_response mock_session = AsyncMock() mock_session.closed = False client.session = mock_session with patch.object(mock_session, 'get') as mock_get, \ patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep: mock_get.side_effect = mock_get_side_effect result = await client._request("test/endpoint", max_retries=3) assert result == {"data": "success"} assert mock_sleep.call_count == 1 mock_sleep.assert_called_once_with(1) await client.close() @pytest.mark.asyncio async def test_max_retries_exhausted(self): """Test that retries stop after max_retries attempts.""" client = TMDBClient(api_key="test_key") mock_session = AsyncMock() mock_session.closed = False client.session = mock_session with patch.object(mock_session, 'get') as mock_get, \ patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep: mock_get.side_effect = asyncio.TimeoutError() max_retries = 5 with pytest.raises(TMDBAPIError) as exc_info: await client._request("test/endpoint", max_retries=max_retries) # Should sleep max_retries - 1 times (no sleep after last failed attempt) assert mock_sleep.call_count == max_retries - 1 assert "failed after" in str(exc_info.value) await client.close() class TestTMDBQuotaExhaustion: """Test TMDB API quota exhaustion handling.""" @pytest.mark.asyncio async def test_quota_exhausted_error_message(self): """Test handling of quota exhaustion error (typically 429 with specific message).""" client = TMDBClient(api_key="test_key") # Mock 429 with quota exhaustion message mock_response = AsyncMock() mock_response.status = 429 mock_response.headers = {'Retry-After': '3600'} # 1 hour mock_session = AsyncMock() mock_session.closed = False client.session = mock_session with patch.object(mock_session, 'get') as mock_get, \ patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep: mock_get.return_value.__aenter__.return_value = mock_response with pytest.raises(TMDBAPIError): await client._request("test/endpoint", max_retries=2) # Should have tried to wait with the Retry-After value assert mock_sleep.call_count >= 1 await client.close() @pytest.mark.asyncio async def test_invalid_api_key_401_response(self): """Test handling of invalid API key (401 response).""" client = TMDBClient(api_key="invalid_key") mock_response = AsyncMock() mock_response.status = 401 mock_session = AsyncMock() mock_session.closed = False client.session = mock_session with patch.object(mock_session, 'get') as mock_get: mock_get.return_value.__aenter__.return_value = mock_response with pytest.raises(TMDBAPIError) as exc_info: await client._request("test/endpoint", max_retries=1) assert "Invalid TMDB API key" in str(exc_info.value) await client.close() class TestTMDBErrorParsing: """Test TMDB API error response parsing.""" @pytest.mark.asyncio async def test_404_not_found_error(self): """Test handling of 404 Not Found response.""" client = TMDBClient(api_key="test_key") mock_response = AsyncMock() mock_response.status = 404 mock_session = AsyncMock() mock_session.closed = False client.session = mock_session with patch.object(mock_session, 'get') as mock_get: mock_get.return_value.__aenter__.return_value = mock_response with pytest.raises(TMDBAPIError) as exc_info: await client._request("tv/999999", max_retries=1) assert "Resource not found" in str(exc_info.value) await client.close() @pytest.mark.asyncio async def test_500_server_error_retry(self): """Test retry on 500 server error.""" client = TMDBClient(api_key="test_key") mock_response_500 = AsyncMock() mock_response_500.status = 500 mock_response_500.raise_for_status = MagicMock( side_effect=aiohttp.ClientResponseError( request_info=MagicMock(), history=(), status=500 ) ) call_count = 0 async def mock_get_side_effect(*args, **kwargs): nonlocal call_count call_count += 1 if call_count < 3: return mock_response_500 # Third attempt succeeds mock_response_200 = AsyncMock() mock_response_200.status = 200 mock_response_200.json = AsyncMock(return_value={"recovered": True}) mock_response_200.raise_for_status = MagicMock() return mock_response_200 mock_session = AsyncMock() mock_session.closed = False client.session = mock_session with patch.object(mock_session, 'get') as mock_get, \ patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep: mock_get.side_effect = mock_get_side_effect result = await client._request("test/endpoint", max_retries=3) assert result == {"recovered": True} assert call_count == 3 await client.close() @pytest.mark.asyncio async def test_network_error_parsing(self): """Test parsing of network connection errors.""" client = TMDBClient(api_key="test_key") mock_session = AsyncMock() mock_session.closed = False client.session = mock_session with patch.object(mock_session, 'get') as mock_get: mock_get.side_effect = aiohttp.ClientConnectorError( connection_key=MagicMock(), os_error=OSError("Network unreachable") ) with pytest.raises(TMDBAPIError) as exc_info: await client._request("test/endpoint", max_retries=2) assert "failed after" in str(exc_info.value).lower() await client.close() class TestTMDBTimeoutHandling: """Test TMDB API timeout handling.""" @pytest.mark.asyncio async def test_request_timeout_error(self): """Test handling of request timeout.""" client = TMDBClient(api_key="test_key") mock_session = AsyncMock() mock_session.closed = False client.session = mock_session with patch.object(mock_session, 'get') as mock_get: mock_get.side_effect = asyncio.TimeoutError() with pytest.raises(TMDBAPIError) as exc_info: await client._request("test/endpoint", max_retries=2) assert "failed after" in str(exc_info.value).lower() await client.close() @pytest.mark.asyncio async def test_timeout_with_successful_retry(self): """Test successful retry after timeout.""" client = TMDBClient(api_key="test_key") call_count = 0 async def mock_get_side_effect(*args, **kwargs): nonlocal call_count call_count += 1 if call_count == 1: raise asyncio.TimeoutError() # Second attempt succeeds mock_response = AsyncMock() mock_response.status = 200 mock_response.json = AsyncMock(return_value={"data": "recovered"}) mock_response.raise_for_status = MagicMock() return mock_response mock_session = AsyncMock() mock_session.closed = False client.session = mock_session with patch.object(mock_session, 'get') as mock_get, \ patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep: mock_get.side_effect = mock_get_side_effect result = await client._request("test/endpoint", max_retries=3) assert result == {"data": "recovered"} assert call_count == 2 await client.close() @pytest.mark.asyncio async def test_timeout_configuration(self): """Test that requests use configured timeout.""" client = TMDBClient(api_key="test_key") mock_response = AsyncMock() mock_response.status = 200 mock_response.json = AsyncMock(return_value={"data": "test"}) mock_response.raise_for_status = MagicMock() mock_session = AsyncMock() mock_session.closed = False client.session = mock_session with patch.object(mock_session, 'get') as mock_get: mock_get.return_value.__aenter__.return_value = mock_response await client._request("test/endpoint") # Verify timeout was configured assert mock_get.called call_kwargs = mock_get.call_args[1] assert 'timeout' in call_kwargs assert isinstance(call_kwargs['timeout'], aiohttp.ClientTimeout) await client.close() @pytest.mark.asyncio async def test_multiple_timeout_retries(self): """Test handling of multiple consecutive timeouts.""" client = TMDBClient(api_key="test_key") mock_session = AsyncMock() mock_session.closed = False client.session = mock_session with patch.object(mock_session, 'get') as mock_get, \ patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep: mock_get.side_effect = asyncio.TimeoutError() max_retries = 4 with pytest.raises(TMDBAPIError): await client._request("test/endpoint", max_retries=max_retries) # Verify retries with exponential backoff assert mock_sleep.call_count == max_retries - 1 delays = [call[0][0] for call in mock_sleep.call_args_list] assert delays == [1, 2, 4] # Exponential: 1, 2, 4 await client.close() class TestTMDBCaching: """Test TMDB client caching behavior.""" @pytest.mark.asyncio async def test_cache_hit_prevents_request(self): """Test that cached responses prevent duplicate requests.""" client = TMDBClient(api_key="test_key") mock_response = AsyncMock() mock_response.status = 200 mock_response.json = AsyncMock(return_value={"cached": "data"}) mock_response.raise_for_status = MagicMock() mock_session = AsyncMock() mock_session.closed = False client.session = mock_session with patch.object(mock_session, 'get') as mock_get: mock_get.return_value.__aenter__.return_value = mock_response # First request result1 = await client._request("test/endpoint", {"param": "value"}) assert result1 == {"cached": "data"} # Second request with same params (should use cache) result2 = await client._request("test/endpoint", {"param": "value"}) assert result2 == {"cached": "data"} # Verify only one actual HTTP request was made assert mock_get.call_count == 1 await client.close() @pytest.mark.asyncio async def test_cache_miss_different_params(self): """Test that different parameters result in cache miss.""" client = TMDBClient(api_key="test_key") mock_response = AsyncMock() mock_response.status = 200 mock_response.json = AsyncMock(return_value={"data": "test"}) mock_response.raise_for_status = MagicMock() mock_session = AsyncMock() mock_session.closed = False client.session = mock_session with patch.object(mock_session, 'get') as mock_get: mock_get.return_value.__aenter__.return_value = mock_response # Two requests with different parameters await client._request("test/endpoint", {"param": "value1"}) await client._request("test/endpoint", {"param": "value2"}) # Both should trigger HTTP requests (no cache hit) assert mock_get.call_count == 2 await client.close() @pytest.mark.asyncio async def test_cache_clear(self): """Test clearing the cache.""" client = TMDBClient(api_key="test_key") mock_response = AsyncMock() mock_response.status = 200 mock_response.json = AsyncMock(return_value={"data": "test"}) mock_response.raise_for_status = MagicMock() mock_session = AsyncMock() mock_session.closed = False client.session = mock_session with patch.object(mock_session, 'get') as mock_get: mock_get.return_value.__aenter__.return_value = mock_response # First request (cache miss) await client._request("test/endpoint") assert mock_get.call_count == 1 # Second request (cache hit) await client._request("test/endpoint") assert mock_get.call_count == 1 # Clear cache client.clear_cache() # Third request (cache miss again) await client._request("test/endpoint") assert mock_get.call_count == 2 await client.close() class TestTMDBSessionManagement: """Test TMDB client session management.""" @pytest.mark.asyncio async def test_session_recreation_after_close(self): """Test that session is recreated after being closed.""" client = TMDBClient(api_key="test_key") # Ensure session exists await client._ensure_session() assert client.session is not None # Close session await client.close() assert client.session is None or client.session.closed # Session should be recreated on next request mock_response = AsyncMock() mock_response.status = 200 mock_response.json = AsyncMock(return_value={"data": "test"}) mock_response.raise_for_status = MagicMock() with patch('aiohttp.ClientSession') as mock_session_class, \ patch('aiohttp.TCPConnector'): mock_session = AsyncMock() mock_session.closed = False mock_session.get.return_value.__aenter__.return_value = mock_response mock_session_class.return_value = mock_session await client._request("test/endpoint") # Verify session was recreated assert mock_session_class.called @pytest.mark.asyncio async def test_connector_closed_error_recovery(self): """Test recovery from 'Connector is closed' error.""" client = TMDBClient(api_key="test_key") call_count = 0 async def mock_get_side_effect(*args, **kwargs): nonlocal call_count call_count += 1 if call_count == 1: raise aiohttp.ClientError("Connector is closed") # Second attempt succeeds after session recreation mock_response = AsyncMock() mock_response.status = 200 mock_response.json = AsyncMock(return_value={"recovered": True}) mock_response.raise_for_status = MagicMock() return mock_response mock_session = AsyncMock() mock_session.closed = False client.session = mock_session with patch.object(mock_session, 'get') as mock_get, \ patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep: mock_get.side_effect = mock_get_side_effect result = await client._request("test/endpoint", max_retries=3) assert result == {"recovered": True} assert call_count == 2 await client.close()