Skip to content
Closed
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
40 changes: 31 additions & 9 deletions examples/multi_platform_seller/src/mock_guaranteed.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
MediaBuy,
SupportedProtocol,
)
from adcp.server.responses import list_creatives_response

# ---------------------------------------------------------------------------
# In-memory inventory + buy state
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand All @@ -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,
Expand Down
41 changes: 32 additions & 9 deletions examples/multi_platform_seller/src/mock_non_guaranteed.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
MediaBuy,
SupportedProtocol,
)
from adcp.server.responses import list_creatives_response

# ---------------------------------------------------------------------------
# In-memory model
Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down
153 changes: 153 additions & 0 deletions tests/test_multi_platform_seller.py
Original file line number Diff line number Diff line change
@@ -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
Loading