65d4cdd5ec
The re-run confirmed round 1 held and then caught second-order bugs in its own fixes plus two long-standing ones everyone missed. TUI decisions after a mid-session reload were counted but never written (rows are now re-adopted into the fresh list on every save, preferring undecided slots on duplicate keys); row_ix was computed by equality so duplicate rows shared an ordinal (identity now, merges included, veto sends it); upload job keys collided for two same-version copies (completions are counted per key, so --limit or an interrupt can no longer strand the second copy); diff consumes collids on exact-version matches (a vetoed same-version second copy was silently swallowed) and splits mismatches: report-only disagreement while an unclaimed copy exists, second-copy add only when every copy is claimed. Also: XML responses are validated and written atomically before caching (a torn or truncated 200 body can never poison a re-run), JSON artifacts write atomically, thing/search parsers refuse missing ids like the collection parser, empty game names are refused by the upload queue, a never-rendering version picker fails retryably instead of terminally, the systemic-failure abort compares exception types, blocked same-title entries defer as a group so positional pairing can't misalign, truncation heads pick the earliest separator, diff messages tell the truth when a token exists without a username, and the shared-constant sweep now actually covers every module (statuses, search types, marker names, client_for, ports). pydantic declared as a direct dependency. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
197 lines
7.0 KiB
Python
197 lines
7.0 KiB
Python
"""BGG client tests: canned transports, fake clocks — never online."""
|
|
|
|
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"})
|
|
|
|
|
|
# -- audit-fix regressions ----------------------------------------------
|
|
|
|
|
|
def test_http_200_error_document_raises_and_is_never_cached(tmp_path):
|
|
# BGG serves some errors as HTTP 200 <errors> XML; caching one would
|
|
# poison every future run for that query
|
|
from bggpipe.models import BGGResponseError
|
|
|
|
errors_xml = "<errors><error><message>Invalid username</message></error></errors>"
|
|
client, _, _ = make_client(tmp_path, [(200, errors_xml)])
|
|
with pytest.raises(BGGResponseError):
|
|
client.get_xml("collection", {"username": "nobody", "own": "1"})
|
|
assert list((tmp_path / "cache").glob("*.xml")) == []
|
|
|
|
|
|
def test_collection_full_merges_and_dedupes_by_collid(tmp_path):
|
|
base_xml = (
|
|
'<items totalitems="2">'
|
|
'<item objectid="13" collid="100" subtype="boardgame">'
|
|
'<name>Catan</name><status own="1"/></item>'
|
|
'<item objectid="177" collid="101" subtype="boardgame">'
|
|
'<name>Advanced Civilization</name><status own="1"/></item>'
|
|
"</items>"
|
|
)
|
|
expansion_xml = (
|
|
'<items totalitems="1">'
|
|
'<item objectid="177" collid="101" subtype="boardgameexpansion">'
|
|
'<name>Advanced Civilization</name><status own="1"/></item>'
|
|
"</items>"
|
|
)
|
|
client, _, _ = make_client(tmp_path, [(200, base_xml), (200, expansion_xml)])
|
|
items = client.collection_full("eric")
|
|
assert len(items) == 2 # collid 101 appears in both responses: one copy
|
|
assert {i.coll_id for i in items} == {100, 101}
|
|
|
|
|
|
def test_collection_item_missing_collid_refuses_to_parse(tmp_path):
|
|
# a truncated response must fail loudly, not coerce ids to 0 and let
|
|
# the dedupe silently drop owned games
|
|
from bggpipe.models import BGGResponseError, parse_collection
|
|
|
|
bad_xml = (
|
|
'<items totalitems="1">'
|
|
'<item objectid="13" subtype="boardgame">'
|
|
'<name>Catan</name><status own="1"/></item>'
|
|
"</items>"
|
|
)
|
|
with pytest.raises(BGGResponseError):
|
|
parse_collection(bad_xml)
|
|
|
|
|
|
def test_malformed_xml_raises_and_is_never_cached(tmp_path):
|
|
from bggpipe.models import BGGResponseError
|
|
|
|
torn = '<items total="1"><item type="boardgame" id="13"><na'
|
|
client, _, _ = make_client(tmp_path, [(200, torn)])
|
|
with pytest.raises(BGGResponseError):
|
|
client.get_xml("search", {"query": "catan", "type": "boardgame"})
|
|
assert list((tmp_path / "cache").glob("*.xml")) == []
|