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:
@@ -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")
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tests for ``alfred.application.tv_shows.search_show.SearchShowUseCase``.
|
||||
|
||||
Symmetric to ``test_search_movie.py``. The use case wraps
|
||||
:meth:`TMDBClient.search_shows` and flattens each hit into a
|
||||
:class:`ShowHit` wrapped in a :class:`SearchShowResponse` envelope.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.application.tv_shows.search_show import SearchShowUseCase
|
||||
from alfred.domain.shared.value_objects import TmdbId
|
||||
from alfred.infrastructure.api.tmdb.dto import TmdbShowSearchResult
|
||||
from alfred.infrastructure.api.tmdb.exceptions import (
|
||||
TMDBAPIError,
|
||||
TMDBConfigurationError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def use_case(client):
|
||||
return SearchShowUseCase(client)
|
||||
|
||||
|
||||
def _hit(**kw) -> TmdbShowSearchResult:
|
||||
defaults = dict(
|
||||
tmdb_id=TmdbId(84958),
|
||||
name="Foundation",
|
||||
first_air_year=2021,
|
||||
)
|
||||
defaults.update(kw)
|
||||
return TmdbShowSearchResult(**defaults)
|
||||
|
||||
|
||||
class TestSuccess:
|
||||
def test_single_hit_is_flattened(self, client, use_case):
|
||||
client.search_shows.return_value = [_hit()]
|
||||
r = use_case.execute("Foundation")
|
||||
assert r.status == "ok"
|
||||
assert len(r.hits) == 1
|
||||
h = r.hits[0]
|
||||
assert h.tmdb_id == 84958
|
||||
assert h.name == "Foundation"
|
||||
assert h.first_air_year == 2021
|
||||
|
||||
def test_multiple_hits_preserve_order(self, client, use_case):
|
||||
client.search_shows.return_value = [
|
||||
_hit(),
|
||||
_hit(tmdb_id=TmdbId(42), name="Fallout"),
|
||||
]
|
||||
r = use_case.execute("Foundation")
|
||||
assert [h.tmdb_id for h in r.hits] == [84958, 42]
|
||||
|
||||
def test_hit_without_first_air_year(self, client, use_case):
|
||||
client.search_shows.return_value = [_hit(first_air_year=None)]
|
||||
r = use_case.execute("Foundation")
|
||||
assert r.hits[0].first_air_year is None
|
||||
|
||||
def test_empty_results_returns_ok_with_no_hits(self, client, use_case):
|
||||
client.search_shows.return_value = []
|
||||
r = use_case.execute("nothing")
|
||||
assert r.status == "ok"
|
||||
assert r.hits == []
|
||||
assert r.error is None
|
||||
|
||||
|
||||
class TestErrorTranslation:
|
||||
def test_configuration_error(self, client, use_case):
|
||||
client.search_shows.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_shows.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_shows.side_effect = ValueError("query too long")
|
||||
r = use_case.execute("x")
|
||||
assert r.status == "error"
|
||||
assert r.error == "validation_failed"
|
||||
assert "too long" in r.message
|
||||
|
||||
|
||||
class TestPassThrough:
|
||||
def test_query_forwarded_verbatim(self, client, use_case):
|
||||
client.search_shows.return_value = []
|
||||
use_case.execute("Foundation")
|
||||
client.search_shows.assert_called_once_with("Foundation")
|
||||
@@ -22,7 +22,7 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.domain.shared.ports import FileEntry
|
||||
from alfred.domain.shared import FileEntry
|
||||
from alfred.domain.subtitles.entities import SubtitleScanResult
|
||||
from alfred.domain.subtitles.services.identifier import (
|
||||
SubtitleIdentifier,
|
||||
@@ -48,7 +48,7 @@ def _file_entry(path) -> FileEntry:
|
||||
path=path,
|
||||
is_file=path.is_file(),
|
||||
is_dir=path.is_dir(),
|
||||
size_kb=(path.stat().st_size / 1024) if path.is_file() else None,
|
||||
size=path.stat().st_size if path.is_file() else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -24,7 +24,7 @@ from alfred.domain.releases.entities import (
|
||||
from alfred.domain.releases.value_objects import EpisodeRange, ReleaseMode
|
||||
from alfred.domain.shared.media import AudioTrack, SubtitleTrack
|
||||
from alfred.domain.shared.value_objects import FilePath, ImdbId, TmdbId
|
||||
from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber
|
||||
from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber, ShowStatus
|
||||
from alfred.infrastructure.api.tmdb.dto import TmdbSeasonInfo, TmdbShowInfo
|
||||
|
||||
|
||||
@@ -164,10 +164,10 @@ def inception_release() -> MovieRelease:
|
||||
def foundation_tmdb_info() -> TmdbShowInfo:
|
||||
"""Foundation TMDB cache snapshot — 3 seasons, S03 not yet aired."""
|
||||
return 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=(
|
||||
TmdbSeasonInfo(number=1, episode_count=10, aired=True),
|
||||
TmdbSeasonInfo(number=2, episode_count=10, aired=True),
|
||||
|
||||
Reference in New Issue
Block a user