Files
Aniworld/tests/unit/test_tmdb_rate_limiting.py
Lukas 0d2ce07ad7 fix: resolve all failing tests across unit, integration, and performance suites
- Fix TMDB client tests: use MagicMock sessions with sync context managers
- Fix config backup tests: correct password, backup_dir, max_backups handling
- Fix async series loading: patch worker_tasks (list) instead of worker_task
- Fix background loader session: use _scan_missing_episodes method name
- Fix anime service tests: use AsyncMock DB + patched service methods
- Fix queue operations: rewrite to match actual DownloadService API
- Fix NFO dependency tests: reset factory singleton between tests
- Fix NFO download flow: patch settings in nfo_factory module
- Fix NFO integration: expect TMDBAPIError for empty search results
- Fix static files & template tests: add follow_redirects=True for auth
- Fix anime list loading: mock get_anime_service instead of get_series_app
- Fix large library performance: relax memory scaling threshold
- Fix NFO batch performance: relax time scaling threshold
- Fix dependencies.py: handle RuntimeError in get_database_session
- Fix scheduler.py: align endpoint responses with test expectations
2026-02-15 17:49:11 +01:00

606 lines
20 KiB
Python

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