55shared shapes route to the first decoder in the list.
66"""
77
8+ import contextlib
89import dataclasses
10+ from collections .abc import Iterator
911from http import HTTPStatus
12+ from unittest .mock import MagicMock , patch
1013
1114import httpx2
1215import msgspec
1316import pydantic
1417import pytest
1518
16- from httpware import AsyncClient , Client , MissingDecoderError
19+ from httpware import AsyncClient , Client , MissingDecoderError , ResponseDecoder
1720from httpware .decoders .msgspec import MsgspecDecoder
1821from httpware .decoders .pydantic import PydanticDecoder
1922
@@ -56,6 +59,19 @@ def handler(request: httpx2.Request) -> httpx2.Response:
5659 )
5760
5861
62+ @contextlib .contextmanager
63+ def _decode_spies (* decoders : ResponseDecoder ) -> Iterator [list [MagicMock ]]:
64+ """Wrap each decoder's `decode` so a test can assert WHICH one ran.
65+
66+ Two real decoders that both claim a shared shape (e.g. `dict[str, int]` or a
67+ stdlib dataclass) produce identical output, so output equality can't prove
68+ the ordering invariant. Spying on `decode` shows which decoder the dispatcher
69+ actually selected. Spies are yielded in the same order as `decoders`.
70+ """
71+ with contextlib .ExitStack () as stack :
72+ yield [stack .enter_context (patch .object (d , "decode" , wraps = d .decode )) for d in decoders ]
73+
74+
5975async def test_async_basemodel_routes_to_pydantic () -> None :
6076 client = _async_client_with_body (
6177 b'{"id": 1, "name": "Ada"}' ,
@@ -77,33 +93,37 @@ async def test_async_struct_routes_to_msgspec() -> None:
7793
7894
7995async def test_async_dict_routes_to_first_decoder () -> None :
80- """Shared shape: first decoder in the list wins."""
81- pyd = PydanticDecoder ()
82- msg = MsgspecDecoder ()
96+ """Shared shape (dict): the FIRST decoder in the list actually decodes it."""
97+ pyd , msg = PydanticDecoder (), MsgspecDecoder ()
8398 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
99+ with _decode_spies ( pyd , msg ) as ( pyd_spy , msg_spy ):
100+ result = await client . get ( "https://example.test/x" , response_model = dict [ str , int ])
86101 assert result == {"a" : 1 }
102+ pyd_spy .assert_called_once ()
103+ msg_spy .assert_not_called ()
87104
88105
89106async 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 ])
107+ """Reversed list: the shared shape now routes to msgspec, proven by the spy."""
108+ msg , pyd = MsgspecDecoder (), PydanticDecoder ()
109+ client = _async_client_with_body (b'{"a": 1}' , decoders = [msg , pyd ])
110+ with _decode_spies (msg , pyd ) as (msg_spy , pyd_spy ):
111+ result = await client .get ("https://example.test/x" , response_model = dict [str , int ])
96112 assert result == {"a" : 1 }
113+ msg_spy .assert_called_once ()
114+ pyd_spy .assert_not_called ()
97115
98116
99117async 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 )
118+ """Stdlib dataclass is a shared shape; the first decoder (pydantic) decodes it."""
119+ pyd , msg = PydanticDecoder (), MsgspecDecoder ()
120+ client = _async_client_with_body ( b'{"id": 1, "name": "Ada"}' , decoders = [pyd , msg ])
121+ with _decode_spies ( pyd , msg ) as ( pyd_spy , msg_spy ):
122+ result = await client .get ("https://example.test/x" , response_model = _DC )
105123 assert type (result ) is _DC
106124 assert result .id == 1
125+ pyd_spy .assert_called_once ()
126+ msg_spy .assert_not_called ()
107127
108128
109129async def test_async_list_of_basemodel_routes_to_pydantic () -> None :
@@ -176,22 +196,51 @@ def test_sync_struct_routes_to_msgspec() -> None:
176196
177197
178198def 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 ])
199+ """Sync twin: shared shape (dict) routes to the first decoder (pydantic)."""
200+ pyd , msg = PydanticDecoder (), MsgspecDecoder ()
201+ client = _sync_client_with_body ( b'{"a": 1}' , decoders = [pyd , msg ])
202+ with _decode_spies ( pyd , msg ) as ( pyd_spy , msg_spy ):
203+ result = client .get ("https://example.test/x" , response_model = dict [str , int ])
184204 assert result == {"a" : 1 }
205+ pyd_spy .assert_called_once ()
206+ msg_spy .assert_not_called ()
185207 client .close ()
186208
187209
188210def test_sync_dict_routes_to_msgspec_when_first () -> None :
211+ """Sync twin: reversed list routes the shared shape to msgspec."""
212+ msg , pyd = MsgspecDecoder (), PydanticDecoder ()
213+ client = _sync_client_with_body (b'{"a": 1}' , decoders = [msg , pyd ])
214+ with _decode_spies (msg , pyd ) as (msg_spy , pyd_spy ):
215+ result = client .get ("https://example.test/x" , response_model = dict [str , int ])
216+ assert result == {"a" : 1 }
217+ msg_spy .assert_called_once ()
218+ pyd_spy .assert_not_called ()
219+ client .close ()
220+
221+
222+ def test_sync_dataclass_routes_to_first_decoder () -> None :
223+ """Sync twin: stdlib dataclass routes to the first decoder (pydantic)."""
224+ pyd , msg = PydanticDecoder (), MsgspecDecoder ()
225+ client = _sync_client_with_body (b'{"id": 1, "name": "Ada"}' , decoders = [pyd , msg ])
226+ with _decode_spies (pyd , msg ) as (pyd_spy , msg_spy ):
227+ result = client .get ("https://example.test/x" , response_model = _DC )
228+ assert type (result ) is _DC
229+ assert result .id == 1
230+ pyd_spy .assert_called_once ()
231+ msg_spy .assert_not_called ()
232+ client .close ()
233+
234+
235+ def test_sync_list_of_basemodel_routes_to_pydantic () -> None :
236+ """Sync twin: list[BaseModel] is claimed only by pydantic (msgspec rejects it)."""
189237 client = _sync_client_with_body (
190- b'{"a ": 1} ' ,
191- decoders = [MsgspecDecoder (), PydanticDecoder ()],
238+ b'[{"id ": 1, "name": "Ada"}, {"id": 2, "name": "Bo"}] ' ,
239+ decoders = [PydanticDecoder (), MsgspecDecoder ()],
192240 )
193- result = client .get ("https://example.test/x" , response_model = dict [str , int ])
194- assert result == {"a" : 1 }
241+ result = client .get ("https://example.test/x" , response_model = list [_PydanticUser ])
242+ assert len (result ) == 2 # noqa: PLR2004
243+ assert all (type (item ) is _PydanticUser for item in result )
195244 client .close ()
196245
197246
0 commit comments