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:
parent
9fc0619a20
commit
d552582a0c
@ -28,7 +28,22 @@ def _stub(name: str, **attrs) -> None:
|
||||
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("openai", OpenAI=lambda **kw: None) # only needed by embeddings live calls
|
||||
|
||||
|
||||
@ -228,12 +228,17 @@ class TestCduPdfAssertionFallback:
|
||||
def close(self):
|
||||
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 pymupdf
|
||||
# Patch both possible references
|
||||
monkeypatch.setattr(fitz, "open", FakeDoc, raising=False)
|
||||
monkeypatch.setattr(pymupdf, "open", FakeDoc, raising=False)
|
||||
if "open" in fitz.__dict__:
|
||||
monkeypatch.setattr(fitz, "open", FakeDoc)
|
||||
if "open" in pymupdf.__dict__:
|
||||
monkeypatch.setattr(pymupdf, "open", FakeDoc)
|
||||
|
||||
# Redirect referenzen-Pfad zu tmp_path
|
||||
from pathlib import Path as _Path
|
||||
@ -339,8 +344,14 @@ class TestPflichtFraktionen:
|
||||
"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
|
||||
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(
|
||||
analyzer_mod.analyze_antrag(
|
||||
|
||||
@ -9,14 +9,21 @@ import pytest
|
||||
try:
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
from app.auth import _is_auth_enabled
|
||||
client = TestClient(app)
|
||||
_HAS_APP = True
|
||||
_AUTH_ON = _is_auth_enabled()
|
||||
except ImportError:
|
||||
_HAS_APP = False
|
||||
_AUTH_ON = False
|
||||
client = None
|
||||
|
||||
|
||||
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:
|
||||
@ -25,7 +32,7 @@ class TestQueueStatus:
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "pending" in data
|
||||
assert "worker_running" in data
|
||||
assert "workers_running" in data
|
||||
|
||||
def test_pending_starts_at_zero(self):
|
||||
data = client.get("/api/queue/status").json()
|
||||
@ -100,6 +107,7 @@ class TestVoteOrphansEndpoint:
|
||||
assert it["bundesland"] == "NRW"
|
||||
|
||||
|
||||
@auth_required
|
||||
class TestVoteOrphansAutoRateAuth:
|
||||
"""POST /api/auswertungen/vote-orphans/auto-rate erfordert Admin."""
|
||||
|
||||
@ -112,6 +120,7 @@ class TestVoteOrphansAutoRateAuth:
|
||||
assert resp.status_code in (401, 403, 307, 302)
|
||||
|
||||
|
||||
@auth_required
|
||||
class TestBatchAnalyzeAuth:
|
||||
"""POST /api/batch-analyze erfordert Admin."""
|
||||
|
||||
@ -224,6 +233,7 @@ class TestScoreHistogramEndpoint:
|
||||
assert data["filter"]["wahlperiode"] == "NRW-WP18"
|
||||
|
||||
|
||||
@auth_required
|
||||
class TestAdminStandAuth:
|
||||
"""/v2/admin/stand + /api/admin/stand erfordern Admin."""
|
||||
|
||||
|
||||
@ -27,6 +27,9 @@ def _collect_referenced_icons() -> set[str]:
|
||||
"""Sammle alle Icon-Namen, die irgendwo in templates/ aufgerufen werden."""
|
||||
referenced: set[str] = set()
|
||||
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")
|
||||
for m in ICON_CALL_PATTERN.finditer(text):
|
||||
referenced.add(m.group(1))
|
||||
|
||||
@ -24,6 +24,15 @@ BASELINE = ROOT / "tools" / "inline_styles_baseline.json"
|
||||
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]:
|
||||
out: dict[str, int] = {}
|
||||
for f in sorted(set(TEMPLATES.rglob("*.html"))):
|
||||
@ -45,6 +54,8 @@ def current() -> dict[str, int]:
|
||||
|
||||
|
||||
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(), (
|
||||
f"Baseline {BASELINE} fehlt. Erst-Erzeugung: "
|
||||
"python3 tools/audit_inline_styles.py --baseline > tools/inline_styles_baseline.json"
|
||||
|
||||
@ -29,16 +29,44 @@ else:
|
||||
|
||||
|
||||
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:
|
||||
"""Mock fuer QwenBewerter.bewerte — gibt feste Response zurueck und
|
||||
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.body = body
|
||||
self.body = body if body is not None else _long_pm_body()
|
||||
self.calls = []
|
||||
|
||||
async def bewerte(self, req):
|
||||
@ -190,7 +218,9 @@ class TestIdempotenz:
|
||||
def test_different_styles_separate_drafts(self, setup_db):
|
||||
"""gleiche (ds, url) aber pm + thread → zwei verschiedene Drafts."""
|
||||
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.")
|
||||
run(generate_draft(
|
||||
drucksache="18/9999", news_url="https://example.com/news/1",
|
||||
|
||||
@ -234,8 +234,13 @@ class TestParseProtocol:
|
||||
def close(self):
|
||||
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",
|
||||
lambda path: FakeDoc(full_text), raising=False)
|
||||
lambda path: FakeDoc(full_text))
|
||||
|
||||
def test_simple_angenommen(self, monkeypatch):
|
||||
from app.protokoll_parsers.nrw import parse_protocol
|
||||
|
||||
@ -62,7 +62,8 @@ def _live_rows(limit: int = 20) -> list[dict]:
|
||||
import json as _json
|
||||
for k in ("fraktionen", "gwoe_matrix", "gwoe_schwerpunkt",
|
||||
"wahlprogramm_scores", "verbesserungen", "staerken",
|
||||
"schwaechen", "themen", "antrag_kernpunkte"):
|
||||
"schwaechen", "themen", "antrag_kernpunkte",
|
||||
"fehlende_programme"):
|
||||
if k in d and isinstance(d[k], str):
|
||||
try:
|
||||
d[k] = _json.loads(d[k])
|
||||
|
||||
Loading…
Reference in New Issue
Block a user