Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/adcp/testing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

from __future__ import annotations

from adcp.testing.decisioning import build_asgi_app, make_request_context
from adcp.testing.test_helpers import (
CREATIVE_AGENT_CONFIG,
TEST_AGENT_A2A_CONFIG,
Expand All @@ -37,6 +38,8 @@
)

__all__ = [
"build_asgi_app",
"make_request_context",
"test_agent",
"test_agent_a2a",
"test_agent_no_auth",
Expand Down
175 changes: 175 additions & 0 deletions src/adcp/testing/decisioning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"""Test helpers for the v6 DecisioningPlatform framework.

Two adopter-facing helpers that close gaps surfaced by the salesagent
v3.12 → 4.x migration:

* :func:`make_request_context` — build a
:class:`adcp.decisioning.RequestContext` for unit tests with sane
defaults. The dataclass has a dozen fields with factory defaults; this
helper documents what tests should reach for so adopters don't guess
whether ``state`` / ``resolve`` / ``now`` factory defaults are safe.

* :func:`build_asgi_app` — build a Starlette ASGI app from a
:class:`adcp.decisioning.DecisioningPlatform` without binding a port.
Useful for in-process integration tests via ``httpx.AsyncClient``,
``starlette.testclient.TestClient``, or direct ASGI invocation. The
default ``auto_emit_completion_webhooks=False`` skips the F12 boot
gate that otherwise refuses to start a sales platform without a
webhook sender wired.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

from adcp.decisioning.context import RequestContext
from adcp.decisioning.types import Account

if TYPE_CHECKING:
from datetime import datetime

from adcp.decisioning import (
AuthInfo,
BuyerAgent,
DecisioningPlatform,
ResourceResolver,
StateReader,
)


def make_request_context(
*,
account: Account[Any] | str | None = None,
auth_info: AuthInfo | None = None,
auth_principal: str | None = None,
buyer_agent: BuyerAgent | None = None,
now: datetime | None = None,
state: StateReader | None = None,
resolve: ResourceResolver | None = None,
request_id: str | None = None,
tenant_id: str | None = None,
caller_identity: str | None = None,
metadata: dict[str, Any] | None = None,
) -> RequestContext[Any]:
"""Build a :class:`RequestContext` for unit tests.

All parameters are optional. The defaults are stable test contract:
an empty ``Account(id="test-account")``, no auth, framework-default
``state`` / ``resolve`` (the v6.0 stub readers), ``now`` set to wall
clock, and an empty metadata dict.

Pass ``account=`` as either an :class:`Account` instance (full
control) or a string (shorthand for ``Account(id=<string>)``) — the
common test case.

:param account: Resolved account. ``None`` → ``Account(id="test-account")``.
``str`` → ``Account(id=<string>)``.
:param auth_info: Verified principal info. ``None`` for unauthenticated
/ ``'derived'`` test fixtures.
:param auth_principal: Convenience field for tests that read
``ctx.auth_principal`` without constructing an ``AuthInfo``.
:param buyer_agent: Resolved commercial buyer agent. ``None`` for
tests not exercising the registry path.
:param now: Request timestamp. ``None`` → wall clock at construction.
:param state: Workflow-state reader. ``None`` → framework default
(v6.0 stub returning empty values).
:param resolve: Async resource resolver. ``None`` → framework default
(v6.0 stub raising ``NotImplementedError``).
:param request_id: Inherited from :class:`adcp.server.ToolContext`.
:param tenant_id: Inherited from :class:`adcp.server.ToolContext`.
:param caller_identity: Inherited from :class:`adcp.server.ToolContext`.
The framework's idempotency middleware reads this; tests that
exercise idempotency paths should set it explicitly.
:param metadata: Inherited from :class:`adcp.server.ToolContext`.
``None`` → empty dict.

:returns: A populated :class:`RequestContext[Any]`.
"""
resolved_account: Account[Any]
if account is None:
resolved_account = Account(id="test-account")
elif isinstance(account, str):
resolved_account = Account(id=account)
else:
resolved_account = account

kwargs: dict[str, Any] = {"account": resolved_account}
if auth_info is not None:
kwargs["auth_info"] = auth_info
if auth_principal is not None:
kwargs["auth_principal"] = auth_principal
if buyer_agent is not None:
kwargs["buyer_agent"] = buyer_agent
if now is not None:
kwargs["now"] = now
if state is not None:
kwargs["state"] = state
if resolve is not None:
kwargs["resolve"] = resolve
if request_id is not None:
kwargs["request_id"] = request_id
if tenant_id is not None:
kwargs["tenant_id"] = tenant_id
if caller_identity is not None:
kwargs["caller_identity"] = caller_identity
if metadata is not None:
kwargs["metadata"] = metadata

return RequestContext(**kwargs)


def build_asgi_app(
platform: DecisioningPlatform,
*,
name: str | None = None,
advertise_all: bool = False,
auto_emit_completion_webhooks: bool = False,
**factory_kwargs: Any,
) -> Any:
"""Build a Starlette ASGI app for in-process integration tests.

Composes :func:`adcp.decisioning.create_adcp_server_from_platform`
with :func:`adcp.server.create_mcp_server` and returns the
streamable-HTTP ASGI app — the same surface
:func:`adcp.decisioning.serve` would mount, minus the network bind.

Defaults differ from the production :func:`serve` wrapper in one
place: ``auto_emit_completion_webhooks`` is ``False`` so tests don't
need to wire a :class:`adcp.webhook_sender.WebhookSender` just to
instantiate a sales platform. Override to ``True`` if your test
explicitly exercises the F12 auto-emit path.

:param platform: The :class:`DecisioningPlatform` instance under
test.
:param name: Server name on the AdCP capabilities envelope. Defaults
to ``type(platform).__name__``.
:param advertise_all: Forwarded to
:func:`create_adcp_server_from_platform` and
:func:`create_mcp_server`. Default ``False`` (override-detection
filter on; matches :func:`serve`).
:param auto_emit_completion_webhooks: Forwarded to
:func:`create_adcp_server_from_platform`. Default ``False`` for
test ergonomics — production :func:`serve` defaults to ``True``.
:param factory_kwargs: Forwarded to
:func:`create_adcp_server_from_platform` (executor, registry,
webhook_sender, etc.).

:returns: A Starlette ASGI application. Usable with
``starlette.testclient.TestClient``,
``httpx.AsyncClient(app=app, ...)``, or any ASGI test harness.
"""
from adcp.decisioning.serve import create_adcp_server_from_platform
from adcp.server.serve import create_mcp_server

handler, _executor, _registry = create_adcp_server_from_platform(
platform,
advertise_all=advertise_all,
auto_emit_completion_webhooks=auto_emit_completion_webhooks,
**factory_kwargs,
)
server_name = name or type(platform).__name__
mcp = create_mcp_server(handler, name=server_name, advertise_all=advertise_all)
return mcp.streamable_http_app()


__all__ = ["build_asgi_app", "make_request_context"]
181 changes: 181 additions & 0 deletions tests/test_testing_decisioning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"""Unit tests for ``adcp.testing.decisioning`` helpers."""

from __future__ import annotations

from datetime import datetime, timezone

import pytest

from adcp.decisioning import (
DecisioningCapabilities,
DecisioningPlatform,
SingletonAccounts,
)
from adcp.decisioning.context import RequestContext
from adcp.decisioning.types import Account
from adcp.testing import build_asgi_app, make_request_context

# ---- make_request_context ----


def test_make_request_context_default_returns_test_account_id() -> None:
"""No-arg form yields a usable RequestContext with a stable
``test-account`` id — the documented default."""
ctx = make_request_context()
assert isinstance(ctx, RequestContext)
assert ctx.account.id == "test-account"
assert ctx.auth_info is None
assert ctx.auth_principal is None


def test_make_request_context_account_string_shorthand() -> None:
"""Passing a string for ``account`` builds ``Account(id=<string>)``
— common test case where adopters only need a stable id."""
ctx = make_request_context(account="acme")
assert ctx.account.id == "acme"


def test_make_request_context_account_instance_passes_through() -> None:
"""Full :class:`Account` instances pass through unchanged."""
acct = Account(id="explicit", metadata={"region": "us"})
ctx = make_request_context(account=acct)
assert ctx.account is acct
assert ctx.account.metadata == {"region": "us"}


def test_make_request_context_threads_optional_fields() -> None:
"""All optional fields land on the constructed context when
explicitly passed."""
fixed_now = datetime(2026, 1, 1, tzinfo=timezone.utc)
ctx = make_request_context(
account="t",
auth_principal="agent.example.com",
request_id="req-123",
tenant_id="tenant-a",
caller_identity="caller-key",
metadata={"trace_id": "abc"},
now=fixed_now,
)
assert ctx.auth_principal == "agent.example.com"
assert ctx.request_id == "req-123"
assert ctx.tenant_id == "tenant-a"
assert ctx.caller_identity == "caller-key"
assert ctx.metadata == {"trace_id": "abc"}
assert ctx.now == fixed_now


def test_make_request_context_state_resolve_default_to_framework_stubs() -> None:
"""Unset ``state`` and ``resolve`` use the framework's v6.0 default
factory readers — the same shape adopter handlers see in
production until v6.1 wires real backing stores."""
ctx = make_request_context()
# The framework defaults are non-None; we don't assert the type
# (it's framework-internal) but we verify they're populated so
# adopter calls into them don't raise AttributeError.
assert ctx.state is not None
assert ctx.resolve is not None


# ---- build_asgi_app ----


class _SalesPlatformWithMethods(DecisioningPlatform):
"""Minimal sales platform with the five SalesPlatform required
methods stubbed — mirrors the shape adopter test fixtures take."""

capabilities = DecisioningCapabilities(
specialisms=["sales-non-guaranteed"],
supported_billing=("operator",),
)
accounts = SingletonAccounts(account_id="hello")

def get_products(self, req, ctx):
return {"products": []}

def create_media_buy(self, req, ctx):
return {"media_buy_id": "x", "status": "active"}

def update_media_buy(self, mid, p, ctx):
return {"media_buy_id": mid, "status": "active"}

def sync_creatives(self, req, ctx):
return {"creatives": []}

def get_media_buy_delivery(self, req, ctx):
return {"media_buy_deliveries": []}

def get_media_buys(self, req, ctx):
return {"media_buys": []}

def list_creative_formats(self, req, ctx):
return {"creative_formats": []}

def list_creatives(self, req, ctx):
return {"creatives": []}

def provide_performance_feedback(self, req, ctx):
return {"acknowledged": True}


def test_build_asgi_app_returns_asgi_callable() -> None:
"""The returned object is a callable ASGI app — can be invoked
directly or handed to a test client."""
platform = _SalesPlatformWithMethods()
app = build_asgi_app(platform)
# ASGI apps are callables: app(scope, receive, send) is async.
assert callable(app)


def test_build_asgi_app_default_skips_webhook_gate() -> None:
"""A sales platform without webhook_sender wired would normally
trip the F12 boot-time gate. The helper's
``auto_emit_completion_webhooks=False`` default skips it so tests
can construct the app without wiring webhook infra."""
platform = _SalesPlatformWithMethods()
# Should not raise the F12 gate AdcpError.
app = build_asgi_app(platform)
assert app is not None


def test_build_asgi_app_accepts_name_kwarg() -> None:
"""Smoke: ``name=`` is a recognized kwarg and construction
succeeds. The wiring of the name to the MCP server is
framework-internal and verified by the underlying
``create_mcp_server`` suite."""
platform = _SalesPlatformWithMethods()
app = build_asgi_app(platform, name="custom-test-agent")
assert app is not None


def test_build_asgi_app_default_name_is_platform_class() -> None:
"""When ``name=`` is omitted, the platform class name is used —
matches :func:`adcp.decisioning.serve` behavior."""
platform = _SalesPlatformWithMethods()
# Construction should not raise; name resolution is internal.
app = build_asgi_app(platform)
assert app is not None


def test_build_asgi_app_forwards_advertise_all() -> None:
"""``advertise_all=True`` reaches both factory layers without
raising."""
platform = _SalesPlatformWithMethods()
app = build_asgi_app(platform, advertise_all=True)
assert app is not None


def test_build_asgi_app_rejects_invalid_platform() -> None:
"""Pass-through validation: a platform missing ``accounts`` fails
via :func:`validate_platform` with a structured AdcpError, the
same as production :func:`serve` would."""
from adcp.decisioning.types import AdcpError

class _BrokenPlatform(DecisioningPlatform):
capabilities = DecisioningCapabilities(
specialisms=["sales-non-guaranteed"],
supported_billing=("operator",),
)
# accounts intentionally not set — validate_platform should reject

with pytest.raises(AdcpError):
build_asgi_app(_BrokenPlatform())
Loading