3e24aa2ecd
Datenverlust behoben: - Nach hartem Stopp blieb das Original in working/ liegen und wurde nie wieder angefasst (_scan_existing sah nur incoming/). Es wird jetzt beim Start an Ort und Stelle wieder aufgegriffen, mit Kollisionsschutz gegen gleichnamige neue Scans; angefangene __ocr_-Fragmente werden geloescht. - TimeoutStopSec 30 -> 300, damit laufendes OCR zu Ende laufen darf. Config-Drift sichtbar gemacht: - Neues --check-config (Exit 0 sauber / 1 Warnungen / 2 Fehler), das update.sh vor dem Neustart ueber alle Instanz-Configs laufen laesst. - Warnungen fuer [ocr].timeout >= 900 (seit 0.4.0 pro SEITE) und gesetztes pdfa_level, beim Dienststart wie im Check. - Unbekannte Config-Keys werden nicht mehr still verworfen, sondern genannt. Updater feldtauglich: - venv-Health-Check erkennt toten Symlink UND Versions-Drift gegen das System-Python; --rebuild-venv als ausdruecklicher Weg nach einem Debian- Major-Upgrade. Neubau ist ganz-oder-gar-nicht mit Rollback. - apt-Pakete werden auch beim Update synchronisiert (Quelle: install.sh). - Instanz-Erfassung inkl. activating/failed, Verifikation prueft is-failed und NRestarts statt sleep 1 + is-active. - Backup enthaelt Configs, Unit, Drop-ins und pip-freeze.txt, liegt auf 0600 und rotiert auf 5; schlaegt es fehl, bricht das Update vorher ab. - ERR-Trap faehrt die vorher laufenden Instanzen wieder hoch. - lxc-compat.conf wird beim Update nachgezogen. - requirements.txt gepinnt (ocrmypdf 16.13.0, geprueft fuer Python 3.11+3.13). Doku in Installation / Update / OS-Upgrade aufgeteilt (docs/). 135 Tests gruen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
205 lines
7.6 KiB
Python
205 lines
7.6 KiB
Python
"""Tests für die Wiederaufnahme abgebrochener Läufe aus working/.
|
|
|
|
Hintergrund: `process_pdf()` verschiebt das Original vor dem OCR nach
|
|
working/. Wird der Dienst dort hart gestoppt (SIGKILL nach TimeoutStopSec),
|
|
blieb die Datei bisher für immer liegen — weder outgoing/, noch error/, noch
|
|
eine Mail. ocrmypdf läuft in diesen Tests nie wirklich.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from pdf_ocr_hotfolder.config import OcrConfig, OutputConfig, VeraPdfConfig
|
|
from pdf_ocr_hotfolder.processor import OCR_TEMP_PREFIX, ProcessResult, process_pdf
|
|
from pdf_ocr_hotfolder.service import HotfolderService
|
|
|
|
|
|
def _fake_success(src: Path, working_dir, outgoing_dir, error_dir, **kwargs):
|
|
"""Simuliert einen erfolgreichen Durchlauf inkl. Entsorgung des Originals."""
|
|
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 _run_once(tmp_config, fake_process=_fake_success):
|
|
"""run_once() mit gemocktem Preflight und gemocktem process_pdf."""
|
|
seen: list[Path] = []
|
|
|
|
def spy(src, *args, **kwargs):
|
|
seen.append(src)
|
|
return fake_process(src, *args, **kwargs)
|
|
|
|
with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \
|
|
patch("pdf_ocr_hotfolder.service.process_pdf", side_effect=spy), \
|
|
patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=True):
|
|
service = HotfolderService(tmp_config)
|
|
try:
|
|
service.run_once()
|
|
finally:
|
|
service._executor.shutdown(wait=False)
|
|
return service, seen
|
|
|
|
|
|
# ---------------- Aufgreifen aus working/ ----------------
|
|
|
|
def test_leftover_in_working_is_picked_up(tmp_config) -> None:
|
|
"""Eine in working/ liegen gebliebene PDF wird wieder verarbeitet."""
|
|
leftover = tmp_config.paths.working / "abgebrochen.pdf"
|
|
leftover.write_bytes(b"%PDF-1.4\n")
|
|
|
|
service, seen = _run_once(tmp_config)
|
|
|
|
assert [p.name for p in seen] == ["abgebrochen.pdf"]
|
|
assert seen[0].parent == tmp_config.paths.working
|
|
assert service.success_count == 1
|
|
assert not leftover.exists()
|
|
assert (tmp_config.paths.outgoing / "OCR_abgebrochen.pdf").exists()
|
|
|
|
|
|
def test_resume_logs_warning(tmp_config, caplog) -> None:
|
|
"""Die Wiederaufnahme muss deutlich im Log stehen."""
|
|
(tmp_config.paths.working / "abgebrochen.pdf").write_bytes(b"%PDF-1.4\n")
|
|
|
|
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.service"):
|
|
_run_once(tmp_config)
|
|
|
|
text = caplog.text
|
|
assert "Abgebrochener Lauf wird fortgesetzt" in text
|
|
assert "abgebrochen.pdf" in text
|
|
|
|
|
|
def test_ocr_fragment_is_removed_and_not_processed(tmp_config, caplog) -> None:
|
|
"""__ocr_-Fragmente sind unbrauchbar: löschen, nicht als Eingabe nehmen."""
|
|
leftover = tmp_config.paths.working / "scan.pdf"
|
|
leftover.write_bytes(b"%PDF-1.4\n")
|
|
fragment = tmp_config.paths.working / f"{OCR_TEMP_PREFIX}OCR_scan.pdf"
|
|
fragment.write_bytes(b"%PDF-1.4 halbfertig\n")
|
|
|
|
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.service"):
|
|
service, seen = _run_once(tmp_config)
|
|
|
|
assert [p.name for p in seen] == ["scan.pdf"]
|
|
assert not fragment.exists()
|
|
assert service.error_count == 0
|
|
assert "Fragment" in caplog.text
|
|
|
|
|
|
def test_non_pdf_in_working_is_ignored(tmp_config) -> None:
|
|
"""Fremddateien in working/ werden nicht angefasst."""
|
|
junk = tmp_config.paths.working / "notizen.txt"
|
|
junk.write_text("kein PDF")
|
|
|
|
_service, seen = _run_once(tmp_config)
|
|
|
|
assert seen == []
|
|
assert junk.exists()
|
|
|
|
|
|
def test_incoming_and_working_both_scanned(tmp_config) -> None:
|
|
"""incoming/ wird weiterhin gescannt — zusätzlich zu working/."""
|
|
(tmp_config.paths.incoming / "neu.pdf").write_bytes(b"%PDF-1.4\n")
|
|
(tmp_config.paths.working / "alt.pdf").write_bytes(b"%PDF-1.4\n")
|
|
|
|
service, seen = _run_once(tmp_config)
|
|
|
|
assert sorted(p.name for p in seen) == ["alt.pdf", "neu.pdf"]
|
|
assert service.success_count == 2
|
|
|
|
|
|
def test_name_collision_between_working_and_incoming(tmp_config, caplog) -> None:
|
|
"""Gleicher Name in beiden Ordnern: die working-Datei wird umbenannt.
|
|
|
|
Sonst würden sich beide dieselbe working- und dieselbe outgoing-Datei
|
|
teilen und eine der beiden ginge verloren.
|
|
"""
|
|
(tmp_config.paths.incoming / "scan.pdf").write_bytes(b"%PDF-1.4 neu\n")
|
|
(tmp_config.paths.working / "scan.pdf").write_bytes(b"%PDF-1.4 alt\n")
|
|
|
|
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.service"):
|
|
service, seen = _run_once(tmp_config)
|
|
|
|
names = sorted(p.name for p in seen)
|
|
assert len(names) == 2
|
|
assert "scan.pdf" in names
|
|
# Die wiederaufgenommene Datei hat einen Zeitstempel bekommen
|
|
renamed = [n for n in names if n != "scan.pdf"][0]
|
|
assert renamed.startswith("scan_") and renamed.endswith(".pdf")
|
|
assert service.success_count == 2
|
|
assert "umbenannt" in caplog.text
|
|
|
|
|
|
# ---------------- process_pdf: kein zweiter Move ----------------
|
|
|
|
def _ocr_ok(src: Path, dst: Path, cfg) -> None:
|
|
dst.write_bytes(b"%PDF-1.4 ocr\n")
|
|
|
|
|
|
def test_process_pdf_resumes_without_second_move(tmp_config) -> None:
|
|
"""Eine Datei aus working/ darf nicht erneut nach working/ verschoben werden."""
|
|
src = tmp_config.paths.working / "scan.pdf"
|
|
src.write_bytes(b"%PDF-1.4\n")
|
|
|
|
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_ocr_ok):
|
|
result = process_pdf(
|
|
src=src,
|
|
working_dir=tmp_config.paths.working,
|
|
outgoing_dir=tmp_config.paths.outgoing,
|
|
error_dir=tmp_config.paths.error,
|
|
ocr_cfg=OcrConfig(),
|
|
vera_cfg=VeraPdfConfig(enabled=False),
|
|
output_cfg=OutputConfig(),
|
|
)
|
|
|
|
assert result.success
|
|
assert (tmp_config.paths.outgoing / "OCR_scan.pdf").exists()
|
|
# Original entsorgt, keine Reste in working/
|
|
assert list(tmp_config.paths.working.iterdir()) == []
|
|
|
|
|
|
def test_process_pdf_resume_logs_warning(tmp_config, caplog) -> None:
|
|
src = tmp_config.paths.working / "scan.pdf"
|
|
src.write_bytes(b"%PDF-1.4\n")
|
|
|
|
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.processor"), \
|
|
patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_ocr_ok):
|
|
process_pdf(
|
|
src=src,
|
|
working_dir=tmp_config.paths.working,
|
|
outgoing_dir=tmp_config.paths.outgoing,
|
|
error_dir=tmp_config.paths.error,
|
|
ocr_cfg=OcrConfig(),
|
|
vera_cfg=VeraPdfConfig(enabled=False),
|
|
output_cfg=OutputConfig(),
|
|
)
|
|
|
|
assert "Wiederaufnahme" in caplog.text
|
|
|
|
|
|
def test_process_pdf_refuses_to_overwrite_working_file(tmp_config) -> None:
|
|
"""Belegter Name in working/: lieber Fehler als stilles Überschreiben."""
|
|
busy = tmp_config.paths.working / "scan.pdf"
|
|
busy.write_bytes(b"%PDF-1.4 laeuft gerade\n")
|
|
src = tmp_config.paths.incoming / "scan.pdf"
|
|
src.write_bytes(b"%PDF-1.4 neu\n")
|
|
|
|
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_ocr_ok):
|
|
result = process_pdf(
|
|
src=src,
|
|
working_dir=tmp_config.paths.working,
|
|
outgoing_dir=tmp_config.paths.outgoing,
|
|
error_dir=tmp_config.paths.error,
|
|
ocr_cfg=OcrConfig(),
|
|
vera_cfg=VeraPdfConfig(enabled=False),
|
|
output_cfg=OutputConfig(),
|
|
)
|
|
|
|
assert not result.success
|
|
assert "scan.pdf" in result.error
|
|
# Beide Dateien unangetastet
|
|
assert busy.read_bytes() == b"%PDF-1.4 laeuft gerade\n"
|
|
assert src.read_bytes() == b"%PDF-1.4 neu\n"
|