feat: stille Datenverlust-Pfade geschlossen, gemeinsame Shell-Lib (v0.7.0)

Vor dem Rollout durchgesehen und die verbliebenen Stellen geschlossen, an
denen etwas schiefgehen konnte, ohne dass es irgendwo sichtbar wurde.

Datenverlust:
- veraPDF: das in [verapdf].binary konfigurierte Programm wird im Preflight
  geprueft. Bisher galt bei falschem Pfad JEDE Datei als "nicht konform" —
  Ergebnis nach error/, Original geloescht (Default delete). run_verapdf()
  trennt jetzt ausserdem ein echtes FAIL-Urteil von einer Stoerung
  (VeraPdfUnavailable: nicht startbar, abgestuerzt, kein PASS/FAIL in der
  Ausgabe). Bei Stoerung wandern Original UND Ergebnis nach error/, das
  Original wird nicht entsorgt.
- Gleichnamige Dateien wurden in outgoing/, error/ und beim Ordner-Upload
  mit abweichendem target kommentarlos ueberschrieben. Jetzt Zeitstempel
  daneben, mit Warnung; ProcessResult.output traegt den echten Pfad.

Robustheit:
- Kaputtes oder nicht lesbares TOML beim Start: Exit 2 statt Traceback.
- RestartPreventExitStatus=2 in der Unit — Exit 2 (Config/Preflight) laeuft
  nicht mehr endlos neu, die Instanz bleibt sichtbar failed stehen.
- Toter watchdog-Observer wird erkannt: Exit 3, systemd setzt den Watch neu
  auf. Vorher blieb die Unit "active" und verarbeitete nichts mehr.
- Relative Pfade in [paths]/archive_dir/target sind ein Config-Fehler statt
  still unter /opt zu landen.
- Fehler beim Archivieren entwertet den Durchlauf nicht mehr: Upload und
  Mail laufen, Sichtbarkeit ueber log.error + "OK mit Warnung"-Mail.
- Nicht-PDFs in incoming/ werden beim Start-Scan gesammelt gemeldet.
- Logging explizit nach stdout (die Doku versprach das schon).

Struktur:
- Neue lib/common.sh, von install.sh und update.sh gesourct. Die doppelte
  venv_is_healthy() gibt es nur noch einmal, in der gruendlichen Fassung —
  die schlanke in install.sh haette eine nach einem Distro-Sprung kaputte
  venv als gesund durchgewunken (nachgewiesen).
- install.sh warnt in Containern, wenn systemd-journald nicht laeuft.

Doku: Dateisystem-Festlegung (ext4/xfs/zfs, kein CIFS/NFS wegen inotify),
Debian 13 in LXC auf Proxmox scheitert an journald (243/CREDENTIALS,
AppArmor blockiert sd-mkdcreds) inkl. Abhilfe, echte Speicher-Messwerte,
Exit-Code-Tabelle.

254 Tests gruen (vorher 152).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 00:59:17 +02:00
parent 305454eeb5
commit cd803a3dfe
28 changed files with 2902 additions and 286 deletions
+211
View File
@@ -0,0 +1,211 @@
"""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
+140
View File
@@ -0,0 +1,140 @@
"""Punkt 2: error/ darf nichts mehr still überschreiben.
Scheiterte dieselbe `scan.pdf` zweimal, ersetzte die zweite die erste in
error/ — dieselbe Datenverlust-Klasse, die für outgoing/ bereits geschlossen
ist. Betrifft `_move_to_error()` und damit auch `_rescue_to_error()`.
"""
from __future__ import annotations
import logging
from pathlib import Path
from unittest.mock import patch
from pdf_ocr_hotfolder.config import OcrConfig, OutputConfig, VeraPdfConfig
from pdf_ocr_hotfolder.processor import _move_to_error, process_pdf
from pdf_ocr_hotfolder.service import HotfolderService
ERSTE = b"%PDF-1.4 erste\n"
ZWEITE = b"%PDF-1.4 zweite\n"
# ---------------- _move_to_error direkt ----------------
def test_move_to_error_keeps_existing_file(tmp_path: Path) -> None:
error_dir = tmp_path / "error"
error_dir.mkdir()
(error_dir / "scan.pdf").write_bytes(ERSTE)
zweite = tmp_path / "scan.pdf"
zweite.write_bytes(ZWEITE)
_move_to_error(zweite, error_dir)
assert (error_dir / "scan.pdf").read_bytes() == ERSTE
ausweich = list(error_dir.glob("scan_*.pdf"))
assert len(ausweich) == 1
assert ausweich[0].read_bytes() == ZWEITE
assert not zweite.exists()
def test_move_to_error_without_collision_keeps_name(tmp_path: Path) -> None:
error_dir = tmp_path / "error"
src = tmp_path / "scan.pdf"
src.write_bytes(ERSTE)
_move_to_error(src, error_dir)
assert (error_dir / "scan.pdf").read_bytes() == ERSTE
assert list(error_dir.iterdir()) == [error_dir / "scan.pdf"]
def test_move_to_error_logs_warning_on_collision(tmp_path: Path, caplog) -> None:
error_dir = tmp_path / "error"
error_dir.mkdir()
(error_dir / "scan.pdf").write_bytes(ERSTE)
src = tmp_path / "scan.pdf"
src.write_bytes(ZWEITE)
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.processor"):
_move_to_error(src, error_dir)
assert "scan.pdf" in caplog.text
assert "überschrieben" in caplog.text
def test_move_to_error_creates_dir(tmp_path: Path) -> None:
src = tmp_path / "scan.pdf"
src.write_bytes(ERSTE)
error_dir = tmp_path / "tief" / "error"
_move_to_error(src, error_dir)
assert (error_dir / "scan.pdf").exists()
def test_move_to_error_survives_oserror(tmp_path: Path, caplog) -> None:
"""Ein fehlgeschlagener Move darf weiterhin nur geloggt werden."""
src = tmp_path / "scan.pdf"
src.write_bytes(ERSTE)
error_dir = tmp_path / "error"
with patch("pdf_ocr_hotfolder.processor.shutil.move",
side_effect=OSError("read-only")):
_move_to_error(src, error_dir) # darf nicht werfen
assert src.exists()
# ---------------- über process_pdf: zweimal dieselbe Datei kaputt ----------------
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)
return dirs
def _run_failing_ocr(dirs: dict, src: Path):
with patch("pdf_ocr_hotfolder.processor.run_ocr",
side_effect=RuntimeError("ocr kaputt")):
return process_pdf(
src=src,
working_dir=dirs["working"],
outgoing_dir=dirs["outgoing"],
error_dir=dirs["error"],
ocr_cfg=OcrConfig(),
vera_cfg=VeraPdfConfig(enabled=False),
output_cfg=OutputConfig(),
)
def test_same_name_failing_twice_keeps_both(tmp_path: Path) -> None:
dirs = _prepare(tmp_path)
for inhalt in (ERSTE, ZWEITE):
src = dirs["incoming"] / "scan.pdf"
src.write_bytes(inhalt)
result = _run_failing_ocr(dirs, src)
assert not result.success
dateien = sorted(p.read_bytes() for p in dirs["error"].iterdir())
assert len(dateien) == 2
assert sorted([ERSTE, ZWEITE]) == dateien
# ---------------- _rescue_to_error erbt den Schutz ----------------
def test_rescue_to_error_keeps_existing_file(tmp_config) -> None:
"""Der Rettungspfad nach einer unerwarteten Exception ebenso."""
(tmp_config.paths.error / "boom.pdf").write_bytes(ERSTE)
src = tmp_config.paths.incoming / "boom.pdf"
src.write_bytes(ZWEITE)
service = HotfolderService(tmp_config)
try:
service._rescue_to_error(src)
finally:
service._executor.shutdown(wait=False)
assert (tmp_config.paths.error / "boom.pdf").read_bytes() == ERSTE
ausweich = list(tmp_config.paths.error.glob("boom_*.pdf"))
assert len(ausweich) == 1
assert ausweich[0].read_bytes() == ZWEITE
+88
View File
@@ -0,0 +1,88 @@
"""Punkt 6: Fremddateien in incoming/ verschwinden nicht mehr lautlos.
Alles ohne .pdf-Endung wurde kommentarlos ignoriert und sammelte sich an.
Jetzt gibt es beim Start-Scan genau EINE Sammelmeldung — kein Spam im
laufenden Betrieb, keine Zeile pro Datei.
"""
from __future__ import annotations
import logging
from unittest.mock import patch
from pdf_ocr_hotfolder.service import HotfolderService
LOGGER = "pdf_ocr_hotfolder.service"
def _scan(tmp_config, caplog) -> str:
service = HotfolderService(tmp_config)
try:
with caplog.at_level(logging.WARNING, logger=LOGGER), \
patch.object(HotfolderService, "enqueue"):
service._scan_existing()
finally:
service._executor.shutdown(wait=False)
return caplog.text
def test_non_pdf_files_are_reported(tmp_config, caplog) -> None:
(tmp_config.paths.incoming / "notizen.txt").write_text("x")
(tmp_config.paths.incoming / "bild.jpg").write_bytes(b"x")
(tmp_config.paths.incoming / "scan.pdf").write_bytes(b"%PDF-1.4\n")
text = _scan(tmp_config, caplog)
assert "2 Datei(en) ohne .pdf-Endung" in text
assert "notizen.txt" in text
assert "bild.jpg" in text
assert "scan.pdf" not in text
def test_single_aggregate_line(tmp_config, caplog) -> None:
"""Eine Sammelmeldung, nicht eine pro Datei."""
for i in range(7):
(tmp_config.paths.incoming / f"datei{i}.txt").write_text("x")
text = _scan(tmp_config, caplog)
assert text.count("ohne .pdf-Endung") == 1
assert "7 Datei(en)" in text
# Nur die ersten drei werden namentlich genannt
assert "+4 weitere" in text
def test_no_message_without_foreign_files(tmp_config, caplog) -> None:
(tmp_config.paths.incoming / "scan.pdf").write_bytes(b"%PDF-1.4\n")
assert "ohne .pdf-Endung" not in _scan(tmp_config, caplog)
def test_empty_incoming_is_quiet(tmp_config, caplog) -> None:
assert "ohne .pdf-Endung" not in _scan(tmp_config, caplog)
def test_directories_are_not_counted(tmp_config, caplog) -> None:
"""Ein Unterverzeichnis ist keine liegengebliebene Fremddatei."""
(tmp_config.paths.incoming / "unterordner").mkdir()
assert "ohne .pdf-Endung" not in _scan(tmp_config, caplog)
def test_uppercase_pdf_is_not_foreign(tmp_config, caplog) -> None:
"""`_is_pdf()` ist case-insensitiv — SCAN.PDF ist eine PDF."""
(tmp_config.paths.incoming / "SCAN.PDF").write_bytes(b"%PDF-1.4\n")
assert "ohne .pdf-Endung" not in _scan(tmp_config, caplog)
def test_no_spam_during_runtime(tmp_config, caplog) -> None:
"""Im laufenden Betrieb bleibt enqueue() für Fremddateien stumm."""
fremd = tmp_config.paths.incoming / "notizen.txt"
fremd.write_text("x")
service = HotfolderService(tmp_config)
try:
with caplog.at_level(logging.DEBUG, logger=LOGGER):
for _ in range(5):
service.enqueue(fremd)
finally:
service._executor.shutdown(wait=False)
assert caplog.text == ""
+117
View File
@@ -0,0 +1,117 @@
"""Log-Ziel: der Dienst loggt nach stdout, nicht nach stderr.
README und docs/INSTALLATION.md versprechen stdout. `logging.basicConfig()`
ohne `stream=` nimmt aber stderr. Für journald ist das egal, für den in der
Doku beschriebenen Vordergrund-Notbehelf und für jede Weiterleitung der
Ausgabe nicht.
Gleichzeitig muss die Trennung in `--check-config` bleiben: Infos und
Warnungen nach stdout, Fehler nach stderr.
"""
from __future__ import annotations
import io
import logging
import sys
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import patch
from pdf_ocr_hotfolder.__main__ import _setup_logging, main
@contextmanager
def _fresh_root_logger():
"""Root-Logger wie beim echten Dienststart: ohne Handler.
`logging.basicConfig()` tut nichts, solange der Root-Logger Handler hat —
und pytest hängt seinen Capture-Handler dort ein, nachdem die Fixtures
gelaufen sind. Deshalb erst hier, direkt um den Aufruf herum, leeren.
"""
root = logging.getLogger()
saved_handlers, saved_level = root.handlers[:], root.level
root.handlers = []
try:
yield root
finally:
for h in root.handlers:
h.close()
root.handlers = saved_handlers
root.level = saved_level
def test_setup_logging_uses_stdout() -> None:
with _fresh_root_logger() as root:
_setup_logging("INFO")
streams = [h.stream for h in root.handlers
if isinstance(h, logging.StreamHandler)]
assert streams, "kein StreamHandler konfiguriert"
assert all(s is sys.stdout for s in streams)
assert not any(s is sys.stderr for s in streams)
def test_log_records_land_on_stdout(monkeypatch) -> None:
"""Ein echter Log-Satz muss im stdout-Puffer stehen, nicht im stderr."""
out, err = io.StringIO(), io.StringIO()
monkeypatch.setattr(sys, "stdout", out)
monkeypatch.setattr(sys, "stderr", err)
with _fresh_root_logger():
_setup_logging("INFO")
logging.getLogger("pdf_ocr_hotfolder.test").warning("Testmeldung 4711")
assert "Testmeldung 4711" in out.getvalue()
assert "Testmeldung 4711" not in err.getvalue()
def test_setup_logging_respects_level() -> None:
with _fresh_root_logger() as root:
_setup_logging("WARNING")
assert root.level == logging.WARNING
def test_setup_logging_falls_back_on_garbage_level() -> None:
"""Ein Tippfehler in [logging].level darf den Start nicht verhindern."""
with _fresh_root_logger() as root:
_setup_logging("LAUT")
assert root.level == logging.INFO
# ---------------- Trennung in --check-config ----------------
def _cfg(tmp_path: Path) -> Path:
cfg = tmp_path / "cfg.toml"
cfg.write_text(f"""
[paths]
incoming = "{tmp_path / 'in'}"
outgoing = "{tmp_path / 'out'}"
working = "{tmp_path / 'work'}"
error = "{tmp_path / 'err'}"
""")
return cfg
def test_check_config_keeps_info_on_stdout(tmp_path: Path, monkeypatch,
capsys) -> None:
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config", str(_cfg(tmp_path)),
"--check-config"])
with patch("pdf_ocr_hotfolder.service.shutil.which",
return_value="/usr/bin/fake"):
assert main() == 0
captured = capsys.readouterr()
assert "Config sauber" in captured.out
assert captured.err == ""
def test_check_config_keeps_errors_on_stderr(tmp_path: Path, monkeypatch,
capsys) -> None:
cfg = tmp_path / "cfg.toml"
cfg.write_text('[ocr]\nlanguages = "deu"\n')
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config", str(cfg),
"--check-config"])
assert main() == 2
captured = capsys.readouterr()
assert "FEHLER" in captured.err
assert "FEHLER" not in captured.out
+199
View File
@@ -0,0 +1,199 @@
"""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
+185
View File
@@ -0,0 +1,185 @@
"""Namens-Kollision in outgoing/ darf kein Ergebnis mehr überschreiben.
`process_pdf()` beendete mit `shutil.move(work_out, final_out)`. Lag dort
bereits eine Datei desselben Namens (Scanner liefert denselben Dateinamen ein
zweites Mal, oder das Vorgängerergebnis wurde noch nicht abgeholt), war das
ältere Ergebnis kommentarlos weg. Jetzt gilt derselbe Zeitstempel-Ausweg wie
im Archiv.
"""
from __future__ import annotations
import logging
from pathlib import Path
from unittest.mock import patch
import pytest
from pdf_ocr_hotfolder.config import OcrConfig, OutputConfig, VeraPdfConfig
from pdf_ocr_hotfolder.processor import _collision_free_path, process_pdf
OLD = b"%PDF-1.4 altes ergebnis\n"
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" + src.read_bytes())
def _prepare(tmp_path: Path) -> dict:
dirs = {name: tmp_path / name
for name in ("incoming", "working", "outgoing", "error", "archive")}
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}
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,
)
# ---------------- Kollision in outgoing/ ----------------
def test_existing_result_is_not_overwritten(tmp_path: Path) -> None:
"""Beide Dateien müssen hinterher existieren."""
env = _prepare(tmp_path)
(env["outgoing"] / "OCR_scan.pdf").write_bytes(OLD)
result = _run(env, OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="delete"))
assert result.success
# Altes Ergebnis unverändert
assert (env["outgoing"] / "OCR_scan.pdf").read_bytes() == OLD
# Neues Ergebnis unter Zeitstempel-Namen daneben
neu = [p for p in env["outgoing"].glob("OCR_scan_*.pdf")]
assert len(neu) == 1
assert neu[0].read_bytes() == b"%PDF-1.4 OCRed\n" + ORIGINAL
assert len(list(env["outgoing"].iterdir())) == 2
def test_result_output_points_to_written_file(tmp_path: Path) -> None:
"""ProcessResult.output muss den TATSÄCHLICH geschriebenen Pfad tragen.
Sonst melden Uploads und die E-Mail-Benachrichtigung die falsche (nämlich
die fremde, ältere) Datei.
"""
env = _prepare(tmp_path)
(env["outgoing"] / "OCR_scan.pdf").write_bytes(OLD)
result = _run(env, OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="delete"))
assert result.output.exists()
assert result.output.name != "OCR_scan.pdf"
assert result.output.parent == env["outgoing"]
assert result.output.read_bytes() != OLD
def test_collision_logs_warning_with_both_names(tmp_path: Path, caplog) -> None:
env = _prepare(tmp_path)
(env["outgoing"] / "OCR_scan.pdf").write_bytes(OLD)
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.processor"):
result = _run(env, OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="delete"))
text = caplog.text
assert "OCR_scan.pdf" in text
assert result.output.name in text
assert "überschrieben" in text
def test_no_collision_keeps_plain_name(tmp_path: Path, caplog) -> None:
"""Ohne Kollision bleibt alles wie bisher — kein Suffix, keine Warnung."""
env = _prepare(tmp_path)
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.processor"):
result = _run(env, OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="delete"))
assert result.output == env["outgoing"] / "OCR_scan.pdf"
assert result.output.exists()
assert "überschrieben" not in caplog.text
def test_collision_with_name_mode_none(tmp_path: Path) -> None:
"""name_mode='none': Ergebnis heißt wie das Original — Kollision ist dort
der Normalfall, nicht die Ausnahme."""
env = _prepare(tmp_path)
(env["outgoing"] / "scan.pdf").write_bytes(OLD)
result = _run(env, OutputConfig(name_mode="none", name_tag="",
original_on_success="delete"))
assert result.success
assert (env["outgoing"] / "scan.pdf").read_bytes() == OLD
assert result.output.name.startswith("scan_")
assert result.output.suffix == ".pdf"
def test_original_is_still_disposed_after_collision(tmp_path: Path) -> None:
"""Der Ausweichname darf die Entsorgung des Originals nicht aushebeln."""
env = _prepare(tmp_path)
(env["outgoing"] / "OCR_scan.pdf").write_bytes(OLD)
_run(env, OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="archive",
archive_dir=str(env["archive"])))
assert list(env["working"].iterdir()) == []
assert (env["archive"] / "scan.pdf").read_bytes() == ORIGINAL
# ---------------- _collision_free_path ----------------
def test_collision_free_path_passes_through_free_name(tmp_path: Path) -> None:
dest = tmp_path / "frei.pdf"
assert _collision_free_path(dest) == dest
def test_collision_free_path_appends_timestamp(tmp_path: Path) -> None:
dest = tmp_path / "belegt.pdf"
dest.write_bytes(b"x")
out = _collision_free_path(dest)
assert out != dest
assert out.name.startswith("belegt_")
assert out.suffix == ".pdf"
assert not out.exists()
def test_collision_free_path_counts_up_within_same_second(tmp_path: Path) -> None:
"""Zwei Ergebnisse in derselben Sekunde (mehrere Worker) kollidieren sonst
erneut — und der move überschriebe wieder still."""
dest = tmp_path / "belegt.pdf"
dest.write_bytes(b"x")
first = _collision_free_path(dest)
first.write_bytes(b"y")
with patch("pdf_ocr_hotfolder.processor.datetime") as dt:
# Zeitstempel einfrieren: erzwingt denselben Namen wie `first`
dt.now.return_value.strftime.return_value = first.stem.split("_", 1)[1]
second = _collision_free_path(dest)
assert second != first
assert not second.exists()
assert second.suffix == ".pdf"
@pytest.mark.parametrize("name", ["ohne_extension", "zwei.punkte.pdf"])
def test_collision_free_path_keeps_extension(tmp_path: Path, name: str) -> None:
dest = tmp_path / name
dest.write_bytes(b"x")
out = _collision_free_path(dest)
assert out.suffix == dest.suffix
assert out.name != dest.name
+111
View File
@@ -0,0 +1,111 @@
"""Punkt 5: Relative Pfade in der Config sind ein Fehler, keine stille Annahme.
`incoming = "in"` legte das Verzeichnis unter dem WorkingDirectory des
Dienstes an (/opt/pdf-ocr-hotfolder/in) statt dort, wo der Scanner ablegt —
ohne Warnung. Der Dienst schaute dann dauerhaft ins Leere.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from pdf_ocr_hotfolder.config import ConfigError, load_config
_ABS = {
"incoming": "/var/lib/pdf-ocr-hotfolder/incoming",
"outgoing": "/var/lib/pdf-ocr-hotfolder/outgoing",
"working": "/var/lib/pdf-ocr-hotfolder/working",
"error": "/var/lib/pdf-ocr-hotfolder/error",
}
def _write(tmp_path: Path, paths: dict[str, str], extra: str = "") -> Path:
cfg = tmp_path / "config.toml"
zeilen = "\n".join(f'{k} = "{v}"' for k, v in paths.items())
cfg.write_text(f"[paths]\n{zeilen}\n{extra}")
return cfg
@pytest.mark.parametrize("key", list(_ABS))
def test_relative_path_key_is_rejected(tmp_path: Path, key: str) -> None:
paths = dict(_ABS)
paths[key] = "in"
with pytest.raises(ConfigError) as exc:
load_config(_write(tmp_path, paths))
msg = str(exc.value)
assert key in msg
assert "absolut" in msg.lower()
assert "WorkingDirectory" in msg
def test_dot_relative_path_is_rejected(tmp_path: Path) -> None:
"""Auch './scans' und '../scans' sind relativ."""
paths = dict(_ABS, incoming="./scans")
with pytest.raises(ConfigError, match="incoming"):
load_config(_write(tmp_path, paths))
def test_absolute_paths_load(tmp_path: Path) -> None:
cfg = load_config(_write(tmp_path, _ABS))
assert cfg.paths.incoming == Path(_ABS["incoming"])
def test_relative_archive_dir_is_rejected(tmp_path: Path) -> None:
cfg = _write(tmp_path, _ABS,
'\n[output]\noriginal_on_success = "archive"\n'
'archive_dir = "archiv"\n')
with pytest.raises(ConfigError) as exc:
load_config(cfg)
assert "archive_dir" in str(exc.value)
def test_relative_upload_target_is_rejected(tmp_path: Path) -> None:
cfg = _write(tmp_path, _ABS,
'\n[upload.folder]\nenabled = true\ntarget = "fertig"\n')
with pytest.raises(ConfigError) as exc:
load_config(cfg)
assert "target" in str(exc.value)
def test_empty_archive_dir_and_target_are_fine(tmp_path: Path) -> None:
"""Leer heißt 'nicht gesetzt' und bleibt erlaubt (das ist der Default)."""
cfg = load_config(_write(tmp_path, _ABS,
'\n[output]\narchive_dir = ""\n'
'\n[upload.folder]\ntarget = ""\n'))
assert cfg.output.archive_dir == ""
assert cfg.folder.target == ""
def test_absolute_archive_dir_and_target_load(tmp_path: Path) -> None:
cfg = load_config(_write(tmp_path, _ABS,
'\n[output]\narchive_dir = "/srv/archiv"\n'
'\n[upload.folder]\ntarget = "/srv/fertig"\n'))
assert cfg.output.archive_dir == "/srv/archiv"
assert cfg.folder.target == "/srv/fertig"
# ---------------- CLI ----------------
def test_main_returns_2_on_relative_path(tmp_path: Path, monkeypatch, capsys) -> None:
cfg = _write(tmp_path, dict(_ABS, incoming="in"))
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config", str(cfg), "--once"])
from pdf_ocr_hotfolder.__main__ import main
assert main() == 2
err = capsys.readouterr().err
assert "FEHLER" in err
assert "incoming" in err
def test_check_config_returns_2_on_relative_path(tmp_path: Path, monkeypatch,
capsys) -> None:
from pdf_ocr_hotfolder.__main__ import CHECK_ERROR, main
cfg = _write(tmp_path, dict(_ABS, outgoing="raus"))
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config", str(cfg),
"--check-config"])
assert main() == CHECK_ERROR
assert "outgoing" in capsys.readouterr().err
+110
View File
@@ -0,0 +1,110 @@
"""Kaputtes TOML beim NORMALEN Dienststart (nicht nur bei --check-config).
`--check-config` fing `tomllib.TOMLDecodeError` schon immer ab, der Startpfad
in `main()` aber nicht: ein Tippfehler in der Instanz-Config ergab einen
nackten Traceback. Zusammen mit `Restart=on-failure` in der Unit lief die
Instanz damit in einen Neustart-Loop.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from pdf_ocr_hotfolder.__main__ import main
# Verschiedene Arten, eine TOML kaputt zu machen
BROKEN_TOMLS = [
pytest.param('[paths]\nincoming = "/tmp/in\n', id="unbalancierte-quotes"),
pytest.param("[paths\nincoming = \n", id="unvollstaendige-sektion"),
pytest.param('[paths]\nincoming "/tmp/in"\n', id="fehlendes-gleich"),
pytest.param('[paths]\nincoming = "/a"\n[paths]\nincoming = "/b"\n',
id="doppelte-sektion"),
]
def _run(monkeypatch, cfg: Path, *extra_args: str) -> int:
monkeypatch.setattr(
sys, "argv",
["pdf-ocr-hotfolder", "--config", str(cfg), *extra_args],
)
return main()
@pytest.mark.parametrize("content", BROKEN_TOMLS)
def test_broken_toml_returns_2_on_normal_start(
tmp_path: Path, monkeypatch, capsys, content: str) -> None:
"""Dienststart ohne --once: Exit 2, keine Exception nach außen."""
cfg = tmp_path / "instanz.toml"
cfg.write_text(content)
assert _run(monkeypatch, cfg) == 2
err = capsys.readouterr().err
assert "FEHLER" in err
assert "TOML" in err
assert str(cfg) in err
@pytest.mark.parametrize("content", BROKEN_TOMLS)
def test_broken_toml_returns_2_with_once(
tmp_path: Path, monkeypatch, capsys, content: str) -> None:
"""Auch --once darf nicht mit Traceback aussteigen."""
cfg = tmp_path / "instanz.toml"
cfg.write_text(content)
assert _run(monkeypatch, cfg, "--once") == 2
assert "TOML" in capsys.readouterr().err
def test_broken_toml_message_names_position(
tmp_path: Path, monkeypatch, capsys) -> None:
"""Die Meldung muss dem Kunden sagen, WO es klemmt.
Zeile/Spalte kommen ab Python 3.14 aus den Exception-Attributen, darunter
stecken sie im Meldungstext von tomllib. Beide Wege müssen in der Ausgabe
landen.
"""
cfg = tmp_path / "instanz.toml"
cfg.write_text('[paths]\nincoming = "/tmp/in"\nworking = "/tmp/w\n')
assert _run(monkeypatch, cfg) == 2
err = capsys.readouterr().err.lower()
assert "zeile 3" in err or "line 3" in err
def test_broken_toml_start_and_check_config_agree(
tmp_path: Path, monkeypatch, capsys) -> None:
"""Startpfad und --check-config liefern denselben Exit-Code und Text."""
cfg = tmp_path / "instanz.toml"
cfg.write_text('[paths]\nincoming = "/tmp/in\n')
assert _run(monkeypatch, cfg) == 2
start_err = capsys.readouterr().err
assert _run(monkeypatch, cfg, "--check-config") == 2
check_err = capsys.readouterr().err
marker = f"{cfg} ist kein gültiges TOML"
assert marker in start_err
assert marker in check_err
def test_unreadable_config_returns_2(tmp_path: Path, monkeypatch, capsys) -> None:
"""Config existiert, ist aber nicht lesbar → Exit 2 statt Traceback."""
cfg = tmp_path / "instanz.toml"
cfg.write_text('[paths]\nincoming = "/tmp/in"\n')
cfg.chmod(0o000)
try:
# Als root greifen Dateirechte nicht — dann ist der Test gegenstandslos
try:
cfg.open("rb").close()
pytest.skip("Datei trotz chmod 000 lesbar (root?)")
except PermissionError:
pass
assert _run(monkeypatch, cfg) == 2
assert "nicht lesbar" in capsys.readouterr().err
finally:
cfg.chmod(0o644)
+68
View File
@@ -64,3 +64,71 @@ def test_upload_folder_reports_failure(tmp_path: Path) -> None:
assert upload_folder(src, FolderUpload(enabled=True,
target=str(tmp_path / "ziel")),
tmp_path / "out") is False
# ---------------- Punkt 3: kein stilles Überschreiben im Ziel ----------------
def test_upload_folder_does_not_overwrite_existing(tmp_path: Path) -> None:
"""Gleichnamige Datei im abweichenden target wurde bisher still ersetzt."""
src = tmp_path / "out" / "OCR_scan.pdf"
src.parent.mkdir()
src.write_bytes(b"%PDF-1.4 neu\n")
target = tmp_path / "ziel"
target.mkdir()
(target / "OCR_scan.pdf").write_bytes(b"%PDF-1.4 alt\n")
assert upload_folder(src, FolderUpload(enabled=True, target=str(target)),
tmp_path / "out") is True
assert (target / "OCR_scan.pdf").read_bytes() == b"%PDF-1.4 alt\n"
neu = list(target.glob("OCR_scan_*.pdf"))
assert len(neu) == 1
assert neu[0].read_bytes() == b"%PDF-1.4 neu\n"
def test_upload_folder_logs_warning_on_collision(tmp_path: Path, caplog) -> None:
import logging
src = tmp_path / "out" / "OCR_scan.pdf"
src.parent.mkdir()
src.write_bytes(b"%PDF-1.4 neu\n")
target = tmp_path / "ziel"
target.mkdir()
(target / "OCR_scan.pdf").write_bytes(b"%PDF-1.4 alt\n")
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.uploaders"):
upload_folder(src, FolderUpload(enabled=True, target=str(target)),
tmp_path / "out")
assert "OCR_scan.pdf" in caplog.text
assert "überschrieben" in caplog.text
def test_upload_folder_without_collision_logs_nothing(tmp_path: Path, caplog) -> None:
import logging
src = tmp_path / "out" / "OCR_scan.pdf"
src.parent.mkdir()
src.write_bytes(b"%PDF-1.4\n")
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.uploaders"):
upload_folder(src, FolderUpload(enabled=True, target=str(tmp_path / "ziel")),
tmp_path / "out")
assert "überschrieben" not in caplog.text
assert (tmp_path / "ziel" / "OCR_scan.pdf").exists()
def test_upload_folder_self_target_is_not_renamed(tmp_path: Path) -> None:
"""Default (leeres target -> outgoing/): der resolve()-Kurzschluss greift.
Ohne ihn würde die Datei hier gegen sich selbst kollidieren und eine
Zeitstempel-Kopie neben sich selbst erzeugen.
"""
out = tmp_path / "out"
out.mkdir()
src = out / "OCR_scan.pdf"
src.write_bytes(b"%PDF-1.4\n")
assert upload_folder(src, FolderUpload(enabled=True, target=""), out) is True
assert list(out.iterdir()) == [src]
+290
View File
@@ -0,0 +1,290 @@
"""Punkt 1: veraPDF-Binary wird geprüft — sonst vernichtet es die Originale.
Mit `[verapdf].enabled = true` und falschem Pfad lieferte `run_verapdf()` für
JEDE Datei False: OCR-Ergebnis nach error/, Original laut
`original_on_success = "delete"` gelöscht. Ein Tippfehler im Pfad vernichtete
so Scan für Scan die Vorlagen, während die Unit als "läuft" dastand.
Zwei Absicherungen:
1. Der Preflight lässt den Dienst gar nicht erst starten (Exit 2).
2. `run_verapdf()` unterscheidet "nicht konform" (False) von "nicht
aufrufbar" (`VeraPdfUnavailable`) — Letzteres entsorgt nichts.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
from pdf_ocr_hotfolder.config import OcrConfig, OutputConfig, VeraPdfConfig
from pdf_ocr_hotfolder.processor import (
VeraPdfUnavailable,
process_pdf,
resolve_verapdf_binary,
run_verapdf,
)
from pdf_ocr_hotfolder.service import (
HotfolderService,
PreflightError,
check_preflight,
check_verapdf_binary,
)
ORIGINAL = b"%PDF-1.4 original\n"
def _executable(tmp_path: Path, name: str = "verapdf") -> Path:
"""Legt eine echte, ausführbare Datei an (wird nie wirklich aufgerufen)."""
b = tmp_path / name
b.write_text("#!/bin/sh\nexit 0\n")
b.chmod(0o755)
return b
# ---------------- check_verapdf_binary ----------------
def test_disabled_verapdf_ignores_binary() -> None:
"""Solange veraPDF aus ist, darf ein unsinniger Pfad nichts blockieren."""
check_verapdf_binary(False, "/gibt/es/nicht/verapdf")
def test_existing_executable_passes(tmp_path: Path) -> None:
check_verapdf_binary(True, str(_executable(tmp_path)))
def test_missing_binary_raises(tmp_path: Path) -> None:
with pytest.raises(PreflightError) as exc:
check_verapdf_binary(True, str(tmp_path / "tippfehler"))
msg = str(exc.value)
assert "verapdf" in msg.lower()
assert "tippfehler" in msg
def test_non_executable_binary_raises(tmp_path: Path) -> None:
"""Vorhanden, aber ohne x-Bit: genauso tödlich wie gar nicht vorhanden."""
b = tmp_path / "verapdf"
b.write_text("#!/bin/sh\n")
b.chmod(0o644)
with pytest.raises(PreflightError, match="ausführbar"):
check_verapdf_binary(True, str(b))
def test_empty_binary_raises() -> None:
with pytest.raises(PreflightError, match=r"\[verapdf\].binary"):
check_verapdf_binary(True, "")
def test_resolve_finds_binary_in_path(tmp_path: Path, monkeypatch) -> None:
"""Ein nackter Name wird im PATH gesucht, nicht nur ein absoluter Pfad."""
_executable(tmp_path, "verapdf")
monkeypatch.setenv("PATH", str(tmp_path))
assert resolve_verapdf_binary("verapdf") == str(tmp_path / "verapdf")
def test_resolve_returns_none_for_empty() -> None:
assert resolve_verapdf_binary("") is None
# ---------------- check_preflight reicht die veraPDF-Prüfung durch ----------------
def test_check_preflight_checks_verapdf(tmp_path: Path) -> None:
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"):
with pytest.raises(PreflightError, match="verapdf"):
check_preflight(verapdf_enabled=True,
verapdf_binary=str(tmp_path / "weg"))
def test_check_preflight_ok_with_verapdf(tmp_path: Path) -> None:
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"):
check_preflight(verapdf_enabled=True,
verapdf_binary=str(_executable(tmp_path)))
def test_run_once_aborts_on_broken_verapdf(tmp_config, tmp_path: Path) -> None:
"""Der Dienst startet nicht — statt Datei für Datei Originale zu löschen."""
tmp_config.verapdf = VeraPdfConfig(enabled=True,
binary=str(tmp_path / "gibtsnicht"))
service = HotfolderService(tmp_config)
try:
with patch("pdf_ocr_hotfolder.service.shutil.which",
return_value="/usr/bin/fake"):
with pytest.raises(PreflightError, match="verapdf"):
service.run_once()
finally:
service._executor.shutdown(wait=False)
def test_check_config_returns_2_for_broken_verapdf(tmp_path, tmp_config,
monkeypatch, capsys) -> None:
"""--check-config meldet Exit 2 (der Updater wertet das aus)."""
from pdf_ocr_hotfolder.__main__ import CHECK_ERROR, 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}"
[verapdf]
enabled = true
binary = "{tmp_path / 'nicht-da'}"
""")
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config", str(cfg_file),
"--check-config"])
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"):
assert main() == CHECK_ERROR
assert "nicht-da" in capsys.readouterr().err
def test_check_config_ok_with_working_verapdf(tmp_path, tmp_config,
monkeypatch) -> None:
from pdf_ocr_hotfolder.__main__ import CHECK_OK, 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}"
[verapdf]
enabled = true
binary = "{_executable(tmp_path)}"
""")
monkeypatch.setattr(sys, "argv",
["pdf-ocr-hotfolder", "--config", str(cfg_file),
"--check-config"])
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"):
assert main() == CHECK_OK
# ---------------- run_verapdf: Urteil vs. Nicht-Aufrufbarkeit ----------------
def _completed(returncode: int, stdout: str = "", stderr: str = ""):
return subprocess.CompletedProcess([], returncode, stdout, stderr)
def test_run_verapdf_pass(tmp_path: Path) -> None:
cfg = VeraPdfConfig(enabled=True, binary=str(_executable(tmp_path)))
with patch("pdf_ocr_hotfolder.processor.subprocess.run",
return_value=_completed(0, "PASS /tmp/x.pdf\n")):
assert run_verapdf(tmp_path / "x.pdf", cfg) is True
def test_run_verapdf_fail_is_a_verdict(tmp_path: Path) -> None:
"""Echtes FAIL bleibt False — das ist ein inhaltliches Urteil."""
cfg = VeraPdfConfig(enabled=True, binary=str(_executable(tmp_path)))
with patch("pdf_ocr_hotfolder.processor.subprocess.run",
return_value=_completed(1, "FAIL /tmp/x.pdf\n")):
assert run_verapdf(tmp_path / "x.pdf", cfg) is False
def test_run_verapdf_missing_binary_raises(tmp_path: Path) -> None:
"""Fehlendes Programm ist KEIN FAIL mehr, sondern ein Fehler."""
cfg = VeraPdfConfig(enabled=True, binary=str(tmp_path / "weg"))
with pytest.raises(VeraPdfUnavailable, match="weg"):
run_verapdf(tmp_path / "x.pdf", cfg)
def test_run_verapdf_timeout_raises(tmp_path: Path) -> None:
cfg = VeraPdfConfig(enabled=True, binary=str(_executable(tmp_path)))
with patch("pdf_ocr_hotfolder.processor.subprocess.run",
side_effect=subprocess.TimeoutExpired("verapdf", 300)):
with pytest.raises(VeraPdfUnavailable, match="nicht geantwortet"):
run_verapdf(tmp_path / "x.pdf", cfg)
def test_run_verapdf_oserror_raises(tmp_path: Path) -> None:
cfg = VeraPdfConfig(enabled=True, binary=str(_executable(tmp_path)))
with patch("pdf_ocr_hotfolder.processor.subprocess.run",
side_effect=OSError("Exec format error")):
with pytest.raises(VeraPdfUnavailable, match="nicht startbar"):
run_verapdf(tmp_path / "x.pdf", cfg)
def test_run_verapdf_without_verdict_raises(tmp_path: Path) -> None:
"""Startet der Wrapper nicht durch (fehlendes Java), steht kein Urteil da.
Exit != 0 ohne PASS/FAIL in der Ausgabe darf nicht als "nicht konform"
durchgehen — genau so würde der Original-Löschpfad wieder aufgehen.
"""
cfg = VeraPdfConfig(enabled=True, binary=str(_executable(tmp_path)))
with patch("pdf_ocr_hotfolder.processor.subprocess.run",
return_value=_completed(127, "", "java: command not found")):
with pytest.raises(VeraPdfUnavailable, match="kein Urteil"):
run_verapdf(tmp_path / "x.pdf", cfg)
def test_run_verapdf_disabled_returns_true(tmp_path: Path) -> None:
assert run_verapdf(tmp_path / "x.pdf", VeraPdfConfig(enabled=False)) is True
# ---------------- process_pdf: nicht aufrufbares veraPDF entsorgt nichts ----------------
def _fake_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
dst.write_bytes(b"%PDF-1.4 OCRed\n")
def _prepare(tmp_path: Path) -> dict:
dirs = {name: tmp_path / name
for name in ("incoming", "working", "outgoing", "error", "archive")}
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}
def _run_unavailable(env: dict, out_cfg: OutputConfig):
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr), \
patch("pdf_ocr_hotfolder.processor.run_verapdf",
side_effect=VeraPdfUnavailable("Binary weg")):
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=True),
output_cfg=out_cfg,
)
def test_unavailable_verapdf_keeps_original_despite_delete(tmp_path: Path) -> None:
"""Der gefährliche Fall: original_on_success='delete' darf nicht greifen."""
env = _prepare(tmp_path)
result = _run_unavailable(env, OutputConfig(name_mode="prefix",
name_tag="OCR_",
original_on_success="delete"))
assert not result.success
# Original ist NICHT weg, sondern in error/ gesichert
gesichert = env["error"] / "scan.pdf"
assert gesichert.exists()
assert gesichert.read_bytes() == ORIGINAL
assert not (env["working"] / "scan.pdf").exists()
# Kein fertiges Ergebnis in outgoing/
assert list(env["outgoing"].iterdir()) == []
def test_unavailable_verapdf_result_is_not_a_fail_verdict(tmp_path: Path) -> None:
"""verapdf_passed bleibt None: es gab kein Urteil, nur einen Fehler."""
env = _prepare(tmp_path)
result = _run_unavailable(env, OutputConfig(original_on_success="delete"))
assert result.verapdf_passed is None
assert "veraPDF" in result.error
def test_unavailable_verapdf_also_keeps_ocr_result(tmp_path: Path) -> None:
env = _prepare(tmp_path)
_run_unavailable(env, OutputConfig(name_mode="prefix", name_tag="OCR_",
original_on_success="delete"))
assert (env["error"] / "__ocr_OCR_scan.pdf").exists()
assert list(env["working"].iterdir()) == []