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
This commit is contained in:
238
tests/test_neuheiten_sync.py
Normal file
238
tests/test_neuheiten_sync.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""Tests für die Sync-Logik: Upsert (Update statt Duplikat), Filter, Robustheit."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from redaktionskern.db import Base
|
||||
|
||||
from tests._neuheiten import (
|
||||
BggFehler,
|
||||
BggSpiel,
|
||||
Neuheit,
|
||||
SuchTreffer,
|
||||
SyncService,
|
||||
ist_moeglicher_prototyp,
|
||||
speichere_oder_aktualisiere,
|
||||
)
|
||||
|
||||
|
||||
class FakeBggClient:
|
||||
"""Duck-typed Ersatz für BggClient mit festen Ergebnissen."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
suchergebnisse: dict[str, list[SuchTreffer]] | None = None,
|
||||
details_pro_id: dict[int, BggSpiel] | None = None,
|
||||
) -> None:
|
||||
self.suchergebnisse = suchergebnisse or {}
|
||||
self.details_pro_id = details_pro_id or {}
|
||||
self.suche_aufrufe: list[str] = []
|
||||
self.details_aufrufe: list[list[int]] = []
|
||||
|
||||
def suche(self, suchbegriff: str, *, max_treffer: int | None = None):
|
||||
self.suche_aufrufe.append(suchbegriff)
|
||||
treffer = list(self.suchergebnisse.get(suchbegriff, []))
|
||||
return treffer[:max_treffer] if max_treffer is not None else treffer
|
||||
|
||||
def details(self, bgg_ids):
|
||||
self.details_aufrufe.append(list(bgg_ids))
|
||||
return [self.details_pro_id[i] for i in bgg_ids if i in self.details_pro_id]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_factory():
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False})
|
||||
Base.metadata.create_all(engine)
|
||||
fabrik = sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)
|
||||
yield fabrik
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _spiel(bgg_id: int = 13, titel: str = "Catan", **aenderungen) -> BggSpiel:
|
||||
basis = dict(
|
||||
bgg_id=bgg_id,
|
||||
titel=titel,
|
||||
verlag="KOSMOS",
|
||||
autor="Klaus Teuber",
|
||||
erscheinungsjahr=1995,
|
||||
typ="boardgame",
|
||||
)
|
||||
basis.update(aenderungen)
|
||||
return BggSpiel(**basis)
|
||||
|
||||
|
||||
def _alle_eintraege(session_factory) -> list[Neuheit]:
|
||||
with session_factory() as db:
|
||||
return list(db.scalars(select(Neuheit).order_by(Neuheit.bgg_id)).all())
|
||||
|
||||
|
||||
# ---------- Update statt Duplikat ----------
|
||||
|
||||
|
||||
def test_sync_reicht_trefferlimit_weiter(session_factory):
|
||||
client = FakeBggClient(
|
||||
suchergebnisse={
|
||||
"viel": [SuchTreffer(i, f"Spiel {i}", None) for i in range(100)]
|
||||
},
|
||||
details_pro_id={},
|
||||
)
|
||||
service = SyncService(session_factory, client, max_treffer_pro_suchbegriff=7)
|
||||
|
||||
ergebnis = service.synchronisiere(["viel"])
|
||||
|
||||
# Das Limit begrenzt die Trefferliste vor der Detailabfrage:
|
||||
assert len(client.details_aufrufe[0]) <= 20 # Batch-Größe bleibt intakt
|
||||
assert sum(len(b) for b in client.details_aufrufe) == 7
|
||||
|
||||
|
||||
def test_sync_legt_eintraege_an(session_factory):
|
||||
client = FakeBggClient(
|
||||
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
|
||||
details_pro_id={13: _spiel()},
|
||||
)
|
||||
service = SyncService(session_factory, client)
|
||||
|
||||
ergebnis = service.synchronisiere(["catan"])
|
||||
|
||||
assert ergebnis.neu == 1
|
||||
assert ergebnis.aktualisiert == 0
|
||||
eintraege = _alle_eintraege(session_factory)
|
||||
assert len(eintraege) == 1
|
||||
eintrag = eintraege[0]
|
||||
assert eintrag.titel == "Catan"
|
||||
assert eintrag.verlag == "KOSMOS"
|
||||
assert eintrag.autor == "Klaus Teuber"
|
||||
assert eintrag.erscheinungsjahr == 1995
|
||||
assert eintrag.bgg_id == 13
|
||||
assert eintrag.status == "neuheit"
|
||||
assert eintrag.quelle == "boardgamegeek"
|
||||
|
||||
|
||||
def test_sync_aktualisiert_bestehende_eintraege_statt_duplikate(session_factory):
|
||||
client = FakeBggClient(
|
||||
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
|
||||
details_pro_id={13: _spiel()},
|
||||
)
|
||||
service = SyncService(session_factory, client)
|
||||
|
||||
service.synchronisiere(["catan"])
|
||||
|
||||
# Zweiter Lauf mit geänderten Daten (z. B. korrigiertes Jahr, neuer Verlag)
|
||||
client.details_pro_id[13] = _spiel(
|
||||
verlag="Catan Studio", erscheinungsjahr=2020
|
||||
)
|
||||
ergebnis = service.synchronisiere(["catan"])
|
||||
|
||||
assert ergebnis.neu == 0
|
||||
assert ergebnis.aktualisiert == 1
|
||||
eintraege = _alle_eintraege(session_factory)
|
||||
assert len(eintraege) == 1, "Update statt Duplikat verletzt"
|
||||
eintrag = eintraege[0]
|
||||
assert eintrag.verlag == "Catan Studio"
|
||||
assert eintrag.erscheinungsjahr == 2020
|
||||
assert eintrag.status == "neuheit", "Status darf beim Update nicht verloren gehen"
|
||||
|
||||
|
||||
def test_sync_veraenderter_titel_gleiche_bgg_id_kein_duplikat(session_factory):
|
||||
client = FakeBggClient(
|
||||
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
|
||||
details_pro_id={13: _spiel()},
|
||||
)
|
||||
service = SyncService(session_factory, client)
|
||||
service.synchronisiere(["catan"])
|
||||
|
||||
client.details_pro_id[13] = _spiel(titel="Catan (Neuausgabe)")
|
||||
service.synchronisiere(["catan"])
|
||||
|
||||
eintraege = _alle_eintraege(session_factory)
|
||||
assert len(eintraege) == 1
|
||||
assert eintraege[0].titel == "Catan (Neuausgabe)"
|
||||
|
||||
|
||||
def test_speichere_oder_aktualisiere_rückgabe_werte(session_factory):
|
||||
with session_factory() as db:
|
||||
assert speichere_oder_aktualisiere(db, _spiel()) is True
|
||||
db.flush()
|
||||
assert speichere_oder_aktualisiere(db, _spiel(erscheinungsjahr=1996)) is False
|
||||
|
||||
|
||||
# ---------- Filter: Erweiterungen & Prototypen ----------
|
||||
|
||||
|
||||
def test_sync_filtert_erweiterungen(session_factory):
|
||||
client = FakeBggClient(
|
||||
suchergebnisse={
|
||||
"catan": [
|
||||
SuchTreffer(13, "Catan", 1995),
|
||||
SuchTreffer(55, "Catan: Erweiterung", 1996),
|
||||
]
|
||||
},
|
||||
details_pro_id={
|
||||
13: _spiel(),
|
||||
55: _spiel(55, "Catan: Erweiterung", typ="boardgameexpansion"),
|
||||
},
|
||||
)
|
||||
service = SyncService(session_factory, client)
|
||||
|
||||
ergebnis = service.synchronisiere(["catan"])
|
||||
|
||||
assert ergebnis.gefiltert == 1
|
||||
eintraege = _alle_eintraege(session_factory)
|
||||
assert [e.bgg_id for e in eintraege] == [13]
|
||||
|
||||
|
||||
def test_sync_filtert_prototypen(session_factory):
|
||||
client = FakeBggClient(
|
||||
suchergebnisse={
|
||||
"essen": [
|
||||
SuchTreffer(13, "Catan", 1995),
|
||||
SuchTreffer(77, "Fabrik — Prototyp 2026", None),
|
||||
SuchTreffer(78, "My Prototype Game", None),
|
||||
]
|
||||
},
|
||||
details_pro_id={
|
||||
13: _spiel(),
|
||||
77: _spiel(77, "Fabrik — Prototyp 2026"),
|
||||
78: _spiel(78, "My Prototype Game"),
|
||||
},
|
||||
)
|
||||
service = SyncService(session_factory, client)
|
||||
|
||||
ergebnis = service.synchronisiere(["essen"])
|
||||
|
||||
assert ergebnis.gefiltert == 2
|
||||
assert [e.bgg_id for e in _alle_eintraege(session_factory)] == [13]
|
||||
|
||||
|
||||
def test_prototyp_heuristik():
|
||||
assert ist_moeglicher_prototyp("Ein Prototyp von etwas")
|
||||
assert ist_moeglicher_prototyp("Prototype X")
|
||||
assert ist_moeglicher_prototyp("PROTOTYPISCHES Spiel")
|
||||
assert not ist_moeglicher_prototyp("Catan")
|
||||
assert not ist_moeglicher_prototyp("Die Burgen von Burgund")
|
||||
|
||||
|
||||
# ---------- Robustheit ----------
|
||||
|
||||
|
||||
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):
|
||||
if suchbegriff == "kaputt":
|
||||
raise BggFehler("BGG nicht erreichbar")
|
||||
return super().suche(suchbegriff, max_treffer=max_treffer)
|
||||
|
||||
client = HalbKaputterClient(
|
||||
suchergebnisse={"gut": [SuchTreffer(13, "Catan", 1995)]},
|
||||
details_pro_id={13: _spiel()},
|
||||
)
|
||||
service = SyncService(session_factory, client)
|
||||
|
||||
ergebnis = service.synchronisiere(["kaputt", "gut"])
|
||||
|
||||
assert len(ergebnis.fehler) == 1
|
||||
assert "kaputt" in ergebnis.fehler[0]
|
||||
assert ergebnis.neu == 1
|
||||
assert len(_alle_eintraege(session_factory)) == 1
|
||||
Reference in New Issue
Block a user