test_discover.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests fuer TMDBDiscoverClient — TMDB Discover API Client.
  4. Alle HTTP-Requests werden via unittest.mock simuliert.
  5. Testet Genre-Mapping, Normalisierung, Paginierung, Fehlerbehandlung
  6. und Standard-Sprach-Zuordnungen pro Dienst.
  7. """
  8. from __future__ import annotations
  9. import asyncio
  10. from datetime import date, timedelta
  11. from unittest.mock import AsyncMock, MagicMock, patch
  12. import pytest
  13. from plugins.streaming.catalog.tmdb_discover import (
  14. GENRE_MAP_MOVIE,
  15. GENRE_MAP_TV,
  16. PROVIDER_IDS,
  17. TMDBDiscoverClient,
  18. _DEFAULT_LANGUAGES,
  19. )
  20. # ---------------------------------------------------------------------------
  21. # Hilfsfunktionen und Testdaten
  22. # ---------------------------------------------------------------------------
  23. def _tmdb_movie(
  24. title: str = "Inception",
  25. movie_id: int = 27205,
  26. genre_ids: list[int] | None = None,
  27. rating: float = 8.4,
  28. release_date: str = "2010-07-16",
  29. overview: str = "Ein Dieb stiehlt Geheimnisse aus dem Unterbewusstsein.",
  30. original_language: str = "en",
  31. ) -> dict:
  32. """Erzeugt ein TMDB-Film-Rohergebnis."""
  33. return {
  34. "id": movie_id,
  35. "title": title,
  36. "genre_ids": genre_ids or [28, 878],
  37. "vote_average": rating,
  38. "release_date": release_date,
  39. "overview": overview,
  40. "original_language": original_language,
  41. }
  42. def _tmdb_tv(
  43. name: str = "Breaking Bad",
  44. tv_id: int = 1396,
  45. genre_ids: list[int] | None = None,
  46. rating: float = 9.5,
  47. first_air_date: str = "2008-01-20",
  48. overview: str = "Ein Chemielehrer wird zum Drogenboss.",
  49. original_language: str = "en",
  50. ) -> dict:
  51. """Erzeugt ein TMDB-TV-Rohergebnis."""
  52. return {
  53. "id": tv_id,
  54. "name": name,
  55. "genre_ids": genre_ids or [18, 80],
  56. "vote_average": rating,
  57. "first_air_date": first_air_date,
  58. "overview": overview,
  59. "original_language": original_language,
  60. }
  61. def _discover_page(results: list[dict], page: int = 1, total_pages: int = 1) -> dict:
  62. """Erzeugt eine TMDB-Discover-API-Antwort (einzelne Seite)."""
  63. return {
  64. "page": page,
  65. "total_pages": total_pages,
  66. "total_results": len(results),
  67. "results": results,
  68. }
  69. @pytest.fixture()
  70. def client() -> TMDBDiscoverClient:
  71. """Liefert einen TMDBDiscoverClient mit Test-API-Key."""
  72. return TMDBDiscoverClient(api_key="test-api-key-123", region="DE", language="de-DE")
  73. # ===================================================================
  74. # Konstruktor
  75. # ===================================================================
  76. class TestKonstruktor:
  77. """Tests fuer __init__."""
  78. def test_konfiguration_gespeichert(self) -> None:
  79. """API-Key, Region und Sprache werden korrekt gespeichert."""
  80. c = TMDBDiscoverClient(api_key="my-key", region="US", language="en-US")
  81. assert c._api_key == "my-key"
  82. assert c._region == "US"
  83. assert c._language == "en-US"
  84. # ===================================================================
  85. # Genre-Mapping
  86. # ===================================================================
  87. class TestMapGenres:
  88. """Tests fuer _map_genres()."""
  89. def test_film_genres_korrekt(self, client: TMDBDiscoverClient) -> None:
  90. """Film-Genre-IDs werden zu deutschen Namen aufgeloest."""
  91. ergebnis = client._map_genres([28, 878, 53], "movie")
  92. assert ergebnis == ["Action", "Science Fiction", "Thriller"]
  93. def test_tv_genres_korrekt(self, client: TMDBDiscoverClient) -> None:
  94. """TV-Genre-IDs werden zu deutschen Namen aufgeloest."""
  95. ergebnis = client._map_genres([10759, 18], "tv")
  96. assert ergebnis == ["Action & Abenteuer", "Drama"]
  97. def test_unbekannte_genre_id_uebersprungen(self, client: TMDBDiscoverClient) -> None:
  98. """Unbekannte Genre-IDs werden uebersprungen."""
  99. ergebnis = client._map_genres([28, 99999, 53], "movie")
  100. assert ergebnis == ["Action", "Thriller"]
  101. assert len(ergebnis) == 2
  102. # ===================================================================
  103. # Normalisierung Film
  104. # ===================================================================
  105. class TestNormalizeMovie:
  106. """Tests fuer _normalize_movie()."""
  107. def test_erstellt_korrektes_titel_dict(self, client: TMDBDiscoverClient) -> None:
  108. """Film wird korrekt in das Katalog-Format konvertiert."""
  109. rohdaten = _tmdb_movie()
  110. ergebnis = client._normalize_movie(rohdaten, "netflix")
  111. assert ergebnis is not None
  112. assert ergebnis["t"] == "Inception"
  113. assert ergebnis["ty"] == "film"
  114. assert ergebnis["yr"] == 2010
  115. assert ergebnis["rt"] == 8.4
  116. assert ergebnis["orig_lang"] == "en"
  117. assert ergebnis["tmdb_id"] == 27205
  118. assert ergebnis["src"] == "tmdb"
  119. assert "netflix" in ergebnis["on"]
  120. def test_standard_dub_sub_netflix(self, client: TMDBDiscoverClient) -> None:
  121. """Netflix bekommt de/en Dub und de Sub als Standard."""
  122. rohdaten = _tmdb_movie()
  123. ergebnis = client._normalize_movie(rohdaten, "netflix")
  124. netflix_info = ergebnis["on"]["netflix"]
  125. assert "de" in netflix_info["dub"]
  126. assert "en" in netflix_info["dub"]
  127. assert "de" in netflix_info["sub"]
  128. def test_standard_dub_sub_crunchyroll(self, client: TMDBDiscoverClient) -> None:
  129. """Crunchyroll bekommt ja Dub und de Sub als Standard."""
  130. rohdaten = _tmdb_movie()
  131. ergebnis = client._normalize_movie(rohdaten, "crunchyroll")
  132. cr_info = ergebnis["on"]["crunchyroll"]
  133. assert cr_info["dub"] == ["ja"]
  134. assert cr_info["sub"] == ["de"]
  135. def test_leerer_titel_gibt_none(self, client: TMDBDiscoverClient) -> None:
  136. """Film ohne Titel gibt None."""
  137. rohdaten = _tmdb_movie(title="")
  138. ergebnis = client._normalize_movie(rohdaten, "netflix")
  139. assert ergebnis is None
  140. def test_fehlende_felder_graceful(self, client: TMDBDiscoverClient) -> None:
  141. """Fehlende optionale Felder werden sauber behandelt."""
  142. rohdaten = {"title": "Testfilm", "id": 1}
  143. ergebnis = client._normalize_movie(rohdaten, "netflix")
  144. assert ergebnis is not None
  145. assert ergebnis["t"] == "Testfilm"
  146. assert ergebnis["yr"] is None
  147. assert ergebnis["g"] == []
  148. # ===================================================================
  149. # Normalisierung TV
  150. # ===================================================================
  151. class TestNormalizeTv:
  152. """Tests fuer _normalize_tv()."""
  153. def test_erstellt_korrektes_titel_dict(self, client: TMDBDiscoverClient) -> None:
  154. """Serie wird korrekt in das Katalog-Format konvertiert."""
  155. rohdaten = _tmdb_tv()
  156. ergebnis = client._normalize_tv(rohdaten, "netflix")
  157. assert ergebnis is not None
  158. assert ergebnis["t"] == "Breaking Bad"
  159. assert ergebnis["ty"] == "serie"
  160. assert ergebnis["yr"] == 2008
  161. assert ergebnis["rt"] == 9.5
  162. assert ergebnis["tmdb_id"] == 1396
  163. def test_fehlende_felder_graceful(self, client: TMDBDiscoverClient) -> None:
  164. """Fehlende optionale Felder werden sauber behandelt."""
  165. rohdaten = {"name": "Testserie", "id": 2}
  166. ergebnis = client._normalize_tv(rohdaten, "netflix")
  167. assert ergebnis is not None
  168. assert ergebnis["t"] == "Testserie"
  169. assert ergebnis["yr"] is None
  170. assert ergebnis["rt"] is None
  171. def test_leerer_name_gibt_none(self, client: TMDBDiscoverClient) -> None:
  172. """Serie ohne Name gibt None."""
  173. rohdaten = _tmdb_tv(name="")
  174. ergebnis = client._normalize_tv(rohdaten, "netflix")
  175. assert ergebnis is None
  176. # ===================================================================
  177. # discover_service
  178. # ===================================================================
  179. class TestDiscoverService:
  180. """Tests fuer discover_service()."""
  181. async def test_kombiniert_filme_und_serien(self, client: TMDBDiscoverClient) -> None:
  182. """discover_service() kombiniert Filme und Serien."""
  183. movie_page = _discover_page([_tmdb_movie()])
  184. tv_page = _discover_page([_tmdb_tv()])
  185. with patch.object(client, "_get", new_callable=AsyncMock) as mock_get:
  186. mock_get.side_effect = [movie_page, tv_page]
  187. ergebnis = await client.discover_service("netflix", max_titles=20)
  188. assert len(ergebnis) == 2
  189. typen = {t["ty"] for t in ergebnis}
  190. assert "film" in typen
  191. assert "serie" in typen
  192. async def test_unbekannter_dienst_gibt_leere_liste(
  193. self, client: TMDBDiscoverClient,
  194. ) -> None:
  195. """Unbekannter Dienst gibt leere Liste ohne API-Aufruf."""
  196. ergebnis = await client.discover_service("nichtexistent")
  197. assert ergebnis == []
  198. # ===================================================================
  199. # Paginierung
  200. # ===================================================================
  201. class TestDiscoverMovies:
  202. """Tests fuer _discover_movies()."""
  203. async def test_paginierung_mehrere_seiten(self, client: TMDBDiscoverClient) -> None:
  204. """Paginierung liest mehrere Seiten bis max_pages."""
  205. seite1 = _discover_page(
  206. [_tmdb_movie(title="Film A", movie_id=1)],
  207. page=1,
  208. total_pages=3,
  209. )
  210. seite2 = _discover_page(
  211. [_tmdb_movie(title="Film B", movie_id=2)],
  212. page=2,
  213. total_pages=3,
  214. )
  215. with patch.object(client, "_get", new_callable=AsyncMock) as mock_get:
  216. mock_get.side_effect = [seite1, seite2]
  217. ergebnis = await client._discover_movies(provider_id=8, max_pages=2)
  218. assert len(ergebnis) == 2
  219. assert mock_get.call_count == 2
  220. async def test_leere_ergebnisse_beenden_paginierung(
  221. self, client: TMDBDiscoverClient,
  222. ) -> None:
  223. """Leere Ergebnisseite beendet die Paginierung."""
  224. leere_seite = _discover_page([], page=1, total_pages=5)
  225. with patch.object(client, "_get", new_callable=AsyncMock) as mock_get:
  226. mock_get.return_value = leere_seite
  227. ergebnis = await client._discover_movies(provider_id=8, max_pages=5)
  228. assert ergebnis == []
  229. assert mock_get.call_count == 1
  230. # ===================================================================
  231. # discover_recent
  232. # ===================================================================
  233. class TestDiscoverRecent:
  234. """Tests fuer discover_recent()."""
  235. async def test_nutzt_datumsfilter(self, client: TMDBDiscoverClient) -> None:
  236. """discover_recent() uebergibt Datums-Range an die API."""
  237. movie_page = _discover_page([_tmdb_movie()])
  238. tv_page = _discover_page([_tmdb_tv()])
  239. with patch.object(client, "_get", new_callable=AsyncMock) as mock_get:
  240. mock_get.side_effect = [movie_page, tv_page]
  241. ergebnis = await client.discover_recent("netflix", days=7)
  242. assert len(ergebnis) == 2
  243. # Pruefen, dass Datums-Parameter uebergeben wurden
  244. movie_call_params = mock_get.call_args_list[0][1].get("params") or mock_get.call_args_list[0][0][1]
  245. assert "primary_release_date.gte" in movie_call_params
  246. assert "primary_release_date.lte" in movie_call_params
  247. # ===================================================================
  248. # HTTP-Fehlerbehandlung (_get)
  249. # ===================================================================
  250. class TestGetFehlerbehandlung:
  251. """Tests fuer _get() — Fehlerszenarien."""
  252. async def test_rate_limit_429_retry(self, client: TMDBDiscoverClient) -> None:
  253. """429 Rate-Limit fuehrt zu Retry."""
  254. # Erstelle Mock-Response-Objekte
  255. response_429 = AsyncMock()
  256. response_429.status = 429
  257. response_429.headers = {"Retry-After": "0.01"}
  258. response_200 = AsyncMock()
  259. response_200.status = 200
  260. response_200.json = AsyncMock(return_value={"results": []})
  261. # aiohttp.ClientSession Mocking
  262. mock_session = AsyncMock()
  263. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  264. mock_session.__aexit__ = AsyncMock(return_value=False)
  265. # get() gibt zuerst 429, dann 200
  266. mock_resp_ctx_429 = AsyncMock()
  267. mock_resp_ctx_429.__aenter__ = AsyncMock(return_value=response_429)
  268. mock_resp_ctx_429.__aexit__ = AsyncMock(return_value=False)
  269. mock_resp_ctx_200 = AsyncMock()
  270. mock_resp_ctx_200.__aenter__ = AsyncMock(return_value=response_200)
  271. mock_resp_ctx_200.__aexit__ = AsyncMock(return_value=False)
  272. mock_session.get = MagicMock(side_effect=[mock_resp_ctx_429, mock_resp_ctx_200])
  273. with patch("plugins.streaming.catalog.tmdb_discover.aiohttp") as mock_aiohttp:
  274. mock_aiohttp.ClientSession = MagicMock(return_value=mock_session)
  275. mock_aiohttp.ClientTimeout = MagicMock()
  276. mock_aiohttp.ClientError = Exception
  277. ergebnis = await client._get("/discover/movie")
  278. assert ergebnis == {"results": []}
  279. async def test_timeout_gibt_none(self, client: TMDBDiscoverClient) -> None:
  280. """Timeout fuehrt zu None."""
  281. mock_session = AsyncMock()
  282. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  283. mock_session.__aexit__ = AsyncMock(return_value=False)
  284. mock_session.get = MagicMock(side_effect=asyncio.TimeoutError)
  285. with patch("plugins.streaming.catalog.tmdb_discover.aiohttp") as mock_aiohttp:
  286. mock_aiohttp.ClientSession = MagicMock(return_value=mock_session)
  287. mock_aiohttp.ClientTimeout = MagicMock()
  288. mock_aiohttp.ClientError = Exception
  289. ergebnis = await client._get("/discover/movie")
  290. assert ergebnis is None
  291. async def test_verbindungsfehler_gibt_none(self, client: TMDBDiscoverClient) -> None:
  292. """Verbindungsfehler fuehrt zu None."""
  293. mock_session = AsyncMock()
  294. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  295. mock_session.__aexit__ = AsyncMock(return_value=False)
  296. # aiohttp.ClientError simulieren
  297. class MockClientError(Exception):
  298. pass
  299. mock_session.get = MagicMock(side_effect=MockClientError("Verbindung fehlgeschlagen"))
  300. with patch("plugins.streaming.catalog.tmdb_discover.aiohttp") as mock_aiohttp:
  301. mock_aiohttp.ClientSession = MagicMock(return_value=mock_session)
  302. mock_aiohttp.ClientTimeout = MagicMock()
  303. mock_aiohttp.ClientError = MockClientError
  304. ergebnis = await client._get("/discover/movie")
  305. assert ergebnis is None
  306. # ===================================================================
  307. # Konstanten und Konfiguration
  308. # ===================================================================
  309. class TestKonstanten:
  310. """Tests fuer Modul-Konstanten."""
  311. def test_provider_ids_erwartete_dienste(self) -> None:
  312. """PROVIDER_IDS enthaelt die erwarteten Streaming-Dienste."""
  313. erwartete = {"netflix", "amazon_prime", "crunchyroll", "disney_plus", "wow"}
  314. assert set(PROVIDER_IDS.keys()) == erwartete
  315. def test_genre_map_movie_hat_erwartete_genres(self) -> None:
  316. """GENRE_MAP_MOVIE enthaelt die wichtigsten Film-Genres."""
  317. assert 28 in GENRE_MAP_MOVIE # Action
  318. assert 18 in GENRE_MAP_MOVIE # Drama
  319. assert 53 in GENRE_MAP_MOVIE # Thriller
  320. assert 878 in GENRE_MAP_MOVIE # Science Fiction
  321. def test_standard_sprachen_variieren_nach_dienst(self) -> None:
  322. """Verschiedene Dienste haben unterschiedliche Standard-Sprachen."""
  323. cr = _DEFAULT_LANGUAGES["crunchyroll"]
  324. nf = _DEFAULT_LANGUAGES["netflix"]
  325. # Crunchyroll: Japanisch-Dub
  326. assert cr["dub"] == ["ja"]
  327. # Netflix: Deutsch/Englisch-Dub
  328. assert "de" in nf["dub"]
  329. assert "en" in nf["dub"]