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>
186 lines
6.7 KiB
Python
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
|