- 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
606 lines
20 KiB
Python
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()
|