"""Tests für Issue #3: Ghostscript 10.0.0–10.02.0 Bug-Erkennung. Seit v0.6.1 bildet der Preflight die reale ocrmypdf-Bedingung ab: betroffene GS-Version UND skip_text UND (pdfa_level ODER ocrmypdf < 17) Der letzte Teil ist der Fall, der in v0.6.0 durchrutschte: mit ocrmypdf 16.x greift die Ghostscript-Prüfung auch ohne PDF/A, und der Dienst meldete trotzdem "Preflight ok", während jede einzelne PDF in error/ landete. """ from __future__ import annotations from contextlib import contextmanager from unittest.mock import patch import pytest from pdf_ocr_hotfolder.service import ( PreflightError, check_preflight, is_ghostscript_broken, ocrmypdf_checks_gs_always, ) @contextmanager def _env(gs_version: str, ocrmypdf_version: str): """Binaries vorhanden, Ghostscript- und ocrmypdf-Version vorgegeben.""" with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \ patch("pdf_ocr_hotfolder.service.detect_ghostscript_version", return_value=gs_version), \ patch("pdf_ocr_hotfolder.service.detect_ocrmypdf_version", return_value=ocrmypdf_version): yield @pytest.mark.parametrize("version,expected", [ # Betroffene Versionen ("10.0.0", True), ("10.00.0", True), ("10.01.0", True), ("10.01.1", True), ("10.01.2", True), ("10.02.0", True), # Sichere Versionen ("10.02.1", False), ("10.03.0", False), ("10.04.0", False), ("11.0.0", False), ("9.56.1", False), # Debian 11 / Ubuntu 22.04 ("9.55.0", False), # Edge cases ("", False), (None, False), ("garbage", False), ]) def test_is_ghostscript_broken(version, expected) -> None: assert is_ghostscript_broken(version) is expected @pytest.mark.parametrize("version,expected", [ ("16.13.0", True), # der Pin aus v0.6.0, der den Ausfall ausgeloest hat ("16.0.0", True), ("15.4.4", True), ("17.0.0", False), # ab hier steckt die Pruefung hinter output_type ("17.4.1", False), # unser Pin ("17.12.1", False), ("18.0.0", False), (None, False), # unbekannt -> kein Fehlalarm ("", False), ("garbage", False), ]) def test_ocrmypdf_checks_gs_always(version, expected) -> None: assert ocrmypdf_checks_gs_always(version) is expected # ---------------- Preflight: ocrmypdf 17.x (unser Pin) ---------------- def test_broken_gs_skip_text_without_pdfa_passes_on_ocrmypdf_17() -> None: """Der Debian-12-Standardfall: ohne PDF/A fasst ocrmypdf 17 gs nicht an. Das ist die Default-Config (skip_text=true, pdfa_level="") auf Debian 12 — sie muss laufen, sonst startet keine einzige Bestandsinstanz mehr. """ with _env("10.0.0", "17.4.1"): check_preflight(pdfa_level="", skip_text=True) # darf nicht werfen def test_broken_gs_with_pdfa_and_skip_text_fails() -> None: """Mit pdfa_level + skip_text + kaputtem GS → PreflightError.""" with _env("10.0.0", "17.4.1"): with pytest.raises(PreflightError, match="Ghostscript 10.0.0"): check_preflight(pdfa_level="2", skip_text=True) def test_broken_gs_with_pdfa_without_skip_text_passes() -> None: """Ohne skip_text greift die ocrmypdf-Bedingung nicht — kein Abbruch.""" with _env("10.0.0", "17.4.1"): check_preflight(pdfa_level="2", skip_text=False) # darf nicht werfen def test_healthy_gs_with_pdfa_and_skip_text_passes() -> None: """Nicht betroffene GS-Version → nie ein Abbruch.""" with _env("10.02.1", "17.4.1"): check_preflight(pdfa_level="2", skip_text=True) # darf nicht werfen # ---------------- Preflight: ocrmypdf 16.x (der Ausfall aus v0.6.0) ---------------- def test_broken_gs_skip_text_without_pdfa_fails_on_ocrmypdf_16() -> None: """DER Fall, der in v0.6.0 durchrutschte. ocrmypdf 16.13.0 + Ghostscript 10.0.0 + skip_text=true + pdfa_level="": "Preflight ok", Dienst active — und jede PDF landete in error/. Jetzt muss der Dienst beim START abbrechen. """ with _env("10.0.0", "16.13.0"): with pytest.raises(PreflightError) as exc_info: check_preflight(pdfa_level="", skip_text=True) msg = str(exc_info.value) assert "10.0.0" in msg assert "16.13.0" in msg def test_broken_gs_without_skip_text_passes_on_ocrmypdf_16() -> None: """skip_text=false → auch 16.x prüft Ghostscript nicht.""" with _env("10.0.0", "16.13.0"): check_preflight(pdfa_level="", skip_text=False) # darf nicht werfen def test_healthy_gs_passes_on_ocrmypdf_16() -> None: """Nicht betroffene GS-Version → auch mit 16.x kein Abbruch.""" with _env("10.02.1", "16.13.0"): check_preflight(pdfa_level="", skip_text=True) # darf nicht werfen # ---------------- Meldungstext ---------------- def test_error_message_names_both_remedies() -> None: """Der Admin muss aus der Meldung heraus handeln können. Die Wege sind die **echten**: `pdfa_level = ""` (Default), `skip_text = false` oder eine Distribution mit neuerem Ghostscript (Debian 13: 10.05.1). Bis v0.7.0 stand hier „Ghostscript aus bookworm-backports" als Weg 1 — das konnte nie funktionieren, denn bookworm-backports führt gar kein Ghostscript-Paket (am echten Paketindex verifiziert: 2606 Pakete, `ghostscript` nicht darunter). Der Rat darf nicht zurückkommen. """ # Fall A: ocrmypdf 16.x ohne PDF/A — Weg 1 ist hier, ocrmypdf anzuheben. with _env("10.0.0", "16.13.0"): with pytest.raises(PreflightError) as exc_info: check_preflight(pdfa_level="", skip_text=True) msg_no_pdfa = str(exc_info.value) assert "ocrmypdf >= 17" in msg_no_pdfa, "Weg 1: ocrmypdf anheben" assert "skip_text = false" in msg_no_pdfa, "Weg 2: skip_text abschalten" assert "Debian 13" in msg_no_pdfa, "Weg 3: neuere Distribution" # Fall B: PDF/A gewünscht — Weg 1 ist hier, PDF/A aufzugeben. with _env("10.0.0", "17.4.1"): with pytest.raises(PreflightError) as exc_info: check_preflight(pdfa_level="2", skip_text=True) msg_pdfa = str(exc_info.value) assert 'pdfa_level = ""' in msg_pdfa, "Weg 1: PDF/A abschalten" assert "skip_text = false" in msg_pdfa, "Weg 2: skip_text abschalten" assert "Debian 13" in msg_pdfa, "Weg 3: neuere Distribution" # Und in keiner der beiden Meldungen der widerlegte Backports-Rat. for msg in (msg_no_pdfa, msg_pdfa): assert "apt install -t bookworm-backports" not in msg assert "sources.list.d/bookworm-backports.list" not in msg # Erwähnt werden darf es — aber nur als ausdrückliche Absage. assert "NICHT" in msg and "kein Ghostscript-Paket" in msg def test_default_config_pdfa_level_is_empty() -> None: """Default-Config der Beispiel-Datei soll pdfa_level='' enthalten (Issue #3).""" from pathlib import Path import tomllib cfg_path = Path(__file__).parent.parent / "config.example.toml" with cfg_path.open("rb") as f: data = tomllib.load(f) assert data["ocr"]["pdfa_level"] == "", \ "config.example.toml muss pdfa_level='' als sicheren Default haben" # ---------------- Der Dienst muss beim START abbrechen ---------------- def test_run_once_aborts_on_ocrmypdf_16_with_broken_gs(tmp_config) -> None: """Abbruch beim Start statt Totalausfall bei der ersten Datei.""" from pdf_ocr_hotfolder.service import HotfolderService assert tmp_config.ocr.skip_text is True assert tmp_config.ocr.pdfa_level == "" service = HotfolderService(tmp_config) try: with _env("10.0.0", "16.13.0"): with pytest.raises(PreflightError): service.run_once() finally: service._executor.shutdown(wait=False) def test_check_config_returns_2_on_ocrmypdf_16_with_broken_gs( tmp_path, tmp_config, monkeypatch, capsys) -> None: """--check-config meldet den Zustand als Fehler (Exit 2) — auch mitten im Update.""" import sys from pdf_ocr_hotfolder.__main__ import CHECK_ERROR, main 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}" [ocr] skip_text = true pdfa_level = "" """) monkeypatch.setattr(sys, "argv", ["pdf-ocr-hotfolder", "--config", str(cfg_file), "--check-config"]) with _env("10.0.0", "16.13.0"): assert main() == CHECK_ERROR assert "Ghostscript" in capsys.readouterr().err