From 60c4b6e8186d0380e1037864dcf402ec428aaf82 Mon Sep 17 00:00:00 2001 From: Flo Hartmann Date: Fri, 21 Aug 2026 23:14:58 +0000 Subject: [PATCH] =?UTF-8?q?Plugin=20neuheiten:=20Bearer-Token-Pflicht=20de?= =?UTF-8?q?r=20BGG-XML-API2=20(Fix=20f=C3=BCr=20HTTP=20401)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BoardGameGeek verlangt für die XML API2 jetzt Bearer-Authentifizierung (Thread 3602374); ohne Token antwortet die API mit 401. - BggClient: neuer Parameter token, sendet Authorization: Bearer … auf allen Requests; Token kommt aus SPIELE_BGG_TOKEN (vor jedem Sync gelesen) - HTTP 401 → klarer BggAuthFehler ('BGG hat die Anfrage abgelehnt (401) — bitte gültigen API-Token in SPIELE_BGG_TOKEN hinterlegen.') statt generischer Meldung, kein Retry - Ohne konfigurierten Token wird der Sync übersprungen ('Kein BGG-API-Token konfiguriert (SPIELE_BGG_TOKEN) — Sync übersprungen.'), statt Requests zu feuern; Meldung erscheint im Sync-Ergebnis, UI-Banner und Protokoll - SyncService: BggAuthFehler bricht den Lauf ab (weitere Suchbegriffe scheitern zwangsläufig gleich), Teilergebnisse bleiben erhalten - Tests: Bearer-Header, 401-Fall (Client/Sync/UI), Skip ohne Token; bestehende Sync-UI-Tests setzen Test-Token - README: neue Env-Variable SPIELE_BGG_TOKEN + Hinweis auf Token-Pflicht --- README.md | 12 ++++++ plugins/neuheiten/__init__.py | 41 +++++++++++++++++--- plugins/neuheiten/bgg.py | 31 ++++++++++++++- plugins/neuheiten/sync.py | 14 ++++++- tests/_neuheiten.py | 2 + tests/test_neuheiten_bgg.py | 72 ++++++++++++++++++++++++++++++++++- tests/test_neuheiten_sync.py | 27 +++++++++++++ tests/test_neuheiten_ui.py | 69 ++++++++++++++++++++++++++++++++- 8 files changed, 259 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 20b6367..e25fdc3 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Beim ersten Start wird automatisch ein Admin-Konto angelegt: | `SPIELE_BGG_SYNC_INTERVALL_STUNDEN` | `24` | Intervall des BGG-Syncs in Stunden (min. 1) | | `SPIELE_BGG_SUCHBEGRIFFE` | `brettspiel` | Komma-getrennte Suchbegriffe für den regelmäßigen Sync | | `SPIELE_BGG_MAX_TREFFER_PRO_SUCHE` | `25` | Obergrenze Treffer je Suchbegriff (schont das BGG-Rate-Limit) | +| `SPIELE_BGG_TOKEN` | *(leer)* | API-Token für die BGG-XML-API2, wird als `Authorization: Bearer …`-Header gesendet; seit der Token-Pflicht von BGG erforderlich (siehe [BGG-Thread 3602374](https://boardgamegeek.com/thread/3602374)) — ohne Token wird der Sync übersprungen | | `SPIELE_DEDUP_BGG_AKTIV` | `1` | BGG-Zusatzdaten für die Dedup-Prüfung an (`1`) oder aus (`0`): Alternate-Names und Erweiterungs-Relationen | | `SPIELE_ARCHIV_JOB_AKTIV` | `1` | Täglicher Archivierungs-Job an (`1`) oder aus (`0`) | | `SPIELE_ARCHIV_JOB_UHRZEIT` | `03:00` | Tageszeit des täglichen Archiv-Laufs im Format `HH:MM` | @@ -300,6 +301,17 @@ einen sofortigen Sync aus — optional mit einem Sofort-Suchbegriff für eine gezielte Recherche. Das Ergebnis (neu/aktualisiert/gefiltert) erscheint als Banner; der Endpunkt ist rollengeschützt (Rezensenten erhalten 403). +**BGG-API-Token (Pflicht):** BoardGameGeek verlangt seit seiner Umstellung +für die XML API2 eine Bearer-Authentifizierung ([Thread +3602374](https://boardgamegeek.com/thread/3602374)). Alle Requests des +Plugins senden deshalb den Header `Authorization: Bearer `; der Token +kommt aus `SPIELE_BGG_TOKEN`. Ist kein Token gesetzt, wird der Sync gar nicht +erst gestartet und mit der Meldung „Kein BGG-API-Token konfiguriert +(SPIELE_BGG_TOKEN) — Sync übersprungen.“ abgebrochen. Lehnt BGG eine Anfrage +mit HTTP 401 ab (fehlender oder ungültiger Token), erscheint statt der +generischen Fehlermeldung der Hinweis, einen gültigen API-Token in +`SPIELE_BGG_TOKEN` zu hinterlegen. + ### UI Die Seite `/neuheiten` zeigt alle Einträge als Tabelle (Spieltitel, Verlag, diff --git a/plugins/neuheiten/__init__.py b/plugins/neuheiten/__init__.py index 3c683e3..6679223 100644 --- a/plugins/neuheiten/__init__.py +++ b/plugins/neuheiten/__init__.py @@ -7,12 +7,17 @@ Umfang: - Manueller „Jetzt synchronisieren“-Button für Admins/Redakteure. - Sortier-/filterbare Neuheitenliste mit Suche (deutsche UI). -Konfiguration über Umgebungsvariablen (gelesen beim Plugin-Start): +Konfiguration über Umgebungsvariablen (gelesen beim Plugin-Start; +SPIELE_BGG_TOKEN wird dagegen vor jedem Sync gelesen): - SPIELE_BGG_SYNC_AKTIV „1“ (Standard) = Hintergrund-Job an - SPIELE_BGG_SYNC_INTERVALL_STUNDEN Intervall in Stunden (Standard 24) - SPIELE_BGG_SUCHBEGRIFFE Komma-getrennte Suchbegriffe (Standard: „brettspiel“) - SPIELE_BGG_MAX_TREFFER_PRO_SUCHE Obergrenze Treffer/Suchbegriff (25) +- SPIELE_BGG_TOKEN API-Token für die BGG-XML-API2 + (Pflicht seit BGG-Umstellung, wird als + „Authorization: Bearer …“ gesendet); + ohne Token wird der Sync übersprungen Keine Fachlogik im Kern — alles hier im Plugin gemäß Plugin-Vertrag. """ @@ -33,10 +38,14 @@ from redaktionskern.contracts import BasePlugin, Migration, NavEntry, PluginCont from .bgg import BggClient, BggFehler from .models import Neuheit -from .sync import SyncService +from .sync import SyncErgebnis, SyncService _logger = logging.getLogger("plugins.neuheiten") +MELDUNG_OHNE_TOKEN = ( + "Kein BGG-API-Token konfiguriert (SPIELE_BGG_TOKEN) — Sync übersprungen." +) + SORTIERBAR = { "titel": Neuheit.titel, "verlag": Neuheit.verlag, @@ -151,7 +160,11 @@ class NeuheitenPlugin(BasePlugin): _logger.warning("Manueller BGG-Sync fehlgeschlagen: %s", exc) ziel = f"/neuheiten?meldung={quote(f'Sync fehlgeschlagen: {exc}')}" return RedirectResponse(ziel, status_code=303) - ziel = f"/neuheiten?meldung={quote(f'Sync abgeschlossen: {ergebnis.als_text()}')}" + if ergebnis.abbruch: + # z. B. fehlender Token oder HTTP 401 — klar benennbare Ursache + ziel = f"/neuheiten?fehler={quote(ergebnis.abbruch)}" + else: + ziel = f"/neuheiten?meldung={quote(f'Sync abgeschlossen: {ergebnis.als_text()}')}" return RedirectResponse(ziel, status_code=303) # ---------- Plugin-Vertrag ---------- @@ -188,11 +201,26 @@ class NeuheitenPlugin(BasePlugin): # ---------- Internas ---------- + def _aktives_bgg_token(self) -> str | None: + """Liest den BGG-API-Token aus der Umgebung (leer = nicht gesetzt). + + Bewusst pro Sync gelesen, damit ein rotierter Token ohne Neustart + greift. + """ + import os + + return (os.environ.get("SPIELE_BGG_TOKEN") or "").strip() or None + def _neuer_client(self) -> BggClient: """Fabrik für den BGG-Client; von Tests überschreibbar.""" - return BggClient() + return BggClient(token=self._aktives_bgg_token()) def _sync_ausfuehren(self, suchbegriffe: list[str]): + if self._aktives_bgg_token() is None: + # Ohne Token lehnt die BGG-API jeden Request mit 401 ab — + # den Sync gar nicht erst starten. + _logger.warning(MELDUNG_OHNE_TOKEN) + return SyncErgebnis(abbruch=MELDUNG_OHNE_TOKEN) client = self._neuer_client() try: service = SyncService( @@ -211,7 +239,10 @@ class NeuheitenPlugin(BasePlugin): return try: ergebnis = self._sync_ausfuehren(self._suchbegriffe) - _logger.info("BGG-Sync abgeschlossen: %s", ergebnis.als_text()) + if ergebnis.abbruch: + _logger.warning("BGG-Sync nicht ausgeführt: %s", ergebnis.abbruch) + else: + _logger.info("BGG-Sync abgeschlossen: %s", ergebnis.als_text()) except Exception as exc: _logger.warning("BGG-Sync fehlgeschlagen: %s", exc) finally: diff --git a/plugins/neuheiten/bgg.py b/plugins/neuheiten/bgg.py index f3cbc1b..cc5fb60 100644 --- a/plugins/neuheiten/bgg.py +++ b/plugins/neuheiten/bgg.py @@ -11,6 +11,13 @@ Eigenschaften: beschränkt; Erweiterungen (`boardgameexpansion`) werden zusätzlich auf Elementebene herausgefiltert. +Authentifizierung: Die XML API2 verlangt einen Bearer-Token +(Authorization-Header, siehe https://boardgamegeek.com/thread/3602374); +der Token wird dem Client über den Parameter `token` übergeben (im Betrieb +aus `SPIELE_BGG_TOKEN`). Ohne Token lehnt BGG Anfragen mit HTTP 401 ab — +der Client wandelt das in eine klare `BggAuthFehler`-Meldung um und versucht +es nicht erneut. + Für Tests sind HTTP-Transport, Uhr und Schlaf-Funktion injizierbar — es gibt keine echten Netzwerk-Aufrufe in der Testsuite. """ @@ -35,6 +42,16 @@ class BggFehler(Exception): """Fehler bei der Kommunikation mit oder dem Parsen der BGG-API.""" +class BggAuthFehler(BggFehler): + """BGG hat die Anfrage wegen fehlender/unültiger Authentifizierung abgelehnt.""" + + +MELDUNG_401 = ( + "BGG hat die Anfrage abgelehnt (401) — bitte gültigen API-Token " + "in SPIELE_BGG_TOKEN hinterlegen." +) + + @dataclass(frozen=True) class SuchTreffer: """Ein Treffer aus der BGG-Suche.""" @@ -161,6 +178,7 @@ class BggClient: def __init__( self, *, + token: str | None = None, transport: httpx.BaseTransport | None = None, mindestabstand_sekunden: float = 1.0, max_versuche: int = 4, @@ -174,14 +192,20 @@ class BggClient: self.max_versuche = max_versuche self.backoff_basis_sekunden = backoff_basis_sekunden self.backoff_maximum_sekunden = backoff_maximum_sekunden + # Bearer-Token für die XML API2 (Pflicht seit BGG-Umstellung, + # https://boardgamegeek.com/thread/3602374); leer/None = nicht gesetzt. + self.token = token.strip() if isinstance(token, str) and token.strip() else None self._schlaf = schlaf self._uhr = uhr self._letzter_request_um: float | None = None + headers: dict[str, str] = {"User-Agent": USER_AGENT} + if self.token: + headers["Authorization"] = f"Bearer {self.token}" self._http = httpx.Client( base_url=BASIS_URL, timeout=timeout_sekunden, transport=transport, - headers={"User-Agent": USER_AGENT}, + headers=headers, ) def schliessen(self) -> None: @@ -214,6 +238,11 @@ class BggClient: if antwort.status_code == 200: return antwort.content + if antwort.status_code == 401: + # Fehlender oder ungültiger Token — dauerhaft, ein Retry + # hilft nicht (Token-Pflicht, siehe Thread 3602374). + raise BggAuthFehler(MELDUNG_401) + if antwort.status_code in (202, 429) or antwort.status_code >= 500: # 202: BGG stellt die Anfrage in eine Warteschlange; # 429/5xx: temporär nicht bedienbar → Backoff und erneut. diff --git a/plugins/neuheiten/sync.py b/plugins/neuheiten/sync.py index ee1b544..ad51c6d 100644 --- a/plugins/neuheiten/sync.py +++ b/plugins/neuheiten/sync.py @@ -17,7 +17,7 @@ from dataclasses import dataclass, field from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker -from .bgg import BggSpiel, BggClient +from .bgg import BggAuthFehler, BggSpiel, BggClient from .models import STATUS_NEUHEIT, Neuheit _logger = logging.getLogger("plugins.neuheiten") @@ -42,8 +42,13 @@ class SyncErgebnis: aktualisiert: int = 0 gefiltert: int = 0 fehler: list[str] = field(default_factory=list) + # Grund, warum der Lauf vorzeitig endete oder ganz übersprungen wurde + # (z. B. fehlender BGG-Token oder HTTP 401); None = normal gelaufen. + abbruch: str | None = None def als_text(self) -> str: + if self.abbruch: + return self.abbruch text = ( f"{self.neu} neu, {self.aktualisiert} aktualisiert, " f"{self.gefiltert} gefiltert (Erweiterungen/Prototypen)" @@ -101,6 +106,13 @@ class SyncService: continue try: self._ein_suchbegriff(db, begriff, ergebnis) + except BggAuthFehler as exc: + # HTTP 401 trifft jede weitere Anfrage genauso — der Lauf + # wird abgebrochen, statt alle Begriffe einzeln scheitern + # zu lassen; bereits gespeicherte Treffer bleiben erhalten. + _logger.warning("BGG-Sync abgebrochen für %r: %s", begriff, exc) + ergebnis.abbruch = str(exc) + break except Exception as exc: # ein Begriff darf den Lauf nicht abbrechen _logger.warning("BGG-Sync fehlgeschlagen für %r: %s", begriff, exc) ergebnis.fehler.append(f"{begriff}: {exc}") diff --git a/tests/_neuheiten.py b/tests/_neuheiten.py index 02c99b8..a5cb933 100644 --- a/tests/_neuheiten.py +++ b/tests/_neuheiten.py @@ -28,6 +28,8 @@ models = _neuheiten.models BggClient = bgg.BggClient BggFehler = bgg.BggFehler +BggAuthFehler = bgg.BggAuthFehler +MELDUNG_401 = bgg.MELDUNG_401 SuchTreffer = bgg.SuchTreffer BggSpiel = bgg.BggSpiel parse_suche = bgg.parse_suche diff --git a/tests/test_neuheiten_bgg.py b/tests/test_neuheiten_bgg.py index 7a47502..300e968 100644 --- a/tests/test_neuheiten_bgg.py +++ b/tests/test_neuheiten_bgg.py @@ -8,7 +8,14 @@ from __future__ import annotations import httpx import pytest -from tests._neuheiten import BggClient, BggFehler, parse_suche, parse_things +from tests._neuheiten import ( + BggAuthFehler, + BggClient, + BggFehler, + MELDUNG_401, + parse_suche, + parse_things, +) SUCHE_XML = """ @@ -61,9 +68,11 @@ class AufzeichnenderTransport(httpx.BaseTransport): self.antworten = list(antworten) self.uhr = uhr self.anfragen: list[tuple[str, float]] = [] + self.auth_header: list[str | None] = [] # Authorization je Request def handle_request(self, request: httpx.Request) -> httpx.Response: self.anfragen.append((str(request.url), self.uhr())) + self.auth_header.append(request.headers.get("Authorization")) element = self.antworten.pop(0) if isinstance(element, Exception): raise element @@ -157,6 +166,39 @@ def test_suche_sendet_type_boardgame(): assert "query=catan" in url +def test_token_wird_als_bearer_header_gesendet(): + uhr = FakeUhr() + transport = AufzeichnenderTransport([_antwort(SUCHE_XML), _antwort(THING_XML)], uhr) + client = BggClient( + transport=transport, + token="geheim-123", + schlaf=lambda s: None, + uhr=uhr, + ) + + client.suche("catan") + client.details([13]) + + # Authorization-Header auf allen Requests (Suche und Thing): + assert transport.auth_header == ["Bearer geheim-123", "Bearer geheim-123"] + + +def test_leerer_token_gilt_als_nicht_konfiguriert(): + uhr = FakeUhr() + transport = AufzeichnenderTransport([_antwort(SUCHE_XML)], uhr) + client = BggClient( + transport=transport, + token=" ", + schlaf=lambda s: None, + uhr=uhr, + ) + assert client.token is None + + client.suche("catan") + + assert transport.auth_header == [None] + + def test_details_sendet_thing_mit_ids_und_filtert_erweiterungen(): uhr = FakeUhr() transport = AufzeichnenderTransport([_antwort(THING_XML)], uhr) @@ -266,6 +308,34 @@ def test_4xx_fuehrt_zu_fehler_ohne_weitere_versuche(): assert wartezeiten == [] +def test_401_fuehrt_zu_klarer_auth_fehlermeldung_ohne_retry(): + uhr = FakeUhr() + wartezeiten: list[float] = [] + transport = AufzeichnenderTransport( + [_antwort("unauthorized", status_code=401)], uhr + ) + client = _client(transport, uhr, _fortschreitender_schlaf(uhr, wartezeiten)) + + with pytest.raises(BggAuthFehler) as aufgetreten: + client.suche("catan") + + assert str(aufgetreten.value) == MELDUNG_401 + assert MELDUNG_401.startswith("BGG hat die Anfrage abgelehnt (401)") + assert len(transport.anfragen) == 1 # kein Retry bei dauerhaftem 401 + assert wartezeiten == [] + + +def test_401_beim_thing_request_gleiche_meldung(): + uhr = FakeUhr() + transport = AufzeichnenderTransport([_antwort("nope", status_code=401)], uhr) + client = _client(transport, uhr, lambda s: None) + + with pytest.raises(BggAuthFehler) as aufgetreten: + client.details([13]) + + assert str(aufgetreten.value) == MELDUNG_401 + + def test_max_versuche_erschoepft_mit_fehler(): uhr = FakeUhr() transport = AufzeichnenderTransport([_antwort("kaputt", status_code=500)], uhr) diff --git a/tests/test_neuheiten_sync.py b/tests/test_neuheiten_sync.py index 8d1da49..870e826 100644 --- a/tests/test_neuheiten_sync.py +++ b/tests/test_neuheiten_sync.py @@ -8,8 +8,10 @@ from sqlalchemy.orm import sessionmaker from redaktionskern.db import Base from tests._neuheiten import ( + BggAuthFehler, BggFehler, BggSpiel, + MELDUNG_401, Neuheit, SuchTreffer, SyncService, @@ -217,6 +219,31 @@ def test_prototyp_heuristik(): # ---------- Robustheit ---------- +def test_401_bricht_sync_ab_mit_klarer_meldung(session_factory): + """BGG-401: Lauf stoppt, Meldung landet im Ergebnis, Teilergebnisse bleiben.""" + + class AuthKaputterClient(FakeBggClient): + def suche(self, suchbegriff: str, *, max_treffer: int | None = None): + self.suche_aufrufe.append(suchbegriff) + if suchbegriff == "abgelehnt": + raise BggAuthFehler(MELDUNG_401) + return list(self.suchergebnisse.get(suchbegriff, [])) + + client = AuthKaputterClient( + suchergebnisse={"gut": [SuchTreffer(13, "Catan", 1995)]}, + details_pro_id={13: _spiel()}, + ) + service = SyncService(session_factory, client) + + ergebnis = service.synchronisiere(["gut", "abgelehnt", "dritter"]) + + assert ergebnis.abbruch == MELDUNG_401 + assert ergebnis.als_text() == MELDUNG_401 # klare Meldung statt Fehlerzähler + assert ergebnis.neu == 1 # Teilergebnis vor dem 401 bleibt erhalten + assert client.suche_aufrufe == ["gut", "abgelehnt"] # kein weiterer Begriff + assert len(_alle_eintraege(session_factory)) == 1 # Commit trotz Abbruch + + def test_fehler_bei_einem_suchbegriff_bricht_lauf_nicht_ab(session_factory): class HalbKaputterClient(FakeBggClient): def suche(self, suchbegriff: str, *, max_treffer: int | None = None): diff --git a/tests/test_neuheiten_ui.py b/tests/test_neuheiten_ui.py index 637a510..798fb90 100644 --- a/tests/test_neuheiten_ui.py +++ b/tests/test_neuheiten_ui.py @@ -8,7 +8,13 @@ from fastapi.testclient import TestClient from redaktionskern.app import create_app -from tests._neuheiten import BggSpiel, Neuheit, SuchTreffer +from tests._neuheiten import ( + BggAuthFehler, + BggSpiel, + MELDUNG_401, + Neuheit, + SuchTreffer, +) from tests.conftest import ADMIN_PASSWORD, lege_benutzer_an, melde_an @@ -33,6 +39,18 @@ class FakeBggClient: pass +class FehlerwerfenderFake(FakeBggClient): + """Wirft bei der Suche einen Fehler (z. B. BggAuthFehler bei HTTP 401).""" + + def __init__(self, fehler: Exception): + super().__init__() + self.fehler = fehler + + def suche(self, suchbegriff: str, *, max_treffer: int | None = None): + self.suche_aufrufe.append(suchbegriff) + raise self.fehler + + def _client_mit_fakes(monkeypatch, client: TestClient, fake: FakeBggClient) -> None: plugin = client.app.state.registry.get("neuheiten") # Instanz-Methode ersetzen; monkeypatch stellt nach dem Test wieder her @@ -165,6 +183,7 @@ def test_manueller_sync_legt_eintraege_an(monkeypatch, settings): ], ) + monkeypatch.setenv("SPIELE_BGG_TOKEN", "test-token") with TestClient(create_app(settings)) as client: melde_an(client) _client_mit_fakes(monkeypatch, client, fake) @@ -193,6 +212,7 @@ def test_manueller_sync_ohne_suchbegriff_nutzt_konfigurierte_begriffe(monkeypatc from fastapi.testclient import TestClient monkeypatch.setenv("SPIELE_BGG_SUCHBEGRIFFE", "essen, familien") + monkeypatch.setenv("SPIELE_BGG_TOKEN", "test-token") fake = FakeBggClient() with TestClient(create_app(settings)) as client: melde_an(client) @@ -201,6 +221,53 @@ def test_manueller_sync_ohne_suchbegriff_nutzt_konfigurierte_begriffe(monkeypatc assert fake.suche_aufrufe == ["essen", "familien"] +def test_sync_ohne_token_wird_uebersprungen(monkeypatch, settings): + from fastapi.testclient import TestClient + + monkeypatch.delenv("SPIELE_BGG_TOKEN", raising=False) + fake = FakeBggClient(treffer=[SuchTreffer(13, "Catan", 1995)]) + + with TestClient(create_app(settings)) as client: + melde_an(client) + _client_mit_fakes(monkeypatch, client, fake) + + antwort = client.post( + "/neuheiten/sync", + data={"suchbegriff": "catan"}, + follow_redirects=False, + ) + + assert antwort.status_code == 303 + folge = client.get(antwort.headers["location"]) + assert ( + "Kein BGG-API-Token konfiguriert (SPIELE_BGG_TOKEN) — " + "Sync übersprungen." in folge.text + ) + assert fake.suche_aufrufe == [] # kein Request an die BGG-API gefeuert + + +def test_sync_401_zeigt_klare_fehlermeldung(monkeypatch, settings): + from fastapi.testclient import TestClient + + monkeypatch.setenv("SPIELE_BGG_TOKEN", "falscher-token") + fake = FehlerwerfenderFake(BggAuthFehler(MELDUNG_401)) + + with TestClient(create_app(settings)) as client: + melde_an(client) + _client_mit_fakes(monkeypatch, client, fake) + + antwort = client.post( + "/neuheiten/sync", + data={"suchbegriff": "catan"}, + follow_redirects=False, + ) + + assert antwort.status_code == 303 + folge = client.get(antwort.headers["location"]) + assert MELDUNG_401 in folge.text + assert "Unerwartete HTTP-Antwort" not in folge.text + + def test_htmx_request_liefert_nur_teilfragment(app, client): melde_an(client) antwort = client.get("/neuheiten", headers={"HX-Request": "true"})