"""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 = ( '' '' "" ) 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"}) # -- response validation and cache hygiene ------------------------------ def test_http_200_error_document_raises_and_is_never_cached(tmp_path): # BGG serves some errors as HTTP 200 XML; caching one would # poison every future run for that query from bggpipe.models import BGGResponseError errors_xml = "Invalid username" 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 = ( '' '' 'Catan' '' 'Advanced Civilization' "" ) expansion_xml = ( '' '' 'Advanced Civilization' "" ) 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 = ( '' '' 'Catan' "" ) 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 = '