Plugin neuheiten: Bearer-Token-Pflicht der BGG-XML-API2 (Fix für HTTP 401)

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
This commit is contained in:
Flo Hartmann
2026-08-21 23:14:58 +00:00
parent 55f6f32f73
commit 60c4b6e818
8 changed files with 259 additions and 9 deletions

View File

@@ -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_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_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_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_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_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` | | `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 gezielte Recherche. Das Ergebnis (neu/aktualisiert/gefiltert) erscheint als
Banner; der Endpunkt ist rollengeschützt (Rezensenten erhalten 403). 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 <Token>`; 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 ### UI
Die Seite `/neuheiten` zeigt alle Einträge als Tabelle (Spieltitel, Verlag, Die Seite `/neuheiten` zeigt alle Einträge als Tabelle (Spieltitel, Verlag,

View File

@@ -7,12 +7,17 @@ Umfang:
- Manueller „Jetzt synchronisieren“-Button für Admins/Redakteure. - Manueller „Jetzt synchronisieren“-Button für Admins/Redakteure.
- Sortier-/filterbare Neuheitenliste mit Suche (deutsche UI). - 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_AKTIV „1“ (Standard) = Hintergrund-Job an
- SPIELE_BGG_SYNC_INTERVALL_STUNDEN Intervall in Stunden (Standard 24) - SPIELE_BGG_SYNC_INTERVALL_STUNDEN Intervall in Stunden (Standard 24)
- SPIELE_BGG_SUCHBEGRIFFE Komma-getrennte Suchbegriffe - SPIELE_BGG_SUCHBEGRIFFE Komma-getrennte Suchbegriffe
(Standard: „brettspiel“) (Standard: „brettspiel“)
- SPIELE_BGG_MAX_TREFFER_PRO_SUCHE Obergrenze Treffer/Suchbegriff (25) - 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. 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 .bgg import BggClient, BggFehler
from .models import Neuheit from .models import Neuheit
from .sync import SyncService from .sync import SyncErgebnis, SyncService
_logger = logging.getLogger("plugins.neuheiten") _logger = logging.getLogger("plugins.neuheiten")
MELDUNG_OHNE_TOKEN = (
"Kein BGG-API-Token konfiguriert (SPIELE_BGG_TOKEN) — Sync übersprungen."
)
SORTIERBAR = { SORTIERBAR = {
"titel": Neuheit.titel, "titel": Neuheit.titel,
"verlag": Neuheit.verlag, "verlag": Neuheit.verlag,
@@ -151,7 +160,11 @@ class NeuheitenPlugin(BasePlugin):
_logger.warning("Manueller BGG-Sync fehlgeschlagen: %s", exc) _logger.warning("Manueller BGG-Sync fehlgeschlagen: %s", exc)
ziel = f"/neuheiten?meldung={quote(f'Sync fehlgeschlagen: {exc}')}" ziel = f"/neuheiten?meldung={quote(f'Sync fehlgeschlagen: {exc}')}"
return RedirectResponse(ziel, status_code=303) 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) return RedirectResponse(ziel, status_code=303)
# ---------- Plugin-Vertrag ---------- # ---------- Plugin-Vertrag ----------
@@ -188,11 +201,26 @@ class NeuheitenPlugin(BasePlugin):
# ---------- Internas ---------- # ---------- 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: def _neuer_client(self) -> BggClient:
"""Fabrik für den BGG-Client; von Tests überschreibbar.""" """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]): 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() client = self._neuer_client()
try: try:
service = SyncService( service = SyncService(
@@ -211,7 +239,10 @@ class NeuheitenPlugin(BasePlugin):
return return
try: try:
ergebnis = self._sync_ausfuehren(self._suchbegriffe) 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: except Exception as exc:
_logger.warning("BGG-Sync fehlgeschlagen: %s", exc) _logger.warning("BGG-Sync fehlgeschlagen: %s", exc)
finally: finally:

View File

@@ -11,6 +11,13 @@ Eigenschaften:
beschränkt; Erweiterungen (`boardgameexpansion`) werden zusätzlich auf beschränkt; Erweiterungen (`boardgameexpansion`) werden zusätzlich auf
Elementebene herausgefiltert. 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 — Für Tests sind HTTP-Transport, Uhr und Schlaf-Funktion injizierbar —
es gibt keine echten Netzwerk-Aufrufe in der Testsuite. 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.""" """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) @dataclass(frozen=True)
class SuchTreffer: class SuchTreffer:
"""Ein Treffer aus der BGG-Suche.""" """Ein Treffer aus der BGG-Suche."""
@@ -161,6 +178,7 @@ class BggClient:
def __init__( def __init__(
self, self,
*, *,
token: str | None = None,
transport: httpx.BaseTransport | None = None, transport: httpx.BaseTransport | None = None,
mindestabstand_sekunden: float = 1.0, mindestabstand_sekunden: float = 1.0,
max_versuche: int = 4, max_versuche: int = 4,
@@ -174,14 +192,20 @@ class BggClient:
self.max_versuche = max_versuche self.max_versuche = max_versuche
self.backoff_basis_sekunden = backoff_basis_sekunden self.backoff_basis_sekunden = backoff_basis_sekunden
self.backoff_maximum_sekunden = backoff_maximum_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._schlaf = schlaf
self._uhr = uhr self._uhr = uhr
self._letzter_request_um: float | None = None 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( self._http = httpx.Client(
base_url=BASIS_URL, base_url=BASIS_URL,
timeout=timeout_sekunden, timeout=timeout_sekunden,
transport=transport, transport=transport,
headers={"User-Agent": USER_AGENT}, headers=headers,
) )
def schliessen(self) -> None: def schliessen(self) -> None:
@@ -214,6 +238,11 @@ class BggClient:
if antwort.status_code == 200: if antwort.status_code == 200:
return antwort.content 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: if antwort.status_code in (202, 429) or antwort.status_code >= 500:
# 202: BGG stellt die Anfrage in eine Warteschlange; # 202: BGG stellt die Anfrage in eine Warteschlange;
# 429/5xx: temporär nicht bedienbar → Backoff und erneut. # 429/5xx: temporär nicht bedienbar → Backoff und erneut.

View File

@@ -17,7 +17,7 @@ from dataclasses import dataclass, field
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from .bgg import BggSpiel, BggClient from .bgg import BggAuthFehler, BggSpiel, BggClient
from .models import STATUS_NEUHEIT, Neuheit from .models import STATUS_NEUHEIT, Neuheit
_logger = logging.getLogger("plugins.neuheiten") _logger = logging.getLogger("plugins.neuheiten")
@@ -42,8 +42,13 @@ class SyncErgebnis:
aktualisiert: int = 0 aktualisiert: int = 0
gefiltert: int = 0 gefiltert: int = 0
fehler: list[str] = field(default_factory=list) 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: def als_text(self) -> str:
if self.abbruch:
return self.abbruch
text = ( text = (
f"{self.neu} neu, {self.aktualisiert} aktualisiert, " f"{self.neu} neu, {self.aktualisiert} aktualisiert, "
f"{self.gefiltert} gefiltert (Erweiterungen/Prototypen)" f"{self.gefiltert} gefiltert (Erweiterungen/Prototypen)"
@@ -101,6 +106,13 @@ class SyncService:
continue continue
try: try:
self._ein_suchbegriff(db, begriff, ergebnis) 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 except Exception as exc: # ein Begriff darf den Lauf nicht abbrechen
_logger.warning("BGG-Sync fehlgeschlagen für %r: %s", begriff, exc) _logger.warning("BGG-Sync fehlgeschlagen für %r: %s", begriff, exc)
ergebnis.fehler.append(f"{begriff}: {exc}") ergebnis.fehler.append(f"{begriff}: {exc}")

View File

@@ -28,6 +28,8 @@ models = _neuheiten.models
BggClient = bgg.BggClient BggClient = bgg.BggClient
BggFehler = bgg.BggFehler BggFehler = bgg.BggFehler
BggAuthFehler = bgg.BggAuthFehler
MELDUNG_401 = bgg.MELDUNG_401
SuchTreffer = bgg.SuchTreffer SuchTreffer = bgg.SuchTreffer
BggSpiel = bgg.BggSpiel BggSpiel = bgg.BggSpiel
parse_suche = bgg.parse_suche parse_suche = bgg.parse_suche

View File

@@ -8,7 +8,14 @@ from __future__ import annotations
import httpx import httpx
import pytest 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 = """<?xml version="1.0" encoding="utf-8"?> SUCHE_XML = """<?xml version="1.0" encoding="utf-8"?>
<items total="3" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> <items total="3" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
@@ -61,9 +68,11 @@ class AufzeichnenderTransport(httpx.BaseTransport):
self.antworten = list(antworten) self.antworten = list(antworten)
self.uhr = uhr self.uhr = uhr
self.anfragen: list[tuple[str, float]] = [] self.anfragen: list[tuple[str, float]] = []
self.auth_header: list[str | None] = [] # Authorization je Request
def handle_request(self, request: httpx.Request) -> httpx.Response: def handle_request(self, request: httpx.Request) -> httpx.Response:
self.anfragen.append((str(request.url), self.uhr())) self.anfragen.append((str(request.url), self.uhr()))
self.auth_header.append(request.headers.get("Authorization"))
element = self.antworten.pop(0) element = self.antworten.pop(0)
if isinstance(element, Exception): if isinstance(element, Exception):
raise element raise element
@@ -157,6 +166,39 @@ def test_suche_sendet_type_boardgame():
assert "query=catan" in url 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(): def test_details_sendet_thing_mit_ids_und_filtert_erweiterungen():
uhr = FakeUhr() uhr = FakeUhr()
transport = AufzeichnenderTransport([_antwort(THING_XML)], uhr) transport = AufzeichnenderTransport([_antwort(THING_XML)], uhr)
@@ -266,6 +308,34 @@ def test_4xx_fuehrt_zu_fehler_ohne_weitere_versuche():
assert wartezeiten == [] 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(): def test_max_versuche_erschoepft_mit_fehler():
uhr = FakeUhr() uhr = FakeUhr()
transport = AufzeichnenderTransport([_antwort("kaputt", status_code=500)], uhr) transport = AufzeichnenderTransport([_antwort("kaputt", status_code=500)], uhr)

View File

@@ -8,8 +8,10 @@ from sqlalchemy.orm import sessionmaker
from redaktionskern.db import Base from redaktionskern.db import Base
from tests._neuheiten import ( from tests._neuheiten import (
BggAuthFehler,
BggFehler, BggFehler,
BggSpiel, BggSpiel,
MELDUNG_401,
Neuheit, Neuheit,
SuchTreffer, SuchTreffer,
SyncService, SyncService,
@@ -217,6 +219,31 @@ def test_prototyp_heuristik():
# ---------- Robustheit ---------- # ---------- 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): def test_fehler_bei_einem_suchbegriff_bricht_lauf_nicht_ab(session_factory):
class HalbKaputterClient(FakeBggClient): class HalbKaputterClient(FakeBggClient):
def suche(self, suchbegriff: str, *, max_treffer: int | None = None): def suche(self, suchbegriff: str, *, max_treffer: int | None = None):

View File

@@ -8,7 +8,13 @@ from fastapi.testclient import TestClient
from redaktionskern.app import create_app 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 from tests.conftest import ADMIN_PASSWORD, lege_benutzer_an, melde_an
@@ -33,6 +39,18 @@ class FakeBggClient:
pass 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: def _client_mit_fakes(monkeypatch, client: TestClient, fake: FakeBggClient) -> None:
plugin = client.app.state.registry.get("neuheiten") plugin = client.app.state.registry.get("neuheiten")
# Instanz-Methode ersetzen; monkeypatch stellt nach dem Test wieder her # 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: with TestClient(create_app(settings)) as client:
melde_an(client) melde_an(client)
_client_mit_fakes(monkeypatch, client, fake) _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 from fastapi.testclient import TestClient
monkeypatch.setenv("SPIELE_BGG_SUCHBEGRIFFE", "essen, familien") monkeypatch.setenv("SPIELE_BGG_SUCHBEGRIFFE", "essen, familien")
monkeypatch.setenv("SPIELE_BGG_TOKEN", "test-token")
fake = FakeBggClient() fake = FakeBggClient()
with TestClient(create_app(settings)) as client: with TestClient(create_app(settings)) as client:
melde_an(client) melde_an(client)
@@ -201,6 +221,53 @@ def test_manueller_sync_ohne_suchbegriff_nutzt_konfigurierte_begriffe(monkeypatc
assert fake.suche_aufrufe == ["essen", "familien"] 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): def test_htmx_request_liefert_nur_teilfragment(app, client):
melde_an(client) melde_an(client)
antwort = client.get("/neuheiten", headers={"HX-Request": "true"}) antwort = client.get("/neuheiten", headers={"HX-Request": "true"})