diff --git a/schemas/v1/_schemas_v1_core_format_json.json b/schemas/v1/_schemas_v1_core_format_json.json index ea886a6b3..35e4924ed 100644 --- a/schemas/v1/_schemas_v1_core_format_json.json +++ b/schemas/v1/_schemas_v1_core_format_json.json @@ -20,7 +20,7 @@ "preview_image": { "type": "string", "format": "uri", - "description": "Optional preview image URL for format browsing/discovery UI. Should be 400x300px (4:3 aspect ratio) PNG or JPG. Used as thumbnail/card image in format browsers." + "description": "DEPRECATED: Use format_card instead. Optional preview image URL for format browsing/discovery UI. Should be 400x300px (4:3 aspect ratio) PNG or JPG. Used as thumbnail/card image in format browsers. This field is maintained for backward compatibility but format_card provides a more flexible, structured approach." }, "example_url": { "type": "string", @@ -152,6 +152,7 @@ "vast", "daast", "text", + "markdown", "html", "css", "javascript", @@ -224,6 +225,7 @@ "vast", "daast", "text", + "markdown", "html", "css", "javascript", @@ -282,6 +284,46 @@ "items": { "$ref": "/schemas/v1/core/format-id.json" } + }, + "format_card": { + "type": "object", + "description": "Optional standard visual card (300x400px) for displaying this format in user interfaces. Can be rendered via preview_creative or pre-generated.", + "properties": { + "format_id": { + "$ref": "/schemas/v1/core/format-id.json", + "description": "Creative format defining the card layout (typically format_card_standard)" + }, + "manifest": { + "type": "object", + "description": "Asset manifest for rendering the card, structure defined by the format", + "additionalProperties": true + } + }, + "required": [ + "format_id", + "manifest" + ], + "additionalProperties": false + }, + "format_card_detailed": { + "type": "object", + "description": "Optional detailed card with carousel and full specifications. Provides rich format documentation similar to ad spec pages.", + "properties": { + "format_id": { + "$ref": "/schemas/v1/core/format-id.json", + "description": "Creative format defining the detailed card layout (typically format_card_detailed)" + }, + "manifest": { + "type": "object", + "description": "Asset manifest for rendering the detailed card, structure defined by the format", + "additionalProperties": true + } + }, + "required": [ + "format_id", + "manifest" + ], + "additionalProperties": false } }, "required": [ diff --git a/schemas/v1/_schemas_v1_core_product_json.json b/schemas/v1/_schemas_v1_core_product_json.json index 09f743ebb..91a52ff55 100644 --- a/schemas/v1/_schemas_v1_core_product_json.json +++ b/schemas/v1/_schemas_v1_core_product_json.json @@ -123,6 +123,46 @@ "type": "string", "format": "date-time", "description": "Expiration timestamp for custom products" + }, + "product_card": { + "type": "object", + "description": "Optional standard visual card (300x400px) for displaying this product in user interfaces. Can be rendered via preview_creative or pre-generated.", + "properties": { + "format_id": { + "$ref": "/schemas/v1/core/format-id.json", + "description": "Creative format defining the card layout (typically product_card_standard)" + }, + "manifest": { + "type": "object", + "description": "Asset manifest for rendering the card, structure defined by the format", + "additionalProperties": true + } + }, + "required": [ + "format_id", + "manifest" + ], + "additionalProperties": false + }, + "product_card_detailed": { + "type": "object", + "description": "Optional detailed card with carousel and full specifications. Provides rich product presentation similar to media kit pages.", + "properties": { + "format_id": { + "$ref": "/schemas/v1/core/format-id.json", + "description": "Creative format defining the detailed card layout (typically product_card_detailed)" + }, + "manifest": { + "type": "object", + "description": "Asset manifest for rendering the detailed card, structure defined by the format", + "additionalProperties": true + } + }, + "required": [ + "format_id", + "manifest" + ], + "additionalProperties": false } }, "required": [ diff --git a/scripts/setup/init_database_ci.py b/scripts/setup/init_database_ci.py index fcbf60c0c..7c7dc4df6 100644 --- a/scripts/setup/init_database_ci.py +++ b/scripts/setup/init_database_ci.py @@ -23,6 +23,7 @@ def init_db_ci(): from src.core.database.models import ( AuthorizedProperty, CurrencyLimit, + GAMInventory, PricingOption, Principal, Product, @@ -367,6 +368,59 @@ def init_db_ci(): else: print(" ℹ️ Authorized property already exists: example.com") + # Create GAM inventory for setup checklist completion (inventory sync) + print("\nCreating GAM inventory for setup checklist...") + stmt_check_inventory = select(GAMInventory).filter_by(tenant_id=tenant_id) + existing_inventory_count = len(session.scalars(stmt_check_inventory).all()) + + if existing_inventory_count == 0: + # Create sample inventory items to satisfy setup checklist + inventory_items = [ + GAMInventory( + tenant_id=tenant_id, + inventory_type="ad_unit", + inventory_id="ci_test_ad_unit_1", + name="CI Test Ad Unit - Homepage", + path=["root", "website", "homepage"], + status="active", + inventory_metadata={"sizes": ["300x250", "728x90"]}, + ), + GAMInventory( + tenant_id=tenant_id, + inventory_type="ad_unit", + inventory_id="ci_test_ad_unit_2", + name="CI Test Ad Unit - Article", + path=["root", "website", "article"], + status="active", + inventory_metadata={"sizes": ["300x600", "970x250"]}, + ), + GAMInventory( + tenant_id=tenant_id, + inventory_type="placement", + inventory_id="ci_test_placement_1", + name="CI Test Placement - Premium", + path=["root"], + status="active", + inventory_metadata={"description": "Premium placement for CI tests"}, + ), + GAMInventory( + tenant_id=tenant_id, + inventory_type="targeting_key", + inventory_id="ci_test_targeting_key_1", + name="CI Test Key - Category", + path=[], + status="active", + inventory_metadata={"type": "predefined", "values": ["news", "sports", "entertainment"]}, + ), + ] + for item in inventory_items: + session.add(item) + + session.commit() + print(f" ✓ Created {len(inventory_items)} inventory items (ad units, placements, targeting)") + else: + print(f" ℹ️ Inventory already exists: {existing_inventory_count} items") + # Verify products were actually saved stmt_verify = select(Product).filter_by(tenant_id=tenant_id) saved_products = session.scalars(stmt_verify).all() diff --git a/src/admin/blueprints/inventory.py b/src/admin/blueprints/inventory.py index 3c95c1615..e0743f46e 100644 --- a/src/admin/blueprints/inventory.py +++ b/src/admin/blueprints/inventory.py @@ -552,10 +552,10 @@ def analyze_ad_server_inventory(tenant_id): platform_mappings=mappings, ) - # Get adapter instance - from src.adapters import get_adapter - - adapter = get_adapter(adapter_type, principal, config=config, dry_run=False) + # TODO: Get adapter instance and call actual discovery methods + # For now, return mock analysis data + # from src.adapters import get_adapter + # adapter = get_adapter(adapter_type, config, principal) # Mock analysis (real adapters would implement actual discovery) analysis = { diff --git a/src/services/setup_checklist_service.py b/src/services/setup_checklist_service.py index 278a1ba68..f78491349 100644 --- a/src/services/setup_checklist_service.py +++ b/src/services/setup_checklist_service.py @@ -14,6 +14,7 @@ from src.core.database.models import ( AuthorizedProperty, CurrencyLimit, + GAMInventory, Principal, Product, Tenant, @@ -184,13 +185,15 @@ def _check_critical_tasks(self, session, tenant: Tenant) -> list[SetupTask]: ) # 5. Inventory Synced - # Check if tenant has any inventory data (products with inventory mappings) - stmt = select(func.count()).select_from(Product).where(Product.tenant_id == self.tenant_id) - product_count = session.scalar(stmt) or 0 - - # For now, we consider inventory synced if products exist - # In future, could check for specific inventory sync timestamp - inventory_synced = product_count > 0 + # Check if tenant has synced inventory from ad server + # This checks GAMInventory table which stores actual synced ad units, placements, and targeting options + stmt = select(func.count()).select_from(GAMInventory).where(GAMInventory.tenant_id == self.tenant_id) + inventory_count = session.scalar(stmt) or 0 + + inventory_synced = inventory_count > 0 + inventory_details = ( + f"{inventory_count:,} inventory items synced" if inventory_synced else "No inventory synced from ad server" + ) tasks.append( SetupTask( key="inventory_synced", @@ -198,11 +201,13 @@ def _check_critical_tasks(self, session, tenant: Tenant) -> list[SetupTask]: description="Sync ad units and placements from ad server", is_complete=inventory_synced, action_url=f"/tenant/{self.tenant_id}/settings#inventory", - details="Inventory synced" if inventory_synced else "Inventory not synced", + details=inventory_details, ) ) # 6. Products Created + stmt = select(func.count()).select_from(Product).where(Product.tenant_id == self.tenant_id) + product_count = session.scalar(stmt) or 0 tasks.append( SetupTask( key="products_created", diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index eaba6ea40..3d352f796 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -270,7 +270,15 @@ def authenticated_admin_session(admin_client, integration_db): @pytest.fixture def test_tenant_with_data(integration_db): - """Create a test tenant in the database with proper configuration.""" + """Create a test tenant in the database with proper configuration and all required setup data.""" + from src.core.database.models import ( + AuthorizedProperty, + CurrencyLimit, + GAMInventory, + Principal, + PropertyTag, + ) + tenant_data = TenantFactory.create() now = datetime.now(UTC) @@ -284,10 +292,80 @@ def test_tenant_with_data(integration_db): auto_approve_formats=[], # JSONType expects list, not json.dumps() human_review_required=False, policy_settings={}, # JSONType expects dict, not json.dumps() + authorized_emails=["test@example.com"], # Required for access control created_at=now, updated_at=now, ) db_session.add(tenant) + db_session.flush() + + # Add all required setup data for tests to pass setup checklist validation + tenant_id = tenant_data["tenant_id"] + + # CurrencyLimit (required for budget validation) + currency_limit = CurrencyLimit( + tenant_id=tenant_id, + currency_code="USD", + min_package_budget=1.00, + max_daily_package_spend=100000.00, + ) + db_session.add(currency_limit) + + # PropertyTag (required for product property_tags) + property_tag = PropertyTag( + tenant_id=tenant_id, + tag_id="all_inventory", + name="All Inventory", + description="All available inventory", + ) + db_session.add(property_tag) + + # AuthorizedProperty (required for setup validation) + auth_property = AuthorizedProperty( + tenant_id=tenant_id, + property_id=f"{tenant_id}_property_1", + property_type="website", + name="Fixture Default Property", # Unique name to avoid conflicts with test assertions + identifiers=[{"type": "domain", "value": "fixture-default.example.com"}], + publisher_domain="fixture-default.example.com", + verification_status="verified", + ) + db_session.add(auth_property) + + # Principal (required for setup completion) + principal = Principal( + tenant_id=tenant_id, + principal_id=f"{tenant_id}_principal", + name="Test Principal", + access_token=f"{tenant_id}_token", + platform_mappings={"mock": {"advertiser_id": f"mock_adv_{tenant_id}"}}, + ) + db_session.add(principal) + + # GAMInventory (required for inventory sync status) + inventory_items = [ + GAMInventory( + tenant_id=tenant_id, + inventory_type="ad_unit", + inventory_id=f"{tenant_id}_ad_unit_1", + name="Test Ad Unit", + path=["root", "test"], + status="active", + inventory_metadata={"sizes": ["300x250"]}, + ), + GAMInventory( + tenant_id=tenant_id, + inventory_type="placement", + inventory_id=f"{tenant_id}_placement_1", + name="Test Placement", + path=["root"], + status="active", + inventory_metadata={}, + ), + ] + for item in inventory_items: + db_session.add(item) + db_session.commit() return tenant_data @@ -310,7 +388,7 @@ def sample_tenant(integration_db): from datetime import UTC, datetime from src.core.database.database_session import get_db_session - from src.core.database.models import AuthorizedProperty, CurrencyLimit, PropertyTag, Tenant + from src.core.database.models import AuthorizedProperty, CurrencyLimit, GAMInventory, PropertyTag, Tenant now = datetime.now(UTC) with get_db_session() as session: @@ -362,6 +440,30 @@ def sample_tenant(integration_db): ) session.add(auth_property) + # Add GAMInventory records (required for inventory sync status in setup checklist) + inventory_items = [ + GAMInventory( + tenant_id=tenant.tenant_id, + inventory_type="ad_unit", + inventory_id="test_ad_unit_1", + name="Test Ad Unit - Homepage", + path=["root", "website", "homepage"], + status="active", + inventory_metadata={"sizes": ["300x250", "728x90"]}, + ), + GAMInventory( + tenant_id=tenant.tenant_id, + inventory_type="placement", + inventory_id="test_placement_1", + name="Test Placement - Premium", + path=["root"], + status="active", + inventory_metadata={"description": "Premium placement"}, + ), + ] + for item in inventory_items: + session.add(item) + session.commit() return { diff --git a/tests/integration/test_setup_checklist_service.py b/tests/integration/test_setup_checklist_service.py index 8c78e7e48..4576949bf 100644 --- a/tests/integration/test_setup_checklist_service.py +++ b/tests/integration/test_setup_checklist_service.py @@ -8,6 +8,7 @@ from src.core.database.models import ( AuthorizedProperty, CurrencyLimit, + GAMInventory, Principal, Product, Tenant, @@ -150,6 +151,40 @@ def setup_complete_tenant(integration_db, test_tenant_id): ) db_session.add(principal) + # Add GAM inventory (ad units, placements, targeting) + # This simulates that inventory has been synced from the ad server + inventory_items = [ + GAMInventory( + tenant_id=test_tenant_id, + inventory_type="ad_unit", + inventory_id="ad_unit_1", + name="Test Ad Unit 1", + path=["root", "test"], + status="active", + inventory_metadata={"size": "300x250"}, + ), + GAMInventory( + tenant_id=test_tenant_id, + inventory_type="placement", + inventory_id="placement_1", + name="Test Placement 1", + path=["root"], + status="active", + inventory_metadata={}, + ), + GAMInventory( + tenant_id=test_tenant_id, + inventory_type="targeting_key", + inventory_id="key_1", + name="Test Key", + path=[], + status="active", + inventory_metadata={"type": "predefined"}, + ), + ] + for item in inventory_items: + db_session.add(item) + db_session.commit() yield tenant @@ -158,6 +193,7 @@ def setup_complete_tenant(integration_db, test_tenant_id): with get_db_session() as db_session: db_session.execute(delete(Principal).where(Principal.tenant_id == test_tenant_id)) db_session.execute(delete(Product).where(Product.tenant_id == test_tenant_id)) + db_session.execute(delete(GAMInventory).where(GAMInventory.tenant_id == test_tenant_id)) db_session.execute(delete(AuthorizedProperty).where(AuthorizedProperty.tenant_id == test_tenant_id)) db_session.execute(delete(CurrencyLimit).where(CurrencyLimit.tenant_id == test_tenant_id)) stmt = select(Tenant).filter_by(tenant_id=test_tenant_id) @@ -207,6 +243,7 @@ def test_complete_tenant_ready_for_orders(self, integration_db, setup_complete_t assert critical["currency_limits"]["is_complete"] assert critical["ad_server_connected"]["is_complete"] assert critical["authorized_properties"]["is_complete"] + assert critical["inventory_synced"]["is_complete"] assert critical["products_created"]["is_complete"] assert critical["principals_created"]["is_complete"] diff --git a/tests/integration_v2/conftest.py b/tests/integration_v2/conftest.py index 1f498ebed..855b58638 100644 --- a/tests/integration_v2/conftest.py +++ b/tests/integration_v2/conftest.py @@ -171,7 +171,7 @@ def sample_tenant(integration_db): from decimal import Decimal from src.core.database.database_session import get_db_session - from src.core.database.models import AuthorizedProperty, CurrencyLimit, PropertyTag, Tenant + from src.core.database.models import AuthorizedProperty, CurrencyLimit, GAMInventory, PropertyTag, Tenant from tests.fixtures import TenantFactory tenant_data = TenantFactory.create() @@ -211,13 +211,37 @@ def sample_tenant(integration_db): tenant_id=tenant_data["tenant_id"], property_id="test_property_1", property_type="website", - name="Test Property", - identifiers=[{"type": "domain", "value": "example.com"}], - publisher_domain="example.com", + name="Fixture Default Property", # Unique name to avoid conflicts with test assertions + identifiers=[{"type": "domain", "value": "fixture-default.example.com"}], + publisher_domain="fixture-default.example.com", verification_status="verified", ) session.add(authorized_property) + # Create GAMInventory records (required for inventory sync status in setup checklist) + inventory_items = [ + GAMInventory( + tenant_id=tenant_data["tenant_id"], + inventory_type="ad_unit", + inventory_id="test_ad_unit_1", + name="Test Ad Unit", + path=["root", "test"], + status="active", + inventory_metadata={"sizes": ["300x250"]}, + ), + GAMInventory( + tenant_id=tenant_data["tenant_id"], + inventory_type="placement", + inventory_id="test_placement_1", + name="Test Placement", + path=["root"], + status="active", + inventory_metadata={}, + ), + ] + for item in inventory_items: + session.add(item) + session.commit() return tenant_data @@ -317,6 +341,7 @@ def add_required_setup_data(session, tenant_id: str): 3. Currency limit (for budget validation) 4. Property tag (for product configuration) 5. Principal (advertiser) (for setup completion validation) + 6. GAM inventory (for inventory sync status) Call this in test fixtures to avoid "Setup incomplete" errors. """ @@ -327,7 +352,14 @@ def add_required_setup_data(session, tenant_id: str): # Update tenant with access control from sqlalchemy.orm import attributes - from src.core.database.models import AuthorizedProperty, CurrencyLimit, Principal, PropertyTag, Tenant + from src.core.database.models import ( + AuthorizedProperty, + CurrencyLimit, + GAMInventory, + Principal, + PropertyTag, + Tenant, + ) stmt = select(Tenant).filter_by(tenant_id=tenant_id) tenant = session.scalars(stmt).first() @@ -344,9 +376,9 @@ def add_required_setup_data(session, tenant_id: str): tenant_id=tenant_id, property_id=f"{tenant_id}_property_1", property_type="website", - name="Test Property", - identifiers=[{"type": "domain", "value": "example.com"}], - publisher_domain="example.com", + name="Fixture Default Property", # Unique name to avoid conflicts with test assertions + identifiers=[{"type": "domain", "value": "fixture-default.example.com"}], + publisher_domain="fixture-default.example.com", verification_status="verified", ) session.add(authorized_property) @@ -385,6 +417,32 @@ def add_required_setup_data(session, tenant_id: str): ) session.add(principal) + # Create GAMInventory if not exists - CRITICAL for inventory sync status validation + stmt_inventory = select(GAMInventory).filter_by(tenant_id=tenant_id) + if not session.scalars(stmt_inventory).first(): + inventory_items = [ + GAMInventory( + tenant_id=tenant_id, + inventory_type="ad_unit", + inventory_id=f"{tenant_id}_ad_unit_1", + name="Test Ad Unit", + path=["root", "test"], + status="active", + inventory_metadata={"sizes": ["300x250"]}, + ), + GAMInventory( + tenant_id=tenant_id, + inventory_type="placement", + inventory_id=f"{tenant_id}_placement_1", + name="Test Placement", + path=["root"], + status="active", + inventory_metadata={}, + ), + ] + for item in inventory_items: + session.add(item) + # ============================================================================ # Pricing Helper Functions @@ -659,7 +717,7 @@ def authenticated_admin_session(admin_client, integration_db): @pytest.fixture def test_tenant_with_data(integration_db): - """Create a test tenant in the database with proper configuration.""" + """Create a test tenant in the database with proper configuration and all required setup data.""" from datetime import UTC, datetime from src.core.database.database_session import get_db_session @@ -679,10 +737,16 @@ def test_tenant_with_data(integration_db): auto_approve_formats=[], # JSONType expects list, not json.dumps() human_review_required=False, policy_settings={}, # JSONType expects dict, not json.dumps() + authorized_emails=["test@example.com"], # Required for access control created_at=now, updated_at=now, ) db_session.add(tenant) + db_session.flush() + + # Add all required setup data using the centralized helper + add_required_setup_data(db_session, tenant_data["tenant_id"]) + db_session.commit() return tenant_data diff --git a/tests/integration_v2/test_minimum_spend_validation.py b/tests/integration_v2/test_minimum_spend_validation.py index ce1b297cb..d555fec8b 100644 --- a/tests/integration_v2/test_minimum_spend_validation.py +++ b/tests/integration_v2/test_minimum_spend_validation.py @@ -24,6 +24,7 @@ from src.core.database.models import ( AuthorizedProperty, CurrencyLimit, + GAMInventory, MediaBuy, Principal, Product, @@ -198,6 +199,30 @@ def setup_test_data(self, integration_db): delivery_type="guaranteed", ) + # Create GAMInventory records (required for inventory sync status in setup checklist) + inventory_items = [ + GAMInventory( + tenant_id="test_minspend_tenant", + inventory_type="ad_unit", + inventory_id="test_minspend_ad_unit_1", + name="Test Ad Unit", + path=["root", "test"], + status="active", + inventory_metadata={"sizes": ["300x250"]}, + ), + GAMInventory( + tenant_id="test_minspend_tenant", + inventory_type="placement", + inventory_id="test_minspend_placement_1", + name="Test Placement", + path=["root"], + status="active", + inventory_metadata={}, + ), + ] + for item in inventory_items: + session.add(item) + session.commit() # Set current tenant diff --git a/tests/unit/test_inventory_sync_status.py b/tests/unit/test_inventory_sync_status.py new file mode 100644 index 000000000..83468c70c --- /dev/null +++ b/tests/unit/test_inventory_sync_status.py @@ -0,0 +1,118 @@ +"""Unit tests for inventory sync status logic fix.""" + +from unittest.mock import MagicMock, patch + + +def test_inventory_sync_checks_gam_inventory_not_products(): + """Test that inventory sync status checks GAMInventory table, not Products.""" + from src.services.setup_checklist_service import SetupChecklistService + + tenant_id = "test_tenant" + + # Mock the database session + with patch("src.services.setup_checklist_service.get_db_session") as mock_session: + # Setup mock session context manager + mock_db = MagicMock() + mock_session.return_value.__enter__.return_value = mock_db + + # Mock tenant + mock_tenant = MagicMock() + mock_tenant.tenant_id = tenant_id + mock_tenant.ad_server = "gam" + mock_tenant.authorized_domains = [] + mock_tenant.authorized_emails = ["test@example.com"] + mock_tenant.policy_settings = {} + mock_tenant.human_review_required = None + mock_tenant.auto_approve_formats = None + mock_tenant.order_name_template = None + mock_tenant.line_item_name_template = None + mock_tenant.slack_webhook_url = None + mock_tenant.enable_axe_signals = False + mock_tenant.virtual_host = None + + # Mock scalars().first() for tenant query + mock_db.scalars.return_value.first.return_value = mock_tenant + + # Mock scalar() calls for counts + # Order of calls: CurrencyLimit, AuthorizedProperty, GAMInventory, Product, Principal, CurrencyLimit (budget) + mock_db.scalar.side_effect = [ + 1, # CurrencyLimit count + 1, # AuthorizedProperty count + 100, # GAMInventory count (inventory synced!) + 0, # Product count (no products yet) + 0, # Principal count + 0, # CurrencyLimit with budget controls count + 1, # CurrencyLimit count again for optional tasks + ] + + # Create service and get status + with patch.dict("os.environ", {"GEMINI_API_KEY": "test_key"}): + service = SetupChecklistService(tenant_id) + status = service.get_setup_status() + + # Find the inventory_synced task + critical = {task["key"]: task for task in status["critical"]} + inventory_task = critical["inventory_synced"] + + # Verify inventory is marked as complete + assert inventory_task["is_complete"], "Inventory should be complete when GAMInventory records exist" + assert "100" in inventory_task["details"], "Details should show inventory count" + + # Verify products task is separate and incomplete + products_task = critical["products_created"] + assert not products_task["is_complete"], "Products should be incomplete when no products exist" + + +def test_inventory_sync_incomplete_when_no_gam_inventory(): + """Test that inventory sync status is incomplete when no GAMInventory records exist.""" + from src.services.setup_checklist_service import SetupChecklistService + + tenant_id = "test_tenant" + + # Mock the database session + with patch("src.services.setup_checklist_service.get_db_session") as mock_session: + # Setup mock session context manager + mock_db = MagicMock() + mock_session.return_value.__enter__.return_value = mock_db + + # Mock tenant + mock_tenant = MagicMock() + mock_tenant.tenant_id = tenant_id + mock_tenant.ad_server = "gam" + mock_tenant.authorized_domains = [] + mock_tenant.authorized_emails = ["test@example.com"] + mock_tenant.policy_settings = {} + mock_tenant.human_review_required = None + mock_tenant.auto_approve_formats = None + mock_tenant.order_name_template = None + mock_tenant.line_item_name_template = None + mock_tenant.slack_webhook_url = None + mock_tenant.enable_axe_signals = False + mock_tenant.virtual_host = None + + # Mock scalars().first() for tenant query + mock_db.scalars.return_value.first.return_value = mock_tenant + + # Mock scalar() calls for counts - all zero (no inventory synced) + mock_db.scalar.side_effect = [ + 1, # CurrencyLimit count + 1, # AuthorizedProperty count + 0, # GAMInventory count (no inventory!) + 0, # Product count + 0, # Principal count + 0, # CurrencyLimit with budget controls count + 1, # CurrencyLimit count again for optional tasks + ] + + # Create service and get status + with patch.dict("os.environ", {"GEMINI_API_KEY": "test_key"}): + service = SetupChecklistService(tenant_id) + status = service.get_setup_status() + + # Find the inventory_synced task + critical = {task["key"]: task for task in status["critical"]} + inventory_task = critical["inventory_synced"] + + # Verify inventory is marked as incomplete + assert not inventory_task["is_complete"], "Inventory should be incomplete when no GAMInventory records exist" + assert "No inventory synced" in inventory_task["details"], "Details should indicate no inventory"