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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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 == ""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
|
||||
@@ -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()) == []
|
||||
Reference in New Issue
Block a user