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
44v3.12 → 4.x migration:
55
66* :func:`make_request_context` — build a
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
2126from __future__ import annotations
2227
28+ import asyncio
29+ from contextlib import asynccontextmanager
2330from typing import TYPE_CHECKING , Any
31+ from urllib .parse import urlparse
2432
2533from adcp .decisioning .context import RequestContext
2634from adcp .decisioning .types import Account
2735
2836if 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" ]
0 commit comments