Files
alfred/tests/infrastructure/api/test_tmdb_dto.py
T
francwa c62ae81275 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.
2026-05-26 05:54:58 +02:00

261 lines
8.5 KiB
Python

"""Tests for the pure parsing helpers in ``alfred.infrastructure.api.tmdb.dto``.
These tests exercise :func:`parse_tv_show_info` without any HTTP — the
function takes the raw dicts that the client would otherwise pass after
deserializing the TMDB JSON response. The reference date is injected so
the ``aired`` derivation is deterministic.
"""
from __future__ import annotations
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,
TmdbShowInfo,
parse_movie_info,
parse_tv_show_info,
)
REF_DATE = date(2026, 5, 25)
def _details(**overrides):
base = {
"id": 84958,
"name": "Foundation",
"status": "Returning Series",
"seasons": [],
}
base.update(overrides)
return base
class TestParseTvShowInfoHappyPath:
def test_minimal(self):
info = parse_tv_show_info(
_details(),
{"imdb_id": "tt0804484"},
today=REF_DATE,
)
assert info == TmdbShowInfo(
tmdb_id=TmdbId(84958),
imdb_id=ImdbId("tt0804484"),
name="Foundation",
status=ShowStatus.RETURNING_SERIES,
seasons=(),
)
def test_with_seasons(self):
info = parse_tv_show_info(
_details(
seasons=[
{"season_number": 1, "episode_count": 10, "air_date": "2021-09-24"},
{"season_number": 2, "episode_count": 10, "air_date": "2023-07-14"},
{"season_number": 3, "episode_count": 10, "air_date": "2027-01-01"},
],
),
{"imdb_id": "tt0804484"},
today=REF_DATE,
)
assert info.seasons == (
TmdbSeasonInfo(number=1, episode_count=10, aired=True),
TmdbSeasonInfo(number=2, episode_count=10, aired=True),
TmdbSeasonInfo(number=3, episode_count=10, aired=False),
)
class TestParseTvShowInfoImdb:
def test_missing_imdb_id_becomes_none(self):
info = parse_tv_show_info(_details(), {}, today=REF_DATE)
assert info.imdb_id is None
def test_null_imdb_id_becomes_none(self):
info = parse_tv_show_info(_details(), {"imdb_id": None}, today=REF_DATE)
assert info.imdb_id is None
def test_empty_string_imdb_id_becomes_none(self):
info = parse_tv_show_info(_details(), {"imdb_id": ""}, today=REF_DATE)
assert info.imdb_id is None
class TestParseTvShowInfoAired:
def test_air_date_today_counts_as_aired(self):
info = parse_tv_show_info(
_details(
seasons=[{"season_number": 1, "episode_count": 1, "air_date": "2026-05-25"}],
),
{},
today=REF_DATE,
)
assert info.seasons[0].aired is True
def test_air_date_tomorrow_not_aired(self):
info = parse_tv_show_info(
_details(
seasons=[{"season_number": 1, "episode_count": 1, "air_date": "2026-05-26"}],
),
{},
today=REF_DATE,
)
assert info.seasons[0].aired is False
def test_no_air_date_not_aired(self):
info = parse_tv_show_info(
_details(
seasons=[{"season_number": 1, "episode_count": 1}],
),
{},
today=REF_DATE,
)
assert info.seasons[0].aired is False
def test_empty_air_date_not_aired(self):
info = parse_tv_show_info(
_details(
seasons=[{"season_number": 1, "episode_count": 1, "air_date": ""}],
),
{},
today=REF_DATE,
)
assert info.seasons[0].aired is False
def test_malformed_air_date_not_aired(self):
info = parse_tv_show_info(
_details(
seasons=[{"season_number": 1, "episode_count": 1, "air_date": "soon"}],
),
{},
today=REF_DATE,
)
assert info.seasons[0].aired is False
class TestParseTvShowInfoErrors:
def test_missing_id_raises(self):
with pytest.raises(ValueError, match="'id'"):
parse_tv_show_info({"name": "X", "status": "Ended"}, {}, today=REF_DATE)
def test_missing_name_raises(self):
with pytest.raises(ValueError, match="'name'"):
parse_tv_show_info({"id": 1, "status": "Ended"}, {}, today=REF_DATE)
def test_empty_name_raises(self):
with pytest.raises(ValueError, match="'name'"):
parse_tv_show_info(
{"id": 1, "name": "", "status": "Ended"}, {}, today=REF_DATE
)
def test_missing_status_raises(self):
with pytest.raises(ValueError, match="'status'"):
parse_tv_show_info({"id": 1, "name": "X"}, {}, today=REF_DATE)
def test_season_missing_number_raises(self):
with pytest.raises(ValueError, match="season_number"):
parse_tv_show_info(
_details(seasons=[{"episode_count": 5}]),
{},
today=REF_DATE,
)
def test_season_missing_episode_count_raises(self):
with pytest.raises(ValueError, match="episode_count"):
parse_tv_show_info(
_details(seasons=[{"season_number": 1}]),
{},
today=REF_DATE,
)
# ════════════════════════════════════════════════════════════════════════════
# parse_movie_info
# ════════════════════════════════════════════════════════════════════════════
def _movie_details(**overrides):
base = {
"id": 27205,
"title": "Inception",
"release_date": "2010-07-16",
}
base.update(overrides)
return base
class TestParseMovieInfoHappyPath:
def test_minimal(self):
info = parse_movie_info(
_movie_details(),
{"imdb_id": "tt1375666"},
)
assert info == TmdbMovieInfo(
tmdb_id=TmdbId(27205),
imdb_id=ImdbId("tt1375666"),
title=MovieTitle("Inception"),
release_year=ReleaseYear(2010),
)
def test_release_year_extracted_from_release_date(self):
info = parse_movie_info(
_movie_details(release_date="1999-03-31"),
{},
)
assert info.release_year == ReleaseYear(1999)
class TestParseMovieInfoImdb:
def test_missing_imdb_id_becomes_none(self):
info = parse_movie_info(_movie_details(), {})
assert info.imdb_id is None
def test_null_imdb_id_becomes_none(self):
info = parse_movie_info(_movie_details(), {"imdb_id": None})
assert info.imdb_id is None
def test_empty_string_imdb_id_becomes_none(self):
info = parse_movie_info(_movie_details(), {"imdb_id": ""})
assert info.imdb_id is None
class TestParseMovieInfoReleaseYear:
def test_no_release_date_yields_none(self):
info = parse_movie_info(_movie_details(release_date=None), {})
assert info.release_year is None
def test_empty_release_date_yields_none(self):
info = parse_movie_info(_movie_details(release_date=""), {})
assert info.release_year is None
def test_release_date_too_short_yields_none(self):
info = parse_movie_info(_movie_details(release_date="201"), {})
assert info.release_year is None
def test_release_date_non_numeric_prefix_yields_none(self):
info = parse_movie_info(_movie_details(release_date="soon"), {})
assert info.release_year is None
def test_missing_release_date_yields_none(self):
# release_date key absent from the payload.
info = parse_movie_info({"id": 1, "title": "X"}, {})
assert info.release_year is None
class TestParseMovieInfoErrors:
def test_missing_id_raises(self):
with pytest.raises(ValueError, match="'id'"):
parse_movie_info({"title": "X"}, {})
def test_missing_title_raises(self):
with pytest.raises(ValueError, match="'title'"):
parse_movie_info({"id": 1}, {})
def test_empty_title_raises(self):
with pytest.raises(ValueError, match="'title'"):
parse_movie_info({"id": 1, "title": ""}, {})