Skip to content

Commit 4324ef9

Browse files
committed
fix(handler): add UNSUPPORTED_FEATURE gate and validate_platform test for sync_catalogs
- Add sync_catalogs to _OPTIONAL_PLATFORM_METHODS (specialism-gated variant) - Call _require_platform_method("sync_catalogs") at shim entry so non-catalog-driven platforms surface UNSUPPORTED_FEATURE instead of INTERNAL_ERROR from the AttributeError wrapper - Remove discovery-mode paragraph from _project_sync_catalogs docstring (belongs in shim/Protocol docstrings, not the projector helper) - Add validate_platform boot-fail test for sales-catalog-driven missing sync_catalogs (pins the D12 boot-fail contract) - Add _project_sync_catalogs unit tests: Pydantic rows, plain dict rows, pre-shaped passthrough, non-list passthrough - Add UNSUPPORTED_FEATURE integration test: sync_catalogs on a sales-non-guaranteed handler raises UNSUPPORTED_FEATURE https://claude.ai/code/session_01JF8GYzpYWE4swrNUhVAbMg
1 parent ba72beb commit 4324ef9

3 files changed

Lines changed: 124 additions & 4 deletions

File tree

src/adcp/decisioning/handler.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,8 @@
376376
# AudiencePlatform adopter-internal helper (not wire-served, but
377377
# listed here for symmetry should a future shim wire it)
378378
"poll_audience_statuses",
379+
# Required for sales-catalog-driven; absent on all other sales-* platforms
380+
"sync_catalogs",
379381
}
380382
)
381383

@@ -810,10 +812,6 @@ def _project_sync_catalogs(result: Any) -> Any:
810812
fully-shaped :class:`SyncCatalogsSuccessResponse`. The wire envelope per
811813
``schemas/cache/media-buy/sync-catalogs-response.json`` is
812814
``{catalogs: [rows]}``. This helper wraps the list case.
813-
814-
Discovery mode (``req.catalogs is None``) returns existing synced catalogs
815-
per spec — the platform method must handle ``req.catalogs is None`` as a
816-
read-only path.
817815
"""
818816
if isinstance(result, list):
819817
return {
@@ -2427,6 +2425,7 @@ async def sync_catalogs( # type: ignore[override]
24272425
to the wire envelope ``{catalogs: [...]}`` so adopters can return
24282426
the ergonomic form.
24292427
"""
2428+
self._require_platform_method("sync_catalogs")
24302429
tool_ctx = context or ToolContext()
24312430
account = await self._resolve_account(getattr(params, "account", None), tool_ctx)
24322431
ctx = self._build_ctx(tool_ctx, account)

tests/test_decisioning_dispatch.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,40 @@ def update_media_buy(self, media_buy_id, patch, ctx):
127127
assert "get_media_buy_delivery" in missing_methods
128128

129129

130+
def test_validate_platform_raises_on_catalog_driven_without_sync_catalogs() -> None:
131+
"""Platform claims sales-catalog-driven but doesn't implement
132+
``sync_catalogs`` — hard-fails at server boot (D12 boot-fail rule).
133+
Docstring on SalesPlatform.sync_catalogs promises this; this test
134+
pins the behavior so a future refactor can't silently break it."""
135+
136+
class _CatalogDrivenNoMethod(DecisioningPlatform):
137+
capabilities = DecisioningCapabilities(specialisms=["sales-catalog-driven"])
138+
accounts = SingletonAccounts(account_id="hello")
139+
140+
def get_products(self, req, ctx):
141+
return {"products": []}
142+
143+
def create_media_buy(self, req, ctx):
144+
return {"media_buy_id": "mb_1"}
145+
146+
def update_media_buy(self, media_buy_id, patch, ctx):
147+
return {"media_buy_id": media_buy_id, "status": "active"}
148+
149+
def sync_creatives(self, req, ctx):
150+
return {"creatives": []}
151+
152+
def get_media_buy_delivery(self, req, ctx):
153+
return {"media_buy_deliveries": []}
154+
155+
# Deliberately no sync_catalogs.
156+
157+
with pytest.raises(AdcpError) as exc_info:
158+
validate_platform(_CatalogDrivenNoMethod())
159+
assert exc_info.value.code == "INVALID_REQUEST"
160+
missing_methods = {m["method"] for m in exc_info.value.details["missing"]}
161+
assert "sync_catalogs" in missing_methods
162+
163+
130164
def test_validate_platform_warns_on_novel_specialism() -> None:
131165
"""Truly novel specialism (no close spelling match to any known
132166
slug) emits UserWarning, NOT a raise. Forward-compat with v6.x+

tests/test_decisioning_handler_shims.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
PlatformHandler,
4141
_project_build_creative,
4242
_project_sync_audiences,
43+
_project_sync_catalogs,
4344
)
4445
from adcp.decisioning.webhook_emit import _BACKGROUND_WEBHOOK_TASKS
4546
from adcp.server.base import ToolContext
@@ -389,6 +390,92 @@ def sync_catalogs(self, req, ctx):
389390
}
390391

391392

393+
# ---- _project_sync_catalogs arms ----
394+
395+
396+
def test_project_sync_catalogs_wraps_pydantic_row_list() -> None:
397+
"""A list of Pydantic-like catalog-result rows wraps into ``{catalogs: [...]}``."""
398+
399+
class _Row:
400+
def __init__(self, cid: str) -> None:
401+
self.cid = cid
402+
403+
def model_dump(self, mode: str = "json") -> dict:
404+
return {"catalog_id": self.cid, "action": "created"}
405+
406+
projected = _project_sync_catalogs([_Row("c1"), _Row("c2")])
407+
assert projected == {
408+
"catalogs": [
409+
{"catalog_id": "c1", "action": "created"},
410+
{"catalog_id": "c2", "action": "created"},
411+
]
412+
}
413+
414+
415+
def test_project_sync_catalogs_wraps_plain_dict_row_list() -> None:
416+
"""List of plain dicts (no model_dump) — the row passthrough
417+
inside the comprehension is exercised."""
418+
projected = _project_sync_catalogs([{"catalog_id": "c1", "action": "updated"}])
419+
assert projected == {"catalogs": [{"catalog_id": "c1", "action": "updated"}]}
420+
421+
422+
def test_project_sync_catalogs_passthrough_envelope_dict() -> None:
423+
"""Already-shaped envelope is unchanged."""
424+
envelope = {"catalogs": [{"catalog_id": "c1"}]}
425+
assert _project_sync_catalogs(envelope) is envelope
426+
427+
428+
def test_project_sync_catalogs_passthrough_non_list() -> None:
429+
"""Non-list, non-Pydantic shape (e.g. SyncCatalogsSuccessResponse instance
430+
or unexpected sentinel) is returned unchanged so the wire validator
431+
surfaces a precise mis-shape error."""
432+
sentinel = "unexpected_string"
433+
assert _project_sync_catalogs(sentinel) == sentinel
434+
435+
436+
# ---- sync_catalogs UNSUPPORTED_FEATURE gate ----
437+
438+
439+
@pytest.mark.asyncio
440+
async def test_sync_catalogs_unsupported_when_platform_lacks_method(executor) -> None:
441+
"""A sales-non-guaranteed platform that doesn't implement ``sync_catalogs``
442+
surfaces ``UNSUPPORTED_FEATURE`` rather than ``INTERNAL_ERROR`` from the
443+
AttributeError wrapper in ``_invoke_platform_method``."""
444+
445+
class _NoSyncCatalogs(DecisioningPlatform):
446+
capabilities = DecisioningCapabilities(specialisms=["sales-non-guaranteed"])
447+
accounts = SingletonAccounts(account_id="hello")
448+
449+
def get_products(self, req, ctx):
450+
return {"products": []}
451+
452+
def create_media_buy(self, req, ctx):
453+
return {"media_buy_id": "mb_1"}
454+
455+
def update_media_buy(self, media_buy_id, patch, ctx):
456+
return {"media_buy_id": media_buy_id, "status": "active"}
457+
458+
def sync_creatives(self, req, ctx):
459+
return {"creatives": []}
460+
461+
def get_media_buy_delivery(self, req, ctx):
462+
return {"media_buy_deliveries": []}
463+
464+
# Deliberately no sync_catalogs.
465+
466+
handler = PlatformHandler(
467+
_NoSyncCatalogs(),
468+
executor=executor,
469+
registry=InMemoryTaskRegistry(),
470+
)
471+
from adcp.types import SyncCatalogsRequest
472+
473+
with pytest.raises(AdcpError) as exc_info:
474+
await handler.sync_catalogs(SyncCatalogsRequest.model_construct(), ToolContext())
475+
assert exc_info.value.code == "UNSUPPORTED_FEATURE"
476+
assert "sync_catalogs" in str(exc_info.value)
477+
478+
392479
@pytest.mark.asyncio
393480
async def test_check_governance_shim_routes_to_platform(executor) -> None:
394481
class _GovernanceAgent(DecisioningPlatform):

0 commit comments

Comments
 (0)