Skip to content

Commit 8198861

Browse files
committed
test(federation): cover PeerRegistry caching
Direct unit tests for the cache layer (server-level tests only exercised the /registry/global wiring): negative caching of dead peers + retry after the window, successful-fetch reuse, non-list coercion, origin annotation, no-peers fast path, and trailing-slash URL handling.
1 parent 5635233 commit 8198861

1 file changed

Lines changed: 123 additions & 0 deletions

File tree

tests/test_federation_cache.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Unit tests for PeerRegistry caching (no server, mocked transport).
2+
3+
The server-level test_federation.py exercises the /registry/global wiring;
4+
these cover the cache layer directly, including the regression where a dead
5+
peer was re-fetched on every aggregate() call.
6+
"""
7+
8+
import httpx
9+
10+
from zhub.federation import PeerRegistry
11+
12+
13+
def _registry(peers, handler, *, refresh_seconds=60.0):
14+
pr = PeerRegistry(peers, refresh_seconds=refresh_seconds)
15+
pr._http = httpx.AsyncClient(transport=httpx.MockTransport(handler), timeout=1.0)
16+
return pr
17+
18+
19+
async def test_dead_peer_negatively_cached_within_window():
20+
"""A peer that errors is fetched once, then served from cache until the
21+
refresh window expires — not re-fetched on every call."""
22+
hits = {"n": 0}
23+
24+
def handler(request):
25+
hits["n"] += 1
26+
raise httpx.ConnectError("refused")
27+
28+
pr = _registry(["http://dead.example"], handler, refresh_seconds=60.0)
29+
try:
30+
for _ in range(3):
31+
assert await pr.aggregate() == []
32+
assert hits["n"] == 1
33+
assert "http://dead.example" in pr._cache
34+
finally:
35+
await pr.close()
36+
37+
38+
async def test_dead_peer_retried_after_window_expires():
39+
"""A negatively cached failure is retried once the window lapses."""
40+
hits = {"n": 0}
41+
42+
def handler(request):
43+
hits["n"] += 1
44+
raise httpx.ConnectError("refused")
45+
46+
pr = _registry(["http://dead.example"], handler, refresh_seconds=0.0)
47+
try:
48+
await pr.get("http://dead.example")
49+
await pr.get("http://dead.example")
50+
assert hits["n"] == 2
51+
finally:
52+
await pr.close()
53+
54+
55+
async def test_successful_fetch_cached_and_reused():
56+
"""A good registry is fetched once and served from cache while fresh."""
57+
hits = {"n": 0}
58+
entries = [{"name": "alpha"}, {"name": "beta"}]
59+
60+
def handler(request):
61+
hits["n"] += 1
62+
return httpx.Response(200, json=entries)
63+
64+
pr = _registry(["http://peer.example"], handler, refresh_seconds=60.0)
65+
try:
66+
assert await pr.get("http://peer.example") == entries
67+
assert await pr.get("http://peer.example") == entries
68+
assert hits["n"] == 1
69+
finally:
70+
await pr.close()
71+
72+
73+
async def test_non_list_registry_treated_as_empty():
74+
"""A peer returning a non-list body is coerced to an empty registry."""
75+
def handler(request):
76+
return httpx.Response(200, json={"not": "a list"})
77+
78+
pr = _registry(["http://peer.example"], handler)
79+
try:
80+
assert await pr.get("http://peer.example") == []
81+
finally:
82+
await pr.close()
83+
84+
85+
async def test_aggregate_annotates_origin():
86+
"""aggregate() tags every peer entry with its origin URL."""
87+
def handler(request):
88+
return httpx.Response(200, json=[{"name": "x"}])
89+
90+
pr = _registry(["http://a.example", "http://b.example"], handler)
91+
try:
92+
out = await pr.aggregate()
93+
assert {e["origin"] for e in out} == {"http://a.example", "http://b.example"}
94+
assert all(e["name"] == "x" for e in out)
95+
finally:
96+
await pr.close()
97+
98+
99+
async def test_aggregate_no_peers_returns_empty():
100+
def handler(request): # never called
101+
raise AssertionError("should not fetch with no peers")
102+
103+
pr = _registry([], handler)
104+
try:
105+
assert await pr.aggregate() == []
106+
finally:
107+
await pr.close()
108+
109+
110+
async def test_registry_url_strips_trailing_slash():
111+
"""The /registry path is appended without doubling the slash."""
112+
seen = {"url": None}
113+
114+
def handler(request):
115+
seen["url"] = str(request.url)
116+
return httpx.Response(200, json=[])
117+
118+
pr = _registry(["http://peer.example/"], handler)
119+
try:
120+
await pr.get("http://peer.example/")
121+
assert seen["url"] == "http://peer.example/registry"
122+
finally:
123+
await pr.close()

0 commit comments

Comments
 (0)