diff --git a/src/adcp/__init__.py b/src/adcp/__init__.py index 2445170d..5859c7f5 100644 --- a/src/adcp/__init__.py +++ b/src/adcp/__init__.py @@ -294,6 +294,7 @@ CreateContentStandardsErrorResponse, CreateContentStandardsSuccessResponse, CreateMediaBuyErrorResponse, + CreateMediaBuySubmittedResponse, CreateMediaBuySuccessResponse, Deployment, Destination, @@ -890,6 +891,7 @@ def get_adcp_version() -> str: "CreateContentStandardsErrorResponse", "CreateMediaBuySuccessResponse", "CreateMediaBuyErrorResponse", + "CreateMediaBuySubmittedResponse", "Deployment", "Destination", "GetContentStandardsSuccessResponse", diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index 045a9fcb..1847bebc 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -98,7 +98,7 @@ CreateContentStandardsRequest, CreateContentStandardsResponse, CreateMediaBuyRequest, - CreateMediaBuySuccessResponse, + CreateMediaBuyResponse, CreatePropertyListRequest, CreatePropertyListResponse, DeleteCollectionListRequest, @@ -1305,7 +1305,7 @@ async def create_media_buy( # type: ignore[override] self, params: CreateMediaBuyRequest, context: ToolContext | None = None, - ) -> CreateMediaBuySuccessResponse: + ) -> CreateMediaBuyResponse: from adcp.decisioning.types import AdcpError tool_ctx = context or ToolContext() @@ -1404,7 +1404,7 @@ async def _release_reservation_hook(_exc: BaseException) -> None: on_failure=on_failure, ) self._maybe_auto_emit_sync_completion("create_media_buy", params, result) - return cast("CreateMediaBuySuccessResponse", result) + return cast("CreateMediaBuyResponse", result) async def update_media_buy( # type: ignore[override] self, diff --git a/src/adcp/types/__init__.py b/src/adcp/types/__init__.py index d1014212..58c54076 100644 --- a/src/adcp/types/__init__.py +++ b/src/adcp/types/__init__.py @@ -459,6 +459,7 @@ CreateContentStandardsErrorResponse, CreateContentStandardsSuccessResponse, CreateMediaBuyErrorResponse, + CreateMediaBuySubmittedResponse, CreateMediaBuySuccessResponse, CssFormatAsset, CssFormatGroupAsset, @@ -1118,6 +1119,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: "CreateContentStandardsErrorResponse", "CreateContentStandardsSuccessResponse", "CreateMediaBuyErrorResponse", + "CreateMediaBuySubmittedResponse", "CreateMediaBuySuccessResponse", "Deployment", "Destination", diff --git a/src/adcp/types/aliases.py b/src/adcp/types/aliases.py index 5a5949a1..5c97abf2 100644 --- a/src/adcp/types/aliases.py +++ b/src/adcp/types/aliases.py @@ -78,6 +78,7 @@ # Create media buy responses CreateMediaBuyResponse1, CreateMediaBuyResponse2, + CreateMediaBuyResponse3, # DAAST assets DaastAsset1, DaastAsset2, @@ -291,6 +292,20 @@ CreateMediaBuyErrorResponse = CreateMediaBuyResponse2 """Error response - media buy creation failed, no media buy created.""" +CreateMediaBuySubmittedResponse = CreateMediaBuyResponse3 +"""Submitted (async) envelope - operation accepted for asynchronous processing. + +Returned when the seller has handed the request off to a long-running task +(e.g., HITL approval, governance review). The buyer receives a ``task_id`` to +poll via ``tasks/get`` or to correlate with push-notification callbacks; the +``media_buy_id`` is issued later on the completion artifact, not here. + +Status discriminator: ``status == 'submitted'`` (task-level), distinguishing +this envelope from the synchronous success branch whose ``status`` field +carries a ``MediaBuyStatus`` value (``pending_creatives``, ``pending_start``, +``active``). +""" + # Performance Feedback Response Variants ProvidePerformanceFeedbackSuccessResponse = ProvidePerformanceFeedbackResponse1 """Success response - performance feedback accepted.""" @@ -1489,6 +1504,7 @@ def get_pricing(options: list[PricingOption]) -> None: # Create media buy responses "CreateMediaBuySuccessResponse", "CreateMediaBuyErrorResponse", + "CreateMediaBuySubmittedResponse", # Creative delivery requests "GetCreativeDeliveryByMediaBuyRequest", "GetCreativeDeliveryByBuyerRefRequest", diff --git a/src/adcp/types/guards.py b/src/adcp/types/guards.py index 8a908ea1..62c182d3 100644 --- a/src/adcp/types/guards.py +++ b/src/adcp/types/guards.py @@ -79,6 +79,7 @@ def is_adcp_success(response: Any) -> bool: CalibrateContentErrorResponse, CalibrateContentSuccessResponse, CreateMediaBuyErrorResponse, + CreateMediaBuySubmittedResponse, CreateMediaBuySuccessResponse, GetAccountFinancialsErrorResponse, GetAccountFinancialsSuccessResponse, @@ -103,7 +104,9 @@ def is_adcp_success(response: Any) -> bool: ) # Type aliases for response unions -CreateMediaBuyResponse = CreateMediaBuySuccessResponse | CreateMediaBuyErrorResponse +CreateMediaBuyResponse = ( + CreateMediaBuySuccessResponse | CreateMediaBuyErrorResponse | CreateMediaBuySubmittedResponse +) UpdateMediaBuyResponse = UpdateMediaBuySuccessResponse | UpdateMediaBuyErrorResponse ActivateSignalResponse = ActivateSignalSuccessResponse | ActivateSignalErrorResponse BuildCreativeResponse = BuildCreativeSuccessResponse | BuildCreativeErrorResponse @@ -116,22 +119,49 @@ def is_adcp_success(response: Any) -> bool: # --- Create Media Buy --- + +def is_create_media_buy_submitted( + response: CreateMediaBuyResponse, +) -> TypeGuard[CreateMediaBuySubmittedResponse]: + """Check if a CreateMediaBuyResponse is the async submitted envelope. + + The submitted branch carries ``status == 'submitted'`` and a ``task_id`` + the buyer uses to poll ``tasks/get`` (or correlate with push-notification + callbacks). It is neither a synchronous success nor a terminal error. + """ + return getattr(response, "status", None) == "submitted" and hasattr(response, "task_id") + + def is_create_media_buy_success( response: CreateMediaBuyResponse, ) -> TypeGuard[CreateMediaBuySuccessResponse]: - """Check if a CreateMediaBuyResponse is a success.""" + """Check if a CreateMediaBuyResponse is a synchronous success. + + Returns False for the submitted (async) envelope — use + ``is_create_media_buy_submitted`` for that branch. + """ + if is_create_media_buy_submitted(response): + return False return not is_adcp_error(response) def is_create_media_buy_error( response: CreateMediaBuyResponse, ) -> TypeGuard[CreateMediaBuyErrorResponse]: - """Check if a CreateMediaBuyResponse is an error.""" + """Check if a CreateMediaBuyResponse is an error. + + Returns False for the submitted (async) envelope, even if it carries + advisory (non-blocking) errors. Use ``is_create_media_buy_submitted`` + for that branch. + """ + if is_create_media_buy_submitted(response): + return False return is_adcp_error(response) # --- Update Media Buy --- + def is_update_media_buy_success( response: UpdateMediaBuyResponse, ) -> TypeGuard[UpdateMediaBuySuccessResponse]: @@ -148,6 +178,7 @@ def is_update_media_buy_error( # --- Activate Signal --- + def is_activate_signal_success( response: ActivateSignalResponse, ) -> TypeGuard[ActivateSignalSuccessResponse]: @@ -164,6 +195,7 @@ def is_activate_signal_error( # --- Build Creative --- + def is_build_creative_success( response: BuildCreativeResponse, ) -> TypeGuard[BuildCreativeSuccessResponse]: @@ -180,6 +212,7 @@ def is_build_creative_error( # --- Sync Creatives --- + def is_sync_creatives_success( response: SyncCreativesResponse, ) -> TypeGuard[SyncCreativesSuccessResponse]: @@ -196,6 +229,7 @@ def is_sync_creatives_error( # --- Performance Feedback --- + def is_performance_feedback_success( response: ProvidePerformanceFeedbackSuccessResponse | ProvidePerformanceFeedbackErrorResponse, ) -> TypeGuard[ProvidePerformanceFeedbackSuccessResponse]: @@ -212,6 +246,7 @@ def is_performance_feedback_error( # --- Sync Accounts --- + def is_sync_accounts_success( response: SyncAccountsResponse, ) -> TypeGuard[SyncAccountsSuccessResponse]: @@ -228,6 +263,7 @@ def is_sync_accounts_error( # --- Log Event --- + def is_log_event_success( response: LogEventResponse, ) -> TypeGuard[LogEventSuccessResponse]: @@ -244,6 +280,7 @@ def is_log_event_error( # --- Sync Catalogs --- + def is_sync_catalogs_success( response: SyncCatalogsResponse, ) -> TypeGuard[SyncCatalogsSuccessResponse]: @@ -260,6 +297,7 @@ def is_sync_catalogs_error( # --- Get Account Financials --- + def is_get_account_financials_success( response: GetAccountFinancialsSuccessResponse | GetAccountFinancialsErrorResponse, ) -> TypeGuard[GetAccountFinancialsSuccessResponse]: @@ -276,6 +314,7 @@ def is_get_account_financials_error( # --- Content Standards --- + def is_calibrate_content_success( response: CalibrateContentSuccessResponse | CalibrateContentErrorResponse, ) -> TypeGuard[CalibrateContentSuccessResponse]: @@ -292,6 +331,7 @@ def is_validate_content_delivery_success( # --- Creative Features --- + def is_get_creative_features_success( response: GetCreativeFeaturesSuccessResponse | GetCreativeFeaturesErrorResponse, ) -> TypeGuard[GetCreativeFeaturesSuccessResponse]: @@ -310,6 +350,7 @@ def is_get_creative_features_success( # Media buy guards "is_create_media_buy_success", "is_create_media_buy_error", + "is_create_media_buy_submitted", "is_update_media_buy_success", "is_update_media_buy_error", # Signal guards diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index bfeede81..dbacfcd5 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -94,6 +94,7 @@ "CreateMediaBuyErrorResponse", "CreateMediaBuyRequest", "CreateMediaBuyResponse", + "CreateMediaBuySubmittedResponse", "CreateMediaBuySuccessResponse", "Creative", "CreativeApproval", @@ -492,6 +493,7 @@ "CreateMediaBuyErrorResponse", "CreateMediaBuyRequest", "CreateMediaBuyResponse", + "CreateMediaBuySubmittedResponse", "CreateMediaBuySuccessResponse", "CreatePropertyListRequest", "CreatePropertyListResponse", diff --git a/tests/test_create_media_buy_response_types.py b/tests/test_create_media_buy_response_types.py new file mode 100644 index 00000000..4b81a5ce --- /dev/null +++ b/tests/test_create_media_buy_response_types.py @@ -0,0 +1,84 @@ +"""Tests for CreateMediaBuyResponse 3-branch discriminated union. + +The wire union has three legitimate shapes: + +1. ``CreateMediaBuySuccessResponse`` (sync success): ``media_buy_id`` + ``packages``. +2. ``CreateMediaBuyErrorResponse`` (sync error): ``errors[]``. +3. ``CreateMediaBuySubmittedResponse`` (async accepted): ``status='submitted'`` + + ``task_id`` — buyer polls via tasks/get; ``media_buy_id`` is issued later + on the completion artifact. + +These tests validate that the public alias surface covers all three branches +and that the submitted-branch payload validates against its alias. +""" + +from __future__ import annotations + +import typing + +import pytest + + +def test_submitted_alias_resolves_to_third_branch() -> None: + """The submitted alias must point at CreateMediaBuyResponse3 — the + branch with status='submitted' + task_id.""" + from adcp.types import CreateMediaBuySubmittedResponse + from adcp.types._generated import CreateMediaBuyResponse3 + + assert CreateMediaBuySubmittedResponse is CreateMediaBuyResponse3 + + +def test_submitted_payload_validates() -> None: + """A spec-compliant submitted payload validates via model_validate.""" + from adcp.types import CreateMediaBuySubmittedResponse + + payload = { + "status": "submitted", + "task_id": "task_abc123", + } + resp = CreateMediaBuySubmittedResponse.model_validate(payload) + assert resp.status == "submitted" + assert resp.task_id == "task_abc123" + + +def test_submitted_payload_with_optional_message_and_errors() -> None: + """Optional advisory fields on the submitted envelope are accepted.""" + from adcp.types import CreateMediaBuySubmittedResponse + + payload = { + "status": "submitted", + "task_id": "task_xyz", + "message": "Awaiting IO signature; typical turnaround 2-4 hours.", + } + resp = CreateMediaBuySubmittedResponse.model_validate(payload) + assert resp.message is not None + assert "IO signature" in resp.message + + +def test_submitted_payload_missing_task_id_rejected() -> None: + """task_id is required — a malformed submitted envelope must fail. + + The triage of issue #570 traced the original FastMCP error to a + submitted-branch payload missing task_id. The schema is correct; + this test pins that the missing-field validation works. + """ + from pydantic import ValidationError + + from adcp.types import CreateMediaBuySubmittedResponse + + with pytest.raises(ValidationError): + CreateMediaBuySubmittedResponse.model_validate({"status": "submitted"}) + + +def test_handler_create_media_buy_return_type_is_union() -> None: + """The PlatformHandler.create_media_buy annotation must be the + 3-branch union (CreateMediaBuyResponse), not the success branch + alone — adopters legitimately return any of the three shapes. + """ + from adcp.decisioning.handler import PlatformHandler + from adcp.types import CreateMediaBuyResponse + + # handler.py uses ``from __future__ import annotations`` (PEP 563), + # so signatures carry strings — resolve to runtime objects. + hints = typing.get_type_hints(PlatformHandler.create_media_buy) + assert hints["return"] == CreateMediaBuyResponse diff --git a/tests/test_type_guards.py b/tests/test_type_guards.py index 5558d56e..3b93acac 100644 --- a/tests/test_type_guards.py +++ b/tests/test_type_guards.py @@ -6,6 +6,7 @@ is_adcp_error, is_adcp_success, is_create_media_buy_error, + is_create_media_buy_submitted, is_create_media_buy_success, is_update_media_buy_error, is_update_media_buy_success, @@ -93,6 +94,34 @@ def test_create_media_buy_error_guard(self) -> None: ) assert is_create_media_buy_success(resp) is False assert is_create_media_buy_error(resp) is True + assert is_create_media_buy_submitted(resp) is False + + def test_create_media_buy_submitted_guard(self) -> None: + """Submitted (async) envelope is neither success nor error.""" + from adcp.types.aliases import ( + CreateMediaBuyErrorResponse, + CreateMediaBuySubmittedResponse, + CreateMediaBuySuccessResponse, + ) + + submitted = CreateMediaBuySubmittedResponse.model_validate( + {"status": "submitted", "task_id": "task_abc"} + ) + assert is_create_media_buy_submitted(submitted) is True + assert is_create_media_buy_success(submitted) is False + assert is_create_media_buy_error(submitted) is False + + # Negative cases: success and error payloads must not be classified + # as submitted. + success = CreateMediaBuySuccessResponse.model_validate( + {"media_buy_id": "mb_123", "packages": []} + ) + assert is_create_media_buy_submitted(success) is False + + error = CreateMediaBuyErrorResponse.model_validate( + {"errors": [{"message": "fail", "code": "INVALID_REQUEST"}]} + ) + assert is_create_media_buy_submitted(error) is False def test_update_media_buy_guards(self) -> None: from adcp.types.aliases import (