"""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 def _make_ctx(response): """Create an async context manager mock wrapping a response.""" ctx = AsyncMock() ctx.__aenter__.return_value = response ctx.__aexit__.return_value = None return ctx def _make_session(): """Create a properly configured mock session for TMDB tests.""" session = MagicMock() session.closed = False session.close = AsyncMock() return session 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 = AsyncMock() mock_response.status = 429 mock_response.headers = {"Retry-After": "2"} session = _make_session() session.get.return_value = _make_ctx(mock_response) client.session = session 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 def mock_get_side_effect(*args, **kwargs): nonlocal call_count call_count += 1 if call_count == 1: return _make_ctx(mock_response_429) return _make_ctx(mock_response_200) session = _make_session() session.get.side_effect = mock_get_side_effect client.session = session with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: result = await client._request("test/endpoint", max_retries=2) 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 = {} 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 def mock_get_side_effect(*args, **kwargs): nonlocal call_count call_count += 1 if call_count == 1: return _make_ctx(mock_response_429) return _make_ctx(mock_response_200) session = _make_session() session.get.side_effect = mock_get_side_effect client.session = session with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: result = await client._request("test/endpoint", max_retries=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 def mock_get_side_effect(*args, **kwargs): nonlocal call_count response = responses[call_count] call_count += 1 return _make_ctx(response) session = _make_session() session.get.side_effect = mock_get_side_effect client.session = session with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: result = await client._request("test/endpoint", max_retries=3) 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") session = _make_session() session.get.side_effect = asyncio.TimeoutError() client.session = session with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: with pytest.raises(TMDBAPIError): await client._request("test/endpoint", max_retries=3) 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_exponential_backoff_on_client_error(self): """Test exponential backoff on aiohttp ClientError.""" client = TMDBClient(api_key="test_key") session = _make_session() session.get.side_effect = aiohttp.ClientError("Connection failed") client.session = session with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: with pytest.raises(TMDBAPIError): await client._request("test/endpoint", max_retries=3) 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 def mock_get_side_effect(*args, **kwargs): nonlocal call_count call_count += 1 if call_count == 1: raise asyncio.TimeoutError() mock_response = AsyncMock() mock_response.status = 200 mock_response.json = AsyncMock(return_value={"data": "success"}) mock_response.raise_for_status = MagicMock() return _make_ctx(mock_response) session = _make_session() session.get.side_effect = mock_get_side_effect client.session = session with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: 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") session = _make_session() session.get.side_effect = asyncio.TimeoutError() client.session = session with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: max_retries = 5 with pytest.raises(TMDBAPIError) as exc_info: await client._request("test/endpoint", max_retries=max_retries) 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.""" client = TMDBClient(api_key="test_key") mock_response = AsyncMock() mock_response.status = 429 mock_response.headers = {"Retry-After": "3600"} session = _make_session() session.get.return_value = _make_ctx(mock_response) client.session = session with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: with pytest.raises(TMDBAPIError): await client._request("test/endpoint", max_retries=2) 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 session = _make_session() session.get.return_value = _make_ctx(mock_response) client.session = session 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 session = _make_session() session.get.return_value = _make_ctx(mock_response) client.session = session 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 def mock_get_side_effect(*args, **kwargs): nonlocal call_count call_count += 1 if call_count < 3: return _make_ctx(mock_response_500) 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 _make_ctx(mock_response_200) session = _make_session() session.get.side_effect = mock_get_side_effect client.session = session with patch("asyncio.sleep", new_callable=AsyncMock): 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") session = _make_session() session.get.side_effect = aiohttp.ClientConnectorError( connection_key=MagicMock(), os_error=OSError("Network unreachable"), ) client.session = session with patch("asyncio.sleep", new_callable=AsyncMock): 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") session = _make_session() session.get.side_effect = asyncio.TimeoutError() client.session = session with patch("asyncio.sleep", new_callable=AsyncMock): 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 def mock_get_side_effect(*args, **kwargs): nonlocal call_count call_count += 1 if call_count == 1: raise asyncio.TimeoutError() mock_response = AsyncMock() mock_response.status = 200 mock_response.json = AsyncMock(return_value={"data": "recovered"}) mock_response.raise_for_status = MagicMock() return _make_ctx(mock_response) session = _make_session() session.get.side_effect = mock_get_side_effect client.session = session with patch("asyncio.sleep", new_callable=AsyncMock): 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() session = _make_session() session.get.return_value = _make_ctx(mock_response) client.session = session await client._request("test/endpoint") assert session.get.called call_kwargs = session.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") session = _make_session() session.get.side_effect = asyncio.TimeoutError() client.session = session with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: max_retries = 4 with pytest.raises(TMDBAPIError): await client._request("test/endpoint", max_retries=max_retries) 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] 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() session = _make_session() session.get.return_value = _make_ctx(mock_response) client.session = session result1 = await client._request("test/endpoint", {"param": "value"}) assert result1 == {"cached": "data"} result2 = await client._request("test/endpoint", {"param": "value"}) assert result2 == {"cached": "data"} assert session.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() session = _make_session() session.get.return_value = _make_ctx(mock_response) client.session = session await client._request("test/endpoint", {"param": "value1"}) await client._request("test/endpoint", {"param": "value2"}) assert session.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() session = _make_session() session.get.return_value = _make_ctx(mock_response) client.session = session await client._request("test/endpoint") assert session.get.call_count == 1 await client._request("test/endpoint") assert session.get.call_count == 1 client.clear_cache() await client._request("test/endpoint") assert session.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") await client._ensure_session() assert client.session is not None await client.close() assert client.session is None or client.session.closed 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 = MagicMock() mock_session.closed = False mock_session.close = AsyncMock() mock_session.get.return_value = _make_ctx(mock_response) mock_session_class.return_value = mock_session await client._request("test/endpoint") 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 def mock_get_side_effect(*args, **kwargs): nonlocal call_count call_count += 1 if call_count == 1: raise aiohttp.ClientError("Connector is closed") mock_response = AsyncMock() mock_response.status = 200 mock_response.json = AsyncMock(return_value={"recovered": True}) mock_response.raise_for_status = MagicMock() return _make_ctx(mock_response) session = _make_session() session.get.side_effect = mock_get_side_effect client.session = session with patch("aiohttp.ClientSession", return_value=session), \ patch("aiohttp.TCPConnector"), \ patch("asyncio.sleep", new_callable=AsyncMock): result = await client._request("test/endpoint", max_retries=3) assert result == {"recovered": True} assert call_count == 2 await client.close()