diff --git a/docs/architecture/ssp-connectors.md b/docs/architecture/ssp-connectors.md index 3b0af24a..a5a018ca 100644 --- a/docs/architecture/ssp-connectors.md +++ b/docs/architecture/ssp-connectors.md @@ -108,17 +108,17 @@ Magnite does not provide a buyer-facing deal creation API. The connector reads d **File:** `connectors/index_exchange.py` -Index Exchange uses API key authentication and a simple REST endpoint. Publishers create deals in IX and specify buyer seat IDs; this connector discovers deals that have been targeted to the buyer's seat. +Index Exchange uses bearer-token authentication against the real Deals API's `/v3/deals` route. Publishers create deals in Index and specify buyer seat IDs; this connector discovers deals that have been targeted to the buyer's seat. There is no client-supplied seat parameter — visibility is determined server-side by the caller's authenticated identity. | Detail | Value | |--------|-------| -| Base URL | `https://api.indexexchange.com` | -| Auth | API key in `X-API-Key` header | -| Seat filtering | `seatId` query parameter | -| Endpoints | `GET /deals`, `GET /deals/{deal_id}` | -| Required env vars | `IX_API_KEY`, `IX_SEAT_ID` | +| Base URL | `https://app.indexexchange.com/api/deals` | +| Auth | `Authorization: Bearer ` | +| Seat filtering | None — server-side, based on the authenticated caller | +| Endpoints | `GET /v3/deals`, `GET /v3/deals/{internalDealID}` | +| Required env vars | `IX_API_KEY` | -Deal type mapping: `PG` → `PG`, `PD` → `PD`, `PMP` → `PA`. +Deal type mapping (derived from `classID` + `directConfigurations.programmaticGuaranteed` + `auctionType`, not a `dealType` field): `classID=1,PG=true` → `PG`; `classID=1,PG=false,auctionType=fixed` → `PD`; everything else → `PA` (there is no `PMP` DealStore type). --- diff --git a/docs/guides/ssp-connectors.md b/docs/guides/ssp-connectors.md index a7a85b19..42af7c66 100644 --- a/docs/guides/ssp-connectors.md +++ b/docs/guides/ssp-connectors.md @@ -119,22 +119,21 @@ The connector authenticates, fetches all deals targeted to your seat, and normal ## Index Exchange -Index Exchange uses a simple API key header (`X-API-Key`). Deal creation in IX is publisher-side only -- this connector discovers deals that publishers have already targeted to your seat. +Index Exchange uses bearer-token authentication (`Authorization: Bearer `). Deal creation in Index is publisher-side only -- this connector discovers deals that publishers have already targeted to your seat. There's no seat ID to configure -- which deals you see is determined server-side by your authenticated identity. ### Credentials | Variable | Description | |----------|-------------| -| `IX_API_KEY` | Your Index Exchange API key | -| `IX_SEAT_ID` | Your IX buyer seat/member ID | +| `IX_API_KEY` | Your Index Exchange Keycloak bearer token | +| `IX_API_URL` | Optional. Overrides the API base URL. Defaults to production if not set. A staging token will not authenticate against production, or vice versa — get this wrong and every call fails with a connection/auth error unrelated to the credential itself. | Get these from your Index Exchange account team. ### Setup ```bash -export IX_API_KEY="your-api-key" -export IX_SEAT_ID="your-seat-id" +export IX_API_KEY="your-bearer-token" ``` ### Test Connectivity @@ -186,7 +185,7 @@ One or more required environment variables is missing or empty. Run `list_ssp_co - **PubMatic**: The bearer token is invalid or expired. Regenerate it in the PubMatic API portal. - **Magnite**: The access key or secret key is wrong. Check for leading/trailing whitespace. -- **Index Exchange**: The API key is invalid. Contact your IX account team. +- **Index Exchange**: The API key is invalid. Contact your Index Exchange account team. ### Rate limit errors (429) diff --git a/docs/planning/SSP_INTEGRATION_PLAN.md b/docs/planning/SSP_INTEGRATION_PLAN.md index 3046f856..2fdc34ac 100644 --- a/docs/planning/SSP_INTEGRATION_PLAN.md +++ b/docs/planning/SSP_INTEGRATION_PLAN.md @@ -335,38 +335,39 @@ class MagniteConnector(SSPConnector): ## 6. Index Exchange Connector (Priority 3) -Index Exchange is the #1 US web SSP (19% share). Its deal model is strictly publisher-side: publishers create deals via IX's UI or API, specifying buyer seats. The buyer agent's IX connector discovers and imports deals that have been created by publishers and targeted to the buyer's seat. +Index Exchange is the #1 US web SSP (19% share). Its deal model is strictly publisher-side: publishers create deals via Index's UI or API, specifying buyer seats. The buyer agent's Index connector discovers and imports deals that have been created by publishers and targeted to the buyer's seat. ### 6.1 API details -- **Base URL:** `https://api.indexexchange.com` -- **Authentication:** API key (login required via IX API portal to obtain) +Confirmed against the Deals API, the service implementing these routes. + +- **Base URL:** `https://app.indexexchange.com/api/deals` +- **Authentication:** `Authorization: Bearer ` (not an API key header) - **Key endpoints:** - - `GET /deals` — list deals targeted to the buyer's seat - - `GET /deals/{deal_id}` — single deal detail -- **Note:** The "Create deal" endpoint in IX's API docs is publisher-side. The buyer agent uses the GET endpoints only. -- **Deal activation model:** IX uses "easy deal activation" — buyers can discover and activate deals created by publishers without manually entering deal IDs + - `GET /v3/deals` — list deals targeted to the buyer's seat + - `GET /v3/deals/{internalDealID}` — single deal detail +- **Note:** The "Create deal" endpoint (`POST /v3/deals`) is publisher-side. The buyer agent uses the GET endpoints only. +- **No seat ID parameter:** which deals are visible is determined server-side by the caller's authenticated identity — there's no client-supplied `seatId` on `GET /v3/deals`. ### 6.2 Data mapping (Index Exchange API response → DealStore) -| IX Field | DealStore Field | Notes | +| Index Field | DealStore Field | Notes | |----------|-----------------|-------| -| `dealId` | `seller_deal_id` | | +| `externalDealID` | `seller_deal_id` | The OpenRTB deal ID buyers reference in bid requests. | +| `internalDealID` | `description` | No first-class DealStore field for this; preserved for future write ops. | | `name` | `display_name` | | -| `status` | `status` | | -| `dealType` | `deal_type` | Map: `PMP`→`PA`, `PG`→`PG`, `PD`→`PD` | -| `floorPrice` | `bid_floor_cpm` | | -| `price` | `fixed_price_cpm` | PG/PD | -| `currency` | `currency` | | -| `publisherDomain` | `seller_domain` | | -| `adType` | `media_type` | Map: `video`→`CTV`, `display`→`DIGITAL` | +| `status` | `status` | Map: `active`→`active`, `paused`/`expired`/`auto-paused`→`paused`. | +| `classID` + `directConfigurations.programmaticGuaranteed` + `auctionType` | `deal_type` | Map: `classID=1,PG=true`→`PG`; `classID=1,PG=false,fixed`→`PD`; everything else (`classID` 1-first/3/4/5)→`PA`. There is no `PMP` DealStore type. | +| `floor` | `fixed_price_cpm` / `bid_floor_cpm` | Single field; goes to `fixed_price_cpm` for PG and PD deals, `bid_floor_cpm` otherwise. | +| `targeting[].keyName` (geo keys only) | `geo_targets` | `targeting` is a structured array, not a flat dict — filter to known geo `keyName`s (`Country`, `region`, `continent`, `place`, `area`, `zipcode`, `domain`). | | `startDate` | `flight_start` | | | `endDate` | `flight_end` | | -| `impressions` | `impressions` | | -| `description` | `description` | | +| `directConfigurations.impressionGoal` | `impressions` | PG deals only. | +| (hardcoded) | `currency` | `"USD"` — not returned by the API. | | (hardcoded) | `seller_org` | `"Index Exchange"` | | (hardcoded) | `seller_type` | `"SSP"` | -| (hardcoded) | `seller_url` | `"https://api.indexexchange.com"` | +| (hardcoded) | `seller_url` | `"https://app.indexexchange.com/api/deals"` | +| (always `None`) | `media_type`, `formats`, `seller_domain`, `content_categories`, `audience_segments` | No `adType`, `formats`, `publisherDomain` fields exist on this API; no confirmed standard targeting key for content/audience. | ### 6.3 Connector configuration @@ -378,8 +379,7 @@ class IndexExchangeConnector(SSPConnector): imports deals that publishers have targeted to the buyer's seat. Credentials: - api_key: Index Exchange API key - seat_id: Buyer seat/member ID + api_key: Index Exchange Keycloak bearer token """ ``` @@ -419,10 +419,10 @@ Returns a JSON list of available connectors, each with: - `page_size`: int, default 100 - Returns: same structure as `import_deals_csv` -**`import_deals_index_exchange(status, deal_type, page_size)`** -- `status`: filter, default `"all"` -- `deal_type`: filter, default `"all"` -- `page_size`: int, default 100 +**`import_deals_index_exchange(status, class_ids, page_size)`** +- `status`: filter — `"active"`, `"paused"`, `"expired"`, `"auto-paused"`, or `"all"` (default `"all"`) +- `class_ids`: filter — list of Index `classID` ints (`1`, `3`, `4`, `5`); omitted means no filter. There is no `deal_type` filter param on this API. +- `page_size`: int, default 100 (capped at 2000, the API maximum) - Returns: same structure as `import_deals_csv` ### 7.3 Credential management @@ -440,8 +440,7 @@ MAGNITE_SECRET_KEY= MAGNITE_SEAT_ID= # Index Exchange -IX_API_KEY= -IX_SEAT_ID= +IX_API_KEY= ``` If credentials for an SSP are not set, the corresponding MCP tool returns an informative error (rather than raising an exception) and `list_ssp_connectors` marks that connector as `configured: false`. diff --git a/src/ad_buyer/interfaces/mcp_server.py b/src/ad_buyer/interfaces/mcp_server.py index 739c8286..b2e96b4d 100644 --- a/src/ad_buyer/interfaces/mcp_server.py +++ b/src/ad_buyer/interfaces/mcp_server.py @@ -1393,15 +1393,24 @@ def create_deal_manual( def get_portfolio_summary( top_sellers_count: int = 5, expiring_within_days: int = 30, + seller_org: str | None = None, + seller_domain: str | None = None, ) -> str: """Get aggregate statistics and summary for the deal portfolio. Provides counts by status, deal type, media type, top sellers, total portfolio value, and deals expiring soon. + By default this summarizes every deal in the local deal library, + across all sellers and SSPs ever synced — not just one account. Pass + seller_org and/or seller_domain to scope the summary down to a single + seller for an apples-to-apples comparison against that seller's API. + Args: top_sellers_count: Number of top sellers to include (default 5). expiring_within_days: Show deals expiring within N days (default 30). + seller_org: Restrict to deals from this seller_org (e.g. "Index Exchange"). + seller_domain: Restrict to deals from this seller_domain. Returns a JSON object with: - total_deals: total number of deals @@ -1419,6 +1428,8 @@ def get_portfolio_summary( store, top_sellers_count=top_sellers_count, expiring_within_days=expiring_within_days, + seller_org=seller_org, + seller_domain=seller_domain, ) return json.dumps(result, indent=2) finally: @@ -2352,7 +2363,7 @@ def list_ssp_connectors() -> str: @mcp.tool() @_require_operator -def import_deals_ssp(ssp_name: str) -> str: +def import_deals_ssp(ssp_name: str, filters: dict[str, Any] | None = None) -> str: """Import deals from a specified SSP connector into the deal portfolio. Instantiates the named SSP connector, calls its API to fetch deals, @@ -2363,6 +2374,13 @@ def import_deals_ssp(ssp_name: str) -> str: ssp_name: Which SSP to import from. One of: ``"pubmatic"``, ``"magnite"``, ``"index_exchange"``. Case-insensitive. + filters: Optional connector-specific fetch filters, passed straight + through as keyword arguments to that connector's fetch_deals(). + Each SSP accepts different keys — for Index Exchange: + {"status": "active", "class_ids": [1, 4], "account_ids": [1470498], + "page_size": 100}. Without account_ids, a broadly-scoped + credential can page through every deal on the platform, not + just the caller's own account(s) — scope this whenever possible. Returns a JSON object with: - total_rows: total deals fetched from the SSP @@ -2405,9 +2423,18 @@ def import_deals_ssp(ssp_name: str) -> str: # Fetch + persist normalised deals via the deal service store = _get_deal_store() try: - result = deal_service.import_deals_ssp(store, connector) + result = deal_service.import_deals_ssp(store, connector, filters=filters) result["ssp_name"] = key return json.dumps(result, indent=2) + except ValueError as exc: + return json.dumps( + { + "error": str(exc), + "ssp_name": key, + "timestamp": datetime.now(UTC).isoformat(), + }, + indent=2, + ) finally: if _deal_store_override is None: store.disconnect() diff --git a/src/ad_buyer/services/deal_service.py b/src/ad_buyer/services/deal_service.py index bef5ad2b..2c9bccd3 100644 --- a/src/ad_buyer/services/deal_service.py +++ b/src/ad_buyer/services/deal_service.py @@ -68,6 +68,7 @@ def list_deals( deal_summaries = [ { "deal_id": d["id"], + "seller_deal_id": d.get("seller_deal_id"), "display_name": d.get("display_name") or d.get("product_name") or "(unnamed)", "status": d.get("status", "unknown"), "deal_type": d.get("deal_type", "unknown"), @@ -118,6 +119,7 @@ def search_deals(store: DealStore, query: str) -> dict[str, Any]: matches.append( { "deal_id": deal["id"], + "seller_deal_id": deal.get("seller_deal_id"), "display_name": ( deal.get("display_name") or deal.get("product_name") or "(unnamed)" ), @@ -217,9 +219,19 @@ def portfolio_summary( *, top_sellers_count: int = 5, expiring_within_days: int = 30, + seller_org: str | None = None, + seller_domain: str | None = None, ) -> dict[str, Any]: - """Aggregate portfolio statistics (counts, value, top sellers, expiring).""" - deals = store.list_deals(limit=10000) + """Aggregate portfolio statistics (counts, value, top sellers, expiring). + + By default this summarizes every deal in the local deal library, + across all sellers and SSPs ever synced -- not just one account. Pass + seller_org and/or seller_domain to scope the summary down to a single + seller for an apples-to-apples comparison against that seller's API. + """ + deals = store.list_deals(limit=10000, seller_domain=seller_domain) + if seller_org is not None: + deals = [d for d in deals if d.get("seller_org") == seller_org] total = len(deals) if total == 0: @@ -356,14 +368,23 @@ def import_deals_csv( } -def import_deals_ssp(store: DealStore, connector: Any) -> dict[str, Any]: +def import_deals_ssp( + store: DealStore, + connector: Any, + *, + filters: dict[str, Any] | None = None, +) -> dict[str, Any]: """Fetch deals from an already-configured SSP connector and persist them. The connector instance (and its configuration checks) are resolved by the caller; this function performs the fetch + persistence and shapes the result identically to ``import_deals_csv``. + + Args: + filters: Optional connector-specific fetch filters, passed straight + through as keyword arguments to the connector's fetch_deals(). """ - fetch_result = connector.fetch_deals() + fetch_result = connector.fetch_deals(**(filters or {})) deal_ids: list[str] = [] today = datetime.now(UTC).strftime("%Y-%m-%d") diff --git a/src/ad_buyer/tools/deal_library/connectors/index_exchange.py b/src/ad_buyer/tools/deal_library/connectors/index_exchange.py index fd30721a..a5a36508 100644 --- a/src/ad_buyer/tools/deal_library/connectors/index_exchange.py +++ b/src/ad_buyer/tools/deal_library/connectors/index_exchange.py @@ -4,26 +4,30 @@ """Index Exchange SSP connector for deal import. Index Exchange is the #1 US web SSP (19% share). Its deal model is strictly -publisher-side: publishers create deals in IX's UI or API and specify buyer +publisher-side: publishers create deals in Index's UI or API and specify buyer seat IDs. This connector discovers and imports deals that publishers have targeted to the buyer's seat — it uses the GET endpoints only. -API details: - Base URL: https://api.indexexchange.com - Auth: API key sent as X-API-Key header (IX_API_KEY env var) - Seat: IX_SEAT_ID env var, sent as seatId query param +API details (confirmed against the Deals API, the service implementing +these routes): + Base URL: https://app.indexexchange.com/api/deals + Auth: Authorization: Bearer (IX_API_KEY env var) Endpoints: - GET /deals — list deals targeted to the buyer seat - GET /deals/{deal_id} — single deal detail + GET /v3/deals — list deals targeted to the buyer seat + GET /v3/deals/{internalDealID} — single deal detail + +There is no seatId query parameter on GET /v3/deals — which deals are +visible is determined server-side by the caller's authenticated identity, +not a client-supplied seat. Usage:: connector = IndexExchangeConnector() # reads env vars # or - connector = IndexExchangeConnector(api_key="...", seat_id="...") + connector = IndexExchangeConnector(api_key="...") if not connector.is_configured(): - raise RuntimeError("Set IX_API_KEY and IX_SEAT_ID") + raise RuntimeError("Set IX_API_KEY") result = connector.fetch_deals(status="active") for deal in result.deals: @@ -57,40 +61,48 @@ # Field mapping constants # --------------------------------------------------------------------------- -# IX dealType → DealStore deal_type -_DEAL_TYPE_MAP: dict[str, str] = { - "PG": "PG", - "PD": "PD", - "PMP": "PA", # Private Marketplace → Private Auction - "PA": "PA", - # Lowercase aliases (defensive; IX docs show uppercase) - "pg": "PG", - "pd": "PD", - "pmp": "PA", - "pa": "PA", -} - -# IX status → DealStore status +# Index status string → DealStore status. "expired" and "auto-paused" are both +# terminal/system-set states not settable via PATCH; mapped to "paused" for +# buyer readability since DealStore has no equivalent distinct states. _STATUS_MAP: dict[str, str] = { "active": "active", - "inactive": "paused", - "pending": "imported", + "paused": "paused", + "expired": "paused", + "auto-paused": "paused", } -# IX adType → DealStore media_type -_MEDIA_TYPE_MAP: dict[str, str] = { - "display": "DIGITAL", - "DISPLAY": "DIGITAL", - "video": "CTV", - "VIDEO": "CTV", - "native": "DIGITAL", - "NATIVE": "DIGITAL", - "audio": "AUDIO", - "AUDIO": "AUDIO", -} +_VALID_STATUS_FILTERS = {"active", "paused", "expired", "auto-paused", "all"} + + +def _validated_int_list(name: str, value: Any) -> list[int]: + """Return value as a list of ints; None/empty mean "no filter". + + A bare string is rejected rather than iterated, since list("123") would + silently become the filter ["1", "2", "3"]. + """ + if value is None: + return [] + if not isinstance(value, (list, tuple)) or any( + isinstance(v, bool) or not isinstance(v, int) for v in value + ): + raise ValueError(f"{name} must be a list of integers, got {value!r}") + return list(value) + + +# Hard stop for fetch_deals() paging, so an API that ignores pageOffset (or never +# reports a short page) cannot loop forever. +_MAX_PAGES = 1000 + +# Keyword filters fetch_deals() accepts. Anything else is rejected rather than +# ignored, since a typo'd account_ids would silently become a platform-wide fetch. +_VALID_FETCH_FILTERS = {"status", "class_ids", "account_ids", "page_size"} + +# Standard targeting keyNames that represent geographic targeting, per the +# targeting key catalogue managed in supply-configuration-api. +_GEO_TARGETING_KEYS = {"Country", "region", "continent", "place", "area", "zipcode", "domain"} -_IX_BASE_URL = "https://api.indexexchange.com" -_DEALS_ENDPOINT = "/deals" +_IX_BASE_URL = "https://app.indexexchange.com/api/deals" +_DEALS_ENDPOINT = "/v3/deals" # --------------------------------------------------------------------------- @@ -103,40 +115,49 @@ class IndexExchangeConnector(SSPConnector): Discovers and imports deals that publishers have targeted to the buyer's seat. Index Exchange deal creation is publisher-side only; this connector - uses GET-only endpoints to fetch deals from the IX API. + uses GET-only endpoints to fetch deals from the Index /v3/deals API. Credentials are read from constructor args first, then from env vars: - IX_API_KEY: Index Exchange API key (sent as X-API-Key header) - IX_SEAT_ID: Buyer seat/member ID (sent as seatId query param) + IX_API_KEY: Index Exchange Keycloak bearer token, sent as + ``Authorization: Bearer ``. Fetch filters (all optional, passed as kwargs to ``fetch_deals``): - status: "active" | "inactive" | "pending" | "all" (default: all) - deal_type: "PG" | "PD" | "PMP" | "all" (default: all) - page_size: int (default: 100) + status: "active" | "paused" | "expired" | "auto-paused" | "all" + (default: "all") + class_ids: list[int] of Index classID values to filter on — 1 (Direct), + 3 (Inventory Package), 4 (Marketplace Package), 5 (Deal with + Marketplaces). Omitted/empty means no filter. + account_ids: list[int] of Index account.accountID values to filter on. + Omitted/empty means no filter — strongly recommended whenever + the credential's visibility spans more than one account (e.g. + an admin-scoped token), since an unfiltered call pages through + every deal that credential can see platform-wide. + page_size: int (default 100, capped at 2000 — the API maximum) """ def __init__( self, *, api_key: str | None = None, - seat_id: str | None = None, - base_url: str = _IX_BASE_URL, + base_url: str | None = None, ) -> None: """Initialise the connector. Args: - api_key: Index Exchange API key. Falls back to the - ``IX_API_KEY`` env var when not provided. - seat_id: Buyer seat/member ID. Falls back to the - ``IX_SEAT_ID`` env var when not provided. - base_url: Override the IX API base URL (used in tests). + api_key: Index Exchange Keycloak bearer token. Falls back to + the ``IX_API_KEY`` env var when not provided. + base_url: Override the Index API base URL. Falls back to the + ``IX_API_URL`` env var, then to the production URL, when + not provided. A token issued for one environment will not + authenticate against another — get this wrong and every + call fails with a connection/auth error that has nothing + to do with the credential itself. """ self._api_key: str = api_key or os.environ.get("IX_API_KEY", "") - self._seat_id: str = seat_id or os.environ.get("IX_SEAT_ID", "") - self._base_url: str = base_url.rstrip("/") + self._base_url: str = (base_url or os.environ.get("IX_API_URL") or _IX_BASE_URL).rstrip("/") # Lazily overridden in tests via connector._client = httpx.Client(...) self._client: httpx.Client = httpx.Client( - headers={"X-API-Key": self._api_key}, + headers={"Authorization": f"Bearer {self._api_key}"}, timeout=30.0, ) @@ -156,7 +177,7 @@ def import_source(self) -> str: def get_required_config(self) -> list[str]: """Required env vars for this connector.""" - return ["IX_API_KEY", "IX_SEAT_ID"] + return ["IX_API_KEY"] # ------------------------------------------------------------------ # Public API @@ -165,46 +186,75 @@ def get_required_config(self) -> list[str]: def fetch_deals(self, **kwargs: Any) -> SSPFetchResult: """Fetch deals from Index Exchange that are targeted to the buyer seat. - Handles pagination automatically: iterates pages until the API - returns an empty deals list or a partial page. + Handles pagination automatically: iterates pages (0-indexed, via + pageOffset) until the cumulative offset reaches the API's reported + totalCount, or a page comes back empty. If a response omits + totalCount, it keeps paging until a page comes back short. Args: - status: Filter by deal status. Pass ``"all"`` (default) - to fetch all statuses. IX values: ``"active"``, - ``"inactive"``, ``"pending"``. - deal_type: Filter by deal type. Pass ``"all"`` (default) - to fetch all types. IX values: ``"PG"``, ``"PD"``, - ``"PMP"``. - page_size: Number of results per page (default 100). + status: Filter by deal status. Pass "all" (default) to fetch + all statuses. Index values: "active", "paused", "expired", + "auto-paused". + class_ids: List of Index classID ints to filter on (1, 3, 4, 5). + Omitted/empty means no filter. + account_ids: List of Index account.accountID ints to filter on. + Omitted/empty means no filter — with a broadly-scoped + credential (e.g. an admin token), an unfiltered call can + page through every deal the token can see across the + entire platform, not just this buyer's own account(s). + Scope this whenever the credential's visibility is + broader than a single seat/account. + page_size: Number of results per page (default 100, max 2000). Returns: SSPFetchResult with normalized deals ready for DealStore. Raises: - SSPAuthError: HTTP 401 or 403 from IX API. - SSPRateLimitError: HTTP 429 from IX API. + ValueError: If a filter key is not recognized, or status is not + a recognized filter value. + SSPAuthError: HTTP 401 or 403 from Index API. + SSPRateLimitError: HTTP 429 from Index API. SSPConnectionError: HTTP 5xx or network error. """ - status_filter: str = kwargs.get("status", "all") - deal_type_filter: str = kwargs.get("deal_type", "all") - page_size: int = int(kwargs.get("page_size", 100)) + unknown_filters = sorted(set(kwargs) - _VALID_FETCH_FILTERS) + if unknown_filters: + raise ValueError( + f"Unknown Index Exchange filter(s) {unknown_filters}. " + f"Must be among: {sorted(_VALID_FETCH_FILTERS)}" + ) + status_filter = kwargs.get("status", "all") + if not isinstance(status_filter, str) or status_filter.lower() not in _VALID_STATUS_FILTERS: + raise ValueError( + f"Invalid status filter '{status_filter}'. " + f"Must be one of: {sorted(_VALID_STATUS_FILTERS)}" + ) + class_ids = _validated_int_list("class_ids", kwargs.get("class_ids")) + account_ids = _validated_int_list("account_ids", kwargs.get("account_ids")) + raw_page_size = kwargs.get("page_size", 100) + if isinstance(raw_page_size, bool) or not isinstance(raw_page_size, int): + raise ValueError(f"page_size must be an integer >= 1, got {raw_page_size!r}") + if raw_page_size < 1: + raise ValueError(f"page_size must be an integer >= 1, got {raw_page_size!r}") + page_size: int = min(raw_page_size, 2000) result = SSPFetchResult(ssp_name=self.ssp_name) seen_deal_ids: set[str] = set() - page = 1 + page_offset = 0 while True: - raw_deals = self._fetch_page( - page=page, + raw_deals, total_count = self._fetch_page( + page_offset=page_offset, page_size=page_size, status_filter=status_filter, - deal_type_filter=deal_type_filter, + class_ids=class_ids, + account_ids=account_ids, ) if not raw_deals: break result.raw_response_count += len(raw_deals) + page_had_new_deal = False for raw in raw_deals: result.total_fetched += 1 @@ -213,6 +263,7 @@ def fetch_deals(self, **kwargs: Any) -> SSPFetchResult: except (KeyError, ValueError) as exc: result.errors.append(f"Deal normalization failed: {exc}") result.failed += 1 + page_had_new_deal = True continue # Deduplicate by seller_deal_id @@ -224,14 +275,25 @@ def fetch_deals(self, **kwargs: Any) -> SSPFetchResult: if deal_id: seen_deal_ids.add(deal_id) + page_had_new_deal = True result.deals.append(normalized) result.successful += 1 - # Stop if we received a partial page (no more data) - if len(raw_deals) < page_size: + page_offset += 1 + if not page_had_new_deal: + result.errors.append( + f"Stopped paging at page {page_offset}: the page contained only deals " + "already fetched, so the API appears to be ignoring pageOffset" + ) + break + if page_offset >= _MAX_PAGES: + result.errors.append(f"Stopped paging after {_MAX_PAGES} pages") + break + if total_count is None: + if len(raw_deals) < page_size: + break + elif (page_offset * page_size) >= total_count: break - - page += 1 logger.info( "Index Exchange: fetched %d deals — %d ok, %d failed, %d skipped", @@ -254,10 +316,11 @@ def test_connection(self) -> bool: """ try: self._fetch_page( - page=1, + page_offset=0, page_size=1, status_filter="all", - deal_type_filter="all", + class_ids=[], + account_ids=[], ) logger.info("Index Exchange: connection test passed") return True @@ -270,75 +333,101 @@ def test_connection(self) -> bool: # ------------------------------------------------------------------ def _normalize_deal(self, raw_deal: dict[str, Any]) -> dict[str, Any]: - """Map a single IX API deal object to DealStore kwargs. + """Map a single Index API deal object to DealStore kwargs. - Index Exchange deal structure (example):: + Index Exchange deal structure (example, GET /v3/deals response + item — see the Index deal-response schema for the full + field reference):: { - "dealId": "IX-PG-2026-001", + "externalDealID": "IX-PG-2026-001", + "internalDealID": 44001, "name": "Premium News PG Package", "status": "active", - "dealType": "PG", - "floorPrice": null, - "price": 52.00, - "currency": "USD", - "publisherDomain": "news.example.com", - "adType": "display", + "classID": 1, + "floor": 52.00, "startDate": "2026-04-01", "endDate": "2026-06-30", - "impressions": 3000000, - "description": "...", - "targeting": { - "geo": ["US", "CA"], - "contentCategories": ["IAB12"], - "audiences": [] + "account": {"accountID": 1470498}, + "auctionType": "fixed", + "directConfigurations": { + "dspID": 85, + "programmaticGuaranteed": true, + "impressionGoal": 3000000 }, - "formats": ["display_banner"] + "targeting": [ + { + "targetingType": "standard", + "keyName": "Country", + "sets": [{"values": [{"value": "US"}], "operator": "ANY_OF"}] + } + ] } Args: - raw_deal: A single deal dict from the IX ``GET /deals`` response. + raw_deal: A single deal dict from the Index ``GET /v3/deals`` + response's ``deals`` array. Returns: Dict matching ``DealStore.save_deal()`` keyword arguments. Raises: - KeyError: If ``dealId`` or ``name`` is missing from ``raw_deal``. - ValueError: If ``dealType`` is present but not a recognized IX - value. + KeyError: If ``externalDealID`` or ``name`` is missing from + ``raw_deal``. + ValueError: If ``classID`` is missing or not a recognized value. """ # Required fields — KeyError propagates as-is - seller_deal_id: str = raw_deal["dealId"] + seller_deal_id: str = raw_deal["externalDealID"] display_name: str = raw_deal["name"] - - # Deal type normalization - raw_deal_type: str = raw_deal.get("dealType", "") - normalized_deal_type = _DEAL_TYPE_MAP.get(raw_deal_type) - if normalized_deal_type is None: + internal_deal_id = raw_deal.get("internalDealID") + + # Deal type normalization — there is no dealType field on the API; + # it's derived from classID + directConfigurations.programmaticGuaranteed + # + auctionType. "PMP" has no DealStore equivalent (VALID_DEAL_TYPES + # doesn't include it) — classID 1-first/3/4 all map to "PA" (Private + # Auction), the same closest-analog choice this connector already + # made for the old, fictional "PMP" dealType value. + class_id = raw_deal.get("classID") + if class_id is None: + raise ValueError("Missing classID — cannot determine deal type") + + direct_config: dict[str, Any] = raw_deal.get("directConfigurations") or {} + programmatic_guaranteed = bool(direct_config.get("programmaticGuaranteed", False)) + auction_type = raw_deal.get("auctionType") + + if class_id == 1 and programmatic_guaranteed: + normalized_deal_type = "PG" + elif class_id == 1 and not programmatic_guaranteed and auction_type == "fixed": + normalized_deal_type = "PD" + elif class_id in (1, 3, 4): + normalized_deal_type = "PA" + elif class_id == 5: + normalized_deal_type = "PA" + else: raise ValueError( - f"Unrecognized Index Exchange deal type: '{raw_deal_type}'. " - f"Expected one of: {sorted(_DEAL_TYPE_MAP.keys())}" + f"Unrecognized Index Exchange classID: {class_id!r}. Expected one of: 1, 3, 4, 5" ) - # Status normalization (unknown statuses → imported) - raw_status: str = raw_deal.get("status", "pending") - normalized_status = _STATUS_MAP.get(raw_status.lower(), "imported") - - # Media type (optional) - raw_ad_type: str = raw_deal.get("adType", "") - media_type = _MEDIA_TYPE_MAP.get(raw_ad_type) if raw_ad_type else None - - # Targeting fields — convert arrays to comma-separated strings for DealStore - targeting = raw_deal.get("targeting") or {} - geo_list: list[str] = targeting.get("geo") or [] - content_cats: list[str] = targeting.get("contentCategories") or [] - audiences: list[str] = targeting.get("audiences") or [] - formats_list: list[str] = raw_deal.get("formats") or [] - - geo_targets = ", ".join(geo_list) if geo_list else None - content_categories = ", ".join(content_cats) if content_cats else None - audience_segments = ", ".join(audiences) if audiences else None - formats = ", ".join(formats_list) if formats_list else None + # Status normalization (unknown statuses → paused, matching + # DealStore.VALID_STATUSES — "imported" is not a valid status there) + raw_status: str = raw_deal.get("status", "") + normalized_status = _STATUS_MAP.get(raw_status.lower(), "paused") + + # Targeting — a structured array, not a flat dict. Filter to known + # geo keyNames for geo_targets; content_categories/audience_segments + # have no confirmed standard-key equivalent in the targeting + # catalogue (see gap analysis), so they're left unset unless a + # future standard key is identified. + targeting_items: list[dict[str, Any]] = raw_deal.get("targeting") or [] + geo_values: list[str] = [] + for item in targeting_items: + if item.get("keyName") in _GEO_TARGETING_KEYS: + for value_set in item.get("sets") or []: + for value_obj in value_set.get("values") or []: + v = value_obj.get("value") + if v: + geo_values.append(str(v)) + geo_targets = ", ".join(geo_values) if geo_values else None return { # Identity @@ -349,27 +438,35 @@ def _normalize_deal(self, raw_deal: dict[str, Any]) -> dict[str, Any]: "seller_org": "Index Exchange", "seller_type": "SSP", "seller_url": self._base_url, - "seller_domain": raw_deal.get("publisherDomain"), + "seller_domain": None, # not a field on this API — no publisherDomain # Deal metadata "deal_type": normalized_deal_type, - "media_type": media_type, + "media_type": None, # not a field on this API — no adType "status": normalized_status, - # Pricing - "fixed_price_cpm": raw_deal.get("price"), - "bid_floor_cpm": raw_deal.get("floorPrice"), - "currency": raw_deal.get("currency") or "USD", - # Inventory targeting (lists serialised as comma-separated strings) - "formats": formats, + # Pricing — a single "floor" field. PG and PD are both fixed-price + # deals (PD is classified by auctionType == "fixed"), so it is their + # fixed CPM; for auction deals it is the bid floor. + "fixed_price_cpm": raw_deal.get("floor") + if normalized_deal_type in ("PG", "PD") + else None, + "bid_floor_cpm": raw_deal.get("floor") + if normalized_deal_type not in ("PG", "PD") + else None, + "currency": "USD", # not returned by the API — always USD today + # Inventory targeting + "formats": None, # not a field on this API — no formats array "geo_targets": geo_targets, - "content_categories": content_categories, - "audience_segments": audience_segments, + "content_categories": None, # no confirmed standard key + "audience_segments": None, # no confirmed standard key # Flight dates "flight_start": raw_deal.get("startDate"), "flight_end": raw_deal.get("endDate"), - # Volume (PG) - "impressions": raw_deal.get("impressions"), - # Description - "description": raw_deal.get("description"), + # Volume (PG only — impressionGoal lives under directConfigurations) + "impressions": direct_config.get("impressionGoal"), + # internalDealID isn't a first-class DealStore field; preserved + # here so future write operations (PATCH, etc.) aren't left with + # no way to address the deal on Index's side. + "description": str(internal_deal_id) if internal_deal_id is not None else None, } # ------------------------------------------------------------------ @@ -379,43 +476,48 @@ def _normalize_deal(self, raw_deal: dict[str, Any]) -> dict[str, Any]: def _build_params( self, *, - page: int, + page_offset: int, page_size: int, status_filter: str, - deal_type_filter: str, - ) -> dict[str, str]: + class_ids: list[int], + account_ids: list[int], + ) -> dict[str, Any]: """Build query parameters for the deals endpoint.""" - params: dict[str, str] = { - "seatId": self._seat_id, - "page": str(page), - "page_size": str(page_size), + params: dict[str, Any] = { + "pageOffset": page_offset, + "pageSize": page_size, } - # Only add status/deal_type params when not "all" — avoids sending - # ?status=all which some APIs treat as a literal filter value. + # Only add status/classIDs/accountIDs params when filtering — avoids + # sending ?status=all which some APIs treat as a literal filter value. if status_filter and status_filter.lower() != "all": params["status"] = status_filter - if deal_type_filter and deal_type_filter.lower() != "all": - params["deal_type"] = deal_type_filter + if class_ids: + params["classIDs"] = class_ids + if account_ids: + params["accountIDs"] = account_ids return params def _fetch_page( self, *, - page: int, + page_offset: int, page_size: int, status_filter: str, - deal_type_filter: str, - ) -> list[dict[str, Any]]: - """Fetch a single page of deals from the IX API. + class_ids: list[int], + account_ids: list[int], + ) -> tuple[list[dict[str, Any]], int | None]: + """Fetch a single page of deals from the Index API. Args: - page: 1-indexed page number. + page_offset: 0-indexed page offset. page_size: Number of results per page. status_filter: Status filter string ("all" = no filter). - deal_type_filter: Deal type filter string ("all" = no filter). + class_ids: classID filter list (empty = no filter). + account_ids: account.accountID filter list (empty = no filter). Returns: - List of raw deal dicts from the API response. + Tuple of (list of raw deal dicts, totalCount from the response + envelope, or None if the envelope omitted it). Raises: SSPAuthError: HTTP 401 or 403. @@ -424,14 +526,15 @@ def _fetch_page( """ url = f"{self._base_url}{_DEALS_ENDPOINT}" params = self._build_params( - page=page, + page_offset=page_offset, page_size=page_size, status_filter=status_filter, - deal_type_filter=deal_type_filter, + class_ids=class_ids, + account_ids=account_ids, ) # Ensure the client has the correct auth header even if api_key was # set after the client was constructed (e.g. read from env). - headers = {"X-API-Key": self._api_key} + headers = {"Authorization": f"Bearer {self._api_key}"} try: response = self._client.get(url, params=params, headers=headers) @@ -465,4 +568,7 @@ def _fetch_page( ) data: dict[str, Any] = response.json() - return data.get("deals", []) + total_count = data.get("totalCount") + if total_count is None: + logger.warning("Index Exchange response had no totalCount; paging until a short page") + return data.get("deals", []), None if total_count is None else int(total_count) diff --git a/tests/unit/fixtures/index_exchange_deals_response.json b/tests/unit/fixtures/index_exchange_deals_response.json index 86c917be..0c9123a4 100644 --- a/tests/unit/fixtures/index_exchange_deals_response.json +++ b/tests/unit/fixtures/index_exchange_deals_response.json @@ -1,92 +1,97 @@ { - "status": "success", - "total": 4, - "page": 1, - "pageSize": 100, + "totalCount": 4, "deals": [ { - "dealId": "IX-PG-2026-001", + "externalDealID": "IX-PG-2026-001", + "internalDealID": 44001, "name": "Premium News PG Package", "status": "active", - "dealType": "PG", - "floorPrice": null, - "price": 52.00, - "currency": "USD", - "publisherDomain": "news.example.com", - "adType": "display", + "classID": 1, + "floor": 52.00, "startDate": "2026-04-01", "endDate": "2026-06-30", - "impressions": 3000000, - "description": "Guaranteed news homepage display inventory", - "targeting": { - "geo": ["US", "CA"], - "contentCategories": ["IAB12", "IAB12-1"], - "audiences": [] + "account": {"accountID": 1470498}, + "auctionType": "fixed", + "directConfigurations": { + "dspID": 85, + "programmaticGuaranteed": true, + "impressionGoal": 3000000 }, - "formats": ["display_banner", "display_interstitial"] + "targeting": [ + { + "targetingType": "standard", + "keyName": "Country", + "sets": [ + {"values": [{"value": "US"}, {"value": "CA"}], "operator": "ANY_OF"} + ] + } + ] }, { - "dealId": "IX-PD-2026-042", + "externalDealID": "IX-PD-2026-042", + "internalDealID": 44042, "name": "Tech Content Preferred Deal", "status": "active", - "dealType": "PD", - "floorPrice": 14.00, - "price": 18.50, - "currency": "USD", - "publisherDomain": "tech.example.com", - "adType": "display", + "classID": 1, + "floor": 14.00, "startDate": "2026-04-01", "endDate": "2026-09-30", - "impressions": null, - "description": "Preferred deal for tech content pages", - "targeting": { - "geo": ["US"], - "contentCategories": ["IAB19"], - "audiences": ["tech-enthusiasts"] + "account": {"accountID": 1470498}, + "auctionType": "fixed", + "directConfigurations": { + "dspID": 85, + "programmaticGuaranteed": false }, - "formats": ["display_banner"] + "targeting": [] }, { - "dealId": "IX-PMP-2026-777", - "name": "In-Market Auto Private Auction", + "externalDealID": "IX-MP-2026-777", + "internalDealID": 44777, + "name": "In-Market Auto Marketplace Package", "status": "active", - "dealType": "PMP", - "floorPrice": 9.50, - "price": null, - "currency": "USD", - "publisherDomain": null, - "adType": "video", + "classID": 4, + "floor": null, "startDate": "2026-03-15", "endDate": "2026-12-31", - "impressions": null, - "description": null, - "targeting": { - "geo": ["US"], - "contentCategories": ["IAB2"], - "audiences": ["in-market-auto", "new-car-buyers-2026"] + "account": {"accountID": 1470498}, + "auctionType": "first", + "marketplaceConfigurations": { + "dspID": 39 }, - "formats": ["video_preroll", "video_midroll"] + "targeting": [ + { + "targetingType": "custom", + "keyName": "AudienceSegment123", + "sets": [ + {"values": [{"value": "in-market-auto"}], "operator": "ANY_OF"} + ] + } + ] }, { - "dealId": "IX-PG-2026-099", - "name": "Paused Entertainment PG", - "status": "inactive", - "dealType": "PG", - "floorPrice": null, - "price": 30.00, - "currency": "USD", - "publisherDomain": "entertainment.example.com", - "adType": "display", + "externalDealID": "IX-PD-2026-099", + "internalDealID": 44099, + "name": "Paused Entertainment Direct Deal", + "status": "paused", + "classID": 1, + "floor": 30.00, "startDate": "2026-01-01", "endDate": "2026-03-31", - "impressions": 1000000, - "description": null, - "targeting": { - "geo": ["US", "UK", "AU"], - "contentCategories": ["IAB1"], - "audiences": [] + "account": {"accountID": 1470498}, + "auctionType": "fixed", + "directConfigurations": { + "dspID": 17, + "programmaticGuaranteed": false }, - "formats": ["display_banner", "display_native"] + "targeting": [ + { + "targetingType": "standard", + "keyName": "domain", + "sets": [ + {"values": [{"value": "example.com"}], "operator": "ANY_OF"} + ] + } + ] } ] } diff --git a/tests/unit/test_deal_service.py b/tests/unit/test_deal_service.py index 5101c841..fc8a3ea1 100644 --- a/tests/unit/test_deal_service.py +++ b/tests/unit/test_deal_service.py @@ -68,6 +68,11 @@ def test_lists_and_filters(self, store): assert active["total"] == 1 assert active["deals"][0]["display_name"] == "A" + def test_includes_seller_deal_id(self, store): + _seed(store, seller_deal_id="IX-123") + result = deal_service.list_deals(store) + assert result["deals"][0]["seller_deal_id"] == "IX-123" + class TestSearchDeals: def test_empty_query_returns_error(self, store): @@ -80,6 +85,11 @@ def test_matches_on_seller_org(self, store): assert result["total"] == 1 assert "seller organization" in result["deals"][0]["matched_in"] + def test_results_include_seller_deal_id(self, store): + _seed(store, display_name="X", seller_org="ESPN", seller_deal_id="IX-456") + result = deal_service.search_deals(store, "espn") + assert result["deals"][0]["seller_deal_id"] == "IX-456" + class TestInspectDeal: def test_not_found(self, store): @@ -108,6 +118,25 @@ def test_aggregates(self, store): assert result["by_status"]["active"] == 2 assert result["top_sellers"][0]["seller"] == "ESPN" + def test_scoped_to_seller_org(self, store): + _seed(store, seller_org="Index Exchange", seller_deal_id="1") + _seed(store, seller_org="Index Exchange", seller_deal_id="2") + _seed(store, seller_org="PubMatic", seller_deal_id="3") + result = deal_service.portfolio_summary(store, seller_org="Index Exchange") + assert result["total_deals"] == 2 + assert [s["seller"] for s in result["top_sellers"]] == ["Index Exchange"] + + def test_scoped_to_seller_domain(self, store): + _seed(store, seller_domain="espn.com", seller_deal_id="1") + _seed(store, seller_domain="hulu.com", seller_deal_id="2") + result = deal_service.portfolio_summary(store, seller_domain="espn.com") + assert result["total_deals"] == 1 + + def test_scoped_to_unknown_seller_is_empty(self, store): + _seed(store, seller_org="ESPN", seller_deal_id="1") + result = deal_service.portfolio_summary(store, seller_org="Nobody") + assert result["total_deals"] == 0 + # --------------------------------------------------------------------------- # Imports @@ -209,3 +238,43 @@ def fetch_deals(self): assert result["successful"] == 2 assert len(result["deal_ids"]) == 2 assert deal_service.list_deals(store)["total"] == 2 + + def test_filters_are_passed_to_connector_fetch_deals(self, store): + class _FakeFetchResult: + total_fetched = 0 + successful = 0 + failed = 0 + skipped = 0 + errors: list = [] + deals: list = [] + + seen: dict = {} + + class _FakeConnector: + import_source = "INDEX_EXCHANGE" + + def fetch_deals(self, **kwargs): + seen.update(kwargs) + return _FakeFetchResult() + + filters = {"status": "active", "account_ids": [1470498]} + deal_service.import_deals_ssp(store, _FakeConnector(), filters=filters) + assert seen == filters + + def test_no_filters_calls_fetch_deals_without_kwargs(self, store): + class _FakeFetchResult: + total_fetched = 0 + successful = 0 + failed = 0 + skipped = 0 + errors: list = [] + deals: list = [] + + class _FakeConnector: + import_source = "INDEX_EXCHANGE" + + def fetch_deals(self, **kwargs): + assert kwargs == {} + return _FakeFetchResult() + + deal_service.import_deals_ssp(store, _FakeConnector()) diff --git a/tests/unit/test_mcp_deal_library.py b/tests/unit/test_mcp_deal_library.py index cdc75c34..c6b2a88a 100644 --- a/tests/unit/test_mcp_deal_library.py +++ b/tests/unit/test_mcp_deal_library.py @@ -841,6 +841,33 @@ async def test_summary_top_sellers(self): assert "top_sellers" in data assert len(data["top_sellers"]) >= 1 + @pytest.mark.asyncio + async def test_summary_scoped_by_seller_org(self): + """seller_org restricts the summary to that seller's deals.""" + store = _make_deal_store() + _seed_deal(store, seller_org="Index Exchange", seller_deal_id="IX-1") + _seed_deal(store, seller_org="Index Exchange", seller_deal_id="IX-2") + _seed_deal(store, seller_org="PubMatic", seller_deal_id="PM-1") + _set_deal_store(store) + + result = await mcp.call_tool("get_portfolio_summary", {"seller_org": "Index Exchange"}) + data = json.loads(_extract_text(result)) + + assert data["total_deals"] == 2 + + @pytest.mark.asyncio + async def test_summary_scoped_by_seller_domain(self): + """seller_domain restricts the summary to that seller's deals.""" + store = _make_deal_store() + _seed_deal(store, seller_domain="espn.com", seller_deal_id="E-1") + _seed_deal(store, seller_domain="cnn.com", seller_deal_id="C-1") + _set_deal_store(store) + + result = await mcp.call_tool("get_portfolio_summary", {"seller_domain": "espn.com"}) + data = json.loads(_extract_text(result)) + + assert data["total_deals"] == 1 + @pytest.mark.asyncio async def test_summary_returns_valid_json(self): """get_portfolio_summary should return valid JSON.""" diff --git a/tests/unit/test_mcp_ssp_tools.py b/tests/unit/test_mcp_ssp_tools.py index aad3cae6..220b623e 100644 --- a/tests/unit/test_mcp_ssp_tools.py +++ b/tests/unit/test_mcp_ssp_tools.py @@ -119,8 +119,17 @@ async def test_connector_names_present(self): assert "index_exchange" in names @pytest.mark.asyncio - async def test_connectors_unconfigured_without_env_vars(self): + async def test_connectors_unconfigured_without_env_vars(self, monkeypatch): """All connectors should show configured=false when env vars are absent.""" + for var in ( + "PUBMATIC_API_TOKEN", + "PUBMATIC_SEAT_ID", + "MAGNITE_ACCESS_KEY", + "MAGNITE_SECRET_KEY", + "MAGNITE_SEAT_ID", + "IX_API_KEY", + ): + monkeypatch.delenv(var, raising=False) result = await mcp.call_tool("list_ssp_connectors", {}) data = json.loads(_extract_text(result)) for connector in data["connectors"]: @@ -198,8 +207,9 @@ async def test_unconfigured_magnite_returns_error(self): assert "error" in data @pytest.mark.asyncio - async def test_unconfigured_index_exchange_returns_error(self): + async def test_unconfigured_index_exchange_returns_error(self, monkeypatch): """Index Exchange import should return an error when not configured.""" + monkeypatch.delenv("IX_API_KEY", raising=False) result = await mcp.call_tool("import_deals_ssp", {"ssp_name": "index_exchange"}) data = json.loads(_extract_text(result)) assert "error" in data @@ -260,6 +270,48 @@ async def test_successful_pubmatic_import(self, monkeypatch): assert data["failed"] == 0 assert len(data["deal_ids"]) == 2 + @pytest.mark.asyncio + async def test_filters_are_passed_through_to_connector(self, monkeypatch): + """The MCP `filters` argument reaches the connector's fetch_deals() as kwargs.""" + monkeypatch.setenv("IX_API_KEY", "test-token") + _set_deal_store(_make_deal_store()) + + with patch("ad_buyer.interfaces.mcp_server.IndexExchangeConnector") as MockConnector: + instance = MagicMock() + instance.is_configured.return_value = True + instance.fetch_deals.return_value = SSPFetchResult(ssp_name="Index Exchange") + instance.import_source = "INDEX_EXCHANGE" + MockConnector.return_value = instance + + filters = {"status": "active", "class_ids": [1], "account_ids": [1470498]} + result = await mcp.call_tool( + "import_deals_ssp", {"ssp_name": "index_exchange", "filters": filters} + ) + data = json.loads(_extract_text(result)) + + assert "error" not in data + instance.fetch_deals.assert_called_once_with(**filters) + + @pytest.mark.asyncio + async def test_unknown_index_exchange_filter_returns_error_and_imports_nothing( + self, monkeypatch + ): + """A typo'd filter key comes back as an error, not an unscoped import.""" + monkeypatch.setenv("IX_API_KEY", "test-token") + store = _make_deal_store() + _set_deal_store(store) + + with patch("ad_buyer.tools.deal_library.connectors.index_exchange.httpx.Client") as client: + result = await mcp.call_tool( + "import_deals_ssp", + {"ssp_name": "index_exchange", "filters": {"account_id": [1470498]}}, + ) + data = json.loads(_extract_text(result)) + + assert "account_id" in data["error"] + client.return_value.get.assert_not_called() + assert store.list_deals(limit=10) == [] + @pytest.mark.asyncio async def test_successful_magnite_import(self, monkeypatch): """A configured Magnite connector that returns deals should save them.""" @@ -513,8 +565,9 @@ async def test_response_includes_timestamp(self): assert "timestamp" in data @pytest.mark.asyncio - async def test_index_exchange_not_configured_returns_false(self): + async def test_index_exchange_not_configured_returns_false(self, monkeypatch): """Index Exchange connection test without env vars → connected=false.""" + monkeypatch.delenv("IX_API_KEY", raising=False) result = await mcp.call_tool("test_ssp_connection", {"ssp_name": "index_exchange"}) data = json.loads(_extract_text(result)) assert data["connected"] is False diff --git a/tests/unit/test_ssp_index_exchange.py b/tests/unit/test_ssp_index_exchange.py index 470d408a..4bc8fdc4 100644 --- a/tests/unit/test_ssp_index_exchange.py +++ b/tests/unit/test_ssp_index_exchange.py @@ -7,29 +7,27 @@ - Connector properties (ssp_name, import_source, required config) - is_configured() with/without env vars - Constructor argument and env var fallback -- _normalize_deal(): all IX field mappings -- _normalize_deal(): deal type normalization (PMP→PA, PG→PG, PD→PD) -- _normalize_deal(): status normalization (active, inactive, unknown) -- _normalize_deal(): media type normalization (display→DIGITAL, video→CTV) -- _normalize_deal(): targeting fields (geo, categories, audiences, formats) +- _normalize_deal(): all Index field mappings against the real /v3/deals shape +- _normalize_deal(): deal type derivation from classID + programmaticGuaranteed + + auctionType (PG, PD, PA — there is no "PMP" DealStore type) +- _normalize_deal(): status normalization (active, paused, unknown) +- _normalize_deal(): pricing (single floor field, PG vs non-PG) +- _normalize_deal(): targeting (structured array, geo keyName filtering) - _normalize_deal(): missing/null optional fields default to None - _normalize_deal(): missing required field raises KeyError -- _normalize_deal(): unknown deal_type raises ValueError +- _normalize_deal(): missing/unrecognized classID raises ValueError - fetch_deals(): happy path with mocked HTTP (MockTransport) -- fetch_deals(): pagination (multiple pages) -- fetch_deals(): status filter applied in query params -- fetch_deals(): deal_type filter applied in query params -- fetch_deals(): API key and seat_id sent in request +- fetch_deals(): pagination via totalCount / pageOffset +- fetch_deals(): status filter applied in query params, validated +- fetch_deals(): classIDs filter applied in query params +- fetch_deals(): Authorization: Bearer header sent, no seatId param - fetch_deals(): deduplication within a single fetch -- fetch_deals(): HTTP 401 raises SSPAuthError -- fetch_deals(): HTTP 403 raises SSPAuthError +- fetch_deals(): HTTP 401/403 raises SSPAuthError - fetch_deals(): HTTP 429 raises SSPRateLimitError with retry_after -- fetch_deals(): HTTP 500 raises SSPConnectionError +- fetch_deals(): HTTP 5xx raises SSPConnectionError - fetch_deals(): network error raises SSPConnectionError - fetch_deals(): normalization error captured in result, not raised -- test_connection(): success path returns True -- test_connection(): auth failure returns False -- test_connection(): network error returns False +- test_connection(): success/auth-failure/network-error paths - Module imports from connectors package """ @@ -122,10 +120,9 @@ def _connector_with_transport( transport: Any, *, api_key: str = "test-ix-api-key", - seat_id: str = "seat-ix-456", ) -> IndexExchangeConnector: """Return an IndexExchangeConnector whose HTTP client uses the given transport.""" - connector = IndexExchangeConnector(api_key=api_key, seat_id=seat_id) + connector = IndexExchangeConnector(api_key=api_key) connector._client = httpx.Client(transport=transport) return connector @@ -140,47 +137,50 @@ class TestIndexExchangeConnectorProperties: def test_ssp_name(self): """ssp_name is 'Index Exchange'.""" - c = IndexExchangeConnector(api_key="key", seat_id="seat") + c = IndexExchangeConnector(api_key="key") assert c.ssp_name == "Index Exchange" def test_import_source(self): """import_source is 'INDEX_EXCHANGE'.""" - c = IndexExchangeConnector(api_key="key", seat_id="seat") + c = IndexExchangeConnector(api_key="key") assert c.import_source == "INDEX_EXCHANGE" def test_required_config(self): - """get_required_config returns IX_API_KEY and IX_SEAT_ID.""" - c = IndexExchangeConnector(api_key="key", seat_id="seat") + """get_required_config returns only IX_API_KEY — no seat ID.""" + c = IndexExchangeConnector(api_key="key") config = c.get_required_config() - assert "IX_API_KEY" in config - assert "IX_SEAT_ID" in config + assert config == ["IX_API_KEY"] def test_is_configured_false_when_env_vars_missing(self): """is_configured returns False when env vars are absent.""" - c = IndexExchangeConnector(api_key="key", seat_id="seat") + c = IndexExchangeConnector(api_key="key") for var in c.get_required_config(): os.environ.pop(var, None) assert c.is_configured() is False def test_is_configured_true_when_env_vars_set(self, monkeypatch): - """is_configured returns True when both env vars are set.""" + """is_configured returns True when IX_API_KEY is set.""" monkeypatch.setenv("IX_API_KEY", "some-key") - monkeypatch.setenv("IX_SEAT_ID", "some-seat") - c = IndexExchangeConnector(api_key="key", seat_id="seat") + c = IndexExchangeConnector(api_key="key") assert c.is_configured() is True def test_constructor_from_env(self, monkeypatch): """IndexExchangeConnector() without args reads from env vars.""" monkeypatch.setenv("IX_API_KEY", "env-key") - monkeypatch.setenv("IX_SEAT_ID", "env-seat") c = IndexExchangeConnector() assert c._api_key == "env-key" - assert c._seat_id == "env-seat" - def test_base_url_default(self): - """Default base URL is Index Exchange API endpoint.""" - c = IndexExchangeConnector(api_key="key", seat_id="seat") - assert c._base_url == "https://api.indexexchange.com" + def test_base_url_default(self, monkeypatch): + """Default base URL is the real Index Deals API endpoint.""" + monkeypatch.delenv("IX_API_URL", raising=False) + c = IndexExchangeConnector(api_key="key") + assert c._base_url == "https://app.indexexchange.com/api/deals" + + def test_base_url_from_env(self, monkeypatch): + """IX_API_URL env var overrides the default (e.g. for staging).""" + monkeypatch.setenv("IX_API_URL", "https://app.staging.indexexchange.com/api/deals") + c = IndexExchangeConnector(api_key="key") + assert c._base_url == "https://app.staging.indexexchange.com/api/deals" # --------------------------------------------------------------------------- @@ -189,10 +189,14 @@ def test_base_url_default(self): class TestNormalizeDeal: - """Tests for IX API response field mapping to DealStore schema.""" + """Tests for Index API response field mapping to DealStore schema.""" def setup_method(self): - self.connector = IndexExchangeConnector(api_key="key", seat_id="seat") + # Explicit base_url so these normalization tests aren't sensitive to + # a real IX_API_URL set in the developer's own environment/.env. + self.connector = IndexExchangeConnector( + api_key="key", base_url="https://app.indexexchange.com/api/deals" + ) def _pg_deal(self) -> dict[str, Any]: return load_fixture("index_exchange_deals_response.json")["deals"][0] @@ -200,7 +204,7 @@ def _pg_deal(self) -> dict[str, Any]: def _pd_deal(self) -> dict[str, Any]: return load_fixture("index_exchange_deals_response.json")["deals"][1] - def _pmp_deal(self) -> dict[str, Any]: + def _mp_deal(self) -> dict[str, Any]: return load_fixture("index_exchange_deals_response.json")["deals"][2] def _paused_deal(self) -> dict[str, Any]: @@ -208,7 +212,7 @@ def _paused_deal(self) -> dict[str, Any]: # Required fields — identity def test_seller_deal_id_mapped(self): - """dealId → seller_deal_id.""" + """externalDealID → seller_deal_id.""" normalized = self.connector._normalize_deal(self._pg_deal()) assert normalized["seller_deal_id"] == "IX-PG-2026-001" @@ -218,7 +222,7 @@ def test_display_name_mapped(self): assert normalized["display_name"] == "Premium News PG Package" def test_product_id_equals_deal_id(self): - """product_id is set to the dealId.""" + """product_id is set to the externalDealID.""" normalized = self.connector._normalize_deal(self._pg_deal()) assert normalized["product_id"] == "IX-PG-2026-001" @@ -235,28 +239,44 @@ def test_seller_type_hardcoded(self): def test_seller_url_hardcoded(self): """seller_url is Index Exchange API base URL.""" normalized = self.connector._normalize_deal(self._pg_deal()) - assert normalized["seller_url"] == "https://api.indexexchange.com" + assert normalized["seller_url"] == "https://app.indexexchange.com/api/deals" + + def test_seller_domain_always_none(self): + """seller_domain is always None — no publisherDomain field on this API.""" + normalized = self.connector._normalize_deal(self._pg_deal()) + assert normalized["seller_domain"] is None + + def test_internal_deal_id_preserved_in_description(self): + """internalDealID is preserved (in description) for future write ops.""" + normalized = self.connector._normalize_deal(self._pg_deal()) + assert normalized["description"] == "44001" - # Deal type normalization - def test_deal_type_pg_passthrough(self): - """dealType 'PG' → 'PG'.""" + # Deal type derivation + def test_deal_type_pg(self): + """classID=1, programmaticGuaranteed=true → 'PG'.""" normalized = self.connector._normalize_deal(self._pg_deal()) assert normalized["deal_type"] == "PG" - def test_deal_type_pd_passthrough(self): - """dealType 'PD' → 'PD'.""" + def test_deal_type_pd(self): + """classID=1, programmaticGuaranteed=false, auctionType=fixed → 'PD'.""" normalized = self.connector._normalize_deal(self._pd_deal()) assert normalized["deal_type"] == "PD" - def test_deal_type_pmp_to_pa(self): - """dealType 'PMP' → 'PA' (Private Auction).""" - normalized = self.connector._normalize_deal(self._pmp_deal()) + def test_deal_type_marketplace_package_maps_to_pa(self): + """classID=4 (Marketplace Package) → 'PA' (no 'PMP' in VALID_DEAL_TYPES).""" + normalized = self.connector._normalize_deal(self._mp_deal()) assert normalized["deal_type"] == "PA" - def test_deal_type_unknown_raises_value_error(self): - """Unknown dealType raises ValueError.""" - raw = {**self._pg_deal(), "dealType": "UNKNOWN_TYPE"} - with pytest.raises(ValueError, match="Unrecognized Index Exchange deal type"): + def test_deal_type_missing_class_id_raises_value_error(self): + """Missing classID raises ValueError.""" + raw = {k: v for k, v in self._pg_deal().items() if k != "classID"} + with pytest.raises(ValueError, match="Missing classID"): + self.connector._normalize_deal(raw) + + def test_deal_type_unrecognized_class_id_raises_value_error(self): + """Unrecognized classID raises ValueError.""" + raw = {**self._pg_deal(), "classID": 99} + with pytest.raises(ValueError, match="Unrecognized Index Exchange classID"): self.connector._normalize_deal(raw) # Status normalization @@ -265,117 +285,104 @@ def test_status_active_passthrough(self): normalized = self.connector._normalize_deal(self._pg_deal()) assert normalized["status"] == "active" - def test_status_inactive_to_paused(self): - """status 'inactive' → 'paused'.""" + def test_status_paused_passthrough(self): + """status 'paused' → 'paused'.""" normalized = self.connector._normalize_deal(self._paused_deal()) assert normalized["status"] == "paused" - def test_status_pending_to_imported(self): - """status 'pending' → 'imported'.""" - raw = {**self._pg_deal(), "status": "pending"} + def test_status_expired_maps_to_paused(self): + """status 'expired' → 'paused' (terminal state, no DealStore equivalent).""" + raw = {**self._pg_deal(), "status": "expired"} + normalized = self.connector._normalize_deal(raw) + assert normalized["status"] == "paused" + + def test_status_auto_paused_maps_to_paused(self): + """status 'auto-paused' → 'paused'.""" + raw = {**self._pg_deal(), "status": "auto-paused"} normalized = self.connector._normalize_deal(raw) - assert normalized["status"] == "imported" + assert normalized["status"] == "paused" - def test_status_unknown_defaults_to_imported(self): - """Unrecognized status defaults to 'imported'.""" + def test_status_unknown_defaults_to_paused(self): + """Unrecognized status defaults to 'paused' — 'imported' is not a + valid DealStore status.""" raw = {**self._pg_deal(), "status": "some_weird_status"} normalized = self.connector._normalize_deal(raw) - assert normalized["status"] == "imported" + assert normalized["status"] == "paused" - # Pricing fields - def test_price_mapped_to_fixed_cpm_for_pg(self): - """price → fixed_price_cpm for PG deals.""" + # Pricing fields — single `floor`, split by fixed-price (PG/PD) vs auction + def test_floor_mapped_to_fixed_cpm_for_pg(self): + """floor → fixed_price_cpm for PG deals; bid_floor_cpm is None.""" normalized = self.connector._normalize_deal(self._pg_deal()) assert normalized["fixed_price_cpm"] == 52.00 + assert normalized["bid_floor_cpm"] is None - def test_price_mapped_to_fixed_cpm_for_pd(self): - """price → fixed_price_cpm for PD deals.""" + def test_floor_mapped_to_fixed_cpm_for_pd(self): + """floor → fixed_price_cpm for PD deals (auctionType 'fixed'); bid_floor_cpm is None.""" normalized = self.connector._normalize_deal(self._pd_deal()) - assert normalized["fixed_price_cpm"] == 18.50 + assert normalized["deal_type"] == "PD" + assert normalized["fixed_price_cpm"] == 14.00 + assert normalized["bid_floor_cpm"] is None - def test_null_price_is_none_for_pmp(self): - """null price → fixed_price_cpm is None (PMP deal).""" - normalized = self.connector._normalize_deal(self._pmp_deal()) + def test_floor_mapped_to_bid_floor_for_pa(self): + """floor → bid_floor_cpm for auction (PA) deals; fixed_price_cpm is None.""" + raw = {**self._mp_deal(), "floor": 9.5} + normalized = self.connector._normalize_deal(raw) + assert normalized["deal_type"] == "PA" + assert normalized["bid_floor_cpm"] == 9.5 assert normalized["fixed_price_cpm"] is None - def test_floor_price_mapped(self): - """floorPrice → bid_floor_cpm.""" - normalized = self.connector._normalize_deal(self._pd_deal()) - assert normalized["bid_floor_cpm"] == 14.00 - - def test_null_floor_price_is_none(self): - """null floorPrice → bid_floor_cpm is None.""" - normalized = self.connector._normalize_deal(self._pg_deal()) + def test_null_floor_is_none(self): + """null floor → both price fields are None.""" + normalized = self.connector._normalize_deal(self._mp_deal()) + assert normalized["fixed_price_cpm"] is None assert normalized["bid_floor_cpm"] is None - def test_currency_mapped(self): - """currency field is passed through.""" + def test_currency_always_usd(self): + """currency is always 'USD' — not returned by the API.""" normalized = self.connector._normalize_deal(self._pg_deal()) assert normalized["currency"] == "USD" - def test_missing_currency_defaults_usd(self): - """Missing currency field defaults to 'USD'.""" - raw = {k: v for k, v in self._pg_deal().items() if k != "currency"} - normalized = self.connector._normalize_deal(raw) - assert normalized["currency"] == "USD" - - # Media type normalization - def test_ad_type_display_maps_to_digital(self): - """adType 'display' → media_type 'DIGITAL'.""" + # Media type / formats — fields that don't exist on this API + def test_media_type_always_none(self): + """media_type is always None — no adType field on this API.""" normalized = self.connector._normalize_deal(self._pg_deal()) - assert normalized["media_type"] == "DIGITAL" - - def test_ad_type_video_maps_to_ctv(self): - """adType 'video' → media_type 'CTV'.""" - normalized = self.connector._normalize_deal(self._pmp_deal()) - assert normalized["media_type"] == "CTV" - - def test_missing_ad_type_is_none(self): - """Missing adType → media_type is None.""" - raw = {k: v for k, v in self._pg_deal().items() if k != "adType"} - normalized = self.connector._normalize_deal(raw) assert normalized["media_type"] is None - # Inventory / targeting fields - def test_publisher_domain_mapped(self): - """publisherDomain → seller_domain.""" + def test_formats_always_none(self): + """formats is always None — no formats array on this API.""" normalized = self.connector._normalize_deal(self._pg_deal()) - assert normalized["seller_domain"] == "news.example.com" + assert normalized["formats"] is None - def test_null_publisher_domain_is_none(self): - """null publisherDomain → seller_domain is None.""" - normalized = self.connector._normalize_deal(self._pmp_deal()) - assert normalized["seller_domain"] is None - - def test_geo_targets_comma_separated(self): - """targeting.geo → geo_targets as comma-separated string.""" + # Targeting — structured array, filtered to geo keyNames + def test_geo_targets_collected_from_country_key(self): + """targeting[].keyName='Country' values → geo_targets, comma-joined.""" normalized = self.connector._normalize_deal(self._pg_deal()) - assert normalized["geo_targets"] is not None - assert "US" in normalized["geo_targets"] - assert "CA" in normalized["geo_targets"] + assert normalized["geo_targets"] == "US, CA" - def test_content_categories_comma_separated(self): - """targeting.contentCategories → content_categories as comma-separated string.""" - normalized = self.connector._normalize_deal(self._pg_deal()) - assert normalized["content_categories"] is not None - assert "IAB12" in normalized["content_categories"] + def test_geo_targets_collected_from_domain_key(self): + """targeting[].keyName='domain' also counts as a geo key.""" + normalized = self.connector._normalize_deal(self._paused_deal()) + assert normalized["geo_targets"] == "example.com" - def test_audience_segments_comma_separated(self): - """targeting.audiences → audience_segments as comma-separated string.""" - normalized = self.connector._normalize_deal(self._pmp_deal()) - assert normalized["audience_segments"] is not None - assert "in-market-auto" in normalized["audience_segments"] + def test_empty_targeting_gives_none_geo_targets(self): + """Empty targeting array → geo_targets is None.""" + normalized = self.connector._normalize_deal(self._pd_deal()) + assert normalized["geo_targets"] is None - def test_empty_audiences_is_none(self): - """Empty targeting.audiences list → audience_segments is None.""" - normalized = self.connector._normalize_deal(self._pg_deal()) - assert normalized["audience_segments"] is None + def test_non_geo_targeting_key_ignored(self): + """A custom, non-geo keyName produces no geo_targets.""" + normalized = self.connector._normalize_deal(self._mp_deal()) + assert normalized["geo_targets"] is None - def test_formats_comma_separated(self): - """formats list → formats as comma-separated string.""" + def test_content_categories_always_none(self): + """content_categories is always None — no confirmed standard key.""" normalized = self.connector._normalize_deal(self._pg_deal()) - assert normalized["formats"] is not None - assert "display_banner" in normalized["formats"] + assert normalized["content_categories"] is None + + def test_audience_segments_always_none(self): + """audience_segments is always None — no confirmed standard key.""" + normalized = self.connector._normalize_deal(self._mp_deal()) + assert normalized["audience_segments"] is None # Date fields def test_start_date_mapped(self): @@ -388,32 +395,26 @@ def test_end_date_mapped(self): normalized = self.connector._normalize_deal(self._pg_deal()) assert normalized["flight_end"] == "2026-06-30" - # Impressions - def test_impressions_mapped_pg(self): - """impressions mapped for PG deal.""" + # Impressions — only populated for PG deals, via directConfigurations + def test_impressions_mapped_from_impression_goal_for_pg(self): + """directConfigurations.impressionGoal → impressions for PG deal.""" normalized = self.connector._normalize_deal(self._pg_deal()) assert normalized["impressions"] == 3000000 - def test_null_impressions_is_none(self): - """null impressions → None.""" + def test_impressions_none_when_no_impression_goal(self): + """Missing directConfigurations.impressionGoal → impressions is None.""" normalized = self.connector._normalize_deal(self._pd_deal()) assert normalized["impressions"] is None - # Description - def test_description_mapped(self): - """description → description.""" - normalized = self.connector._normalize_deal(self._pg_deal()) - assert normalized["description"] == "Guaranteed news homepage display inventory" - - def test_null_description_is_none(self): - """null description → None.""" - normalized = self.connector._normalize_deal(self._pmp_deal()) - assert normalized["description"] is None + def test_impressions_none_when_no_direct_configurations(self): + """Marketplace deals (no directConfigurations) → impressions is None.""" + normalized = self.connector._normalize_deal(self._mp_deal()) + assert normalized["impressions"] is None # Required field validation - def test_missing_deal_id_raises_key_error(self): - """Missing dealId raises KeyError.""" - raw = {k: v for k, v in self._pg_deal().items() if k != "dealId"} + def test_missing_external_deal_id_raises_key_error(self): + """Missing externalDealID raises KeyError.""" + raw = {k: v for k, v in self._pg_deal().items() if k != "externalDealID"} with pytest.raises(KeyError): self.connector._normalize_deal(raw) @@ -479,12 +480,7 @@ def test_fetch_deals_deal_fields_correct(self): def test_fetch_deals_empty_response(self): """fetch_deals() handles empty deals list.""" - transport = _MockTransport( - _make_response( - 200, - {"status": "success", "total": 0, "page": 1, "pageSize": 100, "deals": []}, - ) - ) + transport = _MockTransport(_make_response(200, {"totalCount": 0, "deals": []})) connector = _connector_with_transport(transport) result = connector.fetch_deals() assert result.successful == 0 @@ -512,34 +508,88 @@ def test_status_filter_sent_as_query_param(self): assert transport.last_request is not None assert "status=active" in str(transport.last_request.url) - def test_deal_type_filter_sent_as_query_param(self): - """deal_type kwarg is sent as ?deal_type= query param.""" + def test_invalid_status_filter_raises_value_error(self): + """An unrecognized status filter raises ValueError before any request.""" + connector, transport = self._recording_connector() + with pytest.raises(ValueError, match="Invalid status filter"): + connector.fetch_deals(status="inactive") + assert transport.last_request is None + + def test_class_ids_filter_sent_as_query_param(self): + """class_ids kwarg is sent as ?classIDs= query param(s).""" + connector, transport = self._recording_connector() + connector.fetch_deals(class_ids=[1]) + assert transport.last_request is not None + assert "classIDs=1" in str(transport.last_request.url) + + def test_multiple_class_ids_sent_as_repeated_param(self): + """class_ids=[1, 3] produces two classIDs query values.""" + connector, transport = self._recording_connector() + connector.fetch_deals(class_ids=[1, 3]) + assert transport.last_request is not None + url_str = str(transport.last_request.url) + assert "classIDs=1" in url_str + assert "classIDs=3" in url_str + + def test_account_ids_filter_sent_as_query_param(self): + """account_ids kwarg is sent as ?accountIDs= query param(s).""" connector, transport = self._recording_connector() - connector.fetch_deals(deal_type="PG") + connector.fetch_deals(account_ids=[1470498]) assert transport.last_request is not None - assert "deal_type=PG" in str(transport.last_request.url) + assert "accountIDs=1470498" in str(transport.last_request.url) - def test_page_size_sent_as_query_param(self): - """page_size kwarg is sent as ?page_size= query param.""" + def test_multiple_account_ids_sent_as_repeated_param(self): + """account_ids=[1, 2] produces two accountIDs query values.""" + connector, transport = self._recording_connector() + connector.fetch_deals(account_ids=[1, 2]) + assert transport.last_request is not None + url_str = str(transport.last_request.url) + assert "accountIDs=1" in url_str + assert "accountIDs=2" in url_str + + def test_no_account_ids_param_when_empty(self): + """Omitted account_ids does not add an accountIDs query param — + confirms an unscoped call is possible, not that it's advisable.""" + connector, transport = self._recording_connector() + connector.fetch_deals() + assert transport.last_request is not None + assert "accountIDs" not in str(transport.last_request.url) + + def test_page_size_sent_as_page_size_param(self): + """page_size kwarg is sent as ?pageSize= query param.""" connector, transport = self._recording_connector() connector.fetch_deals(page_size=50) assert transport.last_request is not None - assert "page_size=50" in str(transport.last_request.url) + assert "pageSize=50" in str(transport.last_request.url) - def test_api_key_sent_in_header(self): - """X-API-Key header is included in the request.""" + def test_page_offset_starts_at_zero(self): + """First request uses pageOffset=0 (0-indexed).""" connector, transport = self._recording_connector() connector.fetch_deals() assert transport.last_request is not None - api_key_header = transport.last_request.headers.get("x-api-key", "") - assert api_key_header == "test-ix-api-key" + assert "pageOffset=0" in str(transport.last_request.url) - def test_seat_id_sent_in_query_params(self): - """seatId is sent as a query parameter.""" + def test_authorization_bearer_header_sent(self): + """Authorization: Bearer header is included in the request.""" connector, transport = self._recording_connector() connector.fetch_deals() assert transport.last_request is not None - assert "seatId=seat-ix-456" in str(transport.last_request.url) + auth_header = transport.last_request.headers.get("authorization", "") + assert auth_header == "Bearer test-ix-api-key" + + def test_no_x_api_key_header(self): + """X-API-Key header is never sent — Authorization: Bearer only.""" + connector, transport = self._recording_connector() + connector.fetch_deals() + assert transport.last_request is not None + assert "x-api-key" not in transport.last_request.headers + + def test_no_seat_id_param(self): + """seatId is never sent — GET /v3/deals has no such parameter.""" + connector, transport = self._recording_connector() + connector.fetch_deals() + assert transport.last_request is not None + assert "seatId" not in str(transport.last_request.url) def test_no_status_filter_when_all(self): """status='all' does not add status query param.""" @@ -548,12 +598,12 @@ def test_no_status_filter_when_all(self): assert transport.last_request is not None assert "status=all" not in str(transport.last_request.url) - def test_no_deal_type_filter_when_all(self): - """deal_type='all' does not add deal_type query param.""" + def test_no_class_ids_param_when_empty(self): + """Omitted class_ids does not add a classIDs query param.""" connector, transport = self._recording_connector() - connector.fetch_deals(deal_type="all") + connector.fetch_deals() assert transport.last_request is not None - assert "deal_type=all" not in str(transport.last_request.url) + assert "classIDs" not in str(transport.last_request.url) # --------------------------------------------------------------------------- @@ -584,78 +634,45 @@ def test_duplicate_deal_ids_skipped(self): # --------------------------------------------------------------------------- +def _paging_deal(external_id: str) -> dict[str, Any]: + return { + "externalDealID": external_id, + "internalDealID": 1, + "name": f"Deal {external_id}", + "status": "active", + "classID": 1, + "floor": 10.0, + "account": {"accountID": 1}, + "auctionType": "fixed", + "directConfigurations": {"dspID": 1, "programmaticGuaranteed": True}, + "targeting": [], + } + + class TestFetchDealsPagination: - """Tests for multi-page fetch behavior.""" + """Tests for multi-page fetch behavior, driven by totalCount.""" def test_fetches_multiple_pages(self): - """fetch_deals() follows pagination until no more pages.""" - page1 = { - "status": "success", - "total": 3, - "page": 1, - "pageSize": 2, - "deals": [ - { - "dealId": "IX-001", - "name": "Deal 1", - "status": "active", - "dealType": "PG", - "floorPrice": None, - "price": 10.0, - "currency": "USD", - "publisherDomain": "pub1.example.com", - "adType": "display", - "startDate": "2026-01-01", - "endDate": "2026-12-31", - "impressions": 1000000, - "description": None, - "targeting": {"geo": ["US"], "contentCategories": [], "audiences": []}, - "formats": ["display_banner"], - }, - { - "dealId": "IX-002", - "name": "Deal 2", - "status": "active", - "dealType": "PMP", - "floorPrice": 5.0, - "price": None, - "currency": "USD", - "publisherDomain": None, - "adType": "display", - "startDate": "2026-01-01", - "endDate": "2026-12-31", - "impressions": None, - "description": None, - "targeting": {"geo": ["US"], "contentCategories": [], "audiences": []}, - "formats": ["display_banner"], - }, - ], - } - page2 = { - "status": "success", - "total": 3, - "page": 2, - "pageSize": 2, - "deals": [ - { - "dealId": "IX-003", - "name": "Deal 3", - "status": "active", - "dealType": "PD", - "floorPrice": 3.0, - "price": 8.0, - "currency": "USD", - "publisherDomain": "pub3.example.com", - "adType": "display", - "startDate": "2026-01-01", - "endDate": "2026-12-31", - "impressions": None, - "description": None, - "targeting": {"geo": ["US"], "contentCategories": [], "audiences": []}, - "formats": ["display_banner"], - } - ], - } + """fetch_deals() follows pagination until pageOffset*pageSize >= totalCount.""" + + def _deal(external_id: str) -> dict[str, Any]: + return { + "externalDealID": external_id, + "internalDealID": 1, + "name": f"Deal {external_id}", + "status": "active", + "classID": 1, + "floor": 10.0, + "startDate": "2026-01-01", + "endDate": "2026-12-31", + "account": {"accountID": 1}, + "auctionType": "fixed", + "directConfigurations": {"dspID": 1, "programmaticGuaranteed": True}, + "targeting": [], + } + + page1 = {"totalCount": 3, "deals": [_deal("IX-001"), _deal("IX-002")]} + page2 = {"totalCount": 3, "deals": [_deal("IX-003")]} transport = _MultiPageTransport( _make_response(200, page1), _make_response(200, page2), @@ -665,6 +682,121 @@ def test_fetches_multiple_pages(self): assert result.successful == 3 assert len(result.deals) == 3 + def test_missing_total_count_keeps_paging_until_short_page(self): + """A response without totalCount must not silently end pagination after page 1.""" + + def _deal(external_id: str) -> dict[str, Any]: + return { + "externalDealID": external_id, + "internalDealID": 1, + "name": f"Deal {external_id}", + "status": "active", + "classID": 1, + "floor": 10.0, + "account": {"accountID": 1}, + "auctionType": "fixed", + "directConfigurations": {"dspID": 1, "programmaticGuaranteed": True}, + "targeting": [], + } + + transport = _MultiPageTransport( + _make_response(200, {"deals": [_deal("IX-001"), _deal("IX-002")]}), + _make_response(200, {"deals": [_deal("IX-003")]}), + ) + connector = _connector_with_transport(transport) + result = connector.fetch_deals(page_size=2) + assert result.successful == 3 + + def test_repeated_full_pages_without_total_count_stop(self): + """An API that ignores pageOffset must not be paged forever.""" + deals = [_paging_deal("IX-001"), _paging_deal("IX-002")] + transport = _RecordingTransport(_make_response(200, {"deals": deals})) + connector = _connector_with_transport(transport) + result = connector.fetch_deals(page_size=2) + assert result.successful == 2 + assert result.skipped == 2 + assert any("ignoring pageOffset" in e for e in result.errors) + + def test_repeated_full_pages_with_total_count_stop(self): + deals = [_paging_deal("IX-001"), _paging_deal("IX-002")] + transport = _RecordingTransport(_make_response(200, {"totalCount": 10**6, "deals": deals})) + connector = _connector_with_transport(transport) + result = connector.fetch_deals(page_size=2) + assert result.successful == 2 + assert any("ignoring pageOffset" in e for e in result.errors) + + def test_page_cap_stops_endless_distinct_pages(self, monkeypatch): + from ad_buyer.tools.deal_library.connectors import index_exchange + + monkeypatch.setattr(index_exchange, "_MAX_PAGES", 3) + + class _EndlessTransport(httpx.BaseTransport): + def handle_request(self, request: httpx.Request) -> httpx.Response: + offset = request.url.params["pageOffset"] + return _make_response(200, {"deals": [_paging_deal(f"IX-{offset}")]}) + + connector = _connector_with_transport(_EndlessTransport()) + result = connector.fetch_deals(page_size=1) + assert result.successful == 3 + assert any("after 3 pages" in e for e in result.errors) + + @pytest.mark.parametrize("bad", [0, -5, "10", None, 1.5, True]) + def test_invalid_page_size_is_rejected(self, bad): + transport = _RecordingTransport(_make_response(200, {"totalCount": 0, "deals": []})) + connector = _connector_with_transport(transport) + with pytest.raises(ValueError, match="page_size"): + connector.fetch_deals(page_size=bad) + assert transport.last_request is None + + @pytest.mark.parametrize("bad", [["active"], 5, None]) + def test_non_string_status_filter_raises_value_error(self, bad): + transport = _RecordingTransport(_make_response(200, {"totalCount": 0, "deals": []})) + connector = _connector_with_transport(transport) + with pytest.raises(ValueError, match="status"): + connector.fetch_deals(status=bad) + assert transport.last_request is None + + @pytest.mark.parametrize("key", ["account_ids", "class_ids"]) + @pytest.mark.parametrize("bad", ["1470498", 1470498, [1, "2"], [True], [1.5], {"a": 1}]) + def test_non_int_list_id_filters_are_rejected(self, key, bad): + transport = _RecordingTransport(_make_response(200, {"totalCount": 0, "deals": []})) + connector = _connector_with_transport(transport) + with pytest.raises(ValueError, match=key): + connector.fetch_deals(**{key: bad}) + assert transport.last_request is None + + def test_int_list_id_filters_are_sent(self): + transport = _RecordingTransport(_make_response(200, {"totalCount": 0, "deals": []})) + connector = _connector_with_transport(transport) + connector.fetch_deals(account_ids=[1470498, 7], class_ids=(1, 4)) + url = str(transport.last_request.url) + assert "accountIDs=1470498" in url and "accountIDs=7" in url + assert "classIDs=1" in url and "classIDs=4" in url + + @pytest.mark.parametrize("empty", [None, [], ()]) + def test_empty_id_filters_mean_no_filter(self, empty): + transport = _RecordingTransport(_make_response(200, {"totalCount": 0, "deals": []})) + connector = _connector_with_transport(transport) + connector.fetch_deals(account_ids=empty, class_ids=empty) + url = str(transport.last_request.url) + assert "accountIDs" not in url and "classIDs" not in url + + def test_unknown_filter_key_is_rejected(self): + """A typo'd filter must raise, not fall back to an unscoped platform-wide fetch.""" + transport = _RecordingTransport(_make_response(200, {"totalCount": 0, "deals": []})) + connector = _connector_with_transport(transport) + with pytest.raises(ValueError, match="account_id"): + connector.fetch_deals(account_id=[123]) + assert transport.last_request is None + + def test_page_size_capped_at_2000(self): + """page_size above the API max (2000) is capped, not sent as-is.""" + transport = _RecordingTransport(_make_response(200, {"totalCount": 0, "deals": []})) + connector = _connector_with_transport(transport) + connector.fetch_deals(page_size=5000) + assert transport.last_request is not None + assert "pageSize=2000" in str(transport.last_request.url) + # --------------------------------------------------------------------------- # fetch_deals() — HTTP error handling @@ -734,7 +866,7 @@ def handle_request(self, request: httpx.Request) -> httpx.Response: def test_normalization_error_captured_not_raised(self): """Deals that fail normalization are counted in failed, not raised.""" - bad_deal = {"name": "Missing dealId — should fail normalization"} + bad_deal = {"name": "Missing externalDealID — should fail normalization"} fixture_data = load_fixture("index_exchange_deals_response.json") fixture_data = {**fixture_data, "deals": fixture_data["deals"] + [bad_deal]}