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,165 @@
|
||||
"""Tests für `--check-config`.
|
||||
|
||||
Die Exit-Codes werden vom Updater ausgewertet und müssen verlässlich sein:
|
||||
0 = sauber, 1 = nur Warnungen, 2 = Fehler.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pdf_ocr_hotfolder.__main__ import CHECK_ERROR, CHECK_OK, CHECK_WARN, main
|
||||
|
||||
|
||||
def _cfg_file(tmp_path: Path, tmp_config, extra: str = "") -> Path:
|
||||
cfg_file = tmp_path / "cfg.toml"
|
||||
cfg_file.write_text(f"""
|
||||
[paths]
|
||||
incoming = "{tmp_config.paths.incoming}"
|
||||
outgoing = "{tmp_config.paths.outgoing}"
|
||||
working = "{tmp_config.paths.working}"
|
||||
error = "{tmp_config.paths.error}"
|
||||
""" + extra)
|
||||
return cfg_file
|
||||
|
||||
|
||||
def _check(monkeypatch, cfg_file: Path, binaries_present: bool = True) -> int:
|
||||
monkeypatch.setattr(sys, "argv",
|
||||
["pdf-ocr-hotfolder", "--config", str(cfg_file),
|
||||
"--check-config"])
|
||||
which = "/usr/bin/fake" if binaries_present else None
|
||||
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value=which):
|
||||
return main()
|
||||
|
||||
|
||||
# ---------------- Exit 0 ----------------
|
||||
|
||||
def test_clean_config_returns_0(tmp_path, tmp_config, monkeypatch, capsys) -> None:
|
||||
rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config))
|
||||
assert rc == CHECK_OK
|
||||
out = capsys.readouterr().out
|
||||
assert "Config sauber" in out
|
||||
|
||||
|
||||
def test_check_does_not_process_files(tmp_path, tmp_config, monkeypatch) -> None:
|
||||
"""Der Check darf nichts verarbeiten und nichts verschieben."""
|
||||
pdf = tmp_config.paths.incoming / "scan.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
assert _check(monkeypatch, _cfg_file(tmp_path, tmp_config)) == CHECK_OK
|
||||
assert pdf.exists()
|
||||
assert list(tmp_config.paths.outgoing.iterdir()) == []
|
||||
|
||||
|
||||
# ---------------- Exit 1 (nur Warnungen) ----------------
|
||||
|
||||
def test_legacy_timeout_returns_1(tmp_path, tmp_config, monkeypatch, capsys) -> None:
|
||||
rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config,
|
||||
"\n[ocr]\ntimeout = 1800\n"))
|
||||
assert rc == CHECK_WARN
|
||||
out = capsys.readouterr().out
|
||||
assert "WARNUNG" in out
|
||||
assert "PRO SEITE" in out
|
||||
|
||||
|
||||
def test_unknown_key_returns_1(tmp_path, tmp_config, monkeypatch, capsys) -> None:
|
||||
rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config,
|
||||
'\n[ocr]\nlangauges = "deu"\n'))
|
||||
assert rc == CHECK_WARN
|
||||
assert "[ocr].langauges" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_pdfa_level_with_healthy_ghostscript_returns_1(
|
||||
tmp_path, tmp_config, monkeypatch, capsys) -> None:
|
||||
"""Gesundes Ghostscript: nur Hinweis (Exit 1), kein Abbruch."""
|
||||
monkeypatch.setattr(sys, "argv",
|
||||
["pdf-ocr-hotfolder", "--config",
|
||||
str(_cfg_file(tmp_path, tmp_config,
|
||||
'\n[ocr]\npdfa_level = "2"\n')),
|
||||
"--check-config"])
|
||||
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \
|
||||
patch("pdf_ocr_hotfolder.service.detect_ghostscript_version",
|
||||
return_value="10.02.1"):
|
||||
rc = main()
|
||||
assert rc == CHECK_WARN
|
||||
assert "Ghostscript" in capsys.readouterr().out
|
||||
|
||||
|
||||
# ---------------- Exit 2 (Fehler) ----------------
|
||||
|
||||
def test_missing_config_file_returns_2(tmp_path, monkeypatch, capsys) -> None:
|
||||
monkeypatch.setattr(sys, "argv",
|
||||
["pdf-ocr-hotfolder", "--config",
|
||||
str(tmp_path / "gibtsnicht.toml"), "--check-config"])
|
||||
assert main() == CHECK_ERROR
|
||||
assert "nicht gefunden" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_broken_paths_section_returns_2(tmp_path, monkeypatch, capsys) -> None:
|
||||
cfg_file = tmp_path / "cfg.toml"
|
||||
cfg_file.write_text('[ocr]\nlanguages = "deu"\n')
|
||||
assert _check(monkeypatch, cfg_file) == CHECK_ERROR
|
||||
assert "[paths]" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_invalid_toml_returns_2(tmp_path, monkeypatch, capsys) -> None:
|
||||
"""Kaputtes TOML: saubere Meldung statt Traceback."""
|
||||
cfg_file = tmp_path / "cfg.toml"
|
||||
cfg_file.write_text("[paths\nincoming = ")
|
||||
assert _check(monkeypatch, cfg_file) == CHECK_ERROR
|
||||
assert "TOML" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_missing_binaries_return_2(tmp_path, tmp_config, monkeypatch, capsys) -> None:
|
||||
rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config),
|
||||
binaries_present=False)
|
||||
assert rc == CHECK_ERROR
|
||||
err = capsys.readouterr().err
|
||||
assert "tesseract" in err
|
||||
|
||||
|
||||
def test_invalid_name_mode_returns_2(tmp_path, tmp_config, monkeypatch, capsys) -> None:
|
||||
rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config,
|
||||
'\n[output]\nname_mode = "praefix"\n'))
|
||||
assert rc == CHECK_ERROR
|
||||
assert "name_mode" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_archive_without_dir_returns_2(tmp_path, tmp_config, monkeypatch, capsys) -> None:
|
||||
rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config,
|
||||
'\n[output]\noriginal_on_success = "archive"\n'))
|
||||
assert rc == CHECK_ERROR
|
||||
assert "archive_dir" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_error_beats_warning(tmp_path, tmp_config, monkeypatch) -> None:
|
||||
"""Fehler + Warnung → Exit 2, nicht 1."""
|
||||
rc = _check(monkeypatch,
|
||||
_cfg_file(tmp_path, tmp_config,
|
||||
'\n[ocr]\ntimeout = 1800\n\n[output]\nname_mode = "x"\n'))
|
||||
assert rc == CHECK_ERROR
|
||||
|
||||
|
||||
# ---------------- Zusammenspiel mit anderen Optionen ----------------
|
||||
|
||||
def test_check_config_wins_over_once(tmp_path, tmp_config, monkeypatch) -> None:
|
||||
"""--check-config hat Vorrang: es wird nichts verarbeitet."""
|
||||
pdf = tmp_config.paths.incoming / "scan.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4\n")
|
||||
monkeypatch.setattr(sys, "argv",
|
||||
["pdf-ocr-hotfolder", "--config",
|
||||
str(_cfg_file(tmp_path, tmp_config)),
|
||||
"--once", "--check-config"])
|
||||
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"):
|
||||
assert main() == CHECK_OK
|
||||
assert pdf.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code,expected", [(CHECK_OK, 0), (CHECK_WARN, 1),
|
||||
(CHECK_ERROR, 2)])
|
||||
def test_exit_code_constants(code: int, expected: int) -> None:
|
||||
"""Die Konstanten sind Teil der Schnittstelle zum Updater."""
|
||||
assert code == expected
|
||||
Reference in New Issue
Block a user