From 550aae04e7a763e5552e939bd1e78666d9f5ade1 Mon Sep 17 00:00:00 2001 From: numaras Date: Fri, 11 Sep 2026 18:53:04 -0300 Subject: [PATCH 1/8] feat: list stored deals over REST and make export read them Both booking paths persist deals under deal:, but no route enumerated them: export_deals read a deal_index key that nothing writes, so GET /api/v1/deals/export always returned an empty list. - Add GET /api/v1/deals (optional ?status=) returning DealListResponse, a page of DealBookingResponse envelopes, registered with the other literal deal routes ahead of the {deal_id} catch-all. - Back it with deal_service.list_deals(), which uses the storage backend's deal:* scan; export_deals now reads the same set. - Regenerate docs/reference/endpoints.md. Relies on #75 for the wire mapping of the template path's internal "confirmed" status. docs/api/openapi.json is not touched here: the committed file predates several routes, and a separate docs PR regenerates it from the app. --- docs/reference/endpoints.md | 3 +- src/ad_seller/interfaces/api/routers/deals.py | 23 ++++ src/ad_seller/services/deal_service.py | 27 +++-- tests/unit/test_deal_list_route.py | 111 ++++++++++++++++++ tests/unit/test_route_shadowing.py | 3 + 5 files changed, 153 insertions(+), 14 deletions(-) create mode 100644 tests/unit/test_deal_list_route.py diff --git a/docs/reference/endpoints.md b/docs/reference/endpoints.md index b164b2a1..63888903 100644 --- a/docs/reference/endpoints.md +++ b/docs/reference/endpoints.md @@ -6,7 +6,7 @@ Routes registered on the FastAPI application (`ad_seller.interfaces.api.main:app`), enumerated from the router table. -**Total: 88 endpoints** +**Total: 89 endpoints** | Method | Path | Handler | | --- | --- | --- | @@ -21,6 +21,7 @@ Routes registered on the FastAPI application | `GET` | `/api/v1/curators` | `list_curators` | | `POST` | `/api/v1/curators` | `register_curator` | | `GET` | `/api/v1/curators/{curator_id}` | `get_curator` | +| `GET` | `/api/v1/deals` | `list_deals` | | `POST` | `/api/v1/deals` | `book_deal` | | `POST` | `/api/v1/deals/bulk` | `bulk_deal_operations` | | `POST` | `/api/v1/deals/curated` | `create_curated_deal` | diff --git a/src/ad_seller/interfaces/api/routers/deals.py b/src/ad_seller/interfaces/api/routers/deals.py index 1a791edf..f61d6971 100644 --- a/src/ad_seller/interfaces/api/routers/deals.py +++ b/src/ad_seller/interfaces/api/routers/deals.py @@ -18,6 +18,7 @@ from fastapi import APIRouter, Depends, HTTPException from iab_agentic_primitives.protocol import DealBookingRequest, DealBookingResponse +from pydantic import BaseModel from ....services import deal_service from .. import contract_mappers as cm @@ -283,6 +284,28 @@ async def agentic_audience_match(request: AgenticAudienceMatchRequest): return deal_service.match_agentic_audience(request.audience_ref) +class DealListResponse(BaseModel): + """Page of stored deals in the shared booking-response shape.""" + + deals: list[DealBookingResponse] + count: int + + +@router.get("/api/v1/deals", tags=["Deal Booking"], response_model=DealListResponse) +async def list_deals(status: Optional[str] = None) -> DealListResponse: + """List stored deals, optionally filtered by status. + + Registered ahead of ``/api/v1/deals/{deal_id}``; the two paths differ in + length so there is no shadowing, but keeping literal routes together + with ``/export`` keeps the EP-8.4 ordering rule easy to audit. + """ + deals = await deal_service.list_deals(status=status) + return DealListResponse( + deals=[cm.internal_deal_to_response(d) for d in deals], + count=len(deals), + ) + + @router.get("/api/v1/deals/export", tags=["Deal Booking"]) async def export_deals( format: str = "generic", diff --git a/src/ad_seller/services/deal_service.py b/src/ad_seller/services/deal_service.py index d099d226..5609b066 100644 --- a/src/ad_seller/services/deal_service.py +++ b/src/ad_seller/services/deal_service.py @@ -823,23 +823,24 @@ def _result(index: int, action: str, success: bool, deal_id=None, error=None): # ============================================================================= -async def export_deals(format: str = "generic", status: Optional[str] = None) -> dict[str, Any]: - """Export deals in DSP-native format for platform connectors.""" +async def list_deals(status: Optional[str] = None) -> list[dict[str, Any]]: + """Return every stored deal, optionally filtered by status. + + Both booking paths persist deals under ``deal:``; the storage + backend enumerates them via ``list_deals()`` (a ``deal:*`` key scan). + """ from ..storage.factory import get_storage storage = await get_storage() + deals = await storage.list_deals() + if status: + deals = [d for d in deals if d.get("status") == status] + return deals - # Collect all deals (scan deal:* keys) - all_deals = [] - # Storage doesn't have a list_deals method, so we track deal IDs - deal_index = await storage.get("deal_index") or {"deal_ids": []} - - for deal_id in deal_index.get("deal_ids", []): - deal = await storage.get_deal(deal_id) - if deal: - if status and deal.get("status") != status: - continue - all_deals.append(deal) + +async def export_deals(format: str = "generic", status: Optional[str] = None) -> dict[str, Any]: + """Export deals in DSP-native format for platform connectors.""" + all_deals = await list_deals(status=status) if format == "ttd": # The Trade Desk format diff --git a/tests/unit/test_deal_list_route.py b/tests/unit/test_deal_list_route.py new file mode 100644 index 00000000..d8d5a871 --- /dev/null +++ b/tests/unit/test_deal_list_route.py @@ -0,0 +1,111 @@ +"""GET /api/v1/deals lists stored deals; export reads the same set. + +Deals are persisted under ``deal:`` keys by both booking paths, but no +route enumerated them: ``export_deals`` read a ``deal_index`` key that nothing +writes, so it always returned an empty list. These tests pin a list route and +make export read the stored deals. +""" + +import sys +from types import ModuleType +from unittest.mock import AsyncMock, patch + +import pytest + +_broken_flows = ["ad_seller.flows.execution_activation_flow"] +for _mod_name in _broken_flows: + if _mod_name not in sys.modules: + _stub = ModuleType(_mod_name) + _cls_name = _mod_name.rsplit(".", 1)[-1].replace("_", " ").title().replace(" ", "") + setattr(_stub, _cls_name, type(_cls_name, (), {})) + sys.modules[_mod_name] = _stub + +import httpx # noqa: E402 +from httpx import ASGITransport # noqa: E402 + +from ad_seller.interfaces.api.main import _get_optional_api_key_record, app # noqa: E402 + + +def _deal(deal_id: str, status: str, deal_type: str = "PD") -> dict: + return { + "deal_id": deal_id, + "deal_type": deal_type, + "status": status, + "quote_id": f"qt-{deal_id}", + "product": {"product_id": "ctv-premium", "name": "CTV", "inventory_type": "ctv"}, + "pricing": {"base_cpm": 30.0, "final_cpm": 30.0, "currency": "USD"}, + "terms": { + "impressions": 1000000, + "flight_start": "2026-04-01", + "flight_end": "2026-04-30", + }, + } + + +@pytest.fixture +def mock_storage(): + store = {} + storage = AsyncMock() + storage.get = AsyncMock(side_effect=lambda k: store.get(k)) + storage.set = AsyncMock(side_effect=lambda k, v, ttl=None: store.__setitem__(k, v)) + storage.get_deal = AsyncMock(side_effect=lambda did: store.get(f"deal:{did}")) + storage.set_deal = AsyncMock( + side_effect=lambda did, data: store.__setitem__(f"deal:{did}", data) + ) + storage.list_deals = AsyncMock( + side_effect=lambda: [v for k, v in store.items() if k.startswith("deal:")] + ) + storage._store = store + return storage + + +@pytest.fixture +def client(): + app.dependency_overrides[_get_optional_api_key_record] = lambda: None + transport = ASGITransport(app=app) + c = httpx.AsyncClient(transport=transport, base_url="http://test") + yield c + app.dependency_overrides.clear() + + +class TestDealList: + async def test_list_route_registered(self): + get_paths = [ + r.path + for r in app.routes + if getattr(r, "path", "") == "/api/v1/deals" and "GET" in getattr(r, "methods", set()) + ] + assert get_paths == ["/api/v1/deals"] + + async def test_list_returns_every_stored_deal(self, client, mock_storage): + mock_storage._store["deal:DEMO-A"] = _deal("DEMO-A", "confirmed") + mock_storage._store["deal:DEMO-B"] = _deal("DEMO-B", "proposed", "PG") + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + resp = await client.get("/api/v1/deals") + + assert resp.status_code == 200 + body = resp.json() + assert body["count"] == 2 + assert sorted(d["deal"]["deal_id"] for d in body["deals"]) == ["DEMO-A", "DEMO-B"] + + async def test_list_filters_by_status(self, client, mock_storage): + mock_storage._store["deal:DEMO-A"] = _deal("DEMO-A", "confirmed") + mock_storage._store["deal:DEMO-B"] = _deal("DEMO-B", "proposed", "PG") + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + resp = await client.get("/api/v1/deals?status=proposed") + + assert resp.status_code == 200 + body = resp.json() + assert body["count"] == 1 + assert body["deals"][0]["deal"]["deal_id"] == "DEMO-B" + + async def test_export_returns_stored_deals(self, client, mock_storage): + mock_storage._store["deal:DEMO-A"] = _deal("DEMO-A", "confirmed") + mock_storage._store["deal:DEMO-B"] = _deal("DEMO-B", "proposed", "PG") + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + resp = await client.get("/api/v1/deals/export?format=generic") + + assert resp.status_code == 200 + body = resp.json() + assert body["count"] == 2 + assert sorted(d["deal_id"] for d in body["deals"]) == ["DEMO-A", "DEMO-B"] diff --git a/tests/unit/test_route_shadowing.py b/tests/unit/test_route_shadowing.py index c6712525..33d4aaf1 100644 --- a/tests/unit/test_route_shadowing.py +++ b/tests/unit/test_route_shadowing.py @@ -52,6 +52,9 @@ def mock_storage(): storage.set_deal = AsyncMock( side_effect=lambda did, data: store.__setitem__(f"deal:{did}", data) ) + storage.list_deals = AsyncMock( + side_effect=lambda: [v for k, v in store.items() if k.startswith("deal:")] + ) storage._store = store return storage From 2735338143367e310823ec4a6c1bf933c0f0757b Mon Sep 17 00:00:00 2001 From: numaras Date: Tue, 15 Sep 2026 15:52:42 -0300 Subject: [PATCH 2/8] refactor: move DealListResponse into the schemas module --- src/ad_seller/interfaces/api/routers/deals.py | 9 +-------- src/ad_seller/interfaces/api/schemas.py | 8 ++++++++ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/ad_seller/interfaces/api/routers/deals.py b/src/ad_seller/interfaces/api/routers/deals.py index f61d6971..37282e40 100644 --- a/src/ad_seller/interfaces/api/routers/deals.py +++ b/src/ad_seller/interfaces/api/routers/deals.py @@ -18,7 +18,6 @@ from fastapi import APIRouter, Depends, HTTPException from iab_agentic_primitives.protocol import DealBookingRequest, DealBookingResponse -from pydantic import BaseModel from ....services import deal_service from .. import contract_mappers as cm @@ -33,6 +32,7 @@ DealDeprecationRequest, DealFromTemplateRequest, DealFromTemplateResponse, + DealListResponse, DealMigrationRequest, DealPerformanceResponse, DealPushRequest, @@ -284,13 +284,6 @@ async def agentic_audience_match(request: AgenticAudienceMatchRequest): return deal_service.match_agentic_audience(request.audience_ref) -class DealListResponse(BaseModel): - """Page of stored deals in the shared booking-response shape.""" - - deals: list[DealBookingResponse] - count: int - - @router.get("/api/v1/deals", tags=["Deal Booking"], response_model=DealListResponse) async def list_deals(status: Optional[str] = None) -> DealListResponse: """List stored deals, optionally filtered by status. diff --git a/src/ad_seller/interfaces/api/schemas.py b/src/ad_seller/interfaces/api/schemas.py index 9b693b03..cdc39607 100644 --- a/src/ad_seller/interfaces/api/schemas.py +++ b/src/ad_seller/interfaces/api/schemas.py @@ -35,6 +35,7 @@ from iab_agentic_primitives.protocol import ( AvailsStatus as AvailsStatus, # noqa: PLC0414 — explicit re-export ) +from iab_agentic_primitives.protocol import DealBookingResponse from iab_agentic_primitives.protocol import ( ProductAvailsSearch as ProductAvailsSearch, # noqa: PLC0414 — explicit re-export ) @@ -406,6 +407,13 @@ class DealFromTemplateResponse(BaseModel): created_at: str +class DealListResponse(BaseModel): + """Page of stored deals in the shared booking-response shape.""" + + deals: list[DealBookingResponse] + count: int + + class DealRejectionDetail(BaseModel): """Rejection detail when max_cpm is below seller floor.""" From 7f51239c9c832fe2751a74eca377272783bef02d Mon Sep 17 00:00:00 2001 From: numaras Date: Tue, 15 Sep 2026 15:53:30 -0300 Subject: [PATCH 3/8] fix: require an operator key to list stored deals --- src/ad_seller/interfaces/api/routers/deals.py | 8 +++++++- tests/unit/test_deal_list_route.py | 11 ++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/ad_seller/interfaces/api/routers/deals.py b/src/ad_seller/interfaces/api/routers/deals.py index 37282e40..5d34f63c 100644 --- a/src/ad_seller/interfaces/api/routers/deals.py +++ b/src/ad_seller/interfaces/api/routers/deals.py @@ -285,9 +285,15 @@ async def agentic_audience_match(request: AgenticAudienceMatchRequest): @router.get("/api/v1/deals", tags=["Deal Booking"], response_model=DealListResponse) -async def list_deals(status: Optional[str] = None) -> DealListResponse: +async def list_deals( + status: Optional[str] = None, + _operator=Depends(deps._require_operator_api_key_record), +) -> DealListResponse: """List stored deals, optionally filtered by status. + Operator-only: the list spans every buyer's deals. Buyers read their + own deal with ``GET /api/v1/deals/{deal_id}``. + Registered ahead of ``/api/v1/deals/{deal_id}``; the two paths differ in length so there is no shadowing, but keeping literal routes together with ``/export`` keeps the EP-8.4 ordering rule easy to audit. diff --git a/tests/unit/test_deal_list_route.py b/tests/unit/test_deal_list_route.py index d8d5a871..935f3658 100644 --- a/tests/unit/test_deal_list_route.py +++ b/tests/unit/test_deal_list_route.py @@ -8,7 +8,7 @@ import sys from types import ModuleType -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -23,6 +23,7 @@ import httpx # noqa: E402 from httpx import ASGITransport # noqa: E402 +from ad_seller.interfaces.api import deps # noqa: E402 from ad_seller.interfaces.api.main import _get_optional_api_key_record, app # noqa: E402 @@ -62,6 +63,7 @@ def mock_storage(): @pytest.fixture def client(): app.dependency_overrides[_get_optional_api_key_record] = lambda: None + app.dependency_overrides[deps._require_operator_api_key_record] = lambda: MagicMock() transport = ASGITransport(app=app) c = httpx.AsyncClient(transport=transport, base_url="http://test") yield c @@ -77,6 +79,13 @@ async def test_list_route_registered(self): ] assert get_paths == ["/api/v1/deals"] + async def test_list_requires_operator_key(self, client, mock_storage): + app.dependency_overrides.pop(deps._require_operator_api_key_record) + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + resp = await client.get("/api/v1/deals") + + assert resp.status_code == 401 + async def test_list_returns_every_stored_deal(self, client, mock_storage): mock_storage._store["deal:DEMO-A"] = _deal("DEMO-A", "confirmed") mock_storage._store["deal:DEMO-B"] = _deal("DEMO-B", "proposed", "PG") From 1f750b63ec058d2a25da8d6a3d32772fef99a57b Mon Sep 17 00:00:00 2001 From: numaras Date: Tue, 15 Sep 2026 15:54:10 -0300 Subject: [PATCH 4/8] fix: filter the deal list on the typed wire status --- src/ad_seller/interfaces/api/routers/deals.py | 19 ++++++++++++------- tests/unit/test_deal_list_route.py | 11 +++++++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/ad_seller/interfaces/api/routers/deals.py b/src/ad_seller/interfaces/api/routers/deals.py index 5d34f63c..f305d981 100644 --- a/src/ad_seller/interfaces/api/routers/deals.py +++ b/src/ad_seller/interfaces/api/routers/deals.py @@ -17,6 +17,7 @@ from typing import Optional from fastapi import APIRouter, Depends, HTTPException +from iab_agentic_primitives.primitives import DealStatus from iab_agentic_primitives.protocol import DealBookingRequest, DealBookingResponse from ....services import deal_service @@ -286,23 +287,27 @@ async def agentic_audience_match(request: AgenticAudienceMatchRequest): @router.get("/api/v1/deals", tags=["Deal Booking"], response_model=DealListResponse) async def list_deals( - status: Optional[str] = None, + status: DealStatus | None = None, _operator=Depends(deps._require_operator_api_key_record), ) -> DealListResponse: - """List stored deals, optionally filtered by status. + """List stored deals, optionally filtered by wire status. Operator-only: the list spans every buyer's deals. Buyers read their own deal with ``GET /api/v1/deals/{deal_id}``. + ``status`` is the shared :class:`DealStatus` value the deal carries on + the wire (the same value ``GET /api/v1/deals/{deal_id}`` returns), so + the filter is applied after mapping each stored record to its + response shape. + Registered ahead of ``/api/v1/deals/{deal_id}``; the two paths differ in length so there is no shadowing, but keeping literal routes together with ``/export`` keeps the EP-8.4 ordering rule easy to audit. """ - deals = await deal_service.list_deals(status=status) - return DealListResponse( - deals=[cm.internal_deal_to_response(d) for d in deals], - count=len(deals), - ) + deals = [cm.internal_deal_to_response(d) for d in await deal_service.list_deals()] + if status is not None: + deals = [d for d in deals if d.deal.status == status] + return DealListResponse(deals=deals, count=len(deals)) @router.get("/api/v1/deals/export", tags=["Deal Booking"]) diff --git a/tests/unit/test_deal_list_route.py b/tests/unit/test_deal_list_route.py index 935f3658..8c80d35b 100644 --- a/tests/unit/test_deal_list_route.py +++ b/tests/unit/test_deal_list_route.py @@ -108,6 +108,17 @@ async def test_list_filters_by_status(self, client, mock_storage): assert body["count"] == 1 assert body["deals"][0]["deal"]["deal_id"] == "DEMO-B" + async def test_list_filters_on_wire_status(self, client, mock_storage): + mock_storage._store["deal:DEMO-A"] = _deal("DEMO-A", "confirmed") + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + booked = await client.get("/api/v1/deals?status=booked") + internal = await client.get("/api/v1/deals?status=confirmed") + + assert booked.status_code == 200 + assert booked.json()["count"] == 1 + assert booked.json()["deals"][0]["deal"]["deal_id"] == "DEMO-A" + assert internal.status_code == 422 + async def test_export_returns_stored_deals(self, client, mock_storage): mock_storage._store["deal:DEMO-A"] = _deal("DEMO-A", "confirmed") mock_storage._store["deal:DEMO-B"] = _deal("DEMO-B", "proposed", "PG") From 5e2d3dacf71e09430f274095964b7dda6d552e0a Mon Sep 17 00:00:00 2001 From: numaras Date: Tue, 15 Sep 2026 15:54:54 -0300 Subject: [PATCH 5/8] fix: report unserializable stored deals in skipped instead of failing the list --- src/ad_seller/interfaces/api/routers/deals.py | 15 +++++++++++++-- src/ad_seller/interfaces/api/schemas.py | 3 +++ tests/unit/test_deal_list_route.py | 13 +++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/ad_seller/interfaces/api/routers/deals.py b/src/ad_seller/interfaces/api/routers/deals.py index f305d981..6f712d6f 100644 --- a/src/ad_seller/interfaces/api/routers/deals.py +++ b/src/ad_seller/interfaces/api/routers/deals.py @@ -14,6 +14,7 @@ and handler behavior are otherwise unchanged. """ +import logging from typing import Optional from fastapi import APIRouter, Depends, HTTPException @@ -42,6 +43,8 @@ SSPDealDistributeRequest, ) +logger = logging.getLogger(__name__) + router = APIRouter() # Ordered from lowest to highest privilege, mirroring the string values @@ -304,10 +307,18 @@ async def list_deals( length so there is no shadowing, but keeping literal routes together with ``/export`` keeps the EP-8.4 ordering rule easy to audit. """ - deals = [cm.internal_deal_to_response(d) for d in await deal_service.list_deals()] + deals: list[DealBookingResponse] = [] + skipped: list[str] = [] + for record in await deal_service.list_deals(): + try: + deals.append(cm.internal_deal_to_response(record)) + except Exception: + deal_id = str(record.get("deal_id")) + logger.warning("Skipping unserializable stored deal %s", deal_id, exc_info=True) + skipped.append(deal_id) if status is not None: deals = [d for d in deals if d.deal.status == status] - return DealListResponse(deals=deals, count=len(deals)) + return DealListResponse(deals=deals, count=len(deals), skipped=skipped) @router.get("/api/v1/deals/export", tags=["Deal Booking"]) diff --git a/src/ad_seller/interfaces/api/schemas.py b/src/ad_seller/interfaces/api/schemas.py index cdc39607..3cdd437b 100644 --- a/src/ad_seller/interfaces/api/schemas.py +++ b/src/ad_seller/interfaces/api/schemas.py @@ -412,6 +412,9 @@ class DealListResponse(BaseModel): deals: list[DealBookingResponse] count: int + # deal_ids of stored rows that could not be mapped to the wire shape; a + # bad row is reported here instead of taking the whole list down. + skipped: list[str] = [] class DealRejectionDetail(BaseModel): diff --git a/tests/unit/test_deal_list_route.py b/tests/unit/test_deal_list_route.py index 8c80d35b..af661960 100644 --- a/tests/unit/test_deal_list_route.py +++ b/tests/unit/test_deal_list_route.py @@ -119,6 +119,19 @@ async def test_list_filters_on_wire_status(self, client, mock_storage): assert booked.json()["deals"][0]["deal"]["deal_id"] == "DEMO-A" assert internal.status_code == 422 + async def test_list_skips_unserializable_record(self, client, mock_storage): + mock_storage._store["deal:DEMO-A"] = _deal("DEMO-A", "confirmed") + mock_storage._store["deal:DEMO-B"] = _deal("DEMO-B", "weird") + mock_storage._store["deal:DEMO-C"] = _deal("DEMO-C", "confirmed", deal_type=None) + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + resp = await client.get("/api/v1/deals") + + assert resp.status_code == 200 + body = resp.json() + assert body["count"] == 1 + assert body["deals"][0]["deal"]["deal_id"] == "DEMO-A" + assert sorted(body["skipped"]) == ["DEMO-B", "DEMO-C"] + async def test_export_returns_stored_deals(self, client, mock_storage): mock_storage._store["deal:DEMO-A"] = _deal("DEMO-A", "confirmed") mock_storage._store["deal:DEMO-B"] = _deal("DEMO-B", "proposed", "PG") From a58ebe5687ff819efeb2354ad8a2019b02bb0a1b Mon Sep 17 00:00:00 2001 From: numaras Date: Tue, 15 Sep 2026 15:56:57 -0300 Subject: [PATCH 6/8] test: cover the real deal scan and export of deals booked through both routes --- tests/unit/test_deal_list_route.py | 70 ++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/unit/test_deal_list_route.py b/tests/unit/test_deal_list_route.py index af661960..a447e654 100644 --- a/tests/unit/test_deal_list_route.py +++ b/tests/unit/test_deal_list_route.py @@ -25,6 +25,12 @@ from ad_seller.interfaces.api import deps # noqa: E402 from ad_seller.interfaces.api.main import _get_optional_api_key_record, app # noqa: E402 +from ad_seller.storage.sqlite_backend import SQLiteBackend # noqa: E402 +from tests.unit.test_deal_booking_endpoints import ( # noqa: E402 + _authenticate, + _make_available_quote, +) +from tests.unit.test_trust_tier_verification import _mock_catalog # noqa: E402 def _deal(deal_id: str, status: str, deal_type: str = "PD") -> dict: @@ -142,3 +148,67 @@ async def test_export_returns_stored_deals(self, client, mock_storage): body = resp.json() assert body["count"] == 2 assert sorted(d["deal_id"] for d in body["deals"]) == ["DEMO-A", "DEMO-B"] + + async def test_real_backend_scan_lists_stored_deals(self, client, tmp_path): + storage = SQLiteBackend(f"sqlite:///{tmp_path}/t.db") + await storage.connect() + await storage.set_deal("DEMO-A", _deal("DEMO-A", "confirmed")) + await storage.set_deal("DEMO-B", _deal("DEMO-B", "proposed", "PG")) + await storage.set("idempotency:x", {"deal_id": "DEMO-A", "payload_hash": "h"}) + with patch("ad_seller.storage.factory.get_storage", return_value=storage): + resp = await client.get("/api/v1/deals") + + assert resp.status_code == 200 + body = resp.json() + assert body["count"] == 2 + assert sorted(d["deal"]["deal_id"] for d in body["deals"]) == ["DEMO-A", "DEMO-B"] + + async def test_list_empty_store(self, client, mock_storage): + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + resp = await client.get("/api/v1/deals") + + assert resp.status_code == 200 + assert resp.json() == {"deals": [], "count": 0, "skipped": []} + + async def test_list_non_matching_filter(self, client, mock_storage): + mock_storage._store["deal:DEMO-A"] = _deal("DEMO-A", "confirmed") + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + resp = await client.get("/api/v1/deals?status=cancelled") + + assert resp.status_code == 200 + assert resp.json()["count"] == 0 + + async def test_export_lists_deals_booked_through_both_routes(self, client, tmp_path): + storage = SQLiteBackend(f"sqlite:///{tmp_path}/t.db") + await storage.connect() + quote = _make_available_quote() + await storage.set_quote(quote["quote_id"], quote) + _authenticate() + with ( + patch("ad_seller.storage.factory.get_storage", return_value=storage), + patch( + "ad_seller.interfaces.api.main._get_static_product_catalog", + return_value=_mock_catalog(), + ), + ): + booked = await client.post( + "/api/v1/deals", + json={"idempotency_key": "idem-list", "quote_id": quote["quote_id"]}, + ) + templated = await client.post( + "/api/v1/deals/from-template", + json={ + "deal_type": "PD", + "product_id": "ctv-premium-sports", + "impressions": 1000000, + }, + ) + exported = await client.get("/api/v1/deals/export?format=generic") + + assert booked.status_code == 200, booked.text + assert templated.status_code == 201, templated.text + expected = sorted([booked.json()["deal"]["deal_id"], templated.json()["deal_id"]]) + assert exported.status_code == 200 + body = exported.json() + assert body["count"] == 2 + assert sorted(d["deal_id"] for d in body["deals"]) == expected From 1dc7380b2771e86a47d468af365b3fc425783840 Mon Sep 17 00:00:00 2001 From: numaras Date: Tue, 15 Sep 2026 15:57:12 -0300 Subject: [PATCH 7/8] docs: changelog entry for the deal list route and export fix --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dd74345..41c8a7f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ All notable changes to the IAB Tech Lab Seller Agent are documented here. surfacing later as an uncaught `AttributeError` in `apply_change_request`'s `order_meta.update(proposed)`, a 500 with no way to repair the order. +- `GET /api/v1/deals` lists stored deals (operator key; optional + wire-status filter; unserializable rows reported in `skipped`), and + `GET /api/v1/deals/export` now reads stored deals instead of an index + nothing wrote. ### Changed From 2f36bc8926de581b1bc886711d33629a3a02e95d Mon Sep 17 00:00:00 2001 From: numaras Date: Tue, 15 Sep 2026 15:57:24 -0300 Subject: [PATCH 8/8] docs: add the deal list route to the API overview --- docs/api/overview.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api/overview.md b/docs/api/overview.md index dca20603..1c229f45 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -144,6 +144,7 @@ Registry reads are public; mutations require an operator credential. | Method | Path | Summary | |--------|------|---------| | POST | `/api/v1/deals` | Book a deal from a previously issued quote | +| GET | `/api/v1/deals` | List stored deals (operator; optional wire-status filter) | | GET | `/api/v1/deals/{deal_id}` | Get the current status of a deal | ## Orders