# -*- coding: utf-8 -*- """ Tests fuer StreamingCache — JSON-Katalog-Cache mit In-Memory-Indizes. Deckt JSON I/O, Index-Aufbau, Suche, Filter (Sprache, Genre, Bewertung), Slug-Generierung und atomares Speichern ab. """ from __future__ import annotations import json from pathlib import Path from unittest.mock import AsyncMock, patch import pytest from plugins.streaming.catalog.cache import StreamingCache # --------------------------------------------------------------------------- # Hilfsfunktionen und Testdaten # --------------------------------------------------------------------------- def _make_title( name: str, ty: str = "serie", genres: list[str] | None = None, rating: float = 7.0, year: int = 2020, services: dict | None = None, orig_lang: str = "ja", tmdb_id: int = 1, ) -> dict: """Erzeugt ein realistisches Title-Dict.""" if genres is None: genres = ["Action"] if services is None: services = { "netflix": {"dub": ["de", "en"], "sub": ["de"]}, } return { "t": name, "ty": ty, "g": genres, "d": f"Beschreibung fuer {name}", "yr": year, "rt": rating, "orig_lang": orig_lang, "on": services, "tmdb_id": tmdb_id, } NARUTO = _make_title( "Naruto Shippuden", genres=["Action", "Anime"], rating=8.1, year=2007, services={ "crunchyroll": {"dub": ["ja"], "sub": ["de"]}, "netflix": {"dub": ["de", "ja"], "sub": ["de"]}, }, tmdb_id=46559, ) ONE_PIECE = _make_title( "One Piece", genres=["Action", "Anime"], rating=8.7, year=1999, services={ "crunchyroll": {"dub": ["ja"], "sub": ["de"]}, }, tmdb_id=37854, ) BREAKING_BAD = _make_title( "Breaking Bad", genres=["Drama", "Thriller"], rating=9.5, year=2008, services={ "netflix": {"dub": ["de", "en"], "sub": ["de", "en"]}, }, orig_lang="en", tmdb_id=1396, ) DARK = _make_title( "Dark", genres=["Drama", "Mystery"], rating=8.8, year=2017, services={ "netflix": {"dub": ["de"], "sub": ["de", "en"]}, }, orig_lang="de", tmdb_id=70523, ) INCEPTION = _make_title( "Inception", ty="film", genres=["Action", "Science Fiction"], rating=8.4, year=2010, services={ "netflix": {"dub": ["de", "en"], "sub": ["de"]}, "amazon_prime": {"dub": ["de", "en"], "sub": ["de"]}, }, orig_lang="en", tmdb_id=27205, ) def _copy_title(title: dict) -> dict: """Tiefe Kopie eines Title-Dicts (on-Dict separat kopieren).""" import copy return copy.deepcopy(title) def _seed_cache(cache: StreamingCache) -> None: """Befuellt den Cache mit Testdaten und baut Indizes auf.""" cache.update_title("naruto-shippuden", _copy_title(NARUTO)) cache.update_title("one-piece", _copy_title(ONE_PIECE)) cache.update_title("breaking-bad", _copy_title(BREAKING_BAD)) cache.update_title("dark", _copy_title(DARK)) cache.update_title("inception", _copy_title(INCEPTION)) cache._rebuild_indices() @pytest.fixture() def cache(tmp_path: Path) -> StreamingCache: """Liefert einen leeren StreamingCache mit temporaerem Verzeichnis.""" return StreamingCache(tmp_path) @pytest.fixture() def seeded_cache(cache: StreamingCache) -> StreamingCache: """Liefert einen StreamingCache mit vorbereiteten Testdaten.""" _seed_cache(cache) return cache # =================================================================== # JSON I/O # =================================================================== class TestLoadSave: """Tests fuer load(), save() und save_async().""" def test_load_fehlende_datei_gibt_false(self, cache: StreamingCache) -> None: """Laden ohne vorhandene Datei gibt False und leere Daten.""" ergebnis = cache.load() assert ergebnis is False assert cache._titles == {} assert cache._services == {} assert cache._recent == {} def test_save_und_load_roundtrip(self, cache: StreamingCache) -> None: """Gespeicherte Daten koennen exakt wieder geladen werden.""" _seed_cache(cache) cache.save() # Neuen Cache aus demselben Verzeichnis laden cache2 = StreamingCache(cache._cache_dir) ergebnis = cache2.load() assert ergebnis is True assert len(cache2._titles) == 5 assert cache2._titles["naruto-shippuden"]["t"] == "Naruto Shippuden" assert cache2._titles["breaking-bad"]["rt"] == 9.5 async def test_save_async_ruft_to_thread_auf(self, cache: StreamingCache) -> None: """save_async() delegiert ueber asyncio.to_thread.""" _seed_cache(cache) with patch("plugins.streaming.catalog.cache.asyncio.to_thread", new_callable=AsyncMock) as mock_to_thread: await cache.save_async() mock_to_thread.assert_awaited_once_with(cache._save_sync) def test_load_korrupte_datei_gibt_false(self, cache: StreamingCache) -> None: """Beschaedigte JSON-Datei fuehrt zu False und leeren Daten.""" cache._file_path.write_text("{kaputt:", encoding="utf-8") ergebnis = cache.load() assert ergebnis is False assert cache._titles == {} def test_atomares_schreiben_tmp_rename(self, cache: StreamingCache) -> None: """Speicherung nutzt tmp-Datei + Umbenennung (atomar).""" _seed_cache(cache) cache.save() # Nach dem Speichern darf keine .tmp-Datei uebrig sein tmp_datei = cache._file_path.with_suffix(".tmp") assert not tmp_datei.exists() # Die eigentliche Datei muss existieren und gueltiges JSON enthalten assert cache._file_path.exists() inhalt = json.loads(cache._file_path.read_text(encoding="utf-8")) assert "titles" in inhalt assert "services" in inhalt assert "recent" in inhalt # =================================================================== # Daten-Mutation # =================================================================== class TestUpdateTitle: """Tests fuer update_title().""" def test_neuer_titel_wird_hinzugefuegt(self, cache: StreamingCache) -> None: """Ein neuer Titel wird korrekt in _titles gespeichert.""" cache.update_title("naruto-shippuden", _copy_title(NARUTO)) assert "naruto-shippuden" in cache._titles assert cache._titles["naruto-shippuden"]["t"] == "Naruto Shippuden" def test_bestehender_titel_wird_aktualisiert(self, cache: StreamingCache) -> None: """Beim Update eines Titels werden on-Dicts zusammengefuehrt.""" cache.update_title("naruto-shippuden", _copy_title(NARUTO)) # Zusaetzlichen Dienst hinzufuegen update_daten = _make_title( "Naruto Shippuden", services={"amazon_prime": {"dub": ["de"], "sub": ["de"]}}, ) cache.update_title("naruto-shippuden", update_daten) on = cache._titles["naruto-shippuden"]["on"] # Alte Dienste muessen erhalten bleiben assert "crunchyroll" in on assert "netflix" in on # Neuer Dienst muss vorhanden sein assert "amazon_prime" in on class TestUpdateServices: """Tests fuer update_services().""" def test_dienste_werden_gespeichert(self, cache: StreamingCache) -> None: """update_services() speichert Dienst-Informationen.""" dienste = { "netflix": {"name": "Netflix", "logo": "netflix.png"}, "crunchyroll": {"name": "Crunchyroll", "logo": "cr.png"}, } cache.update_services(dienste) assert cache._services["netflix"]["name"] == "Netflix" assert cache._services["crunchyroll"]["name"] == "Crunchyroll" class TestSetRecent: """Tests fuer set_recent().""" def test_recent_liste_wird_gesetzt(self, cache: StreamingCache) -> None: """set_recent() speichert die Slug-Liste fuer einen Dienst.""" cache.set_recent("netflix", ["naruto-shippuden", "dark"]) assert cache._recent["netflix"] == ["naruto-shippuden", "dark"] class TestPruneMissing: """Tests fuer prune_missing().""" def test_entfernt_dienst_zuordnung_und_leere_titel( self, seeded_cache: StreamingCache, ) -> None: """prune_missing() entfernt Dienst-Zuordnungen und leere Titel.""" # dark ist nur auf netflix — wenn netflix entfernt wird, wird dark geloescht # naruto ist auf crunchyroll + netflix — netflix wird entfernt, titel bleibt aktuelle_slugs = {"breaking-bad", "inception"} entfernt = seeded_cache.prune_missing("netflix", aktuelle_slugs) # naruto-shippuden verliert netflix, dark verliert netflix assert entfernt == 2 # dark hat keinen Dienst mehr → komplett geloescht assert "dark" not in seeded_cache._titles # naruto hat noch crunchyroll assert "naruto-shippuden" in seeded_cache._titles assert "netflix" not in seeded_cache._titles["naruto-shippuden"]["on"] # =================================================================== # Titel-Suche # =================================================================== class TestSearchTitle: """Tests fuer search_title().""" def test_exakte_suche(self, seeded_cache: StreamingCache) -> None: """Exakter Titel wird gefunden.""" ergebnis = seeded_cache.search_title("Naruto Shippuden") assert len(ergebnis) >= 1 assert ergebnis[0]["t"] == "Naruto Shippuden" def test_teiluebereinstimmung(self, seeded_cache: StreamingCache) -> None: """Teilweise Uebereinstimmung funktioniert.""" ergebnis = seeded_cache.search_title("Naruto") assert len(ergebnis) >= 1 titel_namen = [t["t"] for t in ergebnis] assert "Naruto Shippuden" in titel_namen def test_kein_treffer_gibt_leere_liste(self, seeded_cache: StreamingCache) -> None: """Unbekannter Suchbegriff gibt leere Liste.""" ergebnis = seeded_cache.search_title("Nichtexistent XYZ") assert ergebnis == [] def test_limit_wird_eingehalten(self, seeded_cache: StreamingCache) -> None: """Das Limit beschraenkt die Ergebnisse.""" # "action" sollte mehrere Treffer liefern, die "action" im Titel # enthalten koennten — wir nutzen stattdessen breiteren Begriff ergebnis = seeded_cache.search_title("a", limit=2) assert len(ergebnis) <= 2 class TestFindTitle: """Tests fuer find_title().""" def test_bester_treffer_wird_gefunden(self, seeded_cache: StreamingCache) -> None: """find_title() liefert den besten einzelnen Treffer.""" ergebnis = seeded_cache.find_title("Breaking Bad") assert ergebnis is not None assert ergebnis["t"] == "Breaking Bad" def test_kein_treffer_gibt_none(self, seeded_cache: StreamingCache) -> None: """Unbekannter Suchbegriff gibt None.""" ergebnis = seeded_cache.find_title("Nichtexistent XYZ") assert ergebnis is None # =================================================================== # Dienst-Suche # =================================================================== class TestSearchOnService: """Tests fuer search_on_service().""" def test_filter_nach_dienst(self, seeded_cache: StreamingCache) -> None: """Nur Titel auf dem angegebenen Dienst werden zurueckgegeben.""" ergebnis = seeded_cache.search_on_service("crunchyroll") assert len(ergebnis) >= 2 # Naruto + One Piece for titel in ergebnis: assert "crunchyroll" in titel["on"] def test_filter_nach_genre(self, seeded_cache: StreamingCache) -> None: """Genre-Filter schraenkt die Ergebnisse ein.""" ergebnis = seeded_cache.search_on_service("netflix", genre="Drama") for titel in ergebnis: assert any("Drama" in g for g in titel.get("g", [])) def test_filter_dienst_und_genre(self, seeded_cache: StreamingCache) -> None: """Kombination von Dienst- und Genre-Filter.""" ergebnis = seeded_cache.search_on_service( "netflix", genre="Anime", ) for titel in ergebnis: assert "netflix" in titel["on"] assert any("Anime" in g for g in titel.get("g", [])) class TestGetServicesForTitle: """Tests fuer get_services_for_title().""" def test_alle_dienste_zurueckgegeben(self, seeded_cache: StreamingCache) -> None: """Gibt alle Dienste zurueck, auf denen der Titel verfuegbar ist.""" dienste = seeded_cache.get_services_for_title("naruto-shippuden") assert set(dienste) == {"crunchyroll", "netflix"} def test_unbekannter_slug_gibt_leere_liste(self, seeded_cache: StreamingCache) -> None: """Unbekannter Slug gibt leere Liste.""" dienste = seeded_cache.get_services_for_title("nichtexistent") assert dienste == [] # =================================================================== # Recent # =================================================================== class TestGetRecent: """Tests fuer get_recent().""" def test_recent_titel_werden_aufgeloest(self, seeded_cache: StreamingCache) -> None: """get_recent() loest Slugs zu Title-Dicts auf.""" seeded_cache.set_recent("netflix", ["naruto-shippuden", "dark"]) ergebnis = seeded_cache.get_recent("netflix") assert len(ergebnis) == 2 namen = [t["t"] for t in ergebnis] assert "Naruto Shippuden" in namen assert "Dark" in namen def test_leere_recent_gibt_leere_liste(self, seeded_cache: StreamingCache) -> None: """Kein recent-Eintrag gibt leere Liste.""" ergebnis = seeded_cache.get_recent("disney_plus") assert ergebnis == [] # =================================================================== # Genre-Abfragen # =================================================================== class TestGetByGenre: """Tests fuer get_by_genre().""" def test_findet_nach_genre(self, seeded_cache: StreamingCache) -> None: """Titel des gesuchten Genres werden gefunden.""" ergebnis = seeded_cache.get_by_genre("Action") assert len(ergebnis) >= 2 # Naruto, One Piece, Inception for titel in ergebnis: assert any("Action" in g for g in titel.get("g", [])) def test_mit_dienst_filter(self, seeded_cache: StreamingCache) -> None: """Genre + Dienst-Filter schraenkt Ergebnisse ein.""" ergebnis = seeded_cache.get_by_genre("Action", service_id="crunchyroll") for titel in ergebnis: assert "crunchyroll" in titel["on"] def test_case_insensitive(self, seeded_cache: StreamingCache) -> None: """Genre-Suche ist case-insensitive.""" ergebnis_lower = seeded_cache.get_by_genre("action") ergebnis_upper = seeded_cache.get_by_genre("Action") assert len(ergebnis_lower) == len(ergebnis_upper) # =================================================================== # Sprach-Filter (Dub/Sub) # =================================================================== class TestGetWithDub: """Tests fuer get_with_dub().""" def test_findet_deutsche_synchronisation(self, seeded_cache: StreamingCache) -> None: """Titel mit deutscher Synchronisation werden gefunden.""" ergebnis = seeded_cache.get_with_dub("de") assert len(ergebnis) >= 1 # Breaking Bad, Dark, Inception haben alle deutsches Dub namen = [t["t"] for t in ergebnis] assert "Breaking Bad" in namen def test_filter_nach_dienst(self, seeded_cache: StreamingCache) -> None: """Dub-Suche mit Dienst-Filter.""" ergebnis = seeded_cache.get_with_dub("ja", service_id="crunchyroll") for titel in ergebnis: cr_info = titel["on"].get("crunchyroll", {}) assert "ja" in cr_info.get("dub", []) def test_filter_nach_genre(self, seeded_cache: StreamingCache) -> None: """Dub-Suche mit Genre-Filter.""" ergebnis = seeded_cache.get_with_dub("de", genre="Drama") for titel in ergebnis: assert any("Drama" in g for g in titel.get("g", [])) class TestGetWithSub: """Tests fuer get_with_sub().""" def test_findet_deutsche_untertitel(self, seeded_cache: StreamingCache) -> None: """Titel mit deutschen Untertiteln werden gefunden.""" ergebnis = seeded_cache.get_with_sub("de") assert len(ergebnis) >= 3 def test_kein_treffer_gibt_leere_liste(self, seeded_cache: StreamingCache) -> None: """Unbekannte Sprache gibt leere Liste.""" ergebnis = seeded_cache.get_with_sub("xx") assert ergebnis == [] # =================================================================== # Empfehlungen # =================================================================== class TestGetRecommendations: """Tests fuer get_recommendations().""" def test_filter_nach_bewertung(self, seeded_cache: StreamingCache) -> None: """Nur Titel ueber der Mindestbewertung werden zurueckgegeben.""" ergebnis = seeded_cache.get_recommendations("Action", min_rating=8.0) for titel in ergebnis: assert (titel.get("rt") or 0) >= 8.0 def test_sortierung_nach_bewertung_absteigend( self, seeded_cache: StreamingCache, ) -> None: """Ergebnisse sind nach Bewertung absteigend sortiert.""" ergebnis = seeded_cache.get_recommendations("Action", min_rating=0.0) bewertungen = [t.get("rt", 0) for t in ergebnis] assert bewertungen == sorted(bewertungen, reverse=True) # =================================================================== # Slug-Generierung # =================================================================== class TestGenerateSlug: """Tests fuer _generate_slug().""" def test_einfacher_titel(self) -> None: """'Naruto Shippuden' ergibt 'naruto-shippuden'.""" assert StreamingCache._generate_slug("Naruto Shippuden") == "naruto-shippuden" def test_sonderzeichen_entfernt(self) -> None: """Sonderzeichen werden durch Bindestriche ersetzt.""" assert StreamingCache._generate_slug("Jujutsu Kaisen: 0") == "jujutsu-kaisen-0" assert StreamingCache._generate_slug("Re:Zero") == "re-zero" def test_leerer_string(self) -> None: """Leerer String ergibt leeren Slug.""" assert StreamingCache._generate_slug("") == "" def test_nur_sonderzeichen(self) -> None: """Nur Sonderzeichen ergibt leeren Slug.""" assert StreamingCache._generate_slug("!!!???") == "" # =================================================================== # Statistiken # =================================================================== class TestGetStats: """Tests fuer get_stats().""" def test_korrekte_zaehler(self, seeded_cache: StreamingCache) -> None: """get_stats() liefert korrekte Zaehler.""" seeded_cache.update_services({"netflix": {"name": "Netflix"}}) stats = seeded_cache.get_stats() assert stats["title_count"] == 5 assert stats["service_count"] == 1 assert stats["genre_count"] >= 4 # Action, Anime, Drama, Thriller, ... assert stats["index_service_count"] >= 2 # netflix, crunchyroll, ...