test_cache.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests fuer StreamingCache — JSON-Katalog-Cache mit In-Memory-Indizes.
  4. Deckt JSON I/O, Index-Aufbau, Suche, Filter (Sprache, Genre, Bewertung),
  5. Slug-Generierung und atomares Speichern ab.
  6. """
  7. from __future__ import annotations
  8. import json
  9. from pathlib import Path
  10. from unittest.mock import AsyncMock, patch
  11. import pytest
  12. from plugins.streaming.catalog.cache import StreamingCache
  13. # ---------------------------------------------------------------------------
  14. # Hilfsfunktionen und Testdaten
  15. # ---------------------------------------------------------------------------
  16. def _make_title(
  17. name: str,
  18. ty: str = "serie",
  19. genres: list[str] | None = None,
  20. rating: float = 7.0,
  21. year: int = 2020,
  22. services: dict | None = None,
  23. orig_lang: str = "ja",
  24. tmdb_id: int = 1,
  25. ) -> dict:
  26. """Erzeugt ein realistisches Title-Dict."""
  27. if genres is None:
  28. genres = ["Action"]
  29. if services is None:
  30. services = {
  31. "netflix": {"dub": ["de", "en"], "sub": ["de"]},
  32. }
  33. return {
  34. "t": name,
  35. "ty": ty,
  36. "g": genres,
  37. "d": f"Beschreibung fuer {name}",
  38. "yr": year,
  39. "rt": rating,
  40. "orig_lang": orig_lang,
  41. "on": services,
  42. "tmdb_id": tmdb_id,
  43. }
  44. NARUTO = _make_title(
  45. "Naruto Shippuden",
  46. genres=["Action", "Anime"],
  47. rating=8.1,
  48. year=2007,
  49. services={
  50. "crunchyroll": {"dub": ["ja"], "sub": ["de"]},
  51. "netflix": {"dub": ["de", "ja"], "sub": ["de"]},
  52. },
  53. tmdb_id=46559,
  54. )
  55. ONE_PIECE = _make_title(
  56. "One Piece",
  57. genres=["Action", "Anime"],
  58. rating=8.7,
  59. year=1999,
  60. services={
  61. "crunchyroll": {"dub": ["ja"], "sub": ["de"]},
  62. },
  63. tmdb_id=37854,
  64. )
  65. BREAKING_BAD = _make_title(
  66. "Breaking Bad",
  67. genres=["Drama", "Thriller"],
  68. rating=9.5,
  69. year=2008,
  70. services={
  71. "netflix": {"dub": ["de", "en"], "sub": ["de", "en"]},
  72. },
  73. orig_lang="en",
  74. tmdb_id=1396,
  75. )
  76. DARK = _make_title(
  77. "Dark",
  78. genres=["Drama", "Mystery"],
  79. rating=8.8,
  80. year=2017,
  81. services={
  82. "netflix": {"dub": ["de"], "sub": ["de", "en"]},
  83. },
  84. orig_lang="de",
  85. tmdb_id=70523,
  86. )
  87. INCEPTION = _make_title(
  88. "Inception",
  89. ty="film",
  90. genres=["Action", "Science Fiction"],
  91. rating=8.4,
  92. year=2010,
  93. services={
  94. "netflix": {"dub": ["de", "en"], "sub": ["de"]},
  95. "amazon_prime": {"dub": ["de", "en"], "sub": ["de"]},
  96. },
  97. orig_lang="en",
  98. tmdb_id=27205,
  99. )
  100. def _copy_title(title: dict) -> dict:
  101. """Tiefe Kopie eines Title-Dicts (on-Dict separat kopieren)."""
  102. import copy
  103. return copy.deepcopy(title)
  104. def _seed_cache(cache: StreamingCache) -> None:
  105. """Befuellt den Cache mit Testdaten und baut Indizes auf."""
  106. cache.update_title("naruto-shippuden", _copy_title(NARUTO))
  107. cache.update_title("one-piece", _copy_title(ONE_PIECE))
  108. cache.update_title("breaking-bad", _copy_title(BREAKING_BAD))
  109. cache.update_title("dark", _copy_title(DARK))
  110. cache.update_title("inception", _copy_title(INCEPTION))
  111. cache._rebuild_indices()
  112. @pytest.fixture()
  113. def cache(tmp_path: Path) -> StreamingCache:
  114. """Liefert einen leeren StreamingCache mit temporaerem Verzeichnis."""
  115. return StreamingCache(tmp_path)
  116. @pytest.fixture()
  117. def seeded_cache(cache: StreamingCache) -> StreamingCache:
  118. """Liefert einen StreamingCache mit vorbereiteten Testdaten."""
  119. _seed_cache(cache)
  120. return cache
  121. # ===================================================================
  122. # JSON I/O
  123. # ===================================================================
  124. class TestLoadSave:
  125. """Tests fuer load(), save() und save_async()."""
  126. def test_load_fehlende_datei_gibt_false(self, cache: StreamingCache) -> None:
  127. """Laden ohne vorhandene Datei gibt False und leere Daten."""
  128. ergebnis = cache.load()
  129. assert ergebnis is False
  130. assert cache._titles == {}
  131. assert cache._services == {}
  132. assert cache._recent == {}
  133. def test_save_und_load_roundtrip(self, cache: StreamingCache) -> None:
  134. """Gespeicherte Daten koennen exakt wieder geladen werden."""
  135. _seed_cache(cache)
  136. cache.save()
  137. # Neuen Cache aus demselben Verzeichnis laden
  138. cache2 = StreamingCache(cache._cache_dir)
  139. ergebnis = cache2.load()
  140. assert ergebnis is True
  141. assert len(cache2._titles) == 5
  142. assert cache2._titles["naruto-shippuden"]["t"] == "Naruto Shippuden"
  143. assert cache2._titles["breaking-bad"]["rt"] == 9.5
  144. async def test_save_async_ruft_to_thread_auf(self, cache: StreamingCache) -> None:
  145. """save_async() delegiert ueber asyncio.to_thread."""
  146. _seed_cache(cache)
  147. with patch("plugins.streaming.catalog.cache.asyncio.to_thread", new_callable=AsyncMock) as mock_to_thread:
  148. await cache.save_async()
  149. mock_to_thread.assert_awaited_once_with(cache._save_sync)
  150. def test_load_korrupte_datei_gibt_false(self, cache: StreamingCache) -> None:
  151. """Beschaedigte JSON-Datei fuehrt zu False und leeren Daten."""
  152. cache._file_path.write_text("{kaputt:", encoding="utf-8")
  153. ergebnis = cache.load()
  154. assert ergebnis is False
  155. assert cache._titles == {}
  156. def test_atomares_schreiben_tmp_rename(self, cache: StreamingCache) -> None:
  157. """Speicherung nutzt tmp-Datei + Umbenennung (atomar)."""
  158. _seed_cache(cache)
  159. cache.save()
  160. # Nach dem Speichern darf keine .tmp-Datei uebrig sein
  161. tmp_datei = cache._file_path.with_suffix(".tmp")
  162. assert not tmp_datei.exists()
  163. # Die eigentliche Datei muss existieren und gueltiges JSON enthalten
  164. assert cache._file_path.exists()
  165. inhalt = json.loads(cache._file_path.read_text(encoding="utf-8"))
  166. assert "titles" in inhalt
  167. assert "services" in inhalt
  168. assert "recent" in inhalt
  169. # ===================================================================
  170. # Daten-Mutation
  171. # ===================================================================
  172. class TestUpdateTitle:
  173. """Tests fuer update_title()."""
  174. def test_neuer_titel_wird_hinzugefuegt(self, cache: StreamingCache) -> None:
  175. """Ein neuer Titel wird korrekt in _titles gespeichert."""
  176. cache.update_title("naruto-shippuden", _copy_title(NARUTO))
  177. assert "naruto-shippuden" in cache._titles
  178. assert cache._titles["naruto-shippuden"]["t"] == "Naruto Shippuden"
  179. def test_bestehender_titel_wird_aktualisiert(self, cache: StreamingCache) -> None:
  180. """Beim Update eines Titels werden on-Dicts zusammengefuehrt."""
  181. cache.update_title("naruto-shippuden", _copy_title(NARUTO))
  182. # Zusaetzlichen Dienst hinzufuegen
  183. update_daten = _make_title(
  184. "Naruto Shippuden",
  185. services={"amazon_prime": {"dub": ["de"], "sub": ["de"]}},
  186. )
  187. cache.update_title("naruto-shippuden", update_daten)
  188. on = cache._titles["naruto-shippuden"]["on"]
  189. # Alte Dienste muessen erhalten bleiben
  190. assert "crunchyroll" in on
  191. assert "netflix" in on
  192. # Neuer Dienst muss vorhanden sein
  193. assert "amazon_prime" in on
  194. class TestUpdateServices:
  195. """Tests fuer update_services()."""
  196. def test_dienste_werden_gespeichert(self, cache: StreamingCache) -> None:
  197. """update_services() speichert Dienst-Informationen."""
  198. dienste = {
  199. "netflix": {"name": "Netflix", "logo": "netflix.png"},
  200. "crunchyroll": {"name": "Crunchyroll", "logo": "cr.png"},
  201. }
  202. cache.update_services(dienste)
  203. assert cache._services["netflix"]["name"] == "Netflix"
  204. assert cache._services["crunchyroll"]["name"] == "Crunchyroll"
  205. class TestSetRecent:
  206. """Tests fuer set_recent()."""
  207. def test_recent_liste_wird_gesetzt(self, cache: StreamingCache) -> None:
  208. """set_recent() speichert die Slug-Liste fuer einen Dienst."""
  209. cache.set_recent("netflix", ["naruto-shippuden", "dark"])
  210. assert cache._recent["netflix"] == ["naruto-shippuden", "dark"]
  211. class TestPruneMissing:
  212. """Tests fuer prune_missing()."""
  213. def test_entfernt_dienst_zuordnung_und_leere_titel(
  214. self, seeded_cache: StreamingCache,
  215. ) -> None:
  216. """prune_missing() entfernt Dienst-Zuordnungen und leere Titel."""
  217. # dark ist nur auf netflix — wenn netflix entfernt wird, wird dark geloescht
  218. # naruto ist auf crunchyroll + netflix — netflix wird entfernt, titel bleibt
  219. aktuelle_slugs = {"breaking-bad", "inception"}
  220. entfernt = seeded_cache.prune_missing("netflix", aktuelle_slugs)
  221. # naruto-shippuden verliert netflix, dark verliert netflix
  222. assert entfernt == 2
  223. # dark hat keinen Dienst mehr → komplett geloescht
  224. assert "dark" not in seeded_cache._titles
  225. # naruto hat noch crunchyroll
  226. assert "naruto-shippuden" in seeded_cache._titles
  227. assert "netflix" not in seeded_cache._titles["naruto-shippuden"]["on"]
  228. # ===================================================================
  229. # Titel-Suche
  230. # ===================================================================
  231. class TestSearchTitle:
  232. """Tests fuer search_title()."""
  233. def test_exakte_suche(self, seeded_cache: StreamingCache) -> None:
  234. """Exakter Titel wird gefunden."""
  235. ergebnis = seeded_cache.search_title("Naruto Shippuden")
  236. assert len(ergebnis) >= 1
  237. assert ergebnis[0]["t"] == "Naruto Shippuden"
  238. def test_teiluebereinstimmung(self, seeded_cache: StreamingCache) -> None:
  239. """Teilweise Uebereinstimmung funktioniert."""
  240. ergebnis = seeded_cache.search_title("Naruto")
  241. assert len(ergebnis) >= 1
  242. titel_namen = [t["t"] for t in ergebnis]
  243. assert "Naruto Shippuden" in titel_namen
  244. def test_kein_treffer_gibt_leere_liste(self, seeded_cache: StreamingCache) -> None:
  245. """Unbekannter Suchbegriff gibt leere Liste."""
  246. ergebnis = seeded_cache.search_title("Nichtexistent XYZ")
  247. assert ergebnis == []
  248. def test_limit_wird_eingehalten(self, seeded_cache: StreamingCache) -> None:
  249. """Das Limit beschraenkt die Ergebnisse."""
  250. # "action" sollte mehrere Treffer liefern, die "action" im Titel
  251. # enthalten koennten — wir nutzen stattdessen breiteren Begriff
  252. ergebnis = seeded_cache.search_title("a", limit=2)
  253. assert len(ergebnis) <= 2
  254. class TestFindTitle:
  255. """Tests fuer find_title()."""
  256. def test_bester_treffer_wird_gefunden(self, seeded_cache: StreamingCache) -> None:
  257. """find_title() liefert den besten einzelnen Treffer."""
  258. ergebnis = seeded_cache.find_title("Breaking Bad")
  259. assert ergebnis is not None
  260. assert ergebnis["t"] == "Breaking Bad"
  261. def test_kein_treffer_gibt_none(self, seeded_cache: StreamingCache) -> None:
  262. """Unbekannter Suchbegriff gibt None."""
  263. ergebnis = seeded_cache.find_title("Nichtexistent XYZ")
  264. assert ergebnis is None
  265. # ===================================================================
  266. # Dienst-Suche
  267. # ===================================================================
  268. class TestSearchOnService:
  269. """Tests fuer search_on_service()."""
  270. def test_filter_nach_dienst(self, seeded_cache: StreamingCache) -> None:
  271. """Nur Titel auf dem angegebenen Dienst werden zurueckgegeben."""
  272. ergebnis = seeded_cache.search_on_service("crunchyroll")
  273. assert len(ergebnis) >= 2 # Naruto + One Piece
  274. for titel in ergebnis:
  275. assert "crunchyroll" in titel["on"]
  276. def test_filter_nach_genre(self, seeded_cache: StreamingCache) -> None:
  277. """Genre-Filter schraenkt die Ergebnisse ein."""
  278. ergebnis = seeded_cache.search_on_service("netflix", genre="Drama")
  279. for titel in ergebnis:
  280. assert any("Drama" in g for g in titel.get("g", []))
  281. def test_filter_dienst_und_genre(self, seeded_cache: StreamingCache) -> None:
  282. """Kombination von Dienst- und Genre-Filter."""
  283. ergebnis = seeded_cache.search_on_service(
  284. "netflix", genre="Anime",
  285. )
  286. for titel in ergebnis:
  287. assert "netflix" in titel["on"]
  288. assert any("Anime" in g for g in titel.get("g", []))
  289. class TestGetServicesForTitle:
  290. """Tests fuer get_services_for_title()."""
  291. def test_alle_dienste_zurueckgegeben(self, seeded_cache: StreamingCache) -> None:
  292. """Gibt alle Dienste zurueck, auf denen der Titel verfuegbar ist."""
  293. dienste = seeded_cache.get_services_for_title("naruto-shippuden")
  294. assert set(dienste) == {"crunchyroll", "netflix"}
  295. def test_unbekannter_slug_gibt_leere_liste(self, seeded_cache: StreamingCache) -> None:
  296. """Unbekannter Slug gibt leere Liste."""
  297. dienste = seeded_cache.get_services_for_title("nichtexistent")
  298. assert dienste == []
  299. # ===================================================================
  300. # Recent
  301. # ===================================================================
  302. class TestGetRecent:
  303. """Tests fuer get_recent()."""
  304. def test_recent_titel_werden_aufgeloest(self, seeded_cache: StreamingCache) -> None:
  305. """get_recent() loest Slugs zu Title-Dicts auf."""
  306. seeded_cache.set_recent("netflix", ["naruto-shippuden", "dark"])
  307. ergebnis = seeded_cache.get_recent("netflix")
  308. assert len(ergebnis) == 2
  309. namen = [t["t"] for t in ergebnis]
  310. assert "Naruto Shippuden" in namen
  311. assert "Dark" in namen
  312. def test_leere_recent_gibt_leere_liste(self, seeded_cache: StreamingCache) -> None:
  313. """Kein recent-Eintrag gibt leere Liste."""
  314. ergebnis = seeded_cache.get_recent("disney_plus")
  315. assert ergebnis == []
  316. # ===================================================================
  317. # Genre-Abfragen
  318. # ===================================================================
  319. class TestGetByGenre:
  320. """Tests fuer get_by_genre()."""
  321. def test_findet_nach_genre(self, seeded_cache: StreamingCache) -> None:
  322. """Titel des gesuchten Genres werden gefunden."""
  323. ergebnis = seeded_cache.get_by_genre("Action")
  324. assert len(ergebnis) >= 2 # Naruto, One Piece, Inception
  325. for titel in ergebnis:
  326. assert any("Action" in g for g in titel.get("g", []))
  327. def test_mit_dienst_filter(self, seeded_cache: StreamingCache) -> None:
  328. """Genre + Dienst-Filter schraenkt Ergebnisse ein."""
  329. ergebnis = seeded_cache.get_by_genre("Action", service_id="crunchyroll")
  330. for titel in ergebnis:
  331. assert "crunchyroll" in titel["on"]
  332. def test_case_insensitive(self, seeded_cache: StreamingCache) -> None:
  333. """Genre-Suche ist case-insensitive."""
  334. ergebnis_lower = seeded_cache.get_by_genre("action")
  335. ergebnis_upper = seeded_cache.get_by_genre("Action")
  336. assert len(ergebnis_lower) == len(ergebnis_upper)
  337. # ===================================================================
  338. # Sprach-Filter (Dub/Sub)
  339. # ===================================================================
  340. class TestGetWithDub:
  341. """Tests fuer get_with_dub()."""
  342. def test_findet_deutsche_synchronisation(self, seeded_cache: StreamingCache) -> None:
  343. """Titel mit deutscher Synchronisation werden gefunden."""
  344. ergebnis = seeded_cache.get_with_dub("de")
  345. assert len(ergebnis) >= 1
  346. # Breaking Bad, Dark, Inception haben alle deutsches Dub
  347. namen = [t["t"] for t in ergebnis]
  348. assert "Breaking Bad" in namen
  349. def test_filter_nach_dienst(self, seeded_cache: StreamingCache) -> None:
  350. """Dub-Suche mit Dienst-Filter."""
  351. ergebnis = seeded_cache.get_with_dub("ja", service_id="crunchyroll")
  352. for titel in ergebnis:
  353. cr_info = titel["on"].get("crunchyroll", {})
  354. assert "ja" in cr_info.get("dub", [])
  355. def test_filter_nach_genre(self, seeded_cache: StreamingCache) -> None:
  356. """Dub-Suche mit Genre-Filter."""
  357. ergebnis = seeded_cache.get_with_dub("de", genre="Drama")
  358. for titel in ergebnis:
  359. assert any("Drama" in g for g in titel.get("g", []))
  360. class TestGetWithSub:
  361. """Tests fuer get_with_sub()."""
  362. def test_findet_deutsche_untertitel(self, seeded_cache: StreamingCache) -> None:
  363. """Titel mit deutschen Untertiteln werden gefunden."""
  364. ergebnis = seeded_cache.get_with_sub("de")
  365. assert len(ergebnis) >= 3
  366. def test_kein_treffer_gibt_leere_liste(self, seeded_cache: StreamingCache) -> None:
  367. """Unbekannte Sprache gibt leere Liste."""
  368. ergebnis = seeded_cache.get_with_sub("xx")
  369. assert ergebnis == []
  370. # ===================================================================
  371. # Empfehlungen
  372. # ===================================================================
  373. class TestGetRecommendations:
  374. """Tests fuer get_recommendations()."""
  375. def test_filter_nach_bewertung(self, seeded_cache: StreamingCache) -> None:
  376. """Nur Titel ueber der Mindestbewertung werden zurueckgegeben."""
  377. ergebnis = seeded_cache.get_recommendations("Action", min_rating=8.0)
  378. for titel in ergebnis:
  379. assert (titel.get("rt") or 0) >= 8.0
  380. def test_sortierung_nach_bewertung_absteigend(
  381. self, seeded_cache: StreamingCache,
  382. ) -> None:
  383. """Ergebnisse sind nach Bewertung absteigend sortiert."""
  384. ergebnis = seeded_cache.get_recommendations("Action", min_rating=0.0)
  385. bewertungen = [t.get("rt", 0) for t in ergebnis]
  386. assert bewertungen == sorted(bewertungen, reverse=True)
  387. # ===================================================================
  388. # Slug-Generierung
  389. # ===================================================================
  390. class TestGenerateSlug:
  391. """Tests fuer _generate_slug()."""
  392. def test_einfacher_titel(self) -> None:
  393. """'Naruto Shippuden' ergibt 'naruto-shippuden'."""
  394. assert StreamingCache._generate_slug("Naruto Shippuden") == "naruto-shippuden"
  395. def test_sonderzeichen_entfernt(self) -> None:
  396. """Sonderzeichen werden durch Bindestriche ersetzt."""
  397. assert StreamingCache._generate_slug("Jujutsu Kaisen: 0") == "jujutsu-kaisen-0"
  398. assert StreamingCache._generate_slug("Re:Zero") == "re-zero"
  399. def test_leerer_string(self) -> None:
  400. """Leerer String ergibt leeren Slug."""
  401. assert StreamingCache._generate_slug("") == ""
  402. def test_nur_sonderzeichen(self) -> None:
  403. """Nur Sonderzeichen ergibt leeren Slug."""
  404. assert StreamingCache._generate_slug("!!!???") == ""
  405. # ===================================================================
  406. # Statistiken
  407. # ===================================================================
  408. class TestGetStats:
  409. """Tests fuer get_stats()."""
  410. def test_korrekte_zaehler(self, seeded_cache: StreamingCache) -> None:
  411. """get_stats() liefert korrekte Zaehler."""
  412. seeded_cache.update_services({"netflix": {"name": "Netflix"}})
  413. stats = seeded_cache.get_stats()
  414. assert stats["title_count"] == 5
  415. assert stats["service_count"] == 1
  416. assert stats["genre_count"] >= 4 # Action, Anime, Drama, Thriller, ...
  417. assert stats["index_service_count"] >= 2 # netflix, crunchyroll, ...