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,228 @@
|
||||
"""Tests for ``alfred.infrastructure.api.knaben.client.KnabenClient``.
|
||||
|
||||
- ``TestInit`` — explicit args override settings; no API key required.
|
||||
- ``TestMakeRequest`` — error translation: timeout, 404, 429 (rate limit),
|
||||
generic 5xx, and ``RequestException``.
|
||||
- ``TestSearch`` — query validation, success path, empty hits, request
|
||||
parameter wiring (search_field/order_by/etc.), 404 → empty list,
|
||||
per-result parse failures are swallowed (best-effort parsing).
|
||||
- ``TestParseTorrent`` — coverage of optional/missing fields and
|
||||
``int(... or 0)`` coercion for null seeders/leechers.
|
||||
|
||||
All HTTP is mocked at ``alfred.infrastructure.api.knaben.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.knaben.client import KnabenClient
|
||||
from alfred.infrastructure.api.knaben.exceptions import (
|
||||
KnabenAPIError,
|
||||
KnabenNotFoundError,
|
||||
)
|
||||
|
||||
|
||||
def _ok_response(json_body):
|
||||
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 KnabenClient(base_url="https://api.knaben.test/v1", timeout=5)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Init #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestInit:
|
||||
def test_default_base_url(self):
|
||||
c = KnabenClient()
|
||||
assert c.base_url == "https://api.knaben.org/v1"
|
||||
|
||||
def test_explicit_override(self):
|
||||
c = KnabenClient(base_url="https://x", timeout=99)
|
||||
assert c.base_url == "https://x"
|
||||
assert c.timeout == 99
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _make_request #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestMakeRequest:
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_timeout(self, mock_post, client):
|
||||
mock_post.side_effect = Timeout("slow")
|
||||
with pytest.raises(KnabenAPIError, match="timeout"):
|
||||
client._make_request({"q": "x"})
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_http_404(self, mock_post, client):
|
||||
mock_post.return_value = _http_error_response(404)
|
||||
with pytest.raises(KnabenNotFoundError):
|
||||
client._make_request({"q": "x"})
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_http_429_rate_limit(self, mock_post, client):
|
||||
mock_post.return_value = _http_error_response(429)
|
||||
with pytest.raises(KnabenAPIError, match="Rate limit"):
|
||||
client._make_request({"q": "x"})
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_http_500(self, mock_post, client):
|
||||
mock_post.return_value = _http_error_response(500)
|
||||
with pytest.raises(KnabenAPIError, match="500"):
|
||||
client._make_request({"q": "x"})
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_request_exception(self, mock_post, client):
|
||||
mock_post.side_effect = RequestException("net")
|
||||
with pytest.raises(KnabenAPIError, match="connect"):
|
||||
client._make_request({"q": "x"})
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_posts_json_body(self, mock_post, client):
|
||||
mock_post.return_value = _ok_response({"hits": []})
|
||||
client._make_request({"q": "x"})
|
||||
call = mock_post.call_args
|
||||
# KnabenClient sends params as JSON body, not query string
|
||||
assert call.kwargs["json"] == {"q": "x"}
|
||||
assert call.kwargs["timeout"] == 5
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# search #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestSearch:
|
||||
@pytest.mark.parametrize("bad", ["", None, 42])
|
||||
def test_invalid_query(self, client, bad):
|
||||
with pytest.raises(ValueError):
|
||||
client.search(bad)
|
||||
|
||||
def test_query_too_long(self, client):
|
||||
with pytest.raises(ValueError, match="too long"):
|
||||
client.search("a" * 501)
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_success(self, mock_post, client):
|
||||
mock_post.return_value = _ok_response(
|
||||
{
|
||||
"hits": [
|
||||
{
|
||||
"title": "Inception.2010.1080p",
|
||||
"size": "10 GB",
|
||||
"seeders": 500,
|
||||
"leechers": 50,
|
||||
"magnetUrl": "magnet:?xt=...",
|
||||
"hash": "abc",
|
||||
"tracker": "rarbg",
|
||||
"date": "2020-01-01",
|
||||
"category": "movie",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
results = client.search("Inception")
|
||||
assert len(results) == 1
|
||||
r = results[0]
|
||||
assert r.title == "Inception.2010.1080p"
|
||||
assert r.seeders == 500
|
||||
assert r.magnet.startswith("magnet:")
|
||||
assert r.info_hash == "abc"
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_empty_hits_returns_empty_list(self, mock_post, client):
|
||||
mock_post.return_value = _ok_response({"hits": []})
|
||||
assert client.search("nothing") == []
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_404_returns_empty_list(self, mock_post, client):
|
||||
mock_post.return_value = _http_error_response(404)
|
||||
assert client.search("nothing") == []
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_request_parameters(self, mock_post, client):
|
||||
mock_post.return_value = _ok_response({"hits": []})
|
||||
client.search("Inception", limit=25)
|
||||
params = mock_post.call_args.kwargs["json"]
|
||||
assert params["query"] == "Inception"
|
||||
assert params["search_field"] == "title"
|
||||
assert params["order_by"] == "peers"
|
||||
assert params["order_direction"] == "desc"
|
||||
assert params["size"] == 25
|
||||
assert params["hide_unsafe"] is True
|
||||
assert params["hide_xxx"] is True
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_default_limit(self, mock_post, client):
|
||||
mock_post.return_value = _ok_response({"hits": []})
|
||||
client.search("x")
|
||||
assert mock_post.call_args.kwargs["json"]["size"] == 10
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_unexpected_exception_propagates(self, mock_post, client):
|
||||
# Anything other than KnabenNotFoundError bubbles up.
|
||||
mock_post.side_effect = RuntimeError("boom")
|
||||
with pytest.raises(RuntimeError):
|
||||
client.search("x")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _parse_torrent #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestParseTorrent:
|
||||
def test_minimal(self, client):
|
||||
r = client._parse_torrent({})
|
||||
assert r.title == "Unknown"
|
||||
assert r.size == "Unknown"
|
||||
assert r.seeders == 0
|
||||
assert r.leechers == 0
|
||||
assert r.magnet == ""
|
||||
|
||||
def test_null_seeders_coerced_to_zero(self, client):
|
||||
r = client._parse_torrent({"seeders": None, "leechers": None})
|
||||
assert r.seeders == 0
|
||||
assert r.leechers == 0
|
||||
|
||||
def test_optional_fields_propagated(self, client):
|
||||
r = client._parse_torrent(
|
||||
{
|
||||
"title": "X",
|
||||
"size": "1 GB",
|
||||
"seeders": 10,
|
||||
"leechers": 2,
|
||||
"magnetUrl": "magnet:?",
|
||||
"hash": "h",
|
||||
"tracker": "t",
|
||||
"date": "d",
|
||||
"category": "c",
|
||||
}
|
||||
)
|
||||
assert r.info_hash == "h"
|
||||
assert r.tracker == "t"
|
||||
assert r.upload_date == "d"
|
||||
assert r.category == "c"
|
||||
@@ -0,0 +1,421 @@
|
||||
"""Tests for ``alfred.infrastructure.api.qbittorrent.client.QBittorrentClient``.
|
||||
|
||||
Exercises every public method against a ``MagicMock`` ``requests.Session``
|
||||
attached to the client. Auth state (``self._authenticated``) is asserted
|
||||
explicitly so the implicit auto-login behavior of mutation methods is
|
||||
covered.
|
||||
|
||||
Scope:
|
||||
|
||||
- ``TestInit`` — host/credentials wiring + Session attached.
|
||||
- ``TestMakeRequest`` — verb dispatch (GET/POST), JSON vs text fallback,
|
||||
error translation for timeout/403/5xx/RequestException, invalid verb.
|
||||
- ``TestLogin`` — happy path, non-"Ok." rejection, propagation from
|
||||
underlying API error.
|
||||
- ``TestGetTorrents`` — auto-login, non-list payload safety, per-item parse
|
||||
failures.
|
||||
- ``TestAddTorrent`` — magnet payload wiring, optional category/save_path,
|
||||
paused flag, unexpected response.
|
||||
- ``TestMutations`` — pause/resume/delete/recheck/set_location all wire the
|
||||
hash and propagate errors.
|
||||
- ``TestFindByName`` — exact match, case-insensitive match, save_path fallback,
|
||||
no match.
|
||||
- ``TestParseTorrent`` — progress percentage conversion, defaults.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from requests.exceptions import HTTPError, RequestException, Timeout
|
||||
|
||||
from alfred.infrastructure.api.qbittorrent.client import QBittorrentClient
|
||||
from alfred.infrastructure.api.qbittorrent.dto import TorrentInfo
|
||||
from alfred.infrastructure.api.qbittorrent.exceptions import (
|
||||
QBittorrentAPIError,
|
||||
QBittorrentAuthError,
|
||||
)
|
||||
|
||||
|
||||
def _resp(body, *, status=200, json_decodable=True):
|
||||
r = MagicMock()
|
||||
r.status_code = status
|
||||
r.raise_for_status.return_value = None
|
||||
if json_decodable:
|
||||
r.json.return_value = body
|
||||
else:
|
||||
r.json.side_effect = ValueError("not json")
|
||||
r.text = body
|
||||
return r
|
||||
|
||||
|
||||
def _http_error(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():
|
||||
c = QBittorrentClient(
|
||||
host="http://qbit.test:8080",
|
||||
username="admin",
|
||||
password="secret",
|
||||
timeout=5,
|
||||
)
|
||||
# Replace requests.Session with a MagicMock so we control responses
|
||||
c.session = MagicMock()
|
||||
return c
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Init #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestInit:
|
||||
def test_explicit_args(self):
|
||||
c = QBittorrentClient(
|
||||
host="http://x:1", username="u", password="p", timeout=99
|
||||
)
|
||||
assert c.host == "http://x:1"
|
||||
assert c.username == "u"
|
||||
assert c.password == "p"
|
||||
assert c.timeout == 99
|
||||
assert c._authenticated is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _make_request #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestMakeRequest:
|
||||
def test_invalid_verb(self, client):
|
||||
with pytest.raises(ValueError, match="HTTP"):
|
||||
client._make_request("PATCH", "/api/v2/foo")
|
||||
|
||||
def test_get_returns_json(self, client):
|
||||
client.session.get.return_value = _resp({"k": "v"})
|
||||
out = client._make_request("GET", "/x", data={"a": 1})
|
||||
assert out == {"k": "v"}
|
||||
client.session.get.assert_called_once()
|
||||
|
||||
def test_post_returns_text_when_not_json(self, client):
|
||||
client.session.post.return_value = _resp("Ok.", json_decodable=False)
|
||||
out = client._make_request("POST", "/x", data={"a": 1})
|
||||
assert out == "Ok."
|
||||
|
||||
def test_timeout(self, client):
|
||||
client.session.get.side_effect = Timeout("slow")
|
||||
with pytest.raises(QBittorrentAPIError, match="timeout"):
|
||||
client._make_request("GET", "/x")
|
||||
|
||||
def test_http_403_auth_error(self, client):
|
||||
client.session.post.return_value = _http_error(403)
|
||||
with pytest.raises(QBittorrentAuthError):
|
||||
client._make_request("POST", "/x")
|
||||
|
||||
def test_http_500_generic(self, client):
|
||||
client.session.get.return_value = _http_error(500)
|
||||
with pytest.raises(QBittorrentAPIError, match="500"):
|
||||
client._make_request("GET", "/x")
|
||||
|
||||
def test_request_exception(self, client):
|
||||
client.session.get.side_effect = RequestException("net down")
|
||||
with pytest.raises(QBittorrentAPIError, match="connect"):
|
||||
client._make_request("GET", "/x")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Login #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestLogin:
|
||||
def test_login_success(self, client):
|
||||
client.session.post.return_value = _resp("Ok.", json_decodable=False)
|
||||
assert client.login() is True
|
||||
assert client._authenticated is True
|
||||
|
||||
def test_login_wrong_credentials(self, client):
|
||||
client.session.post.return_value = _resp("Fails.", json_decodable=False)
|
||||
with pytest.raises(QBittorrentAuthError):
|
||||
client.login()
|
||||
assert client._authenticated is False
|
||||
|
||||
def test_login_api_error_translated_to_auth_error(self, client):
|
||||
client.session.post.return_value = _http_error(403)
|
||||
with pytest.raises(QBittorrentAuthError):
|
||||
client.login()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# get_torrents (auto-login behavior) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestGetTorrents:
|
||||
def test_auto_logs_in_then_fetches(self, client):
|
||||
# Order: 1) login POST, 2) torrents/info GET
|
||||
client.session.post.return_value = _resp("Ok.", json_decodable=False)
|
||||
client.session.get.return_value = _resp(
|
||||
[
|
||||
{
|
||||
"hash": "h1",
|
||||
"name": "Foo",
|
||||
"size": 100,
|
||||
"progress": 0.5,
|
||||
"state": "downloading",
|
||||
"dlspeed": 1024,
|
||||
"upspeed": 512,
|
||||
"eta": 60,
|
||||
"num_seeds": 5,
|
||||
"num_leechs": 1,
|
||||
"ratio": 0.1,
|
||||
"category": "movies",
|
||||
"save_path": "/dl",
|
||||
}
|
||||
]
|
||||
)
|
||||
torrents = client.get_torrents()
|
||||
assert len(torrents) == 1
|
||||
assert torrents[0].name == "Foo"
|
||||
assert torrents[0].progress == 50.0 # 0.5 → 50%
|
||||
assert client._authenticated is True
|
||||
|
||||
def test_non_list_returns_empty(self, client):
|
||||
client._authenticated = True
|
||||
client.session.get.return_value = _resp({"oops": "bad"})
|
||||
assert client.get_torrents() == []
|
||||
|
||||
def test_filter_and_category_propagated(self, client):
|
||||
client._authenticated = True
|
||||
client.session.get.return_value = _resp([])
|
||||
client.get_torrents(filter="completed", category="movies")
|
||||
params = client.session.get.call_args.kwargs["params"]
|
||||
assert params == {"filter": "completed", "category": "movies"}
|
||||
|
||||
def test_skips_unparseable_torrents(self, client):
|
||||
client._authenticated = True
|
||||
# _parse_torrent uses .get on every field with sensible defaults, so
|
||||
# malformed dicts almost never raise — patch the parser to force it.
|
||||
client.session.get.return_value = _resp([{"good": True}])
|
||||
with patch.object(client, "_parse_torrent", side_effect=Exception("nope")):
|
||||
assert client.get_torrents() == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# add_torrent #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestAddTorrent:
|
||||
def test_add_success(self, client):
|
||||
client._authenticated = True
|
||||
client.session.post.return_value = _resp("Ok.", json_decodable=False)
|
||||
assert client.add_torrent("magnet:?xt=foo") is True
|
||||
|
||||
def test_add_unexpected_response(self, client):
|
||||
client._authenticated = True
|
||||
client.session.post.return_value = _resp("Fails.", json_decodable=False)
|
||||
assert client.add_torrent("magnet:?xt=foo") is False
|
||||
|
||||
def test_add_payload(self, client):
|
||||
client._authenticated = True
|
||||
client.session.post.return_value = _resp("Ok.", json_decodable=False)
|
||||
client.add_torrent(
|
||||
"magnet:?xt=foo", category="movies", save_path="/dl", paused=True
|
||||
)
|
||||
payload = client.session.post.call_args.kwargs["data"]
|
||||
assert payload["urls"] == "magnet:?xt=foo"
|
||||
assert payload["paused"] == "true"
|
||||
assert payload["category"] == "movies"
|
||||
assert payload["savepath"] == "/dl"
|
||||
|
||||
def test_paused_false_serialized(self, client):
|
||||
client._authenticated = True
|
||||
client.session.post.return_value = _resp("Ok.", json_decodable=False)
|
||||
client.add_torrent("magnet:?xt=foo")
|
||||
payload = client.session.post.call_args.kwargs["data"]
|
||||
assert payload["paused"] == "false"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Mutations (delete, pause, resume, recheck, set_location) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestMutations:
|
||||
def _ok(self, client):
|
||||
client._authenticated = True
|
||||
client.session.post.return_value = _resp("Ok.", json_decodable=False)
|
||||
|
||||
def test_delete_success(self, client):
|
||||
self._ok(client)
|
||||
assert client.delete_torrent("hash1", delete_files=True) is True
|
||||
payload = client.session.post.call_args.kwargs["data"]
|
||||
assert payload["hashes"] == "hash1"
|
||||
assert payload["deleteFiles"] == "true"
|
||||
|
||||
def test_delete_no_files_default(self, client):
|
||||
self._ok(client)
|
||||
client.delete_torrent("hash1")
|
||||
assert (
|
||||
client.session.post.call_args.kwargs["data"]["deleteFiles"] == "false"
|
||||
)
|
||||
|
||||
def test_pause(self, client):
|
||||
self._ok(client)
|
||||
assert client.pause_torrent("hash1") is True
|
||||
|
||||
def test_resume(self, client):
|
||||
self._ok(client)
|
||||
assert client.resume_torrent("hash1") is True
|
||||
|
||||
def test_recheck(self, client):
|
||||
self._ok(client)
|
||||
assert client.recheck("hash1") is True
|
||||
|
||||
def test_set_location(self, client):
|
||||
self._ok(client)
|
||||
assert client.set_location("hash1", "/new/path") is True
|
||||
payload = client.session.post.call_args.kwargs["data"]
|
||||
assert payload == {"hashes": "hash1", "location": "/new/path"}
|
||||
|
||||
def test_mutation_propagates_api_error(self, client):
|
||||
client._authenticated = True
|
||||
client.session.post.return_value = _http_error(500)
|
||||
with pytest.raises(QBittorrentAPIError):
|
||||
client.delete_torrent("hash1")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# find_by_name #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _torrent_dict(name, save_path=None):
|
||||
return {
|
||||
"hash": "h",
|
||||
"name": name,
|
||||
"size": 1,
|
||||
"progress": 0.0,
|
||||
"state": "x",
|
||||
"dlspeed": 0,
|
||||
"upspeed": 0,
|
||||
"eta": 0,
|
||||
"num_seeds": 0,
|
||||
"num_leechs": 0,
|
||||
"ratio": 0.0,
|
||||
"save_path": save_path,
|
||||
}
|
||||
|
||||
|
||||
class TestFindByName:
|
||||
def test_exact_match(self, client):
|
||||
client._authenticated = True
|
||||
client.session.get.return_value = _resp(
|
||||
[_torrent_dict("Foundation.S01"), _torrent_dict("Other")]
|
||||
)
|
||||
result = client.find_by_name("Foundation.S01")
|
||||
assert isinstance(result, TorrentInfo)
|
||||
assert result.name == "Foundation.S01"
|
||||
|
||||
def test_case_insensitive_match(self, client):
|
||||
client._authenticated = True
|
||||
client.session.get.return_value = _resp(
|
||||
[_torrent_dict("foundation.s01")]
|
||||
)
|
||||
result = client.find_by_name("Foundation.S01")
|
||||
assert result is not None
|
||||
assert result.name == "foundation.s01"
|
||||
|
||||
def test_save_path_fallback(self, client):
|
||||
client._authenticated = True
|
||||
client.session.get.return_value = _resp(
|
||||
[_torrent_dict("Different", save_path="/dl/Foundation.S01")]
|
||||
)
|
||||
result = client.find_by_name("Foundation.S01")
|
||||
assert result is not None
|
||||
assert result.save_path.endswith("Foundation.S01")
|
||||
|
||||
def test_no_match_returns_none(self, client):
|
||||
client._authenticated = True
|
||||
client.session.get.return_value = _resp([_torrent_dict("nope")])
|
||||
assert client.find_by_name("Foundation.S01") is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _parse_torrent #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestParseTorrent:
|
||||
def test_defaults(self, client):
|
||||
t = client._parse_torrent({})
|
||||
assert t.hash == ""
|
||||
assert t.name == "Unknown"
|
||||
assert t.progress == 0.0
|
||||
assert t.state == "unknown"
|
||||
|
||||
def test_progress_converted_to_percentage(self, client):
|
||||
t = client._parse_torrent({"progress": 0.75})
|
||||
assert t.progress == 75.0
|
||||
|
||||
def test_full_payload(self, client):
|
||||
t = client._parse_torrent(
|
||||
{
|
||||
"hash": "h",
|
||||
"name": "n",
|
||||
"size": 1024,
|
||||
"progress": 1.0,
|
||||
"state": "uploading",
|
||||
"dlspeed": 100,
|
||||
"upspeed": 50,
|
||||
"eta": 0,
|
||||
"num_seeds": 10,
|
||||
"num_leechs": 2,
|
||||
"ratio": 2.5,
|
||||
"category": "movies",
|
||||
"save_path": "/dl",
|
||||
}
|
||||
)
|
||||
assert t.progress == 100.0
|
||||
assert t.ratio == 2.5
|
||||
assert t.category == "movies"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# logout #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestLogout:
|
||||
def test_logout_success(self, client):
|
||||
client._authenticated = True
|
||||
client.session.post.return_value = _resp("", json_decodable=False)
|
||||
assert client.logout() is True
|
||||
assert client._authenticated is False
|
||||
|
||||
def test_logout_swallows_errors(self, client):
|
||||
client._authenticated = True
|
||||
client.session.post.side_effect = RuntimeError("boom")
|
||||
# Per implementation, logout returns False instead of raising.
|
||||
assert client.logout() is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# get_torrent_properties #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestGetTorrentProperties:
|
||||
def test_properties_returned(self, client):
|
||||
client._authenticated = True
|
||||
client.session.get.return_value = _resp({"piece_size": 16384})
|
||||
assert client.get_torrent_properties("h")["piece_size"] == 16384
|
||||
@@ -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