"""Tests für die Fehlerzählung im Service. Deckt drei bisher stumme Fehlerpfade ab: - Exception aus `process_pdf()` (z.B. fehlgeschlagener Move nach outgoing/) - fehlgeschlagene Uploads - Timeout im Stabilitäts-Check """ from __future__ import annotations from pathlib import Path from unittest.mock import patch from pdf_ocr_hotfolder.processor import ProcessResult from pdf_ocr_hotfolder.service import HotfolderService def _run_once(tmp_config, **patches): """Führt run_once() mit gemocktem Preflight aus und gibt den Service zurück.""" stack = [ patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=patches.pop("stable", True)), ] for target, kwargs in patches.items(): stack.append(patch(f"pdf_ocr_hotfolder.service.{target}", **kwargs)) service = HotfolderService(tmp_config) try: for p in stack: p.start() service.run_once() finally: for p in reversed(stack): p.stop() service._executor.shutdown(wait=False) return service # ---------------- Exception aus process_pdf ---------------- def test_exception_from_process_pdf_counts_as_error(tmp_config) -> None: """Eine Exception aus process_pdf() darf die Zählung nicht umgehen.""" (tmp_config.paths.incoming / "boom.pdf").write_bytes(b"%PDF-1.4\n") def explode(src, **kwargs): raise OSError("move to outgoing failed") service = _run_once(tmp_config, process_pdf={"side_effect": explode}) assert service.error_count == 1 assert service.success_count == 0 def test_exception_moves_file_to_error_dir(tmp_config) -> None: """Die Datei landet nach einer Exception im error-Verzeichnis.""" (tmp_config.paths.incoming / "boom.pdf").write_bytes(b"%PDF-1.4\n") def explode(src, **kwargs): raise RuntimeError("kaputt") _run_once(tmp_config, process_pdf={"side_effect": explode}) assert (tmp_config.paths.error / "boom.pdf").exists() assert not (tmp_config.paths.incoming / "boom.pdf").exists() def test_exception_after_move_to_working_rescues_from_working(tmp_config) -> None: """Realistischer Fall: process_pdf hat schon nach working/ verschoben.""" src = tmp_config.paths.incoming / "boom.pdf" src.write_bytes(b"%PDF-1.4\n") def explode(src: Path, working_dir: Path, **kwargs): # process_pdf verschiebt zuerst nach working/, dann knallt der Move # nach outgoing/ src.rename(working_dir / src.name) raise OSError("move to outgoing failed") service = _run_once(tmp_config, process_pdf={"side_effect": explode}) assert service.error_count == 1 assert (tmp_config.paths.error / "boom.pdf").exists() assert not (tmp_config.paths.working / "boom.pdf").exists() def test_exception_with_vanished_file_does_not_raise(tmp_config) -> None: """Ist die Datei nicht mehr auffindbar, wird nur geloggt — kein Crash.""" (tmp_config.paths.incoming / "boom.pdf").write_bytes(b"%PDF-1.4\n") def explode(src: Path, **kwargs): src.unlink(missing_ok=True) raise RuntimeError("kaputt") service = _run_once(tmp_config, process_pdf={"side_effect": explode}) assert service.error_count == 1 assert not (tmp_config.paths.error / "boom.pdf").exists() def test_exception_triggers_error_notification(tmp_config) -> None: """Auch bei einer Exception geht eine Fehler-Mail raus (success=False).""" (tmp_config.paths.incoming / "boom.pdf").write_bytes(b"%PDF-1.4\n") def explode(src, **kwargs): raise RuntimeError("kaputt") with patch("pdf_ocr_hotfolder.service.notify_email") as mail: _run_once(tmp_config, process_pdf={"side_effect": explode}) assert mail.call_count == 1 args = mail.call_args[0] assert "FEHLER" in args[1] assert args[3] is False # success-Flag # ---------------- Upload-Fehler ---------------- def _fake_success(src: Path, working_dir, outgoing_dir, error_dir, **kwargs): out = outgoing_dir / f"OCR_{src.name}" out.parent.mkdir(parents=True, exist_ok=True) out.write_bytes(b"%PDF-1.4 ocr\n") src.unlink(missing_ok=True) return ProcessResult(src, out, True) def test_failed_upload_counts_as_error(tmp_config) -> None: """Ein fehlgeschlagener Upload zählt als Fehler, nicht als Erfolg.""" (tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n") service = _run_once( tmp_config, process_pdf={"side_effect": _fake_success}, upload_nextcloud={"return_value": False}, ) assert service.error_count == 1 assert service.success_count == 0 def test_failed_upload_sends_error_mail_naming_targets(tmp_config) -> None: """Die Fehler-Mail nennt die fehlgeschlagenen Ziele.""" (tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n") with patch("pdf_ocr_hotfolder.service.notify_email") as mail: _run_once( tmp_config, process_pdf={"side_effect": _fake_success}, upload_nextcloud={"return_value": False}, upload_sftp={"return_value": False}, ) assert mail.call_count == 1 _cfg, subject, body, success = mail.call_args[0] assert "FEHLER" in subject assert success is False assert "nextcloud" in body assert "sftp" in body def test_failed_upload_keeps_pdf_in_outgoing(tmp_config) -> None: """Das OCR war erfolgreich — die Datei bleibt in outgoing/, nicht error/.""" (tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n") _run_once( tmp_config, process_pdf={"side_effect": _fake_success}, upload_folder={"return_value": False}, ) assert (tmp_config.paths.outgoing / "OCR_a.pdf").exists() assert not (tmp_config.paths.error / "OCR_a.pdf").exists() def test_successful_uploads_count_as_success(tmp_config) -> None: """Gegenprobe: wenn alle Uploads durchgehen, zählt es als Erfolg.""" (tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n") service = _run_once(tmp_config, process_pdf={"side_effect": _fake_success}) assert service.success_count == 1 assert service.error_count == 0 def test_dispatch_uploads_reports_failed_targets(tmp_config) -> None: """_dispatch_uploads() liefert die Namen der fehlgeschlagenen Ziele.""" service = HotfolderService(tmp_config) try: pdf = tmp_config.paths.outgoing / "x.pdf" pdf.write_bytes(b"%PDF-1.4\n") with patch("pdf_ocr_hotfolder.service.upload_folder", return_value=True), \ patch("pdf_ocr_hotfolder.service.upload_nextcloud", return_value=False), \ patch("pdf_ocr_hotfolder.service.upload_sftp", return_value=True): assert service._dispatch_uploads(pdf) == ["nextcloud"] with patch("pdf_ocr_hotfolder.service.upload_folder", return_value=True), \ patch("pdf_ocr_hotfolder.service.upload_nextcloud", return_value=True), \ patch("pdf_ocr_hotfolder.service.upload_sftp", return_value=True): assert service._dispatch_uploads(pdf) == [] finally: service._executor.shutdown(wait=False) # ---------------- Stabilitäts-Timeout ---------------- def test_unstable_file_counts_as_error(tmp_config) -> None: """Stabilisiert sich eine Datei nicht, ist das ein Fehler (Exit 1).""" (tmp_config.paths.incoming / "slow.pdf").write_bytes(b"%PDF-1.4\n") with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \ patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=False), \ patch("pdf_ocr_hotfolder.service.process_pdf") as proc: service = HotfolderService(tmp_config) try: errors = service.run_once() finally: service._executor.shutdown(wait=False) assert errors == 1 assert service.error_count == 1 proc.assert_not_called() def test_unstable_file_stays_in_incoming(tmp_config) -> None: """Die instabile Datei bleibt bewusst in incoming/ liegen.""" (tmp_config.paths.incoming / "slow.pdf").write_bytes(b"%PDF-1.4\n") with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \ patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=False): service = HotfolderService(tmp_config) try: service.run_once() finally: service._executor.shutdown(wait=False) assert (tmp_config.paths.incoming / "slow.pdf").exists() assert not (tmp_config.paths.error / "slow.pdf").exists() def test_vanished_file_is_not_an_error(tmp_config) -> None: """Verschwundene Datei ist kein Fehler — _wait_until_stable liefert dafür ebenfalls False.""" pdf = tmp_config.paths.incoming / "weg.pdf" pdf.write_bytes(b"%PDF-1.4\n") def vanish(path: Path, **kwargs) -> bool: path.unlink(missing_ok=True) return False with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \ patch("pdf_ocr_hotfolder.service._wait_until_stable", side_effect=vanish): service = HotfolderService(tmp_config) try: errors = service.run_once() finally: service._executor.shutdown(wait=False) assert errors == 0 assert service.error_count == 0