diff --git a/examples/multi_platform_seller/src/mock_guaranteed.py b/examples/multi_platform_seller/src/mock_guaranteed.py index 49b34c906..421dd5a74 100644 --- a/examples/multi_platform_seller/src/mock_guaranteed.py +++ b/examples/multi_platform_seller/src/mock_guaranteed.py @@ -34,6 +34,7 @@ MediaBuy, SupportedProtocol, ) +from adcp.server.responses import list_creatives_response # --------------------------------------------------------------------------- # In-memory inventory + buy state @@ -155,6 +156,8 @@ def __init__( p.product_id: p.capacity_impressions for p in self._catalog.values() } self._buys: dict[str, _MediaBuy] = {} + # Keyed by creative_id so idempotency-replay re-syncs upsert rather than duplicate. + self._synced_creatives: dict[str, dict[str, Any]] = {} # The router's AccountStore is what runtime dispatch threads # ctx.account through; this attribute exists only to satisfy @@ -371,6 +374,7 @@ def sync_creatives( creatives) and ``status`` is the optional review-state hint. """ creatives = _read_creatives(req) + response_items: list[dict[str, Any]] = [] with self._lock: for buy in self._buys.values(): @@ -380,17 +384,35 @@ def sync_creatives( ) buy.status = "pending_start" buy.creatives_attached += len(creatives) - - return { - "creatives": [ - { - "creative_id": _creative_id(c, i), - "action": "created", + for i, c in enumerate(creatives): + cid = _creative_id(c, i) + # Upsert by creative_id so idempotency replays don't accumulate duplicates. + self._synced_creatives[cid] = { + "creative_id": cid, + "name": _attr(c, "name", f"creative_{i}"), + "format_id": _attr(c, "format_id", { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_300x250", + }), "status": "approved", } - for i, c in enumerate(creatives) - ], - } + response_items.append({"creative_id": cid, "action": "created", "status": "approved"}) + + return {"creatives": response_items} + + def list_creatives( + self, + req: Any, + ctx: RequestContext[Any], + ) -> dict[str, Any]: + """Return all creatives synced to this platform. + + Uses :func:`list_creatives_response` which auto-fills + ``created_date``/``updated_date`` for dict items that omit them. + """ + with self._lock: + creatives = list(self._synced_creatives.values()) + return list_creatives_response(creatives, sandbox=False) def get_media_buys( self, diff --git a/examples/multi_platform_seller/src/mock_non_guaranteed.py b/examples/multi_platform_seller/src/mock_non_guaranteed.py index a35d6c227..bd55b25fb 100644 --- a/examples/multi_platform_seller/src/mock_non_guaranteed.py +++ b/examples/multi_platform_seller/src/mock_non_guaranteed.py @@ -36,6 +36,7 @@ MediaBuy, SupportedProtocol, ) +from adcp.server.responses import list_creatives_response # --------------------------------------------------------------------------- # In-memory model @@ -141,6 +142,8 @@ def __init__( # storyboard's delivery assertions stay deterministic. self._clearing_multiplier = clearing_multiplier self._buys: dict[str, _MediaBuy] = {} + # Keyed by creative_id so idempotency-replay re-syncs upsert rather than duplicate. + self._synced_creatives: dict[str, dict[str, Any]] = {} accounts: Any = None # type: ignore[assignment] @@ -332,6 +335,8 @@ def sync_creatives( per-item shape is ``{creative_id, action, status?}``. """ creatives = _read_creatives(req) + response_items: list[dict[str, Any]] = [] + with self._lock: for buy in self._buys.values(): if buy.status == "pending_creatives" and creatives: @@ -349,17 +354,35 @@ 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) - - return { - "creatives": [ - { - "creative_id": _creative_id(c, i), - "action": "created", + for i, c in enumerate(creatives): + cid = _creative_id(c, i) + # Upsert by creative_id so idempotency replays don't accumulate duplicates. + self._synced_creatives[cid] = { + "creative_id": cid, + "name": _attr(c, "name", f"creative_{i}"), + "format_id": _attr(c, "format_id", { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_300x250", + }), "status": "approved", } - for i, c in enumerate(creatives) - ], - } + response_items.append({"creative_id": cid, "action": "created", "status": "approved"}) + + return {"creatives": response_items} + + def list_creatives( + self, + req: Any, + ctx: RequestContext[Any], + ) -> dict[str, Any]: + """Return all creatives synced to this platform. + + Uses :func:`list_creatives_response` which auto-fills + ``created_date``/``updated_date`` for dict items that omit them. + """ + with self._lock: + creatives = list(self._synced_creatives.values()) + return list_creatives_response(creatives, sandbox=False) def get_media_buys( self, diff --git a/tests/test_multi_platform_seller.py b/tests/test_multi_platform_seller.py new file mode 100644 index 000000000..ab7f04f0d --- /dev/null +++ b/tests/test_multi_platform_seller.py @@ -0,0 +1,153 @@ +"""Unit tests for examples/multi_platform_seller/src/ mock platforms. + +Covers: + - list_creatives returns query_summary (schema required field) + - list_creatives returns creatives synced via sync_creatives + - list_creatives on a fresh platform returns an empty but valid response + - re-syncing the same creative_id upserts (no duplicates) +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +# examples/ is not a package — add the multi_platform_seller src to sys.path. +_MULTI_PLATFORM_SRC = str( + Path(__file__).parent.parent / "examples" / "multi_platform_seller" / "src" +) +if _MULTI_PLATFORM_SRC not in sys.path: + sys.path.insert(0, _MULTI_PLATFORM_SRC) + +from mock_guaranteed import MockGuaranteedPlatform # noqa: E402 +from mock_non_guaranteed import MockNonGuaranteedPlatform # noqa: E402 + + +def _ctx() -> Any: + return MagicMock() + + +def _sync_req(creatives: list[dict[str, Any]]) -> Any: + req = MagicMock() + req.creatives = creatives + return req + + +def _list_req() -> Any: + return MagicMock() + + +def _creative(idx: int = 0) -> dict[str, Any]: + return { + "creative_id": f"cr_{idx}", + "name": f"Banner {idx}", + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_300x250", + }, + "assets": [], + } + + +class TestMockGuaranteedPlatformListCreatives: + def test_empty_list_has_query_summary(self) -> None: + platform = MockGuaranteedPlatform() + result = platform.list_creatives(_list_req(), _ctx()) + assert "query_summary" in result + assert result["query_summary"]["total_matching"] == 0 + assert result["query_summary"]["returned"] == 0 + + def test_empty_list_has_pagination(self) -> None: + platform = MockGuaranteedPlatform() + result = platform.list_creatives(_list_req(), _ctx()) + assert "pagination" in result + assert result["pagination"]["has_more"] is False + + def test_synced_creatives_appear_in_list(self) -> None: + platform = MockGuaranteedPlatform() + platform.sync_creatives(_sync_req([_creative(0), _creative(1)]), _ctx()) + result = platform.list_creatives(_list_req(), _ctx()) + assert result["query_summary"]["total_matching"] == 2 + assert result["query_summary"]["returned"] == 2 + ids = {c["creative_id"] for c in result["creatives"]} + assert "cr_0" in ids + assert "cr_1" in ids + + def test_creative_items_have_required_fields(self) -> None: + platform = MockGuaranteedPlatform() + platform.sync_creatives(_sync_req([_creative(0)]), _ctx()) + result = platform.list_creatives(_list_req(), _ctx()) + item = result["creatives"][0] + assert "creative_id" in item + assert "name" in item + assert "format_id" in item + assert "status" in item + assert "created_date" in item + assert "updated_date" in item + + def test_name_fallback_when_omitted(self) -> None: + platform = MockGuaranteedPlatform() + bare = {"creative_id": "cr_bare", "assets": []} + platform.sync_creatives(_sync_req([bare]), _ctx()) + result = platform.list_creatives(_list_req(), _ctx()) + assert result["creatives"][0]["name"] == "creative_0" + + def test_resync_same_id_does_not_duplicate(self) -> None: + platform = MockGuaranteedPlatform() + platform.sync_creatives(_sync_req([_creative(0)]), _ctx()) + platform.sync_creatives(_sync_req([_creative(0)]), _ctx()) + result = platform.list_creatives(_list_req(), _ctx()) + assert result["query_summary"]["total_matching"] == 1 + + def test_sandbox_false_in_response(self) -> None: + platform = MockGuaranteedPlatform() + result = platform.list_creatives(_list_req(), _ctx()) + assert result.get("sandbox") is False + + +class TestMockNonGuaranteedPlatformListCreatives: + def test_empty_list_has_query_summary(self) -> None: + platform = MockNonGuaranteedPlatform() + result = platform.list_creatives(_list_req(), _ctx()) + assert "query_summary" in result + assert result["query_summary"]["total_matching"] == 0 + assert result["query_summary"]["returned"] == 0 + + def test_empty_list_has_pagination(self) -> None: + platform = MockNonGuaranteedPlatform() + result = platform.list_creatives(_list_req(), _ctx()) + assert "pagination" in result + assert result["pagination"]["has_more"] is False + + def test_synced_creatives_appear_in_list(self) -> None: + platform = MockNonGuaranteedPlatform() + platform.sync_creatives(_sync_req([_creative(0)]), _ctx()) + result = platform.list_creatives(_list_req(), _ctx()) + assert result["query_summary"]["total_matching"] == 1 + assert result["creatives"][0]["creative_id"] == "cr_0" + + def test_creative_items_have_required_fields(self) -> None: + platform = MockNonGuaranteedPlatform() + platform.sync_creatives(_sync_req([_creative(0)]), _ctx()) + result = platform.list_creatives(_list_req(), _ctx()) + item = result["creatives"][0] + assert "creative_id" in item + assert "name" in item + assert "format_id" in item + assert "status" in item + assert "created_date" in item + assert "updated_date" in item + + def test_resync_same_id_does_not_duplicate(self) -> None: + platform = MockNonGuaranteedPlatform() + platform.sync_creatives(_sync_req([_creative(0)]), _ctx()) + platform.sync_creatives(_sync_req([_creative(0)]), _ctx()) + result = platform.list_creatives(_list_req(), _ctx()) + assert result["query_summary"]["total_matching"] == 1 + + def test_sandbox_false_in_response(self) -> None: + platform = MockNonGuaranteedPlatform() + result = platform.list_creatives(_list_req(), _ctx()) + assert result.get("sandbox") is False