Skip to content

Commit 032bf72

Browse files
committed
feat(testing): add build_test_client async context manager
Closes #549 Adds build_test_client(platform) to adcp.testing — a single async context manager that wraps build_asgi_app + LifespanManager + httpx.AsyncClient, eliminating 4 lines of boilerplate from every in-process integration test. Also adds allowed_hosts parameter to build_asgi_app so callers can configure FastMCP's transport-security layer without manual server manipulation. https://claude.ai/code/session_01UZyqrAkoJPCEz5VLerzxFv
1 parent b16d18f commit 032bf72

3 files changed

Lines changed: 200 additions & 5 deletions

File tree

src/adcp/testing/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
from __future__ import annotations
2222

23-
from adcp.testing.decisioning import build_asgi_app, make_request_context
23+
from adcp.testing.decisioning import build_asgi_app, build_test_client, make_request_context
2424
from adcp.testing.test_helpers import (
2525
CREATIVE_AGENT_CONFIG,
2626
TEST_AGENT_A2A_CONFIG,
@@ -39,6 +39,7 @@
3939

4040
__all__ = [
4141
"build_asgi_app",
42+
"build_test_client",
4243
"make_request_context",
4344
"test_agent",
4445
"test_agent_a2a",

src/adcp/testing/decisioning.py

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Test helpers for the v6 DecisioningPlatform framework.
22
3-
Two adopter-facing helpers that close gaps surfaced by the salesagent
3+
Three adopter-facing helpers that close gaps surfaced by the salesagent
44
v3.12 → 4.x migration:
55
66
* :func:`make_request_context` — build a
@@ -16,18 +16,29 @@
1616
default ``auto_emit_completion_webhooks=False`` skips the F12 boot
1717
gate that otherwise refuses to start a sales platform without a
1818
webhook sender wired.
19+
20+
* :func:`build_test_client` — async context manager that combines
21+
:func:`build_asgi_app`, ``asgi_lifespan.LifespanManager``, and
22+
``httpx.AsyncClient`` into a single ``async with`` block. Requires
23+
``asgi-lifespan`` (included in ``adcp[dev]``).
1924
"""
2025

2126
from __future__ import annotations
2227

28+
import asyncio
29+
from contextlib import asynccontextmanager
2330
from typing import TYPE_CHECKING, Any
31+
from urllib.parse import urlparse
2432

2533
from adcp.decisioning.context import RequestContext
2634
from adcp.decisioning.types import Account
2735

2836
if TYPE_CHECKING:
37+
from collections.abc import AsyncIterator, Mapping
2938
from datetime import datetime
3039

40+
import httpx
41+
3142
from adcp.decisioning import (
3243
AuthInfo,
3344
BuyerAgent,
@@ -124,6 +135,7 @@ def build_asgi_app(
124135
name: str | None = None,
125136
advertise_all: bool = False,
126137
auto_emit_completion_webhooks: bool = False,
138+
allowed_hosts: list[str] | None = None,
127139
**factory_kwargs: Any,
128140
) -> Any:
129141
"""Build a Starlette ASGI app for in-process integration tests.
@@ -150,6 +162,12 @@ def build_asgi_app(
150162
:param auto_emit_completion_webhooks: Forwarded to
151163
:func:`create_adcp_server_from_platform`. Default ``False`` for
152164
test ergonomics — production :func:`serve` defaults to ``True``.
165+
:param allowed_hosts: Host header values the MCP transport-security
166+
layer will accept. ``None`` → FastMCP's loopback-only default
167+
(``localhost``, ``127.0.0.1``, ``[::1]``). Pass the hostname
168+
embedded in your ``base_url`` when using a non-loopback test
169+
address (e.g. ``["test"]`` for ``base_url="http://test"``).
170+
:func:`build_test_client` sets this automatically.
153171
:param factory_kwargs: Forwarded to
154172
:func:`create_adcp_server_from_platform` (executor, registry,
155173
webhook_sender, etc.).
@@ -168,8 +186,94 @@ def build_asgi_app(
168186
**factory_kwargs,
169187
)
170188
server_name = name or type(platform).__name__
171-
mcp = create_mcp_server(handler, name=server_name, advertise_all=advertise_all)
189+
mcp = create_mcp_server(
190+
handler,
191+
name=server_name,
192+
advertise_all=advertise_all,
193+
allowed_hosts=allowed_hosts,
194+
)
172195
return mcp.streamable_http_app()
173196

174197

175-
__all__ = ["build_asgi_app", "make_request_context"]
198+
@asynccontextmanager
199+
async def build_test_client(
200+
platform: DecisioningPlatform,
201+
*,
202+
base_url: str = "http://test",
203+
name: str | None = None,
204+
advertise_all: bool = False,
205+
auto_emit_completion_webhooks: bool = False,
206+
follow_redirects: bool = True,
207+
headers: Mapping[str, str] | None = None,
208+
**factory_kwargs: Any,
209+
) -> AsyncIterator[httpx.AsyncClient]:
210+
"""Async context manager yielding an ``httpx.AsyncClient`` wired against
211+
the platform's ASGI app via ``httpx.ASGITransport`` + ``LifespanManager``.
212+
213+
Collapses the four-line boilerplate that every in-process integration test
214+
previously needed — ``build_asgi_app`` + ``LifespanManager`` +
215+
``httpx.AsyncClient`` — into a single ``async with`` block::
216+
217+
async with build_test_client(platform) as client:
218+
resp = await client.post("/mcp/", json=...)
219+
220+
The context manager starts the ASGI lifespan on entry and shuts down both
221+
the client and the lifespan manager on exit. The yielded
222+
``httpx.AsyncClient`` is an :class:`contextlib.AbstractAsyncContextManager`
223+
result; callers use it directly as an async HTTP client.
224+
225+
Requires ``asgi-lifespan`` (included in ``adcp[dev]``). Raises
226+
:class:`ImportError` with an actionable message if it is not installed.
227+
228+
:param platform: The :class:`DecisioningPlatform` instance under test.
229+
:param base_url: Base URL for all requests. Default ``"http://test"``.
230+
The hostname is extracted and added to the transport-security
231+
``allowed_hosts`` list automatically — no manual wiring needed.
232+
:param name: Server name forwarded to :func:`build_asgi_app`.
233+
:param advertise_all: Forwarded to :func:`build_asgi_app`.
234+
:param auto_emit_completion_webhooks: Forwarded to :func:`build_asgi_app`.
235+
:param follow_redirects: Forwarded to ``httpx.AsyncClient``. Default
236+
``True`` — FastMCP's streamable-HTTP endpoint can issue a 307
237+
redirect (``/mcp`` → ``/mcp/``) and callers shouldn't have to
238+
handle it manually.
239+
:param headers: Default headers attached to every request. Useful for
240+
auth tests: ``headers={"x-adcp-auth": "tok_..."}``. ``None`` →
241+
no default headers.
242+
:param factory_kwargs: Forwarded to
243+
:func:`create_adcp_server_from_platform` via :func:`build_asgi_app`
244+
(executor, registry, webhook_sender, etc.).
245+
"""
246+
try:
247+
from asgi_lifespan import LifespanManager
248+
except ImportError as exc:
249+
raise ImportError(
250+
"asgi-lifespan is required for build_test_client. "
251+
"Install it with: pip install 'adcp[dev]'"
252+
) from exc
253+
254+
import httpx as _httpx
255+
256+
hostname = urlparse(base_url).hostname or "localhost"
257+
# validate_capabilities_response_shape (called by create_adcp_server_from_platform)
258+
# uses asyncio.run(), which raises if a loop is already running. Run the sync
259+
# builder in a thread so it gets a clean loop.
260+
app = await asyncio.to_thread(
261+
build_asgi_app,
262+
platform,
263+
name=name,
264+
advertise_all=advertise_all,
265+
auto_emit_completion_webhooks=auto_emit_completion_webhooks,
266+
allowed_hosts=[hostname],
267+
**factory_kwargs,
268+
)
269+
async with LifespanManager(app):
270+
async with _httpx.AsyncClient(
271+
transport=_httpx.ASGITransport(app=app),
272+
base_url=base_url,
273+
headers=headers,
274+
follow_redirects=follow_redirects,
275+
) as client:
276+
yield client
277+
278+
279+
__all__ = ["build_asgi_app", "build_test_client", "make_request_context"]

tests/test_testing_decisioning.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from datetime import datetime, timezone
66

7+
import httpx
78
import pytest
89

910
from adcp.decisioning import (
@@ -13,7 +14,7 @@
1314
)
1415
from adcp.decisioning.context import RequestContext
1516
from adcp.decisioning.types import Account
16-
from adcp.testing import build_asgi_app, make_request_context
17+
from adcp.testing import build_asgi_app, build_test_client, make_request_context
1718

1819
# ---- make_request_context ----
1920

@@ -179,3 +180,92 @@ class _BrokenPlatform(DecisioningPlatform):
179180

180181
with pytest.raises(AdcpError):
181182
build_asgi_app(_BrokenPlatform())
183+
184+
185+
# ---- build_asgi_app: allowed_hosts ----
186+
187+
188+
def test_build_asgi_app_forwards_allowed_hosts() -> None:
189+
"""``allowed_hosts=`` reaches ``create_mcp_server`` — construction
190+
succeeds and the app is a callable."""
191+
platform = _SalesPlatformWithMethods()
192+
app = build_asgi_app(platform, allowed_hosts=["test"])
193+
assert callable(app)
194+
195+
196+
# ---- build_test_client ----
197+
198+
199+
@pytest.mark.asyncio
200+
async def test_build_test_client_yields_httpx_async_client() -> None:
201+
"""The context manager yields an ``httpx.AsyncClient`` instance."""
202+
platform = _SalesPlatformWithMethods()
203+
async with build_test_client(platform) as client:
204+
assert isinstance(client, httpx.AsyncClient)
205+
206+
207+
@pytest.mark.asyncio
208+
async def test_build_test_client_default_base_url() -> None:
209+
"""Default ``base_url="http://test"`` is used when not overridden."""
210+
platform = _SalesPlatformWithMethods()
211+
async with build_test_client(platform) as client:
212+
assert str(client.base_url) == "http://test"
213+
214+
215+
@pytest.mark.asyncio
216+
async def test_build_test_client_custom_base_url() -> None:
217+
"""``base_url`` override is forwarded to the client."""
218+
platform = _SalesPlatformWithMethods()
219+
async with build_test_client(platform, base_url="http://localhost") as client:
220+
assert str(client.base_url) == "http://localhost"
221+
222+
223+
@pytest.mark.asyncio
224+
async def test_build_test_client_can_make_request() -> None:
225+
"""The yielded client can actually reach the mounted MCP endpoint."""
226+
platform = _SalesPlatformWithMethods()
227+
async with build_test_client(platform) as client:
228+
resp = await client.post(
229+
"/mcp/",
230+
json={
231+
"jsonrpc": "2.0",
232+
"id": 0,
233+
"method": "initialize",
234+
"params": {
235+
"protocolVersion": "2025-06-18",
236+
"capabilities": {},
237+
"clientInfo": {"name": "test", "version": "1.0"},
238+
},
239+
},
240+
headers={
241+
"content-type": "application/json",
242+
"accept": "application/json, text/event-stream",
243+
},
244+
)
245+
assert resp.status_code == 200
246+
247+
248+
@pytest.mark.asyncio
249+
async def test_build_test_client_headers_kwarg() -> None:
250+
"""Default ``headers=`` are passed through to the client."""
251+
platform = _SalesPlatformWithMethods()
252+
async with build_test_client(
253+
platform, headers={"x-custom": "value"}
254+
) as client:
255+
assert isinstance(client, httpx.AsyncClient)
256+
257+
258+
@pytest.mark.asyncio
259+
async def test_build_test_client_follow_redirects_default_true() -> None:
260+
"""``follow_redirects`` defaults to ``True`` on the yielded client."""
261+
platform = _SalesPlatformWithMethods()
262+
async with build_test_client(platform) as client:
263+
assert client.follow_redirects is True
264+
265+
266+
@pytest.mark.asyncio
267+
async def test_build_test_client_follow_redirects_override() -> None:
268+
"""``follow_redirects=False`` is respected."""
269+
platform = _SalesPlatformWithMethods()
270+
async with build_test_client(platform, follow_redirects=False) as client:
271+
assert client.follow_redirects is False

0 commit comments

Comments
 (0)