cd803a3dfe
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>
212 lines
7.8 KiB
Python
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
|