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:
ox-alpha
2026-08-21 19:10:43 +00:00
parent 83d574d95c
commit 56cc5943b1
16 changed files with 1732 additions and 28 deletions

View 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