diff --git a/src/ad_seller/interfaces/api/contract_mappers.py b/src/ad_seller/interfaces/api/contract_mappers.py index 8893a3f..3d7e9aa 100644 --- a/src/ad_seller/interfaces/api/contract_mappers.py +++ b/src/ad_seller/interfaces/api/contract_mappers.py @@ -350,7 +350,24 @@ def internal_deal_status_to_wire(status: Any) -> DealStatus: def internal_deal_to_shared_deal(data: dict[str, Any]) -> Deal: """Build the shared :class:`Deal` primitive from the internal deal dict.""" - product = data.get("product", {}) + # Template bookings store flat fields. Preserve nested quote-booked + # sections when present, including deliberately empty sections. + product = data.get("product") + if product is None: + product = {"product_id": data.get("product_id", "")} + pricing = data.get("pricing") + if pricing is None: + pricing = { + "final_cpm": data.get("actual_price_cpm"), + "currency": data.get("currency", "USD"), + } + terms = data.get("terms") + if terms is None: + terms = { + "impressions": data.get("impressions"), + "flight_start": data.get("flight_start"), + "flight_end": data.get("flight_end"), + } return Deal( deal_id=data["deal_id"], deal_type=DealType(data["deal_type"]), @@ -361,8 +378,8 @@ def internal_deal_to_shared_deal(data: dict[str, Any]) -> Deal: name=product.get("name", ""), inventory_type=product.get("inventory_type"), ), - pricing=_quote_pricing_to_shared(data.get("pricing", {})), - terms=_quote_terms_to_shared(data.get("terms", {})), + pricing=_quote_pricing_to_shared(pricing), + terms=_quote_terms_to_shared(terms), buyer_tier=AccessTier(data.get("buyer_tier", "public")), seller_id=data.get("seller_id"), expires_at=_parse_dt(data.get("expires_at")), diff --git a/tests/unit/test_deal_status_wire_mapping.py b/tests/unit/test_deal_status_wire_mapping.py index 37763ee..aaadda2 100644 --- a/tests/unit/test_deal_status_wire_mapping.py +++ b/tests/unit/test_deal_status_wire_mapping.py @@ -194,6 +194,42 @@ def test_confirmed_deal_dict_maps_to_shared_deal(self): class TestGetDealCreatedOutsideQuotePath: + async def test_template_post_and_get_preserve_the_booked_terms(self, client, mock_storage): + app.dependency_overrides[_get_optional_api_key_record] = lambda: SimpleNamespace( + identity=BuyerIdentity() + ) + with ( + patch("ad_seller.storage.factory.get_storage", return_value=mock_storage), + patch( + "ad_seller.interfaces.api.deps.get_product_catalog", + new=AsyncMock(return_value=_mock_catalog()), + ), + ): + created = await client.post( + "/api/v1/deals/from-template", + json={ + "deal_type": "PD", + "product_id": "ctv-premium-sports", + "impressions": 1_000_000, + "flight_start": "2026-10-06", + "flight_end": "2026-11-05", + }, + ) + assert created.status_code == 201, created.text + booked = created.json() + response = await client.get(f"/api/v1/deals/{booked['deal_id']}") + + assert response.status_code == 200, response.text + deal = response.json()["deal"] + assert deal["product"]["product_id"] == booked["product_id"] + assert deal["pricing"]["final_cpm"] == { + "amount_micros": round(booked["actual_price_cpm"] * 1_000_000), + "currency": "USD", + } + assert deal["terms"]["impressions"] == booked["impressions"] + assert deal["terms"]["flight_start"] == booked["flight_start"] + assert deal["terms"]["flight_end"] == booked["flight_end"] + async def test_from_template_deal_is_readable_as_booked(self, client, mock_storage): """Create via the from-template service path (what the REST route and the MCP tool both call), then GET through the wire mapper.""" diff --git a/tests/unit/test_template_deal_readback.py b/tests/unit/test_template_deal_readback.py new file mode 100644 index 0000000..89f5b44 --- /dev/null +++ b/tests/unit/test_template_deal_readback.py @@ -0,0 +1,83 @@ +"""Template deal fields survive conversion into the shared wire contract.""" + +from copy import deepcopy +from datetime import date + +import pytest + +from ad_seller.interfaces.api import contract_mappers as cm + + +def _flat_deal(): + return { + "deal_id": "DEMO-TEMPLATE", + "deal_type": "PD", + "status": "confirmed", + "product_id": "ctv-sports", + "actual_price_cpm": 28.26, + "currency": "EUR", + "impressions": 1_000_000, + "flight_start": "2026-10-06", + "flight_end": "2026-11-05", + "created_at": "2026-10-05T12:00:00Z", + } + + +@pytest.mark.parametrize("null_sections", [False, True]) +def test_flat_template_fields_are_mapped_without_changing_storage(null_sections): + stored = _flat_deal() + if null_sections: + stored.update(product=None, pricing=None, terms=None) + original = deepcopy(stored) + + deal = cm.internal_deal_to_shared_deal(stored) + + assert deal.product.product_id == "ctv-sports" + assert deal.product.name == "" + assert deal.pricing.final_cpm.amount_micros == 28_260_000 + assert deal.pricing.final_cpm.currency == "EUR" + assert deal.pricing.base_cpm is None + assert deal.terms.impressions == 1_000_000 + assert deal.terms.flight_start == date(2026, 10, 6) + assert deal.terms.flight_end == date(2026, 11, 5) + assert stored == original + + +def test_nested_sections_remain_authoritative_over_conflicting_flat_fields(): + stored = _flat_deal() + stored.update( + product={"product_id": "nested-product", "name": "Booked product"}, + pricing={"base_cpm": 40, "final_cpm": 32, "currency": "USD"}, + terms={"impressions": 2_000_000, "flight_start": "2026-12-01", "guaranteed": True}, + ) + original = deepcopy(stored) + + deal = cm.internal_deal_to_shared_deal(stored) + + assert deal.product.product_id == "nested-product" + assert deal.product.name == "Booked product" + assert deal.pricing.base_cpm.amount_micros == 40_000_000 + assert deal.pricing.final_cpm.amount_micros == 32_000_000 + assert deal.pricing.final_cpm.currency == "USD" + assert deal.terms.impressions == 2_000_000 + assert deal.terms.flight_start == date(2026, 12, 1) + assert deal.terms.flight_end is None + assert deal.terms.guaranteed is True + assert stored == original + + +def test_empty_nested_sections_do_not_pick_up_flat_values(): + deal = cm.internal_deal_to_shared_deal( + {**_flat_deal(), "product": {}, "pricing": {}, "terms": {}} + ) + assert deal.product.product_id == "" + assert deal.pricing.final_cpm is None + assert deal.terms.impressions is None + + +def test_zero_price_and_impressions_are_preserved(): + deal = cm.internal_deal_to_shared_deal( + {**_flat_deal(), "actual_price_cpm": 0, "impressions": 0} + ) + assert deal.pricing.final_cpm.amount_micros == 0 + assert deal.terms.impressions == 0