"""Punkt 4: Ein Archivierungsfehler darf einen Erfolg nicht in einen Fehler kippen. Lief der `shutil.move` ins Archiv auf einen OSError (Platte voll, read-only), flog die Exception NACH dem erfolgreichen Move nach outgoing/. `_process()` fing sie im Catch-all, zählte einen Fehler — und `_dispatch_uploads()` lief nie. Das fertige PDF lag da und wurde nie hochgeladen. Jetzt: `_dispose_original()` wirft nicht mehr, meldet den Fehler deutlich und reicht ihn als `ProcessResult.warning` durch. Der Durchlauf zählt als Erfolg (das PDF ist fertig und wird ausgeliefert), die Benachrichtigung geht aber als Nicht-Erfolg raus, damit sie auch bei on = "errors" zugestellt wird. """ from __future__ import annotations import logging from pathlib import Path from unittest.mock import patch from pdf_ocr_hotfolder.config import FolderUpload, OcrConfig, OutputConfig, VeraPdfConfig from pdf_ocr_hotfolder.processor import _dispose_original, process_pdf from pdf_ocr_hotfolder.service import HotfolderService ORIGINAL = b"%PDF-1.4 original\n" def _fake_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None: dst.write_bytes(b"%PDF-1.4 OCRed\n") def _blocked_archive(tmp_path: Path) -> str: """Ein Archivpfad, dessen mkdir garantiert scheitert (Elternteil = Datei). Steht stellvertretend für read-only/volle Platte, ohne mocken zu müssen. """ blocker = tmp_path / "blocker" blocker.write_bytes(b"keine Verzeichnis\n") return str(blocker / "archiv") def _prepare(tmp_path: Path) -> dict: dirs = {name: tmp_path / name for name in ("incoming", "working", "outgoing", "error")} for d in dirs.values(): d.mkdir(parents=True, exist_ok=True) src = dirs["incoming"] / "scan.pdf" src.write_bytes(ORIGINAL) return {"src": src, **dirs} # ---------------- _dispose_original wirft nicht mehr ---------------- def test_dispose_archive_failure_returns_message(tmp_path: Path) -> None: work_src = tmp_path / "working" / "scan.pdf" work_src.parent.mkdir() work_src.write_bytes(ORIGINAL) msg = _dispose_original(work_src, "scan.pdf", OutputConfig(original_on_success="archive", archive_dir=_blocked_archive(tmp_path))) assert msg assert "scan.pdf" in msg # Das Original liegt noch da — nichts wurde verloren assert work_src.read_bytes() == ORIGINAL def test_dispose_archive_failure_names_working_dir(tmp_path: Path) -> None: """Die Meldung muss sagen, wo das Original liegen geblieben ist.""" work_src = tmp_path / "working" / "scan.pdf" work_src.parent.mkdir() work_src.write_bytes(ORIGINAL) msg = _dispose_original(work_src, "scan.pdf", OutputConfig(original_on_success="archive", archive_dir=_blocked_archive(tmp_path))) assert str(work_src.parent) in msg def test_dispose_delete_failure_returns_message(tmp_path: Path) -> None: work_src = tmp_path / "working" / "scan.pdf" work_src.parent.mkdir() work_src.write_bytes(ORIGINAL) with patch.object(Path, "unlink", side_effect=OSError("read-only")): msg = _dispose_original(work_src, "scan.pdf", OutputConfig(original_on_success="delete")) assert msg assert "gelöscht" in msg def test_dispose_success_returns_empty(tmp_path: Path) -> None: work_src = tmp_path / "working" / "scan.pdf" work_src.parent.mkdir() work_src.write_bytes(ORIGINAL) archive = tmp_path / "archiv" assert _dispose_original(work_src, "scan.pdf", OutputConfig(original_on_success="archive", archive_dir=str(archive))) == "" assert (archive / "scan.pdf").read_bytes() == ORIGINAL def test_dispose_missing_file_returns_empty(tmp_path: Path) -> None: assert _dispose_original(tmp_path / "gibtsnicht.pdf", "scan.pdf", OutputConfig()) == "" # ---------------- process_pdf bleibt erfolgreich ---------------- def _run(env: dict, out_cfg: OutputConfig): with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr): return process_pdf( src=env["src"], working_dir=env["working"], outgoing_dir=env["outgoing"], error_dir=env["error"], ocr_cfg=OcrConfig(), vera_cfg=VeraPdfConfig(enabled=False), output_cfg=out_cfg, ) def test_archive_failure_keeps_run_successful(tmp_path: Path) -> None: env = _prepare(tmp_path) result = _run(env, OutputConfig(name_mode="prefix", name_tag="OCR_", original_on_success="archive", archive_dir=_blocked_archive(tmp_path))) assert result.success is True assert result.warning # Das fertige PDF liegt in outgoing/ und wird normal ausgeliefert assert (env["outgoing"] / "OCR_scan.pdf").exists() assert result.output == env["outgoing"] / "OCR_scan.pdf" # Das Original ist nicht verloren, sondern liegt noch in working/ assert (env["working"] / "scan.pdf").read_bytes() == ORIGINAL def test_archive_failure_logs_error(tmp_path: Path, caplog) -> None: env = _prepare(tmp_path) with caplog.at_level(logging.ERROR, logger="pdf_ocr_hotfolder.processor"): _run(env, OutputConfig(original_on_success="archive", archive_dir=_blocked_archive(tmp_path))) assert str(env["working"]) in caplog.text def test_successful_run_has_no_warning(tmp_path: Path) -> None: env = _prepare(tmp_path) result = _run(env, OutputConfig(original_on_success="delete")) assert result.success is True assert result.warning == "" # ---------------- Service: Upload läuft trotzdem ---------------- def _run_once(tmp_config, **patches): stack = [ patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=True), patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr), ] 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 def test_upload_still_runs_after_archive_failure(tmp_config, tmp_path) -> None: """Der Kern des Punktes: das fertige PDF muss trotzdem hochgeladen werden.""" ziel = tmp_path / "upload-ziel" tmp_config.folder = FolderUpload(enabled=True, target=str(ziel)) tmp_config.output = OutputConfig(name_mode="prefix", name_tag="OCR_", original_on_success="archive", archive_dir=_blocked_archive(tmp_path)) (tmp_config.paths.incoming / "scan.pdf").write_bytes(ORIGINAL) service = _run_once(tmp_config) assert (ziel / "OCR_scan.pdf").exists() # Der Durchlauf zählt als Erfolg: das PDF ist fertig und ausgeliefert. assert service.success_count == 1 assert service.error_count == 0 def test_warning_notification_goes_out_as_error(tmp_config, tmp_path) -> None: """Die Mail muss auch bei on = 'errors' zugestellt werden.""" from pdf_ocr_hotfolder.processor import ProcessResult service = HotfolderService(tmp_config) try: with patch("pdf_ocr_hotfolder.service.notify_email") as mail: service._notify(ProcessResult( tmp_path / "scan.pdf", tmp_path / "OCR_scan.pdf", True, warning="Original konnte nicht archiviert werden", )) finally: service._executor.shutdown(wait=False) mail.assert_called_once() args = mail.call_args[0] assert "OK mit Warnung" in args[1] assert "Original konnte nicht archiviert werden" in args[2] assert args[3] is False # -> wird auch bei on="errors" verschickt