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:
12
README.md
12
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 <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
|
||||
|
||||
Die Seite `/neuheiten` zeigt alle Einträge als Tabelle (Spieltitel, Verlag,
|
||||
|
||||
@@ -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,6 +160,10 @@ 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)
|
||||
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)
|
||||
|
||||
@@ -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,6 +239,9 @@ class NeuheitenPlugin(BasePlugin):
|
||||
return
|
||||
try:
|
||||
ergebnis = self._sync_ausfuehren(self._suchbegriffe)
|
||||
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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<items total="3" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user