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
64 changes: 55 additions & 9 deletions examples/seller_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,14 +262,32 @@ async def create_media_buy(self, params: dict[str, Any], context: Any = None) ->
field="product_id",
suggestion="Use get_products to discover available products",
)
packages.append(
{
"package_id": f"pkg-{uuid.uuid4().hex[:8]}",
"product_id": product_id,
"pricing_option_id": pkg.get("pricing_option_id"),
"budget": pkg.get("budget"),
}
)
# Inspect per-package measurement_terms for aggressive viewability.
# The storyboard's create_media_buy_aggressive_terms step sends
# measurement_terms.viewability_threshold > 80 per package and expects
# TERMS_REJECTED. viewability_threshold is a storyboard demo convention
# (additionalProperties on measurement-terms.json); real sellers should
# map their own rejection criteria here.
pkg_terms = pkg.get("measurement_terms") or {}
if (pkg_terms.get("viewability_threshold") or 0) > 80:
return adcp_error(
"TERMS_REJECTED",
"Viewability threshold exceeds maximum supported value of 80%",
field="measurement_terms.viewability_threshold",
recovery="correctable",
)

pkg_obj: dict[str, Any] = {
"package_id": f"pkg-{uuid.uuid4().hex[:8]}",
"product_id": product_id,
"pricing_option_id": pkg.get("pricing_option_id"),
"budget": pkg.get("budget"),
}
# Persist overlay and creative fields so get_media_buys can round-trip them.
for field in ("targeting_overlay", "creative_assignments", "creatives"):
if pkg.get(field) is not None:
pkg_obj[field] = pkg[field]
packages.append(pkg_obj)

has_creatives = any(
pkg.get("creative_assignments") or pkg.get("creatives") for pkg in params["packages"]
Expand Down Expand Up @@ -320,7 +338,8 @@ async def update_media_buy(self, params: dict[str, Any], context: Any = None) ->
return adcp_error("CONFLICT", "Revision mismatch - refetch and retry")

if params.get("packages"):
existing_pkg_ids = {p["package_id"] for p in mb.get("packages", [])}
existing_pkgs = {p["package_id"]: p for p in mb.get("packages", [])}
existing_pkg_ids = set(existing_pkgs.keys())
for pkg_update in params["packages"]:
pkg_id = pkg_update.get("package_id")
if pkg_id and pkg_id not in existing_pkg_ids:
Expand All @@ -329,6 +348,13 @@ async def update_media_buy(self, params: dict[str, Any], context: Any = None) ->
f"Package '{pkg_id}' not found in media buy {mb_id}",
field="package_id",
)
# Apply targeting and creative field deltas to persisted package state
# so get_media_buys can round-trip property_list and overlay updates.
if pkg_id and pkg_id in existing_pkgs:
persisted = existing_pkgs[pkg_id]
for field in ("targeting_overlay", "creative_assignments", "creatives"):
if field in pkg_update:
persisted[field] = pkg_update[field]

status = mb["status"]
if status == "pending_creatives" and params.get("packages"):
Expand Down Expand Up @@ -615,6 +641,26 @@ async def seed_product(
data = dict(fixture or {})
pid = product_id or data.get("product_id") or f"seeded-{uuid.uuid4().hex[:8]}"
data["product_id"] = pid
# Ensure schema-required fields are present so downstream validation passes
# even when the runner sends a minimal fixture with only product_id.
data.setdefault("name", pid)
data.setdefault("description", "")
data.setdefault("delivery_type", "non_guaranteed")
data.setdefault("publisher_properties", [])
data.setdefault("format_ids", [])
data.setdefault("pricing_options", [])
data.setdefault(
"reporting_capabilities",
{
"available_metrics": [],
"available_reporting_frequencies": [],
"date_range_support": "date_range",
"supports_webhooks": False,
"expected_delay_minutes": 0,
"timezone": "UTC",
},
)
data.setdefault("delivery_measurement", {"provider": "internal"})
for i, p in enumerate(PRODUCTS):
if p.get("product_id") == pid:
PRODUCTS[i] = data
Expand Down
288 changes: 288 additions & 0 deletions tests/test_seller_agent_storyboard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
"""Tests for the 5 storyboard fixture-gap fixes in examples/seller_agent.py.

Covers:
- seed_product populates schema-required field defaults for minimal fixtures
- create_media_buy returns TERMS_REJECTED for aggressive measurement_terms
- create_media_buy round-trips targeting_overlay / property_list through storage
- get_media_buys returns persisted targeting_overlay
- update_media_buy applies targeting_overlay and property_list deltas
"""

from __future__ import annotations

import sys
from copy import deepcopy
from pathlib import Path
from typing import Any

import pytest

# examples/ is not a package; add it to the path once at import time.
_EXAMPLES = str(Path(__file__).parent.parent / "examples")
if _EXAMPLES not in sys.path:
sys.path.insert(0, _EXAMPLES)

import seller_agent as _sa # noqa: E402 (path manipulation above is intentional)

# ---------------------------------------------------------------------------
# Fixture: reset module-level globals before every test so tests are isolated.
# ---------------------------------------------------------------------------

# Snapshot taken at import time — before any test mutates the module state.
_INITIAL_PRODUCTS: list[dict[str, Any]] = deepcopy(_sa.PRODUCTS)


@pytest.fixture(autouse=True)
def _reset_seller_state() -> Any:
"""Reset all mutable module globals to their initial state before each test."""
_sa.PRODUCTS.clear()
_sa.PRODUCTS.extend(deepcopy(_INITIAL_PRODUCTS))
_sa.media_buys.clear()
_sa.creatives.clear()
_sa.accounts.clear()
_sa.proposals.clear()
_sa.plans.clear()
_sa.seeded_creative_formats.clear()
_sa.pending_directives.clear()
_sa.pending_task_completions.clear()
yield


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _seller() -> _sa.DemoSeller:
return _sa.DemoSeller()


def _store() -> _sa.DemoStore:
return _sa.DemoStore()


# ---------------------------------------------------------------------------
# seed_product — required field defaults (failures 1 & 2)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_seed_product_minimal_fixture_adds_required_fields() -> None:
"""seed_product fills in required fields when the fixture omits them."""
store = _store()
result = await store.seed_product(product_id="outdoor_display_q2")
assert result["product_id"] == "outdoor_display_q2"

seeded = next(p for p in _sa.PRODUCTS if p["product_id"] == "outdoor_display_q2")
assert "name" in seeded
assert isinstance(seeded["format_ids"], list)
assert isinstance(seeded["pricing_options"], list)
assert "reporting_capabilities" in seeded
assert "delivery_measurement" in seeded


@pytest.mark.asyncio
async def test_seed_product_fixture_fields_not_overwritten() -> None:
"""Fixture values must not be overwritten by the setdefault calls."""
store = _store()
fixture = {
"name": "Q2 Outdoor Custom",
"delivery_type": "guaranteed",
"format_ids": [{"agent_url": "http://x", "id": "custom_format"}],
"pricing_options": [{"pricing_option_id": "po-1", "pricing_model": "cpm"}],
"reporting_capabilities": {"available_metrics": ["impressions"]},
"delivery_measurement": {"provider": "moat"},
"publisher_properties": [{"publisher_domain": "example.com"}],
}
await store.seed_product(fixture=fixture, product_id="outdoor_display_q2")

seeded = next(p for p in _sa.PRODUCTS if p["product_id"] == "outdoor_display_q2")
assert seeded["name"] == "Q2 Outdoor Custom"
assert seeded["delivery_type"] == "guaranteed"
assert seeded["format_ids"] == [{"agent_url": "http://x", "id": "custom_format"}]
assert seeded["delivery_measurement"] == {"provider": "moat"}


@pytest.mark.asyncio
async def test_seed_product_is_findable_by_create_media_buy() -> None:
"""After seed_product, create_media_buy must NOT return PRODUCT_NOT_FOUND."""
store = _store()
seller = _seller()

await store.seed_product(
fixture={
"pricing_options": [{"pricing_option_id": "po-q2", "pricing_model": "cpm"}],
},
product_id="outdoor_display_q2",
)

resp = await seller.create_media_buy(
{
"packages": [
{
"product_id": "outdoor_display_q2",
"pricing_option_id": "po-q2",
"budget": 5000,
}
]
}
)
assert resp.get("media_buy_id") is not None, f"Expected media buy, got: {resp}"


# ---------------------------------------------------------------------------
# measurement_terms rejection (failure 3)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_create_media_buy_terms_rejected_high_viewability() -> None:
"""Per-package measurement_terms.viewability_threshold > 80 must return TERMS_REJECTED."""
seller = _seller()
resp = await seller.create_media_buy(
{
"packages": [
{
"product_id": "premium-homepage",
"pricing_option_id": "po-cpm-homepage",
"budget": 10000,
"measurement_terms": {"viewability_threshold": 85},
}
],
}
)
errors = resp.get("errors", [])
assert any(e.get("code") == "TERMS_REJECTED" for e in errors), (
f"Expected TERMS_REJECTED in errors, got: {resp}"
)
# TERMS_REJECTED is a negotiation error; recovery must be correctable, not terminal.
rejected = next(e for e in errors if e.get("code") == "TERMS_REJECTED")
assert rejected.get("recovery") == "correctable", (
f"Expected recovery=correctable, got: {rejected}"
)


@pytest.mark.asyncio
async def test_create_media_buy_terms_rejected_at_boundary() -> None:
"""viewability_threshold == 80 is exactly at the limit and must be accepted."""
seller = _seller()
resp = await seller.create_media_buy(
{
"packages": [
{
"product_id": "premium-homepage",
"pricing_option_id": "po-cpm-homepage",
"budget": 10000,
"measurement_terms": {"viewability_threshold": 80},
}
],
}
)
assert resp.get("media_buy_id") is not None, (
f"viewability_threshold=80 should be accepted, got: {resp}"
)


@pytest.mark.asyncio
async def test_create_media_buy_accepts_normal_viewability() -> None:
"""viewability_threshold < 80 must NOT be rejected."""
seller = _seller()
resp = await seller.create_media_buy(
{
"packages": [
{
"product_id": "premium-homepage",
"pricing_option_id": "po-cpm-homepage",
"budget": 10000,
"measurement_terms": {"viewability_threshold": 70},
}
],
}
)
assert resp.get("media_buy_id") is not None, f"Expected success, got: {resp}"


# ---------------------------------------------------------------------------
# targeting_overlay round-trip on create (failures 4 & 5)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_create_media_buy_round_trips_targeting_overlay() -> None:
"""targeting_overlay must survive create and appear in get_media_buys."""
seller = _seller()
overlay = {"property_list": {"list_id": "acme_outdoor_allowlist_v1", "match": "any"}}
create_resp = await seller.create_media_buy(
{
"packages": [
{
"product_id": "run-of-site",
"pricing_option_id": "po-cpm-ros",
"budget": 2000,
"targeting_overlay": overlay,
}
]
}
)
mb_id = create_resp.get("media_buy_id")
assert mb_id is not None, f"Expected media buy id, got: {create_resp}"

get_resp = await seller.get_media_buys({"media_buy_ids": [mb_id]})
mb_list = get_resp.get("media_buys", [])
assert mb_list, "Expected at least one media buy in response"
packages = mb_list[0].get("packages", [])
assert packages, "Expected packages in media buy"
assert packages[0].get("targeting_overlay") == overlay, (
f"targeting_overlay not round-tripped: {packages[0]}"
)


# ---------------------------------------------------------------------------
# targeting_overlay round-trip on update (failure 5)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_update_media_buy_persists_targeting_overlay() -> None:
"""update_media_buy must apply targeting_overlay to the stored package."""
seller = _seller()

# Create without overlay
create_resp = await seller.create_media_buy(
{
"packages": [
{
"product_id": "run-of-site",
"pricing_option_id": "po-cpm-ros",
"budget": 1500,
}
]
}
)
mb_id = create_resp.get("media_buy_id")
assert mb_id is not None

# Find the generated package_id
pkg_id = _sa.media_buys[mb_id]["packages"][0]["package_id"]

# Update with targeting_overlay
overlay = {"property_list": {"list_id": "acme_outdoor_no_match_v1", "match": "none"}}
update_resp = await seller.update_media_buy(
{
"media_buy_id": mb_id,
"packages": [
{
"package_id": pkg_id,
"targeting_overlay": overlay,
}
],
}
)
assert update_resp.get("media_buy_id") == mb_id, f"Update failed: {update_resp}"

# Verify overlay persisted in get_media_buys
get_resp = await seller.get_media_buys({"media_buy_ids": [mb_id]})
packages = get_resp["media_buys"][0]["packages"]
assert packages[0].get("targeting_overlay") == overlay, (
f"targeting_overlay not persisted after update: {packages[0]}"
)
Loading