gwoe-antragspruefer/tests/test_endpoints_smoke.py

338 lines
11 KiB
Python
Raw Normal View History

"""Smoke-Tests für die neuen API-Endpoints.
Diese Tests prüfen nur ob die Endpoints antworten und das richtige
Format zurückgeben keine echten LLM-Calls oder DB-Writes.
"""
import pytest
# Skip wenn die App nicht importierbar ist (missing dependencies lokal)
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:
def test_returns_json(self):
resp = client.get("/api/queue/status")
assert resp.status_code == 200
data = resp.json()
assert "pending" in data
assert "workers_running" in data
def test_pending_starts_at_zero(self):
data = client.get("/api/queue/status").json()
assert data["pending"] == 0
class TestAuthMe:
def test_unauthenticated_returns_false(self):
resp = client.get("/api/auth/me")
assert resp.status_code == 200
data = resp.json()
assert data["authenticated"] is False
class TestAuthLoginUrl:
def test_returns_enabled_flag(self):
resp = client.get("/api/auth/login-url")
assert resp.status_code == 200
data = resp.json()
assert "enabled" in data
class TestBundeslaender:
def test_returns_list(self):
resp = client.get("/api/bundeslaender")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) > 10 # mindestens 10 BLs
class TestProgramme:
def test_returns_list(self):
resp = client.get("/api/programme")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) > 50 # mindestens 50 Programme
def test_status_returns_indexed_count(self):
resp = client.get("/api/programme/status")
assert resp.status_code == 200
data = resp.json()
assert "indexed" in data
assert "total" in data
class TestHealth:
def test_health(self):
resp = client.get("/health")
assert resp.status_code == 200
feat: Antrag-Detail News-Match-Box + Test-Coverage fuer aktuelle-themen **News-Match-Box im Antrag-Detail:** Reverse-Sicht zur /aktuelle-themen-Seite — pro Antrag-Detail-Page eine Box "Aktuelle News passend zu diesem Antrag" mit den Top-5 Matches der letzten 90 Tage. Pro News-Card direkter "PM-Vorschlag generieren"-Button mit Idempotenz-Check (bestehender Draft wird ohne LLM-Call zurueckgegeben). Loesst das User-Feedback "ich oeffne ja meist Antrags-Detail, nicht den News-Tab — da fehlt mir die News-Sicht". Box laedt lazy via fetch und bleibt komplett versteckt wenn keine Matches existieren (kein Noise). **Test-Coverage fuer die heutigen Backend-Aenderungen:** `tests/test_llm_bewerter.py`: - 6 Tests fuer `_recover_unescaped_newlines` (clean, raw newline, tab+cr, outside-string, makes-invalid-valid, preserves-already-escaped) - 2 Tests fuer `json_object_mode` pass-through (off → kein Param, on → response_format={"type":"json_object"}) - 1 Integration: Recovery greift im bewerte()-Loop ohne Retry `tests/test_endpoints_smoke.py`: - Vote-Orphans-Endpoint (GET) Smoke - Vote-Orphans-Auto-Rate Auth-Wall - Batch-Analyze Auth-Wall (incl. ALL-Modus) - Aktuelle-Themen-Endpoints (top, zeitreihe, top-antraege, cluster, drafts-list, drafts-versions) — 8 Tests `tests/test_batch_helpers.py`: - 4 Unit-Tests fuer _enqueue_for_bl-Logik via Inline-Repro mit Mocks (already-rated skip, no-adapter, limit-cap, empty-text-skip) Suite: 1084 passed, 50 skipped (Smoke-Tests skippen lokal weil FastAPI nicht importbar, greifen aber gegen dev/CI). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 02:22:22 +02:00
class TestVoteOrphansEndpoint:
"""GET /api/auswertungen/vote-orphans (öffentlich)."""
def test_returns_json_structure(self):
resp = client.get("/api/auswertungen/vote-orphans?limit=5")
assert resp.status_code == 200
data = resp.json()
assert "count" in data
assert "items" in data
assert "by_bundesland" in data
assert isinstance(data["items"], list)
def test_filter_bundesland_param(self):
resp = client.get("/api/auswertungen/vote-orphans?bundesland=NRW&limit=3")
assert resp.status_code == 200
data = resp.json()
# Wenn items vorhanden, alle aus NRW
for it in data["items"]:
assert it["bundesland"] == "NRW"
@auth_required
feat: Antrag-Detail News-Match-Box + Test-Coverage fuer aktuelle-themen **News-Match-Box im Antrag-Detail:** Reverse-Sicht zur /aktuelle-themen-Seite — pro Antrag-Detail-Page eine Box "Aktuelle News passend zu diesem Antrag" mit den Top-5 Matches der letzten 90 Tage. Pro News-Card direkter "PM-Vorschlag generieren"-Button mit Idempotenz-Check (bestehender Draft wird ohne LLM-Call zurueckgegeben). Loesst das User-Feedback "ich oeffne ja meist Antrags-Detail, nicht den News-Tab — da fehlt mir die News-Sicht". Box laedt lazy via fetch und bleibt komplett versteckt wenn keine Matches existieren (kein Noise). **Test-Coverage fuer die heutigen Backend-Aenderungen:** `tests/test_llm_bewerter.py`: - 6 Tests fuer `_recover_unescaped_newlines` (clean, raw newline, tab+cr, outside-string, makes-invalid-valid, preserves-already-escaped) - 2 Tests fuer `json_object_mode` pass-through (off → kein Param, on → response_format={"type":"json_object"}) - 1 Integration: Recovery greift im bewerte()-Loop ohne Retry `tests/test_endpoints_smoke.py`: - Vote-Orphans-Endpoint (GET) Smoke - Vote-Orphans-Auto-Rate Auth-Wall - Batch-Analyze Auth-Wall (incl. ALL-Modus) - Aktuelle-Themen-Endpoints (top, zeitreihe, top-antraege, cluster, drafts-list, drafts-versions) — 8 Tests `tests/test_batch_helpers.py`: - 4 Unit-Tests fuer _enqueue_for_bl-Logik via Inline-Repro mit Mocks (already-rated skip, no-adapter, limit-cap, empty-text-skip) Suite: 1084 passed, 50 skipped (Smoke-Tests skippen lokal weil FastAPI nicht importbar, greifen aber gegen dev/CI). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 02:22:22 +02:00
class TestVoteOrphansAutoRateAuth:
"""POST /api/auswertungen/vote-orphans/auto-rate erfordert Admin."""
def test_unauthenticated_rejected(self):
resp = client.post(
"/api/auswertungen/vote-orphans/auto-rate",
data={"limit": 5},
)
# Auth-Wall greift entweder direkt 401, 403 oder Redirect (307/302)
assert resp.status_code in (401, 403, 307, 302)
@auth_required
feat: Antrag-Detail News-Match-Box + Test-Coverage fuer aktuelle-themen **News-Match-Box im Antrag-Detail:** Reverse-Sicht zur /aktuelle-themen-Seite — pro Antrag-Detail-Page eine Box "Aktuelle News passend zu diesem Antrag" mit den Top-5 Matches der letzten 90 Tage. Pro News-Card direkter "PM-Vorschlag generieren"-Button mit Idempotenz-Check (bestehender Draft wird ohne LLM-Call zurueckgegeben). Loesst das User-Feedback "ich oeffne ja meist Antrags-Detail, nicht den News-Tab — da fehlt mir die News-Sicht". Box laedt lazy via fetch und bleibt komplett versteckt wenn keine Matches existieren (kein Noise). **Test-Coverage fuer die heutigen Backend-Aenderungen:** `tests/test_llm_bewerter.py`: - 6 Tests fuer `_recover_unescaped_newlines` (clean, raw newline, tab+cr, outside-string, makes-invalid-valid, preserves-already-escaped) - 2 Tests fuer `json_object_mode` pass-through (off → kein Param, on → response_format={"type":"json_object"}) - 1 Integration: Recovery greift im bewerte()-Loop ohne Retry `tests/test_endpoints_smoke.py`: - Vote-Orphans-Endpoint (GET) Smoke - Vote-Orphans-Auto-Rate Auth-Wall - Batch-Analyze Auth-Wall (incl. ALL-Modus) - Aktuelle-Themen-Endpoints (top, zeitreihe, top-antraege, cluster, drafts-list, drafts-versions) — 8 Tests `tests/test_batch_helpers.py`: - 4 Unit-Tests fuer _enqueue_for_bl-Logik via Inline-Repro mit Mocks (already-rated skip, no-adapter, limit-cap, empty-text-skip) Suite: 1084 passed, 50 skipped (Smoke-Tests skippen lokal weil FastAPI nicht importbar, greifen aber gegen dev/CI). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 02:22:22 +02:00
class TestBatchAnalyzeAuth:
"""POST /api/batch-analyze erfordert Admin."""
def test_unauthenticated_rejected(self):
resp = client.post(
"/api/batch-analyze",
data={"bundesland": "NRW", "limit": 5},
)
assert resp.status_code in (401, 403, 307, 302)
def test_all_bl_unauthenticated_also_rejected(self):
resp = client.post(
"/api/batch-analyze",
data={"bundesland": "ALL", "limit": 10},
)
assert resp.status_code in (401, 403, 307, 302)
class TestAktuelleThemenEndpoints:
"""GET /api/aktuelle-themen/* sind oeffentlich."""
def test_top_returns_buckets(self):
resp = client.get("/api/aktuelle-themen/top?days=7&top_k=3")
assert resp.status_code == 200
data = resp.json()
assert "buckets" in data
assert "n_total_news" in data
assert "filter" in data
def test_top_with_single_date(self):
resp = client.get("/api/aktuelle-themen/top?date=2026-05-01")
assert resp.status_code == 200
data = resp.json()
assert data["filter"]["single_date"] == "2026-05-01"
def test_top_with_only_relevant(self):
resp = client.get("/api/aktuelle-themen/top?only_relevant=true&top_k=5")
assert resp.status_code == 200
data = resp.json()
assert data["filter"]["only_relevant"] is True
def test_zeitreihe(self):
resp = client.get("/api/aktuelle-themen/zeitreihe?days=14")
assert resp.status_code == 200
data = resp.json()
assert "buckets" in data
assert "sources" in data
assert "series" in data
def test_top_antraege(self):
resp = client.get("/api/aktuelle-themen/top-antraege?min_gwoe_score=8.0")
assert resp.status_code == 200
data = resp.json()
assert "antraege" in data
def test_cluster(self):
resp = client.get("/api/aktuelle-themen/cluster?days=7")
assert resp.status_code == 200
data = resp.json()
assert "clusters" in data
def test_drafts_list(self):
resp = client.get("/api/aktuelle-themen/drafts?limit=5")
assert resp.status_code == 200
data = resp.json()
assert "drafts" in data
def test_drafts_versions(self):
resp = client.get(
"/api/aktuelle-themen/drafts-versions"
"?drucksache=missing&news_url=https://example.com/x"
)
assert resp.status_code == 200
data = resp.json()
assert "versions" in data
assert isinstance(data["versions"], list)
class TestScoreHistogramEndpoint:
"""GET /api/auswertungen/score-histogram (öffentlich)."""
def test_returns_11_buckets(self):
resp = client.get("/api/auswertungen/score-histogram")
assert resp.status_code == 200
data = resp.json()
# 11 Buckets: 01, 12, ..., 1011
assert len(data["buckets"]) == 11
for b in data["buckets"]:
assert "score_min" in b
assert "score_max" in b
assert "count" in b
assert b["count"] >= 0
def test_total_matches_sum_of_buckets(self):
resp = client.get("/api/auswertungen/score-histogram")
data = resp.json()
total_sum = sum(b["count"] for b in data["buckets"])
assert data["total"] == total_sum
def test_filter_bundesland(self):
resp = client.get("/api/auswertungen/score-histogram?bundesland=NRW")
assert resp.status_code == 200
data = resp.json()
assert data["filter"]["bundesland"] == "NRW"
def test_filter_wahlperiode(self):
resp = client.get("/api/auswertungen/score-histogram?wahlperiode=NRW-WP18")
assert resp.status_code == 200
data = resp.json()
assert data["filter"]["wahlperiode"] == "NRW-WP18"
@auth_required
class TestAdminStandAuth:
"""/v2/admin/stand + /api/admin/stand erfordern Admin."""
def test_page_unauthenticated_rejected(self):
resp = client.get("/v2/admin/stand")
assert resp.status_code in (401, 403, 307, 302)
def test_api_unauthenticated_rejected(self):
resp = client.get("/api/admin/stand")
assert resp.status_code in (401, 403, 307, 302)
class TestStimmverhaltenAggregateEndpoints:
"""Die 7 Stimmverhalten-Aggregat-Endpoints liefern strukturierte JSONs (siehe ADR 0010)."""
def test_stimm_index_returns_fraktionen(self):
resp = client.get("/api/auswertungen/stimm-index")
assert resp.status_code == 200
data = resp.json()
assert "fraktionen" in data
assert isinstance(data["fraktionen"], list)
assert "n_assessments_matched" in data
def test_stimm_index_min_n_filter(self):
resp = client.get("/api/auswertungen/stimm-index?min_n=1")
assert resp.status_code == 200
data = resp.json()
assert data["filter"]["min_n"] == 1
def test_stimm_index_exclude_antragsteller_default(self):
"""Default `exclude_antragsteller=True`."""
resp = client.get("/api/auswertungen/stimm-index")
data = resp.json()
assert data["filter"]["exclude_antragsteller"] in (True, "true", 1)
def test_heuchelei_returns_fraktionen(self):
resp = client.get("/api/auswertungen/heuchelei")
assert resp.status_code == 200
data = resp.json()
assert "fraktionen" in data
def test_empfehlungs_konsistenz(self):
resp = client.get("/api/auswertungen/empfehlungs-konsistenz")
assert resp.status_code == 200
data = resp.json()
assert "fraktionen" in data or "items" in data
def test_stimm_index_pro_wert_5_werte(self):
resp = client.get("/api/auswertungen/stimm-index-pro-wert")
assert resp.status_code == 200
data = resp.json()
assert "werte" in data
# 5 GWÖ-Werte: Würde, Solidarität, Nachhaltigkeit, Gerechtigkeit, Demokratie
assert len(data["werte"]) == 5
def test_stimm_index_cross_bl(self):
resp = client.get("/api/auswertungen/stimm-index-cross-bl")
assert resp.status_code == 200
data = resp.json()
assert "fraktionen" in data
assert "bundeslaender" in data
assert "cells" in data
def test_stimm_index_zeitreihe(self):
resp = client.get("/api/auswertungen/stimm-index-zeitreihe")
assert resp.status_code == 200
data = resp.json()
# Erwartete Felder: buckets (Zeit-Achse), fraktionen, series
assert "buckets" in data
assert "fraktionen" in data
assert "series" in data
class TestStimmverhaltenCsvExport:
"""CSV-Export Long-Format aus #168."""
def test_csv_returns_text(self):
resp = client.get("/api/auswertungen/stimmverhalten.csv")
assert resp.status_code == 200
ctype = resp.headers.get("content-type", "")
assert "text/csv" in ctype or "text/plain" in ctype
def test_csv_has_header_row(self):
resp = client.get("/api/auswertungen/stimmverhalten.csv")
body = resp.text
# Header-Zeile mit den erwarteten Long-Format-Spalten
first_line = body.split("\n", 1)[0]
# Mindestens drei Pflicht-Spalten erwartet — Partei statt Fraktion in CSV
for required in ("drucksache", "bundesland", "partei", "vote"):
assert required in first_line, f"Spalte {required!r} fehlt im Header: {first_line!r}"
def test_csv_data_rows_have_consistent_columns(self):
"""Datenzeilen haben gleiche Spalten-Anzahl wie der Header."""
resp = client.get("/api/auswertungen/stimmverhalten.csv")
body = resp.text.strip()
if not body:
pytest.skip("kein CSV-Body — DB enthält evtl. keine Vote-Matches")
lines = body.split("\n")
n_cols = lines[0].count(",") + 1
for line in lines[1:6]: # Samples
assert line.count(",") + 1 == n_cols, f"Spaltenzahl-Mismatch: {line!r}"