diff --git a/src/ad_buyer/clients/__init__.py b/src/ad_buyer/clients/__init__.py index a14ab22a..3b5fdb84 100644 --- a/src/ad_buyer/clients/__init__.py +++ b/src/ad_buyer/clients/__init__.py @@ -8,6 +8,7 @@ from .mcp_client import IABMCPClient, MCPToolResult, MCPClientError from .unified_client import UnifiedClient, UnifiedResult, Protocol from .ucp_client import UCPClient, UCPExchangeResult +from .deals_client import DealsClient, DealsClientError __all__ = [ @@ -28,4 +29,7 @@ # UCP client for audience exchange "UCPClient", "UCPExchangeResult", + # IAB Deals API v1.0 client (quote-then-book flow) + "DealsClient", + "DealsClientError", ] diff --git a/src/ad_buyer/clients/deals_client.py b/src/ad_buyer/clients/deals_client.py new file mode 100644 index 00000000..68d9c878 --- /dev/null +++ b/src/ad_buyer/clients/deals_client.py @@ -0,0 +1,415 @@ +# Author: Green Mountain Systems AI Inc. +# Donated to IAB Tech Lab + +"""IAB Deals API v1.0 Client. + +Async HTTP client for the seller's quote-then-book deal endpoints: + +- POST /api/v1/quotes -- request a non-binding price quote +- GET /api/v1/quotes/{id} -- retrieve a quote +- POST /api/v1/deals -- book a deal from a quote +- GET /api/v1/deals/{id} -- retrieve a deal + +Follows the API contract defined in docs/api/deal-creation-api-contract.md. +Uses httpx for async HTTP, with auth header injection, configurable +timeouts, and retry logic for transient server failures (502/503/504). +Optionally persists results to a DealStore when one is attached. +""" + +import json +import logging +from typing import Any, Optional + +import httpx + +from ..models.deals import ( + DealBookingRequest, + DealResponse, + QuoteRequest, + QuoteResponse, + SellerErrorResponse, +) + +logger = logging.getLogger(__name__) + +# HTTP status codes that indicate transient failures worth retrying +_RETRYABLE_STATUS_CODES = {502, 503, 504} + +# Default configuration +_DEFAULT_TIMEOUT = 30.0 +_DEFAULT_MAX_RETRIES = 3 + + +class DealsClientError(Exception): + """Error raised by the DealsClient for API or transport failures. + + Attributes: + status_code: HTTP status code (0 for transport errors like timeout). + error_code: Machine-readable error code from the seller, if available. + detail: Human-readable detail message. + """ + + def __init__( + self, + message: str, + status_code: int = 0, + error_code: str = "", + detail: str = "", + ) -> None: + super().__init__(message) + self.status_code = status_code + self.error_code = error_code + self.detail = detail + + +class DealsClient: + """Async client for the IAB Deals API v1.0 (quote-then-book flow). + + Args: + seller_url: Base URL of the seller system (e.g. ``http://seller.example.com``). + api_key: Optional API key sent via ``X-Api-Key`` header. + bearer_token: Optional bearer token sent via ``Authorization`` header. + timeout: Request timeout in seconds. + max_retries: Maximum retries for transient failures (502/503/504). + deal_store: Optional DealStore for persisting quotes and deals. + """ + + def __init__( + self, + seller_url: str, + *, + api_key: Optional[str] = None, + bearer_token: Optional[str] = None, + timeout: float = _DEFAULT_TIMEOUT, + max_retries: int = _DEFAULT_MAX_RETRIES, + deal_store: Any = None, + ) -> None: + self.seller_url = seller_url.rstrip("/") + self._api_key = api_key + self._bearer_token = bearer_token + self._timeout = timeout + self._max_retries = max_retries + self.deal_store = deal_store + + # Build default headers + headers: dict[str, str] = { + "Content-Type": "application/json", + "Accept": "application/json", + } + if api_key: + headers["X-Api-Key"] = api_key + elif bearer_token: + headers["Authorization"] = f"Bearer {bearer_token}" + + self._client = httpx.AsyncClient( + base_url=self.seller_url, + headers=headers, + timeout=timeout, + ) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + async def request_quote(self, quote_request: QuoteRequest) -> QuoteResponse: + """Request a non-binding price quote from the seller. + + POST /api/v1/quotes + + Args: + quote_request: Quote request parameters. + + Returns: + QuoteResponse from the seller. + + Raises: + DealsClientError: On HTTP or transport errors. + """ + body = quote_request.model_dump(exclude_none=True) + response = await self._request_with_retry("POST", "/api/v1/quotes", json=body) + data = response.json() + result = QuoteResponse.model_validate(data) + + # Persist to DealStore if available + self._persist_quote(result, quote_request) + + return result + + async def get_quote(self, quote_id: str) -> QuoteResponse: + """Retrieve a previously issued quote. + + GET /api/v1/quotes/{quote_id} + + Args: + quote_id: The quote identifier. + + Returns: + QuoteResponse reflecting current state. + + Raises: + DealsClientError: On HTTP or transport errors. + """ + response = await self._request_with_retry("GET", f"/api/v1/quotes/{quote_id}") + data = response.json() + return QuoteResponse.model_validate(data) + + async def book_deal(self, booking_request: DealBookingRequest) -> DealResponse: + """Book a deal from an existing quote. + + POST /api/v1/deals + + Args: + booking_request: Deal booking parameters including the quote_id. + + Returns: + DealResponse with the seller-issued Deal ID. + + Raises: + DealsClientError: On HTTP or transport errors. + """ + body = booking_request.model_dump(exclude_none=True) + response = await self._request_with_retry("POST", "/api/v1/deals", json=body) + data = response.json() + result = DealResponse.model_validate(data) + + # Persist to DealStore if available + self._persist_deal(result) + + return result + + async def get_deal(self, deal_id: str) -> DealResponse: + """Retrieve the current state of a deal. + + GET /api/v1/deals/{deal_id} + + Args: + deal_id: The deal identifier. + + Returns: + DealResponse reflecting current status. + + Raises: + DealsClientError: On HTTP or transport errors. + """ + response = await self._request_with_retry("GET", f"/api/v1/deals/{deal_id}") + data = response.json() + result = DealResponse.model_validate(data) + + # Update stored status if DealStore is available + self._update_stored_deal_status(result) + + return result + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def close(self) -> None: + """Close the underlying HTTP client.""" + await self._client.aclose() + + async def __aenter__(self) -> "DealsClient": + """Async context manager entry.""" + return self + + async def __aexit__(self, *args: Any) -> None: + """Async context manager exit.""" + await self.close() + + # ------------------------------------------------------------------ + # Internal: HTTP with retry + # ------------------------------------------------------------------ + + async def _request_with_retry( + self, + method: str, + path: str, + **kwargs: Any, + ) -> httpx.Response: + """Send an HTTP request with retry logic for transient failures. + + Retries on 502, 503, 504 status codes up to ``_max_retries`` times. + Client errors (4xx) are NOT retried. + + Args: + method: HTTP method (GET, POST, etc.). + path: URL path relative to seller_url. + **kwargs: Additional arguments passed to httpx (json, params, etc.). + + Returns: + The successful httpx.Response. + + Raises: + DealsClientError: On non-retryable errors or when retries are exhausted. + """ + last_error: Optional[DealsClientError] = None + + for attempt in range(1, self._max_retries + 1): + try: + response = await self._client.request(method, path, **kwargs) + except httpx.TimeoutException as exc: + last_error = DealsClientError( + f"Request timeout after {self._timeout}s: {exc}", + status_code=0, + error_code="timeout", + ) + if attempt < self._max_retries: + logger.warning( + "Timeout on attempt %d/%d for %s %s", + attempt, self._max_retries, method, path, + ) + continue + raise last_error from exc + except httpx.ConnectError as exc: + raise DealsClientError( + f"Connection error: {exc}", + status_code=0, + error_code="connect_error", + ) from exc + except httpx.HTTPError as exc: + raise DealsClientError( + f"HTTP error: {exc}", + status_code=0, + error_code="http_error", + ) from exc + + # Success + if response.is_success: + return response + + # Retryable server error + if response.status_code in _RETRYABLE_STATUS_CODES: + last_error = self._build_error_from_response(response) + if attempt < self._max_retries: + logger.warning( + "Retryable error %d on attempt %d/%d for %s %s", + response.status_code, attempt, self._max_retries, method, path, + ) + continue + raise last_error + + # Non-retryable error (4xx or other 5xx) + raise self._build_error_from_response(response) + + # Should not reach here, but just in case + if last_error: + raise last_error + raise DealsClientError("Unexpected retry loop exit", status_code=0) + + @staticmethod + def _build_error_from_response(response: httpx.Response) -> DealsClientError: + """Extract error details from an HTTP error response. + + Tries to parse the seller's structured error JSON. Falls back + to the raw response text if parsing fails. + """ + error_code = "" + detail = "" + try: + data = response.json() + error_code = data.get("error", "") + detail = data.get("detail", "") + except (json.JSONDecodeError, ValueError): + detail = response.text[:500] if response.text else "" + + message = f"Seller API error {response.status_code}" + if error_code: + message += f": {error_code}" + if detail: + message += f" - {detail}" + + return DealsClientError( + message=message, + status_code=response.status_code, + error_code=error_code, + detail=detail, + ) + + # ------------------------------------------------------------------ + # Internal: DealStore persistence + # ------------------------------------------------------------------ + + def _persist_quote(self, quote: QuoteResponse, request: QuoteRequest) -> None: + """Save a quote to the DealStore as a deal record with status 'quoted'. + + Non-fatal: logs errors but does not re-raise. + """ + if self.deal_store is None: + return + try: + self.deal_store.save_deal( + seller_url=self.seller_url, + product_id=quote.product.product_id, + product_name=quote.product.name, + deal_type=request.deal_type, + status="quoted", + price=quote.pricing.final_cpm, + original_price=quote.pricing.base_cpm, + impressions=quote.terms.impressions, + flight_start=quote.terms.flight_start, + flight_end=quote.terms.flight_end, + metadata=json.dumps({ + "quote_id": quote.quote_id, + "buyer_tier": quote.buyer_tier, + "expires_at": quote.expires_at, + }), + ) + except Exception: + logger.exception("Failed to persist quote %s to DealStore", quote.quote_id) + + def _persist_deal(self, deal: DealResponse) -> None: + """Save a booked deal to the DealStore with status 'booked'. + + Non-fatal: logs errors but does not re-raise. + """ + if self.deal_store is None: + return + try: + self.deal_store.save_deal( + seller_url=self.seller_url, + seller_deal_id=deal.deal_id, + product_id=deal.product.product_id, + product_name=deal.product.name, + deal_type=deal.deal_type, + status="booked", + price=deal.pricing.final_cpm, + original_price=deal.pricing.base_cpm, + impressions=deal.terms.impressions, + flight_start=deal.terms.flight_start, + flight_end=deal.terms.flight_end, + metadata=json.dumps({ + "quote_id": deal.quote_id, + "buyer_tier": deal.buyer_tier, + "expires_at": deal.expires_at, + "activation_instructions": deal.activation_instructions, + "openrtb_params": ( + deal.openrtb_params.model_dump() if deal.openrtb_params else None + ), + }), + ) + except Exception: + logger.exception("Failed to persist deal %s to DealStore", deal.deal_id) + + def _update_stored_deal_status(self, deal: DealResponse) -> None: + """Update the status of a stored deal after a GET /deals/{id} call. + + Non-fatal: logs errors but does not re-raise. + """ + if self.deal_store is None: + return + try: + # Find by seller_deal_id and update status + existing_deals = self.deal_store.list_deals(seller_url=self.seller_url) + for stored in existing_deals: + if stored.get("seller_deal_id") == deal.deal_id: + self.deal_store.update_deal_status( + stored["id"], + deal.status, + triggered_by="deals_client", + notes=f"Updated from GET /api/v1/deals/{deal.deal_id}", + ) + break + except Exception: + logger.exception( + "Failed to update stored deal status for %s", deal.deal_id + ) diff --git a/src/ad_buyer/models/__init__.py b/src/ad_buyer/models/__init__.py index f33f9f1e..b5e80dfa 100644 --- a/src/ad_buyer/models/__init__.py +++ b/src/ad_buyer/models/__init__.py @@ -36,6 +36,21 @@ UCPEmbedding, UCPModelDescriptor, ) +from .deals import ( + AvailabilityInfo, + BuyerIdentityPayload, + DealBookingRequest, + OpenRTBParams, + PricingInfo, + ProductInfo, + QuoteRequest, + QuoteResponse, + SellerErrorResponse, + TermsInfo, +) +# Avoid shadowing buyer_identity.DealResponse with deals.DealResponse +# by importing the deals version under a distinct name +from .deals import DealResponse as SellerDealResponse __all__ = [ # OpenDirect models @@ -69,4 +84,16 @@ "UCPContextDescriptor", "UCPEmbedding", "UCPModelDescriptor", + # Deals API v1.0 models (quote-then-book) + "AvailabilityInfo", + "BuyerIdentityPayload", + "DealBookingRequest", + "OpenRTBParams", + "PricingInfo", + "ProductInfo", + "QuoteRequest", + "QuoteResponse", + "SellerDealResponse", + "SellerErrorResponse", + "TermsInfo", ] diff --git a/src/ad_buyer/models/deals.py b/src/ad_buyer/models/deals.py new file mode 100644 index 00000000..68279779 --- /dev/null +++ b/src/ad_buyer/models/deals.py @@ -0,0 +1,167 @@ +# Author: Green Mountain Systems AI Inc. +# Donated to IAB Tech Lab + +"""Pydantic models for the IAB Deals API v1.0 (quote-then-book flow). + +These models match the seller's API contract defined in +docs/api/deal-creation-api-contract.md. They represent the buyer-side +view of quotes and deals returned by the seller's /api/v1/quotes and +/api/v1/deals endpoints. +""" + +from typing import Any, Optional + +from pydantic import BaseModel, Field + + +# --------------------------------------------------------------------------- +# Shared sub-models (nested objects in API responses) +# --------------------------------------------------------------------------- + + +class BuyerIdentityPayload(BaseModel): + """Buyer identity included in quote/deal requests. + + Maps to the ``buyer_identity`` object in the API contract. + """ + + seat_id: Optional[str] = None + agency_id: Optional[str] = None + advertiser_id: Optional[str] = None + dsp_platform: Optional[str] = None + + +class ProductInfo(BaseModel): + """Product summary embedded in quote/deal responses.""" + + product_id: str + name: str + inventory_type: Optional[str] = None + + +class PricingInfo(BaseModel): + """Pricing breakdown returned by the seller.""" + + base_cpm: float + tier_discount_pct: float = 0.0 + volume_discount_pct: float = 0.0 + final_cpm: float + currency: str = "USD" + pricing_model: str = "cpm" + rationale: str = "" + + +class TermsInfo(BaseModel): + """Deal/quote terms (volume, flight dates, guarantee).""" + + impressions: Optional[int] = None + flight_start: Optional[str] = None + flight_end: Optional[str] = None + guaranteed: bool = False + + +class AvailabilityInfo(BaseModel): + """Inventory availability information in a quote.""" + + inventory_available: bool = True + estimated_fill_rate: Optional[float] = None + competing_demand: Optional[str] = None + + +class OpenRTBParams(BaseModel): + """OpenRTB deal parameters for DSP activation.""" + + id: str + bidfloor: float + bidfloorcur: str = "USD" + at: int = 3 + wseat: list[str] = Field(default_factory=list) + wadomain: list[str] = Field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Request models (buyer -> seller) +# --------------------------------------------------------------------------- + + +class QuoteRequest(BaseModel): + """Request body for POST /api/v1/quotes. + + Buyer requests non-binding pricing from a seller. + """ + + product_id: str + deal_type: str = "PD" + impressions: Optional[int] = None + flight_start: Optional[str] = None + flight_end: Optional[str] = None + target_cpm: Optional[float] = None + buyer_identity: Optional[BuyerIdentityPayload] = None + agent_url: Optional[str] = None + + +class DealBookingRequest(BaseModel): + """Request body for POST /api/v1/deals. + + Buyer books a deal from an existing quote. + """ + + quote_id: str + buyer_identity: Optional[BuyerIdentityPayload] = None + notes: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Response models (seller -> buyer) +# --------------------------------------------------------------------------- + + +class QuoteResponse(BaseModel): + """Response from GET/POST /api/v1/quotes. + + Represents a non-binding price quote from the seller. + """ + + quote_id: str + status: str # available, expired, declined, booked + product: ProductInfo + pricing: PricingInfo + terms: TermsInfo + availability: Optional[AvailabilityInfo] = None + buyer_tier: str = "public" + expires_at: Optional[str] = None + seller_id: Optional[str] = None + created_at: Optional[str] = None + + +class DealResponse(BaseModel): + """Response from GET/POST /api/v1/deals. + + Represents a confirmed deal with a seller-issued Deal ID. + """ + + deal_id: str + deal_type: str + status: str # proposed, active, rejected, expired, completed + quote_id: Optional[str] = None + product: ProductInfo + pricing: PricingInfo + terms: TermsInfo + buyer_tier: str = "public" + expires_at: Optional[str] = None + activation_instructions: dict[str, str] = Field(default_factory=dict) + openrtb_params: Optional[OpenRTBParams] = None + created_at: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Error model +# --------------------------------------------------------------------------- + + +class SellerErrorResponse(BaseModel): + """Structured error returned by the seller API.""" + + error: str + detail: str = "" + status_code: int = 0 diff --git a/tests/unit/test_deals_client.py b/tests/unit/test_deals_client.py new file mode 100644 index 00000000..95e81cf5 --- /dev/null +++ b/tests/unit/test_deals_client.py @@ -0,0 +1,930 @@ +# Author: Green Mountain Systems AI Inc. +# Donated to IAB Tech Lab + +"""Tests for the IAB Deals API v1.0 Client. + +Covers all 4 client methods (request_quote, get_quote, book_deal, get_deal), +auth header injection, timeout/retry behavior, response model parsing, +DealStore integration, and error cases. +""" + +import json +from unittest.mock import MagicMock + +import httpx +import pytest + +from ad_buyer.clients.deals_client import DealsClient, DealsClientError +from ad_buyer.models.deals import ( + BuyerIdentityPayload, + DealBookingRequest, + DealResponse, + QuoteRequest, + QuoteResponse, + SellerErrorResponse, +) + + +# --------------------------------------------------------------------------- +# Fixtures and helpers +# --------------------------------------------------------------------------- + +SELLER_URL = "http://seller.example.com" + + +def _quote_response_json() -> dict: + """Minimal valid QuoteResponse JSON matching the API contract.""" + return { + "quote_id": "qt-abc123", + "status": "available", + "product": { + "product_id": "ctv-premium-sports", + "name": "Premium CTV - Sports", + "inventory_type": "ctv", + }, + "pricing": { + "base_cpm": 35.00, + "tier_discount_pct": 15.0, + "volume_discount_pct": 5.0, + "final_cpm": 28.26, + "currency": "USD", + "pricing_model": "cpm", + "rationale": "Base $35 | -15% tier | -5% volume => $28.26", + }, + "terms": { + "impressions": 5000000, + "flight_start": "2026-04-01", + "flight_end": "2026-04-30", + "guaranteed": False, + }, + "availability": { + "inventory_available": True, + "estimated_fill_rate": 0.92, + "competing_demand": "moderate", + }, + "buyer_tier": "advertiser", + "expires_at": "2026-03-09T14:30:00Z", + "seller_id": "seller-premium-pub-001", + "created_at": "2026-03-08T14:30:00Z", + } + + +def _deal_response_json() -> dict: + """Minimal valid DealResponse JSON matching the API contract.""" + return { + "deal_id": "DEMO-A1B2C3D4E5F6", + "deal_type": "PD", + "status": "proposed", + "quote_id": "qt-abc123", + "product": { + "product_id": "ctv-premium-sports", + "name": "Premium CTV - Sports", + "inventory_type": "ctv", + }, + "pricing": { + "base_cpm": 35.00, + "tier_discount_pct": 15.0, + "volume_discount_pct": 5.0, + "final_cpm": 28.26, + "currency": "USD", + "pricing_model": "cpm", + "rationale": "Base $35 | -15% tier | -5% volume => $28.26", + }, + "terms": { + "impressions": 5000000, + "flight_start": "2026-04-01", + "flight_end": "2026-04-30", + "guaranteed": False, + }, + "buyer_tier": "advertiser", + "expires_at": "2026-04-08T00:00:00Z", + "activation_instructions": { + "ttd": "The Trade Desk > Inventory > PMP > Add Deal ID: DEMO-A1B2C3D4E5F6", + "dv360": "DV360 > Inventory > My Inventory > Deal ID: DEMO-A1B2C3D4E5F6", + }, + "openrtb_params": { + "id": "DEMO-A1B2C3D4E5F6", + "bidfloor": 28.26, + "bidfloorcur": "USD", + "at": 3, + "wseat": [], + "wadomain": [], + }, + "created_at": "2026-03-08T14:30:00Z", + } + + +class _RequestCapture: + """Helper to capture requests sent through a mock transport.""" + + def __init__(self): + self.requests: list[httpx.Request] = [] + + def capture(self, request: httpx.Request) -> None: + self.requests.append(request) + + @property + def last(self) -> httpx.Request: + return self.requests[-1] + + +def _make_client_with_transport( + handler, + *, + api_key: str | None = None, + bearer_token: str | None = None, + deal_store=None, +) -> DealsClient: + """Create a DealsClient backed by an httpx.MockTransport. + + The ``handler`` receives an ``httpx.Request`` and must return an + ``httpx.Response``. This is the idiomatic httpx testing pattern. + """ + c = DealsClient( + seller_url=SELLER_URL, + api_key=api_key, + bearer_token=bearer_token, + timeout=5.0, + deal_store=deal_store, + ) + # Replace the internal client with one using the mock transport + transport = httpx.MockTransport(handler) + c._client = httpx.AsyncClient( + transport=transport, + base_url=SELLER_URL, + headers=dict(c._client.headers), + timeout=5.0, + ) + return c + + +def _json_response(status_code: int, body: dict) -> httpx.Response: + """Build an httpx.Response with JSON content.""" + return httpx.Response( + status_code=status_code, + json=body, + ) + + +@pytest.fixture +def client(): + """Create a DealsClient with a no-op transport (for init tests).""" + return DealsClient(seller_url=SELLER_URL, timeout=5.0) + + +# --------------------------------------------------------------------------- +# Model parsing tests +# --------------------------------------------------------------------------- + + +class TestModelParsing: + """Test that response JSON is parsed into correct Pydantic models.""" + + def test_quote_response_parsing(self): + """QuoteResponse parses all fields from JSON.""" + data = _quote_response_json() + resp = QuoteResponse.model_validate(data) + assert resp.quote_id == "qt-abc123" + assert resp.status == "available" + assert resp.product.product_id == "ctv-premium-sports" + assert resp.pricing.final_cpm == 28.26 + assert resp.terms.impressions == 5000000 + assert resp.availability is not None + assert resp.availability.estimated_fill_rate == 0.92 + assert resp.buyer_tier == "advertiser" + + def test_deal_response_parsing(self): + """DealResponse parses all fields from JSON.""" + data = _deal_response_json() + resp = DealResponse.model_validate(data) + assert resp.deal_id == "DEMO-A1B2C3D4E5F6" + assert resp.deal_type == "PD" + assert resp.status == "proposed" + assert resp.quote_id == "qt-abc123" + assert resp.product.name == "Premium CTV - Sports" + assert resp.pricing.base_cpm == 35.00 + assert resp.openrtb_params is not None + assert resp.openrtb_params.bidfloor == 28.26 + assert "ttd" in resp.activation_instructions + + def test_seller_error_parsing(self): + """SellerErrorResponse parses error JSON.""" + data = { + "error": "quote_expired", + "detail": "Quote expired at 2026-03-09T14:30:00Z", + "status_code": 410, + } + err = SellerErrorResponse.model_validate(data) + assert err.error == "quote_expired" + assert err.status_code == 410 + + +# --------------------------------------------------------------------------- +# Client initialization +# --------------------------------------------------------------------------- + + +class TestClientInit: + """Test client construction and configuration.""" + + def test_default_init(self): + """Client initializes with seller URL.""" + c = DealsClient(seller_url=SELLER_URL) + assert c.seller_url == SELLER_URL + + def test_trailing_slash_stripped(self): + """Trailing slash on seller URL is stripped.""" + c = DealsClient(seller_url=SELLER_URL + "/") + assert c.seller_url == SELLER_URL + + def test_custom_timeout(self): + """Custom timeout is stored.""" + c = DealsClient(seller_url=SELLER_URL, timeout=60.0) + assert c._timeout == 60.0 + + def test_api_key_stored(self): + """API key is stored for auth headers.""" + c = DealsClient(seller_url=SELLER_URL, api_key="secret-key") + assert c._api_key == "secret-key" + + +# --------------------------------------------------------------------------- +# Auth header injection +# --------------------------------------------------------------------------- + + +class TestAuthHeaders: + """Test that auth headers are injected correctly.""" + + @pytest.mark.asyncio + async def test_api_key_header_injected(self): + """X-Api-Key header is added when api_key is set.""" + capture = _RequestCapture() + + def handler(request: httpx.Request) -> httpx.Response: + capture.capture(request) + return _json_response(200, _quote_response_json()) + + c = _make_client_with_transport(handler, api_key="my-key") + quote_req = QuoteRequest(product_id="test-product", deal_type="PD") + await c.request_quote(quote_req) + + assert capture.last.headers.get("x-api-key") == "my-key" + await c.close() + + @pytest.mark.asyncio + async def test_bearer_token_header_injected(self): + """Authorization: Bearer header is added when bearer_token is set.""" + capture = _RequestCapture() + + def handler(request: httpx.Request) -> httpx.Response: + capture.capture(request) + return _json_response(200, _quote_response_json()) + + c = _make_client_with_transport(handler, bearer_token="my-token") + quote_req = QuoteRequest(product_id="test-product", deal_type="PD") + await c.request_quote(quote_req) + + assert capture.last.headers.get("authorization") == "Bearer my-token" + await c.close() + + @pytest.mark.asyncio + async def test_no_auth_when_no_key(self): + """No auth headers when neither api_key nor bearer_token is set.""" + capture = _RequestCapture() + + def handler(request: httpx.Request) -> httpx.Response: + capture.capture(request) + return _json_response(200, _quote_response_json()) + + c = _make_client_with_transport(handler) + quote_req = QuoteRequest(product_id="test-product", deal_type="PD") + await c.request_quote(quote_req) + + assert "x-api-key" not in capture.last.headers + assert "authorization" not in capture.last.headers + await c.close() + + +# --------------------------------------------------------------------------- +# request_quote (POST /api/v1/quotes) +# --------------------------------------------------------------------------- + + +class TestRequestQuote: + """Test the request_quote method.""" + + @pytest.mark.asyncio + async def test_request_quote_success(self): + """Successful quote request returns QuoteResponse.""" + def handler(request): + return _json_response(200, _quote_response_json()) + + c = _make_client_with_transport(handler) + quote_req = QuoteRequest( + product_id="ctv-premium-sports", + deal_type="PD", + impressions=5000000, + buyer_identity=BuyerIdentityPayload( + seat_id="seat-ttd-12345", + agency_id="agency-groupm-001", + ), + ) + result = await c.request_quote(quote_req) + + assert isinstance(result, QuoteResponse) + assert result.quote_id == "qt-abc123" + assert result.pricing.final_cpm == 28.26 + await c.close() + + @pytest.mark.asyncio + async def test_request_quote_posts_to_correct_url(self): + """POST is sent to /api/v1/quotes.""" + capture = _RequestCapture() + + def handler(request): + capture.capture(request) + return _json_response(200, _quote_response_json()) + + c = _make_client_with_transport(handler) + quote_req = QuoteRequest(product_id="test", deal_type="PD") + await c.request_quote(quote_req) + + assert capture.last.method == "POST" + assert str(capture.last.url).endswith("/api/v1/quotes") + await c.close() + + @pytest.mark.asyncio + async def test_request_quote_sends_body(self): + """Request body contains quote request fields.""" + capture = _RequestCapture() + + def handler(request): + capture.capture(request) + return _json_response(200, _quote_response_json()) + + c = _make_client_with_transport(handler) + quote_req = QuoteRequest( + product_id="ctv-premium-sports", + deal_type="PG", + impressions=1000000, + target_cpm=28.00, + ) + await c.request_quote(quote_req) + + body = json.loads(capture.last.content) + assert body["product_id"] == "ctv-premium-sports" + assert body["deal_type"] == "PG" + assert body["impressions"] == 1000000 + assert body["target_cpm"] == 28.00 + await c.close() + + @pytest.mark.asyncio + async def test_request_quote_404_product_not_found(self): + """404 from seller raises DealsClientError.""" + error_json = { + "error": "product_not_found", + "detail": "Product 'bad-id' does not exist", + "status_code": 404, + } + + def handler(request): + return _json_response(404, error_json) + + c = _make_client_with_transport(handler) + quote_req = QuoteRequest(product_id="bad-id", deal_type="PD") + with pytest.raises(DealsClientError) as exc_info: + await c.request_quote(quote_req) + + assert exc_info.value.status_code == 404 + assert "product_not_found" in exc_info.value.error_code + await c.close() + + @pytest.mark.asyncio + async def test_request_quote_400_invalid_deal_type(self): + """400 from seller raises DealsClientError.""" + error_json = { + "error": "invalid_deal_type", + "detail": "Deal type not supported", + "status_code": 400, + } + + def handler(request): + return _json_response(400, error_json) + + c = _make_client_with_transport(handler) + quote_req = QuoteRequest(product_id="test", deal_type="INVALID") + with pytest.raises(DealsClientError) as exc_info: + await c.request_quote(quote_req) + + assert exc_info.value.status_code == 400 + await c.close() + + +# --------------------------------------------------------------------------- +# get_quote (GET /api/v1/quotes/{quote_id}) +# --------------------------------------------------------------------------- + + +class TestGetQuote: + """Test the get_quote method.""" + + @pytest.mark.asyncio + async def test_get_quote_success(self): + """Successful quote retrieval returns QuoteResponse.""" + def handler(request): + return _json_response(200, _quote_response_json()) + + c = _make_client_with_transport(handler) + result = await c.get_quote("qt-abc123") + + assert isinstance(result, QuoteResponse) + assert result.quote_id == "qt-abc123" + await c.close() + + @pytest.mark.asyncio + async def test_get_quote_correct_url(self): + """GET is sent to /api/v1/quotes/{quote_id}.""" + capture = _RequestCapture() + + def handler(request): + capture.capture(request) + return _json_response(200, _quote_response_json()) + + c = _make_client_with_transport(handler) + await c.get_quote("qt-abc123") + + assert capture.last.method == "GET" + assert str(capture.last.url).endswith("/api/v1/quotes/qt-abc123") + await c.close() + + @pytest.mark.asyncio + async def test_get_quote_404(self): + """404 raises DealsClientError with quote_not_found.""" + error_json = { + "error": "quote_not_found", + "detail": "Quote does not exist", + "status_code": 404, + } + + def handler(request): + return _json_response(404, error_json) + + c = _make_client_with_transport(handler) + with pytest.raises(DealsClientError) as exc_info: + await c.get_quote("qt-nonexistent") + + assert exc_info.value.status_code == 404 + assert "quote_not_found" in exc_info.value.error_code + await c.close() + + @pytest.mark.asyncio + async def test_get_quote_410_expired(self): + """410 raises DealsClientError with quote_expired.""" + error_json = { + "error": "quote_expired", + "detail": "Quote TTL has elapsed", + "status_code": 410, + } + + def handler(request): + return _json_response(410, error_json) + + c = _make_client_with_transport(handler) + with pytest.raises(DealsClientError) as exc_info: + await c.get_quote("qt-expired") + + assert exc_info.value.status_code == 410 + assert "quote_expired" in exc_info.value.error_code + await c.close() + + +# --------------------------------------------------------------------------- +# book_deal (POST /api/v1/deals) +# --------------------------------------------------------------------------- + + +class TestBookDeal: + """Test the book_deal method.""" + + @pytest.mark.asyncio + async def test_book_deal_success(self): + """Successful booking returns DealResponse.""" + def handler(request): + return _json_response(201, _deal_response_json()) + + c = _make_client_with_transport(handler) + booking_req = DealBookingRequest( + quote_id="qt-abc123", + buyer_identity=BuyerIdentityPayload( + seat_id="seat-ttd-12345", + dsp_platform="ttd", + ), + ) + result = await c.book_deal(booking_req) + + assert isinstance(result, DealResponse) + assert result.deal_id == "DEMO-A1B2C3D4E5F6" + assert result.status == "proposed" + assert result.openrtb_params is not None + assert result.openrtb_params.bidfloor == 28.26 + await c.close() + + @pytest.mark.asyncio + async def test_book_deal_posts_to_correct_url(self): + """POST is sent to /api/v1/deals.""" + capture = _RequestCapture() + + def handler(request): + capture.capture(request) + return _json_response(201, _deal_response_json()) + + c = _make_client_with_transport(handler) + booking_req = DealBookingRequest(quote_id="qt-abc123") + await c.book_deal(booking_req) + + assert capture.last.method == "POST" + assert str(capture.last.url).endswith("/api/v1/deals") + await c.close() + + @pytest.mark.asyncio + async def test_book_deal_sends_body(self): + """Request body contains booking fields.""" + capture = _RequestCapture() + + def handler(request): + capture.capture(request) + return _json_response(201, _deal_response_json()) + + c = _make_client_with_transport(handler) + booking_req = DealBookingRequest( + quote_id="qt-abc123", + buyer_identity=BuyerIdentityPayload(seat_id="seat-1", dsp_platform="ttd"), + notes="Booking after comparing sellers", + ) + await c.book_deal(booking_req) + + body = json.loads(capture.last.content) + assert body["quote_id"] == "qt-abc123" + assert body["buyer_identity"]["seat_id"] == "seat-1" + assert body["notes"] == "Booking after comparing sellers" + await c.close() + + @pytest.mark.asyncio + async def test_book_deal_410_expired_quote(self): + """410 for expired quote raises DealsClientError.""" + error_json = { + "error": "quote_expired", + "detail": "Quote expired", + "status_code": 410, + } + + def handler(request): + return _json_response(410, error_json) + + c = _make_client_with_transport(handler) + booking_req = DealBookingRequest(quote_id="qt-expired") + with pytest.raises(DealsClientError) as exc_info: + await c.book_deal(booking_req) + + assert exc_info.value.status_code == 410 + await c.close() + + @pytest.mark.asyncio + async def test_book_deal_409_inventory_gone(self): + """409 for unavailable inventory raises DealsClientError.""" + error_json = { + "error": "inventory_no_longer_available", + "detail": "Inventory booked by another buyer", + "status_code": 409, + } + + def handler(request): + return _json_response(409, error_json) + + c = _make_client_with_transport(handler) + booking_req = DealBookingRequest(quote_id="qt-abc123") + with pytest.raises(DealsClientError) as exc_info: + await c.book_deal(booking_req) + + assert exc_info.value.status_code == 409 + assert "inventory_no_longer_available" in exc_info.value.error_code + await c.close() + + @pytest.mark.asyncio + async def test_book_deal_403_identity_mismatch(self): + """403 for identity mismatch raises DealsClientError.""" + error_json = { + "error": "buyer_identity_mismatch", + "detail": "Identity does not match quote requester", + "status_code": 403, + } + + def handler(request): + return _json_response(403, error_json) + + c = _make_client_with_transport(handler) + booking_req = DealBookingRequest(quote_id="qt-abc123") + with pytest.raises(DealsClientError) as exc_info: + await c.book_deal(booking_req) + + assert exc_info.value.status_code == 403 + await c.close() + + +# --------------------------------------------------------------------------- +# get_deal (GET /api/v1/deals/{deal_id}) +# --------------------------------------------------------------------------- + + +class TestGetDeal: + """Test the get_deal method.""" + + @pytest.mark.asyncio + async def test_get_deal_success(self): + """Successful deal retrieval returns DealResponse.""" + def handler(request): + return _json_response(200, _deal_response_json()) + + c = _make_client_with_transport(handler) + result = await c.get_deal("DEMO-A1B2C3D4E5F6") + + assert isinstance(result, DealResponse) + assert result.deal_id == "DEMO-A1B2C3D4E5F6" + assert result.deal_type == "PD" + await c.close() + + @pytest.mark.asyncio + async def test_get_deal_correct_url(self): + """GET is sent to /api/v1/deals/{deal_id}.""" + capture = _RequestCapture() + + def handler(request): + capture.capture(request) + return _json_response(200, _deal_response_json()) + + c = _make_client_with_transport(handler) + await c.get_deal("DEMO-A1B2C3D4E5F6") + + assert capture.last.method == "GET" + assert str(capture.last.url).endswith("/api/v1/deals/DEMO-A1B2C3D4E5F6") + await c.close() + + @pytest.mark.asyncio + async def test_get_deal_404(self): + """404 for missing deal raises DealsClientError.""" + error_json = { + "error": "deal_not_found", + "detail": "Deal does not exist", + "status_code": 404, + } + + def handler(request): + return _json_response(404, error_json) + + c = _make_client_with_transport(handler) + with pytest.raises(DealsClientError) as exc_info: + await c.get_deal("DEMO-nonexistent") + + assert exc_info.value.status_code == 404 + await c.close() + + +# --------------------------------------------------------------------------- +# Server error (500) handling +# --------------------------------------------------------------------------- + + +class TestServerErrors: + """Test handling of 500 and other server errors.""" + + @pytest.mark.asyncio + async def test_500_raises_deals_client_error(self): + """500 from seller raises DealsClientError.""" + def handler(request): + return _json_response(500, {"error": "internal_error", "detail": "Server failed"}) + + c = _make_client_with_transport(handler) + quote_req = QuoteRequest(product_id="test", deal_type="PD") + with pytest.raises(DealsClientError) as exc_info: + await c.request_quote(quote_req) + + assert exc_info.value.status_code == 500 + await c.close() + + @pytest.mark.asyncio + async def test_non_json_error_response(self): + """Non-JSON error response still raises DealsClientError.""" + def handler(request): + return httpx.Response( + status_code=502, + content=b"Bad Gateway", + headers={"content-type": "text/plain"}, + ) + + c = _make_client_with_transport(handler) + quote_req = QuoteRequest(product_id="test", deal_type="PD") + with pytest.raises(DealsClientError) as exc_info: + await c.request_quote(quote_req) + + assert exc_info.value.status_code == 502 + await c.close() + + +# --------------------------------------------------------------------------- +# Timeout behavior +# --------------------------------------------------------------------------- + + +class TestTimeout: + """Test timeout configuration.""" + + @pytest.mark.asyncio + async def test_timeout_error_raises_deals_client_error(self): + """httpx.TimeoutException is wrapped in DealsClientError.""" + def handler(request): + raise httpx.TimeoutException("Connection timed out") + + c = _make_client_with_transport(handler) + quote_req = QuoteRequest(product_id="test", deal_type="PD") + with pytest.raises(DealsClientError) as exc_info: + await c.request_quote(quote_req) + + assert "timeout" in str(exc_info.value).lower() or exc_info.value.status_code == 0 + await c.close() + + @pytest.mark.asyncio + async def test_connect_error_raises_deals_client_error(self): + """httpx.ConnectError is wrapped in DealsClientError.""" + def handler(request): + raise httpx.ConnectError("Connection refused") + + c = _make_client_with_transport(handler) + quote_req = QuoteRequest(product_id="test", deal_type="PD") + with pytest.raises(DealsClientError) as exc_info: + await c.request_quote(quote_req) + + assert exc_info.value.status_code == 0 + await c.close() + + +# --------------------------------------------------------------------------- +# Retry behavior +# --------------------------------------------------------------------------- + + +class TestRetry: + """Test retry logic for transient failures.""" + + @pytest.mark.asyncio + async def test_retry_on_503(self): + """Client retries on 503 Service Unavailable.""" + call_count = 0 + + def handler(request): + nonlocal call_count + call_count += 1 + if call_count < 3: + return _json_response(503, {"error": "service_unavailable"}) + return _json_response(200, _quote_response_json()) + + c = _make_client_with_transport(handler) + quote_req = QuoteRequest(product_id="test", deal_type="PD") + result = await c.request_quote(quote_req) + + assert isinstance(result, QuoteResponse) + assert call_count == 3 # 2 retries + 1 success + await c.close() + + @pytest.mark.asyncio + async def test_no_retry_on_400(self): + """Client does NOT retry on 400 Bad Request (not transient).""" + call_count = 0 + + def handler(request): + nonlocal call_count + call_count += 1 + return _json_response(400, { + "error": "invalid_deal_type", + "detail": "Bad request", + "status_code": 400, + }) + + c = _make_client_with_transport(handler) + quote_req = QuoteRequest(product_id="test", deal_type="BAD") + with pytest.raises(DealsClientError): + await c.request_quote(quote_req) + + assert call_count == 1 # No retries for client errors + await c.close() + + @pytest.mark.asyncio + async def test_retry_exhausted_raises_error(self): + """When all retries are exhausted, DealsClientError is raised.""" + def handler(request): + return _json_response(503, {"error": "service_unavailable"}) + + c = _make_client_with_transport(handler) + quote_req = QuoteRequest(product_id="test", deal_type="PD") + with pytest.raises(DealsClientError) as exc_info: + await c.request_quote(quote_req) + + assert exc_info.value.status_code == 503 + await c.close() + + +# --------------------------------------------------------------------------- +# DealStore integration +# --------------------------------------------------------------------------- + + +class TestDealStoreIntegration: + """Test integration with DealStore for persistence.""" + + @pytest.mark.asyncio + async def test_request_quote_persists_to_store(self): + """After requesting a quote, a deal record is saved with status 'quoted'.""" + mock_store = MagicMock() + mock_store.save_deal.return_value = "deal-uuid-1" + + def handler(request): + return _json_response(200, _quote_response_json()) + + c = _make_client_with_transport(handler, deal_store=mock_store) + quote_req = QuoteRequest(product_id="ctv-premium-sports", deal_type="PD") + result = await c.request_quote(quote_req) + + # Verify store was called + mock_store.save_deal.assert_called_once() + call_kwargs = mock_store.save_deal.call_args[1] + assert call_kwargs["product_id"] == "ctv-premium-sports" + assert call_kwargs["status"] == "quoted" + assert call_kwargs["price"] == 28.26 + await c.close() + + @pytest.mark.asyncio + async def test_book_deal_updates_store_to_booked(self): + """After booking a deal, a deal record is saved with status 'booked'.""" + mock_store = MagicMock() + mock_store.save_deal.return_value = "deal-uuid-2" + + def handler(request): + return _json_response(201, _deal_response_json()) + + c = _make_client_with_transport(handler, deal_store=mock_store) + booking_req = DealBookingRequest(quote_id="qt-abc123") + result = await c.book_deal(booking_req) + + mock_store.save_deal.assert_called_once() + call_kwargs = mock_store.save_deal.call_args[1] + assert call_kwargs["seller_deal_id"] == "DEMO-A1B2C3D4E5F6" + assert call_kwargs["status"] == "booked" + await c.close() + + @pytest.mark.asyncio + async def test_no_store_no_error(self): + """Client works fine without a DealStore attached.""" + def handler(request): + return _json_response(200, _quote_response_json()) + + c = _make_client_with_transport(handler) + assert c.deal_store is None + + quote_req = QuoteRequest(product_id="test", deal_type="PD") + result = await c.request_quote(quote_req) + + assert isinstance(result, QuoteResponse) + await c.close() + + @pytest.mark.asyncio + async def test_store_error_does_not_fail_request(self): + """If DealStore raises, the API result is still returned.""" + mock_store = MagicMock() + mock_store.save_deal.side_effect = Exception("DB connection lost") + + def handler(request): + return _json_response(200, _quote_response_json()) + + c = _make_client_with_transport(handler, deal_store=mock_store) + quote_req = QuoteRequest(product_id="test", deal_type="PD") + # Should NOT raise -- store errors are logged but don't break the flow + result = await c.request_quote(quote_req) + + assert isinstance(result, QuoteResponse) + await c.close() + + +# --------------------------------------------------------------------------- +# Context manager +# --------------------------------------------------------------------------- + + +class TestContextManager: + """Test async context manager protocol.""" + + @pytest.mark.asyncio + async def test_async_context_manager(self): + """Client works as async context manager.""" + async with DealsClient(seller_url=SELLER_URL) as c: + assert c is not None + assert c.seller_url == SELLER_URL