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:
42
tests/_neuheiten.py
Normal file
42
tests/_neuheiten.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Zugriff auf die neuheiten-Plugin-Module über den vom Plugin-Loader
|
||||
verwendeten Modulnamen (`spiele_redaktion_plugins.neuheiten`).
|
||||
|
||||
Der Kern teilt sich eine SQLAlchemy-MetaData; deshalb darf es pro Prozess
|
||||
nur eine Modul-Identität geben. Tests importieren das Plugin daher über
|
||||
diesen Helper statt direkt über `plugins.neuheiten`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from redaktionskern.plugin_loader import discover_plugins
|
||||
|
||||
_PROJEKT_WURZEL = Path(__file__).resolve().parents[1]
|
||||
|
||||
if "spiele_redaktion_plugins.neuheiten" not in sys.modules:
|
||||
discover_plugins(
|
||||
_PROJEKT_WURZEL / "plugins",
|
||||
entry_point_group="spiele_redaktion.tests.keine_entry_points",
|
||||
)
|
||||
|
||||
_neuheiten = sys.modules["spiele_redaktion_plugins.neuheiten"]
|
||||
|
||||
bgg = _neuheiten.bgg
|
||||
sync_modul = _neuheiten.sync
|
||||
models = _neuheiten.models
|
||||
|
||||
BggClient = bgg.BggClient
|
||||
BggFehler = bgg.BggFehler
|
||||
SuchTreffer = bgg.SuchTreffer
|
||||
BggSpiel = bgg.BggSpiel
|
||||
parse_suche = bgg.parse_suche
|
||||
parse_things = bgg.parse_things
|
||||
|
||||
SyncService = sync_modul.SyncService
|
||||
SyncErgebnis = sync_modul.SyncErgebnis
|
||||
ist_moeglicher_prototyp = sync_modul.ist_moeglicher_prototyp
|
||||
speichere_oder_aktualisiere = sync_modul.speichere_oder_aktualisiere
|
||||
|
||||
Neuheit = models.Neuheit
|
||||
STATUS_NEUHEIT = models.STATUS_NEUHEIT
|
||||
@@ -14,6 +14,16 @@ PROJEKT_WURZEL = Path(__file__).resolve().parents[1]
|
||||
ADMIN_PASSWORD = "test-admin-123"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hintergrundjobs_deaktiviert(monkeypatch):
|
||||
"""BGG-Hintergrund-Sync in Tests standardmäßig aus (keine Threads/Netzwerk).
|
||||
|
||||
Einzelne Tests aktivieren den Scheduler explizit wieder per
|
||||
monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "1").
|
||||
"""
|
||||
monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "0")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings(tmp_path) -> Settings:
|
||||
return Settings(
|
||||
|
||||
308
tests/test_neuheiten_bgg.py
Normal file
308
tests/test_neuheiten_bgg.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""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]
|
||||
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
|
||||
241
tests/test_neuheiten_ui.py
Normal file
241
tests/test_neuheiten_ui.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Tests für die Neuheiten-UI: Rollen, Liste, Suche/Filter/Sortierung, Sync-Button.
|
||||
|
||||
Alle BGG-Antworten sind gemockt (FakeBggClient), kein echter Netzwerkverkehr.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from redaktionskern.app import create_app
|
||||
|
||||
from tests._neuheiten import BggSpiel, Neuheit, SuchTreffer
|
||||
|
||||
from tests.conftest import ADMIN_PASSWORD, lege_benutzer_an, melde_an
|
||||
|
||||
|
||||
class FakeBggClient:
|
||||
"""Achtung: Attribut heißt bewusst NICHT `details`, sonst würde es die
|
||||
gleichnamige Methode verdecken."""
|
||||
|
||||
def __init__(self, treffer=None, spiel_details=None):
|
||||
self.treffer = treffer or []
|
||||
self.spiel_details = spiel_details or []
|
||||
self.suche_aufrufe: list[str] = []
|
||||
|
||||
def suche(self, suchbegriff: str, *, max_treffer: int | None = None):
|
||||
self.suche_aufrufe.append(suchbegriff)
|
||||
return list(self.treffer)
|
||||
|
||||
def details(self, bgg_ids):
|
||||
return list(self.spiel_details)
|
||||
|
||||
def schliessen(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
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
|
||||
monkeypatch.setattr(plugin, "_neuer_client", lambda: fake)
|
||||
|
||||
|
||||
def test_neuheiten_erfordert_anmeldung(client):
|
||||
antwort = client.get("/neuheiten", follow_redirects=False)
|
||||
assert antwort.status_code == 303
|
||||
assert "/login" in antwort.headers["location"]
|
||||
|
||||
|
||||
def test_liste_zeigt_eintraege_und_deutsche_beschriftung(app, client):
|
||||
with app.state.session_factory() as db:
|
||||
db.add(
|
||||
Neuheit(
|
||||
titel="Catan",
|
||||
verlag="KOSMOS",
|
||||
autor="Klaus Teuber",
|
||||
erscheinungsjahr=1995,
|
||||
bgg_id=13,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
melde_an(client)
|
||||
antwort = client.get("/neuheiten")
|
||||
|
||||
assert antwort.status_code == 200
|
||||
for text in (
|
||||
"Spieltitel", "Verlag", "Autor", "Erscheinungsjahr", "Status",
|
||||
"Catan", "KOSMOS", "Klaus Teuber", "1995", "1 Einträge",
|
||||
):
|
||||
assert text in antwort.text, f"Fehlt in der Seite: {text!r}"
|
||||
|
||||
|
||||
def test_leere_liste_hat_hinweis(client):
|
||||
melde_an(client)
|
||||
antwort = client.get("/neuheiten")
|
||||
assert antwort.status_code == 200
|
||||
assert "Keine Einträge gefunden" in antwort.text
|
||||
|
||||
|
||||
def test_suche_filtert_nach_titel_verlag_autor(app, client):
|
||||
with app.state.session_factory() as db:
|
||||
db.add_all(
|
||||
[
|
||||
Neuheit(titel="Catan", verlag="KOSMOS", autor="Klaus Teuber", bgg_id=13),
|
||||
Neuheit(titel="Azul", verlag="Next Move Games", autor="Michael Kiesling", bgg_id=2),
|
||||
Neuheit(titel="Ticket to Ride", verlag="Days of Wonder", autor="Alan R. Moon", bgg_id=9),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
melde_an(client)
|
||||
assert "Catan" in client.get("/neuheiten?q=catan").text
|
||||
assert "Azul" not in client.get("/neuheiten?q=catan").text
|
||||
assert "Ticket to Ride" in client.get("/neuheiten?q=wonder").text # Verlag-Treffer
|
||||
|
||||
|
||||
def test_status_filter(app, client):
|
||||
with app.state.session_factory() as db:
|
||||
db.add(Neuheit(titel="Catan", bgg_id=13, status="neuheit"))
|
||||
db.add(Neuheit(titel="Azul", bgg_id=2, status="planung"))
|
||||
db.commit()
|
||||
|
||||
melde_an(client)
|
||||
seite = client.get("/neuheiten?status=planung").text
|
||||
assert "Azul" in seite
|
||||
assert ">Catan<" not in seite
|
||||
|
||||
|
||||
def test_sortierung_nach_jahr_abwaerts(app, client):
|
||||
with app.state.session_factory() as db:
|
||||
db.add_all(
|
||||
[
|
||||
Neuheit(titel="Alt", bgg_id=1, erscheinungsjahr=1990),
|
||||
Neuheit(titel="Neu", bgg_id=2, erscheinungsjahr=2026),
|
||||
Neuheit(titel="Mittel", bgg_id=3, erscheinungsjahr=2010),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
melde_an(client)
|
||||
antwort = client.get("/neuheiten?sort=jahr&richtung=ab")
|
||||
reihenfolge = [zeile for zeile in ("Neu", "Mittel", "Alt") if zeile]
|
||||
position = [antwort.text.index(titel) for titel in reihenfolge]
|
||||
assert position == sorted(position)
|
||||
|
||||
|
||||
def test_unbekannte_sortierung_faellt_auf_standard_zurueck(client):
|
||||
melde_an(client)
|
||||
antwort = client.get("/neuheiten?sort=hack&richtung=auf")
|
||||
assert antwort.status_code == 200
|
||||
|
||||
|
||||
def test_sync_button_nur_fuer_redaktion_sichtbar(app, client):
|
||||
lege_benutzer_an(app, "rezensentin", role="rezensent")
|
||||
|
||||
melde_an(client)
|
||||
admin_seite = client.get("/neuheiten").text
|
||||
assert "Jetzt synchronisieren" in admin_seite
|
||||
|
||||
client.post("/logout")
|
||||
melde_an(client, "rezensentin", "test-12345678")
|
||||
rezensent_seite = client.get("/neuheiten").text
|
||||
assert "Jetzt synchronisieren" not in rezensent_seite
|
||||
|
||||
|
||||
def test_sync_endpunkt_rollen_pruefung(app, settings):
|
||||
lege_benutzer_an(app, "rezensentin", role="rezensent")
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with TestClient(create_app(settings)) as client:
|
||||
melde_an(client, "rezensentin", "test-12345678")
|
||||
antwort = client.post("/neuheiten/sync", follow_redirects=False)
|
||||
assert antwort.status_code == 403
|
||||
|
||||
|
||||
def test_manueller_sync_legt_eintraege_an(monkeypatch, settings):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
fake = FakeBggClient(
|
||||
treffer=[SuchTreffer(13, "Catan", 1995)],
|
||||
spiel_details=[
|
||||
BggSpiel(bgg_id=13, titel="Catan", verlag="KOSMOS", autor="Klaus Teuber",
|
||||
erscheinungsjahr=1995, typ="boardgame"),
|
||||
BggSpiel(bgg_id=55, titel="Catan: Erweiterung", verlag=None, autor=None,
|
||||
erscheinungsjahr=1996, typ="boardgameexpansion"),
|
||||
],
|
||||
)
|
||||
|
||||
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
|
||||
assert "/neuheiten" in antwort.headers["location"]
|
||||
# Meldung über Sync-Ergebnis:
|
||||
folge = client.get(antwort.headers["location"])
|
||||
assert "Sync abgeschlossen" in folge.text
|
||||
assert "1 neu" in folge.text
|
||||
|
||||
with client.app.state.session_factory() as db:
|
||||
eintraege = db.query(Neuheit).all()
|
||||
assert [e.bgg_id for e in eintraege] == [13] # Erweiterung gefiltert
|
||||
|
||||
assert fake.suche_aufrufe == ["catan"] # expliziter Begriff statt Standard
|
||||
|
||||
|
||||
def test_manueller_sync_ohne_suchbegriff_nutzt_konfigurierte_begriffe(monkeypatch, settings):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.setenv("SPIELE_BGG_SUCHBEGRIFFE", "essen, familien")
|
||||
fake = FakeBggClient()
|
||||
with TestClient(create_app(settings)) as client:
|
||||
melde_an(client)
|
||||
_client_mit_fakes(monkeypatch, client, fake)
|
||||
client.post("/neuheiten/sync", data={"suchbegriff": ""})
|
||||
assert fake.suche_aufrufe == ["essen", "familien"]
|
||||
|
||||
|
||||
def test_htmx_request_liefert_nur_teilfragment(app, client):
|
||||
melde_an(client)
|
||||
antwort = client.get("/neuheiten", headers={"HX-Request": "true"})
|
||||
assert antwort.status_code == 200
|
||||
assert "neuheiten-liste" in antwort.text # Fragment-Anker vorhanden
|
||||
assert "<html" not in antwort.text.lower()
|
||||
|
||||
|
||||
def test_scheduler_standardmaessig_deaktiviert_in_tests(settings):
|
||||
app = create_app(settings)
|
||||
plugin = app.state.registry.get("neuheiten")
|
||||
assert plugin._scheduler is None
|
||||
|
||||
|
||||
def test_scheduler_laesst_sich_aktivieren(monkeypatch, settings):
|
||||
monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "1")
|
||||
monkeypatch.setenv("SPIELE_BGG_SYNC_INTERVALL_STUNDEN", "24")
|
||||
|
||||
app = create_app(settings)
|
||||
plugin = app.state.registry.get("neuheiten")
|
||||
with TestClient(app):
|
||||
assert plugin._scheduler is not None
|
||||
assert plugin._scheduler.running
|
||||
job = plugin._scheduler.get_job("bgg-neuheiten-sync")
|
||||
assert job is not None
|
||||
|
||||
assert plugin._scheduler is None # on_unload hat ihn gestoppt
|
||||
|
||||
|
||||
def test_migration_legt_tabelle_an(app):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
inspektion = inspect(app.state.engine)
|
||||
spalten = {c["name"] for c in inspektion.get_columns("neuheiten")}
|
||||
assert {
|
||||
"id", "titel", "verlag", "autor", "erscheinungsjahr",
|
||||
"bgg_id", "status", "quelle", "erstellt_am", "aktualisiert_am",
|
||||
} <= spalten
|
||||
Reference in New Issue
Block a user