chore: sprint cleanup — language unification, parser unification, fossils removal
Several weeks of work accumulated without being committed. Grouped here for clarity; see CHANGELOG.md [Unreleased] for the user-facing summary. Highlights ---------- P1 #2 — ISO 639-2/B canonical migration - New Language VO + LanguageRegistry (alfred/domain/shared/knowledge/). - iso_languages.yaml as single source of truth for language codes. - SubtitleKnowledgeBase now delegates lookup to LanguageRegistry; subtitles.yaml only declares subtitle-specific tokens (vostfr, vf, vff, …). - SubtitlePreferences default → ["fre", "eng"]; subtitle filenames written as {iso639_2b}.srt (legacy fr.srt still read via alias). - Scanner: dropped _LANG_KEYWORDS / _SDH_TOKENS / _FORCED_TOKENS / SUBTITLE_EXTENSIONS hardcoded dicts. - Fixed: 'hi' token no longer marks SDH (conflicted with Hindi alias). - Added settings.min_movie_size_bytes (was a module constant). P1 #3 — Release parser unification + data-driven tokenizer - parse_release() is now the single source of truth for release-name parsing. - alfred/knowledge/release/separators.yaml declares the token separators used by the tokenizer (., space, [, ], (, ), _). New conventions can be added without code changes. - Tokenizer now splits on any configured separator instead of name.split('.'). Releases like 'The Father (2020) [1080p] [WEBRip] [5.1] [YTS.MX]' parse via the direct path without sanitization fallback. - Site-tag extraction always runs first; well-formedness only rejects truly forbidden chars. - _parse_season_episode() extended with NxNN / NxNNxNN alt forms. - Removed dead helpers: _sanitize, _normalize. Domain cleanup - Deleted fossil services with zero production callers: alfred/domain/movies/services.py alfred/domain/tv_shows/services.py alfred/domain/subtitles/services.py (replaced by subtitles/services/ package) alfred/domain/subtitles/repositories.py - Split monolithic subtitle services into a package (identifier, matcher, placer, pattern_detector, utils) + dedicated knowledge/ package. - MediaInfo split into dedicated package (alfred/domain/shared/media/: audio, video, subtitle, info, matching). Persistence cleanup - Removed dead JSON repositories (movie/subtitle/tvshow_repository.py). Tests - Major expansion of the test suite organized to mirror the source tree. - Removed obsolete *_edge_cases test files superseded by structured tests. - Suite: 990 passed, 8 skipped. Misc - .gitignore: exclude env_backup/ and *.bak. - Adjustments across agent/llm, app.py, application/filesystem, and infrastructure/filesystem to align with the new domain layout.
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
"""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 TestIsConfigured:
|
||||
def test_true_when_complete(self, client):
|
||||
assert client.is_configured() is True
|
||||
Reference in New Issue
Block a user