gwoe-antragspruefer/tests/test_abgeordnetenwatch.py
Dotty Dotter 2902164eff test: 467 -> 574 Tests (+107) — DDD, abgeordnetenwatch, monitoring, v2, Bug-Regressions
Neue Tests in dieser Migration:
- test_database.py (Merkliste-CRUD, Subscriptions, abgeordnetenwatch-Joins)
- test_clustering.py (82% Coverage)
- test_drucksache_typen.py (100%)
- test_mail.py (86%)
- test_monitoring.py (23 Tests)
- test_abgeordnetenwatch.py (23 Tests, inkl. Drucksache-Extraction)
- test_redline_parser.py (20 Tests fuer §INS§/§DEL§-Marker)
- test_bug_regressions.py (PRAGMA, JWT-azp, CDU-PDF, PFLICHT-FRAKTIONEN, NRW-Titel)
- test_embeddings_v3_v4.py (WRITE/READ-Pattern)
- test_wahlprogramm_check.py (#128)
- test_wahlprogramm_fetch.py (#138)
- test_antrag/bewertung/abonnement_repository.py + test_llm_bewerter.py (DDD)
- test_domain_behavior.py (5 Domain-Methoden boundary tests)
- tests/e2e/test_ui.py (Playwright)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00

406 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests für app/abgeordnetenwatch.py und die DB-Funktionen (#106 Phase 1)."""
from __future__ import annotations
import asyncio
import json
import sys
import types
import pytest
# ─── aiosqlite-Stub-Schutz (analog test_database.py) ────────────────────────
_aio = sys.modules.get("aiosqlite")
if _aio is not None and not hasattr(_aio, "connect"):
del sys.modules["aiosqlite"]
import aiosqlite as _real_aiosqlite # noqa: E402
import importlib as _importlib
for _mod in ("app.database", "app.abgeordnetenwatch"):
if _mod in sys.modules:
_db_mod = sys.modules[_mod]
if _mod == "app.database" and not hasattr(
getattr(_db_mod, "aiosqlite", None), "connect"
):
del sys.modules[_mod]
_importlib.import_module(_mod)
else:
_importlib.import_module(_mod)
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
# ─── Fixtures ────────────────────────────────────────────────────────────────
@pytest.fixture()
def db_path(tmp_path, monkeypatch):
path = tmp_path / "test_aw.db"
from app.config import settings
monkeypatch.setattr(settings, "db_path", str(path))
return str(path)
@pytest.fixture()
def initialized_db(db_path):
from app import database
run(database.init_db())
return db_path
# ─── extract_drucksache_from_intro ───────────────────────────────────────────
class TestExtractDrucksache:
def test_simple_match(self):
from app.abgeordnetenwatch import extract_drucksache_from_intro
html = "<p>Beratung des Antrags 18/1234 der Fraktion SPD.</p>"
assert extract_drucksache_from_intro(html) == "18/1234"
def test_match_in_link(self):
from app.abgeordnetenwatch import extract_drucksache_from_intro
html = '<a href="...">Drucksache 7/98765</a>'
assert extract_drucksache_from_intro(html) == "7/98765"
def test_first_match_wins(self):
from app.abgeordnetenwatch import extract_drucksache_from_intro
html = "Antrag 17/100 und 18/200"
assert extract_drucksache_from_intro(html) == "17/100"
def test_no_match_returns_none(self):
from app.abgeordnetenwatch import extract_drucksache_from_intro
html = "<p>Kein Bezug auf eine Drucksache hier.</p>"
assert extract_drucksache_from_intro(html) is None
def test_empty_string_returns_none(self):
from app.abgeordnetenwatch import extract_drucksache_from_intro
assert extract_drucksache_from_intro("") is None
def test_none_input_returns_none(self):
from app.abgeordnetenwatch import extract_drucksache_from_intro
assert extract_drucksache_from_intro(None) is None
def test_too_short_sequence_not_matched(self):
"""Zwei Ziffern reichen nicht aus (min. 3 nach Slash)."""
from app.abgeordnetenwatch import extract_drucksache_from_intro
html = "Seite 3/12 — nicht relevant"
assert extract_drucksache_from_intro(html) is None
def test_two_digit_wp_number(self):
from app.abgeordnetenwatch import extract_drucksache_from_intro
html = "Bezug: 19/12345"
assert extract_drucksache_from_intro(html) == "19/12345"
# ─── PARLIAMENT_ID-Mapping ────────────────────────────────────────────────────
class TestParliamentIdMapping:
def test_bt_maps_to_5(self):
from app.abgeordnetenwatch import PARLIAMENT_ID
assert PARLIAMENT_ID["BT"] == 5
def test_bund_alias_maps_to_5(self):
from app.abgeordnetenwatch import PARLIAMENT_ID
assert PARLIAMENT_ID["BUND"] == 5
def test_nrw_maps_to_4(self):
from app.abgeordnetenwatch import PARLIAMENT_ID
assert PARLIAMENT_ID["NRW"] == 4
def test_all_16_bundeslaender_plus_bt_present(self):
from app.abgeordnetenwatch import PARLIAMENT_ID
expected_codes = {
"BT", "NRW", "BE", "HH", "BW", "RP", "LSA", "MV",
"HB", "HE", "NI", "BY", "SL", "TH", "BB", "SN", "SH",
}
assert expected_codes <= set(PARLIAMENT_ID.keys())
# ─── fetch_polls — Stub-Test via httpx mock ───────────────────────────────────
class TestFetchPollsStub:
def test_fetch_polls_parses_response(self, monkeypatch):
"""Stub-Test: httpx.AsyncClient.get gibt eine Fake-API-Antwort zurück."""
import httpx
from app import abgeordnetenwatch as aw_mod
fake_polls = [
{
"id": 42,
"label": "Testabstimmung 1",
"field_poll_date": "2026-04-01",
"field_accepted": True,
"field_topics": [{"label": "Klimaschutz"}],
"field_intro": "<p>Antrag 18/999 der Fraktion GRÜNE</p>",
"field_legislature": {"label": "18. Wahlperiode"},
}
]
class FakeResponse:
def raise_for_status(self): pass
def json(self): return {"data": fake_polls}
class FakeClient:
async def __aenter__(self): return self
async def __aexit__(self, *a): pass
async def get(self, url, params=None): return FakeResponse()
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: FakeClient())
polls = run(aw_mod.fetch_polls("NRW", limit=10))
assert len(polls) == 1
assert polls[0]["id"] == 42
assert polls[0]["drucksache"] == "18/999"
assert polls[0]["field_accepted"] is True
def test_fetch_polls_unknown_bundesland_raises(self):
from app.abgeordnetenwatch import fetch_polls
with pytest.raises(ValueError, match="Unbekannter BL-Code"):
run(fetch_polls("XX"))
def test_fetch_votes_parses_response(self, monkeypatch):
"""Stub-Test: Votes werden korrekt geparst."""
import httpx
from app import abgeordnetenwatch as aw_mod
fake_votes = [
{
"mandate": {
"id": 101,
"label": "Erika Mustermann",
"party": {"label": "SPD"},
},
"vote": "yes",
},
{
"mandate": {
"id": 102,
"label": "Max Muster",
"party": {"label": "CDU"},
},
"vote": "no",
},
]
class FakeResponse:
def raise_for_status(self): pass
def json(self): return {"data": fake_votes}
class FakeClient:
async def __aenter__(self): return self
async def __aexit__(self, *a): pass
async def get(self, url, params=None): return FakeResponse()
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: FakeClient())
votes = run(aw_mod.fetch_votes_for_poll(42))
assert len(votes) == 2
assert votes[0]["politician_id"] == 101
assert votes[0]["vote"] == "yes"
assert votes[0]["partei"] == "SPD"
assert votes[1]["vote"] == "no"
def test_fetch_votes_unknown_vote_value_becomes_no_show(self, monkeypatch):
import httpx
from app import abgeordnetenwatch as aw_mod
fake_votes = [{"mandate": {"id": 1, "label": "X"}, "vote": "gibberish"}]
class FakeResponse:
def raise_for_status(self): pass
def json(self): return {"data": fake_votes}
class FakeClient:
async def __aenter__(self): return self
async def __aexit__(self, *a): pass
async def get(self, url, params=None): return FakeResponse()
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: FakeClient())
votes = run(aw_mod.fetch_votes_for_poll(99))
assert votes[0]["vote"] == "no_show"
# ─── DB-Upsert-Round-Trip ─────────────────────────────────────────────────────
class TestDbUpsertRoundTrip:
def test_upsert_poll_new(self, initialized_db):
from app import database
is_new = run(database.upsert_aw_poll(
poll_id=1001,
parliament_id=4,
bundesland="NRW",
drucksache="18/500",
titel="Test-Abstimmung",
datum="2026-04-01",
accepted=True,
topics=["Klimaschutz"],
legislature_label="18. Wahlperiode",
synced_at="2026-04-20T10:00:00",
))
assert is_new is True
def test_upsert_poll_update_returns_false(self, initialized_db):
from app import database
run(database.upsert_aw_poll(
poll_id=1002, parliament_id=4, bundesland="NRW",
drucksache="18/501", titel="Alt", datum="2026-03-01",
accepted=False, topics=[], legislature_label="",
synced_at="2026-04-20T10:00:00",
))
is_new = run(database.upsert_aw_poll(
poll_id=1002, parliament_id=4, bundesland="NRW",
drucksache="18/501", titel="Neu", datum="2026-03-01",
accepted=True, topics=[], legislature_label="",
synced_at="2026-04-20T11:00:00",
))
assert is_new is False
def test_upsert_vote_new(self, initialized_db):
from app import database
run(database.upsert_aw_poll(
poll_id=2001, parliament_id=4, bundesland="NRW",
drucksache=None, titel="V-Test", datum="2026-04-01",
accepted=True, topics=[], legislature_label="",
synced_at="2026-04-20T10:00:00",
))
is_new = run(database.upsert_aw_vote(
poll_id=2001, politician_id=999,
politician_name="Test Politiker",
partei="SPD",
vote="yes",
))
assert is_new is True
def test_upsert_vote_update_returns_false(self, initialized_db):
from app import database
run(database.upsert_aw_poll(
poll_id=2002, parliament_id=4, bundesland="NRW",
drucksache=None, titel="V-Test2", datum="2026-04-01",
accepted=True, topics=[], legislature_label="",
synced_at="2026-04-20T10:00:00",
))
run(database.upsert_aw_vote(2002, 888, "Name", "CDU", "no"))
is_new = run(database.upsert_aw_vote(2002, 888, "Name", "CDU", "yes"))
assert is_new is False
def test_get_abstimmungsverhalten_returns_none_for_missing(self, initialized_db):
from app import database
result = run(database.get_abstimmungsverhalten("99/9999"))
assert result is None
def test_get_abstimmungsverhalten_aggregates(self, initialized_db):
from app import database
# Poll anlegen
run(database.upsert_aw_poll(
poll_id=3001, parliament_id=4, bundesland="NRW",
drucksache="18/3001", titel="AggTest", datum="2026-04-10",
accepted=True, topics=[], legislature_label="",
synced_at="2026-04-20T10:00:00",
))
# Votes: 2× SPD yes, 1× CDU no, 1× CDU abstain
run(database.upsert_aw_vote(3001, 1, "A", "SPD", "yes"))
run(database.upsert_aw_vote(3001, 2, "B", "SPD", "yes"))
run(database.upsert_aw_vote(3001, 3, "C", "CDU", "no"))
run(database.upsert_aw_vote(3001, 4, "D", "CDU", "abstain"))
result = run(database.get_abstimmungsverhalten("18/3001"))
assert result is not None
assert result["accepted"] is True
fraktionen = {f["partei"]: f for f in result["fraktionen"]}
assert fraktionen["SPD"]["yes"] == 2
assert fraktionen["CDU"]["no"] == 1
assert fraktionen["CDU"]["abstain"] == 1
def test_aw_tables_created_by_init_db(self, db_path):
"""init_db legt abgeordnetenwatch_polls und _votes an."""
import aiosqlite
from app import database
run(database.init_db())
async def check():
async with aiosqlite.connect(db_path) as db:
cur = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)
return {r[0] for r in await cur.fetchall()}
tables = run(check())
assert "abgeordnetenwatch_polls" in tables
assert "abgeordnetenwatch_votes" in tables
# ─── fallback_drucksache_by_date_title (#142 Phase 3) ────────────────────────
class TestFallbackDrucksacheByDateTitle:
"""Unit-Tests für den Datum+Titel-Fallback-Lookup gegen die Assessments-DB."""
@pytest.fixture()
def db_with_assessment(self, initialized_db):
"""DB mit einem vorbereiteten Assessment für den Fallback-Test."""
from app import database
run(database.upsert_assessment({
"drucksache": "7/1234",
"title": "Antrag zur Verbesserung des Nahverkehrs in MV",
"bundesland": "MV",
"datum": "2026-04-10",
"fraktionen": ["SPD"],
"gwoeScore": 7.0,
"gwoeBegründung": "Test",
"gwoeMatrix": [],
"gwoeSchwerpunkt": [],
"wahlprogrammScores": [],
"verbesserungen": [],
"stärken": [],
"schwächen": [],
"themen": [],
"antragZusammenfassung": "",
"antragKernpunkte": [],
"source": "batch",
"model": "test",
}))
return initialized_db
def test_fallback_finds_match_within_14_days(self, db_with_assessment):
from app.abgeordnetenwatch import fallback_drucksache_by_date_title
result = run(fallback_drucksache_by_date_title(
datum="2026-04-12", # 2 Tage nach Assessment-Datum
titel="Verbesserung des Nahverkehrs in MV",
bundesland="MV",
))
assert result == "7/1234"
def test_fallback_returns_none_outside_14_days(self, db_with_assessment):
from app.abgeordnetenwatch import fallback_drucksache_by_date_title
result = run(fallback_drucksache_by_date_title(
datum="2026-05-01", # 21 Tage nach Assessment-Datum
titel="Verbesserung des Nahverkehrs in MV",
bundesland="MV",
))
assert result is None
def test_fallback_returns_none_wrong_bundesland(self, db_with_assessment):
from app.abgeordnetenwatch import fallback_drucksache_by_date_title
result = run(fallback_drucksache_by_date_title(
datum="2026-04-10",
titel="Verbesserung des Nahverkehrs in MV",
bundesland="BY", # falsches BL
))
assert result is None
def test_fallback_returns_none_no_titel_match(self, db_with_assessment):
from app.abgeordnetenwatch import fallback_drucksache_by_date_title
result = run(fallback_drucksache_by_date_title(
datum="2026-04-10",
titel="Irgendwas voellig anderes ohne Treffer",
bundesland="MV",
))
assert result is None
def test_fallback_returns_none_for_missing_inputs(self, db_with_assessment):
from app.abgeordnetenwatch import fallback_drucksache_by_date_title
assert run(fallback_drucksache_by_date_title(None, "Titel", "MV")) is None
assert run(fallback_drucksache_by_date_title("2026-04-10", None, "MV")) is None