"""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 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. Returns a MagicMock (not AsyncMock) so that session.get() returns a value directly instead of a coroutine, which is needed because the real aiohttp session.get() returns a context manager, not a coroutine. """ session = MagicMock() session.closed = False session.close = AsyncMock() return session 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") mock_response = AsyncMock() mock_response.status = 503 mock_response.raise_for_status = MagicMock( side_effect=aiohttp.ClientResponseError( request_info=MagicMock(), history=(), status=503, message="Service Unavailable", ) ) session = _make_session() session.get.return_value = _make_ctx(mock_response) client.session = 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") session = _make_session() session.get.side_effect = aiohttp.ClientConnectorError( connection_key=MagicMock(), os_error=ConnectionRefusedError("Connection refused"), ) client.session = 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") session = _make_session() session.get.side_effect = aiohttp.ClientConnectorError( connection_key=MagicMock(), os_error=OSError("Name or service not known"), ) client.session = 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") incomplete_data = {"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() session = _make_session() session.get.return_value = _make_ctx(mock_response) client.session = session 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() session = _make_session() session.get.return_value = _make_ctx(mock_response) client.session = 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() session = _make_session() session.get.return_value = _make_ctx(mock_response) client.session = 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() session = _make_session() session.get.return_value = _make_ctx(mock_response) client.session = 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") 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() session = _make_session() session.get.return_value = _make_ctx(mock_response) client.session = session 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() session = _make_session() session.get.return_value = _make_ctx(mock_response) client.session = 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") 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("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") session = _make_session() session.get.side_effect = asyncio.TimeoutError() client.session = 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: raise asyncio.TimeoutError() 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("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") session = _make_session() session.get.side_effect = aiohttp.ClientError("Connection failed") client.session = 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_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 session = _make_session() session.get.return_value = _make_ctx(mock_response) client.session = session 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") session = _make_session() session.get.side_effect = aiohttp.ClientError("Download failed") client.session = 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") session = _make_session() session.get.side_effect = aiohttp.ClientError("Request failed") client.session = session with patch("asyncio.sleep", new_callable=AsyncMock): with pytest.raises(TMDBAPIError): await client._request("tv/123", max_retries=1) 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") 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() session = _make_session() session.get.return_value = _make_ctx(mock_response_success) client.session = session result1 = await client._request("tv/123") assert result1 == {"data": "cached"} assert len(client._cache) == 1 result2 = await client._request("tv/123") assert result2 == {"data": "cached"} assert 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_response1 = AsyncMock() mock_response1.status = 200 mock_response1.json = AsyncMock(return_value={"client": "1"}) mock_response1.raise_for_status = MagicMock() session1 = _make_session() session1.get.return_value = _make_ctx(mock_response1) client1.session = session1 result1 = await client1._request("tv/123") assert result1 == {"client": "1"} assert len(client2._cache) == 0 await client1.close() await client2.close()