feat: Wiederaufnahme aus working/, --check-config, feldtauglicher Updater (v0.6.0)
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>
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user