Files
pdf-ocr-hotfolder/tests/test_dispose_failure.py
techadmin cd803a3dfe 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>
2026-09-23 00:59:17 +02:00

212 lines
7.8 KiB
Python

"""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