"""Tests fuer den Atom-Feed-Endpoint /api/feed.xml (#125). Backfill aus #134: vorher nur indirekt im Smoke-Test abgedeckt. Hier: - Atom-1.0-Validitaet (XML well-formed, Pflicht-Elemente) - Filter-Parameter wirken (bundesland, partei) - ETag-Header + 304-Verhalten - Limit-Clamping - HTML-Escaping fuer Sonderzeichen in Titeln/Drucksachen """ from __future__ import annotations import pytest from unittest.mock import patch from xml.etree import ElementTree as ET try: from fastapi.testclient import TestClient from app.main import app client = TestClient(app) _HAS_APP = True except ImportError: _HAS_APP = False client = None pytestmark = pytest.mark.skipif(not _HAS_APP, reason="app.main not importable") ATOM_NS = "{http://www.w3.org/2005/Atom}" def _fake_assessments() -> list[dict]: """Drei Fixture-Assessments mit allen Feldern, die der Feed nutzt.""" return [ { "drucksache": "21/1234", "title": "Antrag zu Erneuerbaren Energien", "bundesland": "NRW", "fraktionen": ["GRÜNE", "SPD"], "gwoe_score": 7.5, "empfehlung": "Unterstützen mit Änderungen", "antrag_zusammenfassung": "Solarpflicht für Neubauten", "updated_at": "2026-04-25T10:00:00", }, { "drucksache": "8/4242", "title": "Anti-Terror-Paket & Überwachung", # Sonderzeichen "bundesland": "MV", "fraktionen": ["CDU"], "gwoe_score": 2.1, "empfehlung": "Ablehnen", "antrag_zusammenfassung": None, "updated_at": "2026-04-24T08:30:00", }, { "drucksache": "19/9999", "title": "Bürger:innen-Beteiligung stärken", "bundesland": "BE", "fraktionen": ["LINKE", "GRÜNE"], "gwoe_score": 9.0, "empfehlung": "Uneingeschränkt unterstützen", "antrag_zusammenfassung": "Bürgerräte etablieren", "updated_at": "2026-04-26T12:15:00", }, ] class TestFeedXml: def test_returns_atom_xml(self): with patch("app.main.get_all_assessments", return_value=_fake_assessments()): resp = client.get("/api/feed.xml") assert resp.status_code == 200 assert "atom+xml" in resp.headers["content-type"] # XML well-formed root = ET.fromstring(resp.content) assert root.tag == f"{ATOM_NS}feed" def test_required_atom_elements_present(self): with patch("app.main.get_all_assessments", return_value=_fake_assessments()): resp = client.get("/api/feed.xml") root = ET.fromstring(resp.content) # Pflicht-Top-Level-Elemente nach RFC 4287 for tag in ("id", "title", "updated"): assert root.find(f"{ATOM_NS}{tag}") is not None, f"missing <{tag}>" # mind. ein self-Link self_links = [ l for l in root.findall(f"{ATOM_NS}link") if l.get("rel") == "self" ] assert len(self_links) == 1 def test_entry_count_matches_input(self): with patch("app.main.get_all_assessments", return_value=_fake_assessments()): resp = client.get("/api/feed.xml") root = ET.fromstring(resp.content) entries = root.findall(f"{ATOM_NS}entry") assert len(entries) == 3 def test_entries_sorted_by_updated_desc(self): with patch("app.main.get_all_assessments", return_value=_fake_assessments()): resp = client.get("/api/feed.xml") root = ET.fromstring(resp.content) updateds = [ e.find(f"{ATOM_NS}updated").text for e in root.findall(f"{ATOM_NS}entry") ] # Strip Z-suffix fuer Vergleich bare = [u.rstrip("Z") for u in updateds] assert bare == sorted(bare, reverse=True), updateds def test_html_escaping_in_titles(self): """Anti-Terror-Paket & Überwachung — & muss als & im XML stehen.""" with patch("app.main.get_all_assessments", return_value=_fake_assessments()): resp = client.get("/api/feed.xml") # Roh-XML pruefen, nicht den geparsten Inhalt body = resp.text # Das Ampersand muss als & codiert sein assert "Anti-Terror-Paket &" in body or "Anti-Terror-Paket &#" in body # Der Roh-String darf kein nacktes & vor Whitespace haben assert "Paket & Überw" not in body def test_partei_filter_narrows_results(self): with patch("app.main.get_all_assessments", return_value=_fake_assessments()): resp_all = client.get("/api/feed.xml") resp_cdu = client.get("/api/feed.xml?partei=CDU") all_count = len(ET.fromstring(resp_all.content).findall(f"{ATOM_NS}entry")) cdu_count = len(ET.fromstring(resp_cdu.content).findall(f"{ATOM_NS}entry")) assert cdu_count == 1 assert cdu_count < all_count def test_bundesland_filter_passed_to_query(self): """Der bundesland-Parameter wird an get_all_assessments durchgereicht.""" with patch("app.main.get_all_assessments", return_value=_fake_assessments()) as m: client.get("/api/feed.xml?bundesland=NRW") m.assert_called_once_with("NRW") def test_etag_header_set(self): with patch("app.main.get_all_assessments", return_value=_fake_assessments()): resp = client.get("/api/feed.xml") assert "etag" in {k.lower() for k in resp.headers} etag = resp.headers["etag"] assert etag.startswith('"') and etag.endswith('"') def test_etag_304_not_modified(self): with patch("app.main.get_all_assessments", return_value=_fake_assessments()): resp1 = client.get("/api/feed.xml") etag = resp1.headers["etag"] resp2 = client.get("/api/feed.xml", headers={"If-None-Match": etag}) assert resp2.status_code == 304 def test_limit_clamped_to_200(self): big_input = _fake_assessments() * 100 # 300 Eintraege with patch("app.main.get_all_assessments", return_value=big_input): resp = client.get("/api/feed.xml?limit=500") root = ET.fromstring(resp.content) entries = root.findall(f"{ATOM_NS}entry") assert len(entries) == 200 def test_limit_clamped_to_min_1(self): with patch("app.main.get_all_assessments", return_value=_fake_assessments()): resp = client.get("/api/feed.xml?limit=0") root = ET.fromstring(resp.content) entries = root.findall(f"{ATOM_NS}entry") assert len(entries) >= 1 def test_empty_db_returns_valid_feed(self): with patch("app.main.get_all_assessments", return_value=[]): resp = client.get("/api/feed.xml") assert resp.status_code == 200 root = ET.fromstring(resp.content) # Pflicht-Elemente trotzdem da assert root.find(f"{ATOM_NS}id") is not None assert root.find(f"{ATOM_NS}title") is not None # Aber keine Entries assert root.findall(f"{ATOM_NS}entry") == [] def test_cors_header_present(self): with patch("app.main.get_all_assessments", return_value=[]): resp = client.get("/api/feed.xml") assert resp.headers.get("access-control-allow-origin") == "*" def test_self_url_includes_filter_params(self): with patch("app.main.get_all_assessments", return_value=_fake_assessments()): resp = client.get("/api/feed.xml?bundesland=NRW&partei=GRÜNE") root = ET.fromstring(resp.content) self_link = [l for l in root.findall(f"{ATOM_NS}link") if l.get("rel") == "self"][0] href = self_link.get("href") assert "bundesland=NRW" in href # partei kann URL-codiert sein assert "partei=" in href