"""Integration tests for TMDB API resilience and error handling. This module tests TMDB API resilience scenarios including unavailability, partial/invalid responses, network timeouts, and fallback behavior. """ import asyncio from unittest.mock import AsyncMock, MagicMock, patch import aiohttp import pytest from src.core.services.tmdb_client import TMDBAPIError, TMDBClient class TestTMDBAPIUnavailability: """Test handling of TMDB API unavailability.""" @pytest.mark.asyncio async def test_503_service_unavailable(self): """Test handling of 503 Service Unavailable response.""" client = TMDBClient(api_key="test_key") # Create mock session mock_response = AsyncMock() mock_response.status = 503 mock_response.raise_for_status.side_effect = aiohttp.ClientResponseError( request_info=MagicMock(), history=(), status=503, message="Service Unavailable" ) mock_session = AsyncMock() mock_session.closed = False mock_ctx = AsyncMock() mock_ctx.__aenter__.return_value = mock_response mock_ctx.__aexit__.return_value = None mock_session.get.return_value = mock_ctx client.session = mock_session with patch('asyncio.sleep', new_callable=AsyncMock): with pytest.raises(TMDBAPIError): await client._request("tv/123", max_retries=2) await client.close() @pytest.mark.asyncio async def test_connection_refused_error(self): """Test handling of connection refused error.""" client = TMDBClient(api_key="test_key") mock_session = AsyncMock() mock_session.closed = False mock_session.get.side_effect = aiohttp.ClientConnectorError( connection_key=MagicMock(), os_error=ConnectionRefusedError("Connection refused") ) client.session = mock_session with patch('asyncio.sleep', new_callable=AsyncMock): with pytest.raises(TMDBAPIError): await client._request("tv/123", max_retries=2) await client.close() @pytest.mark.asyncio async def test_dns_resolution_failure(self): """Test handling of DNS resolution failure.""" client = TMDBClient(api_key="test_key") mock_session = AsyncMock() mock_session.closed = False mock_session.get.side_effect = aiohttp.ClientConnectorError( connection_key=MagicMock(), os_error=OSError("Name or service not known") ) client.session = mock_session with patch('asyncio.sleep', new_callable=AsyncMock): with pytest.raises(TMDBAPIError): await client._request("search/tv", {"query": "test"}, max_retries=2) await client.close() class TestTMDBPartialDataResponse: """Test handling of partial or incomplete data responses.""" @pytest.mark.asyncio async def test_missing_required_fields(self): """Test handling of response missing required fields.""" client = TMDBClient(api_key="test_key") # Response missing expected fields incomplete_data = { # Missing 'results' field that search_tv_show expects "page": 1, "total_pages": 0 } mock_response = AsyncMock() mock_response.status = 200 mock_response.json = AsyncMock(return_value=incomplete_data) mock_response.raise_for_status = MagicMock() mock_session = AsyncMock() mock_session.closed = False mock_ctx = AsyncMock() mock_ctx.__aenter__.return_value = mock_response mock_ctx.__aexit__.return_value = None mock_session.get.return_value = mock_ctx client.session = mock_session # Should return partial data (client doesn't validate structure) result = await client.search_tv_show("test query") assert "page" in result assert "results" not in result await client.close() @pytest.mark.asyncio async def test_empty_results_list(self): """Test handling of search with no results.""" client = TMDBClient(api_key="test_key") empty_results = { "page": 1, "results": [], "total_pages": 0, "total_results": 0 } mock_response = AsyncMock() mock_response.status = 200 mock_response.json = AsyncMock(return_value=empty_results) mock_response.raise_for_status = MagicMock() mock_session = AsyncMock() mock_session.closed = False mock_ctx = AsyncMock() mock_ctx.__aenter__.return_value = mock_response mock_ctx.__aexit__.return_value = None mock_session.get.return_value = mock_ctx client.session = mock_session result = await client.search_tv_show("nonexistent show 12345") assert result["results"] == [] assert result["total_results"] == 0 await client.close() @pytest.mark.asyncio async def test_null_values_in_response(self): """Test handling of null values in response data.""" client = TMDBClient(api_key="test_key") data_with_nulls = { "id": 123, "name": "Test Show", "overview": None, "poster_path": None, "backdrop_path": None, "first_air_date": None } mock_response = AsyncMock() mock_response.status = 200 mock_response.json = AsyncMock(return_value=data_with_nulls) mock_response.raise_for_status = MagicMock() mock_session = AsyncMock() mock_session.closed = False mock_ctx = AsyncMock() mock_ctx.__aenter__.return_value = mock_response mock_ctx.__aexit__.return_value = None mock_session.get.return_value = mock_ctx client.session = mock_session result = await client.get_tv_show_details(123) assert result["id"] == 123 assert result["overview"] is None assert result["poster_path"] is None await client.close() class TestTMDBInvalidResponseFormat: """Test handling of invalid response formats.""" @pytest.mark.asyncio async def test_malformed_json_response(self): """Test handling of malformed JSON response.""" client = TMDBClient(api_key="test_key") mock_response = AsyncMock() mock_response.status = 200 mock_response.json.side_effect = aiohttp.ContentTypeError( request_info=MagicMock(), history=(), message="Invalid JSON" ) mock_response.raise_for_status = MagicMock() mock_session = AsyncMock() mock_session.closed = False mock_ctx = AsyncMock() mock_ctx.__aenter__.return_value = mock_response mock_ctx.__aexit__.return_value = None mock_session.get.return_value = mock_ctx client.session = mock_session with patch('asyncio.sleep', new_callable=AsyncMock): with pytest.raises(TMDBAPIError): await client._request("tv/123", max_retries=2) await client.close() @pytest.mark.asyncio async def test_non_dict_json_response(self): """Test handling of JSON response that isn't a dictionary.""" client = TMDBClient(api_key="test_key") # Response is a list instead of dict invalid_structure = ["unexpected", "list", "format"] mock_response = AsyncMock() mock_response.status = 200 mock_response.json = AsyncMock(return_value=invalid_structure) mock_response.raise_for_status = MagicMock() mock_session = AsyncMock() mock_session.closed = False mock_ctx = AsyncMock() mock_ctx.__aenter__.return_value = mock_response mock_ctx.__aexit__.return_value = None mock_session.get.return_value = mock_ctx client.session = mock_session # Client returns what API gives (doesn't validate structure) result = await client._request("tv/123") assert isinstance(result, list) await client.close() @pytest.mark.asyncio async def test_html_error_page_response(self): """Test handling of HTML error page instead of JSON.""" client = TMDBClient(api_key="test_key") mock_response = AsyncMock() mock_response.status = 200 mock_response.json.side_effect = aiohttp.ContentTypeError( request_info=MagicMock(), history=(), message="Expecting JSON, got HTML" ) mock_response.raise_for_status = MagicMock() mock_session = AsyncMock() mock_session.closed = False mock_ctx = AsyncMock() mock_ctx.__aenter__.return_value = mock_response mock_ctx.__aexit__.return_value = None mock_session.get.return_value = mock_ctx client.session = mock_session with patch('asyncio.sleep', new_callable=AsyncMock): with pytest.raises(TMDBAPIError): await client._request("search/tv", {"query": "test"}, max_retries=2) await client.close() class TestTMDBNetworkTimeout: """Test handling of network timeouts.""" @pytest.mark.asyncio async def test_connect_timeout(self): """Test handling of connection timeout.""" client = TMDBClient(api_key="test_key") mock_session = AsyncMock() mock_session.closed = False mock_session.get.side_effect = asyncio.TimeoutError() client.session = mock_session with patch('asyncio.sleep', new_callable=AsyncMock): with pytest.raises(TMDBAPIError) as exc_info: await client._request("tv/123", max_retries=2) assert "failed after" in str(exc_info.value).lower() await client.close() @pytest.mark.asyncio async def test_read_timeout(self): """Test handling of read timeout during response.""" client = TMDBClient(api_key="test_key") mock_session = AsyncMock() mock_session.closed = False mock_session.get.side_effect = asyncio.TimeoutError() client.session = mock_session with patch('asyncio.sleep', new_callable=AsyncMock): with pytest.raises(TMDBAPIError): await client.search_tv_show("test query") await client.close() @pytest.mark.asyncio async def test_slow_response_recovery(self): """Test successful retry after slow response 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: # First attempt times out raise asyncio.TimeoutError() # Second attempt succeeds mock_response = AsyncMock() mock_response.status = 200 mock_response.json = AsyncMock(return_value={"recovered": True}) mock_response.raise_for_status = MagicMock() mock_ctx = AsyncMock() mock_ctx.__aenter__.return_value = mock_response mock_ctx.__aexit__.return_value = None return mock_ctx 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): result = await client._request("tv/123", max_retries=3) assert result == {"recovered": True} assert call_count == 2 await client.close() class TestTMDBFallbackBehavior: """Test fallback behavior when TMDB is unavailable.""" @pytest.mark.asyncio async def test_graceful_degradation_on_search_failure(self): """Test that search failure can be handled gracefully.""" client = TMDBClient(api_key="test_key") mock_session = AsyncMock() mock_session.closed = False mock_session.get.side_effect = aiohttp.ClientError("Connection failed") client.session = mock_session with patch('asyncio.sleep', new_callable=AsyncMock): # Application code should handle TMDBAPIError gracefully with pytest.raises(TMDBAPIError): await client.search_tv_show("test query") await client.close() @pytest.mark.asyncio async def test_details_request_failure_handling(self): """Test that details request failure can be handled gracefully.""" client = TMDBClient(api_key="test_key") mock_response = AsyncMock() mock_response.status = 404 mock_session = AsyncMock() mock_session.closed = False mock_ctx = AsyncMock() mock_ctx.__aenter__.return_value = mock_response mock_ctx.__aexit__.return_value = None mock_session.get.return_value = mock_ctx client.session = mock_session # 404 should raise TMDBAPIError with pytest.raises(TMDBAPIError) as exc_info: await client.get_tv_show_details(999999) assert "Resource not found" in str(exc_info.value) await client.close() @pytest.mark.asyncio async def test_image_download_failure_handling(self): """Test that image download failure can be handled gracefully.""" import tempfile from pathlib import Path client = TMDBClient(api_key="test_key") mock_session = AsyncMock() mock_session.closed = False mock_session.get.side_effect = aiohttp.ClientError("Download failed") client.session = mock_session with tempfile.TemporaryDirectory() as tmpdir: local_path = Path(tmpdir) / "poster.jpg" with pytest.raises(TMDBAPIError) as exc_info: await client.download_image("/path/to/image.jpg", local_path) assert "Failed to download image" in str(exc_info.value) await client.close() class TestTMDBCacheResilience: """Test cache behavior during error scenarios.""" @pytest.mark.asyncio async def test_cache_not_populated_on_error(self): """Test that cache is not populated when request fails.""" client = TMDBClient(api_key="test_key") mock_session = AsyncMock() mock_session.closed = False mock_session.get.side_effect = aiohttp.ClientError("Request failed") client.session = mock_session with patch('asyncio.sleep', new_callable=AsyncMock): with pytest.raises(TMDBAPIError): await client._request("tv/123", max_retries=1) # Cache should be empty after failed request assert len(client._cache) == 0 await client.close() @pytest.mark.asyncio async def test_cache_persists_across_retries(self): """Test that cache persists even when some requests fail.""" client = TMDBClient(api_key="test_key") # First successful request mock_response_success = AsyncMock() mock_response_success.status = 200 mock_response_success.json = AsyncMock(return_value={"data": "cached"}) mock_response_success.raise_for_status = MagicMock() mock_ctx_success = AsyncMock() mock_ctx_success.__aenter__.return_value = mock_response_success mock_ctx_success.__aexit__.return_value = None mock_session = AsyncMock() mock_session.closed = False mock_session.get.return_value = mock_ctx_success client.session = mock_session # Cache a successful request result1 = await client._request("tv/123") assert result1 == {"data": "cached"} assert len(client._cache) == 1 # Subsequent request with same params should use cache result2 = await client._request("tv/123") assert result2 == {"data": "cached"} # Only one actual HTTP request should have been made assert mock_session.get.call_count == 1 await client.close() @pytest.mark.asyncio async def test_cache_isolation_between_clients(self): """Test that cache is isolated between different client instances.""" client1 = TMDBClient(api_key="key1") client2 = TMDBClient(api_key="key2") # Mock response for client1 mock_response1 = AsyncMock() mock_response1.status = 200 mock_response1.json = AsyncMock(return_value={"client": "1"}) mock_response1.raise_for_status = MagicMock() mock_ctx1 = AsyncMock() mock_ctx1.__aenter__.return_value = mock_response1 mock_ctx1.__aexit__.return_value = None mock_session1 = AsyncMock() mock_session1.closed = False mock_session1.get.return_value = mock_ctx1 client1.session = mock_session1 # Make request with client1 result1 = await client1._request("tv/123") assert result1 == {"client": "1"} # client2 should not have access to client1's cache assert len(client2._cache) == 0 await client1.close() await client2.close() class TestTMDBContextManager: """Test async context manager behavior.""" @pytest.mark.asyncio async def test_context_manager_creates_session(self): """Test that context manager properly creates session.""" async with TMDBClient(api_key="test_key") as client: assert client.session is not None assert not client.session.closed @pytest.mark.asyncio async def test_context_manager_closes_session(self): """Test that context manager properly closes session on exit.""" client = TMDBClient(api_key="test_key") async with client: assert client.session is not None # Session should be closed after context exit assert client.session is None or client.session.closed @pytest.mark.asyncio async def test_context_manager_handles_exception(self): """Test that context manager closes session even on exception.""" client = TMDBClient(api_key="test_key") try: async with client: assert client.session is not None raise ValueError("Test exception") except ValueError: pass # Session should still be closed after exception assert client.session is None or client.session.closed