diff --git a/src/adcp/decisioning/__init__.py b/src/adcp/decisioning/__init__.py index cc2c2db9b..aef5cfa7e 100644 --- a/src/adcp/decisioning/__init__.py +++ b/src/adcp/decisioning/__init__.py @@ -121,6 +121,15 @@ def create_media_buy( resolve_property_list, validate_property_list_config, ) +from adcp.decisioning.refine import ( + RefinementOutcome, + RefinementStatus, + RefineResult, + assert_buying_mode_consistent, + build_refinement_applied, + has_refine_support, + project_refine_response, +) from adcp.decisioning.registry import ( ApiKeyCredential, BillingMode, @@ -321,6 +330,13 @@ def __init__(self, *args: object, **kwargs: object) -> None: "filter_products_by_property_list", "resolve_property_list", "validate_property_list_config", + "RefineResult", + "RefinementOutcome", + "RefinementStatus", + "assert_buying_mode_consistent", + "build_refinement_applied", + "has_refine_support", + "project_refine_response", "RateLimitedBuyerAgentRegistry", "RateLimitedError", "RequestContext", diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index 0e3d5599f..9fc89ce01 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -49,6 +49,11 @@ maybe_apply_property_list_filter, property_list_capability_enabled, ) +from adcp.decisioning.refine import ( + assert_buying_mode_consistent, + has_refine_support, + project_refine_response, +) from adcp.decisioning.time_budget import project_incomplete_response, resolve_time_budget from adcp.decisioning.webhook_emit import maybe_emit_sync_completion from adcp.server.base import ADCPHandler, ToolContext @@ -1071,8 +1076,49 @@ async def get_products( # type: ignore[override] response passes through unchanged. """ tool_ctx = context or ToolContext() + # Pre-adapter: validate buying_mode against the wire spec's + # mutual-exclusion rules (refine+brief, wholesale+brief, refine + # without refine[]). + assert_buying_mode_consistent(params) account = await self._resolve_account(params.account, tool_ctx) ctx = self._build_ctx(tool_ctx, account) + # Refine flow: when buying_mode='refine' the framework dispatches + # to refine_get_products() (when present) and projects the result + # into the wire response — adopters return a RefineResult and + # framework constructs position-matched refinement_applied[]. + buying_mode_attr = getattr(params, "buying_mode", None) + mode = ( + ( + buying_mode_attr.value + if hasattr(buying_mode_attr, "value") + else str(buying_mode_attr) + ) + if buying_mode_attr is not None + else None + ) + if mode == "refine": + from adcp.decisioning.types import AdcpError + + if not has_refine_support(self._platform): + raise AdcpError( + "INVALID_REQUEST", + message=( + "buying_mode='refine' is not supported by this " + "seller. The platform does not implement " + "refine_get_products(). Buyers should retry with " + "buying_mode='brief' or 'wholesale'." + ), + field="buying_mode", + ) + refine_result = await _invoke_platform_method( + self._platform, + "refine_get_products", + params, + ctx, + executor=self._executor, + registry=self._registry, + ) + return project_refine_response(refine_result, params.refine or []) # Resolve time_budget to a seconds deadline. _resolve_account and # _build_ctx are intentionally outside this try/except so their # AdcpErrors propagate unmodified; only the platform call is deadline- diff --git a/src/adcp/decisioning/refine.py b/src/adcp/decisioning/refine.py new file mode 100644 index 000000000..97676208e --- /dev/null +++ b/src/adcp/decisioning/refine.py @@ -0,0 +1,266 @@ +"""Refine flow scaffold for ``buying_mode='refine'`` on ``get_products``. + +When a buyer sends ``GetProductsRequest`` with ``buying_mode='refine'``, the +spec requires the seller to: + +* return ``refinement_applied[]`` in the response, **same length, same order**, + echoing ``scope`` and the matching ``id`` field from each ``refine[]`` entry; +* not accept a ``brief`` (the refine list is the iteration mechanism); and +* surface per-entry ``status`` and ``notes`` so the buyer knows what the + seller did. + +The position-matched echo is mechanical: every adopter gets it right or +silently breaks orchestrators that cross-validate alignment. This module +lifts that scaffold into the framework. + +Adopter contract — implement an optional :meth:`SalesPlatform.refine_get_products` +returning a :class:`RefineResult`. The framework constructs the wire +``refinement_applied[]`` from the adopter's per-entry outcomes plus the +request's refine entries. + +Adopters who do **not** implement ``refine_get_products`` see no change; +buyers sending ``buying_mode='refine'`` to such a platform receive +``AdcpError(INVALID_REQUEST, field='buying_mode')`` with a message +identifying that refine is not supported by this seller. + +Reference pattern: :mod:`adcp.decisioning.webhook_emit` — capability-gated +post-adapter side effect. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Literal + +if TYPE_CHECKING: + from adcp.types import ( + GetProductsRequest, + GetProductsResponse, + ) + +logger = logging.getLogger(__name__) + +RefinementStatus = Literal["applied", "partial", "unable"] + + +@dataclass(frozen=True) +class RefinementOutcome: + """Per-refine-entry decision returned by ``refine_get_products``. + + The adopter returns one outcome per entry in ``request.refine[]``, in + the same order. Framework constructs the wire ``refinement_applied[]`` + by zipping outcomes with the request's refine entries — adopter does + NOT echo ``scope``, ``product_id``, or ``proposal_id`` manually. + + :param status: ``'applied'``, ``'partial'``, or ``'unable'`` per the + wire enum (see :class:`adcp.types.generated_poc.bundled.media_buy`'s + ``RefinementApplied.status``). + :param notes: Adopter's explanation; recommended when status is + ``'partial'`` or ``'unable'``. + """ + + status: RefinementStatus + notes: str | None = None + + +@dataclass(frozen=True) +class RefineResult: + """Adopter-returned shape from ``refine_get_products``. + + Framework projects this into the wire :class:`GetProductsResponse`, + constructing ``refinement_applied[]`` from + :attr:`per_refine_outcome` + the request's ``refine[]`` array. + + :param products: Updated product list (per spec, refine returns a + revised set — drop omitted, add ``more_like_this`` discoveries, + update pricing on remaining). + :param proposals: Updated proposal list. ``None`` when the seller + does not produce proposals (sales-non-guaranteed without proposal + mode). Empty list when proposals were all omitted. + :param per_refine_outcome: Exactly ``len(request.refine)`` entries, + in the same order. Mismatched length is a developer-facing error + (raised by the framework before the response is built). + """ + + products: list[Any] + proposals: list[Any] | None + per_refine_outcome: list[RefinementOutcome] = field(default_factory=list) + + +def assert_buying_mode_consistent(req: GetProductsRequest) -> None: + """Validate ``buying_mode`` against the wire spec's mutual-exclusion rules. + + Per the wire description on ``GetProductsRequest``: + + * ``buying_mode='wholesale'`` — ``brief`` MUST NOT be provided. + * ``buying_mode='refine'`` — ``brief`` MUST NOT be provided; ``refine[]`` + drives iteration. + * ``buying_mode='brief'`` — ``brief`` is required (handled by Pydantic + validation upstream). + + Raises :class:`AdcpError(INVALID_REQUEST)` with the offending field on + violation. Called at the top of the ``get_products`` shim before any + platform dispatch. + """ + from adcp.decisioning.types import AdcpError + + mode = _coerce_enum_value(getattr(req, "buying_mode", None)) + brief = getattr(req, "brief", None) + refine = getattr(req, "refine", None) + + if mode == "wholesale" and brief: + raise AdcpError( + "INVALID_REQUEST", + message=( + "buying_mode='wholesale' must not be combined with brief. " + "Wholesale callers request raw inventory and apply their " + "own audiences; the brief is only meaningful in 'brief' mode." + ), + field="brief", + ) + + if mode == "refine": + if brief: + raise AdcpError( + "INVALID_REQUEST", + message=( + "buying_mode='refine' must not be combined with brief. " + "The refine[] array drives iteration on a previous " + "get_products response." + ), + field="brief", + ) + if not refine: + raise AdcpError( + "INVALID_REQUEST", + message=( + "buying_mode='refine' requires a non-empty refine[] " + "array — the buyer must declare what to iterate on." + ), + field="refine", + ) + + +def build_refinement_applied( + refines: list[Any], + outcomes: list[RefinementOutcome], +) -> list[Any]: + """Position-match the request's ``refine[]`` with the adopter's outcomes. + + Each ``refines[i]`` entry has a discriminated ``scope`` (``'request'``, + ``'product'``, or ``'proposal'``). This function emits a parallel + ``refinement_applied[i]`` carrying the same scope, the matching ID + field (``product_id`` / ``proposal_id``), and the adopter's + ``status`` + ``notes``. + + :param refines: ``request.refine`` (length N). + :param outcomes: Adopter's per-entry outcomes (must also be length N). + :returns: Wire-shape ``RefinementApplied`` (RootModel) instances (one per entry). + :raises ValueError: ``len(outcomes) != len(refines)``. Developer-facing, + not buyer-facing — adopter-side bug. + """ + if len(outcomes) != len(refines): + raise ValueError( + f"refine_get_products returned {len(outcomes)} outcomes for " + f"{len(refines)} refine entries — counts must match. The " + "framework constructs refinement_applied[] by zipping these " + "lists; mismatched lengths break the buyer's position-matched " + "echo contract." + ) + + from adcp.types import ( + RefinementApplied, + RefinementApplied1, + RefinementApplied2, + RefinementApplied3, + ) + + # The wire enum on RefinementApplied{1,2,3}.status is the discriminated + # ``Status`` enum (``applied``/``partial``/``unable``). Pydantic accepts + # the matching string at runtime; the model_validate path coerces. + status_field = {"applied": "applied", "partial": "partial", "unable": "unable"} + + out: list[Any] = [] + for entry, outcome in zip(refines, outcomes, strict=True): + # Refine is a RootModel discriminated on `scope`; unwrap to the + # variant. + inner = getattr(entry, "root", entry) + scope = getattr(inner, "scope", None) + status_str = status_field[outcome.status] + + if scope == "request": + applied: Any = RefinementApplied1.model_validate( + {"scope": "request", "status": status_str, "notes": outcome.notes} + ) + elif scope == "product": + applied = RefinementApplied2.model_validate( + { + "scope": "product", + "product_id": str(getattr(inner, "product_id")), + "status": status_str, + "notes": outcome.notes, + } + ) + elif scope == "proposal": + applied = RefinementApplied3.model_validate( + { + "scope": "proposal", + "proposal_id": str(getattr(inner, "proposal_id")), + "status": status_str, + "notes": outcome.notes, + } + ) + else: + raise ValueError( + f"Unknown refine scope {scope!r}; expected " "'request' | 'product' | 'proposal'." + ) + out.append(RefinementApplied(root=applied)) + return out + + +def project_refine_response( + result: RefineResult, + refines: list[Any], +) -> GetProductsResponse: + """Project a :class:`RefineResult` into a wire :class:`GetProductsResponse`. + + Builds ``refinement_applied[]`` from the request's ``refine[]`` and + the adopter's ``per_refine_outcome``, then attaches ``products`` and + ``proposals``. + + :raises ValueError: When ``len(per_refine_outcome) != len(refines)`` + (developer-facing — adopter contract violation). + """ + from adcp.types import GetProductsResponse + + refinement_applied = build_refinement_applied(refines, result.per_refine_outcome) + + return GetProductsResponse( + products=list(result.products), + proposals=list(result.proposals) if result.proposals is not None else None, + refinement_applied=refinement_applied, + ) + + +def has_refine_support(platform: Any) -> bool: + """Return True when the platform implements ``refine_get_products``.""" + return callable(getattr(platform, "refine_get_products", None)) + + +def _coerce_enum_value(value: Any) -> str | None: + """Return a plain string for an enum or string value (None passthrough).""" + if value is None: + return None + return value.value if hasattr(value, "value") else str(value) + + +__all__ = [ + "RefineResult", + "RefinementOutcome", + "RefinementStatus", + "assert_buying_mode_consistent", + "build_refinement_applied", + "has_refine_support", + "project_refine_response", +] diff --git a/src/adcp/decisioning/specialisms/sales.py b/src/adcp/decisioning/specialisms/sales.py index f9165e42b..24a1a0395 100644 --- a/src/adcp/decisioning/specialisms/sales.py +++ b/src/adcp/decisioning/specialisms/sales.py @@ -115,9 +115,37 @@ def get_products( ``ctx.publish_status_change(resource_type='proposal', ...)`` rather than blocking ``get_products`` waiting for trafficker approval. + + **Buying mode dispatch:** when ``req.buying_mode == 'refine'`` and + the platform implements :meth:`refine_get_products`, the framework + dispatches there instead of this method. Platforms that do not + implement ``refine_get_products`` reject ``buying_mode='refine'`` + with ``AdcpError(INVALID_REQUEST, field='buying_mode')`` at the + framework layer — the platform method is not called. """ ... + # Truly optional — adopters who don't implement refine_get_products + # remain structurally conformant. The framework uses + # :func:`adcp.decisioning.has_refine_support` (a ``hasattr`` check) at + # dispatch time and rejects ``buying_mode='refine'`` with + # ``AdcpError(INVALID_REQUEST, field='buying_mode')`` when absent. + # + # Implementations match this signature:: + # + # def refine_get_products( + # self, + # req: GetProductsRequest, + # ctx: RequestContext[TMeta], + # ) -> MaybeAsync[RefineResult]: ... + # + # Return a :class:`adcp.decisioning.RefineResult` with ``products``, + # ``proposals``, and exactly ``len(req.refine)`` outcomes in + # ``per_refine_outcome``. The framework constructs the wire + # ``refinement_applied[]`` by zipping outcomes with ``req.refine`` — + # adopters do NOT echo ``scope`` / ``product_id`` / ``proposal_id`` + # manually. + def create_media_buy( self, req: CreateMediaBuyRequest, diff --git a/src/adcp/types/__init__.py b/src/adcp/types/__init__.py index 784a92bee..b067c64da 100644 --- a/src/adcp/types/__init__.py +++ b/src/adcp/types/__init__.py @@ -293,6 +293,10 @@ QuerySummary, ReachUnit, Refine, + RefinementApplied, + RefinementApplied1, + RefinementApplied2, + RefinementApplied3, ReportingBucket, ReportingCapabilities, ReportingFrequency, @@ -744,6 +748,10 @@ def __init__(self, *args: object, **kwargs: object) -> None: "ProvidePerformanceFeedbackResponse", "QuerySummary", "Refine", + "RefinementApplied", + "RefinementApplied1", + "RefinementApplied2", + "RefinementApplied3", "SortApplied", "StatusSummary", "SyncCatalogsInputRequired", diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index d0af990a6..6180da487 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -760,6 +760,10 @@ "QuerySummary", "ReachUnit", "Refine", + "RefinementApplied", + "RefinementApplied1", + "RefinementApplied2", + "RefinementApplied3", "RepeatableAssetGroup", "ReportPlanOutcomeRequest", "ReportPlanOutcomeResponse", diff --git a/tests/test_decisioning_refine.py b/tests/test_decisioning_refine.py new file mode 100644 index 000000000..7921c64f9 --- /dev/null +++ b/tests/test_decisioning_refine.py @@ -0,0 +1,365 @@ +"""Tests for adcp.decisioning.refine — buying_mode='refine' scaffold. + +Covers: +- assert_buying_mode_consistent rejects refine+brief, wholesale+brief, + refine without refine[]. +- build_refinement_applied position-matches scope + ids correctly for all + three discriminated variants. +- project_refine_response rejects mismatched outcome counts. +- Handler dispatches to refine_get_products() when present and + buying_mode='refine'. +- Handler rejects buying_mode='refine' on platforms without + refine_get_products() with INVALID_REQUEST. +""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +import pytest + +from adcp.decisioning import ( + AdcpError, + DecisioningCapabilities, + DecisioningPlatform, + InMemoryTaskRegistry, + RefinementOutcome, + RefineResult, + SingletonAccounts, + assert_buying_mode_consistent, + build_refinement_applied, + has_refine_support, + project_refine_response, +) +from adcp.decisioning.handler import PlatformHandler +from adcp.server.base import ToolContext + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def executor(): + pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="test-refine-") + yield pool + pool.shutdown(wait=True) + + +def _refine_request_request_scope(ask: str = "more video") -> Any: + """Build a refine request with scope='request'.""" + from adcp.types import GetProductsRequest + + return GetProductsRequest( + buying_mode="refine", + refine=[{"scope": "request", "ask": ask}], + ) + + +def _refine_request_product_scope(product_id: str = "p1", action: str = "include") -> Any: + from adcp.types import GetProductsRequest + + return GetProductsRequest( + buying_mode="refine", + refine=[{"scope": "product", "product_id": product_id, "action": action}], + ) + + +def _refine_request_proposal_scope(proposal_id: str = "prop_1") -> Any: + from adcp.types import GetProductsRequest + + return GetProductsRequest( + buying_mode="refine", + refine=[{"scope": "proposal", "proposal_id": proposal_id, "action": "include"}], + ) + + +# --------------------------------------------------------------------------- +# assert_buying_mode_consistent +# --------------------------------------------------------------------------- + + +def test_brief_with_brief_text_is_valid() -> None: + from adcp.types import GetProductsRequest + + req = GetProductsRequest(buying_mode="brief", brief="display campaign") + assert_buying_mode_consistent(req) # no raise + + +def test_wholesale_without_brief_is_valid() -> None: + from adcp.types import GetProductsRequest + + req = GetProductsRequest(buying_mode="wholesale") + assert_buying_mode_consistent(req) # no raise + + +def test_refine_with_entries_is_valid() -> None: + req = _refine_request_request_scope() + assert_buying_mode_consistent(req) + + +def test_wholesale_with_brief_rejected() -> None: + from adcp.types import GetProductsRequest + + req = GetProductsRequest(buying_mode="wholesale", brief="leftover") + with pytest.raises(AdcpError) as exc: + assert_buying_mode_consistent(req) + assert exc.value.code == "INVALID_REQUEST" + assert exc.value.field == "brief" + + +def test_refine_with_brief_rejected() -> None: + from adcp.types import GetProductsRequest + + req = GetProductsRequest( + buying_mode="refine", + brief="this should not be here", + refine=[{"scope": "request", "ask": "more options"}], + ) + with pytest.raises(AdcpError) as exc: + assert_buying_mode_consistent(req) + assert exc.value.code == "INVALID_REQUEST" + assert exc.value.field == "brief" + + +def test_refine_without_refine_array_rejected() -> None: + from adcp.types import GetProductsRequest + + req = GetProductsRequest(buying_mode="refine") + with pytest.raises(AdcpError) as exc: + assert_buying_mode_consistent(req) + assert exc.value.code == "INVALID_REQUEST" + assert exc.value.field == "refine" + + +# --------------------------------------------------------------------------- +# build_refinement_applied — position-matched echo +# --------------------------------------------------------------------------- + + +def test_refinement_applied_request_scope_echoes_scope() -> None: + req = _refine_request_request_scope("more video") + outcomes = [RefinementOutcome(status="applied", notes="added video products")] + out = build_refinement_applied(req.refine or [], outcomes) + assert len(out) == 1 + inner = out[0].root + assert inner.scope == "request" + assert inner.status.value == "applied" + assert inner.notes == "added video products" + + +def test_refinement_applied_product_scope_echoes_product_id() -> None: + req = _refine_request_product_scope("prod_42") + outcomes = [RefinementOutcome(status="partial", notes="updated pricing only")] + out = build_refinement_applied(req.refine or [], outcomes) + inner = out[0].root + assert inner.scope == "product" + assert inner.product_id == "prod_42" + assert inner.status.value == "partial" + + +def test_refinement_applied_proposal_scope_echoes_proposal_id() -> None: + req = _refine_request_proposal_scope("prop_xyz") + outcomes = [RefinementOutcome(status="unable", notes="not available in inventory")] + out = build_refinement_applied(req.refine or [], outcomes) + inner = out[0].root + assert inner.scope == "proposal" + assert inner.proposal_id == "prop_xyz" + assert inner.status.value == "unable" + + +def test_refinement_applied_mixed_outcomes() -> None: + from adcp.types import GetProductsRequest + + req = GetProductsRequest( + buying_mode="refine", + refine=[ + {"scope": "product", "product_id": "p1", "action": "omit"}, + {"scope": "product", "product_id": "p2", "action": "include"}, + {"scope": "proposal", "proposal_id": "pp1", "action": "include"}, + ], + ) + outcomes = [ + RefinementOutcome(status="applied"), + RefinementOutcome(status="applied"), + RefinementOutcome(status="partial", notes="capacity reduced"), + ] + out = build_refinement_applied(req.refine or [], outcomes) + assert [r.root.scope for r in out] == ["product", "product", "proposal"] + assert out[0].root.product_id == "p1" + assert out[1].root.product_id == "p2" + assert out[2].root.proposal_id == "pp1" + assert out[2].root.notes == "capacity reduced" + + +def test_mismatched_outcome_count_raises() -> None: + req = _refine_request_request_scope() + with pytest.raises(ValueError, match="counts must match"): + build_refinement_applied(req.refine or [], outcomes=[]) + + +# --------------------------------------------------------------------------- +# project_refine_response +# --------------------------------------------------------------------------- + + +def test_project_refine_response_attaches_products_and_outcomes() -> None: + req = _refine_request_request_scope() + result = RefineResult( + products=[], + proposals=None, + per_refine_outcome=[RefinementOutcome(status="applied")], + ) + response = project_refine_response(result, req.refine or []) + assert response.products == [] + assert response.proposals is None + assert response.refinement_applied is not None + assert len(response.refinement_applied) == 1 + assert response.refinement_applied[0].root.scope == "request" + + +def test_project_refine_response_keeps_proposals_when_provided() -> None: + req = _refine_request_proposal_scope("p_1") + result = RefineResult( + products=[], + proposals=[], # adopter explicitly returns empty list, not None + per_refine_outcome=[RefinementOutcome(status="applied")], + ) + response = project_refine_response(result, req.refine or []) + assert response.proposals == [] # framework preserves empty-list intent + + +# --------------------------------------------------------------------------- +# has_refine_support +# --------------------------------------------------------------------------- + + +class _PlatformWithRefine(DecisioningPlatform): + capabilities = DecisioningCapabilities() + accounts = SingletonAccounts(account_id="acct") + + def get_products(self, req, ctx): + from adcp.types import GetProductsResponse + + return GetProductsResponse(products=[]) + + def refine_get_products(self, req, ctx): + return RefineResult( + products=[], + proposals=None, + per_refine_outcome=[RefinementOutcome(status="applied")], + ) + + +class _PlatformNoRefine(DecisioningPlatform): + capabilities = DecisioningCapabilities() + accounts = SingletonAccounts(account_id="acct") + + def get_products(self, req, ctx): + from adcp.types import GetProductsResponse + + return GetProductsResponse(products=[]) + + +def test_has_refine_support_true_when_method_present() -> None: + assert has_refine_support(_PlatformWithRefine()) + + +def test_has_refine_support_false_when_method_absent() -> None: + assert not has_refine_support(_PlatformNoRefine()) + + +# --------------------------------------------------------------------------- +# Handler integration +# --------------------------------------------------------------------------- + + +def _handler(platform, executor): + return PlatformHandler(platform, executor=executor, registry=InMemoryTaskRegistry()) + + +@pytest.mark.asyncio +async def test_handler_dispatches_to_refine_get_products(executor) -> None: + """When buying_mode='refine' and the method exists, framework dispatches there.""" + called: dict[str, Any] = {} + + class _P(_PlatformWithRefine): + def refine_get_products(self, req, ctx): + called["scope"] = req.refine[0].root.scope + called["ask"] = req.refine[0].root.ask + return RefineResult( + products=[], + proposals=None, + per_refine_outcome=[RefinementOutcome(status="applied", notes="ok")], + ) + + handler = _handler(_P(), executor) + req = _refine_request_request_scope("less display") + resp = await handler.get_products(req, ToolContext()) + assert called == {"scope": "request", "ask": "less display"} + assert resp.refinement_applied is not None + assert resp.refinement_applied[0].root.notes == "ok" + + +@pytest.mark.asyncio +async def test_handler_rejects_refine_when_unsupported(executor) -> None: + """Buyer sends buying_mode='refine' to a platform without refine_get_products.""" + handler = _handler(_PlatformNoRefine(), executor) + req = _refine_request_request_scope() + with pytest.raises(AdcpError) as exc: + await handler.get_products(req, ToolContext()) + assert exc.value.code == "INVALID_REQUEST" + assert exc.value.field == "buying_mode" + + +@pytest.mark.asyncio +async def test_handler_brief_mode_unaffected(executor) -> None: + """Default buying_mode='brief' still calls plain get_products.""" + from adcp.types import GetProductsRequest, GetProductsResponse + + class _P(_PlatformWithRefine): + def get_products(self, req, ctx): + return GetProductsResponse(products=[]) + + def refine_get_products(self, req, ctx): # not called + raise AssertionError("refine_get_products should not be called for brief mode") + + handler = _handler(_P(), executor) + req = GetProductsRequest(buying_mode="brief", brief="display only") + resp = await handler.get_products(req, ToolContext()) + assert resp.products == [] + + +@pytest.mark.asyncio +async def test_handler_refine_with_brief_rejected(executor) -> None: + """Wire validation runs before account resolution.""" + from adcp.types import GetProductsRequest + + handler = _handler(_PlatformWithRefine(), executor) + req = GetProductsRequest( + buying_mode="refine", + brief="invalid combo", + refine=[{"scope": "request", "ask": "more video"}], + ) + with pytest.raises(AdcpError) as exc: + await handler.get_products(req, ToolContext()) + assert exc.value.field == "brief" + + +@pytest.mark.asyncio +async def test_handler_refine_validates_outcome_count(executor) -> None: + """Adopter returns wrong number of outcomes — framework raises ValueError.""" + + class _P(_PlatformWithRefine): + def refine_get_products(self, req, ctx): + return RefineResult( + products=[], + proposals=None, + per_refine_outcome=[], # WRONG — should be 1 to match req.refine + ) + + handler = _handler(_P(), executor) + req = _refine_request_request_scope() + with pytest.raises(ValueError, match="counts must match"): + await handler.get_products(req, ToolContext())