test: gesamte Test-Suite gruen (1294/1294) vor v2.0.0

- conftest: pymupdf-Alias-Loading robuster, fuer echte Render-Tests
- test_v2_pdf_consistency: fehlende_programme deserialisieren
- test_endpoints_smoke: Auth-Tests skippen wenn Keycloak nicht aktiv;
  queue/status-Schema auf workers_running aktualisiert
- test_inline_styles_baseline: skippen wenn tools/-Dir fehlt (Container)
- test_presse_generator_style: Mock-Body lang genug fuer kein Re-Generate;
  neuer event-loop pro Test (3.10+-Lifecycle)
- test_bug_regressions: EMBEDDINGS_DB-Patch auch im analyzer_mod;
  raising=False bei fitz/pymupdf raus (zerstoerte Folge-Tests)
- test_icons: macOS AppleDouble-Files (._*) ueberspringen
- test_protokoll_parsers_nrw: raising=False raus (Test-Isolation)
This commit is contained in:
Dotty Dotter 2026-05-09 22:29:37 +02:00
parent 9fc0619a20
commit d552582a0c
8 changed files with 99 additions and 13 deletions

View File

@ -28,7 +28,22 @@ def _stub(name: str, **attrs) -> None:
sys.modules[name] = mod sys.modules[name] = mod
_stub("fitz") # PyMuPDF — used for PDF parsing, not in unit tests # `fitz` ist tricky: PyPI haelt unter "fitz" eine andere Library, das
# echte PyMuPDF importiert man via `pymupdf` oder `fitz` (alias). Wenn
# das echte PyMuPDF installiert ist, soll die echte Lib genutzt werden;
# sonst stubben wir, damit Imports klappen. Tests, die wirklich PDFs
# rendern (test_embeddings TestRenderHighlightedPage), brauchen die
# echte Lib — wenn die fehlt, skippen sie ueber ihre Fixtures.
try:
import pymupdf as _pymupdf # type: ignore
sys.modules["fitz"] = _pymupdf
except ImportError:
try:
import fitz as _fitz_check # noqa: F401 (Real PyMuPDF unter fitz-Namen)
if not hasattr(_fitz_check, "open"):
_stub("fitz")
except ImportError:
_stub("fitz")
_stub("bs4", BeautifulSoup=lambda *a, **kw: None) # only needed by NRWAdapter live calls _stub("bs4", BeautifulSoup=lambda *a, **kw: None) # only needed by NRWAdapter live calls
_stub("openai", OpenAI=lambda **kw: None) # only needed by embeddings live calls _stub("openai", OpenAI=lambda **kw: None) # only needed by embeddings live calls

View File

@ -228,12 +228,17 @@ class TestCduPdfAssertionFallback:
def close(self): def close(self):
pass pass
# fitz is a thin wrapper around pymupdf; patch the fitz.open used inside embeddings.py # fitz is a thin wrapper around pymupdf — embeddings.py ruft fitz.open.
# Wichtig: nur patchen wenn das Attribut direkt am Modul liegt; sonst
# entfernt monkeypatch.undo() es am Test-Ende (raising=False merkt sich
# "war nicht gesetzt"), und nachfolgende Tests crashen mit
# "module 'fitz' has no attribute 'open'".
import fitz import fitz
import pymupdf import pymupdf
# Patch both possible references if "open" in fitz.__dict__:
monkeypatch.setattr(fitz, "open", FakeDoc, raising=False) monkeypatch.setattr(fitz, "open", FakeDoc)
monkeypatch.setattr(pymupdf, "open", FakeDoc, raising=False) if "open" in pymupdf.__dict__:
monkeypatch.setattr(pymupdf, "open", FakeDoc)
# Redirect referenzen-Pfad zu tmp_path # Redirect referenzen-Pfad zu tmp_path
from pathlib import Path as _Path from pathlib import Path as _Path
@ -339,8 +344,14 @@ class TestPflichtFraktionen:
"shareMastodon": "", "shareMastodon": "",
} }
# analyze_antrag schaut auf den Namen, den es selbst beim Import gebunden
# hat (siehe analyzer.py: `from .embeddings import EMBEDDINGS_DB`).
# Das Patchen von emb_mod.EMBEDDINGS_DB allein wuerde den bereits
# gebundenen Name in analyzer_mod nicht treffen — also beide patchen.
import app.embeddings as emb_mod import app.embeddings as emb_mod
monkeypatch.setattr(emb_mod, "EMBEDDINGS_DB", type("P", (), {"exists": lambda self: False})()) fake_db = type("P", (), {"exists": lambda self: False})()
monkeypatch.setattr(emb_mod, "EMBEDDINGS_DB", fake_db)
monkeypatch.setattr(analyzer_mod, "EMBEDDINGS_DB", fake_db)
asyncio.get_event_loop().run_until_complete( asyncio.get_event_loop().run_until_complete(
analyzer_mod.analyze_antrag( analyzer_mod.analyze_antrag(

View File

@ -9,14 +9,21 @@ import pytest
try: try:
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.main import app from app.main import app
from app.auth import _is_auth_enabled
client = TestClient(app) client = TestClient(app)
_HAS_APP = True _HAS_APP = True
_AUTH_ON = _is_auth_enabled()
except ImportError: except ImportError:
_HAS_APP = False _HAS_APP = False
_AUTH_ON = False
client = None client = None
pytestmark = pytest.mark.skipif(not _HAS_APP, reason="app.main not importable") pytestmark = pytest.mark.skipif(not _HAS_APP, reason="app.main not importable")
auth_required = pytest.mark.skipif(
not _AUTH_ON,
reason="Keycloak nicht konfiguriert — Auth-Wall ist deaktiviert (Dev-Modus)",
)
class TestQueueStatus: class TestQueueStatus:
@ -25,7 +32,7 @@ class TestQueueStatus:
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert "pending" in data assert "pending" in data
assert "worker_running" in data assert "workers_running" in data
def test_pending_starts_at_zero(self): def test_pending_starts_at_zero(self):
data = client.get("/api/queue/status").json() data = client.get("/api/queue/status").json()
@ -100,6 +107,7 @@ class TestVoteOrphansEndpoint:
assert it["bundesland"] == "NRW" assert it["bundesland"] == "NRW"
@auth_required
class TestVoteOrphansAutoRateAuth: class TestVoteOrphansAutoRateAuth:
"""POST /api/auswertungen/vote-orphans/auto-rate erfordert Admin.""" """POST /api/auswertungen/vote-orphans/auto-rate erfordert Admin."""
@ -112,6 +120,7 @@ class TestVoteOrphansAutoRateAuth:
assert resp.status_code in (401, 403, 307, 302) assert resp.status_code in (401, 403, 307, 302)
@auth_required
class TestBatchAnalyzeAuth: class TestBatchAnalyzeAuth:
"""POST /api/batch-analyze erfordert Admin.""" """POST /api/batch-analyze erfordert Admin."""
@ -224,6 +233,7 @@ class TestScoreHistogramEndpoint:
assert data["filter"]["wahlperiode"] == "NRW-WP18" assert data["filter"]["wahlperiode"] == "NRW-WP18"
@auth_required
class TestAdminStandAuth: class TestAdminStandAuth:
"""/v2/admin/stand + /api/admin/stand erfordern Admin.""" """/v2/admin/stand + /api/admin/stand erfordern Admin."""

View File

@ -27,6 +27,9 @@ def _collect_referenced_icons() -> set[str]:
"""Sammle alle Icon-Namen, die irgendwo in templates/ aufgerufen werden.""" """Sammle alle Icon-Namen, die irgendwo in templates/ aufgerufen werden."""
referenced: set[str] = set() referenced: set[str] = set()
for path in TEMPLATES_DIR.rglob("*.html"): for path in TEMPLATES_DIR.rglob("*.html"):
# macOS-AppleDouble-Metadaten (._foo.html) ueberspringen — keine Templates.
if path.name.startswith("._"):
continue
text = path.read_text(encoding="utf-8") text = path.read_text(encoding="utf-8")
for m in ICON_CALL_PATTERN.finditer(text): for m in ICON_CALL_PATTERN.finditer(text):
referenced.add(m.group(1)) referenced.add(m.group(1))

View File

@ -24,6 +24,15 @@ BASELINE = ROOT / "tools" / "inline_styles_baseline.json"
PAT = re.compile(r'style="([^"]+)"') PAT = re.compile(r'style="([^"]+)"')
# Im Prod-Container ist `tools/` nicht enthalten (bewusst — Build-Tools
# gehoeren nicht ins Image). In dieser Umgebung skippen wir die Wache,
# weil sie nur in der Repo-Checkout-Umgebung sinnvoll laeuft.
pytestmark = pytest.mark.skipif(
not BASELINE.exists(),
reason=f"Baseline {BASELINE} fehlt (z.B. Prod-Container ohne tools/-Dir).",
)
def _count_per_file() -> dict[str, int]: def _count_per_file() -> dict[str, int]:
out: dict[str, int] = {} out: dict[str, int] = {}
for f in sorted(set(TEMPLATES.rglob("*.html"))): for f in sorted(set(TEMPLATES.rglob("*.html"))):
@ -45,6 +54,8 @@ def current() -> dict[str, int]:
def test_baseline_file_exists(): def test_baseline_file_exists():
# Wenn wir hier ankommen, hat pytestmark den Skip *nicht* ausgeloest —
# dann muss die Baseline auch wirklich da sein.
assert BASELINE.exists(), ( assert BASELINE.exists(), (
f"Baseline {BASELINE} fehlt. Erst-Erzeugung: " f"Baseline {BASELINE} fehlt. Erst-Erzeugung: "
"python3 tools/audit_inline_styles.py --baseline > tools/inline_styles_baseline.json" "python3 tools/audit_inline_styles.py --baseline > tools/inline_styles_baseline.json"

View File

@ -29,16 +29,44 @@ else:
def run(coro): def run(coro):
return asyncio.get_event_loop().run_until_complete(coro) """Robust gegen Python 3.10+ event-loop-Lifecycle.
asyncio.get_event_loop() wirft RuntimeError wenn ein vorheriger Test
den Loop bereits geschlossen hat (passiert bei der vollen Suite, nicht
bei isoliertem Lauf). Ein neuer Loop pro Test ist robuster.
"""
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
return loop.run_until_complete(coro)
finally:
loop.close()
asyncio.set_event_loop(None)
def _long_pm_body(n_words: int = 320) -> str:
"""Generiert einen >= n_words Worte langen Mock-Body in 3 Absaetzen.
Wichtig: presse_generator.generate_draft triggert bei style='pm' und
body < 280 Worten einen Re-Generate-Call (siehe app/presse_generator.py).
PM-Tests muessen den Mock-Body deshalb von vornherein lang genug halten,
damit nur ein einziger Bewerter-Call erfolgt.
"""
para = "Mieter haben ein Recht auf sichere Energieversorgung. " * 8
paras = [para.strip()] * 3
body = "\n\n".join(paras)
while len(body.split()) < n_words:
body += " Wir fordern eine klare Regelung."
return body
class MockBewerter: class MockBewerter:
"""Mock fuer QwenBewerter.bewerte — gibt feste Response zurueck und """Mock fuer QwenBewerter.bewerte — gibt feste Response zurueck und
merkt sich, mit welchem system_prompt aufgerufen wurde.""" merkt sich, mit welchem system_prompt aufgerufen wurde."""
def __init__(self, titel="Mock-Titel", body="Para 1.\n\nPara 2.\n\nPara 3."): def __init__(self, titel="Mock-Titel", body=None):
self.titel = titel self.titel = titel
self.body = body self.body = body if body is not None else _long_pm_body()
self.calls = [] self.calls = []
async def bewerte(self, req): async def bewerte(self, req):
@ -190,7 +218,9 @@ class TestIdempotenz:
def test_different_styles_separate_drafts(self, setup_db): def test_different_styles_separate_drafts(self, setup_db):
"""gleiche (ds, url) aber pm + thread → zwei verschiedene Drafts.""" """gleiche (ds, url) aber pm + thread → zwei verschiedene Drafts."""
from app.presse_generator import generate_draft from app.presse_generator import generate_draft
b1 = MockBewerter(titel="PM", body="Para1.\n\nPara2.") # PM braucht >= 280 Worte sonst triggert der Re-Generate-Pfad
# einen zweiten Bewerter-Call (siehe presse_generator).
b1 = MockBewerter(titel="PM", body=_long_pm_body())
b2 = MockBewerter(titel="Thread", body="P1.\n\nP2.") b2 = MockBewerter(titel="Thread", body="P1.\n\nP2.")
run(generate_draft( run(generate_draft(
drucksache="18/9999", news_url="https://example.com/news/1", drucksache="18/9999", news_url="https://example.com/news/1",

View File

@ -234,8 +234,13 @@ class TestParseProtocol:
def close(self): def close(self):
pass pass
# raising=False weggelassen: fitz.open existiert immer; das Flag
# haette monkeypatch dazu gebracht, das Attribut auf cleanup zu
# *loeschen* statt zurueckzusetzen — was nachfolgende Tests
# (test_embeddings::TestRenderHighlightedPage) crashen liess
# ("module 'fitz' has no attribute 'open'").
monkeypatch.setattr(nrw_mod.fitz, "open", monkeypatch.setattr(nrw_mod.fitz, "open",
lambda path: FakeDoc(full_text), raising=False) lambda path: FakeDoc(full_text))
def test_simple_angenommen(self, monkeypatch): def test_simple_angenommen(self, monkeypatch):
from app.protokoll_parsers.nrw import parse_protocol from app.protokoll_parsers.nrw import parse_protocol

View File

@ -62,7 +62,8 @@ def _live_rows(limit: int = 20) -> list[dict]:
import json as _json import json as _json
for k in ("fraktionen", "gwoe_matrix", "gwoe_schwerpunkt", for k in ("fraktionen", "gwoe_matrix", "gwoe_schwerpunkt",
"wahlprogramm_scores", "verbesserungen", "staerken", "wahlprogramm_scores", "verbesserungen", "staerken",
"schwaechen", "themen", "antrag_kernpunkte"): "schwaechen", "themen", "antrag_kernpunkte",
"fehlende_programme"):
if k in d and isinstance(d[k], str): if k in d and isinstance(d[k], str):
try: try:
d[k] = _json.loads(d[k]) d[k] = _json.loads(d[k])