"""Unit-Tests für /api/feedback — gemockter Gitea-Call. Prüft: - Issue-Body wird korrekt aus Eingaben + Audit-Trail zusammengebaut - Endpoint antwortet mit issue_id und issue_url - Rate-Limit-Decorator ist deklariert - Kein Token → 503 """ import json import pytest from unittest.mock import AsyncMock, MagicMock, patch # Skip falls die App-Abhängigkeiten nicht importierbar sind try: from fastapi.testclient import TestClient from app.main import app, _strip_html, _gitea_ensure_label _HAS_APP = True except ImportError: _HAS_APP = False pytestmark = pytest.mark.skipif(not _HAS_APP, reason="app.main not importable") # ── _strip_html ────────────────────────────────────────────────────────────── class TestStripHtml: def test_removes_tags(self): assert _strip_html("hallo", 200) == "hallo" def test_max_len(self): assert len(_strip_html("a" * 300, 100)) == 100 def test_empty(self): assert _strip_html("", 200) == "" def test_no_tags(self): assert _strip_html("plain text", 200) == "plain text" # ── /api/feedback Endpoint ─────────────────────────────────────────────────── class TestFeedbackEndpoint: """Smoke-Tests mit gemocktem httpx-Client + gemocktem gitea_token.""" @pytest.fixture(autouse=True) def _mock_gitea(self): """Patches settings.gitea_token und httpx.AsyncClient.""" # settings.gitea_token setzen with patch("app.main.settings") as mock_settings: mock_settings.gitea_token = "fake-token-123" mock_settings.gitea_api_url = "https://repo.example.com/api/v1" mock_settings.gitea_repo_owner = "testowner" mock_settings.gitea_repo_name = "testrepo" # httpx.AsyncClient mocken mock_resp_labels = MagicMock() mock_resp_labels.status_code = 200 mock_resp_labels.json.return_value = [{"id": 7, "name": "feedback"}] mock_resp_issue = MagicMock() mock_resp_issue.status_code = 201 mock_resp_issue.json.return_value = { "number": 42, "html_url": "https://repo.example.com/testowner/testrepo/issues/42", } async_client = AsyncMock() async_client.get.return_value = mock_resp_labels async_client.post.return_value = mock_resp_issue async_client.__aenter__ = AsyncMock(return_value=async_client) async_client.__aexit__ = AsyncMock(return_value=False) with patch("httpx.AsyncClient", return_value=async_client): self._async_client = async_client yield def test_happy_path_returns_issue_url(self): client = TestClient(app) resp = client.post("/api/feedback", data={ "titel": "Test-Bug", "beschreibung": "Etwas ist kaputt", "url": "https://gwoe.toppyr.de/antrag/NRW-18/1234", "drucksache": "NRW-18/1234", "viewport": "1440x900", "user_agent": "TestAgent/1.0", "klicks_json": json.dumps([{"t": "2026-04-25T10:00:00Z", "el": "button.v2-nav-item", "txt": "Durchsuchen"}]), "errors_json": json.dumps([]), }) assert resp.status_code == 200 data = resp.json() assert data["issue_id"] == 42 assert "issues/42" in data["issue_url"] def test_issue_body_contains_drucksache(self): """Stellt sicher, dass die Drucksachen-Nummer im POST-Payload auftaucht.""" client = TestClient(app) client.post("/api/feedback", data={ "titel": "Body-Check", "beschreibung": "Details", "drucksache": "BY-18/9999", "klicks_json": "[]", "errors_json": "[]", }) # Zweiter Post-Call ist der Issue-Create-Call calls = self._async_client.post.call_args_list issue_call = next((c for c in calls if "/issues" in str(c)), None) assert issue_call is not None body_arg = issue_call.kwargs.get("json", {}).get("body", "") assert "BY-18/9999" in body_arg def test_missing_titel_returns_422(self): client = TestClient(app) resp = client.post("/api/feedback", data={ "beschreibung": "Ohne Titel", "klicks_json": "[]", "errors_json": "[]", }) assert resp.status_code == 422 def test_missing_beschreibung_returns_422(self): client = TestClient(app) resp = client.post("/api/feedback", data={ "titel": "Ohne Beschreibung", "klicks_json": "[]", "errors_json": "[]", }) assert resp.status_code == 422 def test_html_stripped_from_titel(self): """XSS im Titel wird entfernt.""" client = TestClient(app) client.post("/api/feedback", data={ "titel": "Bug", "beschreibung": "XSS-Test", "klicks_json": "[]", "errors_json": "[]", }) calls = self._async_client.post.call_args_list issue_call = next((c for c in calls if "/issues" in str(c)), None) if issue_call: title_arg = issue_call.kwargs.get("json", {}).get("title", "") assert "