Files
pdf-ocr-hotfolder/tests/test_outgoing_collision.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

186 lines
6.7 KiB
Python

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