578472872e
- [ocr].timeout wird als tesseract_timeout (pro Seite) an ocrmypdf durchgereicht; Default 1800 -> 300, 0 = ocrmypdf-Default - Exceptions nach dem OCR zaehlen als Fehler, Datei wird nach error/ gerettet - Fehlgeschlagene Uploads zaehlen als Fehler und loesen Fehler-Mail aus - name_mode wird im Preflight geprueft, nicht erst pro Datei - Fehlende [paths]-Sektion -> ConfigError mit klarer Meldung statt KeyError - Stabilitaets-Timeout zaehlt als Fehler (--once liefert Exit 1) - upload_folder nutzt shutil.copyfile statt read_bytes/write_bytes - OcrConfig.pdfa_level Default "2" -> "" (Ghostscript-Bug, Issue #3) - 35 neue Tests (92 gesamt), pytest.ini - AI_AGENT_BRIEFING.md auf Stand 0.4.0 gebracht Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
258 lines
9.2 KiB
Python
258 lines
9.2 KiB
Python
"""Tests für die Fehlerzählung im Service.
|
|
|
|
Deckt drei bisher stumme Fehlerpfade ab:
|
|
- Exception aus `process_pdf()` (z.B. fehlgeschlagener Move nach outgoing/)
|
|
- fehlgeschlagene Uploads
|
|
- Timeout im Stabilitäts-Check
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from pdf_ocr_hotfolder.processor import ProcessResult
|
|
from pdf_ocr_hotfolder.service import HotfolderService
|
|
|
|
|
|
def _run_once(tmp_config, **patches):
|
|
"""Führt run_once() mit gemocktem Preflight aus und gibt den Service zurück."""
|
|
stack = [
|
|
patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None),
|
|
patch("pdf_ocr_hotfolder.service._wait_until_stable",
|
|
return_value=patches.pop("stable", True)),
|
|
]
|
|
for target, kwargs in patches.items():
|
|
stack.append(patch(f"pdf_ocr_hotfolder.service.{target}", **kwargs))
|
|
|
|
service = HotfolderService(tmp_config)
|
|
try:
|
|
for p in stack:
|
|
p.start()
|
|
service.run_once()
|
|
finally:
|
|
for p in reversed(stack):
|
|
p.stop()
|
|
service._executor.shutdown(wait=False)
|
|
return service
|
|
|
|
|
|
# ---------------- Exception aus process_pdf ----------------
|
|
|
|
def test_exception_from_process_pdf_counts_as_error(tmp_config) -> None:
|
|
"""Eine Exception aus process_pdf() darf die Zählung nicht umgehen."""
|
|
(tmp_config.paths.incoming / "boom.pdf").write_bytes(b"%PDF-1.4\n")
|
|
|
|
def explode(src, **kwargs):
|
|
raise OSError("move to outgoing failed")
|
|
|
|
service = _run_once(tmp_config, process_pdf={"side_effect": explode})
|
|
|
|
assert service.error_count == 1
|
|
assert service.success_count == 0
|
|
|
|
|
|
def test_exception_moves_file_to_error_dir(tmp_config) -> None:
|
|
"""Die Datei landet nach einer Exception im error-Verzeichnis."""
|
|
(tmp_config.paths.incoming / "boom.pdf").write_bytes(b"%PDF-1.4\n")
|
|
|
|
def explode(src, **kwargs):
|
|
raise RuntimeError("kaputt")
|
|
|
|
_run_once(tmp_config, process_pdf={"side_effect": explode})
|
|
|
|
assert (tmp_config.paths.error / "boom.pdf").exists()
|
|
assert not (tmp_config.paths.incoming / "boom.pdf").exists()
|
|
|
|
|
|
def test_exception_after_move_to_working_rescues_from_working(tmp_config) -> None:
|
|
"""Realistischer Fall: process_pdf hat schon nach working/ verschoben."""
|
|
src = tmp_config.paths.incoming / "boom.pdf"
|
|
src.write_bytes(b"%PDF-1.4\n")
|
|
|
|
def explode(src: Path, working_dir: Path, **kwargs):
|
|
# process_pdf verschiebt zuerst nach working/, dann knallt der Move
|
|
# nach outgoing/
|
|
src.rename(working_dir / src.name)
|
|
raise OSError("move to outgoing failed")
|
|
|
|
service = _run_once(tmp_config, process_pdf={"side_effect": explode})
|
|
|
|
assert service.error_count == 1
|
|
assert (tmp_config.paths.error / "boom.pdf").exists()
|
|
assert not (tmp_config.paths.working / "boom.pdf").exists()
|
|
|
|
|
|
def test_exception_with_vanished_file_does_not_raise(tmp_config) -> None:
|
|
"""Ist die Datei nicht mehr auffindbar, wird nur geloggt — kein Crash."""
|
|
(tmp_config.paths.incoming / "boom.pdf").write_bytes(b"%PDF-1.4\n")
|
|
|
|
def explode(src: Path, **kwargs):
|
|
src.unlink(missing_ok=True)
|
|
raise RuntimeError("kaputt")
|
|
|
|
service = _run_once(tmp_config, process_pdf={"side_effect": explode})
|
|
|
|
assert service.error_count == 1
|
|
assert not (tmp_config.paths.error / "boom.pdf").exists()
|
|
|
|
|
|
def test_exception_triggers_error_notification(tmp_config) -> None:
|
|
"""Auch bei einer Exception geht eine Fehler-Mail raus (success=False)."""
|
|
(tmp_config.paths.incoming / "boom.pdf").write_bytes(b"%PDF-1.4\n")
|
|
|
|
def explode(src, **kwargs):
|
|
raise RuntimeError("kaputt")
|
|
|
|
with patch("pdf_ocr_hotfolder.service.notify_email") as mail:
|
|
_run_once(tmp_config, process_pdf={"side_effect": explode})
|
|
|
|
assert mail.call_count == 1
|
|
args = mail.call_args[0]
|
|
assert "FEHLER" in args[1]
|
|
assert args[3] is False # success-Flag
|
|
|
|
|
|
# ---------------- Upload-Fehler ----------------
|
|
|
|
def _fake_success(src: Path, working_dir, outgoing_dir, error_dir, **kwargs):
|
|
out = outgoing_dir / f"OCR_{src.name}"
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
out.write_bytes(b"%PDF-1.4 ocr\n")
|
|
src.unlink(missing_ok=True)
|
|
return ProcessResult(src, out, True)
|
|
|
|
|
|
def test_failed_upload_counts_as_error(tmp_config) -> None:
|
|
"""Ein fehlgeschlagener Upload zählt als Fehler, nicht als Erfolg."""
|
|
(tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n")
|
|
|
|
service = _run_once(
|
|
tmp_config,
|
|
process_pdf={"side_effect": _fake_success},
|
|
upload_nextcloud={"return_value": False},
|
|
)
|
|
|
|
assert service.error_count == 1
|
|
assert service.success_count == 0
|
|
|
|
|
|
def test_failed_upload_sends_error_mail_naming_targets(tmp_config) -> None:
|
|
"""Die Fehler-Mail nennt die fehlgeschlagenen Ziele."""
|
|
(tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n")
|
|
|
|
with patch("pdf_ocr_hotfolder.service.notify_email") as mail:
|
|
_run_once(
|
|
tmp_config,
|
|
process_pdf={"side_effect": _fake_success},
|
|
upload_nextcloud={"return_value": False},
|
|
upload_sftp={"return_value": False},
|
|
)
|
|
|
|
assert mail.call_count == 1
|
|
_cfg, subject, body, success = mail.call_args[0]
|
|
assert "FEHLER" in subject
|
|
assert success is False
|
|
assert "nextcloud" in body
|
|
assert "sftp" in body
|
|
|
|
|
|
def test_failed_upload_keeps_pdf_in_outgoing(tmp_config) -> None:
|
|
"""Das OCR war erfolgreich — die Datei bleibt in outgoing/, nicht error/."""
|
|
(tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n")
|
|
|
|
_run_once(
|
|
tmp_config,
|
|
process_pdf={"side_effect": _fake_success},
|
|
upload_folder={"return_value": False},
|
|
)
|
|
|
|
assert (tmp_config.paths.outgoing / "OCR_a.pdf").exists()
|
|
assert not (tmp_config.paths.error / "OCR_a.pdf").exists()
|
|
|
|
|
|
def test_successful_uploads_count_as_success(tmp_config) -> None:
|
|
"""Gegenprobe: wenn alle Uploads durchgehen, zählt es als Erfolg."""
|
|
(tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n")
|
|
|
|
service = _run_once(tmp_config, process_pdf={"side_effect": _fake_success})
|
|
|
|
assert service.success_count == 1
|
|
assert service.error_count == 0
|
|
|
|
|
|
def test_dispatch_uploads_reports_failed_targets(tmp_config) -> None:
|
|
"""_dispatch_uploads() liefert die Namen der fehlgeschlagenen Ziele."""
|
|
service = HotfolderService(tmp_config)
|
|
try:
|
|
pdf = tmp_config.paths.outgoing / "x.pdf"
|
|
pdf.write_bytes(b"%PDF-1.4\n")
|
|
with patch("pdf_ocr_hotfolder.service.upload_folder", return_value=True), \
|
|
patch("pdf_ocr_hotfolder.service.upload_nextcloud", return_value=False), \
|
|
patch("pdf_ocr_hotfolder.service.upload_sftp", return_value=True):
|
|
assert service._dispatch_uploads(pdf) == ["nextcloud"]
|
|
|
|
with patch("pdf_ocr_hotfolder.service.upload_folder", return_value=True), \
|
|
patch("pdf_ocr_hotfolder.service.upload_nextcloud", return_value=True), \
|
|
patch("pdf_ocr_hotfolder.service.upload_sftp", return_value=True):
|
|
assert service._dispatch_uploads(pdf) == []
|
|
finally:
|
|
service._executor.shutdown(wait=False)
|
|
|
|
|
|
# ---------------- Stabilitäts-Timeout ----------------
|
|
|
|
def test_unstable_file_counts_as_error(tmp_config) -> None:
|
|
"""Stabilisiert sich eine Datei nicht, ist das ein Fehler (Exit 1)."""
|
|
(tmp_config.paths.incoming / "slow.pdf").write_bytes(b"%PDF-1.4\n")
|
|
|
|
with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \
|
|
patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=False), \
|
|
patch("pdf_ocr_hotfolder.service.process_pdf") as proc:
|
|
service = HotfolderService(tmp_config)
|
|
try:
|
|
errors = service.run_once()
|
|
finally:
|
|
service._executor.shutdown(wait=False)
|
|
|
|
assert errors == 1
|
|
assert service.error_count == 1
|
|
proc.assert_not_called()
|
|
|
|
|
|
def test_unstable_file_stays_in_incoming(tmp_config) -> None:
|
|
"""Die instabile Datei bleibt bewusst in incoming/ liegen."""
|
|
(tmp_config.paths.incoming / "slow.pdf").write_bytes(b"%PDF-1.4\n")
|
|
|
|
with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \
|
|
patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=False):
|
|
service = HotfolderService(tmp_config)
|
|
try:
|
|
service.run_once()
|
|
finally:
|
|
service._executor.shutdown(wait=False)
|
|
|
|
assert (tmp_config.paths.incoming / "slow.pdf").exists()
|
|
assert not (tmp_config.paths.error / "slow.pdf").exists()
|
|
|
|
|
|
def test_vanished_file_is_not_an_error(tmp_config) -> None:
|
|
"""Verschwundene Datei ist kein Fehler — _wait_until_stable liefert dafür
|
|
ebenfalls False."""
|
|
pdf = tmp_config.paths.incoming / "weg.pdf"
|
|
pdf.write_bytes(b"%PDF-1.4\n")
|
|
|
|
def vanish(path: Path, **kwargs) -> bool:
|
|
path.unlink(missing_ok=True)
|
|
return False
|
|
|
|
with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \
|
|
patch("pdf_ocr_hotfolder.service._wait_until_stable", side_effect=vanish):
|
|
service = HotfolderService(tmp_config)
|
|
try:
|
|
errors = service.run_once()
|
|
finally:
|
|
service._executor.shutdown(wait=False)
|
|
|
|
assert errors == 0
|
|
assert service.error_count == 0
|