Add TMDB rate limiting tests (22 tests, needs async mock refinement)
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
* Tests queue management user flows, download control, and persistence
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Queue Page - Initial Load', () => {
|
||||
test('should load queue page successfully', async ({ page }) => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Tests queue management, button handlers, display updates, and statistics
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock DOM setup
|
||||
function setupDOM() {
|
||||
|
||||
@@ -6,14 +6,17 @@ server restart recovery, authentication, message ordering, and broadcast filteri
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from typing import List, Dict, Any
|
||||
from typing import Any, Dict, List
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import WebSocket
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.server.services.websocket_service import WebSocketService, get_websocket_service
|
||||
from src.server.services.websocket_service import (
|
||||
WebSocketService,
|
||||
get_websocket_service,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
628
tests/unit/test_tmdb_rate_limiting.py
Normal file
628
tests/unit/test_tmdb_rate_limiting.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user