Files
bggpipe/tests/test_client.py
T
Eric Wagoner 2109e3544a 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>
2026-08-01 12:33:32 -04:00

134 lines
4.5 KiB
Python

from __future__ import annotations
import random
import httpx
import pytest
from bggpipe.bgg_client import BGGClient, BGGQueueTimeout, cache_key
SEARCH_XML = (
'<items total="1"><item type="boardgame" id="13">'
'<name type="primary" value="CATAN"/><yearpublished value="1995"/>'
"</item></items>"
)
class FakeClock:
def __init__(self) -> None:
self.now = 1000.0
self.sleeps: list[float] = []
def monotonic(self) -> float:
return self.now
def sleep(self, seconds: float) -> None:
self.sleeps.append(seconds)
self.now += seconds
def make_client(tmp_path, responses, clock=None):
"""Client whose transport pops canned (status, body) responses."""
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
status, body = responses.pop(0)
return httpx.Response(status, text=body)
clock = clock or FakeClock()
client = BGGClient(
cache_dir=tmp_path / "cache",
transport=httpx.MockTransport(handler),
sleep=clock.sleep,
monotonic=clock.monotonic,
rng=random.Random(42),
)
return client, calls, clock
def test_caches_successful_responses(tmp_path):
client, calls, _ = make_client(tmp_path, [(200, SEARCH_XML)])
first = client.get_xml("search", {"query": "catan", "type": "boardgame"})
second = client.get_xml("search", {"query": "catan", "type": "boardgame"})
assert first == second == SEARCH_XML
assert len(calls) == 1 # second call served from disk
cached = (
tmp_path
/ "cache"
/ cache_key("search", {"query": "catan", "type": "boardgame"})
)
assert cached.exists()
def test_202_retries_with_spec_backoff_then_succeeds(tmp_path):
client, calls, clock = make_client(
tmp_path, [(202, ""), (202, ""), (200, SEARCH_XML)]
)
xml = client.get_xml("collection", {"username": "someone", "own": "1"})
assert xml == SEARCH_XML
assert len(calls) == 3
assert clock.sleeps == [2.0, 5.0] # spec schedule between attempts
def test_202_gives_up_after_five_attempts(tmp_path):
client, calls, clock = make_client(tmp_path, [(202, "")] * 5)
with pytest.raises(BGGQueueTimeout):
client.get_xml("collection", {"username": "someone", "own": "1"})
assert len(calls) == 5
assert clock.sleeps == [2.0, 5.0, 10.0, 30.0]
# nothing cached on failure
assert not (tmp_path / "cache").exists()
def test_429_backs_off_with_jitter(tmp_path):
client, calls, clock = make_client(tmp_path, [(429, ""), (200, SEARCH_XML)])
client.get_xml("search", {"query": "catan"})
assert len(calls) == 2
(backoff,) = clock.sleeps
assert 2.0 <= backoff <= 3.0 # base 2s, up to +50% jitter
def test_rate_limit_spaces_consecutive_requests(tmp_path):
client, calls, clock = make_client(tmp_path, [(200, SEARCH_XML), (200, SEARCH_XML)])
client.get_xml("search", {"query": "catan"})
client.get_xml("search", {"query": "wingspan"})
# second request must wait the full 2s window (fake clock: no time passed)
assert clock.sleeps == [2.0]
def test_http_error_raises(tmp_path):
client, _, _ = make_client(tmp_path, [(500, "boom")])
with pytest.raises(httpx.HTTPStatusError):
client.get_xml("search", {"query": "catan"})
def test_cache_key_stable_and_filename_safe(tmp_path):
key1 = cache_key("search", {"query": "Café & Krieg?", "type": "boardgame"})
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"})