"""Tests de l'indexeur Torznab (compatibilité Sonarr / Prowlarr).""" import xml.etree.ElementTree as ET import pytest from httpx import ASGITransport, AsyncClient from app.db import db from app.main import app from app.scrapers.base import Episode, SearchResult, VideoLink from app.services.settings import get_torznab_apikey from app.services.torznab import torznab as service @pytest.fixture async def client() -> AsyncClient: transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as c: yield c @pytest.fixture async def apikey() -> str: return await get_torznab_apikey() class FakeSource: name = "fake" label = "Fake" base_url = "https://fake.example" async def search(self, query: str) -> list[SearchResult]: return [ SearchResult( source="fake", source_id="frieren-1", title="Frieren", url="https://fake.example/frieren", ) ] async def list_episodes(self, source_id: str) -> list[Episode]: return [ Episode(number=1, title="Épisode 1", url="https://fake.example/ep1", season=1), Episode(number=2, title="Épisode 2", url="https://fake.example/ep2", season=1), Episode(number=2.5, title="OAV", url="https://fake.example/oav", season=1), Episode(number=1, title="Épisode 1", url="https://fake.example/s2ep1", season=2), ] async def extract_embed_links(self, episode_url: str) -> list[str]: return ["https://embed.example/player"] async def get_details(self, source_id): ... async def latest(self): return [] @pytest.fixture def fake_source(monkeypatch): source = FakeSource() async def _sources(): return [source] monkeypatch.setattr(service, "_enabled_sources", _sources) return source # ---------------------------------------------------------------- auth + caps async def test_torznab_rejects_missing_apikey(client): r = await client.get("/torznab/api", params={"t": "caps"}) assert r.status_code == 401 assert 'code="100"' in r.text async def test_torznab_rejects_wrong_apikey(client): r = await client.get("/torznab/api", params={"t": "caps", "apikey": "mauvaise"}) assert r.status_code == 401 async def test_torznab_caps(client, apikey): r = await client.get("/torznab/api", params={"t": "caps", "apikey": apikey}) assert r.status_code == 200 root = ET.fromstring(r.text) assert root.tag == "caps" tv = root.find(".//tv-search") assert tv is not None and tv.get("available") == "yes" assert "q,season,ep" in (tv.get("supportedParams") or "") assert any(c.get("id") == "5070" for c in root.findall(".//subcat")) async def test_torznab_apikey_via_header(client, apikey): r = await client.get("/torznab/api", params={"t": "caps"}, headers={"X-Api-Key": apikey}) assert r.status_code == 200 async def test_torznab_unknown_function(client, apikey): r = await client.get("/torznab/api", params={"t": "music", "apikey": apikey}) assert r.status_code == 400 assert 'code="203"' in r.text # ---------------------------------------------------------------- recherche async def test_torznab_tvsearch_filters_season_ep(client, apikey, fake_source): r = await client.get( "/torznab/api", params={"t": "tvsearch", "q": "Frieren", "season": 1, "ep": 2, "apikey": apikey}, ) assert r.status_code == 200 root = ET.fromstring(r.text) items = root.findall(".//item") assert len(items) == 1 assert items[0].find("title").text == "Frieren S01E02 VOSTFR WEB-DL" link = items[0].find("link").text assert "/torznab/download" in link and "season=1" in link and "ep=2" in link async def test_torznab_tvsearch_all_episodes(client, apikey, fake_source): r = await client.get( "/torznab/api", params={"t": "tvsearch", "q": "Frieren", "apikey": apikey} ) root = ET.fromstring(r.text) titles = [i.find("title").text for i in root.findall(".//item")] assert len(titles) == 3 # OAV 2.5 exclue : S01E01, S01E02, S02E01 assert "Frieren S02E01 VOSTFR WEB-DL" in titles async def test_torznab_search_requires_query(client, apikey): r = await client.get("/torznab/api", params={"t": "search", "apikey": apikey}) assert r.status_code == 400 # ---------------------------------------------------------------- grab class _FakeManager: def __init__(self) -> None: self.calls: list[tuple[str, str, str]] = [] async def enqueue(self, video_url: str, page_url: str, title: str) -> dict: self.calls.append((video_url, page_url, title)) return {"id": 7, "title": title, "status": "pending"} class _FakeExtractor: name = "fakehost" async def extract(self, embed_url: str) -> VideoLink: return VideoLink(url="https://cdn.example/video.mp4", hoster="fakehost") async def test_torznab_download_enqueues_and_returns_torrent(client, apikey, fake_source, monkeypatch): manager = _FakeManager() monkeypatch.setattr("app.scrapers.base.get_source", lambda name: fake_source) monkeypatch.setattr("app.services.torznab.resolve_hoster", lambda url: _FakeExtractor()) monkeypatch.setattr("app.services.torznab.download_manager", manager) r = await client.get( "/torznab/download", params={ "apikey": apikey, "source": "fake", "sid": "frieren-1", "season": 1, "ep": 2, "series": "Frieren", }, ) assert r.status_code == 200 assert r.headers["content-type"].startswith("application/x-bittorrent") assert r.content.startswith(b"d") and b"OhmStreaming" in r.content # bencode valide assert manager.calls == [ ("https://cdn.example/video.mp4", "https://fake.example/ep2", "Frieren S01E02") ] async def test_torznab_download_rejects_bad_key(client): r = await client.get("/torznab/download", params={"apikey": "mauvaise"}) assert r.status_code == 401 async def test_torznab_download_unknown_episode(client, apikey, fake_source, monkeypatch): monkeypatch.setattr("app.scrapers.base.get_source", lambda name: fake_source) r = await client.get( "/torznab/download", params={ "apikey": apikey, "source": "fake", "sid": "frieren-1", "season": 9, "ep": 9, "series": "Frieren", }, ) assert r.status_code == 502 assert 'code="300"' in r.text # ---------------------------------------------------------------- admin async def _admin(client: AsyncClient): r = await client.post("/auth/register", data={"username": "admin", "password": "secret123"}) assert r.status_code == 303 return r.cookies async def test_admin_integrations(client, apikey): cookies = await _admin(client) r = await client.get("/api/admin/integrations", cookies=cookies) assert r.status_code == 200 data = r.json() assert data["torznab"]["apikey"] == apikey assert data["torznab"]["endpoint"].endswith("/torznab/api") assert data["sonarr"] == {"url": "", "apikey": ""} r = await client.put( "/api/admin/integrations/sonarr", json={"url": "http://sonarr:8989/", "apikey": "abc"}, cookies=cookies, ) assert r.status_code == 200 row = await db.fetchone("SELECT value FROM settings WHERE key = 'sonarr:url'") assert row["value"] == '"http://sonarr:8989"' # slash final retiré async def test_admin_regenerate_torznab_key(client, apikey): cookies = await _admin(client) r = await client.post("/api/admin/integrations/torznab/regenerate", cookies=cookies) assert r.status_code == 200 new_key = r.json()["apikey"] assert new_key != apikey and len(new_key) == 32 r = await client.get("/torznab/api", params={"t": "caps", "apikey": apikey}) assert r.status_code == 401 # l'ancienne clé est révoquée