-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathtest_hello_seller_integration.py
More file actions
343 lines (287 loc) · 12.4 KB
/
Copy pathtest_hello_seller_integration.py
File metadata and controls
343 lines (287 loc) · 12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
"""Vertical-slice integration tests for ``examples/hello_seller.py``.
Exercises the full v6.0 dispatch path — typed request → account
resolution via :class:`SingletonAccounts` → :class:`RequestContext`
hydration → platform method invocation → typed response — without
spinning up an MCP server. The MCP transport is exercised by the
adcp-client-python repo's own MCP test surface (separate concern);
here we focus on the decisioning framework wiring.
Two-example file plan per the dispatch design's D13:
* This file — sync vertical slice.
* :file:`tests/test_hello_seller_async_handoff_integration.py` —
hybrid + AdcpError round-trip.
"""
from __future__ import annotations
import sys
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any
import pytest
# examples/ is not a package — add to sys.path so the integration
# tests can import the module directly.
_EXAMPLES = str(Path(__file__).parent.parent / "examples")
if _EXAMPLES not in sys.path:
sys.path.insert(0, _EXAMPLES)
import hello_seller as _hello # noqa: E402
from adcp.decisioning import ( # noqa: E402
AdcpError,
InMemoryTaskRegistry,
)
from adcp.decisioning.handler import PlatformHandler # noqa: E402
from adcp.server.base import ToolContext # noqa: E402
@pytest.fixture
def executor():
pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="test-int-hello-")
yield pool
pool.shutdown(wait=True)
@pytest.fixture
def handler(executor: ThreadPoolExecutor) -> PlatformHandler:
return PlatformHandler(
_hello.HelloSeller(),
executor=executor,
registry=InMemoryTaskRegistry(),
)
@pytest.mark.asyncio
async def test_get_products_returns_one_product(handler: PlatformHandler) -> None:
"""End-to-end: typed Pydantic request → resolved account → platform
method → typed response. The hello_seller stubs return one product
with all spec-required fields populated."""
from adcp.types import GetProductsRequest
resp = await handler.get_products(
GetProductsRequest(buying_mode="brief", brief="anything"),
ToolContext(),
)
# Response is the raw dict the platform returned; framework-level
# serialization happens at the wire layer (out of scope here).
assert isinstance(resp, dict)
products = resp["products"]
assert len(products) == 1
p = products[0]
assert p["product_id"] == "display-rotation"
# Spec-required fields populated.
for required in (
"name",
"description",
"delivery_type",
"publisher_properties",
"format_ids",
"pricing_options",
"reporting_capabilities",
):
assert required in p, f"Product missing required field: {required}"
@pytest.mark.asyncio
async def test_create_media_buy_sync_path(handler: PlatformHandler) -> None:
"""Hello seller's create_media_buy is sync — accepts the request
and returns the success envelope. media_buy_id encodes the
resolved account.id (proves account resolution wired correctly)."""
from adcp.types import CreateMediaBuyRequest
req = CreateMediaBuyRequest(
account={"account_id": "buyer-1"},
brand={"domain": "buyer.example.com"},
idempotency_key="idem_int_test_aaaa1234",
start_time="2026-05-01T00:00:00Z",
end_time="2026-05-31T23:59:59Z",
packages=[
{
"product_id": "display-rotation",
"pricing_option_id": "po-cpm-default",
"budget": 1000,
},
],
)
resp = await handler.create_media_buy(req, ToolContext())
assert isinstance(resp, dict)
# SingletonAccounts(account_id="hello") + no auth_info → resolved
# to "hello:anonymous" per per-principal scoping. The hello seller
# encodes the resolved account.id into media_buy_id.
assert resp["media_buy_id"].startswith("mb_hello:anonymous_"), resp
assert resp["status"] == "active"
assert len(resp["packages"]) == 1
@pytest.mark.asyncio
async def test_create_media_buy_rejects_empty_packages(
handler: PlatformHandler,
) -> None:
"""AdcpError raise-and-project — empty packages tripping the
platform's own correctable rejection. The framework propagates
AdcpError verbatim (not wrapped to INTERNAL_ERROR) so the wire
response carries the structured envelope.
The wire schema also enforces ``packages.minItems: 1``, so
real-world buyers can't reach this branch — but adopters
relying on extra business validation (e.g., budget floors,
blocked products) hit the same code path. We construct via
``model_construct`` to bypass Pydantic's pre-validation and
exercise the platform's defensive check."""
from adcp.types import CreateMediaBuyRequest
req = CreateMediaBuyRequest.model_construct(
account={"account_id": "buyer-1"},
brand={"domain": "buyer.example.com"},
idempotency_key="idem_int_test_bbbb1234",
start_time="2026-05-01T00:00:00Z",
end_time="2026-05-31T23:59:59Z",
packages=[],
)
with pytest.raises(AdcpError) as exc_info:
await handler.create_media_buy(req, ToolContext())
assert exc_info.value.code == "INVALID_REQUEST"
assert exc_info.value.recovery == "correctable"
assert exc_info.value.field == "packages"
@pytest.mark.asyncio
async def test_get_media_buy_delivery_returns_zeros(
handler: PlatformHandler,
) -> None:
"""Stub delivery snapshot — proves the dispatch path works for a
second sync read tool."""
from adcp.types import GetMediaBuyDeliveryRequest
req = GetMediaBuyDeliveryRequest(
account={"account_id": "buyer-1"},
media_buy_ids=["mb_x"],
)
resp = await handler.get_media_buy_delivery(req, ToolContext())
assert isinstance(resp, dict)
# Wire field is ``media_buy_deliveries`` per
# ``schemas/cache/media-buy/get-media-buy-delivery-response.json``.
# Pre-fix the example used the wrong key (``deliveries``); pin the
# spec field name here so future drift fails the test.
assert len(resp["media_buy_deliveries"]) == 1
assert resp["media_buy_deliveries"][0]["totals"]["impressions"] == 0
@pytest.mark.asyncio
async def test_account_resolution_threads_through(
handler: PlatformHandler,
) -> None:
"""The framework's _build_request_context wires
``ctx.account.id`` into the platform method via SingletonAccounts.
Different auth_info principals (set via ctx.metadata) yield
different account ids."""
from adcp.decisioning import AuthInfo
from adcp.types import GetProductsRequest
seen_ids: list[str] = []
# Inject an AuthInfo via ctx.metadata['adcp.auth_info'] and
# observe via the platform method body.
class _SpyHelloSeller(_hello.HelloSeller):
def get_products(self, req, ctx):
seen_ids.append(ctx.account.id)
return super().get_products(req, ctx)
spy = PlatformHandler(
_SpyHelloSeller(),
executor=handler._executor, # share the fixture's executor
registry=InMemoryTaskRegistry(),
)
# Two different principals → two different per-principal account ids.
for principal in ("buyer-a", "buyer-b"):
ctx = ToolContext(
metadata={
"adcp.auth_info": AuthInfo(
kind="signed_request",
principal=principal,
),
},
)
await spy.get_products(
GetProductsRequest(buying_mode="brief", brief="x"),
ctx,
)
assert seen_ids == ["hello:buyer-a", "hello:buyer-b"]
@pytest.mark.asyncio
async def test_caller_identity_uses_composite_key(
handler: PlatformHandler,
) -> None:
"""The framework sets ``ctx.caller_identity`` to the composite
cache scope key (D9 round-3 — module + qualname + account.id).
Idempotency middleware reads this; different stores can't collide."""
from adcp.types import GetProductsRequest
seen_caller: list[Any] = []
class _SpySeller(_hello.HelloSeller):
def get_products(self, req, ctx):
seen_caller.append(ctx.caller_identity)
return super().get_products(req, ctx)
spy_handler = PlatformHandler(
_SpySeller(),
executor=handler._executor,
registry=InMemoryTaskRegistry(),
)
await spy_handler.get_products(
GetProductsRequest(buying_mode="brief", brief="x"),
ToolContext(),
)
assert seen_caller[0] == ("adcp.decisioning.accounts.SingletonAccounts:hello:anonymous")
@pytest.mark.asyncio
async def test_advertised_tools_class_attribute_set(
handler: PlatformHandler,
) -> None:
"""The codegen-target ``advertised_tools`` ClassVar is populated
at class definition time on PlatformHandler — adopters get a
focused tools/list filter without manual registration (after
prep PR #318 wires __init_subclass__ auto-registration)."""
assert "get_products" in PlatformHandler.advertised_tools
assert "create_media_buy" in PlatformHandler.advertised_tools
assert "update_media_buy" in PlatformHandler.advertised_tools
assert "sync_creatives" in PlatformHandler.advertised_tools
assert "get_media_buy_delivery" in PlatformHandler.advertised_tools
@pytest.mark.asyncio
async def test_get_media_buys_returns_spec_valid_envelope(handler: PlatformHandler) -> None:
"""Stub returns a wire shape that satisfies ``GetMediaBuysResponse``."""
from adcp.types import GetMediaBuysRequest, GetMediaBuysResponse
req = GetMediaBuysRequest(account={"account_id": "buyer-1"})
resp = await handler.get_media_buys(req, ToolContext())
# Validate against the canonical Pydantic model — catches drift
# between stub and spec, not just dict-key presence.
GetMediaBuysResponse.model_validate(resp)
assert resp["media_buys"] == []
@pytest.mark.asyncio
async def test_list_creative_formats_returns_spec_valid_envelope(
handler: PlatformHandler,
) -> None:
"""Stub returns a wire shape that satisfies ``ListCreativeFormatsResponse``."""
from adcp.types import ListCreativeFormatsRequest, ListCreativeFormatsResponse
req = ListCreativeFormatsRequest()
resp = await handler.list_creative_formats(req, ToolContext())
ListCreativeFormatsResponse.model_validate(resp)
assert resp["formats"] == []
@pytest.mark.asyncio
async def test_list_creatives_returns_spec_valid_envelope(handler: PlatformHandler) -> None:
"""Stub returns a wire shape that satisfies ``ListCreativesResponse``,
including the spec-required ``query_summary`` and ``pagination``
envelopes (a buyer hitting the example otherwise gets a non-conformant
response)."""
from adcp.types import ListCreativesRequest, ListCreativesResponse
req = ListCreativesRequest(account={"account_id": "buyer-1"})
resp = await handler.list_creatives(req, ToolContext())
ListCreativesResponse.model_validate(resp)
assert resp["creatives"] == []
@pytest.mark.asyncio
async def test_provide_performance_feedback_acknowledges(handler: PlatformHandler) -> None:
"""Smoke: stub returns success acknowledgment for provide_performance_feedback."""
from adcp.types import ProvidePerformanceFeedbackRequest
req = ProvidePerformanceFeedbackRequest(
account={"account_id": "buyer-1"},
media_buy_id="mb_test",
idempotency_key="perf-feedback-test-key-001",
measurement_period={"start": "2026-05-01T00:00:00Z", "end": "2026-05-31T23:59:59Z"},
performance_index=1.0,
feedback=[],
)
resp = await handler.provide_performance_feedback(req, ToolContext())
assert isinstance(resp, dict)
assert resp["success"] is True
@pytest.mark.asyncio
async def test_validate_platform_no_soft_warns_on_hello_seller() -> None:
"""HelloSeller passes validate_platform without any soft-warn for the
four RECOMMENDED_METHODS_PER_SPECIALISM methods."""
import warnings
from adcp.decisioning.dispatch import validate_platform
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
validate_platform(_hello.HelloSeller())
soft_warns = [
x
for x in w
if any(
m in str(x.message)
for m in [
"get_media_buys",
"list_creative_formats",
"list_creatives",
"provide_performance_feedback",
]
)
]
assert soft_warns == [], f"Unexpected soft-warns: {[str(x.message) for x in soft_warns]}"