fix: Preflight-Check und Exit-Code in --once Modus (v0.2.1)
- #1: check_preflight() prüft beim Start tesseract + gs, wirft PreflightError. CLI endet mit Exit 2 statt grün zu bleiben. - #2: run_once() gibt Anzahl fehlgeschlagener PDFs zurück, CLI endet mit Exit 1 wenn mindestens eine Datei scheiterte. - pytest-Suite mit 11 Tests für beide Szenarien - ocrmypdf-Import lazy in processor.py (Tests ohne ocrmypdf möglich) Closes #1, #2 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
"""Gemeinsame pytest-Fixtures."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pdf_ocr_hotfolder.config import (
|
||||
Config,
|
||||
EmailNotify,
|
||||
FolderUpload,
|
||||
NextcloudUpload,
|
||||
OcrConfig,
|
||||
Paths,
|
||||
SftpUpload,
|
||||
VeraPdfConfig,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_config(tmp_path: Path) -> Config:
|
||||
"""Minimal-Config mit tmp_path-Verzeichnissen, alle Uploads deaktiviert."""
|
||||
paths = Paths(
|
||||
incoming=tmp_path / "incoming",
|
||||
outgoing=tmp_path / "outgoing",
|
||||
working=tmp_path / "working",
|
||||
error=tmp_path / "error",
|
||||
)
|
||||
for p in (paths.incoming, paths.outgoing, paths.working, paths.error):
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return Config(
|
||||
paths=paths,
|
||||
ocr=OcrConfig(max_workers=1),
|
||||
verapdf=VeraPdfConfig(enabled=False),
|
||||
folder=FolderUpload(enabled=False),
|
||||
nextcloud=NextcloudUpload(enabled=False),
|
||||
sftp=SftpUpload(enabled=False),
|
||||
email=EmailNotify(enabled=False),
|
||||
log_level="DEBUG",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dummy_pdf(tmp_config: Config) -> Path:
|
||||
"""Legt eine Datei mit .pdf-Extension im incoming-Ordner ab.
|
||||
|
||||
Achtung: kein echtes PDF. Für Tests wird `process_pdf` gemockt.
|
||||
"""
|
||||
pdf = tmp_config.paths.incoming / "test.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4 fake\n")
|
||||
return pdf
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Tests für Issue #2: --once Modus muss Exit-Code != 0 bei Fehlern liefern."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from pdf_ocr_hotfolder.processor import ProcessResult
|
||||
from pdf_ocr_hotfolder.service import HotfolderService
|
||||
|
||||
|
||||
def _fake_success(src: Path, working_dir, outgoing_dir, error_dir, ocr_cfg, vera_cfg):
|
||||
out = outgoing_dir / f"OCR_{src.name}"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_bytes(b"%PDF-1.4 ocr\n")
|
||||
src.unlink(missing_ok=True)
|
||||
return ProcessResult(src, out, True)
|
||||
|
||||
|
||||
def _fake_failure(src: Path, working_dir, outgoing_dir, error_dir, ocr_cfg, vera_cfg):
|
||||
error_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = error_dir / src.name
|
||||
src.rename(dest)
|
||||
return ProcessResult(src, outgoing_dir / f"OCR_{src.name}", False,
|
||||
error="fake ocr failure")
|
||||
|
||||
|
||||
def _run(tmp_config, fake_process):
|
||||
"""Helper: führt run_once() mit gemocktem process_pdf und preflight aus."""
|
||||
with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \
|
||||
patch("pdf_ocr_hotfolder.service.process_pdf", side_effect=fake_process), \
|
||||
patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=True):
|
||||
service = HotfolderService(tmp_config)
|
||||
try:
|
||||
return service.run_once()
|
||||
finally:
|
||||
service._executor.shutdown(wait=False)
|
||||
|
||||
|
||||
def test_once_exit_0_when_no_files(tmp_config) -> None:
|
||||
"""Szenario: Keine PDFs vorhanden → Exit 0."""
|
||||
errors = _run(tmp_config, _fake_success)
|
||||
assert errors == 0
|
||||
|
||||
|
||||
def test_once_exit_0_when_all_success(tmp_config) -> None:
|
||||
"""Szenario: Alle PDFs erfolgreich → Exit 0."""
|
||||
(tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
(tmp_config.paths.incoming / "b.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
errors = _run(tmp_config, _fake_success)
|
||||
assert errors == 0
|
||||
|
||||
|
||||
def test_once_exit_nonzero_when_all_fail(tmp_config) -> None:
|
||||
"""Szenario: Alle PDFs fehlgeschlagen → Exit != 0 (Issue #2)."""
|
||||
(tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
(tmp_config.paths.incoming / "b.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
errors = _run(tmp_config, _fake_failure)
|
||||
assert errors == 2
|
||||
|
||||
|
||||
def test_once_exit_nonzero_when_some_fail(tmp_config) -> None:
|
||||
"""Szenario: Teilweise fehlgeschlagen → Exit != 0."""
|
||||
(tmp_config.paths.incoming / "ok.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
(tmp_config.paths.incoming / "bad.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
def mixed(src, *args, **kwargs):
|
||||
if "bad" in src.name:
|
||||
return _fake_failure(src, *args, **kwargs)
|
||||
return _fake_success(src, *args, **kwargs)
|
||||
|
||||
errors = _run(tmp_config, mixed)
|
||||
assert errors == 1
|
||||
|
||||
|
||||
def test_counters_track_success_and_failure(tmp_config) -> None:
|
||||
"""success_count und error_count sollen korrekt mitzählen."""
|
||||
(tmp_config.paths.incoming / "ok.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
(tmp_config.paths.incoming / "bad.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
def mixed(src, *args, **kwargs):
|
||||
if "bad" in src.name:
|
||||
return _fake_failure(src, *args, **kwargs)
|
||||
return _fake_success(src, *args, **kwargs)
|
||||
|
||||
with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \
|
||||
patch("pdf_ocr_hotfolder.service.process_pdf", side_effect=mixed), \
|
||||
patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=True):
|
||||
service = HotfolderService(tmp_config)
|
||||
try:
|
||||
service.run_once()
|
||||
assert service.success_count == 1
|
||||
assert service.error_count == 1
|
||||
finally:
|
||||
service._executor.shutdown(wait=False)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tests für Issue #1: Preflight-Check bei fehlendem Tesseract."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pdf_ocr_hotfolder.service import (
|
||||
HotfolderService,
|
||||
PreflightError,
|
||||
check_preflight,
|
||||
)
|
||||
|
||||
|
||||
def test_preflight_passes_when_all_binaries_present() -> None:
|
||||
"""Wenn tesseract + gs im PATH sind, darf kein Fehler fliegen."""
|
||||
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"):
|
||||
check_preflight() # darf nicht werfen
|
||||
|
||||
|
||||
def test_preflight_fails_when_tesseract_missing() -> None:
|
||||
"""Fehlendes tesseract → PreflightError mit passender Meldung."""
|
||||
def fake_which(name: str) -> str | None:
|
||||
return None if name == "tesseract" else "/usr/bin/fake"
|
||||
|
||||
with patch("pdf_ocr_hotfolder.service.shutil.which", side_effect=fake_which):
|
||||
with pytest.raises(PreflightError, match="tesseract"):
|
||||
check_preflight()
|
||||
|
||||
|
||||
def test_preflight_fails_when_ghostscript_missing() -> None:
|
||||
def fake_which(name: str) -> str | None:
|
||||
return None if name == "gs" else "/usr/bin/fake"
|
||||
|
||||
with patch("pdf_ocr_hotfolder.service.shutil.which", side_effect=fake_which):
|
||||
with pytest.raises(PreflightError, match="gs"):
|
||||
check_preflight()
|
||||
|
||||
|
||||
def test_preflight_lists_all_missing_binaries() -> None:
|
||||
"""Bei mehreren fehlenden Binaries werden alle genannt."""
|
||||
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value=None):
|
||||
with pytest.raises(PreflightError) as exc_info:
|
||||
check_preflight()
|
||||
msg = str(exc_info.value)
|
||||
assert "tesseract" in msg
|
||||
assert "gs" in msg
|
||||
|
||||
|
||||
def test_run_once_raises_preflight_error(tmp_config) -> None:
|
||||
"""HotfolderService.run_once() wirft PreflightError, wenn tesseract fehlt."""
|
||||
service = HotfolderService(tmp_config)
|
||||
try:
|
||||
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value=None):
|
||||
with pytest.raises(PreflightError):
|
||||
service.run_once()
|
||||
finally:
|
||||
service._executor.shutdown(wait=False)
|
||||
|
||||
|
||||
def test_main_returns_2_on_preflight_error(tmp_config, tmp_path, monkeypatch) -> None:
|
||||
"""CLI liefert Exit-Code 2 bei Preflight-Fehler (Issue #1 Szenario)."""
|
||||
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), "--once"])
|
||||
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value=None):
|
||||
from pdf_ocr_hotfolder.__main__ import main
|
||||
assert main() == 2
|
||||
Reference in New Issue
Block a user