diff --git a/src/adcp/decisioning/__init__.py b/src/adcp/decisioning/__init__.py index 53ded86b5..1e0975037 100644 --- a/src/adcp/decisioning/__init__.py +++ b/src/adcp/decisioning/__init__.py @@ -114,6 +114,13 @@ def create_media_buy( DecisioningPlatform, ) from adcp.decisioning.platform_router import PlatformRouter +from adcp.decisioning.property_list import ( + PropertyListFetcher, + filter_products_by_property_list, + property_list_capability_enabled, + resolve_property_list, + validate_property_list_config, +) from adcp.decisioning.registry import ( ApiKeyCredential, BillingMode, @@ -298,9 +305,14 @@ def __init__(self, *args: object, **kwargs: object) -> None: "PostgresTaskRegistry", "Proposal", "PropertyList", + "PropertyListFetcher", "PropertyListReference", "ProductConfigStore", + "property_list_capability_enabled", "PropertyListsPlatform", + "filter_products_by_property_list", + "resolve_property_list", + "validate_property_list_config", "RateLimitedBuyerAgentRegistry", "RateLimitedError", "RequestContext", diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index 341c0e141..c8a5336ac 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -45,6 +45,10 @@ ) from adcp.decisioning.implementation_config import ProductConfigStore from adcp.decisioning.pagination import _query_hash, apply_framework_pagination +from adcp.decisioning.property_list import ( + maybe_apply_property_list_filter, + property_list_capability_enabled, +) from adcp.decisioning.webhook_emit import maybe_emit_sync_completion from adcp.server.base import ADCPHandler, ToolContext @@ -151,6 +155,7 @@ from concurrent.futures import ThreadPoolExecutor from adcp.decisioning.platform import DecisioningPlatform + from adcp.decisioning.property_list import PropertyListFetcher from adcp.decisioning.registry import BuyerAgent, BuyerAgentRegistry from adcp.decisioning.resolve import ResourceResolver from adcp.decisioning.state import StateReader @@ -696,6 +701,7 @@ def __init__( auto_emit_completion_webhooks: bool = True, buyer_agent_registry: BuyerAgentRegistry | None = None, config_store: ProductConfigStore | None = None, + property_list_fetcher: PropertyListFetcher | None = None, ) -> None: super().__init__() self._platform = platform @@ -708,6 +714,7 @@ def __init__( self._auto_emit_completion_webhooks = auto_emit_completion_webhooks self._buyer_agent_registry = buyer_agent_registry self._config_store = config_store + self._property_list_fetcher = property_list_fetcher # Cache whether the platform's create_media_buy accepts 'configs' # so we only pay the inspect.signature cost at construction time. @@ -1076,6 +1083,16 @@ async def get_products( # type: ignore[override] registry=self._registry, ), ) + # Post-adapter: capability-gated property-list filter. + response = cast( + "GetProductsResponse", + await maybe_apply_property_list_filter( + params=params, + response=response, + fetcher=self._property_list_fetcher, + capability_enabled=property_list_capability_enabled(self._platform), + ), + ) if self._platform.capabilities.auto_paginate and params.pagination is not None: response = cast( "GetProductsResponse", diff --git a/src/adcp/decisioning/property_list.py b/src/adcp/decisioning/property_list.py new file mode 100644 index 000000000..be696e0b8 --- /dev/null +++ b/src/adcp/decisioning/property_list.py @@ -0,0 +1,325 @@ +"""Property-list resolver and product intersection helper for get_products. + +Capability-gated framework support for the ``property_list`` buyer-side filter +on ``get_products``. When an adopter declares +``Features(property_list_filtering=True)`` on their +:class:`~adcp.decisioning.platform.DecisioningCapabilities` and supplies a +:class:`PropertyListFetcher`, the framework automatically: + +1. Fetches the buyer's authorized property IDs from ``request.property_list.agent_url``. +2. Applies :func:`filter_products_by_property_list` to the platform's response. +3. Sets ``response.property_list_applied = True`` on the returned envelope. + +Adopters who want to apply the filter themselves (e.g., pushed down into a DB +query) set ``Features(property_list_filtering=False)`` and call these helpers +directly inside their ``get_products`` implementation. + +Reference pattern: :mod:`adcp.decisioning.webhook_emit` (capability-gated +post-adapter side effect). +""" + +from __future__ import annotations + +import logging +from typing import Any, Protocol, runtime_checkable + +logger = logging.getLogger(__name__) + + +@runtime_checkable +class PropertyListFetcher(Protocol): + """Adopter-supplied protocol for fetching a buyer's authorized property IDs. + + The framework calls :meth:`fetch` when ``property_list_filtering`` is + declared in capabilities and the request carries a ``PropertyList`` + reference. Adopters plug in their own HTTP client — the framework ships + no hidden HTTP dependency. + + Typical implementation:: + + class MyFetcher: + def __init__(self, client: httpx.AsyncClient) -> None: + self._client = client + + async def fetch( + self, + agent_url: str, + list_id: str, + *, + auth_token: str | None = None, + ) -> list[str]: + headers = {"Authorization": f"Bearer {auth_token}"} if auth_token else {} + resp = await self._client.get( + f"{agent_url}/property-lists/{list_id}", + headers=headers, + ) + resp.raise_for_status() + return resp.json()["property_ids"] + + Wire the fetcher via:: + + create_adcp_server_from_platform(platform, property_list_fetcher=MyFetcher(client)) + """ + + async def fetch( + self, + agent_url: str, + list_id: str, + *, + auth_token: str | None = None, + ) -> list[str]: + """Fetch and return the list of allowed property_id strings. + + :param agent_url: Buyer agent URL from the wire ``PropertyList`` reference. + :param list_id: Property list identifier. + :param auth_token: Optional JWT/bearer token. Never log this value. + :returns: List of allowed property_id strings (``^[a-z0-9_]+$`` format). + :raises Exception: Any exception; the framework wraps it in + :class:`~adcp.decisioning.types.AdcpError` with ``recovery='transient'``. + """ + ... + + +async def resolve_property_list( + ref: Any, + *, + fetcher: PropertyListFetcher, +) -> set[str]: + """Fetch the buyer's authorized property IDs from the agent at ``ref.agent_url``. + + :param ref: ``PropertyList`` wire object (has ``agent_url``, ``list_id``, + ``auth_token``). + :param fetcher: Adopter-supplied :class:`PropertyListFetcher`. + :returns: Set of allowed property_id strings. + :raises AdcpError: ``recovery='transient'`` on any fetch failure. + ``auth_token`` is never included in the error details. + """ + from adcp.decisioning.types import AdcpError + + list_id: str = ref.list_id + agent_url: str = str(ref.agent_url) + auth_token: str | None = getattr(ref, "auth_token", None) + + try: + ids = await fetcher.fetch(agent_url, list_id, auth_token=auth_token) + return set(ids) + except Exception as exc: + # Log the raw exception server-side; never include it in the wire + # error message — the exception repr may carry auth_token or other + # credential-shaped values from the upstream HTTP response. + logger.warning( + "[adcp.property_list] fetch failed for list_id=%r agent_url=%r: %s", + list_id, + agent_url, + exc, + ) + raise AdcpError( + "SERVICE_UNAVAILABLE", + message=( + f"Property list fetch failed for list_id={list_id!r} " + f"from agent_url={agent_url!r}" + ), + recovery="transient", + details={"list_id": list_id, "agent_url": agent_url}, + ) from exc + + +def filter_products_by_property_list( + products: list[Any], + allowed_property_ids: set[str], +) -> list[Any]: + """Filter a product list to those matching the buyer's authorized property IDs. + + Respects ``publisher_properties.selection_type``: + + * ``'all'`` — product covers all publisher properties; always included. + * ``'by_id'`` — intersect the product's ``property_ids`` with + ``allowed_property_ids``. + * ``'by_tag'`` — property tags cannot be matched against a property ID list; + this entry does not contribute to inclusion. + + Respects ``product.property_targeting_allowed``: + + * ``False`` (default, "all or nothing") — the product's full set of + ``by_id`` property IDs must be a subset of ``allowed_property_ids``. + * ``True`` (permissive) — any non-empty intersection is sufficient. + + A product is included if ANY of its ``publisher_properties`` entries passes + the filter. This models the semantics of a product covering inventory from + multiple publishers: if ANY publisher's inventory is in the buyer's allowed + set, the product is relevant. + + :param products: Products from the platform's ``get_products`` response. + :param allowed_property_ids: Set of property_id strings the buyer is + authorized to spend on (result of :func:`resolve_property_list`). + :returns: Filtered product list; original order preserved. + """ + return [p for p in products if _product_matches(p, allowed_property_ids)] + + +def _product_matches(product: Any, allowed: set[str]) -> bool: + """Return True if the product should be included after property-list filtering.""" + permissive: bool = bool(getattr(product, "property_targeting_allowed", False)) + pub_props: list[Any] = list(getattr(product, "publisher_properties", None) or []) + product_id: str = str(getattr(product, "product_id", "?")) + + for pp_wrapper in pub_props: + # PublisherProperties is a RootModel; unwrap to the discriminated variant. + pp = getattr(pp_wrapper, "root", pp_wrapper) + st = getattr(pp, "selection_type", None) + + if st == "all": + logger.debug( + "[adcp.property_list] product %r: selection_type='all' → include", + product_id, + ) + return True + + if st == "by_id": + raw_ids: list[Any] = list(getattr(pp, "property_ids", None) or []) + product_ids = { + (pid.root if hasattr(pid, "root") else str(pid)) for pid in raw_ids + } + if permissive: + if product_ids & allowed: + logger.debug( + "[adcp.property_list] product %r: by_id permissive" + " — intersection found → include", + product_id, + ) + return True + else: + if product_ids and product_ids.issubset(allowed): + logger.debug( + "[adcp.property_list] product %r: by_id strict" + " — all IDs in allowed set → include", + product_id, + ) + return True + logger.debug( + "[adcp.property_list] product %r: by_id %s — no match → continue", + product_id, + "permissive" if permissive else "strict", + ) + + elif st == "by_tag": + # Tag-based selection cannot be matched against property ID lists. + logger.debug( + "[adcp.property_list] product %r: selection_type='by_tag'" + " → cannot match IDs, skip entry", + product_id, + ) + + logger.debug( + "[adcp.property_list] product %r: no publisher_properties entry passed → exclude", + product_id, + ) + return False + + +async def maybe_apply_property_list_filter( + *, + params: Any, + response: Any, + fetcher: PropertyListFetcher | None, + capability_enabled: bool, +) -> Any: + """Post-adapter gate: apply property-list filtering to a get_products response. + + Called by the ``get_products`` handler shim after the platform method + returns. The gate is a no-op when either: + + * ``capability_enabled`` is False (adopter hasn't declared the feature). + * ``params.property_list`` is absent (buyer didn't send a list reference). + + When ``fetcher`` is None but the gate would otherwise fire, a WARNING is + emitted and the response is returned unmodified (defense-in-depth; + :func:`validate_property_list_config` should have caught this at boot). + + Uses :meth:`model_copy` to avoid mutating the platform's return value. + + :raises AdcpError: ``recovery='transient'`` propagated from + :func:`resolve_property_list` on fetch failure. + """ + if not capability_enabled: + return response + + property_list_ref = getattr(params, "property_list", None) + if property_list_ref is None: + return response + + if fetcher is None: + logger.warning( + "[adcp.property_list] property_list_filtering capability is declared " + "and the request carries a property_list reference, but no " + "PropertyListFetcher was wired — filter skipped. Pass " + "property_list_fetcher= to " + "adcp.decisioning.serve.create_adcp_server_from_platform." + ) + return response + + allowed = await resolve_property_list(property_list_ref, fetcher=fetcher) + products: list[Any] = list(getattr(response, "products", None) or []) + filtered = filter_products_by_property_list(products, allowed) + + return response.model_copy( + update={"products": filtered, "property_list_applied": True} + ) + + +def validate_property_list_config( + *, + capability_enabled: bool, + fetcher: PropertyListFetcher | None, +) -> None: + """Boot-time fail-fast: raise when property_list_filtering=True but no fetcher. + + Mirrors :func:`~adcp.decisioning.webhook_emit.validate_webhook_sender_for_platform`: + a declared capability without the required runtime dependency would silently + skip filtering at request time — a buyer who sends ``property_list`` would + receive unfiltered products with ``property_list_applied`` absent or False, + mismatching what the seller's ``get_adcp_capabilities`` advertised. + + :raises AdcpError: ``recovery='terminal'`` when misconfigured. + """ + if not capability_enabled: + return + if fetcher is not None: + return + + from adcp.decisioning.types import AdcpError + + raise AdcpError( + "INVALID_REQUEST", + message=( + "Features.property_list_filtering=True is declared in capabilities " + "but no PropertyListFetcher was wired. Buyers who send " + "property_list on get_products requests would have their list " + "filter silently skipped. Pass property_list_fetcher= to " + "adcp.decisioning.serve.create_adcp_server_from_platform, " + "or set Features(property_list_filtering=False) to opt out." + ), + recovery="terminal", + details={"missing": "property_list_fetcher"}, + ) + + +def property_list_capability_enabled(platform: Any) -> bool: + """Return True if ``platform.capabilities.media_buy.features.property_list_filtering`` is set. + + Centralises the three-level ``getattr`` chain used in both ``handler.py`` + and ``serve.py`` so they can't drift apart. + """ + media_buy = getattr(getattr(platform, "capabilities", None), "media_buy", None) + features = getattr(media_buy, "features", None) + return bool(getattr(features, "property_list_filtering", False)) + + +__all__ = [ + "PropertyListFetcher", + "filter_products_by_property_list", + "maybe_apply_property_list_filter", + "property_list_capability_enabled", + "resolve_property_list", + "validate_property_list_config", +] diff --git a/src/adcp/decisioning/serve.py b/src/adcp/decisioning/serve.py index 950e4b338..d552117d1 100644 --- a/src/adcp/decisioning/serve.py +++ b/src/adcp/decisioning/serve.py @@ -42,6 +42,7 @@ if TYPE_CHECKING: from adcp.decisioning.implementation_config import ProductConfigStore from adcp.decisioning.platform import DecisioningPlatform + from adcp.decisioning.property_list import PropertyListFetcher from adcp.decisioning.registry import BuyerAgentRegistry from adcp.decisioning.resolve import ResourceResolver from adcp.decisioning.state import StateReader @@ -85,6 +86,7 @@ def create_adcp_server_from_platform( auto_emit_completion_webhooks: bool = True, buyer_agent_registry: BuyerAgentRegistry | None = None, config_store: ProductConfigStore | None = None, + property_list_fetcher: PropertyListFetcher | None = None, ) -> tuple[PlatformHandler, ThreadPoolExecutor, TaskRegistry]: """Build the :class:`PlatformHandler` + supporting wiring from a :class:`DecisioningPlatform`. @@ -275,6 +277,18 @@ def create_adcp_server_from_platform( auto_emit_completion_webhooks=auto_emit_completion_webhooks, buyer_agent_registry=buyer_agent_registry, config_store=config_store, + property_list_fetcher=property_list_fetcher, + ) + + # Boot-time fail-fast: property_list_filtering declared but no fetcher wired. + from adcp.decisioning.property_list import ( + property_list_capability_enabled, + validate_property_list_config, + ) + + validate_property_list_config( + capability_enabled=property_list_capability_enabled(platform), + fetcher=property_list_fetcher, ) # F12 boot-time fail-fast (Emma sales-direct P0 root cause): if @@ -326,6 +340,7 @@ def serve( auto_emit_completion_webhooks: bool = True, buyer_agent_registry: BuyerAgentRegistry | None = None, config_store: ProductConfigStore | None = None, + property_list_fetcher: PropertyListFetcher | None = None, advertise_all: bool = False, mock_ad_server: Any | None = None, enable_debug_endpoints: bool = False, @@ -404,6 +419,7 @@ def serve( auto_emit_completion_webhooks=auto_emit_completion_webhooks, buyer_agent_registry=buyer_agent_registry, config_store=config_store, + property_list_fetcher=property_list_fetcher, ) # Phase 1 sandbox-authority — wire the comply controller's account diff --git a/tests/test_decisioning_property_list.py b/tests/test_decisioning_property_list.py new file mode 100644 index 000000000..53a56cccb --- /dev/null +++ b/tests/test_decisioning_property_list.py @@ -0,0 +1,493 @@ +"""Tests for adcp.decisioning.property_list — resolver and filter helpers.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from adcp.decisioning.property_list import ( + PropertyListFetcher, + filter_products_by_property_list, + maybe_apply_property_list_filter, + resolve_property_list, + validate_property_list_config, +) +from adcp.decisioning.types import AdcpError + + +# --------------------------------------------------------------------------- +# Helpers — minimal wire-shape stubs +# --------------------------------------------------------------------------- + + +def _make_pp_all(publisher_domain: str = "example.com") -> MagicMock: + """PublisherProperties(selection_type='all') stub.""" + pp = MagicMock() + pp.selection_type = "all" + pp.publisher_domain = publisher_domain + return pp + + +def _make_pp_by_id(property_ids: list[str]) -> MagicMock: + """PublisherProperties(selection_type='by_id') stub.""" + pp = MagicMock() + pp.selection_type = "by_id" + pp.property_ids = [_make_property_id(pid) for pid in property_ids] + return pp + + +def _make_pp_by_tag(tags: list[str]) -> MagicMock: + """PublisherProperties(selection_type='by_tag') stub.""" + pp = MagicMock() + pp.selection_type = "by_tag" + pp.property_tags = tags + return pp + + +def _make_property_id(pid: str) -> MagicMock: + """PropertyId(root=pid) stub.""" + obj = MagicMock() + obj.root = pid + return obj + + +def _make_pp_wrapper(pp: MagicMock) -> MagicMock: + """PublisherProperties RootModel wrapper stub.""" + wrapper = MagicMock() + wrapper.root = pp + return wrapper + + +def _make_product( + product_id: str, + publisher_properties: list[Any], + property_targeting_allowed: bool | None = False, +) -> MagicMock: + product = MagicMock() + product.product_id = product_id + product.publisher_properties = [_make_pp_wrapper(pp) for pp in publisher_properties] + product.property_targeting_allowed = property_targeting_allowed + return product + + +def _make_response(products: list[Any]) -> MagicMock: + resp = MagicMock() + resp.products = products + resp.property_list_applied = None + + def model_copy(update: dict[str, Any]) -> MagicMock: + new_resp = MagicMock() + new_resp.products = update.get("products", products) + new_resp.property_list_applied = update.get("property_list_applied") + return new_resp + + resp.model_copy = model_copy + return resp + + +def _make_property_list_ref( + agent_url: str = "https://agent.example.com", + list_id: str = "list_1", + auth_token: str | None = None, +) -> MagicMock: + ref = MagicMock() + ref.agent_url = agent_url + ref.list_id = list_id + ref.auth_token = auth_token + return ref + + +# --------------------------------------------------------------------------- +# filter_products_by_property_list +# --------------------------------------------------------------------------- + + +class TestFilterProductsByPropertyList: + def test_selection_type_all_always_included(self) -> None: + """Products with selection_type='all' always pass the filter.""" + product = _make_product("p1", [_make_pp_all()]) + result = filter_products_by_property_list([product], allowed_property_ids=set()) + assert result == [product] + + def test_selection_type_all_ignores_allowed_set(self) -> None: + """selection_type='all' includes the product regardless of allowed set.""" + product = _make_product("p1", [_make_pp_all()]) + result = filter_products_by_property_list( + [product], allowed_property_ids={"completely_unrelated_id"} + ) + assert result == [product] + + def test_by_id_strict_exact_match(self) -> None: + """Strict (property_targeting_allowed=False): all product IDs must be in allowed set.""" + product = _make_product( + "p1", + [_make_pp_by_id(["home", "sports"])], + property_targeting_allowed=False, + ) + result = filter_products_by_property_list( + [product], allowed_property_ids={"home", "sports", "news"} + ) + assert result == [product] + + def test_by_id_strict_partial_match_excluded(self) -> None: + """Strict mode: partial subset → product excluded.""" + product = _make_product( + "p1", + [_make_pp_by_id(["home", "sports"])], + property_targeting_allowed=False, + ) + result = filter_products_by_property_list( + [product], allowed_property_ids={"home"} # missing "sports" + ) + assert result == [] + + def test_by_id_strict_full_subset_required(self) -> None: + """Strict: product with property_targeting_allowed=False requires all IDs in allowed.""" + product = _make_product( + "p1", + [_make_pp_by_id(["home"])], + property_targeting_allowed=False, + ) + assert filter_products_by_property_list([product], {"home", "sports"}) == [product] + assert filter_products_by_property_list([product], {"sports"}) == [] + + def test_by_id_permissive_any_intersection_sufficient(self) -> None: + """Permissive (property_targeting_allowed=True): any intersection suffices.""" + product = _make_product( + "p1", + [_make_pp_by_id(["home", "sports"])], + property_targeting_allowed=True, + ) + result = filter_products_by_property_list( + [product], allowed_property_ids={"sports"} + ) + assert result == [product] + + def test_by_id_permissive_no_intersection_excluded(self) -> None: + """Permissive: no intersection → excluded.""" + product = _make_product( + "p1", + [_make_pp_by_id(["home", "sports"])], + property_targeting_allowed=True, + ) + result = filter_products_by_property_list( + [product], allowed_property_ids={"news", "entertainment"} + ) + assert result == [] + + def test_by_tag_always_excluded(self) -> None: + """Products with only selection_type='by_tag' cannot be matched by ID → excluded.""" + product = _make_product("p1", [_make_pp_by_tag(["premium", "ctv"])]) + result = filter_products_by_property_list( + [product], allowed_property_ids={"premium", "ctv", "any_id"} + ) + assert result == [] + + def test_mixed_all_and_by_tag_included_via_all(self) -> None: + """If ANY publisher entry is 'all', product is included regardless of other entries.""" + product = _make_product( + "p1", + [_make_pp_by_tag(["ctv"]), _make_pp_all()], + ) + result = filter_products_by_property_list([product], allowed_property_ids=set()) + assert result == [product] + + def test_mixed_by_id_and_by_tag_respects_by_id(self) -> None: + """by_id entry can include product even when another entry is by_tag.""" + product = _make_product( + "p1", + [_make_pp_by_tag(["ctv"]), _make_pp_by_id(["home"])], + property_targeting_allowed=True, + ) + result = filter_products_by_property_list( + [product], allowed_property_ids={"home"} + ) + assert result == [product] + + def test_multiple_products_filtered_correctly(self) -> None: + """Filter applied correctly across a list of products.""" + p1 = _make_product("p1", [_make_pp_all()]) # always included + p2 = _make_product( + "p2", [_make_pp_by_id(["a", "b"])], property_targeting_allowed=False + ) # strict, "b" not in allowed → excluded + p3 = _make_product( + "p3", [_make_pp_by_id(["a"])], property_targeting_allowed=False + ) # strict, "a" in allowed → included + allowed = {"a"} + result = filter_products_by_property_list([p1, p2, p3], allowed) + assert result == [p1, p3] + + def test_empty_products_list(self) -> None: + result = filter_products_by_property_list([], {"any"}) + assert result == [] + + def test_empty_allowed_set_excludes_by_id(self) -> None: + """Empty allowed set: by_id products excluded (nothing to intersect with).""" + product = _make_product("p1", [_make_pp_by_id(["home"])]) + assert filter_products_by_property_list([product], set()) == [] + + def test_by_id_empty_property_ids_excluded_strict(self) -> None: + """by_id with an empty property_ids list is excluded in strict mode. + + An empty product_ids set is a mathematical subset of any set; the + ``if product_ids and …`` guard prevents this vacuous truth from + accidentally including the product. + """ + product = _make_product( + "p1", + [_make_pp_by_id([])], # no IDs — empty list + property_targeting_allowed=False, + ) + result = filter_products_by_property_list( + [product], allowed_property_ids={"home", "sports", "anything"} + ) + assert result == [] + + def test_by_id_empty_property_ids_excluded_permissive(self) -> None: + """by_id with an empty property_ids list is excluded in permissive mode.""" + product = _make_product( + "p1", + [_make_pp_by_id([])], + property_targeting_allowed=True, + ) + result = filter_products_by_property_list( + [product], allowed_property_ids={"home"} + ) + assert result == [] + + def test_property_targeting_allowed_none_treated_as_false(self) -> None: + """property_targeting_allowed=None is treated as False (strict).""" + product = _make_product( + "p1", + [_make_pp_by_id(["home", "sports"])], + property_targeting_allowed=None, + ) + # Strict: need all IDs in allowed + assert filter_products_by_property_list([product], {"home"}) == [] + assert filter_products_by_property_list([product], {"home", "sports"}) == [product] + + +# --------------------------------------------------------------------------- +# resolve_property_list +# --------------------------------------------------------------------------- + + +class TestResolvePropertyList: + @pytest.mark.asyncio + async def test_returns_set_from_fetcher(self) -> None: + fetcher = AsyncMock(spec=PropertyListFetcher) + fetcher.fetch = AsyncMock(return_value=["home", "sports", "news"]) + ref = _make_property_list_ref( + agent_url="https://agent.example.com", list_id="list_1" + ) + + result = await resolve_property_list(ref, fetcher=fetcher) + + assert result == {"home", "sports", "news"} + + @pytest.mark.asyncio + async def test_auth_token_threaded_to_fetcher(self) -> None: + """auth_token from the wire ref is threaded through to the fetcher.""" + fetcher = AsyncMock(spec=PropertyListFetcher) + fetcher.fetch = AsyncMock(return_value=["a"]) + ref = _make_property_list_ref(auth_token="jwt_token_xyz") + + await resolve_property_list(ref, fetcher=fetcher) + + fetcher.fetch.assert_called_once_with( + str(ref.agent_url), + ref.list_id, + auth_token="jwt_token_xyz", + ) + + @pytest.mark.asyncio + async def test_fetch_failure_raises_adcp_error_transient(self) -> None: + """Fetch failures are wrapped as AdcpError with recovery='transient'.""" + fetcher = AsyncMock(spec=PropertyListFetcher) + fetcher.fetch = AsyncMock(side_effect=RuntimeError("connection refused")) + ref = _make_property_list_ref(list_id="list_1") + + with pytest.raises(AdcpError) as exc_info: + await resolve_property_list(ref, fetcher=fetcher) + + err = exc_info.value + assert err.recovery == "transient" + # auth_token must NOT appear in error details + assert "auth_token" not in str(err.details) + assert "jwt" not in str(err.details).lower() + + @pytest.mark.asyncio + async def test_error_details_do_not_include_auth_token(self) -> None: + """Credential-shaped values are not leaked into error details.""" + fetcher = AsyncMock(spec=PropertyListFetcher) + fetcher.fetch = AsyncMock(side_effect=ValueError("403 Forbidden")) + ref = _make_property_list_ref(auth_token="secret_bearer_token") + + with pytest.raises(AdcpError) as exc_info: + await resolve_property_list(ref, fetcher=fetcher) + + err = exc_info.value + # details should include list_id and agent_url but NOT auth_token + assert err.details is not None + assert "list_id" in err.details + assert "agent_url" in err.details + assert "auth_token" not in err.details + assert "secret_bearer_token" not in str(err.details) + + +# --------------------------------------------------------------------------- +# validate_property_list_config +# --------------------------------------------------------------------------- + + +class TestValidatePropertyListConfig: + def test_no_capability_no_fetcher_ok(self) -> None: + """No capability declared → no validation needed.""" + validate_property_list_config(capability_enabled=False, fetcher=None) + + def test_capability_with_fetcher_ok(self) -> None: + """Capability declared + fetcher wired → valid.""" + fetcher = MagicMock(spec=PropertyListFetcher) + validate_property_list_config(capability_enabled=True, fetcher=fetcher) + + def test_capability_without_fetcher_raises(self) -> None: + """Capability declared but no fetcher → AdcpError(recovery='terminal').""" + with pytest.raises(AdcpError) as exc_info: + validate_property_list_config(capability_enabled=True, fetcher=None) + + err = exc_info.value + assert err.recovery == "terminal" + assert "property_list_filtering" in str(err) + assert "property_list_fetcher" in str(err) + + def test_no_capability_with_fetcher_ok(self) -> None: + """Fetcher provided but capability disabled → no error (defensive wiring).""" + fetcher = MagicMock(spec=PropertyListFetcher) + validate_property_list_config(capability_enabled=False, fetcher=fetcher) + + +# --------------------------------------------------------------------------- +# maybe_apply_property_list_filter +# --------------------------------------------------------------------------- + + +class TestMaybeApplyPropertyListFilter: + @pytest.mark.asyncio + async def test_no_op_when_capability_disabled(self) -> None: + """Gate is a no-op when capability_enabled=False.""" + params = MagicMock() + params.property_list = _make_property_list_ref() + response = _make_response([]) + + result = await maybe_apply_property_list_filter( + params=params, + response=response, + fetcher=None, + capability_enabled=False, + ) + assert result is response + + @pytest.mark.asyncio + async def test_no_op_when_property_list_absent(self) -> None: + """Gate is a no-op when params.property_list is None.""" + params = MagicMock() + params.property_list = None + response = _make_response([]) + + result = await maybe_apply_property_list_filter( + params=params, + response=response, + fetcher=MagicMock(), + capability_enabled=True, + ) + assert result is response + + @pytest.mark.asyncio + async def test_filter_applied_and_flag_set(self) -> None: + """When capability+property_list present: filter applied, property_list_applied=True.""" + p_pass = _make_product("pass", [_make_pp_all()]) + p_fail = _make_product("fail", [_make_pp_by_id(["x"])]) + response = _make_response([p_pass, p_fail]) + params = MagicMock() + params.property_list = _make_property_list_ref() + + fetcher = AsyncMock(spec=PropertyListFetcher) + fetcher.fetch = AsyncMock(return_value=["y"]) # "x" not in allowed → fail excluded + + result = await maybe_apply_property_list_filter( + params=params, + response=response, + fetcher=fetcher, + capability_enabled=True, + ) + + assert result.property_list_applied is True + assert result.products == [p_pass] + + @pytest.mark.asyncio + async def test_no_fetcher_returns_response_unmodified(self, caplog: Any) -> None: + """When fetcher is None despite capability, log warning + return unmodified.""" + import logging + + params = MagicMock() + params.property_list = _make_property_list_ref() + response = _make_response([]) + + with caplog.at_level(logging.WARNING, logger="adcp.decisioning.property_list"): + result = await maybe_apply_property_list_filter( + params=params, + response=response, + fetcher=None, + capability_enabled=True, + ) + + assert result is response + assert any("property_list_fetcher" in r.message for r in caplog.records) + + @pytest.mark.asyncio + async def test_fetch_failure_propagates_as_adcp_error(self) -> None: + """Fetch failure from resolve_property_list propagates to caller.""" + params = MagicMock() + params.property_list = _make_property_list_ref() + response = _make_response([]) + + fetcher = AsyncMock(spec=PropertyListFetcher) + fetcher.fetch = AsyncMock(side_effect=ConnectionError("timeout")) + + with pytest.raises(AdcpError) as exc_info: + await maybe_apply_property_list_filter( + params=params, + response=response, + fetcher=fetcher, + capability_enabled=True, + ) + + assert exc_info.value.recovery == "transient" + + @pytest.mark.asyncio + async def test_model_copy_used_not_in_place_mutation(self) -> None: + """Response is updated via model_copy, not in-place mutation.""" + p = _make_product("p1", [_make_pp_all()]) + original_response = _make_response([p]) + original_response.model_copy = MagicMock( + side_effect=original_response.model_copy + ) + params = MagicMock() + params.property_list = _make_property_list_ref() + + fetcher = AsyncMock(spec=PropertyListFetcher) + fetcher.fetch = AsyncMock(return_value=["a"]) + + await maybe_apply_property_list_filter( + params=params, + response=original_response, + fetcher=fetcher, + capability_enabled=True, + ) + + original_response.model_copy.assert_called_once() + call_kwargs = original_response.model_copy.call_args.kwargs + assert "update" in call_kwargs + assert call_kwargs["update"]["property_list_applied"] is True