From 07efd457f431a1be36c429a12790c9454f1d4030 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 3 May 2026 21:09:22 -0400 Subject: [PATCH] fix(examples/multi_platform_seller): list_creatives populates query_summary (closes #510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both MockGuaranteedPlatform and MockNonGuaranteedPlatform were missing list_creatives entirely. The storyboard step ``creative_fate_after_cancellation/list_creatives_before_cancel`` fails for both tenants on the post-#508 artifact because the wire contract requires query_summary on every list_creatives response. Fix: each mock now persists creatives at sync_creatives time into an in-memory library keyed by creative_id, and list_creatives returns them with the required ``query_summary`` (total_matching, returned) and ``pagination`` (has_more=False, total_count) blocks. Per-item Creative shape projects to the schemas/3.0.6/creative/list-creatives-response.json contract: {creative_id, name, format_id, status: "approved", created_date, updated_date}. Auto-approval mirrors the existing sync_creatives policy on both mocks. Pagination is intentionally trivial (returns the full library) — the storyboard catalog is small and modeling cursor-based paging adds noise without illustrating new platform-shape concerns. Validated: ListCreativesResponse.model_validate accepts the output on both mocks; full pytest suite stays green (3760 passed). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/mock_guaranteed.py | 59 +++++++++++++++++++ .../src/mock_non_guaranteed.py | 59 +++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/examples/multi_platform_seller/src/mock_guaranteed.py b/examples/multi_platform_seller/src/mock_guaranteed.py index 49b34c906..bf8064f0f 100644 --- a/examples/multi_platform_seller/src/mock_guaranteed.py +++ b/examples/multi_platform_seller/src/mock_guaranteed.py @@ -155,6 +155,10 @@ def __init__( p.product_id: p.capacity_impressions for p in self._catalog.values() } self._buys: dict[str, _MediaBuy] = {} + # Creative library — populated by sync_creatives, read by + # list_creatives. Wire-shape dicts keyed by creative_id so + # list_creatives can return them without re-projecting. + self._creatives: dict[str, dict[str, Any]] = {} # The router's AccountStore is what runtime dispatch threads # ctx.account through; this attribute exists only to satisfy @@ -380,6 +384,9 @@ def sync_creatives( ) buy.status = "pending_start" buy.creatives_attached += len(creatives) + for i, c in enumerate(creatives): + stored = _project_creative_to_wire(c, i) + self._creatives[stored["creative_id"]] = stored return { "creatives": [ @@ -392,6 +399,27 @@ def sync_creatives( ], } + def list_creatives( + self, + req: Any, + ctx: RequestContext[Any], + ) -> dict[str, Any]: + """Return the seller's view of buyer-uploaded creatives. + + Returns the full library; pagination is not modeled (the mock + runs against a small fixed-size storyboard catalog). The + ``query_summary`` block is required by + ``schemas/3.0.6/creative/list-creatives-response.json``. + """ + with self._lock: + creatives = list(self._creatives.values()) + total = len(creatives) + return { + "query_summary": {"total_matching": total, "returned": total}, + "pagination": {"has_more": False, "total_count": total}, + "creatives": creatives, + } + def get_media_buys( self, req: Any, @@ -626,6 +654,37 @@ def _check_measurement_terms(terms: Any) -> None: ) +def _project_creative_to_wire(creative: Any, idx: int) -> dict[str, Any]: + """Project a sync_creatives input item to the + ``schemas/3.0.6/creative/list-creatives-response.json`` Creative + shape. Auto-approval mirrors the sync_creatives policy: every + submitted creative comes back as ``approved``.""" + creative_id = _creative_id(creative, idx) + name = _attr(creative, "name", None) or creative_id + raw_format = _attr(creative, "format_id", None) + if isinstance(raw_format, dict): + format_id = raw_format + elif raw_format is not None: + format_id = { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": str(raw_format), + } + else: + format_id = { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_300x250", + } + now_iso = datetime.now(timezone.utc).isoformat() + return { + "creative_id": creative_id, + "name": str(name), + "format_id": format_id, + "status": "approved", + "created_date": now_iso, + "updated_date": now_iso, + } + + def _project_media_buy_to_wire(buy: _MediaBuy) -> dict[str, Any]: """Project an in-memory ``_MediaBuy`` to the ``schemas/3.0.6/media-buy/get-media-buys-response.json`` MediaBuy diff --git a/examples/multi_platform_seller/src/mock_non_guaranteed.py b/examples/multi_platform_seller/src/mock_non_guaranteed.py index a35d6c227..73dae7022 100644 --- a/examples/multi_platform_seller/src/mock_non_guaranteed.py +++ b/examples/multi_platform_seller/src/mock_non_guaranteed.py @@ -141,6 +141,10 @@ def __init__( # storyboard's delivery assertions stay deterministic. self._clearing_multiplier = clearing_multiplier self._buys: dict[str, _MediaBuy] = {} + # Creative library — populated by sync_creatives, read by + # list_creatives. Wire-shape dicts keyed by creative_id so + # list_creatives can return them without re-projecting. + self._creatives: dict[str, dict[str, Any]] = {} accounts: Any = None # type: ignore[assignment] @@ -349,6 +353,9 @@ def sync_creatives( assert_media_buy_transition(buy.status, "active", media_buy_id=buy.media_buy_id) buy.status = "active" buy.creatives_attached += len(creatives) + for i, c in enumerate(creatives): + stored = _project_creative_to_wire(c, i) + self._creatives[stored["creative_id"]] = stored return { "creatives": [ @@ -361,6 +368,27 @@ def sync_creatives( ], } + def list_creatives( + self, + req: Any, + ctx: RequestContext[Any], + ) -> dict[str, Any]: + """Return the seller's view of buyer-uploaded creatives. + + Returns the full library; pagination is not modeled (the mock + runs against a small fixed-size storyboard catalog). The + ``query_summary`` block is required by + ``schemas/3.0.6/creative/list-creatives-response.json``. + """ + with self._lock: + creatives = list(self._creatives.values()) + total = len(creatives) + return { + "query_summary": {"total_matching": total, "returned": total}, + "pagination": {"has_more": False, "total_count": total}, + "creatives": creatives, + } + def get_media_buys( self, req: Any, @@ -597,6 +625,37 @@ def _check_measurement_terms(terms: Any) -> None: ) +def _project_creative_to_wire(creative: Any, idx: int) -> dict[str, Any]: + """Project a sync_creatives input item to the + ``schemas/3.0.6/creative/list-creatives-response.json`` Creative + shape. Auto-approval mirrors the sync_creatives policy: every + submitted creative comes back as ``approved``.""" + creative_id = _creative_id(creative, idx) + name = _attr(creative, "name", None) or creative_id + raw_format = _attr(creative, "format_id", None) + if isinstance(raw_format, dict): + format_id = raw_format + elif raw_format is not None: + format_id = { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": str(raw_format), + } + else: + format_id = { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_300x250", + } + now_iso = datetime.now(timezone.utc).isoformat() + return { + "creative_id": creative_id, + "name": str(name), + "format_id": format_id, + "status": "approved", + "created_date": now_iso, + "updated_date": now_iso, + } + + def _project_media_buy_to_wire(buy: _MediaBuy) -> dict[str, Any]: """Project an in-memory ``_MediaBuy`` to the ``schemas/3.0.6/media-buy/get-media-buys-response.json`` shape."""