Add TMDB rate limiting tests (22 tests, needs async mock refinement)

This commit is contained in:
2026-02-01 09:55:18 +01:00
parent 08123d40e4
commit 212b971bba
5 changed files with 647 additions and 12 deletions

View File

@@ -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 }) => {

View File

@@ -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() {

View File

@@ -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

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