diff --git a/src/admin/blueprints/publisher_partners.py b/src/admin/blueprints/publisher_partners.py index b0ed76ed2..29be096ca 100644 --- a/src/admin/blueprints/publisher_partners.py +++ b/src/admin/blueprints/publisher_partners.py @@ -217,6 +217,58 @@ def sync_publisher_partners(tenant_id: str) -> Response | tuple[Response, int]: session.commit() + # Create mock properties for verified publishers (only for mock adapters - they don't have real adagents.json) + properties_created = 0 + tags_created = 0 + if is_mock and verified_domains: + from src.core.database.models import AuthorizedProperty, PropertyTag + + # Ensure 'all_inventory' tag exists + tag_stmt = select(PropertyTag).where( + PropertyTag.tenant_id == tenant_id, PropertyTag.tag_id == "all_inventory" + ) + all_inventory_tag = session.scalars(tag_stmt).first() + if not all_inventory_tag: + all_inventory_tag = PropertyTag( + tag_id="all_inventory", + tenant_id=tenant_id, + name="All Inventory", + description="Default tag that applies to all properties.", + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + session.add(all_inventory_tag) + tags_created += 1 + logger.info(f"Created 'all_inventory' tag for tenant {tenant_id}") + + # Create a mock property for each publisher domain + for domain in verified_domains: + property_id = f"website_{domain.replace('.', '_').replace('-', '_')}" + prop_stmt = select(AuthorizedProperty).where( + AuthorizedProperty.tenant_id == tenant_id, + AuthorizedProperty.property_id == property_id, + ) + existing = session.scalars(prop_stmt).first() + if not existing: + mock_property = AuthorizedProperty( + tenant_id=tenant_id, + property_id=property_id, + property_type="website", + name=domain, + publisher_domain=domain, + identifiers=[{"type": "domain", "value": domain}], + tags=["all_inventory"], + verification_status="verified", + verification_checked_at=datetime.now(UTC), + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + session.add(mock_property) + properties_created += 1 + logger.info(f"Created mock property for {domain}") + + session.commit() + return jsonify( { "message": f"Sync completed ({reason_str} - auto-verified)", @@ -224,6 +276,8 @@ def sync_publisher_partners(tenant_id: str) -> Response | tuple[Response, int]: "verified": len(partners), "errors": 0, "total": len(partners), + "properties_created": properties_created, + "tags_created": tags_created, } ) diff --git a/src/services/delivery_webhook_scheduler.py b/src/services/delivery_webhook_scheduler.py index 0281728a1..aa6fdf9e7 100644 --- a/src/services/delivery_webhook_scheduler.py +++ b/src/services/delivery_webhook_scheduler.py @@ -27,7 +27,7 @@ # 1 hour because AdCP protocol has frequency options hourly, daily and monthly # Configurable via env var for testing -SLEEP_INTERVAL_SECONDS = int(os.getenv("DELIVERY_WEBHOOK_INTERVAL", "3600")) +SLEEP_INTERVAL_SECONDS = int(os.getenv("DELIVERY_WEBHOOK_INTERVAL") or "3600") class DeliveryWebhookScheduler: diff --git a/src/services/media_buy_status_scheduler.py b/src/services/media_buy_status_scheduler.py index 724149223..0c376a669 100644 --- a/src/services/media_buy_status_scheduler.py +++ b/src/services/media_buy_status_scheduler.py @@ -23,7 +23,7 @@ logger = logging.getLogger(__name__) # Configurable via env var - default 60 seconds -STATUS_CHECK_INTERVAL_SECONDS = int(os.getenv("MEDIA_BUY_STATUS_CHECK_INTERVAL", "60")) +STATUS_CHECK_INTERVAL_SECONDS = int(os.getenv("MEDIA_BUY_STATUS_CHECK_INTERVAL") or "60") class MediaBuyStatusScheduler: diff --git a/tests/integration/test_mock_adapter_publisher_sync.py b/tests/integration/test_mock_adapter_publisher_sync.py new file mode 100644 index 000000000..2d499809a --- /dev/null +++ b/tests/integration/test_mock_adapter_publisher_sync.py @@ -0,0 +1,221 @@ +"""Tests for mock adapter publisher sync property creation. + +These tests ensure that when syncing publishers for mock/dev adapters, +AuthorizedProperty and PropertyTag records are created correctly. + +This is a regression test for an issue where mock tenants would have +verified publishers but no properties, causing the product creation UI +to show empty property/tag lists. +""" + +import pytest +from sqlalchemy import select + +from src.core.database.database_session import get_db_session +from src.core.database.models import ( + AdapterConfig, + AuthorizedProperty, + PropertyTag, + PublisherPartner, + Tenant, +) + + +@pytest.mark.requires_db +class TestMockAdapterPublisherSync: + """Test that mock adapter publisher sync creates properties and tags.""" + + @pytest.fixture + def mock_tenant(self, integration_db): + """Create a tenant with mock adapter configuration.""" + from datetime import UTC, datetime + + with get_db_session() as session: + # Create tenant + tenant = Tenant( + tenant_id="test_mock_sync", + name="Test Mock Sync Tenant", + subdomain="test-mock-sync", + ad_server="mock", + authorized_emails=["test@example.com"], + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + session.add(tenant) + session.flush() + + # Create adapter config for mock adapter + adapter_config = AdapterConfig( + tenant_id="test_mock_sync", + adapter_type="mock", + ) + session.add(adapter_config) + session.commit() + + yield "test_mock_sync" + + # Cleanup + session.execute(select(AuthorizedProperty).where(AuthorizedProperty.tenant_id == "test_mock_sync")) + for prop in session.scalars( + select(AuthorizedProperty).where(AuthorizedProperty.tenant_id == "test_mock_sync") + ).all(): + session.delete(prop) + for tag in session.scalars(select(PropertyTag).where(PropertyTag.tenant_id == "test_mock_sync")).all(): + session.delete(tag) + for partner in session.scalars( + select(PublisherPartner).where(PublisherPartner.tenant_id == "test_mock_sync") + ).all(): + session.delete(partner) + session.delete(adapter_config) + session.delete(tenant) + session.commit() + + @pytest.fixture + def publisher_partner(self, mock_tenant): + """Create a publisher partner for the mock tenant.""" + from datetime import UTC, datetime + + with get_db_session() as session: + partner = PublisherPartner( + tenant_id=mock_tenant, + publisher_domain="example.com", + display_name="Example Publisher", + sync_status="pending", + is_verified=False, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + session.add(partner) + session.commit() + + return partner.id + + def test_sync_creates_property_tag(self, mock_tenant, publisher_partner): + """Test that sync creates 'all_inventory' PropertyTag for mock tenants.""" + from unittest.mock import patch + + from src.admin.app import create_app + from src.admin.blueprints.publisher_partners import sync_publisher_partners + from src.core.config import AppConfig + + # Mock config to return development environment (triggers auto-verify) + mock_config = AppConfig() + mock_config.environment = "development" + + app, _ = create_app() + + with app.test_request_context(): + with patch("src.admin.blueprints.publisher_partners.get_config", return_value=mock_config): + response = sync_publisher_partners(mock_tenant) + data = response.get_json() + + assert data["verified"] == 1 + assert data.get("tags_created", 0) >= 0 # May be 0 if tag already exists + + # Verify PropertyTag was created + with get_db_session() as session: + tag = session.scalars( + select(PropertyTag).where(PropertyTag.tenant_id == mock_tenant, PropertyTag.tag_id == "all_inventory") + ).first() + + assert tag is not None, "all_inventory PropertyTag should be created" + assert tag.name == "All Inventory" + + def test_sync_creates_authorized_property(self, mock_tenant, publisher_partner): + """Test that sync creates AuthorizedProperty for each publisher domain.""" + from unittest.mock import patch + + from src.admin.app import create_app + from src.admin.blueprints.publisher_partners import sync_publisher_partners + from src.core.config import AppConfig + + mock_config = AppConfig() + mock_config.environment = "development" + + app, _ = create_app() + + with app.test_request_context(): + with patch("src.admin.blueprints.publisher_partners.get_config", return_value=mock_config): + response = sync_publisher_partners(mock_tenant) + data = response.get_json() + + assert data["verified"] == 1 + assert data.get("properties_created", 0) >= 1 + + # Verify AuthorizedProperty was created + with get_db_session() as session: + props = session.scalars(select(AuthorizedProperty).where(AuthorizedProperty.tenant_id == mock_tenant)).all() + + assert len(props) >= 1, "At least one AuthorizedProperty should be created" + + # Find the property for example.com + example_prop = next((p for p in props if "example" in p.property_id), None) + assert example_prop is not None, "Property for example.com should exist" + assert example_prop.verification_status == "verified" + assert example_prop.property_type == "website" + assert "all_inventory" in (example_prop.tags or []) + + def test_sync_property_has_verified_status(self, mock_tenant, publisher_partner): + """Test that created properties have verification_status='verified'. + + This is critical because the product creation UI only shows properties + with verification_status='verified'. + """ + from unittest.mock import patch + + from src.admin.app import create_app + from src.admin.blueprints.publisher_partners import sync_publisher_partners + from src.core.config import AppConfig + + mock_config = AppConfig() + mock_config.environment = "development" + + app, _ = create_app() + + with app.test_request_context(): + with patch("src.admin.blueprints.publisher_partners.get_config", return_value=mock_config): + sync_publisher_partners(mock_tenant) + + # Verify all properties have verified status + with get_db_session() as session: + props = session.scalars(select(AuthorizedProperty).where(AuthorizedProperty.tenant_id == mock_tenant)).all() + + for prop in props: + assert prop.verification_status == "verified", ( + f"Property {prop.property_id} should have verification_status='verified', " + f"got '{prop.verification_status}'" + ) + + def test_sync_is_idempotent(self, mock_tenant, publisher_partner): + """Test that running sync multiple times doesn't create duplicate records.""" + from unittest.mock import patch + + from src.admin.app import create_app + from src.admin.blueprints.publisher_partners import sync_publisher_partners + from src.core.config import AppConfig + + mock_config = AppConfig() + mock_config.environment = "development" + + app, _ = create_app() + + # Run sync twice + with app.test_request_context(): + with patch("src.admin.blueprints.publisher_partners.get_config", return_value=mock_config): + sync_publisher_partners(mock_tenant) + sync_publisher_partners(mock_tenant) + + # Verify no duplicates + with get_db_session() as session: + tags = session.scalars( + select(PropertyTag).where(PropertyTag.tenant_id == mock_tenant, PropertyTag.tag_id == "all_inventory") + ).all() + assert len(tags) == 1, "Should have exactly one all_inventory tag" + + props = session.scalars( + select(AuthorizedProperty).where( + AuthorizedProperty.tenant_id == mock_tenant, + AuthorizedProperty.publisher_domain == "example.com", + ) + ).all() + assert len(props) == 1, "Should have exactly one property per domain" diff --git a/tests/unit/test_scheduler_env_var_handling.py b/tests/unit/test_scheduler_env_var_handling.py new file mode 100644 index 000000000..1d23a5833 --- /dev/null +++ b/tests/unit/test_scheduler_env_var_handling.py @@ -0,0 +1,97 @@ +"""Tests for scheduler environment variable handling. + +These tests ensure that scheduler modules handle edge cases in environment +variable parsing, particularly empty strings which can cause startup crashes. +""" + +import os +from unittest.mock import patch + + +class TestDeliveryWebhookSchedulerEnvVar: + """Test DELIVERY_WEBHOOK_INTERVAL environment variable handling.""" + + def test_default_value_when_env_not_set(self): + """Test that default value (3600) is used when env var is not set.""" + with patch.dict(os.environ, {}, clear=True): + # Remove the env var if it exists + os.environ.pop("DELIVERY_WEBHOOK_INTERVAL", None) + + # Re-import to get fresh module-level constant + import importlib + + import src.services.delivery_webhook_scheduler as module + + importlib.reload(module) + + assert module.SLEEP_INTERVAL_SECONDS == 3600 + + def test_default_value_when_env_is_empty_string(self): + """Test that default value is used when env var is empty string. + + This is a regression test for a production crash where docker-compose + set DELIVERY_WEBHOOK_INTERVAL="" which caused int('') to raise ValueError. + """ + with patch.dict(os.environ, {"DELIVERY_WEBHOOK_INTERVAL": ""}, clear=False): + import importlib + + import src.services.delivery_webhook_scheduler as module + + importlib.reload(module) + + # Should use default 3600, not crash with ValueError + assert module.SLEEP_INTERVAL_SECONDS == 3600 + + def test_custom_value_when_env_is_set(self): + """Test that custom value is used when env var is set to valid integer.""" + with patch.dict(os.environ, {"DELIVERY_WEBHOOK_INTERVAL": "1800"}, clear=False): + import importlib + + import src.services.delivery_webhook_scheduler as module + + importlib.reload(module) + + assert module.SLEEP_INTERVAL_SECONDS == 1800 + + +class TestMediaBuyStatusSchedulerEnvVar: + """Test MEDIA_BUY_STATUS_CHECK_INTERVAL environment variable handling.""" + + def test_default_value_when_env_not_set(self): + """Test that default value (60) is used when env var is not set.""" + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("MEDIA_BUY_STATUS_CHECK_INTERVAL", None) + + import importlib + + import src.services.media_buy_status_scheduler as module + + importlib.reload(module) + + assert module.STATUS_CHECK_INTERVAL_SECONDS == 60 + + def test_default_value_when_env_is_empty_string(self): + """Test that default value is used when env var is empty string. + + This is a regression test - same pattern as DELIVERY_WEBHOOK_INTERVAL. + """ + with patch.dict(os.environ, {"MEDIA_BUY_STATUS_CHECK_INTERVAL": ""}, clear=False): + import importlib + + import src.services.media_buy_status_scheduler as module + + importlib.reload(module) + + # Should use default 60, not crash with ValueError + assert module.STATUS_CHECK_INTERVAL_SECONDS == 60 + + def test_custom_value_when_env_is_set(self): + """Test that custom value is used when env var is set to valid integer.""" + with patch.dict(os.environ, {"MEDIA_BUY_STATUS_CHECK_INTERVAL": "120"}, clear=False): + import importlib + + import src.services.media_buy_status_scheduler as module + + importlib.reload(module) + + assert module.STATUS_CHECK_INTERVAL_SECONDS == 120