|
|
|
|
@@ -0,0 +1,628 @@
|
|
|
|
|
"""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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 with 429 status
|
|
|
|
|
mock_response = AsyncMock()
|
|
|
|
|
mock_response.status = 429
|
|
|
|
|
mock_response.headers = {'Retry-After': '2'}
|
|
|
|
|
|
|
|
|
|
with patch.object(client, '_ensure_session'), \
|
|
|
|
|
patch('aiohttp.ClientSession.get') as mock_get:
|
|
|
|
|
mock_get.return_value.__aenter__.return_value = mock_response
|
|
|
|
|
|
|
|
|
|
# Should retry after rate limit
|
|
|
|
|
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
|
|
|
|
|
async def mock_get_side_effect(*args, **kwargs):
|
|
|
|
|
nonlocal call_count
|
|
|
|
|
call_count += 1
|
|
|
|
|
if call_count == 1:
|
|
|
|
|
mock_ctx = AsyncMock()
|
|
|
|
|
mock_ctx.__aenter__.return_value = mock_response_429
|
|
|
|
|
mock_ctx.__aexit__.return_value = None
|
|
|
|
|
return mock_ctx
|
|
|
|
|
mock_ctx = AsyncMock()
|
|
|
|
|
mock_ctx.__aenter__.return_value = mock_response_200
|
|
|
|
|
mock_ctx.__aexit__.return_value = None
|
|
|
|
|
return mock_ctx
|
|
|
|
|
|
|
|
|
|
# Mock session
|
|
|
|
|
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) as mock_sleep:
|
|
|
|
|
result = await client._request("test/endpoint", max_retries=2)
|
|
|
|
|
|
|
|
|
|
# Verify sleep was called with retry_after value
|
|
|
|
|
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 = {} # No Retry-After header
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
async def mock_get_side_effect(*args, **kwargs):
|
|
|
|
|
nonlocal call_count
|
|
|
|
|
call_count += 1
|
|
|
|
|
if call_count == 1:
|
|
|
|
|
return mock_response_429
|
|
|
|
|
return mock_response_200
|
|
|
|
|
|
|
|
|
|
with patch.object(client, '_ensure_session'), \
|
|
|
|
|
patch('aiohttp.ClientSession.get') as mock_get, \
|
|
|
|
|
patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep:
|
|
|
|
|
mock_get.side_effect = mock_get_side_effect
|
|
|
|
|
|
|
|
|
|
result = await client._request("test/endpoint", max_retries=2)
|
|
|
|
|
|
|
|
|
|
# Should use default backoff (delay * 2 = 1 * 2 = 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
|
|
|
|
|
|
|
|
|
|
async def mock_get_side_effect(*args, **kwargs):
|
|
|
|
|
nonlocal call_count
|
|
|
|
|
response = responses[call_count]
|
|
|
|
|
call_count += 1
|
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
with patch.object(client, '_ensure_session'), \
|
|
|
|
|
patch('aiohttp.ClientSession.get') as mock_get, \
|
|
|
|
|
patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep:
|
|
|
|
|
mock_get.side_effect = mock_get_side_effect
|
|
|
|
|
|
|
|
|
|
result = await client._request("test/endpoint", max_retries=3)
|
|
|
|
|
|
|
|
|
|
# Verify both retry delays were used
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
with patch.object(client, '_ensure_session'), \
|
|
|
|
|
patch('aiohttp.ClientSession.get') as mock_get, \
|
|
|
|
|
patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep:
|
|
|
|
|
# Mock timeout errors
|
|
|
|
|
mock_get.side_effect = asyncio.TimeoutError()
|
|
|
|
|
|
|
|
|
|
with pytest.raises(TMDBAPIError):
|
|
|
|
|
await client._request("test/endpoint", max_retries=3)
|
|
|
|
|
|
|
|
|
|
# Verify exponential backoff: 1s, 2s
|
|
|
|
|
assert mock_sleep.call_count == 2
|
|
|
|
|
calls = [call[0][0] for call in mock_sleep.call_args_list]
|
|
|
|
|
assert calls == [1, 2] # First retry waits 1s, second waits 2s
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
with patch.object(client, '_ensure_session'), \
|
|
|
|
|
patch('aiohttp.ClientSession.get') as mock_get, \
|
|
|
|
|
patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep:
|
|
|
|
|
mock_get.side_effect = aiohttp.ClientError("Connection failed")
|
|
|
|
|
|
|
|
|
|
with pytest.raises(TMDBAPIError):
|
|
|
|
|
await client._request("test/endpoint", max_retries=3)
|
|
|
|
|
|
|
|
|
|
# Verify exponential backoff
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
async def mock_get_side_effect(*args, **kwargs):
|
|
|
|
|
nonlocal call_count
|
|
|
|
|
call_count += 1
|
|
|
|
|
if call_count == 1:
|
|
|
|
|
raise asyncio.TimeoutError()
|
|
|
|
|
# Second attempt succeeds
|
|
|
|
|
mock_response = AsyncMock()
|
|
|
|
|
mock_response.status = 200
|
|
|
|
|
mock_response.json = AsyncMock(return_value={"data": "success"})
|
|
|
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
|
return mock_response
|
|
|
|
|
|
|
|
|
|
with patch.object(client, '_ensure_session'), \
|
|
|
|
|
patch('aiohttp.ClientSession.get') as mock_get, \
|
|
|
|
|
patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep:
|
|
|
|
|
mock_get.side_effect = mock_get_side_effect
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
with patch.object(client, '_ensure_session'), \
|
|
|
|
|
patch('aiohttp.ClientSession.get') as mock_get, \
|
|
|
|
|
patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep:
|
|
|
|
|
mock_get.side_effect = asyncio.TimeoutError()
|
|
|
|
|
|
|
|
|
|
max_retries = 5
|
|
|
|
|
with pytest.raises(TMDBAPIError) as exc_info:
|
|
|
|
|
await client._request("test/endpoint", max_retries=max_retries)
|
|
|
|
|
|
|
|
|
|
# Should sleep max_retries - 1 times (no sleep after last failed attempt)
|
|
|
|
|
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 (typically 429 with specific message)."""
|
|
|
|
|
client = TMDBClient(api_key="test_key")
|
|
|
|
|
|
|
|
|
|
# Mock 429 with quota exhaustion message
|
|
|
|
|
mock_response = AsyncMock()
|
|
|
|
|
mock_response.status = 429
|
|
|
|
|
mock_response.headers = {'Retry-After': '3600'} # 1 hour
|
|
|
|
|
|
|
|
|
|
with patch.object(client, '_ensure_session'), \
|
|
|
|
|
patch('aiohttp.ClientSession.get') as mock_get, \
|
|
|
|
|
patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep:
|
|
|
|
|
mock_get.return_value.__aenter__.return_value = mock_response
|
|
|
|
|
|
|
|
|
|
with pytest.raises(TMDBAPIError):
|
|
|
|
|
await client._request("test/endpoint", max_retries=2)
|
|
|
|
|
|
|
|
|
|
# Should have tried to wait with the Retry-After value
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
with patch.object(client, '_ensure_session'), \
|
|
|
|
|
patch('aiohttp.ClientSession.get') as mock_get:
|
|
|
|
|
mock_get.return_value.__aenter__.return_value = mock_response
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
with patch.object(client, '_ensure_session'), \
|
|
|
|
|
patch('aiohttp.ClientSession.get') as mock_get:
|
|
|
|
|
mock_get.return_value.__aenter__.return_value = mock_response
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
async def mock_get_side_effect(*args, **kwargs):
|
|
|
|
|
nonlocal call_count
|
|
|
|
|
call_count += 1
|
|
|
|
|
if call_count < 3:
|
|
|
|
|
return mock_response_500
|
|
|
|
|
# Third attempt succeeds
|
|
|
|
|
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 mock_response_200
|
|
|
|
|
|
|
|
|
|
with patch.object(client, '_ensure_session'), \
|
|
|
|
|
patch('aiohttp.ClientSession.get') as mock_get, \
|
|
|
|
|
patch('asyncio.sleep', new_callable=AsyncMock):
|
|
|
|
|
mock_get.side_effect = mock_get_side_effect
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
with patch.object(client, '_ensure_session'), \
|
|
|
|
|
patch('aiohttp.ClientSession.get') as mock_get:
|
|
|
|
|
mock_get.side_effect = aiohttp.ClientConnectorError(
|
|
|
|
|
connection_key=MagicMock(),
|
|
|
|
|
os_error=OSError("Network unreachable")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
with patch.object(client, '_ensure_session'), \
|
|
|
|
|
patch('aiohttp.ClientSession.get') as mock_get:
|
|
|
|
|
mock_get.side_effect = asyncio.TimeoutError()
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
async def mock_get_side_effect(*args, **kwargs):
|
|
|
|
|
nonlocal call_count
|
|
|
|
|
call_count += 1
|
|
|
|
|
if call_count == 1:
|
|
|
|
|
raise asyncio.TimeoutError()
|
|
|
|
|
# Second attempt succeeds
|
|
|
|
|
mock_response = AsyncMock()
|
|
|
|
|
mock_response.status = 200
|
|
|
|
|
mock_response.json = AsyncMock(return_value={"data": "recovered"})
|
|
|
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
|
return mock_response
|
|
|
|
|
|
|
|
|
|
with patch.object(client, '_ensure_session'), \
|
|
|
|
|
patch('aiohttp.ClientSession.get') as mock_get, \
|
|
|
|
|
patch('asyncio.sleep', new_callable=AsyncMock):
|
|
|
|
|
mock_get.side_effect = mock_get_side_effect
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
with patch.object(client, '_ensure_session'), \
|
|
|
|
|
patch('aiohttp.ClientSession.get') as mock_get:
|
|
|
|
|
mock_get.return_value.__aenter__.return_value = mock_response
|
|
|
|
|
|
|
|
|
|
await client._request("test/endpoint")
|
|
|
|
|
|
|
|
|
|
# Verify timeout was configured
|
|
|
|
|
assert mock_get.called
|
|
|
|
|
call_kwargs = mock_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")
|
|
|
|
|
|
|
|
|
|
with patch.object(client, '_ensure_session'), \
|
|
|
|
|
patch('aiohttp.ClientSession.get') as mock_get, \
|
|
|
|
|
patch('asyncio.sleep', new_callable=AsyncMock) as mock_sleep:
|
|
|
|
|
mock_get.side_effect = asyncio.TimeoutError()
|
|
|
|
|
|
|
|
|
|
max_retries = 4
|
|
|
|
|
with pytest.raises(TMDBAPIError):
|
|
|
|
|
await client._request("test/endpoint", max_retries=max_retries)
|
|
|
|
|
|
|
|
|
|
# Verify retries with exponential backoff
|
|
|
|
|
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] # Exponential: 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()
|
|
|
|
|
|
|
|
|
|
with patch.object(client, '_ensure_session'), \
|
|
|
|
|
patch('aiohttp.ClientSession.get') as mock_get:
|
|
|
|
|
mock_get.return_value.__aenter__.return_value = mock_response
|
|
|
|
|
|
|
|
|
|
# First request
|
|
|
|
|
result1 = await client._request("test/endpoint", {"param": "value"})
|
|
|
|
|
assert result1 == {"cached": "data"}
|
|
|
|
|
|
|
|
|
|
# Second request with same params (should use cache)
|
|
|
|
|
result2 = await client._request("test/endpoint", {"param": "value"})
|
|
|
|
|
assert result2 == {"cached": "data"}
|
|
|
|
|
|
|
|
|
|
# Verify only one actual HTTP request was made
|
|
|
|
|
assert mock_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()
|
|
|
|
|
|
|
|
|
|
with patch.object(client, '_ensure_session'), \
|
|
|
|
|
patch('aiohttp.ClientSession.get') as mock_get:
|
|
|
|
|
mock_get.return_value.__aenter__.return_value = mock_response
|
|
|
|
|
|
|
|
|
|
# Two requests with different parameters
|
|
|
|
|
await client._request("test/endpoint", {"param": "value1"})
|
|
|
|
|
await client._request("test/endpoint", {"param": "value2"})
|
|
|
|
|
|
|
|
|
|
# Both should trigger HTTP requests (no cache hit)
|
|
|
|
|
assert mock_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()
|
|
|
|
|
|
|
|
|
|
with patch.object(client, '_ensure_session'), \
|
|
|
|
|
patch('aiohttp.ClientSession.get') as mock_get:
|
|
|
|
|
mock_get.return_value.__aenter__.return_value = mock_response
|
|
|
|
|
|
|
|
|
|
# First request (cache miss)
|
|
|
|
|
await client._request("test/endpoint")
|
|
|
|
|
assert mock_get.call_count == 1
|
|
|
|
|
|
|
|
|
|
# Second request (cache hit)
|
|
|
|
|
await client._request("test/endpoint")
|
|
|
|
|
assert mock_get.call_count == 1
|
|
|
|
|
|
|
|
|
|
# Clear cache
|
|
|
|
|
client.clear_cache()
|
|
|
|
|
|
|
|
|
|
# Third request (cache miss again)
|
|
|
|
|
await client._request("test/endpoint")
|
|
|
|
|
assert mock_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")
|
|
|
|
|
|
|
|
|
|
# Ensure session exists
|
|
|
|
|
await client._ensure_session()
|
|
|
|
|
assert client.session is not None
|
|
|
|
|
|
|
|
|
|
# Close session
|
|
|
|
|
await client.close()
|
|
|
|
|
assert client.session is None or client.session.closed
|
|
|
|
|
|
|
|
|
|
# Session should be recreated on next request
|
|
|
|
|
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 = AsyncMock()
|
|
|
|
|
mock_session.closed = False
|
|
|
|
|
mock_session.get.return_value.__aenter__.return_value = mock_response
|
|
|
|
|
mock_session_class.return_value = mock_session
|
|
|
|
|
|
|
|
|
|
await client._request("test/endpoint")
|
|
|
|
|
|
|
|
|
|
# Verify session was recreated
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
async def mock_get_side_effect(*args, **kwargs):
|
|
|
|
|
nonlocal call_count
|
|
|
|
|
call_count += 1
|
|
|
|
|
if call_count == 1:
|
|
|
|
|
raise aiohttp.ClientError("Connector is closed")
|
|
|
|
|
# Second attempt succeeds after session recreation
|
|
|
|
|
mock_response = AsyncMock()
|
|
|
|
|
mock_response.status = 200
|
|
|
|
|
mock_response.json = AsyncMock(return_value={"recovered": True})
|
|
|
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
|
return mock_response
|
|
|
|
|
|
|
|
|
|
with patch.object(client, '_ensure_session'), \
|
|
|
|
|
patch('aiohttp.ClientSession.get') as mock_get, \
|
|
|
|
|
patch('asyncio.sleep', new_callable=AsyncMock):
|
|
|
|
|
mock_get.side_effect = mock_get_side_effect
|
|
|
|
|
|
|
|
|
|
result = await client._request("test/endpoint", max_retries=3)
|
|
|
|
|
|
|
|
|
|
assert result == {"recovered": True}
|
|
|
|
|
assert call_count == 2
|
|
|
|
|
|
|
|
|
|
await client.close()
|