diff --git a/src/ad_seller/interfaces/api/main.py b/src/ad_seller/interfaces/api/main.py index 20bdbf8..aefc605 100644 --- a/src/ad_seller/interfaces/api/main.py +++ b/src/ad_seller/interfaces/api/main.py @@ -140,6 +140,8 @@ class ProposalResponse(BaseModel): status: str counter_terms: Optional[dict[str, Any]] = None approval_id: Optional[str] = None + pricing_verified: bool = False + pricing_verification_reason: str = "" errors: list[str] = [] @@ -533,12 +535,24 @@ async def submit_proposal( products=setup_flow.state.products, ) + # Verify pricing against quote history (Layer 4 — CPM hallucination defense) + from ...storage.factory import get_storage + from ...storage.quote_history import QuoteHistoryStore + + storage = await get_storage() + quote_history = QuoteHistoryStore(storage) + verification = await quote_history.verify_pricing( + buyer_id=context.get_pricing_key(), + product_id=request.product_id, + proposed_cpm=request.price, + ) + pricing_verified = verification.pricing_verified + pricing_verification_reason = verification.reason + # If pending approval, create the approval request if result.get("pending_approval"): from ...events.approval import ApprovalGate - from ...storage.factory import get_storage - storage = await get_storage() gate = ApprovalGate(storage) approval_req = await gate.request_approval( flow_id=result["flow_id"], @@ -549,6 +563,8 @@ async def submit_proposal( "recommendation": result["recommendation"], "evaluation": result.get("evaluation"), "counter_terms": result.get("counter_terms"), + "pricing_verified": pricing_verified, + "pricing_verification_reason": pricing_verification_reason, }, flow_state_snapshot=result.get("_flow_state_snapshot", {}), proposal_id=proposal_id, @@ -559,6 +575,8 @@ async def submit_proposal( status="pending_approval", counter_terms=result.get("counter_terms"), approval_id=approval_req.approval_id, + pricing_verified=pricing_verified, + pricing_verification_reason=pricing_verification_reason, errors=result.get("errors", []), ) @@ -567,6 +585,8 @@ async def submit_proposal( recommendation=result["recommendation"], status=result["status"], counter_terms=result.get("counter_terms"), + pricing_verified=pricing_verified, + pricing_verification_reason=pricing_verification_reason, errors=result.get("errors", []), ) @@ -1939,6 +1959,18 @@ async def create_quote( storage = await get_storage() await storage.set_quote(quote_id, quote.model_dump(mode="json"), ttl=86400) + # Record in quote history for pricing verification (Layer 4) + from ...storage.quote_history import QuoteHistoryStore + + quote_history = QuoteHistoryStore(storage) + await quote_history.record_quote( + quote_id=quote_id, + buyer_id=context.get_pricing_key(), + product_id=request.product_id, + quoted_cpm=final_cpm, + expires_at=expires_at, + ) + return quote.model_dump(mode="json") diff --git a/src/ad_seller/models/__init__.py b/src/ad_seller/models/__init__.py index f073bdd..51d0e23 100644 --- a/src/ad_seller/models/__init__.py +++ b/src/ad_seller/models/__init__.py @@ -27,6 +27,7 @@ BuyerIdentity, IdentityLevel, ) +from .pricing_type import PricingType from .change_request import ( ChangeRequest, ChangeRequestStatus, @@ -170,6 +171,8 @@ ) __all__ = [ + # Pricing type enum + "PricingType", # Core ad tech entities "Organization", "OrganizationRole", diff --git a/src/ad_seller/models/core.py b/src/ad_seller/models/core.py index 1fdfb99..86359e1 100644 --- a/src/ad_seller/models/core.py +++ b/src/ad_seller/models/core.py @@ -18,6 +18,8 @@ from pydantic import BaseModel, ConfigDict, Field +from .pricing_type import PricingType + # ============================================================================= # Enums # ============================================================================= @@ -337,6 +339,7 @@ class Pricing(BaseModel): model_config = ConfigDict(populate_by_name=True) + pricing_type: PricingType = PricingType.FIXED pricing_model: Optional[PricingModel] = Field(default=None, alias="pricingmodel") price: Optional[float] = None currency: Optional[str] = None diff --git a/src/ad_seller/models/flow_state.py b/src/ad_seller/models/flow_state.py index 68a4c31..1cee9b1 100644 --- a/src/ad_seller/models/flow_state.py +++ b/src/ad_seller/models/flow_state.py @@ -14,6 +14,7 @@ from pydantic import BaseModel, Field from .core import DealType, PricingModel +from .pricing_type import PricingType class ExecutionStatus(str, Enum): @@ -44,8 +45,9 @@ class ProductDefinition(BaseModel): inventory_segment_ids: list[str] = Field(default_factory=list) supported_deal_types: list[DealType] = Field(default_factory=list) supported_pricing_models: list[PricingModel] = Field(default_factory=list) - base_cpm: float - floor_cpm: float + pricing_type: PricingType = PricingType.FIXED + base_cpm: Optional[float] = None + floor_cpm: Optional[float] = None audience_targeting: Optional[dict[str, Any]] = None content_targeting: Optional[dict[str, Any]] = None ad_product_targeting: Optional[dict[str, Any]] = None @@ -113,6 +115,16 @@ class ProposalEvaluation(BaseModel): description="UCP embedding similarity score (0-1)", ) + # Quote-based pricing verification (Layer 4 — CPM hallucination defense) + pricing_verified: bool = Field( + default=False, + description="Whether the proposed CPM matches a quote the seller issued", + ) + pricing_verification_reason: str = Field( + default="", + description="Explanation of pricing verification result", + ) + # Overall recommendation recommendation: str # accept, counter, reject counter_terms: Optional[dict[str, Any]] = None diff --git a/src/ad_seller/models/media_kit.py b/src/ad_seller/models/media_kit.py index b56756f..a36984a 100644 --- a/src/ad_seller/models/media_kit.py +++ b/src/ad_seller/models/media_kit.py @@ -27,6 +27,7 @@ from pydantic import BaseModel, Field from .core import PricingModel +from .pricing_type import PricingType class PackageLayer(str, Enum): @@ -88,8 +89,9 @@ class Package(BaseModel): geo_targets: list[str] = Field(default_factory=list) # ["US", "US-NY", "US-CA"] # Pricing (blended from constituent products) - base_price: float - floor_price: float + pricing_type: PricingType = PricingType.FIXED + base_price: Optional[float] = None + floor_price: Optional[float] = None rate_type: PricingModel = PricingModel.CPM currency: str = "USD" # ISO 4217 diff --git a/src/ad_seller/models/pricing_type.py b/src/ad_seller/models/pricing_type.py new file mode 100644 index 0000000..51c6f2c --- /dev/null +++ b/src/ad_seller/models/pricing_type.py @@ -0,0 +1,26 @@ +# Author: Green Mountain Systems AI Inc. +# Donated to IAB Tech Lab + +"""Pricing type enum for seller inventory pricing signals. + +Allows sellers to express whether a price is fixed, a floor for +negotiation, or unavailable (rate on request). Buyer agents must +respect these signals and never fabricate pricing when a seller +has indicated on_request. +""" + +from enum import Enum + + +class PricingType(str, Enum): + """How pricing should be interpreted for a product or package. + + - FIXED: Price is set by the seller, use as-is. + - FLOOR: Minimum price; negotiation expected above this level. + - ON_REQUEST: No price available; buyer must negotiate before + any pricing exists. Pricing fields should be None. + """ + + FIXED = "fixed" + FLOOR = "floor" + ON_REQUEST = "on_request" diff --git a/src/ad_seller/models/quotes.py b/src/ad_seller/models/quotes.py index 4a165e3..d80483d 100644 --- a/src/ad_seller/models/quotes.py +++ b/src/ad_seller/models/quotes.py @@ -15,6 +15,8 @@ from pydantic import BaseModel, Field +from .pricing_type import PricingType + class QuoteStatus(str, Enum): """Status of a price quote.""" @@ -84,7 +86,8 @@ class QuoteProductInfo(BaseModel): class QuotePricing(BaseModel): """Pricing breakdown in a quote response.""" - base_cpm: float + pricing_type: PricingType = PricingType.FIXED + base_cpm: Optional[float] = None tier_discount_pct: float = 0.0 volume_discount_pct: float = 0.0 final_cpm: float diff --git a/src/ad_seller/storage/quote_history.py b/src/ad_seller/storage/quote_history.py new file mode 100644 index 0000000..cb7293f --- /dev/null +++ b/src/ad_seller/storage/quote_history.py @@ -0,0 +1,220 @@ +# Author: Green Mountain Systems AI Inc. +# Donated to IAB Tech Lab + +"""Quote history storage for seller-side pricing validation. + +Records every quote the seller issues and provides verification +against incoming buyer proposals. This is Layer 4 of the CPM +hallucination fix — defense in depth that catches fabricated pricing +even if buyer-side guards fail. + +Bead: ar-hm9l (child of epic ar-rrgw) +""" + +from datetime import datetime, timezone +from typing import Optional + +from pydantic import BaseModel, Field + +from ad_seller.storage.base import StorageBackend + + +class PricingVerificationResult(BaseModel): + """Result of verifying a proposal's CPM against quote history.""" + + pricing_verified: bool = False + matched_quote_id: Optional[str] = None + reason: str = "" + + +class QuoteHistoryStore: + """Records and verifies quotes issued by the seller. + + Uses the existing StorageBackend (key-value) to persist quote + history entries keyed by quote_id, with a secondary index by + buyer_id + product_id for fast lookup during proposal validation. + + Tolerance: proposed CPM must be within 1% of a non-expired quote + to be considered verified. + """ + + # Default CPM tolerance: 1% relative difference + DEFAULT_TOLERANCE_PCT: float = 0.01 + + def __init__( + self, + storage: StorageBackend, + tolerance_pct: float = DEFAULT_TOLERANCE_PCT, + ) -> None: + self._storage = storage + self._tolerance_pct = tolerance_pct + + async def record_quote( + self, + quote_id: str, + buyer_id: str, + product_id: str, + quoted_cpm: float, + expires_at: Optional[datetime] = None, + ) -> dict: + """Persist a quote to history when the seller issues it. + + Args: + quote_id: Unique identifier for the quote. + buyer_id: Buyer identifier (API key, seat ID, etc.). + product_id: Product the quote is for. + quoted_cpm: The final CPM in the quote. + expires_at: When the quote expires (optional). + + Returns: + The stored quote history record. + """ + now = datetime.now(timezone.utc) + record = { + "quote_id": quote_id, + "buyer_id": buyer_id, + "product_id": product_id, + "quoted_cpm": quoted_cpm, + "quoted_at": now.isoformat(), + "expires_at": expires_at.isoformat() if expires_at else None, + } + + # Store by quote_id + await self._storage.set( + f"quote_history:{quote_id}", record + ) + + # Maintain a buyer+product index for fast lookup + index_key = f"quote_history_index:{buyer_id}:{product_id}" + existing_ids = await self._storage.get(index_key) or [] + if quote_id not in existing_ids: + existing_ids.append(quote_id) + await self._storage.set(index_key, existing_ids) + + return record + + async def get_quote(self, quote_id: str) -> Optional[dict]: + """Retrieve a single quote history record by ID.""" + return await self._storage.get(f"quote_history:{quote_id}") + + async def find_quotes( + self, + buyer_id: str, + product_id: str, + ) -> list[dict]: + """Find all quotes issued to a buyer for a product. + + Args: + buyer_id: Buyer identifier. + product_id: Product identifier. + + Returns: + List of quote history records (may include expired). + """ + index_key = f"quote_history_index:{buyer_id}:{product_id}" + quote_ids = await self._storage.get(index_key) or [] + + quotes = [] + for qid in quote_ids: + record = await self._storage.get(f"quote_history:{qid}") + if record is not None: + quotes.append(record) + return quotes + + async def verify_pricing( + self, + buyer_id: str, + product_id: str, + proposed_cpm: float, + tolerance_pct: Optional[float] = None, + ) -> PricingVerificationResult: + """Verify a proposed CPM against quote history. + + Checks whether the seller previously quoted a CPM to this buyer + for this product that is within tolerance of the proposed CPM + and has not expired. + + Args: + buyer_id: Buyer identifier. + product_id: Product identifier. + proposed_cpm: The CPM the buyer is proposing. + tolerance_pct: Override for the default tolerance (0.01 = 1%). + + Returns: + PricingVerificationResult with verified/unverified status. + """ + tol = tolerance_pct if tolerance_pct is not None else self._tolerance_pct + quotes = await self.find_quotes(buyer_id, product_id) + + if not quotes: + return PricingVerificationResult( + pricing_verified=False, + reason="No matching quote found for this buyer and product.", + ) + + now = datetime.now(timezone.utc) + + for quote in quotes: + # Check expiration + expires_at_str = quote.get("expires_at") + if expires_at_str: + expires_at = datetime.fromisoformat(expires_at_str) + # Ensure timezone-aware comparison + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + if now > expires_at: + continue # Skip expired quotes + + # Check CPM tolerance + quoted_cpm = quote["quoted_cpm"] + if quoted_cpm == 0: + # Avoid division by zero; exact match only + if proposed_cpm == 0: + return PricingVerificationResult( + pricing_verified=True, + matched_quote_id=quote["quote_id"], + reason="Exact match with quoted CPM of $0.00.", + ) + continue + + relative_diff = abs(proposed_cpm - quoted_cpm) / quoted_cpm + if relative_diff <= tol: + return PricingVerificationResult( + pricing_verified=True, + matched_quote_id=quote["quote_id"], + reason=( + f"Proposed CPM ${proposed_cpm:.2f} matches quote " + f"{quote['quote_id']} (${quoted_cpm:.2f}, " + f"{relative_diff*100:.1f}% difference)." + ), + ) + + # If we got here, we have quotes but none match + # Determine if all are expired or all are outside tolerance + all_expired = True + for quote in quotes: + expires_at_str = quote.get("expires_at") + if expires_at_str: + expires_at = datetime.fromisoformat(expires_at_str) + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + if now <= expires_at: + all_expired = False + break + else: + all_expired = False + break + + if all_expired: + return PricingVerificationResult( + pricing_verified=False, + reason="All quotes for this buyer and product have expired.", + ) + + return PricingVerificationResult( + pricing_verified=False, + reason=( + f"Proposed CPM ${proposed_cpm:.2f} is outside tolerance " + f"of all active quotes for this buyer and product." + ), + ) diff --git a/tests/unit/test_pricing_type.py b/tests/unit/test_pricing_type.py new file mode 100644 index 0000000..950b75f --- /dev/null +++ b/tests/unit/test_pricing_type.py @@ -0,0 +1,210 @@ +# Author: Green Mountain Systems AI Inc. +# Donated to IAB Tech Lab + +"""Unit tests for PricingType enum and its integration with seller models. + +Tests that the pricing_type field correctly supports fixed, floor, and +on_request pricing across ProductDefinition, Package, QuotePricing, and +Pricing models. Backward compatibility is verified: existing code that +creates models without pricing_type should continue to work unchanged. +""" + +import pytest +from pydantic import ValidationError + +from ad_seller.models.pricing_type import PricingType +from ad_seller.models.core import DealType, Pricing, PricingModel +from ad_seller.models.flow_state import ProductDefinition +from ad_seller.models.media_kit import Package, PackageLayer +from ad_seller.models.quotes import QuotePricing + + +class TestPricingTypeEnum: + """Tests for the PricingType enum itself.""" + + def test_enum_values(self): + """PricingType has exactly three values: fixed, floor, on_request.""" + assert PricingType.FIXED.value == "fixed" + assert PricingType.FLOOR.value == "floor" + assert PricingType.ON_REQUEST.value == "on_request" + + def test_enum_is_str(self): + """PricingType is a str enum for JSON serialization.""" + assert isinstance(PricingType.FIXED, str) + assert PricingType.ON_REQUEST == "on_request" + + def test_enum_from_string(self): + """PricingType can be constructed from string values.""" + assert PricingType("fixed") == PricingType.FIXED + assert PricingType("floor") == PricingType.FLOOR + assert PricingType("on_request") == PricingType.ON_REQUEST + + def test_invalid_value_raises(self): + """Invalid string raises ValueError.""" + with pytest.raises(ValueError): + PricingType("negotiable") + + +class TestProductDefinitionPricingType: + """Tests for PricingType on ProductDefinition.""" + + def test_on_request_with_no_pricing(self): + """ProductDefinition with pricing_type=on_request and no CPMs is valid.""" + product = ProductDefinition( + product_id="prod-001", + name="Premium Sponsorship", + inventory_type="display", + pricing_type=PricingType.ON_REQUEST, + base_cpm=None, + floor_cpm=None, + ) + assert product.pricing_type == PricingType.ON_REQUEST + assert product.base_cpm is None + assert product.floor_cpm is None + + def test_fixed_with_pricing(self): + """ProductDefinition with pricing_type=fixed and CPMs set is valid (backward compat).""" + product = ProductDefinition( + product_id="prod-002", + name="Standard Display", + inventory_type="display", + pricing_type=PricingType.FIXED, + base_cpm=25.0, + floor_cpm=20.0, + ) + assert product.pricing_type == PricingType.FIXED + assert product.base_cpm == 25.0 + assert product.floor_cpm == 20.0 + + def test_defaults_to_fixed(self): + """ProductDefinition without pricing_type defaults to FIXED.""" + product = ProductDefinition( + product_id="prod-003", + name="Default Product", + inventory_type="video", + base_cpm=30.0, + floor_cpm=25.0, + ) + assert product.pricing_type == PricingType.FIXED + + def test_floor_pricing_type(self): + """ProductDefinition with pricing_type=floor is valid.""" + product = ProductDefinition( + product_id="prod-004", + name="Floor Priced Product", + inventory_type="ctv", + pricing_type=PricingType.FLOOR, + base_cpm=40.0, + floor_cpm=35.0, + ) + assert product.pricing_type == PricingType.FLOOR + + def test_backward_compat_existing_fixture(self, sample_product): + """Existing sample_product fixture still works and defaults to FIXED.""" + assert sample_product.pricing_type == PricingType.FIXED + assert sample_product.base_cpm == 15.0 + assert sample_product.floor_cpm == 10.0 + + +class TestPackagePricingType: + """Tests for PricingType on Package.""" + + def test_on_request_package(self): + """Package with pricing_type=on_request and no prices is valid.""" + package = Package( + package_id="pkg-001", + name="Custom Sponsorship Package", + layer=PackageLayer.CURATED, + pricing_type=PricingType.ON_REQUEST, + base_price=None, + floor_price=None, + ) + assert package.pricing_type == PricingType.ON_REQUEST + assert package.base_price is None + assert package.floor_price is None + + def test_fixed_package(self): + """Package with pricing_type=fixed and prices set is valid.""" + package = Package( + package_id="pkg-002", + name="Standard Package", + layer=PackageLayer.CURATED, + pricing_type=PricingType.FIXED, + base_price=28.0, + floor_price=22.0, + ) + assert package.pricing_type == PricingType.FIXED + assert package.base_price == 28.0 + + def test_package_defaults_to_fixed(self): + """Package without pricing_type defaults to FIXED.""" + package = Package( + package_id="pkg-003", + name="Default Package", + layer=PackageLayer.SYNCED, + base_price=30.0, + floor_price=25.0, + ) + assert package.pricing_type == PricingType.FIXED + + +class TestQuotePricingPricingType: + """Tests for PricingType on QuotePricing.""" + + def test_on_request_quote_pricing(self): + """QuotePricing with pricing_type=on_request and null base_cpm is valid.""" + qp = QuotePricing( + pricing_type=PricingType.ON_REQUEST, + base_cpm=None, + final_cpm=0.0, + ) + assert qp.pricing_type == PricingType.ON_REQUEST + assert qp.base_cpm is None + + def test_fixed_quote_pricing(self): + """QuotePricing with pricing_type=fixed and base_cpm set is valid.""" + qp = QuotePricing( + pricing_type=PricingType.FIXED, + base_cpm=25.0, + final_cpm=22.5, + ) + assert qp.pricing_type == PricingType.FIXED + assert qp.base_cpm == 25.0 + + def test_quote_pricing_defaults_to_fixed(self): + """QuotePricing without pricing_type defaults to FIXED.""" + qp = QuotePricing( + base_cpm=20.0, + final_cpm=18.0, + ) + assert qp.pricing_type == PricingType.FIXED + + +class TestCorePricingPricingType: + """Tests for PricingType on Pricing (core model).""" + + def test_pricing_with_pricing_type(self): + """Pricing model accepts pricing_type field.""" + pricing = Pricing( + pricingmodel=PricingModel.CPM, + price=25.0, + currency="USD", + pricing_type=PricingType.FLOOR, + ) + assert pricing.pricing_type == PricingType.FLOOR + + def test_pricing_defaults_to_fixed(self): + """Pricing model defaults pricing_type to FIXED.""" + pricing = Pricing( + pricingmodel=PricingModel.CPM, + price=25.0, + ) + assert pricing.pricing_type == PricingType.FIXED + + def test_pricing_on_request(self): + """Pricing with on_request and no price.""" + pricing = Pricing( + pricing_type=PricingType.ON_REQUEST, + ) + assert pricing.pricing_type == PricingType.ON_REQUEST + assert pricing.price is None diff --git a/tests/unit/test_quote_endpoints.py b/tests/unit/test_quote_endpoints.py index da20746..38ced06 100644 --- a/tests/unit/test_quote_endpoints.py +++ b/tests/unit/test_quote_endpoints.py @@ -283,6 +283,36 @@ async def test_below_minimum_impressions(self, client, mock_storage): assert resp.status_code == 400 assert resp.json()["detail"]["error"] == "below_minimum_impressions" + async def test_quote_recorded_in_history(self, client, mock_storage): + """Layer 4: quote creation should persist a quote_history record.""" + with ( + patch( + "ad_seller.flows.ProductSetupFlow", + return_value=_mock_product_setup_flow(_products()), + ), + patch("ad_seller.storage.factory.get_storage", return_value=mock_storage), + ): + resp = await client.post( + "/api/v1/quotes", + json={ + "product_id": "ctv-premium-sports", + "deal_type": "PD", + "impressions": 5000000, + }, + ) + + assert resp.status_code == 200 + quote_id = resp.json()["quote_id"] + + # Verify quote_history record was created + history_key = f"quote_history:{quote_id}" + assert history_key in mock_storage._store + record = mock_storage._store[history_key] + assert record["product_id"] == "ctv-premium-sports" + assert record["quoted_cpm"] > 0 + assert "buyer_id" in record + assert "quoted_at" in record + # ============================================================================= # GET /api/v1/quotes/{quote_id} diff --git a/tests/unit/test_quote_validation.py b/tests/unit/test_quote_validation.py new file mode 100644 index 0000000..241d407 --- /dev/null +++ b/tests/unit/test_quote_validation.py @@ -0,0 +1,239 @@ +# Author: Green Mountain Systems AI Inc. +# Donated to IAB Tech Lab + +"""Unit tests for Layer 4: seller-side quote validation. + +Tests that when a buyer submits a proposal with a CPM, the seller +cross-references against quotes it previously issued and flags +proposals with unverified pricing. + +Bead: ar-hm9l (child of epic ar-rrgw) +""" + +import time +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock + +import pytest + +from ad_seller.storage.quote_history import QuoteHistoryStore + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def mock_storage(): + """In-memory dict-backed mock storage for the KV store.""" + store: dict = {} + 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.keys = AsyncMock( + side_effect=lambda pattern="*": [ + k for k in store if k.startswith(pattern.replace("*", "")) + ] + ) + storage._store = store + return storage + + +@pytest.fixture +def quote_history(mock_storage) -> QuoteHistoryStore: + """Create a QuoteHistoryStore backed by mock storage.""" + return QuoteHistoryStore(mock_storage) + + +# ============================================================================= +# Test: Quote is recorded when issued +# ============================================================================= + + +class TestRecordQuote: + @pytest.mark.asyncio + async def test_quote_recorded_on_issue(self, quote_history, mock_storage): + """When a quote is issued, it should be persisted in quote_history.""" + await quote_history.record_quote( + quote_id="qt-abc123", + buyer_id="buyer-001", + product_id="ctv-premium-sports", + quoted_cpm=29.75, + expires_at=datetime.now(timezone.utc) + timedelta(hours=24), + ) + + record = await quote_history.get_quote("qt-abc123") + assert record is not None + assert record["quote_id"] == "qt-abc123" + assert record["buyer_id"] == "buyer-001" + assert record["product_id"] == "ctv-premium-sports" + assert record["quoted_cpm"] == 29.75 + assert "quoted_at" in record + assert "expires_at" in record + + @pytest.mark.asyncio + async def test_multiple_quotes_for_same_buyer_product( + self, quote_history + ): + """Multiple quotes for the same buyer+product should all be stored.""" + await quote_history.record_quote( + quote_id="qt-001", + buyer_id="buyer-001", + product_id="prod-A", + quoted_cpm=25.0, + expires_at=datetime.now(timezone.utc) + timedelta(hours=24), + ) + await quote_history.record_quote( + quote_id="qt-002", + buyer_id="buyer-001", + product_id="prod-A", + quoted_cpm=22.0, + expires_at=datetime.now(timezone.utc) + timedelta(hours=24), + ) + + quotes = await quote_history.find_quotes( + buyer_id="buyer-001", product_id="prod-A" + ) + assert len(quotes) >= 2 + + +# ============================================================================= +# Test: Proposal with matching quote CPM -> pricing_verified=true +# ============================================================================= + + +class TestVerifyPricing: + @pytest.mark.asyncio + async def test_matching_cpm_verified(self, quote_history): + """Proposal CPM that matches a quote should be verified.""" + await quote_history.record_quote( + quote_id="qt-match", + buyer_id="buyer-001", + product_id="ctv-premium-sports", + quoted_cpm=29.75, + expires_at=datetime.now(timezone.utc) + timedelta(hours=24), + ) + + result = await quote_history.verify_pricing( + buyer_id="buyer-001", + product_id="ctv-premium-sports", + proposed_cpm=29.75, + ) + + assert result.pricing_verified is True + assert result.matched_quote_id == "qt-match" + + @pytest.mark.asyncio + async def test_cpm_within_tolerance_verified(self, quote_history): + """CPM within tolerance (default 1%) of quote should be verified.""" + await quote_history.record_quote( + quote_id="qt-tol", + buyer_id="buyer-001", + product_id="prod-A", + quoted_cpm=30.00, + expires_at=datetime.now(timezone.utc) + timedelta(hours=24), + ) + + # 0.5% off — should be within 1% tolerance + result = await quote_history.verify_pricing( + buyer_id="buyer-001", + product_id="prod-A", + proposed_cpm=29.85, + ) + + assert result.pricing_verified is True + + # ================================================================= + # Test: Proposal with no matching quote -> pricing_verified=false + # ================================================================= + + @pytest.mark.asyncio + async def test_no_matching_quote_unverified(self, quote_history): + """Proposal with no matching quote should be unverified.""" + result = await quote_history.verify_pricing( + buyer_id="buyer-999", + product_id="ctv-premium-sports", + proposed_cpm=50.00, + ) + + assert result.pricing_verified is False + assert result.matched_quote_id is None + assert "no matching quote" in result.reason.lower() + + # ================================================================= + # Test: Proposal with CPM outside tolerance -> pricing_verified=false + # ================================================================= + + @pytest.mark.asyncio + async def test_cpm_outside_tolerance_unverified(self, quote_history): + """CPM that differs >1% from any quote should be unverified.""" + await quote_history.record_quote( + quote_id="qt-far", + buyer_id="buyer-001", + product_id="prod-A", + quoted_cpm=30.00, + expires_at=datetime.now(timezone.utc) + timedelta(hours=24), + ) + + # 10% off — well outside 1% tolerance + result = await quote_history.verify_pricing( + buyer_id="buyer-001", + product_id="prod-A", + proposed_cpm=33.00, + ) + + assert result.pricing_verified is False + assert "outside tolerance" in result.reason.lower() + + # ================================================================= + # Test: Expired quote -> pricing_verified=false + # ================================================================= + + @pytest.mark.asyncio + async def test_expired_quote_unverified(self, quote_history): + """Proposal referencing an expired quote should be unverified.""" + await quote_history.record_quote( + quote_id="qt-expired", + buyer_id="buyer-001", + product_id="prod-A", + quoted_cpm=30.00, + expires_at=datetime.now(timezone.utc) - timedelta(hours=1), + ) + + result = await quote_history.verify_pricing( + buyer_id="buyer-001", + product_id="prod-A", + proposed_cpm=30.00, + ) + + assert result.pricing_verified is False + assert "expired" in result.reason.lower() + + +# ============================================================================= +# Test: Backward compatibility — proposals without pricing_verified still work +# ============================================================================= + + +class TestBackwardCompat: + def test_proposal_response_defaults_pricing_verified_false(self): + """ProposalResponse should have pricing_verified defaulting to False.""" + from ad_seller.models.core import Pricing + + pricing = Pricing(price=15.0) + # pricing_verified should not be required — defaults to False + assert not hasattr(pricing, "pricing_verified") or pricing.pricing_verified is False + + def test_pricing_verification_result_model(self): + """PricingVerificationResult should be importable and constructable.""" + from ad_seller.storage.quote_history import PricingVerificationResult + + result = PricingVerificationResult( + pricing_verified=False, + reason="No matching quote found", + ) + assert result.pricing_verified is False + assert result.matched_quote_id is None