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
+47 -57
View File
@@ -1,15 +1,16 @@
"""Tests for ``alfred.application.movies.search_movie.SearchMovieUseCase``.
The use case wraps ``TMDBClient.search_media`` and converts results / errors
into a ``SearchMovieResponse`` envelope (status="ok"|"error").
The use case wraps :meth:`TMDBClient.search_movies` and flattens each
hit's domain VOs into agent-friendly primitives wrapped in a
:class:`SearchMovieResponse` envelope (status="ok"|"error").
Coverage:
- ``TestSuccess`` — full MediaResult with imdb_id → ok+imdb_id; missing
imdb_id → ok+no_imdb_id; TV media_type preserved.
- ``TestErrorTranslation`` — ``TMDBNotFoundError`` → not_found,
``TMDBConfigurationError`` → configuration_error,
``TMDBAPIError`` → api_error, ``ValueError`` → validation_failed.
- ``TestSuccess`` — list of hits flattened, year present/absent,
empty list still ``status="ok"``.
- ``TestErrorTranslation`` — ``TMDBConfigurationError`` →
configuration_error, ``TMDBAPIError`` → api_error, ``ValueError``
→ validation_failed.
- ``TestPassThrough`` — query is forwarded to the client unchanged.
TMDBClient is fully mocked — no real HTTP.
@@ -22,11 +23,12 @@ from unittest.mock import MagicMock
import pytest
from alfred.application.movies.search_movie import SearchMovieUseCase
from alfred.infrastructure.api.tmdb.dto import MediaResult
from alfred.domain.movies.value_objects import MovieTitle, ReleaseYear
from alfred.domain.shared.value_objects import TmdbId
from alfred.infrastructure.api.tmdb.dto import TmdbMovieSearchResult
from alfred.infrastructure.api.tmdb.exceptions import (
TMDBAPIError,
TMDBConfigurationError,
TMDBNotFoundError,
)
@@ -40,19 +42,14 @@ def use_case(client):
return SearchMovieUseCase(client)
def _result(**kw) -> MediaResult:
def _hit(**kw) -> TmdbMovieSearchResult:
defaults = dict(
tmdb_id=1,
title="Inception",
media_type="movie",
imdb_id="tt1375666",
overview="o",
release_date="2010-07-15",
poster_path="/x.jpg",
vote_average=8.4,
tmdb_id=TmdbId(27205),
title=MovieTitle("Inception"),
release_year=ReleaseYear(2010),
)
defaults.update(kw)
return MediaResult(**defaults)
return TmdbMovieSearchResult(**defaults)
# --------------------------------------------------------------------------- #
@@ -61,36 +58,36 @@ def _result(**kw) -> MediaResult:
class TestSuccess:
def test_full_result_returns_ok_with_imdb_id(self, client, use_case):
client.search_media.return_value = _result()
def test_single_hit_is_flattened(self, client, use_case):
client.search_movies.return_value = [_hit()]
r = use_case.execute("Inception")
assert r.status == "ok"
assert r.imdb_id == "tt1375666"
assert r.title == "Inception"
assert r.media_type == "movie"
assert r.tmdb_id == 1
assert r.vote_average == 8.4
assert len(r.hits) == 1
h = r.hits[0]
assert h.tmdb_id == 27205
assert h.title == "Inception"
assert h.release_year == 2010
def test_multiple_hits_preserve_order(self, client, use_case):
client.search_movies.return_value = [
_hit(),
_hit(tmdb_id=TmdbId(42), title=MovieTitle("Inception 2")),
]
r = use_case.execute("Inception")
assert [h.tmdb_id for h in r.hits] == [27205, 42]
def test_hit_without_release_year(self, client, use_case):
client.search_movies.return_value = [_hit(release_year=None)]
r = use_case.execute("Inception")
assert r.hits[0].release_year is None
def test_empty_results_returns_ok_with_no_hits(self, client, use_case):
client.search_movies.return_value = []
r = use_case.execute("nothing")
assert r.status == "ok"
assert r.hits == []
assert r.error is None
def test_tv_result(self, client, use_case):
client.search_media.return_value = _result(
media_type="tv", title="Breaking Bad", imdb_id="tt0903747"
)
r = use_case.execute("Breaking Bad")
assert r.status == "ok"
assert r.media_type == "tv"
assert r.imdb_id == "tt0903747"
def test_missing_imdb_id_returns_ok_with_no_imdb_id_error(self, client, use_case):
client.search_media.return_value = _result(imdb_id=None)
r = use_case.execute("Inception")
assert r.status == "ok"
assert r.error == "no_imdb_id"
assert r.message is not None
assert "Inception" in r.message
assert r.imdb_id is None
assert r.title == "Inception"
# --------------------------------------------------------------------------- #
# Error translation #
@@ -98,28 +95,21 @@ class TestSuccess:
class TestErrorTranslation:
def test_not_found(self, client, use_case):
client.search_media.side_effect = TMDBNotFoundError("no match")
r = use_case.execute("ghost")
assert r.status == "error"
assert r.error == "not_found"
assert "no match" in r.message
def test_configuration_error(self, client, use_case):
client.search_media.side_effect = TMDBConfigurationError("missing key")
client.search_movies.side_effect = TMDBConfigurationError("missing key")
r = use_case.execute("x")
assert r.status == "error"
assert r.error == "configuration_error"
def test_api_error(self, client, use_case):
client.search_media.side_effect = TMDBAPIError("500 oops")
client.search_movies.side_effect = TMDBAPIError("500 oops")
r = use_case.execute("x")
assert r.status == "error"
assert r.error == "api_error"
assert "500" in r.message
def test_validation_error(self, client, use_case):
client.search_media.side_effect = ValueError("query too long")
client.search_movies.side_effect = ValueError("query too long")
r = use_case.execute("x")
assert r.status == "error"
assert r.error == "validation_failed"
@@ -133,6 +123,6 @@ class TestErrorTranslation:
class TestPassThrough:
def test_query_forwarded_verbatim(self, client, use_case):
client.search_media.return_value = _result()
client.search_movies.return_value = []
use_case.execute("Inception")
client.search_media.assert_called_once_with("Inception")
client.search_movies.assert_called_once_with("Inception")