test_sync_state.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests fuer den SyncState der EPG-Synchronisation.
  4. Testet Persistenz, Fetch-Logik, Enrichment-Warteschlange und
  5. Fehlertoleranzen.
  6. """
  7. from __future__ import annotations
  8. import json
  9. from datetime import datetime, timedelta, timezone
  10. from pathlib import Path
  11. import pytest
  12. from plugins.tv_program.sync.sync_state import SyncState
  13. # ============================================================================
  14. # SyncState - JSON I/O
  15. # ============================================================================
  16. class TestSyncStateLadenSpeichern:
  17. """Tests fuer die JSON-Persistenz des Sync-States."""
  18. def test_load_fehlende_datei_standardwerte(self, tmp_path: Path) -> None:
  19. """Fehlende Datei setzt Standardwerte, kein Fehler."""
  20. state = SyncState(tmp_path)
  21. state.load()
  22. assert state.last_epg_fetch == ""
  23. assert state.enrichment_queue == []
  24. assert state.enriched_titles == {}
  25. def test_save_und_load_roundtrip(self, tmp_path: Path) -> None:
  26. """Gespeicherte Daten koennen wieder geladen werden."""
  27. state = SyncState(tmp_path)
  28. state.last_epg_fetch = "2025-01-15T10:00:00+00:00"
  29. state.enrichment_queue = ["tatort", "tagesschau"]
  30. state.enriched_titles = {"krimi": "tmdb"}
  31. state.next_sync_after = "2025-01-15T16:00:00+00:00"
  32. state.save()
  33. # Neuen State aus gleicher Datei laden
  34. state2 = SyncState(tmp_path)
  35. state2.load()
  36. assert state2.last_epg_fetch == "2025-01-15T10:00:00+00:00"
  37. assert state2.enrichment_queue == ["tatort", "tagesschau"]
  38. assert state2.enriched_titles == {"krimi": "tmdb"}
  39. assert state2.next_sync_after == "2025-01-15T16:00:00+00:00"
  40. def test_load_korrupte_datei_toleriert(self, tmp_path: Path) -> None:
  41. """Korrupte JSON-Datei wird toleriert, Standardwerte werden gesetzt."""
  42. datei = tmp_path / SyncState.FILE_NAME
  43. datei.write_text("das ist kein JSON!!!", encoding="utf-8")
  44. state = SyncState(tmp_path)
  45. state.load() # Darf keinen Fehler werfen
  46. assert state.last_epg_fetch == ""
  47. assert state.enrichment_queue == []
  48. def test_save_erzeugt_datei(self, tmp_path: Path) -> None:
  49. """Nach dem Speichern existiert die State-Datei."""
  50. state = SyncState(tmp_path)
  51. state.save()
  52. assert (tmp_path / SyncState.FILE_NAME).exists()
  53. def test_save_atomisch_ueber_tmp_datei(self, tmp_path: Path) -> None:
  54. """Save schreibt zuerst eine .tmp-Datei und benennt dann um."""
  55. state = SyncState(tmp_path)
  56. state.last_epg_fetch = "2025-01-01T00:00:00+00:00"
  57. state.save()
  58. # Die finale Datei muss existieren
  59. assert (tmp_path / SyncState.FILE_NAME).exists()
  60. # Die tmp-Datei darf nicht mehr existieren (wurde umbenannt)
  61. assert not (tmp_path / "sync_state.tmp").exists()
  62. def test_save_erzeugt_gueltiges_json(self, tmp_path: Path) -> None:
  63. """Die gespeicherte Datei enthaelt gueltiges JSON."""
  64. state = SyncState(tmp_path)
  65. state.enrichment_queue = ["test-id"]
  66. state.save()
  67. datei = tmp_path / SyncState.FILE_NAME
  68. inhalt = json.loads(datei.read_text(encoding="utf-8"))
  69. assert isinstance(inhalt, dict)
  70. assert inhalt["enrichment_queue"] == ["test-id"]
  71. # ============================================================================
  72. # SyncState - needs_epg_fetch()
  73. # ============================================================================
  74. class TestSyncStateNeedsEpgFetch:
  75. """Tests fuer die EPG-Fetch-Pruefung."""
  76. def test_nie_synchronisiert_braucht_fetch(self, tmp_path: Path) -> None:
  77. """Ohne vorherigen Fetch wird True zurueckgegeben."""
  78. state = SyncState(tmp_path)
  79. assert state.needs_epg_fetch() is True
  80. def test_kuerzlich_synchronisiert_kein_fetch(self, tmp_path: Path) -> None:
  81. """Nach kuerzlichem Fetch mit Zukunfts-Sync wird False zurueckgegeben."""
  82. state = SyncState(tmp_path)
  83. jetzt = datetime.now(tz=timezone.utc)
  84. state.last_epg_fetch = jetzt.isoformat()
  85. state.next_sync_after = (jetzt + timedelta(hours=6)).isoformat()
  86. assert state.needs_epg_fetch() is False
  87. def test_next_sync_after_vergangen_braucht_fetch(self, tmp_path: Path) -> None:
  88. """Wenn next_sync_after in der Vergangenheit liegt, wird True zurueckgegeben."""
  89. state = SyncState(tmp_path)
  90. vergangen = datetime.now(tz=timezone.utc) - timedelta(hours=1)
  91. state.last_epg_fetch = (vergangen - timedelta(hours=6)).isoformat()
  92. state.next_sync_after = vergangen.isoformat()
  93. assert state.needs_epg_fetch() is True
  94. def test_last_fetch_gesetzt_ohne_next_sync_braucht_fetch(self, tmp_path: Path) -> None:
  95. """Mit last_epg_fetch aber ohne next_sync_after wird True zurueckgegeben."""
  96. state = SyncState(tmp_path)
  97. state.last_epg_fetch = datetime.now(tz=timezone.utc).isoformat()
  98. state.next_sync_after = ""
  99. assert state.needs_epg_fetch() is True
  100. def test_ungueltiger_zeitstempel_braucht_fetch(self, tmp_path: Path) -> None:
  101. """Ungueltiger Zeitstempel fuehrt sicherheitshalber zu True."""
  102. state = SyncState(tmp_path)
  103. state.last_epg_fetch = "gestern"
  104. state.next_sync_after = "ungueltig"
  105. assert state.needs_epg_fetch() is True
  106. # ============================================================================
  107. # SyncState - mark_epg_fetched()
  108. # ============================================================================
  109. class TestSyncStateMarkEpgFetched:
  110. """Tests fuer das Markieren eines abgeschlossenen EPG-Fetch."""
  111. def test_mark_setzt_zeitstempel(self, tmp_path: Path) -> None:
  112. """mark_epg_fetched setzt last_epg_fetch auf aktuelle Zeit."""
  113. state = SyncState(tmp_path)
  114. vorher = datetime.now(tz=timezone.utc)
  115. state.mark_epg_fetched(retention_days=14, min_interval_hours=6)
  116. nachher = datetime.now(tz=timezone.utc)
  117. fetch_time = datetime.fromisoformat(state.last_epg_fetch)
  118. assert vorher <= fetch_time <= nachher
  119. def test_mark_setzt_next_sync_after(self, tmp_path: Path) -> None:
  120. """mark_epg_fetched berechnet next_sync_after korrekt."""
  121. state = SyncState(tmp_path)
  122. state.mark_epg_fetched(retention_days=14, min_interval_hours=6)
  123. fetch_time = datetime.fromisoformat(state.last_epg_fetch)
  124. next_sync = datetime.fromisoformat(state.next_sync_after)
  125. differenz = next_sync - fetch_time
  126. # Sollte ungefaehr 6 Stunden sein
  127. assert timedelta(hours=5, minutes=59) <= differenz <= timedelta(hours=6, minutes=1)
  128. def test_mark_speichert_automatisch(self, tmp_path: Path) -> None:
  129. """mark_epg_fetched ruft save() auf."""
  130. state = SyncState(tmp_path)
  131. state.mark_epg_fetched(retention_days=14, min_interval_hours=6)
  132. # Datei muss existieren (save wurde aufgerufen)
  133. assert (tmp_path / SyncState.FILE_NAME).exists()
  134. def test_mark_danach_kein_fetch_noetig(self, tmp_path: Path) -> None:
  135. """Nach mark_epg_fetched ist needs_epg_fetch() False."""
  136. state = SyncState(tmp_path)
  137. state.mark_epg_fetched(retention_days=14, min_interval_hours=6)
  138. assert state.needs_epg_fetch() is False
  139. # ============================================================================
  140. # SyncState - needs_enrichment()
  141. # ============================================================================
  142. class TestSyncStateNeedsEnrichment:
  143. """Tests fuer die Enrichment-Warteschlangen-Pruefung."""
  144. def test_leere_queue_kein_enrichment(self, tmp_path: Path) -> None:
  145. """Leere Warteschlange gibt False zurueck."""
  146. state = SyncState(tmp_path)
  147. assert state.needs_enrichment() is False
  148. def test_nicht_leere_queue_braucht_enrichment(self, tmp_path: Path) -> None:
  149. """Nicht-leere Warteschlange gibt True zurueck."""
  150. state = SyncState(tmp_path)
  151. state.enrichment_queue = ["tatort"]
  152. assert state.needs_enrichment() is True
  153. # ============================================================================
  154. # SyncState - add_to_enrichment_queue()
  155. # ============================================================================
  156. class TestSyncStateAddToEnrichmentQueue:
  157. """Tests fuer das Hinzufuegen zur Enrichment-Warteschlange."""
  158. def test_neue_ids_werden_hinzugefuegt(self, tmp_path: Path) -> None:
  159. """Neue Serien-IDs werden zur Queue hinzugefuegt."""
  160. state = SyncState(tmp_path)
  161. state.add_to_enrichment_queue(["tatort", "tagesschau"])
  162. assert "tatort" in state.enrichment_queue
  163. assert "tagesschau" in state.enrichment_queue
  164. def test_bereits_angereicherte_werden_uebersprungen(self, tmp_path: Path) -> None:
  165. """Bereits angereicherte IDs werden nicht zur Queue hinzugefuegt."""
  166. state = SyncState(tmp_path)
  167. state.enriched_titles = {"tatort": "tmdb"}
  168. state.add_to_enrichment_queue(["tatort", "tagesschau"])
  169. assert "tatort" not in state.enrichment_queue
  170. assert "tagesschau" in state.enrichment_queue
  171. def test_duplikate_in_queue_werden_verhindert(self, tmp_path: Path) -> None:
  172. """Bereits in der Queue vorhandene IDs werden nicht dupliziert."""
  173. state = SyncState(tmp_path)
  174. state.enrichment_queue = ["tatort"]
  175. state.add_to_enrichment_queue(["tatort", "tagesschau"])
  176. assert state.enrichment_queue.count("tatort") == 1
  177. assert "tagesschau" in state.enrichment_queue
  178. def test_hinzufuegen_speichert_bei_aenderung(self, tmp_path: Path) -> None:
  179. """Bei neuen Eintraegen wird automatisch gespeichert."""
  180. state = SyncState(tmp_path)
  181. state.add_to_enrichment_queue(["tatort"])
  182. assert (tmp_path / SyncState.FILE_NAME).exists()
  183. def test_hinzufuegen_leere_liste_kein_save(self, tmp_path: Path) -> None:
  184. """Leere Liste fuehrt nicht zum Speichern."""
  185. state = SyncState(tmp_path)
  186. state.add_to_enrichment_queue([])
  187. assert not (tmp_path / SyncState.FILE_NAME).exists()
  188. # ============================================================================
  189. # SyncState - mark_title_enriched()
  190. # ============================================================================
  191. class TestSyncStateMarkTitleEnriched:
  192. """Tests fuer das Markieren einzelner Titel als angereichert."""
  193. def test_entfernt_aus_queue(self, tmp_path: Path) -> None:
  194. """Die markierte ID wird aus der Queue entfernt."""
  195. state = SyncState(tmp_path)
  196. state.enrichment_queue = ["tatort", "tagesschau"]
  197. state.mark_title_enriched("tatort", "tmdb")
  198. assert "tatort" not in state.enrichment_queue
  199. assert "tagesschau" in state.enrichment_queue
  200. def test_fuegt_zu_enriched_hinzu(self, tmp_path: Path) -> None:
  201. """Die markierte ID wird zu enriched_titles hinzugefuegt."""
  202. state = SyncState(tmp_path)
  203. state.mark_title_enriched("tatort", "tmdb")
  204. assert state.enriched_titles["tatort"] == "tmdb"
  205. def test_mark_speichert_automatisch(self, tmp_path: Path) -> None:
  206. """mark_title_enriched ruft save() auf."""
  207. state = SyncState(tmp_path)
  208. state.mark_title_enriched("tatort", "omdb")
  209. assert (tmp_path / SyncState.FILE_NAME).exists()
  210. def test_mark_id_nicht_in_queue_kein_fehler(self, tmp_path: Path) -> None:
  211. """Markieren einer ID, die nicht in der Queue ist, verursacht keinen Fehler."""
  212. state = SyncState(tmp_path)
  213. state.mark_title_enriched("unbekannt", "tmdb")
  214. assert state.enriched_titles["unbekannt"] == "tmdb"
  215. # ============================================================================
  216. # SyncState - mark_enrichment_done()
  217. # ============================================================================
  218. class TestSyncStateMarkEnrichmentDone:
  219. """Tests fuer das Abschliessen einer Enrichment-Runde."""
  220. def test_setzt_zeitstempel(self, tmp_path: Path) -> None:
  221. """mark_enrichment_done setzt last_enrichment auf aktuelle Zeit."""
  222. state = SyncState(tmp_path)
  223. state.mark_enrichment_done()
  224. assert state.last_enrichment != ""
  225. # Muss ein gueltiger ISO-Zeitstempel sein
  226. datetime.fromisoformat(state.last_enrichment)
  227. def test_speichert_automatisch(self, tmp_path: Path) -> None:
  228. """mark_enrichment_done ruft save() auf."""
  229. state = SyncState(tmp_path)
  230. state.mark_enrichment_done()
  231. assert (tmp_path / SyncState.FILE_NAME).exists()
  232. def test_queue_bleibt_unveraendert(self, tmp_path: Path) -> None:
  233. """mark_enrichment_done veraendert die Queue nicht direkt."""
  234. state = SyncState(tmp_path)
  235. state.enrichment_queue = ["tatort"]
  236. state.mark_enrichment_done()
  237. # mark_enrichment_done leert die Queue NICHT (nur Zeitstempel)
  238. # Die Queue wird separat ueber mark_title_enriched geleert
  239. assert state.last_enrichment != ""
  240. # ============================================================================
  241. # SyncState - Verzeichnis-Erstellung
  242. # ============================================================================
  243. class TestSyncStateVerzeichnis:
  244. """Tests fuer die automatische Verzeichnis-Erstellung."""
  245. def test_erstellt_cache_verzeichnis(self, tmp_path: Path) -> None:
  246. """Das Cache-Verzeichnis wird automatisch erstellt."""
  247. subdir = tmp_path / "tief" / "verschachtelt"
  248. state = SyncState(subdir)
  249. assert subdir.exists()