391 lines
14 KiB
Python
391 lines
14 KiB
Python
"""Tests for ``alfred.infrastructure.api.tmdb.client.TMDBClient``.
|
|
|
|
Exercises the public surface without any real HTTP traffic:
|
|
|
|
- ``TestInit`` — configuration via constructor args vs. ``Settings``;
|
|
enforcement of the ``api_key``/``base_url`` invariants.
|
|
- ``TestMakeRequest`` — error translation for timeouts, HTTP 401/404/5xx,
|
|
and generic ``RequestException``.
|
|
- ``TestSearchMovies`` / ``TestSearchShows`` — query validation, success
|
|
path (VO-wrapped hits), empty results yield empty list (no exception).
|
|
- ``TestGetExternalIds`` — ``media_type`` whitelist enforcement.
|
|
- ``TestDetailsEndpoints`` — ``get_movie_details`` / ``get_tv_details``.
|
|
- ``TestGetTvShowInfo`` / ``TestGetMovieInfo`` — composite info getters
|
|
aggregating details + external_ids into VO-typed DTOs.
|
|
- ``TestIsConfigured`` — reports ``True`` only when both api_key & url set.
|
|
|
|
All HTTP is mocked at ``alfred.infrastructure.api.tmdb.client.requests``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from requests.exceptions import HTTPError, RequestException, Timeout
|
|
|
|
from alfred.domain.movies_TO_CHECK.value_objects import MovieTitle, ReleaseYear
|
|
from alfred.domain.shared_TO_CHECK.value_objects import ImdbId, TmdbId
|
|
from alfred.domain.tv_shows.value_objects import ShowStatus
|
|
from alfred.infrastructure.api_TO_CHECK.tmdb.client import TMDBClient
|
|
from alfred.infrastructure.api_TO_CHECK.tmdb.exceptions import (
|
|
TMDBAPIError,
|
|
TMDBConfigurationError,
|
|
TMDBNotFoundError,
|
|
)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Helpers #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _ok_response(json_body):
|
|
"""Return a Mock that mimics a successful requests.Response."""
|
|
r = MagicMock()
|
|
r.status_code = 200
|
|
r.json.return_value = json_body
|
|
r.raise_for_status.return_value = None
|
|
return r
|
|
|
|
|
|
def _http_error_response(status_code):
|
|
r = MagicMock()
|
|
r.status_code = status_code
|
|
err = HTTPError(f"{status_code}")
|
|
err.response = r
|
|
r.raise_for_status.side_effect = err
|
|
return r
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
return TMDBClient(
|
|
api_key="fake-key",
|
|
base_url="https://api.example.com/3",
|
|
timeout=5,
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Init / configuration #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class TestInit:
|
|
def test_explicit_args_win_over_settings(self):
|
|
c = TMDBClient(api_key="explicit", base_url="https://x", timeout=99)
|
|
assert c.api_key == "explicit"
|
|
assert c.base_url == "https://x"
|
|
assert c.timeout == 99
|
|
|
|
def test_missing_api_key_raises(self):
|
|
from alfred.settings import Settings
|
|
|
|
cfg = Settings(tmdb_api_key="", tmdb_base_url="https://x")
|
|
with pytest.raises(TMDBConfigurationError, match="API key"):
|
|
TMDBClient(api_key="", config=cfg)
|
|
|
|
def test_missing_base_url_raises(self):
|
|
from alfred.settings import Settings
|
|
|
|
cfg = Settings(tmdb_api_key="fake", tmdb_base_url="")
|
|
with pytest.raises(TMDBConfigurationError, match="base URL"):
|
|
TMDBClient(config=cfg, base_url="")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# _make_request — error translation #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class TestMakeRequest:
|
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
|
def test_timeout_translated(self, mock_get, client):
|
|
mock_get.side_effect = Timeout("slow")
|
|
with pytest.raises(TMDBAPIError, match="timeout"):
|
|
client._make_request("/x")
|
|
|
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
|
def test_http_401_invalid_key(self, mock_get, client):
|
|
mock_get.return_value = _http_error_response(401)
|
|
with pytest.raises(TMDBAPIError, match="Invalid"):
|
|
client._make_request("/x")
|
|
|
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
|
def test_http_404_not_found(self, mock_get, client):
|
|
mock_get.return_value = _http_error_response(404)
|
|
with pytest.raises(TMDBNotFoundError):
|
|
client._make_request("/x")
|
|
|
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
|
def test_http_500_generic(self, mock_get, client):
|
|
mock_get.return_value = _http_error_response(500)
|
|
with pytest.raises(TMDBAPIError, match="500"):
|
|
client._make_request("/x")
|
|
|
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
|
def test_request_exception_translated(self, mock_get, client):
|
|
mock_get.side_effect = RequestException("network down")
|
|
with pytest.raises(TMDBAPIError, match="connect"):
|
|
client._make_request("/x")
|
|
|
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
|
def test_api_key_added_to_params(self, mock_get, client):
|
|
mock_get.return_value = _ok_response({"ok": True})
|
|
client._make_request("/path", {"q": "foo"})
|
|
called_kwargs = mock_get.call_args.kwargs
|
|
assert called_kwargs["params"]["api_key"] == "fake-key"
|
|
assert called_kwargs["params"]["q"] == "foo"
|
|
assert called_kwargs["timeout"] == 5
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# search_movies #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class TestSearchMovies:
|
|
@pytest.mark.parametrize("bad", ["", None, 123])
|
|
def test_invalid_query_raises_value_error(self, client, bad):
|
|
with pytest.raises(ValueError):
|
|
client.search_movies(bad)
|
|
|
|
def test_query_too_long(self, client):
|
|
with pytest.raises(ValueError, match="too long"):
|
|
client.search_movies("a" * 501)
|
|
|
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
|
def test_success_wraps_vos(self, mock_get, client):
|
|
mock_get.return_value = _ok_response(
|
|
{
|
|
"results": [
|
|
{
|
|
"id": 27205,
|
|
"title": "Inception",
|
|
"release_date": "2010-07-15",
|
|
}
|
|
]
|
|
}
|
|
)
|
|
results = client.search_movies("Inception")
|
|
assert len(results) == 1
|
|
hit = results[0]
|
|
assert hit.tmdb_id == TmdbId(27205)
|
|
assert hit.title == MovieTitle("Inception")
|
|
assert hit.release_year == ReleaseYear(2010)
|
|
|
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
|
def test_empty_results_returns_empty_list(self, mock_get, client):
|
|
mock_get.return_value = _ok_response({"results": []})
|
|
assert client.search_movies("nothing") == []
|
|
|
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
|
def test_missing_release_date_yields_none_year(self, mock_get, client):
|
|
mock_get.return_value = _ok_response(
|
|
{"results": [{"id": 1, "title": "X"}]}
|
|
)
|
|
results = client.search_movies("X")
|
|
assert results[0].release_year is None
|
|
|
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
|
def test_malformed_year_yields_none(self, mock_get, client):
|
|
mock_get.return_value = _ok_response(
|
|
{"results": [{"id": 1, "title": "X", "release_date": "soon"}]}
|
|
)
|
|
results = client.search_movies("X")
|
|
assert results[0].release_year is None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# search_shows #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class TestSearchShows:
|
|
@pytest.mark.parametrize("bad", ["", None, 123])
|
|
def test_invalid_query_raises_value_error(self, client, bad):
|
|
with pytest.raises(ValueError):
|
|
client.search_shows(bad)
|
|
|
|
def test_query_too_long(self, client):
|
|
with pytest.raises(ValueError, match="too long"):
|
|
client.search_shows("a" * 501)
|
|
|
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
|
def test_success_wraps_vos(self, mock_get, client):
|
|
mock_get.return_value = _ok_response(
|
|
{
|
|
"results": [
|
|
{
|
|
"id": 84958,
|
|
"name": "Foundation",
|
|
"first_air_date": "2021-09-24",
|
|
}
|
|
]
|
|
}
|
|
)
|
|
results = client.search_shows("Foundation")
|
|
assert len(results) == 1
|
|
hit = results[0]
|
|
assert hit.tmdb_id == TmdbId(84958)
|
|
assert hit.name == "Foundation"
|
|
assert hit.first_air_year == 2021
|
|
|
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
|
def test_empty_results_returns_empty_list(self, mock_get, client):
|
|
mock_get.return_value = _ok_response({"results": []})
|
|
assert client.search_shows("nothing") == []
|
|
|
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
|
def test_missing_first_air_date_yields_none_year(self, mock_get, client):
|
|
mock_get.return_value = _ok_response(
|
|
{"results": [{"id": 1, "name": "X"}]}
|
|
)
|
|
results = client.search_shows("X")
|
|
assert results[0].first_air_year is None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# get_external_ids #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class TestGetExternalIds:
|
|
def test_invalid_media_type(self, client):
|
|
with pytest.raises(ValueError, match="media_type"):
|
|
client.get_external_ids("game", 42)
|
|
|
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
|
def test_movie(self, mock_get, client):
|
|
mock_get.return_value = _ok_response({"imdb_id": "tt1375666"})
|
|
result = client.get_external_ids("movie", 27205)
|
|
assert result["imdb_id"] == "tt1375666"
|
|
|
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
|
def test_tv(self, mock_get, client):
|
|
mock_get.return_value = _ok_response({"imdb_id": "tt0903747"})
|
|
result = client.get_external_ids("tv", 1396)
|
|
assert result["imdb_id"] == "tt0903747"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Details endpoints #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class TestDetailsEndpoints:
|
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
|
def test_movie_details(self, mock_get, client):
|
|
mock_get.return_value = _ok_response({"id": 27205, "runtime": 148})
|
|
result = client.get_movie_details(27205)
|
|
assert result["runtime"] == 148
|
|
|
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
|
def test_tv_details(self, mock_get, client):
|
|
mock_get.return_value = _ok_response({"id": 1396, "number_of_seasons": 5})
|
|
result = client.get_tv_details(1396)
|
|
assert result["number_of_seasons"] == 5
|
|
|
|
|
|
class TestGetTvShowInfo:
|
|
"""``get_tv_show_info`` aggregates ``/tv/{id}`` + external_ids."""
|
|
|
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
|
def test_happy_path(self, mock_get, client):
|
|
details = {
|
|
"id": 84958,
|
|
"name": "Foundation",
|
|
"status": "Returning Series",
|
|
"seasons": [
|
|
{
|
|
"season_number": 1,
|
|
"episode_count": 10,
|
|
"air_date": "2021-09-24",
|
|
},
|
|
{
|
|
"season_number": 2,
|
|
"episode_count": 10,
|
|
"air_date": "2023-07-14",
|
|
},
|
|
],
|
|
}
|
|
external = {"imdb_id": "tt0804484"}
|
|
mock_get.side_effect = [
|
|
_ok_response(details),
|
|
_ok_response(external),
|
|
]
|
|
|
|
info = client.get_tv_show_info(84958)
|
|
|
|
assert info.tmdb_id == TmdbId(84958)
|
|
assert info.imdb_id == ImdbId("tt0804484")
|
|
assert info.name == "Foundation"
|
|
assert info.status == ShowStatus.RETURNING_SERIES
|
|
assert len(info.seasons) == 2
|
|
assert info.seasons[0].number == 1
|
|
assert info.seasons[0].episode_count == 10
|
|
assert info.seasons[0].aired is True
|
|
|
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
|
def test_missing_imdb_id_becomes_none(self, mock_get, client):
|
|
mock_get.side_effect = [
|
|
_ok_response(
|
|
{
|
|
"id": 1,
|
|
"name": "X",
|
|
"status": "Ended",
|
|
"seasons": [],
|
|
}
|
|
),
|
|
_ok_response({}),
|
|
]
|
|
info = client.get_tv_show_info(1)
|
|
assert info.imdb_id is None
|
|
assert info.seasons == ()
|
|
assert info.status == ShowStatus.ENDED
|
|
|
|
|
|
class TestGetMovieInfo:
|
|
"""``get_movie_info`` aggregates ``/movie/{id}`` + external_ids."""
|
|
|
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
|
def test_happy_path(self, mock_get, client):
|
|
details = {
|
|
"id": 27205,
|
|
"title": "Inception",
|
|
"release_date": "2010-07-16",
|
|
}
|
|
external = {"imdb_id": "tt1375666"}
|
|
mock_get.side_effect = [
|
|
_ok_response(details),
|
|
_ok_response(external),
|
|
]
|
|
|
|
info = client.get_movie_info(27205)
|
|
|
|
assert info.tmdb_id == TmdbId(27205)
|
|
assert info.imdb_id == ImdbId("tt1375666")
|
|
assert info.title == MovieTitle("Inception")
|
|
assert info.release_year == ReleaseYear(2010)
|
|
|
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
|
def test_missing_imdb_id_becomes_none(self, mock_get, client):
|
|
mock_get.side_effect = [
|
|
_ok_response(
|
|
{
|
|
"id": 1,
|
|
"title": "X",
|
|
"release_date": "2024-01-01",
|
|
}
|
|
),
|
|
_ok_response({}),
|
|
]
|
|
info = client.get_movie_info(1)
|
|
assert info.imdb_id is None
|
|
assert info.release_year == ReleaseYear(2024)
|
|
|
|
|
|
class TestIsConfigured:
|
|
def test_true_when_complete(self, client):
|
|
assert client.is_configured() is True
|