refactor(tmdb): ACL pass — push VOs into DTOs, split search per media type

Anti-corruption boundary tightened on the TMDB adapter:

* TmdbMovieInfo / TmdbShowInfo now carry domain VOs (TmdbId, ImdbId,
  MovieTitle, ReleaseYear, ShowStatus) instead of raw scalars —
  validation happens at the boundary, not three layers later.
* ShowStatus enum added (domain/tv_shows/value_objects) with a
  from_tmdb() mapper that falls back to UNKNOWN + logs a warning on
  unrecognized values. TVShow.status is now ShowStatus, not str.
* MovieTitle cap raised from 100 to 150 chars.
* MediaResult / ExternalIds dropped. Replaced by per-media search
  DTOs: TmdbMovieSearchResult and TmdbShowSearchResult. Neither
  carries imdb_id — search no longer enriches with external_ids
  (callers needing imdb_id follow up with get_movie_info /
  get_tv_show_info on the chosen tmdb_id).
* TMDBClient: search_multi / search_media / _parse_result removed.
  search_movies (/search/movie) and search_shows (/search/tv) added,
  each parsing hits into VO-typed DTOs.
* SearchMovieUseCase returns a list of MovieHit (flattened to
  primitives for the agent). New symmetric SearchShowUseCase +
  ShowHit / SearchShowResponse DTOs.
* agent/tools/api.py: find_media_imdb_id → search_movies +
  search_shows wrappers.
* FileEntry moved from domain/shared/ports/filesystem_scanner.py to
  domain/shared/file_entry.py (it's a DTO, not a Protocol); size_kb
  (float) → size (int bytes). Scanner and SubtitleIdentifier
  updated.

Tests: 79/79 pass on tests/infrastructure/api/ +
tests/application/test_search_movie.py +
tests/application/test_search_show.py.
This commit is contained in:
2026-05-26 05:45:30 +02:00
parent cffafa2e60
commit c62ae81275
29 changed files with 735 additions and 490 deletions
+103 -118
View File
@@ -6,13 +6,12 @@ Exercises the public surface without any real HTTP traffic:
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``.
- ``TestSearchMovies`` / ``TestSearchShows`` — query validation, success
path (VO-wrapped hits), empty results yield empty list (no exception).
- ``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``.
- ``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``.
@@ -25,8 +24,10 @@ from unittest.mock import MagicMock, patch
import pytest
from requests.exceptions import HTTPError, RequestException, Timeout
from alfred.domain.movies.value_objects import MovieTitle, ReleaseYear
from alfred.domain.shared.value_objects import ImdbId, TmdbId
from alfred.domain.tv_shows.value_objects import ShowStatus
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,
@@ -85,7 +86,6 @@ class TestInit:
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="")
@@ -140,34 +140,109 @@ class TestMakeRequest:
# --------------------------------------------------------------------------- #
# search_multi #
# search_movies #
# --------------------------------------------------------------------------- #
class TestSearchMulti:
class TestSearchMovies:
@pytest.mark.parametrize("bad", ["", None, 123])
def test_invalid_query_raises_value_error(self, client, bad):
with pytest.raises(ValueError):
client.search_multi(bad)
client.search_movies(bad)
def test_query_too_long(self, client):
with pytest.raises(ValueError, match="too long"):
client.search_multi("a" * 501)
client.search_movies("a" * 501)
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
def test_success(self, mock_get, client):
def test_success_wraps_vos(self, mock_get, client):
mock_get.return_value = _ok_response(
{"results": [{"id": 1, "media_type": "movie"}]}
{
"results": [
{
"id": 27205,
"title": "Inception",
"release_date": "2010-07-15",
}
]
}
)
results = client.search_multi("Inception")
results = client.search_movies("Inception")
assert len(results) == 1
assert results[0]["id"] == 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_raise_not_found(self, mock_get, client):
def test_empty_results_returns_empty_list(self, mock_get, client):
mock_get.return_value = _ok_response({"results": []})
with pytest.raises(TMDBNotFoundError):
client.search_multi("nothing")
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
# --------------------------------------------------------------------------- #
@@ -193,97 +268,6 @@ class TestGetExternalIds:
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 #
# --------------------------------------------------------------------------- #
@@ -333,10 +317,10 @@ class TestGetTvShowInfo:
info = client.get_tv_show_info(84958)
assert info.tmdb_id == 84958
assert info.imdb_id == "tt0804484"
assert info.tmdb_id == TmdbId(84958)
assert info.imdb_id == ImdbId("tt0804484")
assert info.name == "Foundation"
assert info.status == "Returning Series"
assert info.status == ShowStatus.RETURNING_SERIES
assert len(info.seasons) == 2
assert info.seasons[0].number == 1
assert info.seasons[0].episode_count == 10
@@ -353,11 +337,12 @@ class TestGetTvShowInfo:
"seasons": [],
}
),
_ok_response({}), # external_ids without imdb_id
_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:
@@ -378,10 +363,10 @@ class TestGetMovieInfo:
info = client.get_movie_info(27205)
assert info.tmdb_id == 27205
assert info.imdb_id == "tt1375666"
assert info.title == "Inception"
assert info.release_year == 2010
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):
@@ -397,7 +382,7 @@ class TestGetMovieInfo:
]
info = client.get_movie_info(1)
assert info.imdb_id is None
assert info.release_year == 2024
assert info.release_year == ReleaseYear(2024)
class TestIsConfigured:
+11 -8
View File
@@ -12,6 +12,9 @@ from datetime import date
import pytest
from alfred.domain.movies.value_objects import MovieTitle, ReleaseYear
from alfred.domain.shared.value_objects import ImdbId, TmdbId
from alfred.domain.tv_shows.value_objects import ShowStatus
from alfred.infrastructure.api.tmdb.dto import (
TmdbMovieInfo,
TmdbSeasonInfo,
@@ -42,10 +45,10 @@ class TestParseTvShowInfoHappyPath:
today=REF_DATE,
)
assert info == TmdbShowInfo(
tmdb_id=84958,
imdb_id="tt0804484",
tmdb_id=TmdbId(84958),
imdb_id=ImdbId("tt0804484"),
name="Foundation",
status="Returning Series",
status=ShowStatus.RETURNING_SERIES,
seasons=(),
)
@@ -192,10 +195,10 @@ class TestParseMovieInfoHappyPath:
{"imdb_id": "tt1375666"},
)
assert info == TmdbMovieInfo(
tmdb_id=27205,
imdb_id="tt1375666",
title="Inception",
release_year=2010,
tmdb_id=TmdbId(27205),
imdb_id=ImdbId("tt1375666"),
title=MovieTitle("Inception"),
release_year=ReleaseYear(2010),
)
def test_release_year_extracted_from_release_date(self):
@@ -203,7 +206,7 @@ class TestParseMovieInfoHappyPath:
_movie_details(release_date="1999-03-31"),
{},
)
assert info.release_year == 1999
assert info.release_year == ReleaseYear(1999)
class TestParseMovieInfoImdb: