Files
alfred/tests/infrastructure/api/test_tmdb_client.py
T
francwa e65c1df229 feat(.alfred v2 — Phase 2): Pydantic sidecars, atomic repos, auto-heal index
Spec: specs/dot_alfred_v2.md (Phase 2).

New package alfred/infrastructure/persistence/dot_alfred/v2/:
  * sidecar_release.py / sidecar_root.py — Pydantic DTOs
    (extra="forbid", frozen=True) for per-item sidecars and the
    library-root index. schema_version enforced via model_validator.
  * serializer.py — read_yaml / atomic_write_yaml (.tmp + os.replace).
    SidecarSchemaError wraps YAML + Pydantic errors uniformly.
  * bridge.py — lossless domain <-> sidecar for SeriesRelease /
    MovieRelease; projection-only show_index_entry_from /
    movie_index_entry_from with multi-episode-file flattening.
  * repository.py — DotAlfredSeriesReleaseRepository /
    DotAlfredMovieReleaseRepository (log+skip on corruption),
    DotAlfredTVShowLibraryIndex / DotAlfredMovieLibraryIndex with
    silent auto-heal on missing/corrupt index reads. Writes never
    auto-heal (read paths handle that).

TMDB client extensions:
  * TmdbSeasonInfo / TmdbShowInfo DTOs + pure parse_tv_show_info.
  * TMDBClient.get_tv_show_info aggregates /tv/{id} +
    /tv/{id}/external_ids.

Domain change:
  * SubtitleTrack gains is_sdh: bool = False, populated from
    ffprobe's hearing_impaired disposition. Required for v2 sidecar
    parity (spec replaces v1's type: "sdh" with explicit flag).
    Default keeps every existing caller unchanged.

Tests: 37 new v2 integration tests on tmp_path (round-trips, atomic
writes, schema mismatch handling, anchor warnings, auto-heal paths)
plus 16 TMDB DTO tests. Full suite: 1240 -> 1277 passed.

Implementation notes filed in .claude/specs/dot_alfred_v2_notes.md
(strict=True trade-off, upsert signature deviation from spec, etc.).

Phases 3-5 (TVShow/Movie refactor to TMDB-only, rescan_show rewrite,
v1 deletion + wiring) are next.
2026-05-25 16:01:39 +02:00

366 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``.
- ``TestSearchMulti`` — query validation, success path, empty-results →
``TMDBNotFoundError``.
- ``TestGetExternalIds`` — ``media_type`` whitelist enforcement.
- ``TestSearchMedia`` — happy path (movie/tv), media_type fallthrough to
the next result, structural-validation error, and the case where
external-ID resolution fails but the search still succeeds.
- ``TestDetailsEndpoints`` — ``get_movie_details`` / ``get_tv_details``.
- ``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.infrastructure.api.tmdb.client import TMDBClient
from alfred.infrastructure.api.tmdb.dto import MediaResult
from alfred.infrastructure.api.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):
# Pass api_key but force empty base_url. Need a config with empty URL too.
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_multi #
# --------------------------------------------------------------------------- #
class TestSearchMulti:
@pytest.mark.parametrize("bad", ["", None, 123])
def test_invalid_query_raises_value_error(self, client, bad):
with pytest.raises(ValueError):
client.search_multi(bad)
def test_query_too_long(self, client):
with pytest.raises(ValueError, match="too long"):
client.search_multi("a" * 501)
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_success(self, mock_get, client):
mock_get.return_value = _ok_response(
{"results": [{"id": 1, "media_type": "movie"}]}
)
results = client.search_multi("Inception")
assert len(results) == 1
assert results[0]["id"] == 1
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_empty_results_raise_not_found(self, mock_get, client):
mock_get.return_value = _ok_response({"results": []})
with pytest.raises(TMDBNotFoundError):
client.search_multi("nothing")
# --------------------------------------------------------------------------- #
# 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"
# --------------------------------------------------------------------------- #
# search_media (composite) #
# --------------------------------------------------------------------------- #
class TestSearchMedia:
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_happy_path_movie(self, mock_get, client):
# First call → /search/multi ; second → /movie/X/external_ids
mock_get.side_effect = [
_ok_response(
{
"results": [
{
"id": 27205,
"media_type": "movie",
"title": "Inception",
"overview": "...",
"release_date": "2010-07-15",
"poster_path": "/x.jpg",
"vote_average": 8.4,
}
]
}
),
_ok_response({"imdb_id": "tt1375666"}),
]
result = client.search_media("Inception")
assert isinstance(result, MediaResult)
assert result.title == "Inception"
assert result.imdb_id == "tt1375666"
assert result.media_type == "movie"
assert result.vote_average == 8.4
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_tv_uses_name_field(self, mock_get, client):
mock_get.side_effect = [
_ok_response(
{"results": [{"id": 1396, "media_type": "tv", "name": "Breaking Bad"}]}
),
_ok_response({"imdb_id": "tt0903747"}),
]
result = client.search_media("Breaking Bad")
assert result.title == "Breaking Bad"
assert result.media_type == "tv"
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_person_result_skipped_uses_next(self, mock_get, client):
# First result is a person → falls through to second result.
mock_get.side_effect = [
_ok_response(
{
"results": [
{"id": 1, "media_type": "person", "name": "X"},
{"id": 2, "media_type": "movie", "title": "Y"},
]
}
),
_ok_response({"imdb_id": "tt7654321"}),
]
result = client.search_media("Y")
assert result.title == "Y"
assert result.media_type == "movie"
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_only_person_result_raises_not_found(self, mock_get, client):
mock_get.return_value = _ok_response(
{"results": [{"id": 1, "media_type": "person", "name": "X"}]}
)
with pytest.raises(TMDBNotFoundError):
client.search_media("X")
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_malformed_top_result_raises(self, mock_get, client):
mock_get.return_value = _ok_response(
{"results": [{"title": "no id or media_type"}]}
)
with pytest.raises(TMDBAPIError, match="Invalid"):
client.search_media("X")
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_external_ids_failure_returns_result_without_imdb(self, mock_get, client):
# Second call (external IDs) fails — the search should still succeed.
mock_get.side_effect = [
_ok_response({"results": [{"id": 1, "media_type": "movie", "title": "X"}]}),
Timeout("slow"),
]
result = client.search_media("X")
assert result.imdb_id is None
# --------------------------------------------------------------------------- #
# 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 == 84958
assert info.imdb_id == "tt0804484"
assert info.name == "Foundation"
assert info.status == "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({}), # external_ids without imdb_id
]
info = client.get_tv_show_info(1)
assert info.imdb_id is None
assert info.seasons == ()
class TestIsConfigured:
def test_true_when_complete(self, client):
assert client.is_configured() is True