e07c9ec77b
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.
148 lines
4.9 KiB
Python
148 lines
4.9 KiB
Python
"""Tests for ``alfred.application.torrents.search_torrents.SearchTorrentsUseCase``.
|
|
|
|
Wraps ``KnabenClient.search`` and converts ``TorrentResult`` objects into
|
|
plain dicts inside a ``SearchTorrentsResponse`` envelope.
|
|
|
|
Coverage:
|
|
|
|
- ``TestSuccess`` — multiple results → status="ok" + ``count`` + dict shape.
|
|
- ``TestEmptyResults`` — empty list from client → status="error",
|
|
error="not_found".
|
|
- ``TestErrorTranslation`` — ``KnabenNotFoundError`` → not_found,
|
|
``KnabenAPIError`` → api_error, ``ValueError`` → validation_failed.
|
|
- ``TestPassThrough`` — query + limit are forwarded to the client.
|
|
|
|
KnabenClient is fully mocked — no real HTTP.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from alfred.application.torrents.search_torrents import SearchTorrentsUseCase
|
|
from alfred.infrastructure.api.knaben.dto import TorrentResult
|
|
from alfred.infrastructure.api.knaben.exceptions import (
|
|
KnabenAPIError,
|
|
KnabenNotFoundError,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
return MagicMock()
|
|
|
|
|
|
@pytest.fixture
|
|
def use_case(client):
|
|
return SearchTorrentsUseCase(client)
|
|
|
|
|
|
def _torrent(**kw) -> TorrentResult:
|
|
defaults = dict(
|
|
title="Inception.2010.1080p",
|
|
size="10 GB",
|
|
seeders=500,
|
|
leechers=50,
|
|
magnet="magnet:?xt=abc",
|
|
info_hash="abc",
|
|
tracker="rarbg",
|
|
upload_date="2020-01-01",
|
|
category="movie",
|
|
)
|
|
defaults.update(kw)
|
|
return TorrentResult(**defaults)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Success #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class TestSuccess:
|
|
def test_single_result_serialized_to_dict(self, client, use_case):
|
|
client.search.return_value = [_torrent()]
|
|
r = use_case.execute("Inception")
|
|
assert r.status == "ok"
|
|
assert r.count == 1
|
|
assert len(r.torrents) == 1
|
|
t = r.torrents[0]
|
|
assert t["name"] == "Inception.2010.1080p"
|
|
assert t["size"] == "10 GB"
|
|
assert t["seeders"] == 500
|
|
assert t["leechers"] == 50
|
|
assert t["magnet"].startswith("magnet:")
|
|
assert t["info_hash"] == "abc"
|
|
assert t["tracker"] == "rarbg"
|
|
assert t["upload_date"] == "2020-01-01"
|
|
assert t["category"] == "movie"
|
|
|
|
def test_multiple_results(self, client, use_case):
|
|
client.search.return_value = [
|
|
_torrent(title="A"),
|
|
_torrent(title="B"),
|
|
_torrent(title="C"),
|
|
]
|
|
r = use_case.execute("x")
|
|
assert r.count == 3
|
|
assert [t["name"] for t in r.torrents] == ["A", "B", "C"]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Empty #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class TestEmptyResults:
|
|
def test_empty_list_becomes_not_found(self, client, use_case):
|
|
client.search.return_value = []
|
|
r = use_case.execute("ghost")
|
|
assert r.status == "error"
|
|
assert r.error == "not_found"
|
|
assert "ghost" in r.message
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Error translation #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class TestErrorTranslation:
|
|
def test_not_found(self, client, use_case):
|
|
client.search.side_effect = KnabenNotFoundError("nope")
|
|
r = use_case.execute("x")
|
|
assert r.status == "error"
|
|
assert r.error == "not_found"
|
|
assert "nope" in r.message
|
|
|
|
def test_api_error(self, client, use_case):
|
|
client.search.side_effect = KnabenAPIError("rate limited")
|
|
r = use_case.execute("x")
|
|
assert r.status == "error"
|
|
assert r.error == "api_error"
|
|
assert "rate" in r.message
|
|
|
|
def test_validation_error(self, client, use_case):
|
|
client.search.side_effect = ValueError("too long")
|
|
r = use_case.execute("x")
|
|
assert r.status == "error"
|
|
assert r.error == "validation_failed"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Pass-through #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class TestPassThrough:
|
|
def test_default_limit_forwarded(self, client, use_case):
|
|
client.search.return_value = [_torrent()]
|
|
use_case.execute("Inception")
|
|
client.search.assert_called_once_with("Inception", limit=10)
|
|
|
|
def test_custom_limit_forwarded(self, client, use_case):
|
|
client.search.return_value = [_torrent()]
|
|
use_case.execute("Inception", limit=25)
|
|
client.search.assert_called_once_with("Inception", limit=25)
|