diff --git a/tests/conftest.py b/tests/conftest.py index 9f50970..8c0f222 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_bug_regressions.py b/tests/test_bug_regressions.py index 63a77f1..586bd51 100644 --- a/tests/test_bug_regressions.py +++ b/tests/test_bug_regressions.py @@ -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( diff --git a/tests/test_endpoints_smoke.py b/tests/test_endpoints_smoke.py index 547a03b..79232b5 100644 --- a/tests/test_endpoints_smoke.py +++ b/tests/test_endpoints_smoke.py @@ -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.""" diff --git a/tests/test_icons.py b/tests/test_icons.py index c6ac2c6..6c8c73f 100644 --- a/tests/test_icons.py +++ b/tests/test_icons.py @@ -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)) diff --git a/tests/test_inline_styles_baseline.py b/tests/test_inline_styles_baseline.py index 0cfdc82..e0ea171 100644 --- a/tests/test_inline_styles_baseline.py +++ b/tests/test_inline_styles_baseline.py @@ -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" diff --git a/tests/test_presse_generator_style.py b/tests/test_presse_generator_style.py index afe491a..17bc4e6 100644 --- a/tests/test_presse_generator_style.py +++ b/tests/test_presse_generator_style.py @@ -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", diff --git a/tests/test_protokoll_parsers_nrw.py b/tests/test_protokoll_parsers_nrw.py index c5cc287..219eee4 100644 --- a/tests/test_protokoll_parsers_nrw.py +++ b/tests/test_protokoll_parsers_nrw.py @@ -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 diff --git a/tests/test_v2_pdf_consistency.py b/tests/test_v2_pdf_consistency.py index 3b54993..9cd3767 100644 --- a/tests/test_v2_pdf_consistency.py +++ b/tests/test_v2_pdf_consistency.py @@ -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])