Resolve stage: matching, version resolution, fixtures; BGG API auth
bggpipe resolve works end to end: search -> exact/fuzzy candidate scoring -> auto/ambiguous/unmatched classification with owned-count tie-breaks (mixed base/expansion candidates never auto-match), version scoring from edition cues (never guessed; no cues -> version_unknown), idempotent matches.csv appends. Discovered mid-build: BGG now requires registered-application Bearer tokens on the XML API (2025 policy change) and returns 401 otherwise. Client sends Authorization from BGG_API_TOKEN and raises an actionable BGGAuthError; CLAUDE.md and the bgg-api skill are updated to match. Live fixture recording is blocked until registration is approved, so tests replay hand-crafted stub fixtures via a network-refusing transport; scripts/record_fixtures.py re-records real XML under the same cache keys once a token exists. One live read-only smoke test is skipped unless --run-live. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+1
@@ -0,0 +1 @@
|
||||
<items total="0"></items>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<items total="2"><item type="boardgame" id="373"><name type="primary" value="Café International"/><yearpublished value="1989"/></item><item type="boardgame" id="8103"><name type="primary" value="Café International: Das Kartenspiel"/><yearpublished value="2001"/></item></items>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<items total="4"><item type="boardgame" id="13"><name type="primary" value="CATAN"/><yearpublished value="1995"/></item><item type="boardgameexpansion" id="926"><name type="primary" value="CATAN: Seafarers"/><yearpublished value="1997"/></item><item type="boardgame" id="27760"><name type="primary" value="Catan Dice Game"/><yearpublished value="2007"/></item><item type="boardgame" id="133038"><name type="primary" value="CATAN 3D Collector's Edition"/><yearpublished value="2005"/></item></items>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<items total="2"><item type="boardgame" id="478"><name type="primary" value="Citadels"/><yearpublished value="2000"/></item><item type="boardgame" id="205398"><name type="primary" value="Citadels"/><yearpublished value="2016"/></item></items>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<items total="4"><item type="boardgame" id="266192"><name type="primary" value="Wingspan"/><yearpublished value="2019"/></item><item type="boardgameexpansion" id="290448"><name type="primary" value="Wingspan: European Expansion"/><yearpublished value="2019"/></item><item type="boardgameexpansion" id="300580"><name type="primary" value="Wingspan: Oceania Expansion"/><yearpublished value="2020"/></item><item type="boardgame" id="366161"><name type="primary" value="Wingspan Asia"/><yearpublished value="2022"/></item></items>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<items total="4"><item type="boardgame" id="266192"><name type="primary" value="Wingspan"/><yearpublished value="2019"/></item><item type="boardgameexpansion" id="290448"><name type="primary" value="Wingspan: European Expansion"/><yearpublished value="2019"/></item><item type="boardgameexpansion" id="300580"><name type="primary" value="Wingspan: Oceania Expansion"/><yearpublished value="2020"/></item><item type="boardgame" id="366161"><name type="primary" value="Wingspan Asia"/><yearpublished value="2022"/></item></items>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<items total="4"><item type="boardgame" id="266192"><name type="primary" value="Wingspan"/><yearpublished value="2019"/></item><item type="boardgameexpansion" id="290448"><name type="primary" value="Wingspan: European Expansion"/><yearpublished value="2019"/></item><item type="boardgameexpansion" id="300580"><name type="primary" value="Wingspan: Oceania Expansion"/><yearpublished value="2020"/></item><item type="boardgame" id="366161"><name type="primary" value="Wingspan Asia"/><yearpublished value="2022"/></item></items>
|
||||
@@ -0,0 +1,25 @@
|
||||
<items>
|
||||
<item type="boardgame" id="266192">
|
||||
<name type="primary" value="Wingspan"/><yearpublished value="2019"/>
|
||||
<versions>
|
||||
<item type="boardgameversion" id="465063">
|
||||
<name type="primary" value="English first edition"/>
|
||||
<yearpublished value="2019"/>
|
||||
<link type="boardgamepublisher" id="23202" value="Stonemaier Games"/>
|
||||
<link type="language" id="2184" value="English"/>
|
||||
</item>
|
||||
<item type="boardgameversion" id="521212">
|
||||
<name type="primary" value="English fourth printing"/>
|
||||
<yearpublished value="2020"/>
|
||||
<link type="boardgamepublisher" id="23202" value="Stonemaier Games"/>
|
||||
<link type="language" id="2184" value="English"/>
|
||||
</item>
|
||||
<item type="boardgameversion" id="472430">
|
||||
<name type="primary" value="German edition"/>
|
||||
<yearpublished value="2019"/>
|
||||
<link type="boardgamepublisher" id="22160" value="Feuerland Spiele"/>
|
||||
<link type="language" id="2188" value="German"/>
|
||||
</item>
|
||||
</versions>
|
||||
</item>
|
||||
</items>
|
||||
@@ -0,0 +1,14 @@
|
||||
<items>
|
||||
<item type="boardgame" id="478">
|
||||
<name type="primary" value="Citadels"/><yearpublished value="2000"/>
|
||||
<statistics><ratings><owned value="85000"/>
|
||||
<ranks><rank type="subtype" id="1" name="boardgame" value="250"/></ranks>
|
||||
</ratings></statistics>
|
||||
</item>
|
||||
<item type="boardgame" id="205398">
|
||||
<name type="primary" value="Citadels"/><yearpublished value="2016"/>
|
||||
<statistics><ratings><owned value="24000"/>
|
||||
<ranks><rank type="subtype" id="1" name="boardgame" value="400"/></ranks>
|
||||
</ratings></statistics>
|
||||
</item>
|
||||
</items>
|
||||
@@ -108,3 +108,26 @@ def test_cache_key_stable_and_filename_safe(tmp_path):
|
||||
key2 = cache_key("search", {"type": "boardgame", "query": "Café & Krieg?"})
|
||||
assert key1 == key2 # param order must not matter
|
||||
assert "/" not in key1 and "?" not in key1 and key1.endswith(".xml")
|
||||
|
||||
|
||||
def test_api_token_sent_as_bearer_header(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("BGG_API_TOKEN", "test-token-123")
|
||||
client, calls, _ = make_client(tmp_path, [(200, SEARCH_XML)])
|
||||
client.get_xml("search", {"query": "catan"})
|
||||
assert calls[0].headers["Authorization"] == "Bearer test-token-123"
|
||||
|
||||
|
||||
def test_no_auth_header_without_token(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("BGG_API_TOKEN", raising=False)
|
||||
client, calls, _ = make_client(tmp_path, [(200, SEARCH_XML)])
|
||||
client.get_xml("search", {"query": "catan"})
|
||||
assert "authorization" not in calls[0].headers
|
||||
|
||||
|
||||
def test_401_raises_actionable_auth_error(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("BGG_API_TOKEN", raising=False)
|
||||
from bggpipe.bgg_client import BGGAuthError
|
||||
|
||||
client, _, _ = make_client(tmp_path, [(401, "Unauthorized")])
|
||||
with pytest.raises(BGGAuthError, match="BGG_API_TOKEN"):
|
||||
client.get_xml("search", {"query": "catan"})
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Resolve-stage tests, replayed from tests/fixtures/bgg_cache — never online.
|
||||
|
||||
The transport below raises on any network attempt, proving every response
|
||||
comes from the committed fixture cache.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from bggpipe.bgg_client import BGGClient
|
||||
from bggpipe.config import Config
|
||||
from bggpipe.models import GameVersion
|
||||
from bggpipe.resolve import (
|
||||
Candidate,
|
||||
TitleEntry,
|
||||
_dominant,
|
||||
_score_version,
|
||||
load_titles,
|
||||
resolve_entry,
|
||||
run_resolve,
|
||||
)
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "bgg_cache"
|
||||
TITLES_JSON = Path(__file__).parent.parent / "data" / "titles.json"
|
||||
|
||||
|
||||
def _no_network(request: httpx.Request) -> httpx.Response:
|
||||
raise AssertionError(f"test hit the network: {request.url}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> BGGClient:
|
||||
return BGGClient(
|
||||
cache_dir=FIXTURES,
|
||||
transport=httpx.MockTransport(_no_network),
|
||||
sleep=lambda s: pytest.fail("slept during fixture replay"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rows_by_title(client) -> dict[str, object]:
|
||||
entries = load_titles(TITLES_JSON)
|
||||
return {e.title_raw: resolve_entry(client, e) for e in entries}
|
||||
|
||||
|
||||
def test_catan_auto_matches_base_game(rows_by_title):
|
||||
row = rows_by_title["Catan"]
|
||||
assert (row.match_status, row.bgg_id, row.bgg_name) == ("auto", 13, "CATAN")
|
||||
assert row.version_status == "version_unknown" # no cues -> never guess
|
||||
|
||||
|
||||
def test_wingspan_auto_with_version_from_cues(rows_by_title):
|
||||
row = rows_by_title["Wingspan"]
|
||||
assert (row.match_status, row.bgg_id) == ("auto", 266192)
|
||||
# cues: Stonemaier + 2019 + English -> the first edition, not the
|
||||
# 2020 printing (score 5 vs 3) and not the German edition
|
||||
assert row.version_status == "version_auto"
|
||||
assert row.version_id == 465063
|
||||
assert row.version_name == "English first edition"
|
||||
|
||||
|
||||
def test_expansion_matches_expansion_not_base(rows_by_title):
|
||||
row = rows_by_title["Wingspan: European Expansion"]
|
||||
assert (row.match_status, row.bgg_id) == ("auto", 290448)
|
||||
assert row.type == "boardgameexpansion"
|
||||
|
||||
|
||||
def test_wingspan_europe_spine_never_matches_base(rows_by_title):
|
||||
"""The spec's canonical trap: a truncated spine must not auto-match."""
|
||||
row = rows_by_title["Wingspan Europe"]
|
||||
assert row.match_status == "unmatched"
|
||||
assert row.bgg_id is None
|
||||
|
||||
|
||||
def test_accented_title_resolves(rows_by_title):
|
||||
row = rows_by_title["Café International"]
|
||||
assert (row.match_status, row.bgg_id) == ("auto", 373)
|
||||
|
||||
|
||||
def test_same_name_editions_stay_ambiguous(rows_by_title):
|
||||
row = rows_by_title["Citadels"]
|
||||
assert row.match_status == "ambiguous"
|
||||
assert {c.bgg_id for c in row.candidates} == {478, 205398}
|
||||
# stats were fetched for the review UI
|
||||
assert all(c.owned is not None for c in row.candidates)
|
||||
|
||||
|
||||
def test_nonsense_lands_unmatched(rows_by_title):
|
||||
row = rows_by_title["Blorvath: Quest of the Zzyzx"]
|
||||
assert row.match_status == "unmatched"
|
||||
|
||||
|
||||
def test_run_resolve_writes_csv_and_is_idempotent(client, tmp_path):
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
shutil.copy(TITLES_JSON, data_dir / "titles.json")
|
||||
cfg = Config(data_dir=data_dir)
|
||||
|
||||
first = run_resolve(cfg, client=client)
|
||||
assert len(first) == 7
|
||||
|
||||
with cfg.matches_path.open(newline="") as f:
|
||||
rows = list(csv.DictReader(f))
|
||||
assert len(rows) == 7
|
||||
by_title = {r["title_raw"]: r for r in rows}
|
||||
assert by_title["Catan"]["match_status"] == "auto"
|
||||
assert by_title["Citadels"]["match_status"] == "ambiguous"
|
||||
candidates = json.loads(by_title["Citadels"]["candidates_json"])
|
||||
assert len(candidates) == 2
|
||||
assert by_title["Wingspan"]["version_id"] == "465063"
|
||||
|
||||
# second run: everything already in matches.csv is skipped, file unchanged
|
||||
before = cfg.matches_path.read_text()
|
||||
second = run_resolve(cfg, client=client)
|
||||
assert second == []
|
||||
assert cfg.matches_path.read_text() == before
|
||||
|
||||
|
||||
# -- pure-logic unit tests (no fixtures) --------------------------------
|
||||
|
||||
|
||||
def _cand(bgg_id, exact=True, owned=None, type_="boardgame"):
|
||||
return Candidate(
|
||||
bgg_id=bgg_id,
|
||||
name="X",
|
||||
year=2000,
|
||||
type=type_,
|
||||
exact=exact,
|
||||
fuzzy=100.0,
|
||||
owned=owned,
|
||||
)
|
||||
|
||||
|
||||
def test_dominant_famous_game_beats_obscure_duplicate():
|
||||
top = [_cand(1, owned=50000), _cand(2, owned=300)]
|
||||
assert _dominant(top).bgg_id == 1
|
||||
|
||||
|
||||
def test_dominant_refuses_mixed_types():
|
||||
top = [_cand(1, owned=50000), _cand(2, owned=300, type_="boardgameexpansion")]
|
||||
assert _dominant(top) is None
|
||||
|
||||
|
||||
def test_dominant_refuses_close_call():
|
||||
top = [_cand(1, owned=50000), _cand(2, owned=20000)]
|
||||
assert _dominant(top) is None
|
||||
|
||||
|
||||
def test_score_version_all_cues():
|
||||
entry = TitleEntry(
|
||||
title_raw="Wingspan",
|
||||
title_normalized="wingspan",
|
||||
publisher_hint="Stonemaier",
|
||||
year_hint=2019,
|
||||
language_hint="english",
|
||||
)
|
||||
version = GameVersion(
|
||||
version_id=1,
|
||||
name="English first edition",
|
||||
year=2019,
|
||||
publishers=("Stonemaier Games",),
|
||||
languages=("English",),
|
||||
)
|
||||
assert _score_version(entry, version) == 5
|
||||
|
||||
|
||||
def test_score_version_no_overlap():
|
||||
entry = TitleEntry(
|
||||
title_raw="Wingspan",
|
||||
title_normalized="wingspan",
|
||||
publisher_hint="Feuerland",
|
||||
year_hint=2021,
|
||||
language_hint="german",
|
||||
)
|
||||
version = GameVersion(
|
||||
version_id=1,
|
||||
name="English first edition",
|
||||
year=2019,
|
||||
publishers=("Stonemaier Games",),
|
||||
languages=("English",),
|
||||
)
|
||||
assert _score_version(entry, version) == 0
|
||||
@@ -0,0 +1,31 @@
|
||||
"""The one test allowed to touch the real BGG API. Read-only, skipped by
|
||||
default; run with: uv run pytest --run-live -m live
|
||||
|
||||
Needs bgg_username in config.toml and BGG_API_TOKEN in the environment
|
||||
(register at https://boardgamegeek.com/applications).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from bggpipe.bgg_client import BGGClient
|
||||
from bggpipe.config import load_config
|
||||
|
||||
|
||||
@pytest.mark.live
|
||||
def test_fetch_own_collection_read_only(tmp_path: Path) -> None:
|
||||
cfg = load_config()
|
||||
if not cfg.bgg_username:
|
||||
pytest.skip("set bgg_username in config.toml to run the live smoke test")
|
||||
if not os.environ.get("BGG_API_TOKEN"):
|
||||
pytest.skip("set BGG_API_TOKEN to run the live smoke test")
|
||||
|
||||
# fresh cache dir so this genuinely exercises the live API + 202 queue
|
||||
client = BGGClient(cache_dir=tmp_path / "cache")
|
||||
items = client.collection_full(cfg.bgg_username)
|
||||
assert isinstance(items, list)
|
||||
assert all(item.own for item in items)
|
||||
Reference in New Issue
Block a user