diff --git a/src/core/tools/media_buy_create.py b/src/core/tools/media_buy_create.py index 8429bb956..5e7b70acf 100644 --- a/src/core/tools/media_buy_create.py +++ b/src/core/tools/media_buy_create.py @@ -13,6 +13,7 @@ import uuid from datetime import UTC, datetime from typing import Any, Literal +from urllib.parse import urlparse from fastmcp.exceptions import ToolError from fastmcp.server.context import Context @@ -20,11 +21,28 @@ from pydantic import ValidationError from rich.console import Console -from src.core.tool_context import ToolContext - logger = logging.getLogger(__name__) console = Console() + +def validate_agent_url(url: str | None) -> bool: + """Validate agent_url is a valid HTTP(S) URL per AdCP spec. + + Args: + url: URL string to validate + + Returns: + True if valid HTTP(S) URL, False otherwise + """ + if not url or not isinstance(url, str): + return False + try: + result = urlparse(url) + return all([result.scheme in ("http", "https"), result.netloc]) + except Exception: + return False + + # Tool-specific imports from src.core import schemas from src.core.audit_logger import get_audit_logger @@ -52,6 +70,7 @@ TaskStatus, ) from src.core.testing_hooks import TestingContext, apply_testing_hooks, get_testing_context +from src.core.tool_context import ToolContext # Import get_product_catalog from main (after refactor) from src.core.validation_helpers import format_validation_error @@ -386,16 +405,74 @@ def execute_approved_media_buy(media_buy_id: str, tenant_id: str) -> tuple[bool, if delivery_type_str not in ["guaranteed", "non_guaranteed"]: delivery_type_str = "non_guaranteed" # Default fallback - # Convert formats to FormatId objects + # Convert formats to FormatId objects with comprehensive validation from src.core.schemas import FormatId as FormatIdType + from src.core.schemas import FormatReference - format_ids: list[FormatIdType] = [] - if product.formats: - for fmt in product.formats: + format_ids_list: list[FormatIdType] = [] + formats = product.formats or [] + + logger.debug(f"[APPROVAL] Converting {len(formats)} formats for package {package_id}") + + for idx, fmt in enumerate(formats): + try: + # Most common case: dict from database (JSONB field returns dicts) if isinstance(fmt, dict): - format_ids.append(FormatIdType(**fmt)) + agent_url = fmt.get("agent_url") + format_id = fmt.get("format_id") or fmt.get("id") + + # Validate required fields exist and are non-empty strings + if not agent_url or not isinstance(agent_url, str): + raise ValueError(f"Format missing or invalid agent_url: agent_url={agent_url!r}") + if not validate_agent_url(agent_url): + raise ValueError(f"agent_url must be valid HTTP(S) URL: {agent_url!r}") + if not format_id or not isinstance(format_id, str): + raise ValueError(f"Format missing or invalid id: id={format_id!r}") + + format_ids_list.append(FormatIdType(agent_url=agent_url, id=format_id)) + + # Already correct type (no conversion needed) elif isinstance(fmt, FormatIdType): - format_ids.append(fmt) + # Defensive validation even for FormatId objects + if not fmt.agent_url or not validate_agent_url(fmt.agent_url): + raise ValueError(f"FormatId has invalid agent_url: {fmt.agent_url!r}") + if not fmt.id: + raise ValueError(f"FormatId has empty id: {fmt.id!r}") + format_ids_list.append(fmt) + + # Legacy FormatReference object (convert format_id -> id) + elif isinstance(fmt, FormatReference): + if not fmt.agent_url or not validate_agent_url(fmt.agent_url): + raise ValueError(f"FormatReference has invalid agent_url: {fmt.agent_url!r}") + if not fmt.format_id: + raise ValueError(f"FormatReference has empty format_id: {fmt.format_id!r}") + format_ids_list.append(FormatIdType(agent_url=fmt.agent_url, id=fmt.format_id)) + + else: + raise ValueError(f"Unknown format type: {type(fmt).__name__}") + + except (ValueError, ValidationError) as e: + error_msg = ( + f"Failed to reconstruct package {package_id}: " + f"Format validation failed at index {idx}: {e}" + ) + logger.error(f"[APPROVAL] {error_msg}") + return False, error_msg + + # Validate non-empty format_ids (required by AdCP spec) + if not format_ids_list: + error_msg = ( + f"Failed to reconstruct package {package_id}: " + f"Product {product_id} has no valid formats - cannot create media buy" + ) + logger.error(f"[APPROVAL] {error_msg}") + return False, error_msg + + # Log conversion results + logger.info( + f"[APPROVAL] Package {package_id}: " + f"Successfully converted all {len(format_ids_list)} formats" + ) media_package = MediaPackage( package_id=package_id, @@ -403,7 +480,7 @@ def execute_approved_media_buy(media_buy_id: str, tenant_id: str) -> tuple[bool, delivery_type=delivery_type_str, # type: ignore[arg-type] cpm=cpm, impressions=impressions, - format_ids=format_ids, + format_ids=format_ids_list, targeting_overlay=targeting_overlay, buyer_ref=package_config.get("buyer_ref"), product_id=product_id, diff --git a/tests/integration/test_a2a_response_compliance.py b/tests/integration/test_a2a_response_compliance.py index 52cdfd2a3..eb96d4453 100644 --- a/tests/integration/test_a2a_response_compliance.py +++ b/tests/integration/test_a2a_response_compliance.py @@ -22,7 +22,7 @@ UpdateMediaBuyResponse, ) -pytestmark = [pytest.mark.integration] +pytestmark = [pytest.mark.integration, pytest.mark.requires_db] @pytest.mark.integration diff --git a/tests/integration/test_format_conversion_approval.py b/tests/integration/test_format_conversion_approval.py new file mode 100644 index 000000000..36dc80903 --- /dev/null +++ b/tests/integration/test_format_conversion_approval.py @@ -0,0 +1,1007 @@ +"""Integration tests for format conversion logic during media buy approval. + +Tests the conversion of product.formats (FormatReference/dict) to MediaPackage.format_ids +(FormatId objects) in execute_approved_media_buy function. + +This tests the critical format conversion logic at lines 391-452 of media_buy_create.py. +""" + +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +import pytest +from sqlalchemy import select + +from src.core.database.database_session import get_db_session +from src.core.database.models import ( + CurrencyLimit, + MediaBuy, + PricingOption, + Principal, + Product, + PropertyTag, + Tenant, +) +from src.core.tools.media_buy_create import execute_approved_media_buy + + +@pytest.fixture +def test_tenant(integration_db): + """Create test tenant with mock ad server.""" + tenant_id = "test_format_conversion" + with get_db_session() as session: + tenant = Tenant( + tenant_id=tenant_id, + name="Format Test Tenant", + subdomain="formattest", + is_active=True, + ad_server="mock", + ) + session.add(tenant) + session.commit() + + yield tenant_id + + # Cleanup + with get_db_session() as session: + stmt = select(Tenant).filter_by(tenant_id=tenant_id) + tenant = session.scalars(stmt).first() + if tenant: + session.delete(tenant) + session.commit() + + +@pytest.fixture +def test_currency_limit(integration_db, test_tenant): + """Create required CurrencyLimit for budget validation. + + Per CLAUDE.md "Database Initialization Dependencies": + Products require CurrencyLimit for budget validation in media buys. + """ + with get_db_session() as session: + currency_limit = CurrencyLimit( + tenant_id=test_tenant, + currency_code="USD", + max_daily_package_spend=100000.0, + ) + session.add(currency_limit) + session.commit() + + yield "USD" + + # Cleanup + with get_db_session() as session: + stmt = select(CurrencyLimit).filter_by(tenant_id=test_tenant, currency_code="USD") + limit = session.scalars(stmt).first() + if limit: + session.delete(limit) + session.commit() + + +@pytest.fixture +def test_property_tag(integration_db, test_tenant): + """Create required PropertyTag for property_tags array references. + + Per CLAUDE.md "Database Initialization Dependencies": + Products with property_tags=["all_inventory"] require PropertyTag record. + """ + with get_db_session() as session: + property_tag = PropertyTag( + tenant_id=test_tenant, + tag_id="all_inventory", + name="All Inventory", + description="All available inventory", + ) + session.add(property_tag) + session.commit() + + yield "all_inventory" + + # Cleanup + with get_db_session() as session: + stmt = select(PropertyTag).filter_by(tenant_id=test_tenant, tag_id="all_inventory") + tag = session.scalars(stmt).first() + if tag: + session.delete(tag) + session.commit() + + +@pytest.fixture +def test_principal(integration_db, test_tenant): + """Create test principal.""" + principal_id = "test_advertiser" + with get_db_session() as session: + principal = Principal( + tenant_id=test_tenant, + principal_id=principal_id, + name="Test Advertiser", + access_token="test_token_12345", + platform_mappings={"mock": {"advertiser_id": "test_adv"}}, + ) + session.add(principal) + session.commit() + + yield principal_id + + # Cleanup + with get_db_session() as session: + stmt = select(Principal).filter_by(tenant_id=test_tenant, principal_id=principal_id) + principal = session.scalars(stmt).first() + if principal: + session.delete(principal) + session.commit() + + +@pytest.mark.requires_db +class TestFormatConversionApproval: + """Test format conversion during media buy approval execution.""" + + def test_valid_format_reference_dict_conversion( + self, test_tenant, test_principal, test_currency_limit, test_property_tag + ): + """✅ Valid FormatReference dict (with format_id) converts to FormatId successfully.""" + product_id = "prod_format_ref" + media_buy_id = "mb_format_ref" + + with get_db_session() as session: + # Create product with FormatReference-style dict (has format_id field) + product = Product( + tenant_id=test_tenant, + product_id=product_id, + name="Format Reference Product", + description="Product with FormatReference format", + formats=[ + { + "agent_url": "https://creatives.example.com", + "format_id": "display_300x250", # Legacy field name + } + ], + targeting_template={}, + delivery_type="guaranteed", + property_tags=["all_inventory"], + ) + session.add(product) + + # Add pricing option + pricing = PricingOption( + tenant_id=test_tenant, + product_id=product_id, + pricing_model="CPM", + rate=Decimal("10.00"), + currency="USD", + is_fixed=True, + ) + session.add(pricing) + + # Create media buy + now = datetime.now(UTC) + media_buy = MediaBuy( + tenant_id=test_tenant, + media_buy_id=media_buy_id, + principal_id=test_principal, + order_name="Format Ref Test Order", + advertiser_name="Test Advertiser", + budget=1000.0, + start_date=(now + timedelta(days=1)).date(), + end_date=(now + timedelta(days=7)).date(), + start_time=now + timedelta(days=1), + end_time=now + timedelta(days=7), + status="pending_approval", + raw_request={ + "buyer_ref": "test_buyer_ref", + "brand_manifest": "https://example.com/brand-manifest.json", + "start_time": (now + timedelta(days=1)).isoformat(), + "end_time": (now + timedelta(days=7)).isoformat(), + "packages": [ + { + "package_id": "pkg_1", + "product_id": product_id, + "budget": 1000.0, + } + ], + }, + ) + session.add(media_buy) + session.commit() + + # Execute approval + success, message = execute_approved_media_buy(media_buy_id, test_tenant) + + assert success, f"Approval should succeed: {message}" + assert "successfully" in message.lower() + + # Cleanup + with get_db_session() as session: + stmt_mb = select(MediaBuy).filter_by(media_buy_id=media_buy_id) + media_buy = session.scalars(stmt_mb).first() + if media_buy: + session.delete(media_buy) + + stmt_prod = select(Product).filter_by(product_id=product_id) + product = session.scalars(stmt_prod).first() + if product: + session.delete(product) + + session.commit() + + def test_invalid_format_missing_agent_url( + self, test_tenant, test_principal, test_currency_limit, test_property_tag + ): + """❌ FormatReference dict missing agent_url should fail validation.""" + product_id = "prod_no_agent_url" + media_buy_id = "mb_no_agent_url" + + with get_db_session() as session: + # Create product with invalid format (no agent_url) + product = Product( + tenant_id=test_tenant, + product_id=product_id, + name="Invalid Format Product", + description="Product with missing agent_url", + formats=[ + { + "format_id": "display_300x250", + # Missing agent_url - should fail + } + ], + targeting_template={}, + delivery_type="guaranteed", + property_tags=["all_inventory"], + ) + session.add(product) + + # Add pricing option + pricing = PricingOption( + tenant_id=test_tenant, + product_id=product_id, + pricing_model="CPM", + rate=Decimal("10.00"), + currency="USD", + is_fixed=True, + ) + session.add(pricing) + + # Create media buy + now = datetime.now(UTC) + media_buy = MediaBuy( + tenant_id=test_tenant, + media_buy_id=media_buy_id, + principal_id=test_principal, + order_name="Invalid Format Order", + advertiser_name="Test Advertiser", + budget=1000.0, + start_date=(now + timedelta(days=1)).date(), + end_date=(now + timedelta(days=7)).date(), + start_time=now + timedelta(days=1), + end_time=now + timedelta(days=7), + status="pending_approval", + raw_request={ + "buyer_ref": "test_buyer_ref", + "brand_manifest": "https://example.com/brand-manifest.json", + "start_time": (now + timedelta(days=1)).isoformat(), + "end_time": (now + timedelta(days=7)).isoformat(), + "packages": [ + { + "package_id": "pkg_1", + "product_id": product_id, + "budget": 1000.0, + } + ], + }, + ) + session.add(media_buy) + session.commit() + + # Execute approval - should fail + success, message = execute_approved_media_buy(media_buy_id, test_tenant) + + assert not success, "Approval should fail with missing agent_url" + assert "agent_url" in message.lower() + assert "format validation failed" in message.lower() + + # Cleanup + with get_db_session() as session: + stmt_mb = select(MediaBuy).filter_by(media_buy_id=media_buy_id) + media_buy = session.scalars(stmt_mb).first() + if media_buy: + session.delete(media_buy) + + stmt_prod = select(Product).filter_by(product_id=product_id) + product = session.scalars(stmt_prod).first() + if product: + session.delete(product) + + session.commit() + + def test_invalid_format_empty_agent_url(self, test_tenant, test_principal, test_currency_limit, test_property_tag): + """❌ FormatReference dict with empty agent_url should fail validation.""" + product_id = "prod_empty_agent_url" + media_buy_id = "mb_empty_agent_url" + + with get_db_session() as session: + # Create product with empty agent_url + product = Product( + tenant_id=test_tenant, + product_id=product_id, + name="Empty Agent URL Product", + description="Product with empty agent_url", + formats=[ + { + "agent_url": "", # Empty string - should fail + "format_id": "display_300x250", + } + ], + targeting_template={}, + delivery_type="guaranteed", + property_tags=["all_inventory"], + ) + session.add(product) + + # Add pricing option + pricing = PricingOption( + tenant_id=test_tenant, + product_id=product_id, + pricing_model="CPM", + rate=Decimal("10.00"), + currency="USD", + is_fixed=True, + ) + session.add(pricing) + + # Create media buy + now = datetime.now(UTC) + media_buy = MediaBuy( + tenant_id=test_tenant, + media_buy_id=media_buy_id, + principal_id=test_principal, + order_name="Empty Agent URL Order", + advertiser_name="Test Advertiser", + budget=1000.0, + start_date=(now + timedelta(days=1)).date(), + end_date=(now + timedelta(days=7)).date(), + start_time=now + timedelta(days=1), + end_time=now + timedelta(days=7), + status="pending_approval", + raw_request={ + "buyer_ref": "test_buyer_ref", + "brand_manifest": "https://example.com/brand-manifest.json", + "start_time": (now + timedelta(days=1)).isoformat(), + "end_time": (now + timedelta(days=7)).isoformat(), + "packages": [ + { + "package_id": "pkg_1", + "product_id": product_id, + "budget": 1000.0, + } + ], + }, + ) + session.add(media_buy) + session.commit() + + # Execute approval - should fail + success, message = execute_approved_media_buy(media_buy_id, test_tenant) + + assert not success, "Approval should fail with empty agent_url" + assert "agent_url" in message.lower() + + # Cleanup + with get_db_session() as session: + stmt_mb = select(MediaBuy).filter_by(media_buy_id=media_buy_id) + media_buy = session.scalars(stmt_mb).first() + if media_buy: + session.delete(media_buy) + + stmt_prod = select(Product).filter_by(product_id=product_id) + product = session.scalars(stmt_prod).first() + if product: + session.delete(product) + + session.commit() + + def test_invalid_agent_url_not_http(self, test_tenant, test_principal, test_currency_limit, test_property_tag): + """❌ FormatReference with non-HTTP(S) agent_url should fail validation.""" + product_id = "prod_invalid_url" + media_buy_id = "mb_invalid_url" + + with get_db_session() as session: + # Create product with invalid URL scheme + product = Product( + tenant_id=test_tenant, + product_id=product_id, + name="Invalid URL Product", + description="Product with non-HTTP URL", + formats=[ + { + "agent_url": "ftp://creatives.example.com", # FTP not allowed + "format_id": "display_300x250", + } + ], + targeting_template={}, + delivery_type="guaranteed", + property_tags=["all_inventory"], + ) + session.add(product) + + # Add pricing option + pricing = PricingOption( + tenant_id=test_tenant, + product_id=product_id, + pricing_model="CPM", + rate=Decimal("10.00"), + currency="USD", + is_fixed=True, + ) + session.add(pricing) + + # Create media buy + now = datetime.now(UTC) + media_buy = MediaBuy( + tenant_id=test_tenant, + media_buy_id=media_buy_id, + principal_id=test_principal, + order_name="Invalid URL Order", + advertiser_name="Test Advertiser", + budget=1000.0, + start_date=(now + timedelta(days=1)).date(), + end_date=(now + timedelta(days=7)).date(), + start_time=now + timedelta(days=1), + end_time=now + timedelta(days=7), + status="pending_approval", + raw_request={ + "buyer_ref": "test_buyer_ref", + "brand_manifest": "https://example.com/brand-manifest.json", + "start_time": (now + timedelta(days=1)).isoformat(), + "end_time": (now + timedelta(days=7)).isoformat(), + "packages": [ + { + "package_id": "pkg_1", + "product_id": product_id, + "budget": 1000.0, + } + ], + }, + ) + session.add(media_buy) + session.commit() + + # Execute approval - should fail + success, message = execute_approved_media_buy(media_buy_id, test_tenant) + + assert not success, "Approval should fail with non-HTTP URL" + assert "agent_url" in message.lower() + assert "http" in message.lower() + + # Cleanup + with get_db_session() as session: + stmt_mb = select(MediaBuy).filter_by(media_buy_id=media_buy_id) + media_buy = session.scalars(stmt_mb).first() + if media_buy: + session.delete(media_buy) + + stmt_prod = select(Product).filter_by(product_id=product_id) + product = session.scalars(stmt_prod).first() + if product: + session.delete(product) + + session.commit() + + def test_invalid_format_missing_format_id( + self, test_tenant, test_principal, test_currency_limit, test_property_tag + ): + """❌ FormatReference dict missing format_id/id should fail validation.""" + product_id = "prod_no_format_id" + media_buy_id = "mb_no_format_id" + + with get_db_session() as session: + # Create product with missing format_id + product = Product( + tenant_id=test_tenant, + product_id=product_id, + name="No Format ID Product", + description="Product with missing format_id", + formats=[ + { + "agent_url": "https://creatives.example.com", + # Missing format_id/id - should fail + } + ], + targeting_template={}, + delivery_type="guaranteed", + property_tags=["all_inventory"], + ) + session.add(product) + + # Add pricing option + pricing = PricingOption( + tenant_id=test_tenant, + product_id=product_id, + pricing_model="CPM", + rate=Decimal("10.00"), + currency="USD", + is_fixed=True, + ) + session.add(pricing) + + # Create media buy + now = datetime.now(UTC) + media_buy = MediaBuy( + tenant_id=test_tenant, + media_buy_id=media_buy_id, + principal_id=test_principal, + order_name="No Format ID Order", + advertiser_name="Test Advertiser", + budget=1000.0, + start_date=(now + timedelta(days=1)).date(), + end_date=(now + timedelta(days=7)).date(), + start_time=now + timedelta(days=1), + end_time=now + timedelta(days=7), + status="pending_approval", + raw_request={ + "buyer_ref": "test_buyer_ref", + "brand_manifest": "https://example.com/brand-manifest.json", + "start_time": (now + timedelta(days=1)).isoformat(), + "end_time": (now + timedelta(days=7)).isoformat(), + "packages": [ + { + "package_id": "pkg_1", + "product_id": product_id, + "budget": 1000.0, + } + ], + }, + ) + session.add(media_buy) + session.commit() + + # Execute approval - should fail + success, message = execute_approved_media_buy(media_buy_id, test_tenant) + + assert not success, "Approval should fail with missing format_id" + assert "id" in message.lower() + assert "format validation failed" in message.lower() + + # Cleanup + with get_db_session() as session: + stmt_mb = select(MediaBuy).filter_by(media_buy_id=media_buy_id) + media_buy = session.scalars(stmt_mb).first() + if media_buy: + session.delete(media_buy) + + stmt_prod = select(Product).filter_by(product_id=product_id) + product = session.scalars(stmt_prod).first() + if product: + session.delete(product) + + session.commit() + + def test_valid_format_id_dict_conversion(self, test_tenant, test_principal, test_currency_limit, test_property_tag): + """✅ Valid FormatId dict (with 'id' key) converts successfully.""" + product_id = "prod_format_id" + media_buy_id = "mb_format_id" + + with get_db_session() as session: + # Create product with FormatId-style dict (has 'id' field, not 'format_id') + product = Product( + tenant_id=test_tenant, + product_id=product_id, + name="Format ID Product", + description="Product with FormatId format", + formats=[ + { + "agent_url": "https://creatives.example.com", + "id": "display_728x90", # New field name per AdCP spec + } + ], + targeting_template={}, + delivery_type="guaranteed", + property_tags=["all_inventory"], + ) + session.add(product) + + # Add pricing option + pricing = PricingOption( + tenant_id=test_tenant, + product_id=product_id, + pricing_model="CPM", + rate=Decimal("15.00"), + currency="USD", + is_fixed=True, + ) + session.add(pricing) + + # Create media buy + now = datetime.now(UTC) + media_buy = MediaBuy( + tenant_id=test_tenant, + media_buy_id=media_buy_id, + principal_id=test_principal, + order_name="Format ID Test Order", + advertiser_name="Test Advertiser", + budget=1500.0, + start_date=(now + timedelta(days=1)).date(), + end_date=(now + timedelta(days=7)).date(), + start_time=now + timedelta(days=1), + end_time=now + timedelta(days=7), + status="pending_approval", + raw_request={ + "buyer_ref": "test_buyer_ref", + "brand_manifest": "https://example.com/brand-manifest.json", + "start_time": (now + timedelta(days=1)).isoformat(), + "end_time": (now + timedelta(days=7)).isoformat(), + "packages": [ + { + "package_id": "pkg_1", + "product_id": product_id, + "budget": 1500.0, + } + ], + }, + ) + session.add(media_buy) + session.commit() + + # Execute approval + success, message = execute_approved_media_buy(media_buy_id, test_tenant) + + assert success, f"Approval should succeed: {message}" + assert "successfully" in message.lower() + + # Cleanup + with get_db_session() as session: + stmt_mb = select(MediaBuy).filter_by(media_buy_id=media_buy_id) + media_buy = session.scalars(stmt_mb).first() + if media_buy: + session.delete(media_buy) + + stmt_prod = select(Product).filter_by(product_id=product_id) + product = session.scalars(stmt_prod).first() + if product: + session.delete(product) + + session.commit() + + def test_invalid_dict_missing_id(self, test_tenant, test_principal, test_currency_limit, test_property_tag): + """❌ Dict with neither 'id' nor 'format_id' should fail validation.""" + product_id = "prod_missing_both" + media_buy_id = "mb_missing_both" + + with get_db_session() as session: + # Create product with dict missing both id fields + product = Product( + tenant_id=test_tenant, + product_id=product_id, + name="Missing ID Product", + description="Product with dict missing both id fields", + formats=[ + { + "agent_url": "https://creatives.example.com", + "name": "Display Ad", # Wrong field - not id or format_id + } + ], + targeting_template={}, + delivery_type="guaranteed", + property_tags=["all_inventory"], + ) + session.add(product) + + # Add pricing option + pricing = PricingOption( + tenant_id=test_tenant, + product_id=product_id, + pricing_model="CPM", + rate=Decimal("10.00"), + currency="USD", + is_fixed=True, + ) + session.add(pricing) + + # Create media buy + now = datetime.now(UTC) + media_buy = MediaBuy( + tenant_id=test_tenant, + media_buy_id=media_buy_id, + principal_id=test_principal, + order_name="Missing Both IDs Order", + advertiser_name="Test Advertiser", + budget=1000.0, + start_date=(now + timedelta(days=1)).date(), + end_date=(now + timedelta(days=7)).date(), + start_time=now + timedelta(days=1), + end_time=now + timedelta(days=7), + status="pending_approval", + raw_request={ + "buyer_ref": "test_buyer_ref", + "brand_manifest": "https://example.com/brand-manifest.json", + "start_time": (now + timedelta(days=1)).isoformat(), + "end_time": (now + timedelta(days=7)).isoformat(), + "packages": [ + { + "package_id": "pkg_1", + "product_id": product_id, + "budget": 1000.0, + } + ], + }, + ) + session.add(media_buy) + session.commit() + + # Execute approval - should fail + success, message = execute_approved_media_buy(media_buy_id, test_tenant) + + assert not success, "Approval should fail with missing id/format_id" + assert "id" in message.lower() + + # Cleanup + with get_db_session() as session: + stmt_mb = select(MediaBuy).filter_by(media_buy_id=media_buy_id) + media_buy = session.scalars(stmt_mb).first() + if media_buy: + session.delete(media_buy) + + stmt_prod = select(Product).filter_by(product_id=product_id) + product = session.scalars(stmt_prod).first() + if product: + session.delete(product) + + session.commit() + + def test_empty_formats_list_fails(self, test_tenant, test_principal, test_currency_limit, test_property_tag): + """❌ Product with empty formats list should fail validation.""" + product_id = "prod_empty_formats" + media_buy_id = "mb_empty_formats" + + with get_db_session() as session: + # Create product with empty formats list + product = Product( + tenant_id=test_tenant, + product_id=product_id, + name="Empty Formats Product", + description="Product with no formats", + formats=[], # Empty list - should fail + targeting_template={}, + delivery_type="guaranteed", + property_tags=["all_inventory"], + ) + session.add(product) + + # Add pricing option + pricing = PricingOption( + tenant_id=test_tenant, + product_id=product_id, + pricing_model="CPM", + rate=Decimal("10.00"), + currency="USD", + is_fixed=True, + ) + session.add(pricing) + + # Create media buy + now = datetime.now(UTC) + media_buy = MediaBuy( + tenant_id=test_tenant, + media_buy_id=media_buy_id, + principal_id=test_principal, + order_name="Empty Formats Order", + advertiser_name="Test Advertiser", + budget=1000.0, + start_date=(now + timedelta(days=1)).date(), + end_date=(now + timedelta(days=7)).date(), + start_time=now + timedelta(days=1), + end_time=now + timedelta(days=7), + status="pending_approval", + raw_request={ + "buyer_ref": "test_buyer_ref", + "brand_manifest": "https://example.com/brand-manifest.json", + "start_time": (now + timedelta(days=1)).isoformat(), + "end_time": (now + timedelta(days=7)).isoformat(), + "packages": [ + { + "package_id": "pkg_1", + "product_id": product_id, + "budget": 1000.0, + } + ], + }, + ) + session.add(media_buy) + session.commit() + + # Execute approval - should fail + success, message = execute_approved_media_buy(media_buy_id, test_tenant) + + assert not success, "Approval should fail with empty formats" + assert "no valid formats" in message.lower() + + # Cleanup + with get_db_session() as session: + stmt_mb = select(MediaBuy).filter_by(media_buy_id=media_buy_id) + media_buy = session.scalars(stmt_mb).first() + if media_buy: + session.delete(media_buy) + + stmt_prod = select(Product).filter_by(product_id=product_id) + product = session.scalars(stmt_prod).first() + if product: + session.delete(product) + + session.commit() + + def test_mixed_valid_format_types(self, test_tenant, test_principal, test_currency_limit, test_property_tag): + """✅ Product with mixed valid format types (FormatRef, FormatId, dict) succeeds.""" + product_id = "prod_mixed_formats" + media_buy_id = "mb_mixed_formats" + + with get_db_session() as session: + # Create product with multiple format types + product = Product( + tenant_id=test_tenant, + product_id=product_id, + name="Mixed Formats Product", + description="Product with different format styles", + formats=[ + # FormatReference style (legacy) + { + "agent_url": "https://creatives.example.com", + "format_id": "display_300x250", + }, + # FormatId style (new) + { + "agent_url": "https://creatives.example.com", + "id": "display_728x90", + }, + # Another FormatId style + { + "agent_url": "https://creatives.example.com", + "id": "display_160x600", + }, + ], + targeting_template={}, + delivery_type="guaranteed", + property_tags=["all_inventory"], + ) + session.add(product) + + # Add pricing option + pricing = PricingOption( + tenant_id=test_tenant, + product_id=product_id, + pricing_model="CPM", + rate=Decimal("20.00"), + currency="USD", + is_fixed=True, + ) + session.add(pricing) + + # Create media buy + now = datetime.now(UTC) + media_buy = MediaBuy( + tenant_id=test_tenant, + media_buy_id=media_buy_id, + principal_id=test_principal, + order_name="Mixed Formats Order", + advertiser_name="Test Advertiser", + budget=2000.0, + start_date=(now + timedelta(days=1)).date(), + end_date=(now + timedelta(days=7)).date(), + start_time=now + timedelta(days=1), + end_time=now + timedelta(days=7), + status="pending_approval", + raw_request={ + "buyer_ref": "test_buyer_ref", + "brand_manifest": "https://example.com/brand-manifest.json", + "start_time": (now + timedelta(days=1)).isoformat(), + "end_time": (now + timedelta(days=7)).isoformat(), + "packages": [ + { + "package_id": "pkg_1", + "product_id": product_id, + "budget": 2000.0, + } + ], + }, + ) + session.add(media_buy) + session.commit() + + # Execute approval - should succeed + success, message = execute_approved_media_buy(media_buy_id, test_tenant) + + assert success, f"Approval should succeed with mixed formats: {message}" + assert "successfully" in message.lower() + + # Cleanup + with get_db_session() as session: + stmt_mb = select(MediaBuy).filter_by(media_buy_id=media_buy_id) + media_buy = session.scalars(stmt_mb).first() + if media_buy: + session.delete(media_buy) + + stmt_prod = select(Product).filter_by(product_id=product_id) + product = session.scalars(stmt_prod).first() + if product: + session.delete(product) + + session.commit() + + def test_invalid_format_unknown_type(self, test_tenant, test_principal, test_currency_limit, test_property_tag): + """❌ Format with unknown type (string, int) should fail validation.""" + product_id = "prod_invalid_type" + media_buy_id = "mb_invalid_type" + + with get_db_session() as session: + # Create product with invalid format type (string instead of dict) + product = Product( + tenant_id=test_tenant, + product_id=product_id, + name="Invalid Type Product", + description="Product with string format (should be dict)", + formats=["display_300x250"], # String instead of dict - should fail + targeting_template={}, + delivery_type="guaranteed", + property_tags=["all_inventory"], + ) + session.add(product) + + # Add pricing option + pricing = PricingOption( + tenant_id=test_tenant, + product_id=product_id, + pricing_model="CPM", + rate=Decimal("10.00"), + currency="USD", + is_fixed=True, + ) + session.add(pricing) + + # Create media buy + now = datetime.now(UTC) + media_buy = MediaBuy( + tenant_id=test_tenant, + media_buy_id=media_buy_id, + principal_id=test_principal, + order_name="Invalid Type Order", + advertiser_name="Test Advertiser", + budget=1000.0, + start_date=(now + timedelta(days=1)).date(), + end_date=(now + timedelta(days=7)).date(), + start_time=now + timedelta(days=1), + end_time=now + timedelta(days=7), + status="pending_approval", + raw_request={ + "buyer_ref": "test_buyer_ref", + "brand_manifest": "https://example.com/brand-manifest.json", + "start_time": (now + timedelta(days=1)).isoformat(), + "end_time": (now + timedelta(days=7)).isoformat(), + "packages": [ + { + "package_id": "pkg_1", + "product_id": product_id, + "budget": 1000.0, + } + ], + }, + ) + session.add(media_buy) + session.commit() + + # Execute approval - should fail + success, message = execute_approved_media_buy(media_buy_id, test_tenant) + + assert not success, "Approval should fail with unknown format type" + assert "unknown format type" in message.lower() or "format validation failed" in message.lower() + + # Cleanup + with get_db_session() as session: + stmt_mb = select(MediaBuy).filter_by(media_buy_id=media_buy_id) + media_buy = session.scalars(stmt_mb).first() + if media_buy: + session.delete(media_buy) + + stmt_prod = select(Product).filter_by(product_id=product_id) + product = session.scalars(stmt_prod).first() + if product: + session.delete(product) + + session.commit() diff --git a/tests/integration/test_gam_validation_integration.py b/tests/integration/test_gam_validation_integration.py index 08ca1d02d..bb41d0bdd 100644 --- a/tests/integration/test_gam_validation_integration.py +++ b/tests/integration/test_gam_validation_integration.py @@ -8,9 +8,13 @@ from datetime import datetime from unittest.mock import patch +import pytest + from src.adapters.google_ad_manager import GoogleAdManager from src.core.schemas import Principal +pytestmark = [pytest.mark.integration, pytest.mark.requires_db] + class TestGAMValidationIntegration: """Test GAM adapter integration with validation.""" diff --git a/tests/integration/test_impression_tracker_flow.py b/tests/integration/test_impression_tracker_flow.py index 4657ff815..6c9cce976 100644 --- a/tests/integration/test_impression_tracker_flow.py +++ b/tests/integration/test_impression_tracker_flow.py @@ -8,9 +8,13 @@ from datetime import UTC, datetime from unittest.mock import patch +import pytest + from src.core.helpers import _convert_creative_to_adapter_asset from src.core.schemas import Creative +pytestmark = [pytest.mark.integration, pytest.mark.requires_db] + class TestImpressionTrackerFlow: """Test impression tracker URL preservation through creative conversion.""" diff --git a/tests/integration/test_mcp_contract_validation.py b/tests/integration/test_mcp_contract_validation.py index 86dace3c0..b44cdc734 100644 --- a/tests/integration/test_mcp_contract_validation.py +++ b/tests/integration/test_mcp_contract_validation.py @@ -22,7 +22,7 @@ UpdateMediaBuyRequest, ) -pytestmark = pytest.mark.integration +pytestmark = [pytest.mark.integration, pytest.mark.requires_db] class TestMCPContractValidation: diff --git a/tests/integration/test_media_buy_readiness.py b/tests/integration/test_media_buy_readiness.py index b177aebfc..e8116ec68 100644 --- a/tests/integration/test_media_buy_readiness.py +++ b/tests/integration/test_media_buy_readiness.py @@ -12,6 +12,8 @@ from src.core.database.database_session import get_db_session from src.core.database.models import Creative, CreativeAssignment, MediaBuy, Principal, Tenant +pytestmark = [pytest.mark.integration, pytest.mark.requires_db] + @pytest.fixture def test_tenant(integration_db, request): diff --git a/tests/integration/test_mock_adapter.py b/tests/integration/test_mock_adapter.py index e0ea18334..46d027fdf 100644 --- a/tests/integration/test_mock_adapter.py +++ b/tests/integration/test_mock_adapter.py @@ -5,7 +5,7 @@ import pytest -pytestmark = pytest.mark.integration +pytestmark = [pytest.mark.integration, pytest.mark.requires_db] # This is what the API endpoint /api/tenant/{tenant_id}/gam/line-item/7047822666 would return mock_response = { diff --git a/tests/integration/test_notification_urls_exist.py b/tests/integration/test_notification_urls_exist.py index a252a7df8..0c4c14d8d 100644 --- a/tests/integration/test_notification_urls_exist.py +++ b/tests/integration/test_notification_urls_exist.py @@ -15,6 +15,8 @@ from src.admin.app import create_app +pytestmark = [pytest.mark.integration, pytest.mark.requires_db] + class TestNotificationUrlsExist: """Verify all notification URLs correspond to actual routes.""" diff --git a/tests/integration/test_oauth_session_handling.py b/tests/integration/test_oauth_session_handling.py index d261a8984..de913b18d 100644 --- a/tests/integration/test_oauth_session_handling.py +++ b/tests/integration/test_oauth_session_handling.py @@ -5,6 +5,10 @@ key behaviors while preventing regressions. """ +import pytest + +pytestmark = [pytest.mark.integration, pytest.mark.requires_db] + class TestOAuthCrossDomainLimitations: """Test documenting OAuth cross-domain limitations and current behavior.""" diff --git a/tests/integration/test_policy.py b/tests/integration/test_policy.py index 253f69710..d86271760 100644 --- a/tests/integration/test_policy.py +++ b/tests/integration/test_policy.py @@ -7,7 +7,7 @@ from src.core.schema_adapters import GetProductsRequest from src.services.policy_check_service import PolicyCheckResult, PolicyCheckService, PolicyStatus -pytestmark = pytest.mark.integration +pytestmark = [pytest.mark.integration, pytest.mark.requires_db] @pytest.fixture diff --git a/tests/integration/test_schema_contract_validation.py b/tests/integration/test_schema_contract_validation.py index 335178bbd..5fd8c3427 100644 --- a/tests/integration/test_schema_contract_validation.py +++ b/tests/integration/test_schema_contract_validation.py @@ -22,6 +22,8 @@ import pytest +pytestmark = [pytest.mark.integration, pytest.mark.requires_db] + from src.core.schemas import ( Budget, Creative, diff --git a/tests/integration/test_schema_field_validation.py b/tests/integration/test_schema_field_validation.py index 6186e5bf7..c949eeb11 100644 --- a/tests/integration/test_schema_field_validation.py +++ b/tests/integration/test_schema_field_validation.py @@ -12,8 +12,9 @@ from src.core.database.models import MediaBuy, Product, Tenant +pytestmark = [pytest.mark.integration, pytest.mark.requires_db] + -@pytest.mark.integration class TestSchemaFieldValidation: """Validate that field references in code match actual database schema.""" diff --git a/tests/integration/test_self_service_signup.py b/tests/integration/test_self_service_signup.py index 66dd8d2b8..b12f44c6b 100644 --- a/tests/integration/test_self_service_signup.py +++ b/tests/integration/test_self_service_signup.py @@ -19,6 +19,8 @@ from src.core.database.database_session import get_db_session from src.core.database.models import AdapterConfig, CurrencyLimit, Tenant, User +pytestmark = [pytest.mark.integration, pytest.mark.requires_db] + class TestSelfServiceSignupFlow: """Test self-service tenant signup flow.""" diff --git a/tests/integration/test_spec_compliance.py b/tests/integration/test_spec_compliance.py index 4aed3d9e9..cb190c085 100644 --- a/tests/integration/test_spec_compliance.py +++ b/tests/integration/test_spec_compliance.py @@ -8,7 +8,7 @@ from fastmcp.client import Client from fastmcp.client.transports import StreamableHttpTransport -pytestmark = pytest.mark.integration +pytestmark = [pytest.mark.integration, pytest.mark.requires_db] @pytest.mark.asyncio diff --git a/tests/integration/test_template_url_validation.py b/tests/integration/test_template_url_validation.py index 47306b224..7eb4140e3 100644 --- a/tests/integration/test_template_url_validation.py +++ b/tests/integration/test_template_url_validation.py @@ -15,10 +15,9 @@ admin_app, _ = create_app() +pytestmark = [pytest.mark.integration, pytest.mark.requires_db, pytest.mark.ui] + -@pytest.mark.integration -@pytest.mark.ui -@pytest.mark.requires_db class TestTemplateUrlValidation: """Validate all url_for calls in templates can be resolved.""" @@ -77,10 +76,6 @@ def test_all_template_url_for_calls_resolve(self, authenticated_admin_session, t test_params["property_id"] = "test_property" if "config_id" in params: test_params["config_id"] = "test_config" - if "agent_id" in params: - test_params["agent_id"] = 1 # Integer ID for signals/creative agents - if "filename" in params: - test_params["filename"] = "test.js" # For static file routes # Try to build the URL url = url_for(endpoint, **test_params) @@ -267,12 +262,6 @@ def test_ajax_urls_are_valid(self): if "delete" in endpoint or "toggle" in endpoint: test_params["config_id"] = "test_config" - # Add agent_id for signals agent endpoints - if "signals_agent" in endpoint and ( - "edit" in endpoint or "test" in endpoint or "delete" in endpoint - ): - test_params["agent_id"] = 1 - url_for(endpoint, **test_params) except BuildError as e: ajax_errors.append( diff --git a/tests/integration/test_tool_registration.py b/tests/integration/test_tool_registration.py index d6d5b1d7f..089143e1d 100644 --- a/tests/integration/test_tool_registration.py +++ b/tests/integration/test_tool_registration.py @@ -1,5 +1,9 @@ """Test that all AdCP tools are properly registered with MCP server.""" +import pytest + +pytestmark = [pytest.mark.integration, pytest.mark.requires_db] + def test_all_tools_registered(): """Verify all expected AdCP tools are registered with MCP.""" diff --git a/tests/integration/test_unified_delivery.py b/tests/integration/test_unified_delivery.py index 8f25f91ab..e73e9cf7c 100644 --- a/tests/integration/test_unified_delivery.py +++ b/tests/integration/test_unified_delivery.py @@ -10,7 +10,7 @@ from fastmcp.client import Client from fastmcp.client.transports import StreamableHttpTransport -pytestmark = pytest.mark.integration +pytestmark = [pytest.mark.integration, pytest.mark.requires_db] @pytest.mark.asyncio diff --git a/tests/integration/test_virtual_host_integration.py b/tests/integration/test_virtual_host_integration.py index 919e92739..63c83e708 100644 --- a/tests/integration/test_virtual_host_integration.py +++ b/tests/integration/test_virtual_host_integration.py @@ -1,7 +1,11 @@ """Integration tests for virtual host functionality.""" +import pytest + from src.core.config_loader import get_tenant_by_virtual_host +pytestmark = [pytest.mark.integration, pytest.mark.requires_db] + class TestVirtualHostIntegration: """Test virtual host integration across multiple components.""" diff --git a/tests/integration/test_workflow_approval.py b/tests/integration/test_workflow_approval.py index 8fa4a1c13..cc99b7df3 100644 --- a/tests/integration/test_workflow_approval.py +++ b/tests/integration/test_workflow_approval.py @@ -16,8 +16,9 @@ from src.core.database.models import Context, ObjectWorkflowMapping, WorkflowStep from tests.utils.database_helpers import get_utc_now +pytestmark = [pytest.mark.integration, pytest.mark.requires_db] + -@pytest.mark.integration class TestWorkflowApproval: """Test the workflow approval system.""" diff --git a/tests/integration/test_workflow_with_server.py b/tests/integration/test_workflow_with_server.py index cfb6103c9..e046b5f5c 100644 --- a/tests/integration/test_workflow_with_server.py +++ b/tests/integration/test_workflow_with_server.py @@ -10,6 +10,8 @@ console = Console() +pytestmark = [pytest.mark.integration, pytest.mark.requires_db] + @pytest.mark.asyncio @pytest.mark.requires_server