357 lines
13 KiB
Python
357 lines
13 KiB
Python
"""Unit tests for TMDB client."""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from aiohttp import ClientResponseError, ClientSession
|
|
|
|
from src.core.services.tmdb_client import TMDBAPIError, TMDBClient
|
|
|
|
|
|
@pytest.fixture
|
|
def tmdb_client():
|
|
"""Create TMDB client with test API key."""
|
|
return TMDBClient(api_key="test_api_key")
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_response():
|
|
"""Create mock aiohttp response."""
|
|
mock = AsyncMock()
|
|
mock.status = 200
|
|
mock.json = AsyncMock(return_value={"success": True})
|
|
return mock
|
|
|
|
|
|
class TestTMDBClientInit:
|
|
"""Test TMDB client initialization."""
|
|
|
|
def test_init_with_api_key(self):
|
|
"""Test initialization with API key."""
|
|
client = TMDBClient(api_key="my_key")
|
|
assert client.api_key == "my_key"
|
|
assert client.base_url == "https://api.themoviedb.org/3"
|
|
assert client.image_base_url == "https://image.tmdb.org/t/p"
|
|
assert client.session is None
|
|
assert client._cache == {}
|
|
|
|
def test_init_sets_attributes(self):
|
|
"""Test all attributes are set correctly."""
|
|
client = TMDBClient(api_key="test")
|
|
assert hasattr(client, "api_key")
|
|
assert hasattr(client, "base_url")
|
|
assert hasattr(client, "image_base_url")
|
|
assert hasattr(client, "session")
|
|
assert hasattr(client, "_cache")
|
|
|
|
|
|
class TestTMDBClientContextManager:
|
|
"""Test TMDB client as context manager."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_context_manager(self):
|
|
"""Test async context manager creates session."""
|
|
client = TMDBClient(api_key="test")
|
|
|
|
async with client as c:
|
|
assert c.session is not None
|
|
assert isinstance(c.session, ClientSession)
|
|
|
|
# Session should be closed after context
|
|
assert client.session is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_closes_session(self):
|
|
"""Test close method closes session."""
|
|
client = TMDBClient(api_key="test")
|
|
await client.__aenter__()
|
|
|
|
assert client.session is not None
|
|
await client.close()
|
|
assert client.session is None
|
|
|
|
|
|
class TestTMDBClientSearchTVShow:
|
|
"""Test search_tv_show method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_tv_show_success(self, tmdb_client, mock_response):
|
|
"""Test successful TV show search."""
|
|
mock_response.json = AsyncMock(return_value={
|
|
"results": [
|
|
{"id": 1, "name": "Test Show"},
|
|
{"id": 2, "name": "Another Show"}
|
|
]
|
|
})
|
|
|
|
with patch.object(tmdb_client, "_request", return_value=mock_response.json.return_value):
|
|
result = await tmdb_client.search_tv_show("Test Show")
|
|
|
|
assert "results" in result
|
|
assert len(result["results"]) == 2
|
|
assert result["results"][0]["name"] == "Test Show"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_tv_show_with_year(self, tmdb_client):
|
|
"""Test TV show search with year filter."""
|
|
mock_data = {"results": [{"id": 1, "name": "Test Show", "first_air_date": "2020-01-01"}]}
|
|
|
|
with patch.object(tmdb_client, "_request", return_value=mock_data):
|
|
result = await tmdb_client.search_tv_show("Test Show")
|
|
|
|
assert "results" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_tv_show_empty_results(self, tmdb_client):
|
|
"""Test search with no results."""
|
|
with patch.object(tmdb_client, "_request", return_value={"results": []}):
|
|
result = await tmdb_client.search_tv_show("NonexistentShow")
|
|
|
|
assert result["results"] == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_tv_show_uses_cache(self, tmdb_client):
|
|
"""Test search results are cached."""
|
|
# Clear cache first
|
|
tmdb_client.clear_cache()
|
|
|
|
mock_data = {"results": [{"id": 1, "name": "Cached Show"}]}
|
|
mock_session = MagicMock()
|
|
mock_response = AsyncMock()
|
|
mock_response.status = 200
|
|
mock_response.json = AsyncMock(return_value=mock_data)
|
|
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
|
|
mock_response.__aexit__ = AsyncMock(return_value=None)
|
|
mock_session.get = MagicMock(return_value=mock_response)
|
|
|
|
tmdb_client.session = mock_session
|
|
|
|
with patch.object(tmdb_client, '_ensure_session', new_callable=AsyncMock):
|
|
# First call should hit API
|
|
result1 = await tmdb_client.search_tv_show("Cached Show")
|
|
assert mock_session.get.call_count == 1
|
|
|
|
# Second call should use cache
|
|
result2 = await tmdb_client.search_tv_show("Cached Show")
|
|
assert mock_session.get.call_count == 1 # Not called again
|
|
|
|
assert result1 == result2
|
|
|
|
|
|
class TestTMDBClientGetTVShowDetails:
|
|
"""Test get_tv_show_details method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_tv_show_details_success(self, tmdb_client):
|
|
"""Test successful TV show details retrieval."""
|
|
mock_data = {
|
|
"id": 123,
|
|
"name": "Test Show",
|
|
"overview": "A test show",
|
|
"first_air_date": "2020-01-01"
|
|
}
|
|
|
|
with patch.object(tmdb_client, "_request", return_value=mock_data):
|
|
result = await tmdb_client.get_tv_show_details(123)
|
|
|
|
assert result["id"] == 123
|
|
assert result["name"] == "Test Show"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_tv_show_details_with_append(self, tmdb_client):
|
|
"""Test details with append_to_response."""
|
|
mock_data = {
|
|
"id": 123,
|
|
"name": "Test Show",
|
|
"credits": {"cast": []},
|
|
"images": {"posters": []}
|
|
}
|
|
|
|
with patch.object(tmdb_client, "_request", return_value=mock_data) as mock_request:
|
|
result = await tmdb_client.get_tv_show_details(123, append_to_response="credits,images")
|
|
|
|
assert "credits" in result
|
|
assert "images" in result
|
|
|
|
# Verify append_to_response was passed
|
|
call_args = mock_request.call_args
|
|
assert "credits,images" in str(call_args)
|
|
|
|
|
|
class TestTMDBClientGetExternalIDs:
|
|
"""Test get_tv_show_external_ids method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_external_ids_success(self, tmdb_client):
|
|
"""Test successful external IDs retrieval."""
|
|
mock_data = {
|
|
"imdb_id": "tt1234567",
|
|
"tvdb_id": 98765
|
|
}
|
|
|
|
with patch.object(tmdb_client, "_request", return_value=mock_data):
|
|
result = await tmdb_client.get_tv_show_external_ids(123)
|
|
|
|
assert result["imdb_id"] == "tt1234567"
|
|
assert result["tvdb_id"] == 98765
|
|
|
|
|
|
class TestTMDBClientGetImages:
|
|
"""Test get_tv_show_images method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_images_success(self, tmdb_client):
|
|
"""Test successful images retrieval."""
|
|
mock_data = {
|
|
"posters": [{"file_path": "/poster.jpg"}],
|
|
"backdrops": [{"file_path": "/backdrop.jpg"}],
|
|
"logos": [{"file_path": "/logo.png"}]
|
|
}
|
|
|
|
with patch.object(tmdb_client, "_request", return_value=mock_data):
|
|
result = await tmdb_client.get_tv_show_images(123)
|
|
|
|
assert "posters" in result
|
|
assert "backdrops" in result
|
|
assert "logos" in result
|
|
assert len(result["posters"]) == 1
|
|
|
|
|
|
class TestTMDBClientImageURL:
|
|
"""Test get_image_url method."""
|
|
|
|
def test_get_image_url_with_size(self, tmdb_client):
|
|
"""Test image URL generation with size."""
|
|
url = tmdb_client.get_image_url("/test.jpg", "w500")
|
|
assert url == "https://image.tmdb.org/t/p/w500/test.jpg"
|
|
|
|
def test_get_image_url_original(self, tmdb_client):
|
|
"""Test image URL with original size."""
|
|
url = tmdb_client.get_image_url("/test.jpg", "original")
|
|
assert url == "https://image.tmdb.org/t/p/original/test.jpg"
|
|
|
|
def test_get_image_url_strips_leading_slash(self, tmdb_client):
|
|
"""Test path without leading slash works."""
|
|
url = tmdb_client.get_image_url("/test.jpg", "w500")
|
|
assert url == "https://image.tmdb.org/t/p/w500/test.jpg"
|
|
|
|
|
|
class TestTMDBClientMakeRequest:
|
|
"""Test _make_request private method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_make_request_success(self, tmdb_client):
|
|
"""Test successful request."""
|
|
mock_session = MagicMock()
|
|
mock_response = AsyncMock()
|
|
mock_response.status = 200
|
|
mock_response.json = AsyncMock(return_value={"data": "test"})
|
|
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
|
|
mock_response.__aexit__ = AsyncMock(return_value=None)
|
|
mock_session.get = MagicMock(return_value=mock_response)
|
|
|
|
tmdb_client.session = mock_session
|
|
|
|
with patch.object(tmdb_client, '_ensure_session', new_callable=AsyncMock):
|
|
result = await tmdb_client._request("tv/search", {"query": "test"})
|
|
|
|
assert result == {"data": "test"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_make_request_unauthorized(self, tmdb_client):
|
|
"""Test 401 unauthorized error."""
|
|
mock_session = AsyncMock()
|
|
mock_response = AsyncMock()
|
|
mock_response.status = 401
|
|
mock_response.raise_for_status = MagicMock(
|
|
side_effect=ClientResponseError(None, None, status=401)
|
|
)
|
|
mock_session.get = AsyncMock(return_value=mock_response)
|
|
|
|
tmdb_client.session = mock_session
|
|
|
|
with pytest.raises(TMDBAPIError, match="Invalid"):
|
|
await tmdb_client._request("tv/search", {})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_make_request_not_found(self, tmdb_client):
|
|
"""Test 404 not found error."""
|
|
mock_session = MagicMock()
|
|
mock_response = AsyncMock()
|
|
mock_response.status = 404
|
|
mock_response.raise_for_status = MagicMock(
|
|
side_effect=ClientResponseError(None, None, status=404)
|
|
)
|
|
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
|
|
mock_response.__aexit__ = AsyncMock(return_value=None)
|
|
mock_session.get = MagicMock(return_value=mock_response)
|
|
|
|
tmdb_client.session = mock_session
|
|
|
|
with patch.object(tmdb_client, '_ensure_session', new_callable=AsyncMock):
|
|
with pytest.raises(TMDBAPIError, match="Resource not found"):
|
|
await tmdb_client._request("tv/99999", {})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_make_request_rate_limit(self, tmdb_client):
|
|
"""Test 429 rate limit error."""
|
|
mock_session = AsyncMock()
|
|
mock_response = AsyncMock()
|
|
mock_response.status = 429
|
|
mock_response.raise_for_status = MagicMock(
|
|
side_effect=ClientResponseError(None, None, status=429)
|
|
)
|
|
mock_session.get = AsyncMock(return_value=mock_response)
|
|
|
|
tmdb_client.session = mock_session
|
|
|
|
with pytest.raises(TMDBAPIError):
|
|
await tmdb_client._request("tv/search", {})
|
|
|
|
|
|
class TestTMDBClientDownloadImage:
|
|
"""Test download_image method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_image_success(self, tmdb_client, tmp_path):
|
|
"""Test successful image download."""
|
|
image_data = b"fake_image_data"
|
|
|
|
mock_session = MagicMock()
|
|
mock_response = AsyncMock()
|
|
mock_response.status = 200
|
|
mock_response.read = AsyncMock(return_value=image_data)
|
|
mock_response.raise_for_status = AsyncMock()
|
|
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
|
|
mock_response.__aexit__ = AsyncMock(return_value=None)
|
|
mock_session.get = MagicMock(return_value=mock_response)
|
|
|
|
tmdb_client.session = mock_session
|
|
|
|
with patch.object(tmdb_client, '_ensure_session', new_callable=AsyncMock):
|
|
output_path = tmp_path / "test.jpg"
|
|
await tmdb_client.download_image("/image.jpg", output_path)
|
|
|
|
assert output_path.exists()
|
|
assert output_path.read_bytes() == image_data
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_image_failure(self, tmdb_client, tmp_path):
|
|
"""Test image download failure."""
|
|
mock_session = AsyncMock()
|
|
mock_response = AsyncMock()
|
|
mock_response.status = 404
|
|
mock_response.raise_for_status = MagicMock(
|
|
side_effect=ClientResponseError(None, None, status=404)
|
|
)
|
|
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
|
|
mock_response.__aexit__ = AsyncMock(return_value=None)
|
|
mock_session.get = AsyncMock(return_value=mock_response)
|
|
|
|
tmdb_client.session = mock_session
|
|
|
|
output_path = tmp_path / "test.jpg"
|
|
|
|
with pytest.raises(TMDBAPIError):
|
|
await tmdb_client.download_image("/missing.jpg", output_path)
|