44
55import asyncio
66
7+ import httpx
78import pytest
89
910from agentex .lib .core .compat import version_guard as vg
@@ -13,14 +14,49 @@ def _run(coro):
1314 return asyncio .run (coro )
1415
1516
17+ def _patch_transport (monkeypatch , handler ):
18+ """Make version_guard's httpx.AsyncClient route through an in-memory MockTransport,
19+ so fetch_backend_version runs for real (request build, status check, JSON parse)
20+ without touching the network. `handler(request) -> httpx.Response` (or raises)."""
21+
22+ real_client = httpx .AsyncClient # capture before patching to avoid recursing into the factory
23+
24+ def factory (** kwargs ):
25+ kwargs .pop ("transport" , None )
26+ return real_client (transport = httpx .MockTransport (handler ), ** kwargs )
27+
28+ monkeypatch .setattr (vg .httpx , "AsyncClient" , factory )
29+
30+
1631def test_parse_versions ():
1732 assert vg ._parse ("0.2.1" ) == (0 , 2 , 1 , None )
1833 assert vg ._parse ("v1.4.0" ) == (1 , 4 , 0 , None )
1934 assert vg ._parse ("0.2.1-rc.1+build5" ) == (0 , 2 , 1 , "rc.1" ) # build metadata ignored
35+ assert vg ._parse ("0.1.0+build5" ) == (0 , 1 , 0 , None ) # build metadata only, still stable
2036 assert vg ._parse ("garbage" ) is None
2137 assert vg ._parse (None ) is None
2238
2339
40+ def test_parse_rejects_malformed_tails ():
41+ # Anchored regex: a junk tail after the triplet must NOT silently parse as stable 0.1.0;
42+ # it has to fall through to None (→ unknown / unparseable path), not satisfy the floor.
43+ for bad in ("0.1.0rc1" , "0.1.0foo" , "0.1.0.1" , "0.1.0-" , "1.2" , "0.1.0-rc 1" ):
44+ assert vg ._parse (bad ) is None , bad
45+
46+
47+ def test_parse_anchored_both_ends ():
48+ # Leading anchor (^): anything before the triplet (other than whitespace / a `v`) is rejected.
49+ for bad in ("foo0.1.0" , ">=0.1.0" , "x0.1.0" , "=0.1.0" , "0 0.1.0" ):
50+ assert vg ._parse (bad ) is None , bad
51+ # Trailing anchor ($): anything after the version (other than whitespace) is rejected.
52+ for bad in ("0.1.0 extra" , "0.1.0;" , "0.1.0/" , "0.1.0+" , "0.1.0 0.1.0" ):
53+ assert vg ._parse (bad ) is None , bad
54+ # What the anchors DO permit: surrounding whitespace and an optional leading `v`.
55+ assert vg ._parse (" 0.1.0 " ) == (0 , 1 , 0 , None )
56+ assert vg ._parse ("\t v1.2.3\n " ) == (1 , 2 , 3 , None )
57+ assert vg ._parse (" 0.2.0-rc.1 " ) == (0 , 2 , 0 , "rc.1" )
58+
59+
2460def test_prerelease_precedence ():
2561 k = lambda v : vg ._precedence_key (vg ._parse (v )) # noqa: E731
2662 assert k ("0.1.0-rc.1" ) < k ("0.1.0" ) # prerelease precedes its stable release (SemVer §11)
@@ -81,3 +117,92 @@ async def fake(url, **kw):
81117def test_no_base_url_is_noop ():
82118 _run (vg .assert_backend_compatible (None ))
83119 _run (vg .assert_backend_compatible ("" ))
120+
121+
122+ def test_truthy (monkeypatch ):
123+ for val in ("1" , "true" , "True" , "YES" , "on" ):
124+ monkeypatch .setenv ("X_GUARD_FLAG" , val )
125+ assert vg ._truthy ("X_GUARD_FLAG" )
126+ for val in ("0" , "false" , "no" , "off" , "" ):
127+ monkeypatch .setenv ("X_GUARD_FLAG" , val )
128+ assert not vg ._truthy ("X_GUARD_FLAG" )
129+ monkeypatch .delenv ("X_GUARD_FLAG" , raising = False )
130+ assert not vg ._truthy ("X_GUARD_FLAG" ) # unset → falsy
131+
132+
133+ # --- fetch_backend_version: exercised for real through MockTransport (not mocked out) ---
134+
135+
136+ def test_fetch_success_and_url_construction (monkeypatch ):
137+ seen = {}
138+
139+ def handler (request ):
140+ seen ["url" ] = str (request .url )
141+ seen ["method" ] = request .method
142+ return httpx .Response (200 , json = {"openapi" : "3.1.0" , "info" : {"version" : "0.2.0" }})
143+
144+ _patch_transport (monkeypatch , handler )
145+ assert _run (vg .fetch_backend_version ("http://backend/" )) == "0.2.0"
146+ assert seen ["url" ] == "http://backend/openapi.json" # trailing slash trimmed, path appended
147+ assert seen ["method" ] == "GET"
148+
149+
150+ def test_fetch_missing_version_field (monkeypatch ):
151+ _patch_transport (monkeypatch , lambda r : httpx .Response (200 , json = {"info" : {}}))
152+ assert _run (vg .fetch_backend_version ("http://backend" )) is None
153+
154+
155+ def test_fetch_missing_info_object (monkeypatch ):
156+ # `info` absent entirely, and `info: null` — both must coalesce to None, not crash.
157+ _patch_transport (monkeypatch , lambda r : httpx .Response (200 , json = {}))
158+ assert _run (vg .fetch_backend_version ("http://backend" )) is None
159+ _patch_transport (monkeypatch , lambda r : httpx .Response (200 , json = {"info" : None }))
160+ assert _run (vg .fetch_backend_version ("http://backend" )) is None
161+
162+
163+ def test_fetch_http_error_status (monkeypatch ):
164+ # raise_for_status() → caught → None (e.g. server has no /openapi.json)
165+ _patch_transport (monkeypatch , lambda r : httpx .Response (404 , text = "not found" ))
166+ assert _run (vg .fetch_backend_version ("http://backend" )) is None
167+ _patch_transport (monkeypatch , lambda r : httpx .Response (503 , text = "unavailable" ))
168+ assert _run (vg .fetch_backend_version ("http://backend" )) is None
169+
170+
171+ def test_fetch_non_json_body (monkeypatch ):
172+ _patch_transport (monkeypatch , lambda r : httpx .Response (200 , text = "<html>nope</html>" ))
173+ assert _run (vg .fetch_backend_version ("http://backend" )) is None
174+
175+
176+ def test_fetch_connection_error (monkeypatch ):
177+ def handler (request ):
178+ raise httpx .ConnectError ("connection refused" , request = request )
179+
180+ _patch_transport (monkeypatch , handler )
181+ assert _run (vg .fetch_backend_version ("http://backend" )) is None
182+
183+
184+ # --- assert_backend_compatible end-to-end: real fetch through MockTransport, not mocked out ---
185+
186+
187+ def test_assert_end_to_end_old_backend_raises (monkeypatch ):
188+ monkeypatch .delenv (vg .SKIP_ENV , raising = False )
189+ _patch_transport (monkeypatch , lambda r : httpx .Response (200 , json = {"info" : {"version" : "0.0.9" }}))
190+ with pytest .raises (vg .IncompatibleBackendError ):
191+ _run (vg .assert_backend_compatible ("http://backend" , min_version = "0.1.0" , sdk_version = "0.13.0" ))
192+
193+
194+ def test_assert_end_to_end_new_backend_passes (monkeypatch ):
195+ monkeypatch .delenv (vg .SKIP_ENV , raising = False )
196+ _patch_transport (monkeypatch , lambda r : httpx .Response (200 , json = {"info" : {"version" : "0.2.0" }}))
197+ _run (vg .assert_backend_compatible ("http://backend" , min_version = "0.1.0" ))
198+
199+
200+ def test_assert_end_to_end_unreachable_backend_does_not_raise (monkeypatch ):
201+ # real fetch returns None on connection failure → guard proceeds (no crash on transient blip)
202+ monkeypatch .delenv (vg .SKIP_ENV , raising = False )
203+
204+ def handler (request ):
205+ raise httpx .ConnectError ("refused" , request = request )
206+
207+ _patch_transport (monkeypatch , handler )
208+ _run (vg .assert_backend_compatible ("http://backend" , min_version = "9.9.9" ))
0 commit comments