gwoe-antragspruefer/tests/test_monitoring.py

507 lines
19 KiB
Python
Raw Normal View History

"""Unit-Tests für app/monitoring.py (#135).
Testet:
- Kosten-Schätzung (estimate_cost_qwen_plus)
- daily_scan() mit Fake-Adapter (kein Netzwerk, kein LLM)
- daily_summary-Aggregation über mehrere Bundesländer
- Fehlerbehandlung: Adapter-Exception soll anderen BL nicht blockieren
- Plaintext-Render (_render_plain)
"""
from __future__ import annotations
import asyncio
import sys
import types
from dataclasses import dataclass
from unittest.mock import AsyncMock, patch
import pytest
# ─── Dependency-Stubs (analog conftest.py) ──────────────────────────────────
def _stub(name: str, **attrs) -> None:
if name in sys.modules:
return
mod = types.ModuleType(name)
for k, v in attrs.items():
setattr(mod, k, v)
sys.modules[name] = mod
_stub("aiosqlite")
_stub("fitz")
_stub("bs4", BeautifulSoup=lambda *a, **kw: None)
_stub("openai", OpenAI=lambda **kw: None)
# ─── Imports ─────────────────────────────────────────────────────────────────
from app.monitoring import (
estimate_cost_qwen_plus,
BundeslandScanResult,
DailyScanResult,
_render_plain,
_search_adapter,
_QWEN_PLUS_INPUT_USD_PER_1K,
_QWEN_PLUS_OUTPUT_USD_PER_1K,
_USD_TO_EUR,
)
# ─── Hilfsobjekte ─────────────────────────────────────────────────────────────
@dataclass
class FakeDrucksache:
drucksache: str
title: str
bundesland: str
fraktionen: list
datum: str = "2026-04-20"
link: str = "https://example.com/test.pdf"
typ: str = "Antrag"
typ_normiert: str = "antrag"
class FakeAdapter:
"""Adapter-Stub mit konfigurierbaren Suchergebnissen."""
def __init__(self, bundesland: str, docs: list, fail: bool = False):
self.bundesland = bundesland
self._docs = docs
self._fail = fail
self.called_with: list[tuple] = []
async def search(self, query: str, limit: int = 20) -> list:
self.called_with.append((query, limit))
if self._fail:
raise ConnectionError(f"Fake-Fehler für {self.bundesland}")
return self._docs
# ─── Kosten-Schätzung ────────────────────────────────────────────────────────
class TestEstimateCostQwenPlus:
def test_zero_new_is_zero_cost(self):
assert estimate_cost_qwen_plus(0) == 0.0
def test_negative_new_is_zero_cost(self):
assert estimate_cost_qwen_plus(-5) == 0.0
def test_one_antrag_reasonable_range(self):
"""Ein Antrag mit Default-Werten sollte wenige Cent kosten."""
cost = estimate_cost_qwen_plus(1)
assert 0.005 < cost < 0.02, f"Unerwartete Kosten: {cost}"
def test_cost_scales_linearly(self):
c1 = estimate_cost_qwen_plus(1)
c10 = estimate_cost_qwen_plus(10)
# round() in der Funktion kann minimal divergieren — 0.001 Toleranz
assert abs(c10 - c1 * 10) < 0.001
def test_manual_calculation(self):
"""Prüft die Formel gegen manuelle Berechnung."""
n, in_t, out_t = 5, 20_000, 3_000
expected_usd = (
(in_t / 1000) * _QWEN_PLUS_INPUT_USD_PER_1K * n
+ (out_t / 1000) * _QWEN_PLUS_OUTPUT_USD_PER_1K * n
)
expected_eur = round(expected_usd * _USD_TO_EUR, 4)
assert estimate_cost_qwen_plus(n, in_t, out_t) == expected_eur
def test_custom_token_counts_used(self):
cheap = estimate_cost_qwen_plus(10, avg_in_tokens=1000, avg_out_tokens=100)
expensive = estimate_cost_qwen_plus(10, avg_in_tokens=50_000, avg_out_tokens=10_000)
assert cheap < expensive
def test_result_is_float(self):
assert isinstance(estimate_cost_qwen_plus(3), float)
# ─── _search_adapter ─────────────────────────────────────────────────────────
class TestSearchAdapter:
def test_empty_string_query_works(self):
doc = FakeDrucksache("18/1", "Test", "NRW", ["SPD"])
adapter = FakeAdapter("NRW", [doc])
result = asyncio.run(_search_adapter(adapter, "NRW"))
assert len(result) == 1
# Erster Versuch mit leerem String
assert adapter.called_with[0][0] == ""
def test_fallback_to_space_on_first_failure(self):
"""Wenn leerer String fehlschlägt, wird Leerzeichen probiert."""
doc = FakeDrucksache("18/2", "Fallback", "NRW", ["CDU"])
call_count = [0]
class PartialFailAdapter:
bundesland = "NRW"
async def search(self, query: str, limit: int = 20):
call_count[0] += 1
if query == "":
raise ValueError("Leerer Query nicht erlaubt")
return [doc]
result = asyncio.run(_search_adapter(PartialFailAdapter(), "NRW"))
assert len(result) == 1
assert call_count[0] == 2 # erstes Fail, zweiter Versuch erfolgreich
def test_all_queries_fail_raises(self):
adapter = FakeAdapter("NRW", [], fail=True)
with pytest.raises(ConnectionError):
asyncio.run(_search_adapter(adapter, "NRW"))
# ─── daily_scan ──────────────────────────────────────────────────────────────
def _make_docs(bl: str, n: int) -> list:
return [
FakeDrucksache(
drucksache=f"{bl}/100{i}",
title=f"Testantrag {i}",
bundesland=bl,
fraktionen=["SPD"],
)
for i in range(n)
]
class TestDailyScan:
def _run_scan_with_adapters(self, adapters_dict: dict, bl_codes: list) -> DailyScanResult:
"""Führt daily_scan() mit gefakten Adapters und BL-Liste aus."""
from app.bundeslaender import Bundesland
fake_bls = [
Bundesland(
code=code,
name=code,
parlament_name=code,
wahlperiode=1,
wahlperiode_start="2024-01-01",
naechste_wahl=None,
regierungsfraktionen=[],
landtagsfraktionen=[],
doku_system="Test",
doku_base_url="http://example.com",
drucksache_format="1/1234",
dokukratie_scraper=None,
aktiv=True,
)
for code in bl_codes
]
db_upsert_calls: list[dict] = []
summary_calls: list[dict] = []
async def fake_upsert_scan(**kwargs) -> bool:
db_upsert_calls.append(kwargs)
# Eintrag ist "neu" wenn drucksache endet auf "00" oder "01" (erstes Mal)
return True
async def fake_upsert_summary(**kwargs) -> None:
summary_calls.append(kwargs)
import app.monitoring as mon_mod
import app.database as db_mod
with (
patch("app.monitoring.aktive_bundeslaender", return_value=fake_bls),
patch("app.monitoring.ADAPTERS", adapters_dict, create=True),
patch.object(db_mod, "upsert_monitoring_scan", side_effect=fake_upsert_scan),
patch.object(db_mod, "upsert_monitoring_summary", side_effect=fake_upsert_summary),
):
# ADAPTERS wird innerhalb von daily_scan() aus parlamente importiert —
# wir patchen direkt im Modul-Namespace über die import-Referenz
import app.parlamente as parl_mod
original_adapters = getattr(parl_mod, "ADAPTERS", {})
parl_mod.ADAPTERS = adapters_dict
try:
result = asyncio.run(mon_mod.daily_scan())
finally:
parl_mod.ADAPTERS = original_adapters
return result, db_upsert_calls, summary_calls
def test_single_bl_all_new(self):
docs = _make_docs("NRW", 3)
adapter = FakeAdapter("NRW", docs)
result, upserts, summaries = self._run_scan_with_adapters({"NRW": adapter}, ["NRW"])
assert result.new_total == 3
assert result.total_seen == 3
assert len(result.results) == 1
assert result.results[0].bundesland == "NRW"
assert len(upserts) == 3
def test_multiple_bl_aggregated(self):
adapters = {
"NRW": FakeAdapter("NRW", _make_docs("NRW", 5)),
"BY": FakeAdapter("BY", _make_docs("BY", 2)),
}
result, _, summaries = self._run_scan_with_adapters(adapters, ["NRW", "BY"])
assert result.new_total == 7
assert result.total_seen == 7
assert len(result.results) == 2
# Eine Summary pro BL
bl_codes = {s["bundesland"] for s in summaries}
assert "NRW" in bl_codes
assert "BY" in bl_codes
def test_adapter_exception_does_not_block_other_bls(self):
adapters = {
"NRW": FakeAdapter("NRW", _make_docs("NRW", 3)),
"BY": FakeAdapter("BY", [], fail=True),
"BE": FakeAdapter("BE", _make_docs("BE", 2)),
}
result, upserts, summaries = self._run_scan_with_adapters(
adapters, ["NRW", "BY", "BE"]
)
# NRW + BE erfolgreich, BY fehlerhaft
assert result.new_total == 5
assert len(result.errors) == 1
assert "BY" in result.errors[0]
successful_bls = [r.bundesland for r in result.results if not r.error]
assert "NRW" in successful_bls
assert "BE" in successful_bls
def test_no_adapter_for_bl_skipped_gracefully(self):
adapters = {} # kein Adapter für keinen BL
result, upserts, _ = self._run_scan_with_adapters(adapters, ["NRW"])
assert result.new_total == 0
assert len(upserts) == 0
assert len(result.errors) == 0
def test_estimated_cost_non_zero_when_new_docs(self):
docs = _make_docs("NRW", 10)
adapters = {"NRW": FakeAdapter("NRW", docs)}
result, _, _ = self._run_scan_with_adapters(adapters, ["NRW"])
assert result.estimated_cost_eur > 0
def test_scan_date_is_today(self):
from datetime import datetime, timezone
adapters = {"NRW": FakeAdapter("NRW", [])}
result, _, _ = self._run_scan_with_adapters(adapters, ["NRW"])
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
assert result.scan_date == today
# ─── _render_plain ────────────────────────────────────────────────────────────
class TestRenderPlain:
def _make_result(self, new_total=2, total_seen=10, errors=None) -> DailyScanResult:
results = [
BundeslandScanResult(bundesland="NRW", total_seen=8, new_count=2),
BundeslandScanResult(bundesland="BY", total_seen=2, new_count=0),
]
if errors:
results.append(
BundeslandScanResult(bundesland="SN", total_seen=0, new_count=0, error=errors[0])
)
return DailyScanResult(
scan_date="2026-04-20",
results=results,
new_total=new_total,
total_seen=total_seen,
estimated_cost_eur=0.0093,
errors=errors or [],
)
def test_contains_scan_date(self):
text = _render_plain(self._make_result(), [])
assert "2026-04-20" in text
def test_contains_new_total(self):
text = _render_plain(self._make_result(new_total=5), [])
assert "5" in text
def test_contains_bundesland_codes(self):
text = _render_plain(self._make_result(), [])
assert "NRW" in text
assert "BY" in text
def test_errors_listed_when_present(self):
text = _render_plain(self._make_result(errors=["SN: Fake-Fehler"]), [])
assert "Fehler" in text
assert "SN" in text
def test_new_docs_listed(self):
docs = [{"bundesland": "NRW", "drucksache": "18/9999", "title": "Klimaschutz Plus",
"fraktionen": ["GRÜNE"]}]
text = _render_plain(self._make_result(), docs)
assert "18/9999" in text
assert "Klimaschutz Plus" in text
def test_truncation_after_30_docs(self):
docs = [
{"bundesland": "NRW", "drucksache": f"18/{i}", "title": f"Antrag {i}", "fraktionen": []}
for i in range(35)
]
text = _render_plain(self._make_result(), docs)
assert "und 5 weitere" in text
def test_no_truncation_marker_for_30_or_fewer(self):
docs = [
{"bundesland": "NRW", "drucksache": f"18/{i}", "title": f"Antrag {i}", "fraktionen": []}
for i in range(30)
]
text = _render_plain(self._make_result(), docs)
assert "weitere" not in text
# ─── Coverage-Backfill (#134) ────────────────────────────────────────────────
class TestSearchAdapterFallbackLogging:
"""Erste/zweite Query schlagen fehl → Debug-Log, dritter klappt."""
def test_fallback_logs_intermediate_failures(self, caplog):
import asyncio
import logging
from app.monitoring import _search_adapter
class FakeAdapter:
calls = 0
async def search(self, q, limit):
FakeAdapter.calls += 1
if FakeAdapter.calls < 3:
raise RuntimeError(f"transient {FakeAdapter.calls}")
return ["ok"]
with caplog.at_level(logging.DEBUG, logger="app.monitoring"):
result = asyncio.run(_search_adapter(FakeAdapter(), "BX"))
assert result == ["ok"]
class TestDailyScanDbUpsertFailure:
"""Wenn upsert_monitoring_scan fuer einzelne Drucksache crasht,
wird der Rest weiter verarbeitet (Line 191-192).
Adapter werden aus app.parlamente.ADAPTERS importiert also
monkey-patchen wir dort.
"""
def test_upsert_exception_logged_and_skipped(self, monkeypatch, caplog):
import asyncio
import logging
from types import SimpleNamespace
from app import monitoring as mon
from app.bundeslaender import Bundesland
import app.parlamente as parl_mod
import app.database as db_mod
# Adapter mit zwei Drucksachen
class FakeAdapter:
async def search(self, q, limit):
return [
SimpleNamespace(bundesland="BX", drucksache="1/1",
title="A1", datum="2026-04-01",
typ="Antrag", typ_normiert="antrag",
fraktionen=["CDU"], link="https://x"),
SimpleNamespace(bundesland="BX", drucksache="1/2",
title="A2", datum="2026-04-02",
typ="Antrag", typ_normiert="antrag",
fraktionen=["SPD"], link="https://y"),
]
# Erster upsert wirft, zweiter klappt
call_count = {"n": 0}
async def fake_upsert(**kw):
call_count["n"] += 1
if call_count["n"] == 1:
raise RuntimeError("DB-Lock")
return True
async def fake_summary(**kw):
return None
# Fake-BL fuer aktive_bundeslaender
fake_bl = Bundesland(
code="BX", name="Test-BL", parlament_name="Test", wahlperiode=1,
wahlperiode_start="2024-01-01", naechste_wahl=None,
regierungsfraktionen=[], landtagsfraktionen=[],
doku_system="Test", doku_base_url="http://example.com",
drucksache_format="1/1234", dokukratie_scraper=None, aktiv=True,
)
monkeypatch.setattr(mon, "aktive_bundeslaender", lambda: [fake_bl])
# Adapter-Dict im parlamente-Modul (von dem mon importiert)
monkeypatch.setitem(parl_mod.ADAPTERS, "BX", FakeAdapter())
monkeypatch.setattr(db_mod, "upsert_monitoring_scan", fake_upsert)
monkeypatch.setattr(db_mod, "upsert_monitoring_summary", fake_summary)
with caplog.at_level(logging.ERROR, logger="app.monitoring"):
result = asyncio.run(mon.daily_scan(limit=10))
bx_results = [r for r in result.results if r.bundesland == "BX"]
assert len(bx_results) == 1
# Erster crashte → new_count=1 (zweiter klappte)
assert bx_results[0].new_count == 1
assert call_count["n"] == 2
assert any("DB-UPSERT fehlgeschlagen" in r.message for r in caplog.records)
class TestSendMonitoringDigest:
"""run_monitoring_digest rendert Template, ruft send_mail."""
def test_mail_sent_returns_true(self, monkeypatch, tmp_path):
import asyncio
from app import monitoring as mon
async def fake_scan(**kw):
return mon.DailyScanResult(
scan_date="2026-04-28",
results=[], new_total=0, total_seen=0,
estimated_cost_eur=0.0, errors=[],
)
async def fake_get_new_today(scan_date):
return []
async def fake_send_mail(to, subj, text, html):
return None
monkeypatch.setattr(mon, "daily_scan", fake_scan)
# Importer-Patches innerhalb der Funktion sind tricky — wir patchen
# stattdessen die Module-Funktionen direkt
import app.mail
import app.database
monkeypatch.setattr(app.mail, "send_mail", fake_send_mail)
monkeypatch.setattr(app.database, "get_monitoring_new_today",
fake_get_new_today)
result = asyncio.run(mon.run_monitoring_digest("admin@test"))
assert result["mail_sent"] is True
assert result["scan_date"] == "2026-04-28"
def test_mail_failure_returns_false_but_not_raises(self, monkeypatch):
import asyncio
from app import monitoring as mon
async def fake_scan(**kw):
return mon.DailyScanResult(
scan_date="2026-04-28",
results=[], new_total=0, total_seen=0,
estimated_cost_eur=0.0, errors=[],
)
async def fake_get_new_today(scan_date):
return []
async def failing_send_mail(to, subj, text, html):
raise ConnectionError("SMTP down")
monkeypatch.setattr(mon, "daily_scan", fake_scan)
import app.mail
import app.database
monkeypatch.setattr(app.mail, "send_mail", failing_send_mail)
monkeypatch.setattr(app.database, "get_monitoring_new_today",
fake_get_new_today)
result = asyncio.run(mon.run_monitoring_digest("admin@test"))
assert result["mail_sent"] is False