Files
spiele-redaktion/tests/test_neuheiten_bgg.py
ox-alpha 56cc5943b1 Plugin neuheiten: BGG-Sync mit APScheduler, Filtern und deutscher UI
- Datenmodell + eigene Migration 0001_neuheiten_tabelle (Tabelle neuheiten:
  Titel, Verlag, Autor, Erscheinungsjahr, BGG-ID, Status 'neuheit', Quelle,
  Zeitstempel; bgg_id eindeutig als Merge-Kriterium)
- BoardGameGeek XML API2-Client (search + thing, Batches à 20 IDs):
  Rate-Limit >= 1 s zwischen Requests, Retry mit exponentiellem Backoff bei
  5xx/429/Netzwerkfehlern, HTTP 202 gemäß Retry-After, robustes XML-Parsing;
  Transport/Uhr/Sleep injizierbar (keine echten Calls in Tests)
- Filter: Erweiterungen (boardgameexpansion) auf Request- und Elementebene
  ausgeschlossen; Prototypen per Titel-Heuristik (BGG hat keinen Marker)
- Sync-Service mit Update-statt-Duplikat-Logik über die eindeutige BGG-ID
  (Status bleibt erhalten); Fehler je Suchbegriff brechen den Lauf nicht ab
- APScheduler-Hintergrundjob (Standard 24 h) mit Überlappungsschutz,
  abschaltbar/intervallkonfigurierbar per Env; manueller
  'Jetzt synchronisieren'-Endpunkt nur für Admin/Redakteur, optional mit
  Sofort-Suchbegriff
- UI /neuheiten: sortier-/filterbare Tabelle mit Volltextsuche (HTMX-Teilladung,
  noscript-fähig), deutsche Oberfläche, BGG-Links, Ergebnis-Banner
- Plugin-Loader: idempotentes Laden (Modul-Caching), damit mehrere
  create_app()-Aufrufe dieselben Plugin-Klassen/Tabellen nutzen
- Tests: Parsing, Erweiterungs-/Prototyp-Filter, Rate-Limit/Backoff/202,
  Update-statt-Duplikat, Rollen am Sync-Endpunkt, Scheduler-Lifecycle —
  ausschließlich mit gemockten BGG-Antworten (uv run pytest: 96 grün)
- README/AGENTS: Plugin-Doku, Env-Variablen, Fortschrittstabelle aktualisiert
2026-08-21 19:10:43 +00:00

309 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests für den BGG-XML-API2-Client — ausschließlich mit gemockten Antworten.
Kein echter Netzwerkverkehr: Der HTTP-Transport wird durch Fakes ersetzt,
Uhr und Schlaf-Funktion sind injiziert.
"""
from __future__ import annotations
import httpx
import pytest
from tests._neuheiten import BggClient, BggFehler, parse_suche, parse_things
SUCHE_XML = """<?xml version="1.0" encoding="utf-8"?>
<items total="3" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
<item type="boardgame" id="13">
<name value="Catan"/>
<yearpublished value="1995"/>
</item>
<item type="boardgameexpansion" id="55">
<name value="Catan: Erweiterung"/>
<yearpublished value="1996"/>
</item>
<item type="boardgame" id="9999">
<name value="Mysterium"/>
</item>
</items>
"""
THING_XML = """<?xml version="1.0" encoding="utf-8"?>
<items termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
<item type="boardgame" id="13">
<thumbnail>https://example.org/thumb.jpg</thumbnail>
<name type="primary" sortindex="1" value="Catan"/>
<yearpublished value="1995"/>
<link type="boardgamepublisher" id="4" value="KOSMOS"/>
<link type="boardgamepublisher" id="27" value="Catan Studio"/>
<link type="boardgamedesigner" id="2" value="Klaus Teuber"/>
</item>
<item type="boardgameexpansion" id="55">
<name type="primary" value="Catan: Erweiterung"/>
<yearpublished value="1996"/>
</item>
</items>
"""
class FakeUhr:
"""Steuerbare Monoton-Uhr."""
def __init__(self) -> None:
self.zeit = 0.0
def __call__(self) -> float:
return self.zeit
class AufzeichnenderTransport(httpx.BaseTransport):
"""Liefert eine feste Antwortkette; zeichnet URLs und Uhrzeiten auf."""
def __init__(self, antworten: list, uhr: FakeUhr):
self.antworten = list(antworten)
self.uhr = uhr
self.anfragen: list[tuple[str, float]] = []
def handle_request(self, request: httpx.Request) -> httpx.Response:
self.anfragen.append((str(request.url), self.uhr()))
element = self.antworten.pop(0)
if isinstance(element, Exception):
raise element
if not self.antworten:
self.antworten.append(element) # letzte Antwort zyklisch wiederholen
return element
def _antwort(
xml: str, status_code: int = 200, headers: dict | None = None
) -> httpx.Response:
return httpx.Response(
status_code,
content=xml.encode("utf-8"),
headers=headers or {},
request=httpx.Request("GET", "https://boardgamegeek.com/xmlapi2/test"),
)
def _client(
transport: httpx.BaseTransport, uhr: FakeUhr, schlaf
) -> BggClient:
return BggClient(
transport=transport,
mindestabstand_sekunden=1.0,
backoff_basis_sekunden=0.5,
backoff_maximum_sekunden=4.0,
schlaf=schlaf,
uhr=uhr,
)
def _fortschreitender_schlaf(uhr: FakeUhr, wartezeiten: list[float]):
def schlaf(sekunden: float) -> None:
wartezeiten.append(sekunden)
uhr.zeit += sekunden
return schlaf
# ---------- Parsing ----------
def test_parse_suche_nur_brettspiele():
treffer = parse_suche(SUCHE_XML)
assert [t.bgg_id for t in treffer] == [13, 9999] # Erweiterung raus
assert treffer[0].titel == "Catan"
assert treffer[0].erscheinungsjahr == 1995
assert treffer[1].erscheinungsjahr is None # fehlendes Jahr toleriert
def test_parse_suche_umlaute_und_kaputtes_xml():
xml = '<items><item type="boardgame" id="1"><name value="Schätze von Trullala"/></item></items>'
treffer = parse_suche(xml)
assert treffer[0].titel == "Schätze von Trullala"
with pytest.raises(BggFehler):
parse_suche("<items><item></items>")
def test_parse_things_felder_und_erweiterungsfilter():
spiele = parse_things(THING_XML)
assert len(spiele) == 1 # boardgameexpansion wird verworfen
spiel = spiele[0]
assert spiel.typ == "boardgame"
assert spiel.titel == "Catan"
assert spiel.verlag == "KOSMOS, Catan Studio"
assert spiel.autor == "Klaus Teuber"
assert spiel.erscheinungsjahr == 1995
def test_parse_things_leere_und_fehlerhafte_antwort():
assert parse_things('<items total="0"/>') == []
with pytest.raises(BggFehler):
parse_things("kein xml")
# ---------- HTTP-Verhalten ----------
def test_suche_sendet_type_boardgame():
uhr = FakeUhr()
transport = AufzeichnenderTransport([_antwort(SUCHE_XML)], uhr)
client = _client(transport, uhr, lambda s: None)
client.suche("catan")
url = transport.anfragen[0][0]
assert "/search" in url
assert "type=boardgame" in url
assert "query=catan" in url
def test_details_sendet_thing_mit_ids_und_filtert_erweiterungen():
uhr = FakeUhr()
transport = AufzeichnenderTransport([_antwort(THING_XML)], uhr)
client = _client(transport, uhr, lambda s: None)
spiele = client.details([13, 55])
url = transport.anfragen[0][0]
assert "/thing" in url
assert "id=13%2C55" in url or "id=13,55" in url
assert "type=boardgame" in url
assert [s.bgg_id for s in spiele] == [13]
def test_rate_limit_mindestens_eine_sekunde_zwischen_requests():
uhr = FakeUhr()
wartezeiten: list[float] = []
transport = AufzeichnenderTransport([_antwort(SUCHE_XML)], uhr)
client = _client(transport, uhr, _fortschreitender_schlaf(uhr, wartezeiten))
client.suche("a")
client.suche("b")
zeitpunkt_1 = transport.anfragen[0][1]
zeitpunkt_2 = transport.anfragen[1][1]
assert zeitpunkt_2 - zeitpunkt_1 >= 1.0
assert wartezeiten # es wurde aktiv gewartet
def test_retry_bei_5xx_mit_backoff_dann_erfolg():
uhr = FakeUhr()
wartezeiten: list[float] = []
transport = AufzeichnenderTransport(
[_antwort("server error", status_code=500),
_antwort("server error", status_code=500),
_antwort(SUCHE_XML)], uhr
)
client = _client(transport, uhr, _fortschreitender_schlaf(uhr, wartezeiten))
treffer = client.suche("catan")
assert len(treffer) == 2
assert len(transport.anfragen) == 3 # 2 × 500, dann Erfolg
# Backoff 0,5 s → Rate-Limit-Rest 0,5 s → Backoff 1,0 s:
assert wartezeiten == [0.5, 0.5, 1.0]
def test_202_wird_gemaess_retry_after_erneut_versucht():
uhr = FakeUhr()
wartezeiten: list[float] = []
transport = AufzeichnenderTransport(
[
_antwort("queued", status_code=202, headers={"Retry-After": "3"}),
_antwort(SUCHE_XML),
],
uhr,
)
client = _client(transport, uhr, _fortschreitender_schlaf(uhr, wartezeiten))
treffer = client.suche("catan")
assert len(treffer) == 2
assert wartezeiten == [3.0] # Retry-After wird beachtet
assert len(transport.anfragen) == 2
def test_429_ohne_retry_after_nutzt_backoff():
uhr = FakeUhr()
wartezeiten: list[float] = []
transport = AufzeichnenderTransport(
[_antwort("zu schnell", status_code=429), _antwort(SUCHE_XML)], uhr
)
client = _client(transport, uhr, _fortschreitender_schlaf(uhr, wartezeiten))
treffer = client.suche("catan")
assert len(treffer) == 2
# Backoff nach 429 plus Rate-Limit-Abstand vor dem zweiten Request:
assert wartezeiten == [0.5, 0.5]
def test_netzwerkfehler_wird_mit_backoff_wiederholt():
uhr = FakeUhr()
wartezeiten: list[float] = []
transport = AufzeichnenderTransport(
[httpx.ConnectError("Verbindung fehlgeschlagen"), _antwort(SUCHE_XML)], uhr
)
client = _client(transport, uhr, _fortschreitender_schlaf(uhr, wartezeiten))
treffer = client.suche("catan")
assert len(treffer) == 2
assert wartezeiten == [0.5]
assert len(transport.anfragen) == 2
def test_4xx_fuehrt_zu_fehler_ohne_weitere_versuche():
uhr = FakeUhr()
wartezeiten: list[float] = []
transport = AufzeichnenderTransport([_antwort("nope", status_code=404)], uhr)
client = _client(transport, uhr, _fortschreitender_schlaf(uhr, wartezeiten))
with pytest.raises(BggFehler):
client.suche("catan")
assert len(transport.anfragen) == 1
assert wartezeiten == []
def test_max_versuche_erschoepft_mit_fehler():
uhr = FakeUhr()
transport = AufzeichnenderTransport([_antwort("kaputt", status_code=500)], uhr)
client = BggClient(
transport=transport,
max_versuche=3,
backoff_basis_sekunden=0.1,
schlaf=lambda s: uhr.__setattr__("zeit", uhr.zeit + s),
uhr=uhr,
)
with pytest.raises(BggFehler):
client.suche("catan")
assert len(transport.anfragen) == 3
def test_details_batched_ueber_20_ids_pro_request():
uhr = FakeUhr()
aufgerufene_id_listen: list[list[str]] = []
def handler(request: httpx.Request) -> httpx.Response:
ids = request.url.params["id"].split(",")
aufgerufene_id_listen.append(ids)
items = "".join(
f'<item type="boardgame" id="{i}"><name value="Spiel {i}"/></item>'
for i in ids
)
return _antwort(f"<items>{items}</items>")
client = BggClient(
transport=httpx.MockTransport(handler),
schlaf=lambda s: None,
uhr=uhr,
)
spiele = client.details(list(range(1, 26))) # 25 IDs → 2 Requests
assert len(spiele) == 25
assert [len(batch) for batch in aufgerufene_id_listen] == [20, 5]