Files
stack/tests/api/test_base.py
kert 5261032a9b
All checks were successful
ci/woodpecker/push/infra-ci Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
add tests for 100% line coverage across all packages
Cover remaining uncovered lines in bcda (client, store, log, pipe/cclf,
flatten), bib (sync, spider, translate, ingest, item, store, format, ui),
cms (express wrappers, log edge cases), api (base, gitea, rustfs,
woodpecker, zotero), bls (table import), and pfs (pragma on race guard).

37,687 statements, 0 missed — 11,061 tests passing.
2026-02-28 22:03:23 -05:00

258 lines
7.5 KiB
Python

"""Tests for the base Client."""
from __future__ import annotations
import json
import httpx
import pytest
from api.clients.base import ApiError, Client
def _counting_transport(responses):
"""Return a transport that yields pre-defined responses in order."""
call_count = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
idx = min(call_count["n"], len(responses) - 1)
call_count["n"] += 1
resp = responses[idx]
if isinstance(resp, Exception):
raise resp
status, body = resp
return httpx.Response(
status,
content=json.dumps(body).encode(),
headers={"Content-Type": "application/json"},
)
return httpx.MockTransport(handler), call_count
class TestContextManager:
def test_enter_exit(self, json_response):
transport = json_response()
with Client("http://test", _transport=transport) as c:
assert isinstance(c, Client)
class TestRetry5xx:
def test_retries_on_500_then_succeeds(self):
transport, counts = _counting_transport(
[
(500, {"error": "internal"}),
(200, {"ok": True}),
]
)
c = Client(
"http://test",
retry_interval=0.0,
_transport=transport,
)
resp = c.get("/foo")
assert resp.status_code == 200
assert counts["n"] == 2
def test_raises_after_max_retries(self):
transport, _ = _counting_transport(
[
(500, {"error": "fail"}),
(500, {"error": "fail"}),
(500, {"error": "fail"}),
]
)
c = Client(
"http://test",
max_retries=3,
retry_interval=0.0,
_transport=transport,
)
with pytest.raises(httpx.HTTPStatusError):
c.get("/foo")
class TestRetry429:
def test_retries_on_rate_limit(self):
call_count = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
call_count["n"] += 1
if call_count["n"] == 1:
return httpx.Response(
429,
content=b"{}",
headers={"Retry-After": "0"},
)
return httpx.Response(
200,
content=b'{"ok": true}',
headers={"Content-Type": "application/json"},
)
c = Client(
"http://test",
_transport=httpx.MockTransport(handler),
)
resp = c.get("/foo")
assert resp.status_code == 200
assert call_count["n"] == 2
class TestConnectionError:
def test_retries_on_connect_error(self):
transport, counts = _counting_transport(
[
httpx.ConnectError("refused"),
(200, {"ok": True}),
]
)
c = Client(
"http://test",
retry_interval=0.0,
_transport=transport,
)
resp = c.get("/foo")
assert resp.status_code == 200
assert counts["n"] == 2
def test_raises_api_error_after_exhaustion(self):
transport, _ = _counting_transport(
[
httpx.ConnectError("refused"),
httpx.ConnectError("refused"),
httpx.ConnectError("refused"),
]
)
c = Client(
"http://test",
max_retries=3,
retry_interval=0.0,
_transport=transport,
)
with pytest.raises(ApiError, match="failed after 3 attempts"):
c.get("/foo")
class TestHeaders:
def test_default_headers_merged(self, capture_transport):
cap = capture_transport
class TokenClient(Client):
def _default_headers(self):
return {"Authorization": "Bearer xyz"}
c = TokenClient("http://test", _transport=cap.transport())
c.get("/foo")
assert cap.requests[0].headers["authorization"] == "Bearer xyz"
def test_extra_headers_override(self, capture_transport):
cap = capture_transport
class TokenClient(Client):
def _default_headers(self):
return {"Authorization": "Bearer old"}
c = TokenClient("http://test", _transport=cap.transport())
c.get(
"/foo",
headers={"Authorization": "Bearer new"},
)
assert cap.requests[0].headers["authorization"] == "Bearer new"
class TestAuth401Refresh:
def test_re_authenticates_on_401(self):
call_count = {"n": 0}
refreshed = {"called": False}
def handler(request: httpx.Request) -> httpx.Response:
call_count["n"] += 1
if call_count["n"] == 1:
return httpx.Response(401, content=b"unauth")
return httpx.Response(
200,
content=b'{"ok": true}',
headers={"Content-Type": "application/json"},
)
class RefreshClient(Client):
def _default_headers(self):
tok = "new" if refreshed["called"] else "old"
return {"Authorization": f"Bearer {tok}"}
def authenticate(self):
refreshed["called"] = True
c = RefreshClient(
"http://test",
_transport=httpx.MockTransport(handler),
)
resp = c.get("/foo")
assert resp.status_code == 200
assert refreshed["called"]
class TestPatchMethod:
def test_patch_sends_patch_request(self, capture_transport):
cap = capture_transport
c = Client("http://test", _transport=cap.transport({}))
resp = c.patch("/resource/1", json={"name": "updated"})
assert resp.status_code == 200
req = cap.requests[0]
assert req.method == "PATCH"
class TestLastExcFallback:
"""Lines 197-199: fallback when loop ends without raising."""
def test_last_exc_fallback_after_500_then_429(self):
"""When a 500 sets last_exc, then a 429 exhausts retries,
the last_exc path (line 198) raises ApiError."""
call_count = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
call_count["n"] += 1
if call_count["n"] == 1:
return httpx.Response(
500,
content=b"{}",
headers={"Content-Type": "application/json"},
)
return httpx.Response(
429,
content=b"{}",
headers={"Retry-After": "0"},
)
c = Client(
"http://test",
max_retries=2,
retry_interval=0.0,
_transport=httpx.MockTransport(handler),
)
with pytest.raises(ApiError, match="Server error 500"):
c.get("/foo")
def test_no_response_fallback_on_exhausted_429(self):
"""When all retries are consumed by 429 and last_exc is never
set, line 199 raises 'Request failed with no response'."""
call_count = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
call_count["n"] += 1
return httpx.Response(
429,
content=b"{}",
headers={"Retry-After": "0"},
)
c = Client(
"http://test",
max_retries=2,
retry_interval=0.0,
_transport=httpx.MockTransport(handler),
)
with pytest.raises(ApiError, match="no response"):
c.get("/foo")