"""Punkt 7: Ein toter watchdog-Observer muss auffallen. Die Hauptschleife wartete nur auf `_stop` und fragte nie `is_alive()`. Stirbt der Observer im Betrieb (erschöpftes inotify-Watch-Limit, ersetztes oder neu gemountetes Verzeichnis), blieb die Unit `active (running)` und verarbeitete nichts mehr — kein Log, keine Mail, niemand merkt es. Jetzt endet der Dienst mit `EXIT_OBSERVER_DEAD` (3), damit systemd ihn per `Restart=on-failure` neu startet. Bewusst nicht 2: die Unit setzt `RestartPreventExitStatus=2` für Config-/Preflight-Fehler. """ from __future__ import annotations import logging import sys import threading from unittest.mock import MagicMock, patch from pdf_ocr_hotfolder.service import EXIT_OBSERVER_DEAD, HotfolderService class _NoSleepEvent(threading.Event): """Event, dessen wait() nicht schläft — hält die Tests schnell.""" def wait(self, timeout: float | None = None) -> bool: # noqa: D102 return self.is_set() class _StopAfter(_NoSleepEvent): """Setzt sich nach n Warteschritten selbst — simuliert ein SIGTERM.""" def __init__(self, n: int) -> None: super().__init__() self._left = n def wait(self, timeout: float | None = None) -> bool: self._left -= 1 if self._left <= 0: self.set() return self.is_set() def _service(tmp_config, stop: threading.Event, alive) -> HotfolderService: service = HotfolderService(tmp_config) service._stop = stop observer = MagicMock() if isinstance(alive, list): observer.is_alive.side_effect = alive else: observer.is_alive.return_value = alive service._observer = observer return service def _wait_loop(service: HotfolderService) -> int: try: return service._wait_loop() finally: service._executor.shutdown(wait=False) # ---------------- _wait_loop ---------------- def test_dead_observer_returns_exit_code(tmp_config) -> None: service = _service(tmp_config, _NoSleepEvent(), alive=False) assert _wait_loop(service) == EXIT_OBSERVER_DEAD def test_exit_code_is_not_2(tmp_config) -> None: """2 ist für Config-/Preflight-Fehler reserviert (RestartPreventExitStatus).""" assert EXIT_OBSERVER_DEAD != 2 assert EXIT_OBSERVER_DEAD != 0 def test_dead_observer_logs_clearly(tmp_config, caplog) -> None: service = _service(tmp_config, _NoSleepEvent(), alive=False) with caplog.at_level(logging.ERROR, logger="pdf_ocr_hotfolder.service"): _wait_loop(service) text = caplog.text assert str(tmp_config.paths.incoming) in text assert "KEINE neuen Dateien" in text assert "inotify" in text def test_observer_is_checked_repeatedly(tmp_config) -> None: """Der Observer wird nicht nur einmal beim Start geprüft.""" service = _service(tmp_config, _NoSleepEvent(), alive=[True, True, True, False]) assert _wait_loop(service) == EXIT_OBSERVER_DEAD assert service._observer.is_alive.call_count == 4 def test_regular_stop_returns_zero(tmp_config) -> None: """SIGTERM/SIGINT bei lebendem Observer: kein Fehlalarm.""" service = _service(tmp_config, _StopAfter(3), alive=True) assert _wait_loop(service) == 0 def test_stop_wins_over_dead_observer(tmp_config) -> None: """Beim planmäßigen Stoppen darf ein gestoppter Observer nichts auslösen. `shutdown()` stoppt den Observer — läuft die Schleife danach noch einen Takt, wäre das sonst ein Fehlalarm mit Exit 3 beim normalen Beenden. """ stop = _NoSleepEvent() stop.set() service = _service(tmp_config, stop, alive=False) assert _wait_loop(service) == 0 service._observer.is_alive.assert_not_called() def test_missing_observer_does_not_crash(tmp_config) -> None: service = HotfolderService(tmp_config) service._stop = _StopAfter(2) service._observer = None assert _wait_loop(service) == 0 # ---------------- run() / main() reichen den Code durch ---------------- def test_run_returns_exit_code(tmp_config) -> None: observer = MagicMock() observer.is_alive.return_value = False service = HotfolderService(tmp_config) service._stop = _NoSleepEvent() try: with patch("pdf_ocr_hotfolder.service.Observer", return_value=observer), \ patch("pdf_ocr_hotfolder.service.check_preflight"): assert service.run() == EXIT_OBSERVER_DEAD finally: service._executor.shutdown(wait=False) # Auch im Fehlerfall wird sauber heruntergefahren observer.stop.assert_called_once() def test_run_returns_zero_on_regular_stop(tmp_config) -> None: observer = MagicMock() observer.is_alive.return_value = True service = HotfolderService(tmp_config) service._stop = _StopAfter(2) try: with patch("pdf_ocr_hotfolder.service.Observer", return_value=observer), \ patch("pdf_ocr_hotfolder.service.check_preflight"): assert service.run() == 0 finally: service._executor.shutdown(wait=False) def test_main_passes_exit_code_through(tmp_path, tmp_config, monkeypatch) -> None: from pdf_ocr_hotfolder.__main__ import main cfg_file = tmp_path / "cfg.toml" cfg_file.write_text(f""" [paths] incoming = "{tmp_config.paths.incoming}" outgoing = "{tmp_config.paths.outgoing}" working = "{tmp_config.paths.working}" error = "{tmp_config.paths.error}" """) monkeypatch.setattr(sys, "argv", ["pdf-ocr-hotfolder", "--config", str(cfg_file)]) with patch.object(HotfolderService, "run", return_value=EXIT_OBSERVER_DEAD): assert main() == EXIT_OBSERVER_DEAD def test_main_returns_zero_on_regular_stop(tmp_path, tmp_config, monkeypatch) -> None: from pdf_ocr_hotfolder.__main__ import main cfg_file = tmp_path / "cfg.toml" cfg_file.write_text(f""" [paths] incoming = "{tmp_config.paths.incoming}" outgoing = "{tmp_config.paths.outgoing}" working = "{tmp_config.paths.working}" error = "{tmp_config.paths.error}" """) monkeypatch.setattr(sys, "argv", ["pdf-ocr-hotfolder", "--config", str(cfg_file)]) with patch.object(HotfolderService, "run", return_value=0): assert main() == 0 def test_main_returns_zero_on_keyboard_interrupt(tmp_path, tmp_config, monkeypatch) -> None: from pdf_ocr_hotfolder.__main__ import main cfg_file = tmp_path / "cfg.toml" cfg_file.write_text(f""" [paths] incoming = "{tmp_config.paths.incoming}" outgoing = "{tmp_config.paths.outgoing}" working = "{tmp_config.paths.working}" error = "{tmp_config.paths.error}" """) monkeypatch.setattr(sys, "argv", ["pdf-ocr-hotfolder", "--config", str(cfg_file)]) with patch.object(HotfolderService, "run", side_effect=KeyboardInterrupt): assert main() == 0