Skip to content

Commit 485073d

Browse files
committed
test(client): cover type-dispatched decoder routing across both clients
1 parent 89ccda5 commit 485073d

1 file changed

Lines changed: 209 additions & 0 deletions

File tree

tests/test_client_dispatch.py

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
"""Dispatch routing across multiple registered decoders.
2+
3+
Covers the routing examples in planning/specs/2026-06-09-multi-decoder-design.md
4+
§ Architecture — native types route via their library regardless of order,
5+
shared shapes route to the first decoder in the list.
6+
"""
7+
8+
import dataclasses
9+
from http import HTTPStatus
10+
11+
import httpx2
12+
import msgspec
13+
import pydantic
14+
import pytest
15+
16+
from httpware import AsyncClient, Client, MissingDecoderError
17+
from httpware.decoders.msgspec import MsgspecDecoder
18+
from httpware.decoders.pydantic import PydanticDecoder
19+
20+
21+
class _PydanticUser(pydantic.BaseModel):
22+
id: int
23+
name: str
24+
25+
26+
class _MsgspecUser(msgspec.Struct):
27+
id: int
28+
name: str
29+
30+
31+
@dataclasses.dataclass
32+
class _DC:
33+
id: int
34+
name: str
35+
36+
37+
def _async_client_with_body(payload: bytes, decoders: list) -> AsyncClient:
38+
def handler(request: httpx2.Request) -> httpx2.Response:
39+
return httpx2.Response(HTTPStatus.OK, content=payload, request=request)
40+
41+
transport = httpx2.MockTransport(handler)
42+
return AsyncClient(
43+
httpx2_client=httpx2.AsyncClient(transport=transport),
44+
decoders=decoders,
45+
)
46+
47+
48+
def _sync_client_with_body(payload: bytes, decoders: list) -> Client:
49+
def handler(request: httpx2.Request) -> httpx2.Response:
50+
return httpx2.Response(HTTPStatus.OK, content=payload, request=request)
51+
52+
transport = httpx2.MockTransport(handler)
53+
return Client(
54+
httpx2_client=httpx2.Client(transport=transport),
55+
decoders=decoders,
56+
)
57+
58+
59+
async def test_async_basemodel_routes_to_pydantic() -> None:
60+
client = _async_client_with_body(
61+
b'{"id": 1, "name": "Ada"}',
62+
decoders=[PydanticDecoder(), MsgspecDecoder()],
63+
)
64+
result = await client.get("https://example.test/x", response_model=_PydanticUser)
65+
assert type(result) is _PydanticUser
66+
assert result.id == 1
67+
68+
69+
async def test_async_struct_routes_to_msgspec() -> None:
70+
client = _async_client_with_body(
71+
b'{"id": 1, "name": "Ada"}',
72+
decoders=[PydanticDecoder(), MsgspecDecoder()],
73+
)
74+
result = await client.get("https://example.test/x", response_model=_MsgspecUser)
75+
assert type(result) is _MsgspecUser
76+
assert result.id == 1
77+
78+
79+
async def test_async_dict_routes_to_first_decoder() -> None:
80+
"""Shared shape: first decoder in the list wins."""
81+
pyd = PydanticDecoder()
82+
msg = MsgspecDecoder()
83+
client = _async_client_with_body(b'{"a": 1}', decoders=[pyd, msg])
84+
result = await client.get("https://example.test/x", response_model=dict[str, int])
85+
assert type(result) is dict
86+
assert result == {"a": 1}
87+
88+
89+
async def test_async_dict_routes_to_msgspec_when_first() -> None:
90+
"""Reversed list flips routing for shared shapes."""
91+
client = _async_client_with_body(
92+
b'{"a": 1}',
93+
decoders=[MsgspecDecoder(), PydanticDecoder()],
94+
)
95+
result = await client.get("https://example.test/x", response_model=dict[str, int])
96+
assert result == {"a": 1}
97+
98+
99+
async def test_async_dataclass_routes_to_first_decoder() -> None:
100+
client = _async_client_with_body(
101+
b'{"id": 1, "name": "Ada"}',
102+
decoders=[PydanticDecoder(), MsgspecDecoder()],
103+
)
104+
result = await client.get("https://example.test/x", response_model=_DC)
105+
assert type(result) is _DC
106+
assert result.id == 1
107+
108+
109+
async def test_async_list_of_basemodel_routes_to_pydantic() -> None:
110+
client = _async_client_with_body(
111+
b'[{"id": 1, "name": "Ada"}, {"id": 2, "name": "Bo"}]',
112+
decoders=[PydanticDecoder(), MsgspecDecoder()],
113+
)
114+
result = await client.get("https://example.test/x", response_model=list[_PydanticUser])
115+
assert len(result) == 2 # noqa: PLR2004
116+
assert all(type(item) is _PydanticUser for item in result)
117+
118+
119+
async def test_async_missing_decoder_with_empty_list() -> None:
120+
"""Empty decoder list and response_model= raises before HTTP call."""
121+
122+
def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover
123+
pytest.fail("transport should not be invoked")
124+
125+
transport = httpx2.MockTransport(handler)
126+
client = AsyncClient(
127+
httpx2_client=httpx2.AsyncClient(transport=transport),
128+
decoders=[],
129+
)
130+
with pytest.raises(MissingDecoderError) as exc_info:
131+
await client.get("https://example.test/x", response_model=_PydanticUser)
132+
assert exc_info.value.registered_names == ()
133+
134+
135+
async def test_async_missing_decoder_when_none_claim() -> None:
136+
"""Registered decoders that all reject the model raise MissingDecoderError."""
137+
138+
class _Stub:
139+
def can_decode(self, model: type) -> bool: # noqa: ARG002
140+
return False
141+
142+
def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 # pragma: no cover
143+
return None
144+
145+
def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover
146+
pytest.fail("transport should not be invoked")
147+
148+
transport = httpx2.MockTransport(handler)
149+
client = AsyncClient(
150+
httpx2_client=httpx2.AsyncClient(transport=transport),
151+
decoders=[_Stub()],
152+
)
153+
with pytest.raises(MissingDecoderError) as exc_info:
154+
await client.get("https://example.test/x", response_model=_PydanticUser)
155+
assert exc_info.value.registered_names == ("_Stub",)
156+
157+
158+
def test_sync_basemodel_routes_to_pydantic() -> None:
159+
client = _sync_client_with_body(
160+
b'{"id": 1, "name": "Ada"}',
161+
decoders=[PydanticDecoder(), MsgspecDecoder()],
162+
)
163+
result = client.get("https://example.test/x", response_model=_PydanticUser)
164+
assert type(result) is _PydanticUser
165+
client.close()
166+
167+
168+
def test_sync_struct_routes_to_msgspec() -> None:
169+
client = _sync_client_with_body(
170+
b'{"id": 1, "name": "Ada"}',
171+
decoders=[PydanticDecoder(), MsgspecDecoder()],
172+
)
173+
result = client.get("https://example.test/x", response_model=_MsgspecUser)
174+
assert type(result) is _MsgspecUser
175+
client.close()
176+
177+
178+
def test_sync_dict_routes_to_first_decoder() -> None:
179+
client = _sync_client_with_body(
180+
b'{"a": 1}',
181+
decoders=[PydanticDecoder(), MsgspecDecoder()],
182+
)
183+
result = client.get("https://example.test/x", response_model=dict[str, int])
184+
assert result == {"a": 1}
185+
client.close()
186+
187+
188+
def test_sync_dict_routes_to_msgspec_when_first() -> None:
189+
client = _sync_client_with_body(
190+
b'{"a": 1}',
191+
decoders=[MsgspecDecoder(), PydanticDecoder()],
192+
)
193+
result = client.get("https://example.test/x", response_model=dict[str, int])
194+
assert result == {"a": 1}
195+
client.close()
196+
197+
198+
def test_sync_missing_decoder_with_empty_list() -> None:
199+
def handler(_: httpx2.Request) -> httpx2.Response: # pragma: no cover
200+
pytest.fail("transport should not be invoked")
201+
202+
transport = httpx2.MockTransport(handler)
203+
client = Client(
204+
httpx2_client=httpx2.Client(transport=transport),
205+
decoders=[],
206+
)
207+
with pytest.raises(MissingDecoderError):
208+
client.get("https://example.test/x", response_model=_PydanticUser)
209+
client.close()

0 commit comments

Comments
 (0)