diff --git a/.env.example b/.env.example index 6fd5361f..44ab39ee 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,14 @@ # GONG_ACCESS_TOKEN= # GONG_API_BASE_URL= +# -- Google Ads -- +# GOOGLE_ADS_ACCESS_TOKEN= +# ADWORDS_DEVELOPER_TOKEN= +# GOOGLE_ADS_LOGIN_CUSTOMER_ID= +# GOOGLE_ADS_CUSTOMER_ID= +# GOOGLE_ADS_TEST_CAMPAIGN_ID= +# GOOGLE_ADS_TEST_AD_GROUP_ID= + # -- Float -- # FLOAT_API_KEY= diff --git a/google-ads/config.json b/google-ads/config.json index 5a77db23..bc52baa1 100644 --- a/google-ads/config.json +++ b/google-ads/config.json @@ -1,1491 +1,1491 @@ { - "name": "Google Ads", - "version": "2.0.0", - "description": "Full CRUD operations for Google Ads API including campaigns, ad groups, ads, keywords, and Keyword Planner functionality.", - "entry_point": "google_ads.py", - "supports_billing": true, - "auth": { - "type": "platform", - "provider": "Google Ads", - "scopes": [ - "https://www.googleapis.com/auth/adwords" - ] - }, - "actions": { - "get_accessible_accounts": { - "display_name": "Get Accessible Accounts", - "description": "Lists all Google Ads accounts accessible to the authenticated user via OAuth.", - "input_schema": { - "type": "object", - "properties": {}, - "required": [] - }, - "output_schema": { - "type": "object", - "properties": { - "accounts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "resource_name": { - "type": "string" - }, - "customer_id": { - "type": "string" - }, - "descriptive_name": { - "type": "string" - }, - "currency_code": { - "type": "string" - } - } - } - } - } - } - }, - "retrieve_campaign_metrics": { - "display_name": "Get Campaign Metrics", - "description": "Retrieves overall performance metrics for campaigns (e.g., total clicks, cost, conversions per campaign). Use this for high-level summaries of how each campaign is performing as a whole.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes (e.g., '1234567890')." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID of the specific account you want to query data from." - }, - "date_ranges": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of date ranges. Supported formats: 'YYYY-MM-DD_YYYY-MM-DD', 'last 7 days', or single dates 'DD/MM/YYYY'." - }, - "campaign_type": { - "type": "string", - "enum": [ - "SEARCH", - "VIDEO", - "DISPLAY", - "PERFORMANCE_MAX", - "ALL" - ], - "description": "Filter by campaign type. VIDEO includes video-specific metrics (average_cpv). SEARCH, DISPLAY, PERFORMANCE_MAX filter to that type. ALL (default) uses universal metrics safe for mixed campaign types." - } + "name": "Google Ads", + "version": "3.0.0", + "description": "Full CRUD operations for Google Ads API including campaigns, ad groups, ads, keywords, and Keyword Planner functionality.", + "entry_point": "google_ads.py", + "supports_billing": true, + "auth": { + "type": "platform", + "provider": "Google Ads", + "scopes": [ + "https://www.googleapis.com/auth/adwords" + ] + }, + "actions": { + "get_accessible_accounts": { + "display_name": "Get Accessible Accounts", + "description": "Lists all Google Ads accounts accessible to the authenticated user via OAuth.", + "input_schema": { + "type": "object", + "properties": {}, + "required": [] + }, + "output_schema": { + "type": "object", + "properties": { + "accounts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "resource_name": { + "type": "string" }, - "required": [ - "login_customer_id", - "customer_id" - ] - }, - "output_schema": { - "type": "object", - "properties": { - "results": { - "type": "array", - "items": { - "type": "object", - "properties": { - "date_range": { - "type": "string" - }, - "data": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - } - } - } - } - } - } - }, - "retrieve_keyword_metrics": { - "display_name": "Get Keyword Metrics", - "description": "Retrieves detailed performance metrics for keywords including match type, impressions, clicks, cost, conversions, and interaction rate.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID of the specific account." - }, - "ad_group_ids": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of ad group IDs to filter by." - }, - "campaign_ids": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of campaign IDs to filter by." - }, - "date_ranges": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of date ranges." - } + "customer_id": { + "type": "string" }, - "required": [ - "login_customer_id", - "customer_id", - "ad_group_ids", - "campaign_ids" - ] - }, - "output_schema": { - "type": "object", - "properties": { - "results": { - "type": "array", - "items": { - "type": "object", - "properties": { - "date_range": { - "type": "string" - }, - "data": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - } - } - } - } - } - } - }, - "create_campaign": { - "display_name": "Create Campaign", - "description": "Creates a new Google Ads Search campaign with a budget. The campaign is created in PAUSED status by default.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID." - }, - "campaign_name": { - "type": "string", - "description": "Name for the new campaign." - }, - "budget_amount_micros": { - "type": "integer", - "description": "Daily budget in micros (e.g., 1000000 = $1.00)." - }, - "budget_name": { - "type": "string", - "description": "Name for the campaign budget." - }, - "start_date": { - "type": "string", - "description": "Campaign start date in YYYYMMDD format. Defaults to tomorrow." - }, - "end_date": { - "type": "string", - "description": "Campaign end date in YYYYMMDD format. Defaults to 1 year from start." - }, - "bidding_strategy": { - "type": "string", - "enum": [ - "MANUAL_CPC", - "TARGET_SPEND", - "MAXIMIZE_CONVERSIONS", - "MAXIMIZE_CLICKS" - ], - "description": "Bidding strategy for the campaign. Defaults to MANUAL_CPC." - }, - "enhanced_cpc_enabled": { - "type": "boolean", - "description": "For MANUAL_CPC: Enable enhanced CPC. Defaults to false.", - "default": false - }, - "target_spend_micros": { - "type": "integer", - "description": "For TARGET_SPEND: Optional target spend in micros. If omitted, Google optimizes automatically." - }, - "target_cpa_micros": { - "type": "integer", - "description": "For MAXIMIZE_CONVERSIONS: Optional target CPA in micros. If omitted, Google optimizes automatically." - }, - "cpc_bid_ceiling_micros": { - "type": "integer", - "description": "For MAXIMIZE_CLICKS: Optional max CPC bid ceiling in micros. If omitted, no ceiling is applied." - }, - "contains_eu_political_advertising": { - "type": "boolean", - "description": "Whether the campaign contains EU political advertising content. Required by Google Ads API. Defaults to false.", - "default": false - } + "descriptive_name": { + "type": "string" }, - "required": [ - "login_customer_id", - "customer_id", - "campaign_name", - "budget_amount_micros" - ] - }, - "output_schema": { - "type": "object", - "properties": { - "campaign_resource_name": { - "type": "string" - }, - "budget_resource_name": { - "type": "string" - }, - "campaign_id": { - "type": "string" - }, - "status": { - "type": "string" - } + "currency_code": { + "type": "string" } + } } - }, - "update_campaign": { - "display_name": "Update Campaign", - "description": "Updates an existing campaign's status (ENABLED, PAUSED) or other settings.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID." - }, - "campaign_id": { - "type": "string", - "description": "The ID of the campaign to update." - }, - "status": { - "type": "string", - "enum": [ - "ENABLED", - "PAUSED" - ], - "description": "New status for the campaign." - }, - "name": { - "type": "string", - "description": "New name for the campaign (optional)." - } - }, - "required": [ - "login_customer_id", - "customer_id", - "campaign_id" - ] + } + } + } + }, + "retrieve_campaign_metrics": { + "display_name": "Get Campaign Metrics", + "description": "Retrieves overall performance metrics for campaigns (e.g., total clicks, cost, conversions per campaign). Use this for high-level summaries of how each campaign is performing as a whole.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes (e.g., '1234567890')." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID of the specific account you want to query data from." + }, + "date_ranges": { + "type": "array", + "items": { + "type": "string" }, - "output_schema": { - "type": "object", - "properties": { - "campaign_resource_name": { - "type": "string" - }, - "status": { - "type": "string" - } - } - } + "description": "List of date ranges. Supported formats: 'YYYY-MM-DD_YYYY-MM-DD', 'last 7 days', or single dates 'DD/MM/YYYY'." + }, + "campaign_type": { + "type": "string", + "enum": [ + "SEARCH", + "VIDEO", + "DISPLAY", + "PERFORMANCE_MAX", + "ALL" + ], + "description": "Filter by campaign type. VIDEO includes video-specific metrics (average_cpv). SEARCH, DISPLAY, PERFORMANCE_MAX filter to that type. ALL (default) uses universal metrics safe for mixed campaign types." + } }, - "remove_campaign": { - "display_name": "Remove Campaign", - "description": "Removes (deletes) a campaign. This sets the campaign status to REMOVED.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID." - }, - "campaign_id": { - "type": "string", - "description": "The ID of the campaign to remove." - } + "required": [ + "login_customer_id", + "customer_id" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date_range": { + "type": "string" }, - "required": [ - "login_customer_id", - "customer_id", - "campaign_id" - ] - }, - "output_schema": { - "type": "object", - "properties": { - "removed_campaign_resource_name": { - "type": "string" - }, - "status": { - "type": "string" - } + "data": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } } + } } - }, - "create_ad_group": { - "display_name": "Create Ad Group", - "description": "Creates a new ad group within an existing campaign.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID." - }, - "campaign_id": { - "type": "string", - "description": "The ID of the campaign to add the ad group to." - }, - "ad_group_name": { - "type": "string", - "description": "Name for the new ad group." - }, - "cpc_bid_micros": { - "type": "integer", - "description": "CPC bid in micros (e.g., 1000000 = $1.00). Defaults to 1000000." - }, - "status": { - "type": "string", - "enum": [ - "ENABLED", - "PAUSED" - ], - "description": "Status for the ad group. Defaults to PAUSED." - } - }, - "required": [ - "login_customer_id", - "customer_id", - "campaign_id", - "ad_group_name" - ] + } + } + } + }, + "retrieve_keyword_metrics": { + "display_name": "Get Keyword Metrics", + "description": "Retrieves detailed performance metrics for keywords including match type, impressions, clicks, cost, conversions, and interaction rate.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID of the specific account." + }, + "ad_group_ids": { + "type": "array", + "items": { + "type": "string" }, - "output_schema": { - "type": "object", - "properties": { - "ad_group_resource_name": { - "type": "string" - }, - "ad_group_id": { - "type": "string" - }, - "status": { - "type": "string" - } - } - } - }, - "create_responsive_search_ad": { - "display_name": "Create Responsive Search Ad", - "description": "Creates a new Responsive Search Ad (RSA) in an existing ad group with multiple headlines and descriptions.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID." - }, - "ad_group_id": { - "type": "string", - "description": "The ID of the ad group to add the ad to." - }, - "headlines": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of headlines (3-15 required, each max 30 characters)." - }, - "descriptions": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of descriptions (2-4 required, each max 90 characters)." - }, - "final_url": { - "type": "string", - "description": "The landing page URL for the ad." - }, - "path1": { - "type": "string", - "description": "First path text that appears in display URL (optional, max 15 chars)." - }, - "path2": { - "type": "string", - "description": "Second path text that appears in display URL (optional, max 15 chars)." - }, - "status": { - "type": "string", - "enum": [ - "ENABLED", - "PAUSED" - ], - "description": "Status for the ad. Defaults to PAUSED." - } - }, - "required": [ - "login_customer_id", - "customer_id", - "ad_group_id", - "headlines", - "descriptions", - "final_url" - ] + "description": "List of ad group IDs to filter by." + }, + "campaign_ids": { + "type": "array", + "items": { + "type": "string" }, - "output_schema": { - "type": "object", - "properties": { - "ad_resource_name": { - "type": "string" - }, - "ad_id": { - "type": "string" - }, - "status": { - "type": "string" - } - } - } - }, - "add_keywords": { - "display_name": "Add Keywords to Ad Group", - "description": "Adds keywords to an existing ad group with specified match types.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID." - }, - "ad_group_id": { - "type": "string", - "description": "The ID of the ad group to add keywords to." - }, - "keywords": { - "type": "array", - "items": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "The keyword text." - }, - "match_type": { - "type": "string", - "enum": [ - "BROAD", - "PHRASE", - "EXACT" - ], - "description": "Match type for the keyword. Defaults to BROAD." - } - }, - "required": [ - "text" - ] - }, - "description": "List of keywords with their match types." - } - }, - "required": [ - "login_customer_id", - "customer_id", - "ad_group_id", - "keywords" - ] + "description": "List of campaign IDs to filter by." + }, + "date_ranges": { + "type": "array", + "items": { + "type": "string" }, - "output_schema": { - "type": "object", - "properties": { - "added_keywords": { - "type": "array", - "items": { - "type": "object", - "properties": { - "resource_name": { - "type": "string" - }, - "keyword_text": { - "type": "string" - }, - "match_type": { - "type": "string" - } - } - } - }, - "status": { - "type": "string" - } - } - } + "description": "List of date ranges." + } }, - "generate_keyword_ideas": { - "display_name": "Generate Keyword Ideas (Keyword Planner)", - "description": "Uses Keyword Planner to generate keyword ideas based on seed keywords and/or a URL. Returns search volume and competition data.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID." - }, - "seed_keywords": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of seed keywords to generate ideas from." - }, - "page_url": { - "type": "string", - "description": "URL to analyze for keyword ideas (optional)." - }, - "language_id": { - "type": "string", - "description": "Language ID (e.g., '1000' for English). Defaults to English." - }, - "location_ids": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of geo target location IDs (e.g., ['2840'] for USA). Defaults to USA." - }, - "include_adult_keywords": { - "type": "boolean", - "description": "Whether to include adult keywords. Defaults to false." - } + "required": [ + "login_customer_id", + "customer_id", + "ad_group_ids", + "campaign_ids" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date_range": { + "type": "string" }, - "required": [ - "login_customer_id", - "customer_id" - ] - }, - "output_schema": { - "type": "object", - "properties": { - "keyword_ideas": { - "type": "array", - "items": { - "type": "object", - "properties": { - "keyword": { - "type": "string" - }, - "avg_monthly_searches": { - "type": "integer" - }, - "competition": { - "type": "string" - }, - "competition_index": { - "type": "number" - }, - "low_top_of_page_bid_micros": { - "type": "integer" - }, - "high_top_of_page_bid_micros": { - "type": "integer" - } - } - } - }, - "total_results": { - "type": "integer" - } + "data": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } } + } } + } + } + } + }, + "create_campaign": { + "display_name": "Create Campaign", + "description": "Creates a new Google Ads Search campaign with a budget. The campaign is created in PAUSED status by default.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID." + }, + "campaign_name": { + "type": "string", + "description": "Name for the new campaign." + }, + "budget_amount_micros": { + "type": "integer", + "description": "Daily budget in micros (e.g., 1000000 = $1.00)." + }, + "budget_name": { + "type": "string", + "description": "Name for the campaign budget." + }, + "start_date": { + "type": "string", + "description": "Campaign start date in YYYYMMDD format. Defaults to tomorrow." + }, + "end_date": { + "type": "string", + "description": "Campaign end date in YYYYMMDD format. Defaults to 1 year from start." + }, + "bidding_strategy": { + "type": "string", + "enum": [ + "MANUAL_CPC", + "TARGET_SPEND", + "MAXIMIZE_CONVERSIONS", + "MAXIMIZE_CLICKS" + ], + "description": "Bidding strategy for the campaign. Defaults to MANUAL_CPC." + }, + "enhanced_cpc_enabled": { + "type": "boolean", + "description": "For MANUAL_CPC: Enable enhanced CPC. Defaults to false.", + "default": false + }, + "target_spend_micros": { + "type": "integer", + "description": "For TARGET_SPEND: Optional target spend in micros. If omitted, Google optimizes automatically." + }, + "target_cpa_micros": { + "type": "integer", + "description": "For MAXIMIZE_CONVERSIONS: Optional target CPA in micros. If omitted, Google optimizes automatically." + }, + "cpc_bid_ceiling_micros": { + "type": "integer", + "description": "For MAXIMIZE_CLICKS: Optional max CPC bid ceiling in micros. If omitted, no ceiling is applied." + }, + "contains_eu_political_advertising": { + "type": "boolean", + "description": "Whether the campaign contains EU political advertising content. Required by Google Ads API. Defaults to false.", + "default": false + } }, - "generate_keyword_historical_metrics": { - "display_name": "Get Keyword Historical Metrics", - "description": "Retrieves historical metrics (search volume, competition, bid estimates) for specific keywords using the Keyword Planner.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID." - }, - "keywords": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of keywords to get historical metrics for." - }, - "language_id": { - "type": "string", - "description": "Language ID (e.g., '1000' for English). Defaults to English." - }, - "location_ids": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of geo target location IDs. Defaults to USA." - } - }, - "required": [ - "login_customer_id", - "customer_id", - "keywords" - ] - }, - "output_schema": { - "type": "object", - "properties": { - "keyword_metrics": { - "type": "array", - "items": { - "type": "object", - "properties": { - "keyword": { - "type": "string" - }, - "avg_monthly_searches": { - "type": "integer" - }, - "competition": { - "type": "string" - }, - "competition_index": { - "type": "number" - }, - "low_top_of_page_bid_micros": { - "type": "integer" - }, - "high_top_of_page_bid_micros": { - "type": "integer" - }, - "monthly_search_volumes": { - "type": "array", - "items": { - "type": "object", - "properties": { - "month": { - "type": "string" - }, - "year": { - "type": "integer" - }, - "monthly_searches": { - "type": "integer" - } - } - } - } - } - } - } - } - } + "required": [ + "login_customer_id", + "customer_id", + "campaign_name", + "budget_amount_micros" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "campaign_resource_name": { + "type": "string" + }, + "budget_resource_name": { + "type": "string" + }, + "campaign_id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + }, + "update_campaign": { + "display_name": "Update Campaign", + "description": "Updates an existing campaign's status (ENABLED, PAUSED) or other settings.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID." + }, + "campaign_id": { + "type": "string", + "description": "The ID of the campaign to update." + }, + "status": { + "type": "string", + "enum": [ + "ENABLED", + "PAUSED" + ], + "description": "New status for the campaign." + }, + "name": { + "type": "string", + "description": "New name for the campaign (optional)." + } }, - "retrieve_ad_group_metrics": { - "display_name": "Get Ad Group Metrics", - "description": "Retrieves performance metrics for ad groups including impressions, clicks, cost, conversions, and CTR.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID." - }, - "date_ranges": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of date ranges." - }, - "campaign_ids": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Optional: Filter by specific campaign IDs." - } - }, - "required": [ - "login_customer_id", - "customer_id", - "date_ranges" - ] - }, - "output_schema": { - "type": "object", - "properties": { - "results": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - } - } - } + "required": [ + "login_customer_id", + "customer_id", + "campaign_id" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "campaign_resource_name": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + }, + "remove_campaign": { + "display_name": "Remove Campaign", + "description": "Removes (deletes) a campaign. This sets the campaign status to REMOVED.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID." + }, + "campaign_id": { + "type": "string", + "description": "The ID of the campaign to remove." + } }, - "retrieve_ad_metrics": { - "display_name": "Get Ad Metrics", - "description": "Retrieves performance metrics for individual ads including impressions, clicks, cost, conversions, final URLs, and ad copy.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID." - }, - "date_ranges": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of date ranges." - }, - "campaign_ids": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Optional: Filter by specific campaign IDs." - }, - "ad_group_ids": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Optional: Filter by specific ad group IDs." - } - }, - "required": [ - "login_customer_id", - "customer_id", - "date_ranges" - ] - }, - "output_schema": { - "type": "object", - "properties": { - "results": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - } - } - } + "required": [ + "login_customer_id", + "customer_id", + "campaign_id" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "removed_campaign_resource_name": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + }, + "create_ad_group": { + "display_name": "Create Ad Group", + "description": "Creates a new ad group within an existing campaign.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID." + }, + "campaign_id": { + "type": "string", + "description": "The ID of the campaign to add the ad group to." + }, + "ad_group_name": { + "type": "string", + "description": "Name for the new ad group." + }, + "cpc_bid_micros": { + "type": "integer", + "description": "CPC bid in micros (e.g., 1000000 = $1.00). Defaults to 1000000." + }, + "status": { + "type": "string", + "enum": [ + "ENABLED", + "PAUSED" + ], + "description": "Status for the ad group. Defaults to PAUSED." + } }, - "retrieve_search_terms": { - "display_name": "Get Search Terms Report", - "description": "Retrieves the search terms that triggered your ads, including the matched keyword, impressions, clicks, cost, and conversions.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID." - }, - "date_ranges": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of date ranges." - }, - "campaign_ids": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Optional: Filter by specific campaign IDs." - }, - "ad_group_ids": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Optional: Filter by specific ad group IDs." - } - }, - "required": [ - "login_customer_id", - "customer_id", - "date_ranges" - ] + "required": [ + "login_customer_id", + "customer_id", + "campaign_id", + "ad_group_name" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "ad_group_resource_name": { + "type": "string" + }, + "ad_group_id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + }, + "create_responsive_search_ad": { + "display_name": "Create Responsive Search Ad", + "description": "Creates a new Responsive Search Ad (RSA) in an existing ad group with multiple headlines and descriptions.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID." + }, + "ad_group_id": { + "type": "string", + "description": "The ID of the ad group to add the ad to." + }, + "headlines": { + "type": "array", + "items": { + "type": "string" }, - "output_schema": { - "type": "object", - "properties": { - "results": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "get_active_ad_urls": { - "display_name": "Get Active Ad URLs", - "description": "Retrieves all currently active (ENABLED) ads with their destination URLs. Useful for monitoring which URLs are being advertised.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID." - }, - "url_filter": { - "type": "string", - "description": "Optional: Filter to only return ads containing this URL substring." - } - }, - "required": [ - "login_customer_id", - "customer_id" - ] + "description": "List of headlines (3-15 required, each max 30 characters)." + }, + "descriptions": { + "type": "array", + "items": { + "type": "string" }, - "output_schema": { - "type": "object", - "properties": { - "active_ads": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - }, - "total_count": { - "type": "integer" - } - } - } + "description": "List of descriptions (2-4 required, each max 90 characters)." + }, + "final_url": { + "type": "string", + "description": "The landing page URL for the ad." + }, + "path1": { + "type": "string", + "description": "First path text that appears in display URL (optional, max 15 chars)." + }, + "path2": { + "type": "string", + "description": "Second path text that appears in display URL (optional, max 15 chars)." + }, + "status": { + "type": "string", + "enum": [ + "ENABLED", + "PAUSED" + ], + "description": "Status for the ad. Defaults to PAUSED." + } }, - "add_negative_keywords_to_campaign": { - "display_name": "Add Negative Keywords to Campaign", - "description": "Adds negative keywords to a campaign to prevent ads from showing for specific search terms.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID." - }, - "campaign_id": { - "type": "string", - "description": "The ID of the campaign to add negative keywords to." - }, - "keywords": { - "type": "array", - "items": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "The negative keyword text." - }, - "match_type": { - "type": "string", - "enum": [ - "BROAD", - "PHRASE", - "EXACT" - ], - "description": "Match type for the negative keyword. Defaults to BROAD." - } - }, - "required": [ - "text" - ] - }, - "description": "List of negative keywords with their match types." - } + "required": [ + "login_customer_id", + "customer_id", + "ad_group_id", + "headlines", + "descriptions", + "final_url" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "ad_resource_name": { + "type": "string" + }, + "ad_id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + }, + "add_keywords": { + "display_name": "Add Keywords to Ad Group", + "description": "Adds keywords to an existing ad group with specified match types.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID." + }, + "ad_group_id": { + "type": "string", + "description": "The ID of the ad group to add keywords to." + }, + "keywords": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The keyword text." }, - "required": [ - "login_customer_id", - "customer_id", - "campaign_id", - "keywords" - ] - }, - "output_schema": { - "type": "object", - "properties": { - "added_negative_keywords": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - }, - "campaign_id": { - "type": "string" - }, - "status": { - "type": "string" - } + "match_type": { + "type": "string", + "enum": [ + "BROAD", + "PHRASE", + "EXACT" + ], + "description": "Match type for the keyword. Defaults to BROAD." } - } + }, + "required": [ + "text" + ] + }, + "description": "List of keywords with their match types." + } }, - "add_negative_keywords_to_ad_group": { - "display_name": "Add Negative Keywords to Ad Group", - "description": "Adds negative keywords to an ad group to prevent ads from showing for specific search terms at the ad group level.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID." - }, - "ad_group_id": { - "type": "string", - "description": "The ID of the ad group to add negative keywords to." - }, - "keywords": { - "type": "array", - "items": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "The negative keyword text." - }, - "match_type": { - "type": "string", - "enum": [ - "BROAD", - "PHRASE", - "EXACT" - ], - "description": "Match type for the negative keyword. Defaults to BROAD." - } - }, - "required": [ - "text" - ] - }, - "description": "List of negative keywords with their match types." - } + "required": [ + "login_customer_id", + "customer_id", + "ad_group_id", + "keywords" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "added_keywords": { + "type": "array", + "items": { + "type": "object", + "properties": { + "resource_name": { + "type": "string" }, - "required": [ - "login_customer_id", - "customer_id", - "ad_group_id", - "keywords" - ] - }, - "output_schema": { - "type": "object", - "properties": { - "added_negative_keywords": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - }, - "ad_group_id": { - "type": "string" - }, - "status": { - "type": "string" - } + "keyword_text": { + "type": "string" + }, + "match_type": { + "type": "string" } + } } + }, + "status": { + "type": "string" + } + } + } + }, + "generate_keyword_ideas": { + "display_name": "Generate Keyword Ideas (Keyword Planner)", + "description": "Uses Keyword Planner to generate keyword ideas based on seed keywords and/or a URL. Returns search volume and competition data.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID." + }, + "seed_keywords": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of seed keywords to generate ideas from." + }, + "page_url": { + "type": "string", + "description": "URL to analyze for keyword ideas (optional)." + }, + "language_id": { + "type": "string", + "description": "Language ID (e.g., '1000' for English). Defaults to English." + }, + "location_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of geo target location IDs (e.g., ['2840'] for USA). Defaults to USA." + }, + "include_adult_keywords": { + "type": "boolean", + "description": "Whether to include adult keywords. Defaults to false." + } }, - "update_ad_group": { - "display_name": "Update Ad Group", - "description": "Updates an existing ad group's status, name, or CPC bid.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID." - }, - "ad_group_id": { - "type": "string", - "description": "The ID of the ad group to update." - }, - "status": { - "type": "string", - "enum": [ - "ENABLED", - "PAUSED" - ], - "description": "New status for the ad group." - }, - "name": { - "type": "string", - "description": "New name for the ad group (optional)." - }, - "cpc_bid_micros": { - "type": "integer", - "description": "New CPC bid in micros (optional)." - } + "required": [ + "login_customer_id", + "customer_id" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "keyword_ideas": { + "type": "array", + "items": { + "type": "object", + "properties": { + "keyword": { + "type": "string" }, - "required": [ - "login_customer_id", - "customer_id", - "ad_group_id" - ] - }, - "output_schema": { - "type": "object", - "properties": { - "ad_group_resource_name": { - "type": "string" - }, - "ad_group_id": { - "type": "string" - }, - "status": { - "type": "string" - } + "avg_monthly_searches": { + "type": "integer" + }, + "competition": { + "type": "string" + }, + "competition_index": { + "type": "number" + }, + "low_top_of_page_bid_micros": { + "type": "integer" + }, + "high_top_of_page_bid_micros": { + "type": "integer" } + } } + }, + "total_results": { + "type": "integer" + } + } + } + }, + "generate_keyword_historical_metrics": { + "display_name": "Get Keyword Historical Metrics", + "description": "Retrieves historical metrics (search volume, competition, bid estimates) for specific keywords using the Keyword Planner.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID." + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of keywords to get historical metrics for." + }, + "language_id": { + "type": "string", + "description": "Language ID (e.g., '1000' for English). Defaults to English." + }, + "location_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of geo target location IDs. Defaults to USA." + } }, - "remove_ad_group": { - "display_name": "Remove Ad Group", - "description": "Removes (deletes) an ad group. This sets the ad group status to REMOVED.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID." - }, - "ad_group_id": { - "type": "string", - "description": "The ID of the ad group to remove." - } + "required": [ + "login_customer_id", + "customer_id", + "keywords" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "keyword_metrics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "keyword": { + "type": "string" }, - "required": [ - "login_customer_id", - "customer_id", - "ad_group_id" - ] - }, - "output_schema": { - "type": "object", - "properties": { - "removed_ad_group_resource_name": { - "type": "string" - }, - "ad_group_id": { - "type": "string" - }, - "status": { + "avg_monthly_searches": { + "type": "integer" + }, + "competition": { + "type": "string" + }, + "competition_index": { + "type": "number" + }, + "low_top_of_page_bid_micros": { + "type": "integer" + }, + "high_top_of_page_bid_micros": { + "type": "integer" + }, + "monthly_search_volumes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "month": { "type": "string" + }, + "year": { + "type": "integer" + }, + "monthly_searches": { + "type": "integer" + } } + } } + } } - }, - "update_keyword": { - "display_name": "Update Keyword", - "description": "Updates a keyword's status or CPC bid.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID." - }, - "ad_group_id": { - "type": "string", - "description": "The ID of the ad group containing the keyword." - }, - "criterion_id": { - "type": "string", - "description": "The criterion ID of the keyword to update." - }, - "status": { - "type": "string", - "enum": [ - "ENABLED", - "PAUSED" - ], - "description": "New status for the keyword." - }, - "cpc_bid_micros": { - "type": "integer", - "description": "New CPC bid in micros (optional)." - } - }, - "required": [ - "login_customer_id", - "customer_id", - "ad_group_id", - "criterion_id" - ] + } + } + } + }, + "retrieve_ad_group_metrics": { + "display_name": "Get Ad Group Metrics", + "description": "Retrieves performance metrics for ad groups including impressions, clicks, cost, conversions, and CTR.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID." + }, + "date_ranges": { + "type": "array", + "items": { + "type": "string" }, - "output_schema": { - "type": "object", - "properties": { - "keyword_resource_name": { - "type": "string" - }, - "criterion_id": { - "type": "string" - }, - "status": { - "type": "string" - } - } + "description": "List of date ranges." + }, + "campaign_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional: Filter by specific campaign IDs." + } + }, + "required": [ + "login_customer_id", + "customer_id", + "date_ranges" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true } + } + } + } + }, + "retrieve_ad_metrics": { + "display_name": "Get Ad Metrics", + "description": "Retrieves performance metrics for individual ads including impressions, clicks, cost, conversions, final URLs, and ad copy.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID." + }, + "date_ranges": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of date ranges." + }, + "campaign_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional: Filter by specific campaign IDs." + }, + "ad_group_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional: Filter by specific ad group IDs." + } }, - "remove_keyword": { - "display_name": "Remove Keyword", - "description": "Removes (deletes) a keyword from an ad group.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID." - }, - "ad_group_id": { - "type": "string", - "description": "The ID of the ad group containing the keyword." - }, - "criterion_id": { - "type": "string", - "description": "The criterion ID of the keyword to remove." - } - }, - "required": [ - "login_customer_id", - "customer_id", - "ad_group_id", - "criterion_id" - ] + "required": [ + "login_customer_id", + "customer_id", + "date_ranges" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "retrieve_search_terms": { + "display_name": "Get Search Terms Report", + "description": "Retrieves the search terms that triggered your ads, including the matched keyword, impressions, clicks, cost, and conversions.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID." + }, + "date_ranges": { + "type": "array", + "items": { + "type": "string" }, - "output_schema": { - "type": "object", - "properties": { - "removed_keyword_resource_name": { - "type": "string" - }, - "criterion_id": { - "type": "string" - }, - "status": { - "type": "string" - } - } + "description": "List of date ranges." + }, + "campaign_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional: Filter by specific campaign IDs." + }, + "ad_group_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional: Filter by specific ad group IDs." + } + }, + "required": [ + "login_customer_id", + "customer_id", + "date_ranges" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true } + } + } + } + }, + "get_active_ad_urls": { + "display_name": "Get Active Ad URLs", + "description": "Retrieves all currently active (ENABLED) ads with their destination URLs. Useful for monitoring which URLs are being advertised.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID." + }, + "url_filter": { + "type": "string", + "description": "Optional: Filter to only return ads containing this URL substring." + } }, - "update_ad": { - "display_name": "Update Ad", - "description": "Updates an ad's status (ENABLED or PAUSED).", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID." - }, - "ad_group_id": { - "type": "string", - "description": "The ID of the ad group containing the ad." - }, - "ad_id": { - "type": "string", - "description": "The ID of the ad to update." - }, - "status": { - "type": "string", - "enum": [ - "ENABLED", - "PAUSED" - ], - "description": "New status for the ad." - } + "required": [ + "login_customer_id", + "customer_id" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "active_ads": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "total_count": { + "type": "integer" + } + } + } + }, + "add_negative_keywords_to_campaign": { + "display_name": "Add Negative Keywords to Campaign", + "description": "Adds negative keywords to a campaign to prevent ads from showing for specific search terms.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID." + }, + "campaign_id": { + "type": "string", + "description": "The ID of the campaign to add negative keywords to." + }, + "keywords": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The negative keyword text." }, - "required": [ - "login_customer_id", - "customer_id", - "ad_group_id", - "ad_id" - ] - }, - "output_schema": { - "type": "object", - "properties": { - "ad_resource_name": { - "type": "string" - }, - "ad_id": { - "type": "string" - }, - "status": { - "type": "string" - } + "match_type": { + "type": "string", + "enum": [ + "BROAD", + "PHRASE", + "EXACT" + ], + "description": "Match type for the negative keyword. Defaults to BROAD." } - } + }, + "required": [ + "text" + ] + }, + "description": "List of negative keywords with their match types." + } }, - "remove_ad": { - "display_name": "Remove Ad", - "description": "Removes (deletes) an ad from an ad group.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID." - }, - "ad_group_id": { - "type": "string", - "description": "The ID of the ad group containing the ad." - }, - "ad_id": { - "type": "string", - "description": "The ID of the ad to remove." - } + "required": [ + "login_customer_id", + "customer_id", + "campaign_id", + "keywords" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "added_negative_keywords": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "campaign_id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + }, + "add_negative_keywords_to_ad_group": { + "display_name": "Add Negative Keywords to Ad Group", + "description": "Adds negative keywords to an ad group to prevent ads from showing for specific search terms at the ad group level.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID." + }, + "ad_group_id": { + "type": "string", + "description": "The ID of the ad group to add negative keywords to." + }, + "keywords": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The negative keyword text." }, - "required": [ - "login_customer_id", - "customer_id", - "ad_group_id", - "ad_id" - ] - }, - "output_schema": { - "type": "object", - "properties": { - "removed_ad_resource_name": { - "type": "string" - }, - "ad_id": { - "type": "string" - }, - "status": { - "type": "string" - } + "match_type": { + "type": "string", + "enum": [ + "BROAD", + "PHRASE", + "EXACT" + ], + "description": "Match type for the negative keyword. Defaults to BROAD." } + }, + "required": [ + "text" + ] + }, + "description": "List of negative keywords with their match types." + } + }, + "required": [ + "login_customer_id", + "customer_id", + "ad_group_id", + "keywords" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "added_negative_keywords": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true } + }, + "ad_group_id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + }, + "update_ad_group": { + "display_name": "Update Ad Group", + "description": "Updates an existing ad group's status, name, or CPC bid.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID." + }, + "ad_group_id": { + "type": "string", + "description": "The ID of the ad group to update." + }, + "status": { + "type": "string", + "enum": [ + "ENABLED", + "PAUSED" + ], + "description": "New status for the ad group." + }, + "name": { + "type": "string", + "description": "New name for the ad group (optional)." + }, + "cpc_bid_micros": { + "type": "integer", + "description": "New CPC bid in micros (optional)." + } }, - "generate_keyword_forecast": { - "display_name": "Generate Keyword Forecast", - "description": "Generates forecast metrics (impressions, clicks, cost) for keywords with specified budget and targeting settings.", - "input_schema": { - "type": "object", - "properties": { - "login_customer_id": { - "type": "string", - "description": "Your Google Ads Manager Account ID (MCC) without dashes." - }, - "customer_id": { - "type": "string", - "description": "The Google Ads Customer ID." - }, - "keywords": { - "type": "array", - "items": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "The keyword text." - }, - "match_type": { - "type": "string", - "enum": [ - "BROAD", - "PHRASE", - "EXACT" - ], - "description": "Match type. Defaults to BROAD." - }, - "cpc_bid_micros": { - "type": "integer", - "description": "CPC bid for this keyword in micros." - } - }, - "required": [ - "text" - ] - }, - "description": "List of keywords to forecast." - }, - "daily_budget_micros": { - "type": "integer", - "description": "Daily budget in micros for Target Spend bidding strategy." - }, - "max_cpc_bid_micros": { - "type": "integer", - "description": "Max CPC bid in micros for Manual CPC. Defaults to 1000000 ($1)." - }, - "language_id": { - "type": "string", - "description": "Language ID (e.g., '1000' for English). Defaults to English." - }, - "location_ids": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of geo target location IDs. Defaults to USA ['2840']." - }, - "forecast_days": { - "type": "integer", - "description": "Number of days to forecast. Defaults to 30." - } + "required": [ + "login_customer_id", + "customer_id", + "ad_group_id" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "ad_group_resource_name": { + "type": "string" + }, + "ad_group_id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + }, + "remove_ad_group": { + "display_name": "Remove Ad Group", + "description": "Removes (deletes) an ad group. This sets the ad group status to REMOVED.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID." + }, + "ad_group_id": { + "type": "string", + "description": "The ID of the ad group to remove." + } + }, + "required": [ + "login_customer_id", + "customer_id", + "ad_group_id" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "removed_ad_group_resource_name": { + "type": "string" + }, + "ad_group_id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + }, + "update_keyword": { + "display_name": "Update Keyword", + "description": "Updates a keyword's status or CPC bid.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID." + }, + "ad_group_id": { + "type": "string", + "description": "The ID of the ad group containing the keyword." + }, + "criterion_id": { + "type": "string", + "description": "The criterion ID of the keyword to update." + }, + "status": { + "type": "string", + "enum": [ + "ENABLED", + "PAUSED" + ], + "description": "New status for the keyword." + }, + "cpc_bid_micros": { + "type": "integer", + "description": "New CPC bid in micros (optional)." + } + }, + "required": [ + "login_customer_id", + "customer_id", + "ad_group_id", + "criterion_id" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "keyword_resource_name": { + "type": "string" + }, + "criterion_id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + }, + "remove_keyword": { + "display_name": "Remove Keyword", + "description": "Removes (deletes) a keyword from an ad group.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID." + }, + "ad_group_id": { + "type": "string", + "description": "The ID of the ad group containing the keyword." + }, + "criterion_id": { + "type": "string", + "description": "The criterion ID of the keyword to remove." + } + }, + "required": [ + "login_customer_id", + "customer_id", + "ad_group_id", + "criterion_id" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "removed_keyword_resource_name": { + "type": "string" + }, + "criterion_id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + }, + "update_ad": { + "display_name": "Update Ad", + "description": "Updates an ad's status (ENABLED or PAUSED).", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID." + }, + "ad_group_id": { + "type": "string", + "description": "The ID of the ad group containing the ad." + }, + "ad_id": { + "type": "string", + "description": "The ID of the ad to update." + }, + "status": { + "type": "string", + "enum": [ + "ENABLED", + "PAUSED" + ], + "description": "New status for the ad." + } + }, + "required": [ + "login_customer_id", + "customer_id", + "ad_group_id", + "ad_id" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "ad_resource_name": { + "type": "string" + }, + "ad_id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + }, + "remove_ad": { + "display_name": "Remove Ad", + "description": "Removes (deletes) an ad from an ad group.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID." + }, + "ad_group_id": { + "type": "string", + "description": "The ID of the ad group containing the ad." + }, + "ad_id": { + "type": "string", + "description": "The ID of the ad to remove." + } + }, + "required": [ + "login_customer_id", + "customer_id", + "ad_group_id", + "ad_id" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "removed_ad_resource_name": { + "type": "string" + }, + "ad_id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + }, + "generate_keyword_forecast": { + "display_name": "Generate Keyword Forecast", + "description": "Generates forecast metrics (impressions, clicks, cost) for keywords with specified budget and targeting settings.", + "input_schema": { + "type": "object", + "properties": { + "login_customer_id": { + "type": "string", + "description": "Your Google Ads Manager Account ID (MCC) without dashes." + }, + "customer_id": { + "type": "string", + "description": "The Google Ads Customer ID." + }, + "keywords": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The keyword text." }, - "required": [ - "login_customer_id", - "customer_id", - "keywords" - ] - }, - "output_schema": { - "type": "object", - "properties": { - "forecast_period": { - "type": "object", - "properties": { - "start_date": { - "type": "string" - }, - "end_date": { - "type": "string" - }, - "days": { - "type": "integer" - } - } - }, - "campaign_metrics": { - "type": "object", - "properties": { - "impressions": { - "type": "number" - }, - "clicks": { - "type": "number" - }, - "cost_micros": { - "type": "integer" - }, - "cost": { - "type": "number" - }, - "average_cpc_micros": { - "type": "integer" - }, - "average_cpc": { - "type": "number" - } - } - }, - "keywords_count": { - "type": "integer" - } + "match_type": { + "type": "string", + "enum": [ + "BROAD", + "PHRASE", + "EXACT" + ], + "description": "Match type. Defaults to BROAD." + }, + "cpc_bid_micros": { + "type": "integer", + "description": "CPC bid for this keyword in micros." } + }, + "required": [ + "text" + ] + }, + "description": "List of keywords to forecast." + }, + "daily_budget_micros": { + "type": "integer", + "description": "Daily budget in micros for Target Spend bidding strategy." + }, + "max_cpc_bid_micros": { + "type": "integer", + "description": "Max CPC bid in micros for Manual CPC. Defaults to 1000000 ($1)." + }, + "language_id": { + "type": "string", + "description": "Language ID (e.g., '1000' for English). Defaults to English." + }, + "location_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of geo target location IDs. Defaults to USA ['2840']." + }, + "forecast_days": { + "type": "integer", + "description": "Number of days to forecast. Defaults to 30." + } + }, + "required": [ + "login_customer_id", + "customer_id", + "keywords" + ] + }, + "output_schema": { + "type": "object", + "properties": { + "forecast_period": { + "type": "object", + "properties": { + "start_date": { + "type": "string" + }, + "end_date": { + "type": "string" + }, + "days": { + "type": "integer" + } } + }, + "campaign_metrics": { + "type": "object", + "properties": { + "impressions": { + "type": "number" + }, + "clicks": { + "type": "number" + }, + "cost_micros": { + "type": "integer" + }, + "cost": { + "type": "number" + }, + "average_cpc_micros": { + "type": "integer" + }, + "average_cpc": { + "type": "number" + } + } + }, + "keywords_count": { + "type": "integer" + } } - }, - "display_name": "Google Ads" + } + } + }, + "display_name": "Google Ads" } \ No newline at end of file diff --git a/google-ads/google_ads.py b/google-ads/google_ads.py index ba84e3ff..e8d54735 100644 --- a/google-ads/google_ads.py +++ b/google-ads/google_ads.py @@ -7,22 +7,10 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") logger = logging.getLogger(__name__) -# Setup environment variables -from dotenv import load_dotenv # noqa: E402 - -load_dotenv() - -try: - DEVELOPER_TOKEN = os.environ["ADWORDS_DEVELOPER_TOKEN"] - CLIENT_ID = os.environ["ADWORDS_CLIENT_ID"] - CLIENT_SECRET = os.environ["ADWORDS_CLIENT_SECRET"] -except KeyError as e: - logger.error(f"Error loading environment variables: {str(e)}") - raise - from enum import Enum # noqa: E402 import proto # noqa: E402 from autohive_integrations_sdk import ( # noqa: E402 + ActionError, ActionHandler, ExecutionContext, Integration, @@ -30,6 +18,7 @@ ) from google.ads.googleads.client import GoogleAdsClient # noqa: E402 from google.api_core import protobuf_helpers # noqa: E402 +from google.oauth2.credentials import Credentials # noqa: E402 # Load integration configuration google_ads = Integration.load() @@ -48,37 +37,35 @@ def micros_to_currency(micros): return float(micros) / 1000000 if micros is not None else "N/A" -def _get_google_ads_client(refresh_token: str, login_customer_id: Optional[str] = None) -> GoogleAdsClient: +def _get_developer_token() -> str: + """Return the server-side Google Ads developer token.""" + developer_token = os.environ.get("ADWORDS_DEVELOPER_TOKEN", "").strip() + if not developer_token: + raise ValueError("ADWORDS_DEVELOPER_TOKEN is required for Google Ads API requests") + return developer_token + + +def _get_google_ads_client(access_token: str, login_customer_id: Optional[str] = None) -> GoogleAdsClient: """Initialize and return a Google Ads API client.""" - credentials = { - "developer_token": DEVELOPER_TOKEN, - "token_uri": "https://oauth2.googleapis.com/token", # nosec B105 - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "refresh_token": refresh_token, + credentials = Credentials(token=access_token) + client_kwargs = { + "credentials": credentials, + "developer_token": _get_developer_token(), "use_proto_plus": True, } if login_customer_id: - credentials["login_customer_id"] = login_customer_id - - return GoogleAdsClient.load_from_dict(credentials) + client_kwargs["login_customer_id"] = login_customer_id + return GoogleAdsClient(**client_kwargs) -def _validate_auth_and_inputs(inputs: Dict[str, Any], context: ExecutionContext) -> tuple: - """Validate authentication and extract common inputs.""" - refresh_token = context.auth.get("credentials", {}).get("refresh_token") - if not refresh_token: - raise Exception("Refresh token is required for authentication with Google Ads API") - login_customer_id = inputs.get("login_customer_id") - customer_id = inputs.get("customer_id") - - if not login_customer_id: - raise Exception("Manager Account ID (login_customer_id) is required") - if not customer_id: - raise Exception("Customer ID is required") - - return refresh_token, login_customer_id, customer_id +def _validate_auth(context: ExecutionContext) -> str: + """Validate platform OAuth authentication and return an access token.""" + credentials = context.auth.get("credentials", {}) + access_token = credentials.get("access_token") or credentials.get("refresh_token") + if not access_token: + raise Exception("Access token is required for authentication with Google Ads API") + return access_token def _get_ad_text_assets(ad_data_from_row: Dict[str, Any]) -> Dict[str, list]: @@ -366,20 +353,18 @@ class GetAccessibleAccountsAction(ActionHandler): """Action handler for listing accessible Google Ads accounts.""" async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): - refresh_token = context.auth.get("credentials", {}).get("refresh_token") - if not refresh_token: - raise Exception("Refresh token is required for authentication with Google Ads API") - try: + access_token = _validate_auth(context) + # 1. List accessible customers (no login_customer_id needed) - client = _get_google_ads_client(refresh_token, None) + client = _get_google_ads_client(access_token, None) customer_service = client.get_service("CustomerService") try: response = customer_service.list_accessible_customers() except Exception as e: logger.error(f"Failed to list accessible customers: {e}") - raise + return ActionError(message=str(e)) accounts = [] for resource_name in response.resource_names: @@ -400,7 +385,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): for account in accounts: try: # Re-initialize client specifically for this customer - sub_client = _get_google_ads_client(refresh_token, account["customer_id"]) + sub_client = _get_google_ads_client(access_token, account["customer_id"]) google_ads_service = sub_client.get_service("GoogleAdsService") query = """ @@ -426,7 +411,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Failed to get accessible accounts: {str(e)}") - raise + return ActionError(message=str(e)) @google_ads.action("retrieve_campaign_metrics") @@ -435,15 +420,17 @@ class RetrieveCampaignMetricsAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise + return ActionError(message=str(e)) date_ranges_input = inputs.get("date_ranges") if not date_ranges_input: - raise Exception("'date_ranges' is required.") + return ActionError(message="'date_ranges' is required.") campaign_type = inputs.get("campaign_type", "ALL") @@ -453,7 +440,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): return ActionResult(data={"results": results}, cost_usd=0.00) except Exception as e: logger.exception(f"Exception during campaign data retrieval: {str(e)}") - raise + return ActionError(message=str(e)) @google_ads.action("retrieve_keyword_metrics") @@ -462,18 +449,20 @@ class RetrieveKeywordMetricsAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise + return ActionError(message=str(e)) date_ranges_input = inputs.get("date_ranges") - campaign_ids = inputs.get("campaign_ids", []) - ad_group_ids = inputs.get("ad_group_ids", []) + campaign_ids = inputs["campaign_ids"] + ad_group_ids = inputs["ad_group_ids"] if not date_ranges_input: - raise Exception("'date_ranges' is required.") + return ActionError(message="'date_ranges' is required.") try: results = fetch_keyword_data(client, customer_id, date_ranges_input, campaign_ids, ad_group_ids) @@ -481,7 +470,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): return ActionResult(data={"results": results}, cost_usd=0.00) except Exception as e: logger.exception(f"Exception during keyword data retrieval: {str(e)}") - raise + return ActionError(message=str(e)) # ---- Action Handlers: CAMPAIGN CRUD Operations ---- @@ -493,20 +482,22 @@ class CreateCampaignAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise + return ActionError(message=str(e)) - campaign_name = inputs.get("campaign_name") - budget_amount_micros = inputs.get("budget_amount_micros") + campaign_name = inputs["campaign_name"] + budget_amount_micros = inputs["budget_amount_micros"] # Validate required inputs if not campaign_name: - raise Exception("campaign_name is required") + return ActionError(message="campaign_name is required") if not budget_amount_micros: - raise Exception("budget_amount_micros is required") + return ActionError(message="budget_amount_micros is required") budget_name = inputs.get("budget_name", f"Budget for {campaign_name}") bidding_strategy = inputs.get("bidding_strategy", "MANUAL_CPC") @@ -614,7 +605,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Failed to create campaign: {str(e)}") - raise + return ActionError(message=str(e)) @google_ads.action("update_campaign") @@ -623,18 +614,20 @@ class UpdateCampaignAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise + return ActionError(message=str(e)) - campaign_id = inputs.get("campaign_id") + campaign_id = inputs["campaign_id"] new_status = inputs.get("status") new_name = inputs.get("name") if not campaign_id: - raise Exception("campaign_id is required") + return ActionError(message="campaign_id is required") try: campaign_service = client.get_service("CampaignService") @@ -673,7 +666,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Failed to update campaign: {str(e)}") - raise + return ActionError(message=str(e)) @google_ads.action("remove_campaign") @@ -682,15 +675,15 @@ class RemoveCampaignAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise + return ActionError(message=str(e)) - campaign_id = inputs.get("campaign_id") - if not campaign_id: - raise Exception("campaign_id is required") + campaign_id = inputs["campaign_id"] try: campaign_service = client.get_service("CampaignService") @@ -714,7 +707,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Failed to remove campaign: {str(e)}") - raise + return ActionError(message=str(e)) # ---- Action Handlers: AD GROUP Operations ---- @@ -726,19 +719,21 @@ class CreateAdGroupAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise + return ActionError(message=str(e)) - campaign_id = inputs.get("campaign_id") - ad_group_name = inputs.get("ad_group_name") + campaign_id = inputs["campaign_id"] + ad_group_name = inputs["ad_group_name"] cpc_bid_micros = inputs.get("cpc_bid_micros", 1000000) status = inputs.get("status", "PAUSED") if not campaign_id or not ad_group_name: - raise Exception("campaign_id and ad_group_name are required") + return ActionError(message="campaign_id and ad_group_name are required") try: ad_group_service = client.get_service("AdGroupService") @@ -773,7 +768,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Failed to create ad group: {str(e)}") - raise + return ActionError(message=str(e)) # ---- Action Handlers: AD Operations ---- @@ -785,27 +780,29 @@ class CreateResponsiveSearchAdAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise + return ActionError(message=str(e)) - ad_group_id = inputs.get("ad_group_id") - headlines = inputs.get("headlines", []) - descriptions = inputs.get("descriptions", []) - final_url = inputs.get("final_url") + ad_group_id = inputs["ad_group_id"] + headlines = inputs["headlines"] + descriptions = inputs["descriptions"] + final_url = inputs["final_url"] path1 = inputs.get("path1", "") path2 = inputs.get("path2", "") status = inputs.get("status", "PAUSED") if not ad_group_id or not headlines or not descriptions or not final_url: - raise Exception("ad_group_id, headlines, descriptions, and final_url are required") + return ActionError(message="ad_group_id, headlines, descriptions, and final_url are required") if len(headlines) < 3: - raise Exception("At least 3 headlines are required for RSA") + return ActionError(message="At least 3 headlines are required for RSA") if len(descriptions) < 2: - raise Exception("At least 2 descriptions are required for RSA") + return ActionError(message="At least 2 descriptions are required for RSA") try: ad_group_ad_service = client.get_service("AdGroupAdService") @@ -860,7 +857,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Failed to create RSA: {str(e)}") - raise + return ActionError(message=str(e)) # ---- Action Handlers: KEYWORD Operations ---- @@ -872,17 +869,16 @@ class AddKeywordsAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise - - ad_group_id = inputs.get("ad_group_id") - keywords = inputs.get("keywords", []) + return ActionError(message=str(e)) - if not ad_group_id or not keywords: - raise Exception("ad_group_id and keywords are required") + ad_group_id = inputs["ad_group_id"] + keywords = inputs["keywords"] try: ad_group_criterion_service = client.get_service("AdGroupCriterionService") @@ -932,7 +928,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Failed to add keywords: {str(e)}") - raise + return ActionError(message=str(e)) # ---- Action Handlers: KEYWORD PLANNER Operations ---- @@ -944,11 +940,13 @@ class GenerateKeywordIdeasAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise + return ActionError(message=str(e)) seed_keywords = inputs.get("seed_keywords", []) page_url = inputs.get("page_url") @@ -957,7 +955,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): include_adult_keywords = inputs.get("include_adult_keywords", False) if not seed_keywords and not page_url: - raise Exception("At least one of seed_keywords or page_url is required") + return ActionError(message="At least one of seed_keywords or page_url is required") try: keyword_plan_idea_service = client.get_service("KeywordPlanIdeaService") @@ -1015,7 +1013,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Failed to generate keyword ideas: {str(e)}") - raise + return ActionError(message=str(e)) @google_ads.action("generate_keyword_historical_metrics") @@ -1024,18 +1022,20 @@ class GenerateKeywordHistoricalMetricsAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise + return ActionError(message=str(e)) - keywords = inputs.get("keywords", []) + keywords = inputs["keywords"] language_id = inputs.get("language_id", "1000") location_ids = inputs.get("location_ids", ["2840"]) if not keywords: - raise Exception("keywords list is required") + return ActionError(message="keywords list is required") try: keyword_plan_idea_service = client.get_service("KeywordPlanIdeaService") @@ -1090,7 +1090,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Failed to get keyword historical metrics: {str(e)}") - raise + return ActionError(message=str(e)) # ---- NEW Action Handlers: Additional READ Operations ---- @@ -1102,17 +1102,19 @@ class RetrieveAdGroupMetricsAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise + return ActionError(message=str(e)) - date_ranges_input = inputs.get("date_ranges") + date_ranges_input = inputs["date_ranges"] campaign_ids = inputs.get("campaign_ids", []) if not date_ranges_input: - raise Exception("'date_ranges' is required.") + return ActionError(message="'date_ranges' is required.") ga_service = client.get_service("GoogleAdsService") all_results = [] @@ -1198,7 +1200,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Exception during ad group metrics retrieval: {str(e)}") - raise + return ActionError(message=str(e)) @google_ads.action("retrieve_ad_metrics") @@ -1207,18 +1209,20 @@ class RetrieveAdMetricsAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise + return ActionError(message=str(e)) - date_ranges_input = inputs.get("date_ranges") + date_ranges_input = inputs["date_ranges"] campaign_ids = inputs.get("campaign_ids", []) ad_group_ids = inputs.get("ad_group_ids", []) if not date_ranges_input: - raise Exception("'date_ranges' is required.") + return ActionError(message="'date_ranges' is required.") ga_service = client.get_service("GoogleAdsService") all_results = [] @@ -1315,7 +1319,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Exception during ad metrics retrieval: {str(e)}") - raise + return ActionError(message=str(e)) @google_ads.action("retrieve_search_terms") @@ -1324,18 +1328,20 @@ class RetrieveSearchTermsAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise + return ActionError(message=str(e)) - date_ranges_input = inputs.get("date_ranges") + date_ranges_input = inputs["date_ranges"] campaign_ids = inputs.get("campaign_ids", []) ad_group_ids = inputs.get("ad_group_ids", []) if not date_ranges_input: - raise Exception("'date_ranges' is required.") + return ActionError(message="'date_ranges' is required.") ga_service = client.get_service("GoogleAdsService") all_results = [] @@ -1423,7 +1429,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Exception during search terms retrieval: {str(e)}") - raise + return ActionError(message=str(e)) @google_ads.action("get_active_ad_urls") @@ -1432,11 +1438,13 @@ class GetActiveAdUrlsAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise + return ActionError(message=str(e)) url_filter = inputs.get("url_filter") # Optional: filter by specific URL @@ -1503,7 +1511,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Exception during active ad URLs retrieval: {str(e)}") - raise + return ActionError(message=str(e)) # ---- NEW Action Handlers: Negative Keywords ---- @@ -1515,17 +1523,16 @@ class AddNegativeKeywordsToCampaignAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise - - campaign_id = inputs.get("campaign_id") - keywords = inputs.get("keywords", []) + return ActionError(message=str(e)) - if not campaign_id or not keywords: - raise Exception("campaign_id and keywords are required") + campaign_id = inputs["campaign_id"] + keywords = inputs["keywords"] try: campaign_criterion_service = client.get_service("CampaignCriterionService") @@ -1579,7 +1586,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Failed to add negative keywords to campaign: {str(e)}") - raise + return ActionError(message=str(e)) @google_ads.action("add_negative_keywords_to_ad_group") @@ -1588,17 +1595,16 @@ class AddNegativeKeywordsToAdGroupAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise + return ActionError(message=str(e)) - ad_group_id = inputs.get("ad_group_id") - keywords = inputs.get("keywords", []) - - if not ad_group_id or not keywords: - raise Exception("ad_group_id and keywords are required") + ad_group_id = inputs["ad_group_id"] + keywords = inputs["keywords"] try: ad_group_criterion_service = client.get_service("AdGroupCriterionService") @@ -1652,7 +1658,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Failed to add negative keywords to ad group: {str(e)}") - raise + return ActionError(message=str(e)) # ---- NEW Action Handlers: Ad Group CRUD ---- @@ -1664,19 +1670,21 @@ class UpdateAdGroupAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise + return ActionError(message=str(e)) - ad_group_id = inputs.get("ad_group_id") + ad_group_id = inputs["ad_group_id"] new_status = inputs.get("status") new_name = inputs.get("name") new_cpc_bid_micros = inputs.get("cpc_bid_micros") if not ad_group_id: - raise Exception("ad_group_id is required") + return ActionError(message="ad_group_id is required") try: ad_group_service = client.get_service("AdGroupService") @@ -1719,7 +1727,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Failed to update ad group: {str(e)}") - raise + return ActionError(message=str(e)) @google_ads.action("remove_ad_group") @@ -1728,15 +1736,15 @@ class RemoveAdGroupAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise + return ActionError(message=str(e)) - ad_group_id = inputs.get("ad_group_id") - if not ad_group_id: - raise Exception("ad_group_id is required") + ad_group_id = inputs["ad_group_id"] try: ad_group_service = client.get_service("AdGroupService") @@ -1761,7 +1769,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Failed to remove ad group: {str(e)}") - raise + return ActionError(message=str(e)) # ---- NEW Action Handlers: Keyword CRUD ---- @@ -1773,19 +1781,21 @@ class UpdateKeywordAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise + return ActionError(message=str(e)) - ad_group_id = inputs.get("ad_group_id") - criterion_id = inputs.get("criterion_id") + ad_group_id = inputs["ad_group_id"] + criterion_id = inputs["criterion_id"] new_status = inputs.get("status") new_cpc_bid_micros = inputs.get("cpc_bid_micros") if not ad_group_id or not criterion_id: - raise Exception("ad_group_id and criterion_id are required") + return ActionError(message="ad_group_id and criterion_id are required") try: ad_group_criterion_service = client.get_service("AdGroupCriterionService") @@ -1826,7 +1836,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Failed to update keyword: {str(e)}") - raise + return ActionError(message=str(e)) @google_ads.action("remove_keyword") @@ -1835,17 +1845,16 @@ class RemoveKeywordAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise - - ad_group_id = inputs.get("ad_group_id") - criterion_id = inputs.get("criterion_id") + return ActionError(message=str(e)) - if not ad_group_id or not criterion_id: - raise Exception("ad_group_id and criterion_id are required") + ad_group_id = inputs["ad_group_id"] + criterion_id = inputs["criterion_id"] try: ad_group_criterion_service = client.get_service("AdGroupCriterionService") @@ -1872,7 +1881,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Failed to remove keyword: {str(e)}") - raise + return ActionError(message=str(e)) # ---- NEW Action Handlers: Ad CRUD ---- @@ -1884,18 +1893,20 @@ class UpdateAdAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise + return ActionError(message=str(e)) - ad_group_id = inputs.get("ad_group_id") - ad_id = inputs.get("ad_id") + ad_group_id = inputs["ad_group_id"] + ad_id = inputs["ad_id"] new_status = inputs.get("status") if not ad_group_id or not ad_id: - raise Exception("ad_group_id and ad_id are required") + return ActionError(message="ad_group_id and ad_id are required") try: ad_group_ad_service = client.get_service("AdGroupAdService") @@ -1932,7 +1943,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Failed to update ad: {str(e)}") - raise + return ActionError(message=str(e)) @google_ads.action("remove_ad") @@ -1941,17 +1952,16 @@ class RemoveAdAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise - - ad_group_id = inputs.get("ad_group_id") - ad_id = inputs.get("ad_id") + return ActionError(message=str(e)) - if not ad_group_id or not ad_id: - raise Exception("ad_group_id and ad_id are required") + ad_group_id = inputs["ad_group_id"] + ad_id = inputs["ad_id"] try: ad_group_ad_service = client.get_service("AdGroupAdService") @@ -1976,7 +1986,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Failed to remove ad: {str(e)}") - raise + return ActionError(message=str(e)) # ---- NEW Action Handler: Keyword Forecast ---- @@ -1988,13 +1998,15 @@ class GenerateKeywordForecastAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: - refresh_token, login_customer_id, customer_id = _validate_auth_and_inputs(inputs, context) + login_customer_id = inputs["login_customer_id"] + customer_id = inputs["customer_id"] + refresh_token = _validate_auth(context) client = _get_google_ads_client(refresh_token, login_customer_id) except Exception as e: logger.exception(f"Failed to initialize GoogleAdsClient: {str(e)}") - raise + return ActionError(message=str(e)) - keywords = inputs.get("keywords", []) + keywords = inputs["keywords"] daily_budget_micros = inputs.get("daily_budget_micros") max_cpc_bid_micros = inputs.get("max_cpc_bid_micros", 1000000) language_id = inputs.get("language_id", "1000") @@ -2002,7 +2014,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): forecast_days = inputs.get("forecast_days", 30) if not keywords: - raise Exception("keywords list is required") + return ActionError(message="keywords list is required") try: keyword_plan_idea_service = client.get_service("KeywordPlanIdeaService") @@ -2089,4 +2101,4 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): except Exception as e: logger.exception(f"Failed to generate keyword forecast: {str(e)}") - raise + return ActionError(message=str(e)) diff --git a/google-ads/requirements.txt b/google-ads/requirements.txt index d65385d4..d125cda0 100644 --- a/google-ads/requirements.txt +++ b/google-ads/requirements.txt @@ -1,4 +1,3 @@ -autohive-integrations-sdk~=1.0.2 +autohive-integrations-sdk~=2.0.0 google-ads~=30.0 -python-dotenv proto-plus diff --git a/google-ads/ruff.toml b/google-ads/ruff.toml new file mode 100644 index 00000000..f11cf635 --- /dev/null +++ b/google-ads/ruff.toml @@ -0,0 +1 @@ +line-length = 120 diff --git a/google-ads/run_live_readonly.py b/google-ads/run_live_readonly.py new file mode 100644 index 00000000..eaee8eb0 --- /dev/null +++ b/google-ads/run_live_readonly.py @@ -0,0 +1,196 @@ +""" +Quick live test using a short-lived access token (no refresh token needed). + +Usage: + Set the variables in the CONFIG block below, then run: + python google-ads/run_live_readonly.py +""" + +import os +import sys +import asyncio +import importlib +from unittest.mock import MagicMock, AsyncMock + +# ============================================================================ +# CONFIG — fill these in before running +# ============================================================================ +ACCESS_TOKEN = os.environ.get("GOOGLE_ADS_ACCESS_TOKEN", "") +DEVELOPER_TOKEN = os.environ.get("ADWORDS_DEVELOPER_TOKEN", "") +LOGIN_CUSTOMER_ID = os.environ.get("GOOGLE_ADS_LOGIN_CUSTOMER_ID", "") # MCC account ID +CUSTOMER_ID = os.environ.get("GOOGLE_ADS_CUSTOMER_ID", "") # client account ID +# ============================================================================ + +if not all([ACCESS_TOKEN, DEVELOPER_TOKEN, LOGIN_CUSTOMER_ID, CUSTOMER_ID]): + print("Missing required config. Set these env vars:") + print(" GOOGLE_ADS_ACCESS_TOKEN") + print(" ADWORDS_DEVELOPER_TOKEN") + print(" GOOGLE_ADS_LOGIN_CUSTOMER_ID (MCC/manager account ID)") + print(" GOOGLE_ADS_CUSTOMER_ID (client account ID to query)") + sys.exit(1) + +# Make the developer token available to google_ads.py when it builds the client. +os.environ.setdefault("ADWORDS_DEVELOPER_TOKEN", DEVELOPER_TOKEN) + +_parent = os.path.abspath(os.path.join(os.path.dirname(__file__))) +_deps = os.path.abspath(os.path.join(os.path.dirname(__file__), "dependencies")) +sys.path.insert(0, _parent) +sys.path.insert(0, _deps) + +_spec = importlib.util.spec_from_file_location("google_ads_mod", os.path.join(_parent, "google_ads.py")) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + +google_ads = _mod.google_ads + +from autohive_integrations_sdk.integration import ResultType # noqa: E402 + +ctx = MagicMock(name="ExecutionContext") +ctx.fetch = AsyncMock() +ctx.auth = { + "auth_type": "PlatformOauth2", + "credentials": {"access_token": ACCESS_TOKEN}, +} + +base = {"login_customer_id": LOGIN_CUSTOMER_ID, "customer_id": CUSTOMER_ID} + +PASS = "✓" # nosec B105 +FAIL = "✗" +SKIP = "—" + + +async def run(): + results = [] + + async def check(label, coro): + try: + result = await coro + if result.type == ResultType.ACTION: + print(f" {PASS} {label}") + results.append((label, True, None)) + return result + else: + msg = getattr(result.result, "message", None) or str(result.result) + print(f" {FAIL} {label}: {result.type.value} — {msg[:120]}") + results.append((label, False, msg)) + return result + except Exception as e: + print(f" {FAIL} {label}: EXCEPTION — {e}") + results.append((label, False, str(e))) + return None + + print("\n── get_accessible_accounts ──────────────────────────────") + r = await check( + "returns accounts list", + google_ads.execute_action("get_accessible_accounts", {}, ctx), + ) + if r and r.type == ResultType.ACTION: + accounts = r.result.data.get("accounts", []) + print(f" → {len(accounts)} account(s) found") + for a in accounts[:5]: + print(f" • {a.get('customer_id')} — {a.get('descriptive_name')}") + + print("\n── retrieve_campaign_metrics ────────────────────────────") + r = await check( + "last 7 days", + google_ads.execute_action( + "retrieve_campaign_metrics", + {**base, "date_ranges": ["last 7 days"]}, + ctx, + ), + ) + if r and r.type == ResultType.ACTION: + entries = r.result.data.get("results", [{}])[0].get("data", []) + print(f" → {len(entries)} campaign(s)") + for c in entries[:3]: + print(f" • {c.get('Campaign')} — clicks: {c.get('Clicks')}, cost: {c.get('Cost')}") + + print("\n── retrieve_ad_group_metrics ────────────────────────────") + r = await check( + "last 7 days", + google_ads.execute_action( + "retrieve_ad_group_metrics", + {**base, "date_ranges": ["last 7 days"]}, + ctx, + ), + ) + if r and r.type == ResultType.ACTION: + entries = r.result.data.get("results", [{}])[0].get("data", []) + print(f" → {len(entries)} ad group(s)") + + print("\n── retrieve_ad_metrics ──────────────────────────────────") + r = await check( + "last 7 days", + google_ads.execute_action( + "retrieve_ad_metrics", + {**base, "date_ranges": ["last 7 days"]}, + ctx, + ), + ) + if r and r.type == ResultType.ACTION: + entries = r.result.data.get("results", [{}])[0].get("data", []) + print(f" → {len(entries)} ad(s)") + + print("\n── retrieve_search_terms ────────────────────────────────") + await check( + "last 7 days", + google_ads.execute_action( + "retrieve_search_terms", + {**base, "date_ranges": ["last 7 days"]}, + ctx, + ), + ) + + print("\n── get_active_ad_urls ───────────────────────────────────") + r = await check( + "all active ads", + google_ads.execute_action("get_active_ad_urls", base, ctx), + ) + if r and r.type == ResultType.ACTION: + print(f" → {r.result.data.get('total_count', 0)} active ad(s)") + + print("\n── generate_keyword_ideas ───────────────────────────────") + r = await check( + "seed: digital marketing", + google_ads.execute_action( + "generate_keyword_ideas", + {**base, "seed_keywords": ["digital marketing"]}, + ctx, + ), + ) + if r and r.type == ResultType.ACTION: + ideas = r.result.data.get("keyword_ideas", []) + print(f" → {len(ideas)} idea(s)") + for i in ideas[:3]: + comp = i.get("competition") + print(f" • {i.get('keyword')} — {i.get('avg_monthly_searches')} searches/mo, comp: {comp}") + + print("\n── generate_keyword_historical_metrics ──────────────────") + r = await check( + "keywords: [digital marketing, seo]", + google_ads.execute_action( + "generate_keyword_historical_metrics", + {**base, "keywords": ["digital marketing", "seo"]}, + ctx, + ), + ) + if r and r.type == ResultType.ACTION: + metrics = r.result.data.get("keyword_metrics", []) + print(f" → {len(metrics)} keyword(s)") + for m in metrics[:3]: + print(f" • {m.get('keyword')} — avg: {m.get('avg_monthly_searches')}, comp: {m.get('competition')}") + + # Summary + total = len(results) + passed = sum(1 for _, ok, _ in results if ok) + print(f"\n{'─' * 56}") + print(f" {passed}/{total} passed") + if passed < total: + print("\n Failures:") + for label, ok, err in results: + if not ok: + print(f" {FAIL} {label}: {err[:100] if err else 'unknown'}") + print() + + +asyncio.run(run()) diff --git a/google-ads/tests/conftest.py b/google-ads/tests/conftest.py new file mode 100644 index 00000000..39c77ddf --- /dev/null +++ b/google-ads/tests/conftest.py @@ -0,0 +1,20 @@ +import sys +import os +from unittest.mock import AsyncMock, MagicMock + +import pytest + +# Allow imports to work when pytest runs from repo root. +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + + +@pytest.fixture +def mock_context(): + """Mock context with Google Ads platform OAuth credentials.""" + ctx = MagicMock(name="ExecutionContext") + ctx.fetch = AsyncMock(name="fetch") + ctx.auth = { + "auth_type": "PlatformOauth2", + "credentials": {"access_token": "test_access_token"}, # nosec B105 + } + return ctx diff --git a/google-ads/tests/context.py b/google-ads/tests/context.py deleted file mode 100644 index 4e97343e..00000000 --- a/google-ads/tests/context.py +++ /dev/null @@ -1,6 +0,0 @@ -# -*- coding: utf-8 -*- -import sys -import os - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../dependencies"))) diff --git a/google-ads/tests/test_google_ads.py b/google-ads/tests/test_google_ads.py deleted file mode 100644 index e5a9657a..00000000 --- a/google-ads/tests/test_google_ads.py +++ /dev/null @@ -1,282 +0,0 @@ -import asyncio -from pprint import pprint -from context import google_ads -from autohive_integrations_sdk import ExecutionContext - -# Test configuration - replace with your actual values -TEST_AUTH = { - "auth_type": "PlatformOAuth2", - "credentials": { - "access_token": "ACCESS_TOKEN", # nosec B105 - "refresh_token": "REFRESH_TOKEN", # nosec B105 - }, -} - -TEST_INPUTS = { - "login_customer_id": "LOGIN_CUSTOMER_ID", - "customer_id": "CUSTOMER_ID", -} - - -async def test_get_accessible_accounts(): - """Test retrieving accessible accounts.""" - context = ExecutionContext(auth=TEST_AUTH) - inputs = {} - - try: - result = await google_ads.execute_action("get_accessible_accounts", inputs, context) - print("\n=== Get Accessible Accounts Test ===") - pprint(result) - except Exception as e: - print(f"Error: {str(e)}") - import traceback - - traceback.print_exc() - - -async def test_retrieve_campaign_metrics(): - """Test retrieving campaign metrics.""" - context = ExecutionContext(auth=TEST_AUTH) - inputs = {**TEST_INPUTS, "date_ranges": ["2025-05-14_2025-05-20"]} - - try: - result = await google_ads.execute_action("retrieve_campaign_metrics", inputs, context) - print("\n=== Campaign Metrics Test ===") - pprint(result) - except Exception as e: - print(f"Error: {str(e)}") - import traceback - - traceback.print_exc() - - -async def test_retrieve_keyword_metrics(): - """Test retrieving keyword metrics.""" - context = ExecutionContext(auth=TEST_AUTH) - inputs = { - **TEST_INPUTS, - "ad_group_ids": ["123456789"], - "campaign_ids": ["111222333"], - "date_ranges": ["2025-05-14_2025-05-20"], - } - - try: - result = await google_ads.execute_action("retrieve_keyword_metrics", inputs, context) - print("\n=== Keyword Metrics Test ===") - pprint(result) - except Exception as e: - print(f"Error: {str(e)}") - import traceback - - traceback.print_exc() - - -async def test_create_campaign(): - """Test creating a new campaign.""" - context = ExecutionContext(auth=TEST_AUTH) - inputs = { - **TEST_INPUTS, - "campaign_name": "Test Campaign via API", - "budget_amount_micros": 10000000, # $10/day - "budget_name": "Test Budget", - "bidding_strategy": "MANUAL_CPC", - } - - try: - result = await google_ads.execute_action("create_campaign", inputs, context) - print("\n=== Create Campaign Test ===") - pprint(result) - return result - except Exception as e: - print(f"Error: {str(e)}") - import traceback - - traceback.print_exc() - - -async def test_update_campaign(campaign_id: str): - """Test updating an existing campaign.""" - context = ExecutionContext(auth=TEST_AUTH) - inputs = { - **TEST_INPUTS, - "campaign_id": campaign_id, - "status": "PAUSED", - "name": "Updated Campaign Name", - } - - try: - result = await google_ads.execute_action("update_campaign", inputs, context) - print("\n=== Update Campaign Test ===") - pprint(result) - except Exception as e: - print(f"Error: {str(e)}") - import traceback - - traceback.print_exc() - - -async def test_create_ad_group(campaign_id: str): - """Test creating a new ad group.""" - context = ExecutionContext(auth=TEST_AUTH) - inputs = { - **TEST_INPUTS, - "campaign_id": campaign_id, - "ad_group_name": "Test Ad Group", - "cpc_bid_micros": 500000, # $0.50 - "status": "PAUSED", - } - - try: - result = await google_ads.execute_action("create_ad_group", inputs, context) - print("\n=== Create Ad Group Test ===") - pprint(result) - return result - except Exception as e: - print(f"Error: {str(e)}") - import traceback - - traceback.print_exc() - - -async def test_create_responsive_search_ad(ad_group_id: str): - """Test creating a Responsive Search Ad.""" - context = ExecutionContext(auth=TEST_AUTH) - inputs = { - **TEST_INPUTS, - "ad_group_id": ad_group_id, - "headlines": ["Best Product Ever", "Amazing Quality", "Free Shipping Today"], - "descriptions": [ - "Shop now and save big on our amazing products.", - "Limited time offer. Don't miss out!", - ], - "final_url": "https://www.example.com", - "path1": "products", - "path2": "deals", - "status": "PAUSED", - } - - try: - result = await google_ads.execute_action("create_responsive_search_ad", inputs, context) - print("\n=== Create RSA Test ===") - pprint(result) - except Exception as e: - print(f"Error: {str(e)}") - import traceback - - traceback.print_exc() - - -async def test_add_keywords(ad_group_id: str): - """Test adding keywords to an ad group.""" - context = ExecutionContext(auth=TEST_AUTH) - inputs = { - **TEST_INPUTS, - "ad_group_id": ad_group_id, - "keywords": [ - {"text": "best product", "match_type": "BROAD"}, - {"text": "buy product online", "match_type": "PHRASE"}, - {"text": "product store", "match_type": "EXACT"}, - ], - } - - try: - result = await google_ads.execute_action("add_keywords", inputs, context) - print("\n=== Add Keywords Test ===") - pprint(result) - except Exception as e: - print(f"Error: {str(e)}") - import traceback - - traceback.print_exc() - - -async def test_generate_keyword_ideas(): - """Test generating keyword ideas via Keyword Planner.""" - context = ExecutionContext(auth=TEST_AUTH) - inputs = { - **TEST_INPUTS, - "seed_keywords": ["digital marketing", "seo services"], - "language_id": "1000", # English - "location_ids": ["2840"], # USA - "include_adult_keywords": False, - } - - try: - result = await google_ads.execute_action("generate_keyword_ideas", inputs, context) - print("\n=== Keyword Ideas Test ===") - pprint(result) - except Exception as e: - print(f"Error: {str(e)}") - import traceback - - traceback.print_exc() - - -async def test_generate_keyword_historical_metrics(): - """Test getting historical metrics for keywords.""" - context = ExecutionContext(auth=TEST_AUTH) - inputs = { - **TEST_INPUTS, - "keywords": ["digital marketing", "seo services", "ppc advertising"], - "language_id": "1000", - "location_ids": ["2840"], - } - - try: - result = await google_ads.execute_action("generate_keyword_historical_metrics", inputs, context) - print("\n=== Keyword Historical Metrics Test ===") - pprint(result) - except Exception as e: - print(f"Error: {str(e)}") - import traceback - - traceback.print_exc() - - -async def test_remove_campaign(campaign_id: str): - """Test removing a campaign.""" - context = ExecutionContext(auth=TEST_AUTH) - inputs = {**TEST_INPUTS, "campaign_id": campaign_id} - - try: - result = await google_ads.execute_action("remove_campaign", inputs, context) - print("\n=== Remove Campaign Test ===") - pprint(result) - except Exception as e: - print(f"Error: {str(e)}") - import traceback - - traceback.print_exc() - - -async def main(): - print("=" * 50) - print("Google Ads Integration Tests") - print("=" * 50) - - # Read operations - await test_get_accessible_accounts() - await test_retrieve_campaign_metrics() - await test_retrieve_keyword_metrics() - - # Keyword Planner operations - await test_generate_keyword_ideas() - await test_generate_keyword_historical_metrics() - - # CRUD operations (uncomment to test - will create real resources) - # campaign_result = await test_create_campaign() - # if campaign_result: - # campaign_id = campaign_result.result.data.get('campaign_id') - # await test_update_campaign(campaign_id) - # - # ad_group_result = await test_create_ad_group(campaign_id) - # if ad_group_result: - # ad_group_id = ad_group_result.result.data.get('ad_group_id') - # await test_create_responsive_search_ad(ad_group_id) - # await test_add_keywords(ad_group_id) - # - # await test_remove_campaign(campaign_id) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/google-ads/tests/test_google_ads_accounts_unit.py b/google-ads/tests/test_google_ads_accounts_unit.py new file mode 100644 index 00000000..687c55c7 --- /dev/null +++ b/google-ads/tests/test_google_ads_accounts_unit.py @@ -0,0 +1,162 @@ +import os +import sys +import importlib + +os.environ.setdefault("ADWORDS_DEVELOPER_TOKEN", "test_developer_token") # nosec B105 +os.environ.setdefault("ADWORDS_CLIENT_ID", "test_client_id") # nosec B105 +os.environ.setdefault("ADWORDS_CLIENT_SECRET", "test_client_secret") # nosec B105 + +_parent = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +_deps = os.path.abspath(os.path.join(os.path.dirname(__file__), "../dependencies")) +sys.path.insert(0, _parent) +sys.path.insert(0, _deps) + +import pytest # noqa: E402 +from unittest.mock import AsyncMock, MagicMock, patch # noqa: E402 +from autohive_integrations_sdk.integration import ResultType # noqa: E402 + +_spec = importlib.util.spec_from_file_location("google_ads_mod", os.path.join(_parent, "google_ads.py")) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + +google_ads = _mod.google_ads + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def mock_context(): + ctx = MagicMock(name="ExecutionContext") + ctx.fetch = AsyncMock(name="fetch") + ctx.auth = {"auth_type": "PlatformOauth2", "credentials": {"access_token": "test_access_token"}} # nosec B105 + return ctx + + +@pytest.fixture +def mock_gads_client(): + with patch.object(_mod, "_get_google_ads_client") as mock_factory: + client = MagicMock(name="GoogleAdsClient") + mock_factory.return_value = client + yield client + + +def _make_list_response(resource_names): + mock_response = MagicMock() + mock_response.resource_names = resource_names + return mock_response + + +# --------------------------------------------------------------------------- +# get_accessible_accounts +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_missing_access_token(mock_context): + """Missing access token returns ActionError with descriptive message.""" + mock_context.auth = {} + + result = await google_ads.execute_action("get_accessible_accounts", {}, mock_context) + + assert result.type == ResultType.ACTION_ERROR + assert "access token" in result.result.message.lower() + + +@pytest.mark.asyncio +async def test_returns_accounts_list(mock_context, mock_gads_client): + """Valid credentials with 2 resource names returns ActionResult with accounts key.""" + mock_response = _make_list_response(["customers/111", "customers/222"]) + mock_gads_client.get_service.return_value.list_accessible_customers.return_value = mock_response + mock_gads_client.get_service.return_value.search.return_value = [] + + result = await google_ads.execute_action("get_accessible_accounts", {}, mock_context) + + assert result.type == ResultType.ACTION + assert "accounts" in result.result.data + assert len(result.result.data["accounts"]) == 2 + + +@pytest.mark.asyncio +async def test_accounts_have_expected_fields(mock_context, mock_gads_client): + """Each account entry exposes the four required fields.""" + mock_response = _make_list_response(["customers/111", "customers/222"]) + mock_gads_client.get_service.return_value.list_accessible_customers.return_value = mock_response + mock_gads_client.get_service.return_value.search.return_value = [] + + result = await google_ads.execute_action("get_accessible_accounts", {}, mock_context) + + for account in result.result.data["accounts"]: + assert "resource_name" in account + assert "customer_id" in account + assert "descriptive_name" in account + assert "currency_code" in account + + +@pytest.mark.asyncio +async def test_accounts_customer_id_parsed_correctly(mock_context, mock_gads_client): + """customer_id is the numeric portion of the resource name.""" + mock_response = _make_list_response(["customers/987654321"]) + mock_gads_client.get_service.return_value.list_accessible_customers.return_value = mock_response + mock_gads_client.get_service.return_value.search.return_value = [] + + result = await google_ads.execute_action("get_accessible_accounts", {}, mock_context) + + account = result.result.data["accounts"][0] + assert account["customer_id"] == "987654321" + assert account["resource_name"] == "customers/987654321" + + +@pytest.mark.asyncio +async def test_api_error_returns_action_error(mock_context, mock_gads_client): + """If list_accessible_customers raises, the action returns ActionError.""" + mock_gads_client.get_service.return_value.list_accessible_customers.side_effect = Exception("API unavailable") + + result = await google_ads.execute_action("get_accessible_accounts", {}, mock_context) + + assert result.type == ResultType.ACTION_ERROR + assert "API unavailable" in result.result.message + + +@pytest.mark.asyncio +async def test_empty_accounts(mock_context, mock_gads_client): + """No accessible customers returns ActionResult with empty accounts list.""" + mock_response = _make_list_response([]) + mock_gads_client.get_service.return_value.list_accessible_customers.return_value = mock_response + mock_gads_client.get_service.return_value.search.return_value = [] + + result = await google_ads.execute_action("get_accessible_accounts", {}, mock_context) + + assert result.type == ResultType.ACTION + assert result.result.data["accounts"] == [] + + +@pytest.mark.asyncio +async def test_detail_fetch_failure_still_returns_account(mock_context, mock_gads_client): + """If the per-account detail query fails, the account is still included with default values.""" + mock_response = _make_list_response(["customers/555"]) + service_mock = MagicMock() + service_mock.list_accessible_customers.return_value = mock_response + service_mock.search.side_effect = Exception("Permission denied") + mock_gads_client.get_service.return_value = service_mock + + result = await google_ads.execute_action("get_accessible_accounts", {}, mock_context) + + assert result.type == ResultType.ACTION + assert len(result.result.data["accounts"]) == 1 + account = result.result.data["accounts"][0] + assert account["customer_id"] == "555" + assert account["descriptive_name"] == "Unknown" + assert account["currency_code"] == "N/A" + + +@pytest.mark.asyncio +async def test_cost_usd_is_zero(mock_context, mock_gads_client): + """Successful response carries cost_usd of 0.00.""" + mock_response = _make_list_response(["customers/111"]) + mock_gads_client.get_service.return_value.list_accessible_customers.return_value = mock_response + mock_gads_client.get_service.return_value.search.return_value = [] + + result = await google_ads.execute_action("get_accessible_accounts", {}, mock_context) + + assert result.type == ResultType.ACTION + assert result.result.cost_usd == 0.00 diff --git a/google-ads/tests/test_google_ads_ad_groups_unit.py b/google-ads/tests/test_google_ads_ad_groups_unit.py new file mode 100644 index 00000000..ef9178f4 --- /dev/null +++ b/google-ads/tests/test_google_ads_ad_groups_unit.py @@ -0,0 +1,340 @@ +import os +import sys +import importlib + +os.environ.setdefault("ADWORDS_DEVELOPER_TOKEN", "test_developer_token") # nosec B105 +os.environ.setdefault("ADWORDS_CLIENT_ID", "test_client_id") # nosec B105 +os.environ.setdefault("ADWORDS_CLIENT_SECRET", "test_client_secret") # nosec B105 + +_parent = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +_deps = os.path.abspath(os.path.join(os.path.dirname(__file__), "../dependencies")) +sys.path.insert(0, _parent) +sys.path.insert(0, _deps) + +import pytest # noqa: E402 +from unittest.mock import AsyncMock, MagicMock, patch # noqa: E402 +from autohive_integrations_sdk.integration import ResultType # noqa: E402 + +_spec = importlib.util.spec_from_file_location("google_ads_mod", os.path.join(_parent, "google_ads.py")) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + +google_ads = _mod.google_ads + +pytestmark = pytest.mark.unit + +BASE_INPUTS = {"login_customer_id": "1234567890", "customer_id": "9876543210"} + + +@pytest.fixture +def mock_context(): + ctx = MagicMock(name="ExecutionContext") + ctx.fetch = AsyncMock(name="fetch") + ctx.auth = {"auth_type": "PlatformOauth2", "credentials": {"access_token": "test_access_token"}} # nosec B105 + return ctx + + +@pytest.fixture +def mock_gads_client(): + with patch.object(_mod, "_get_google_ads_client") as mock_factory: + client = MagicMock(name="GoogleAdsClient") + mock_factory.return_value = client + yield client + + +# --------------------------------------------------------------------------- +# retrieve_ad_group_metrics +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_retrieve_ad_group_metrics_missing_date_ranges(mock_context, mock_gads_client): + result = await google_ads.execute_action("retrieve_ad_group_metrics", {**BASE_INPUTS}, mock_context) + assert result.type != ResultType.ACTION + assert "date_ranges" in str(result.result) + + +@pytest.mark.asyncio +async def test_retrieve_ad_group_metrics_auth_error(mock_context): + mock_context.auth = {"credentials": {}} + result = await google_ads.execute_action( + "retrieve_ad_group_metrics", + {**BASE_INPUTS, "date_ranges": ["2025-05-14_2025-05-20"]}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + + +@pytest.mark.asyncio +async def test_retrieve_ad_group_metrics_empty_results(mock_context, mock_gads_client): + mock_gads_client.get_service.return_value.search.return_value = [] + result = await google_ads.execute_action( + "retrieve_ad_group_metrics", + {**BASE_INPUTS, "date_ranges": ["2025-05-14_2025-05-20"]}, + mock_context, + ) + assert result.type == ResultType.ACTION + assert "results" in result.result.data + assert isinstance(result.result.data["results"], list) + + +@pytest.mark.asyncio +async def test_retrieve_ad_group_metrics_returns_data(mock_context, mock_gads_client): + row = MagicMock() + mock_gads_client.get_service.return_value.search.return_value = [row] + + row_data = { + "ad_group": { + "id": "111", + "name": "Test AG", + "status": "ENABLED", + "type": "SEARCH_STANDARD", + "cpc_bid_micros": 500000, + }, + "campaign": {"id": "222", "name": "Test Campaign", "status": "ENABLED"}, + "metrics": { + "impressions": 100, + "clicks": 10, + "ctr": 0.1, + "average_cpc": 500000, + "cost_micros": 5000000, + "conversions": 1.0, + "conversions_value": 10.0, + "cost_per_conversion": 5000000, + "all_conversions": 1.0, + "interaction_rate": 0.1, + }, + } + + with patch("proto.Message.to_dict", return_value=row_data): + result = await google_ads.execute_action( + "retrieve_ad_group_metrics", + {**BASE_INPUTS, "date_ranges": ["2025-05-14_2025-05-20"]}, + mock_context, + ) + + assert result.type == ResultType.ACTION + assert len(result.result.data["results"]) == 1 + assert result.result.data["results"][0]["data"][0]["ad_group_id"] == "111" + + +@pytest.mark.asyncio +async def test_retrieve_ad_group_metrics_api_error(mock_context, mock_gads_client): + mock_gads_client.get_service.return_value.search.side_effect = Exception("API failure") + result = await google_ads.execute_action( + "retrieve_ad_group_metrics", + {**BASE_INPUTS, "date_ranges": ["2025-05-14_2025-05-20"]}, + mock_context, + ) + assert result.type != ResultType.ACTION + assert "API failure" in str(result.result) + + +# --------------------------------------------------------------------------- +# create_ad_group +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_ad_group_success(mock_context, mock_gads_client): + mock_service = mock_gads_client.get_service.return_value + result_mock = MagicMock() + result_mock.resource_name = "customers/123/adGroups/456" + mock_service.mutate_ad_groups.return_value.results = [result_mock] + mock_gads_client.get_type.return_value = MagicMock() + mock_gads_client.enums.AdGroupTypeEnum.SEARCH_STANDARD = "SEARCH_STANDARD" + mock_gads_client.enums.AdGroupStatusEnum.PAUSED = "PAUSED" + + result = await google_ads.execute_action( + "create_ad_group", + {**BASE_INPUTS, "campaign_id": "111", "ad_group_name": "My Ad Group"}, + mock_context, + ) + + assert result.type == ResultType.ACTION + assert result.result.data["ad_group_id"] == "456" + + +@pytest.mark.asyncio +async def test_create_ad_group_missing_campaign_id(mock_context, mock_gads_client): + result = await google_ads.execute_action( + "create_ad_group", + {**BASE_INPUTS, "ad_group_name": "My Ad Group"}, + mock_context, + ) + assert result.type != ResultType.ACTION + assert "campaign_id" in str(result.result) + + +@pytest.mark.asyncio +async def test_create_ad_group_missing_ad_group_name(mock_context, mock_gads_client): + result = await google_ads.execute_action( + "create_ad_group", + {**BASE_INPUTS, "campaign_id": "111"}, + mock_context, + ) + assert result.type != ResultType.ACTION + + +@pytest.mark.asyncio +async def test_create_ad_group_auth_error(mock_context): + mock_context.auth = {"credentials": {}} + result = await google_ads.execute_action( + "create_ad_group", + {**BASE_INPUTS, "campaign_id": "111", "ad_group_name": "My Ad Group"}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + + +@pytest.mark.asyncio +async def test_create_ad_group_api_error(mock_context, mock_gads_client): + mock_gads_client.get_service.return_value.mutate_ad_groups.side_effect = Exception("Quota exceeded") + mock_gads_client.get_type.return_value = MagicMock() + + result = await google_ads.execute_action( + "create_ad_group", + {**BASE_INPUTS, "campaign_id": "111", "ad_group_name": "My Ad Group"}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + assert "Quota exceeded" in result.result.message + + +# --------------------------------------------------------------------------- +# update_ad_group +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_update_ad_group_success(mock_context, mock_gads_client): + mock_service = mock_gads_client.get_service.return_value + result_mock = MagicMock() + result_mock.resource_name = "customers/123/adGroups/789" + mock_service.mutate_ad_groups.return_value.results = [result_mock] + mock_gads_client.get_type.return_value = MagicMock() + mock_gads_client.enums.AdGroupStatusEnum.PAUSED = "PAUSED" + + result = await google_ads.execute_action( + "update_ad_group", + {**BASE_INPUTS, "ad_group_id": "789", "status": "PAUSED"}, + mock_context, + ) + + assert result.type == ResultType.ACTION + assert result.result.data["ad_group_id"] == "789" + + +@pytest.mark.asyncio +async def test_update_ad_group_missing_ad_group_id(mock_context, mock_gads_client): + result = await google_ads.execute_action("update_ad_group", {**BASE_INPUTS}, mock_context) + assert result.type != ResultType.ACTION + assert "ad_group_id" in str(result.result) + + +@pytest.mark.asyncio +async def test_update_ad_group_auth_error(mock_context): + mock_context.auth = {"credentials": {}} + result = await google_ads.execute_action( + "update_ad_group", + {**BASE_INPUTS, "ad_group_id": "789", "status": "PAUSED"}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + + +@pytest.mark.asyncio +async def test_update_ad_group_api_error(mock_context, mock_gads_client): + mock_gads_client.get_service.return_value.mutate_ad_groups.side_effect = Exception("Permission denied") + mock_gads_client.get_type.return_value = MagicMock() + + result = await google_ads.execute_action( + "update_ad_group", + {**BASE_INPUTS, "ad_group_id": "789", "status": "PAUSED"}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + assert "Permission denied" in result.result.message + + +@pytest.mark.asyncio +async def test_update_ad_group_with_field_mask(mock_context, mock_gads_client): + """Ensure field mask is applied when updating multiple fields.""" + mock_service = mock_gads_client.get_service.return_value + result_mock = MagicMock() + result_mock.resource_name = "customers/123/adGroups/789" + mock_service.mutate_ad_groups.return_value.results = [result_mock] + ad_group_op = MagicMock() + mock_gads_client.get_type.return_value = ad_group_op + mock_gads_client.enums.AdGroupStatusEnum.ENABLED = "ENABLED" + + result = await google_ads.execute_action( + "update_ad_group", + { + **BASE_INPUTS, + "ad_group_id": "789", + "status": "ENABLED", + "name": "Renamed Group", + "cpc_bid_micros": 2000000, + }, + mock_context, + ) + + assert result.type == ResultType.ACTION + mock_gads_client.copy_from.assert_called() + + +# --------------------------------------------------------------------------- +# remove_ad_group +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_remove_ad_group_success(mock_context, mock_gads_client): + mock_service = mock_gads_client.get_service.return_value + result_mock = MagicMock() + result_mock.resource_name = "customers/123/adGroups/789" + mock_service.mutate_ad_groups.return_value.results = [result_mock] + mock_gads_client.get_type.return_value = MagicMock() + + result = await google_ads.execute_action( + "remove_ad_group", + {**BASE_INPUTS, "ad_group_id": "789"}, + mock_context, + ) + + assert result.type == ResultType.ACTION + assert result.result.data["status"] == "REMOVED" + assert result.result.data["ad_group_id"] == "789" + + +@pytest.mark.asyncio +async def test_remove_ad_group_missing_ad_group_id(mock_context, mock_gads_client): + result = await google_ads.execute_action("remove_ad_group", {**BASE_INPUTS}, mock_context) + assert result.type != ResultType.ACTION + assert "ad_group_id" in str(result.result) + + +@pytest.mark.asyncio +async def test_remove_ad_group_auth_error(mock_context): + mock_context.auth = {"credentials": {}} + result = await google_ads.execute_action( + "remove_ad_group", + {**BASE_INPUTS, "ad_group_id": "789"}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + + +@pytest.mark.asyncio +async def test_remove_ad_group_api_error(mock_context, mock_gads_client): + mock_gads_client.get_service.return_value.mutate_ad_groups.side_effect = Exception("Not found") + mock_gads_client.get_type.return_value = MagicMock() + + result = await google_ads.execute_action( + "remove_ad_group", + {**BASE_INPUTS, "ad_group_id": "789"}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + assert "Not found" in result.result.message diff --git a/google-ads/tests/test_google_ads_ads_unit.py b/google-ads/tests/test_google_ads_ads_unit.py new file mode 100644 index 00000000..dfe4b973 --- /dev/null +++ b/google-ads/tests/test_google_ads_ads_unit.py @@ -0,0 +1,385 @@ +import os +import sys +import importlib + +os.environ.setdefault("ADWORDS_DEVELOPER_TOKEN", "test_developer_token") # nosec B105 +os.environ.setdefault("ADWORDS_CLIENT_ID", "test_client_id") # nosec B105 +os.environ.setdefault("ADWORDS_CLIENT_SECRET", "test_client_secret") # nosec B105 + +_parent = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +_deps = os.path.abspath(os.path.join(os.path.dirname(__file__), "../dependencies")) +sys.path.insert(0, _parent) +sys.path.insert(0, _deps) + +import pytest # noqa: E402 +from unittest.mock import AsyncMock, MagicMock, patch # noqa: E402 +from autohive_integrations_sdk.integration import ResultType # noqa: E402 + +_spec = importlib.util.spec_from_file_location("google_ads_mod", os.path.join(_parent, "google_ads.py")) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + +google_ads = _mod.google_ads + +pytestmark = pytest.mark.unit + +BASE_INPUTS = {"login_customer_id": "1234567890", "customer_id": "9876543210"} + + +@pytest.fixture +def mock_context(): + ctx = MagicMock(name="ExecutionContext") + ctx.fetch = AsyncMock(name="fetch") + ctx.auth = {"auth_type": "PlatformOauth2", "credentials": {"access_token": "test_access_token"}} # nosec B105 + return ctx + + +@pytest.fixture +def mock_context_no_token(): + ctx = MagicMock(name="ExecutionContext") + ctx.fetch = AsyncMock(name="fetch") + ctx.auth = {"credentials": {}} + return ctx + + +@pytest.fixture +def mock_gads_client(): + with patch.object(_mod, "_get_google_ads_client") as mock_factory: + client = MagicMock(name="GoogleAdsClient") + mock_factory.return_value = client + yield client + + +_AD_PROTO_ROW = { + "ad_group_ad": { + "status": "ENABLED", + "ad": { + "id": "789", + "name": "Test Ad", + "type": "RESPONSIVE_SEARCH_AD", + "final_urls": ["https://example.com"], + "responsive_search_ad": { + "headlines": [{"text": "Headline 1"}, {"text": "Headline 2"}], + "descriptions": [{"text": "Description 1"}], + }, + }, + }, + "ad_group": {"id": "456", "name": "Test Ad Group"}, + "campaign": {"id": "123", "name": "Test Campaign"}, + "metrics": { + "impressions": 1000, + "clicks": 50, + "ctr": 0.05, + "average_cpc": 500000, + "cost_micros": 25000000, + "conversions": 5.0, + "conversions_value": 100.0, + "cost_per_conversion": 5000000, + }, +} + + +# =========================================================================== +# 1. retrieve_ad_metrics +# =========================================================================== + + +class TestRetrieveAdMetrics: + @pytest.mark.asyncio + async def test_missing_date_ranges(self, mock_context, mock_gads_client): + result = await google_ads.execute_action("retrieve_ad_metrics", {**BASE_INPUTS}, mock_context) + assert result.type != ResultType.ACTION + assert "date_ranges" in str(result.result) + + @pytest.mark.asyncio + async def test_returns_empty_results(self, mock_context, mock_gads_client): + mock_gads_client.get_service.return_value.search.return_value = [] + + result = await google_ads.execute_action( + "retrieve_ad_metrics", + {**BASE_INPUTS, "date_ranges": ["2025-05-14_2025-05-20"]}, + mock_context, + ) + assert result.type == ResultType.ACTION + assert "results" in result.result.data + assert len(result.result.data["results"]) == 1 + assert result.result.data["results"][0]["data"] == [] + + @pytest.mark.asyncio + async def test_auth_error(self, mock_context_no_token): + result = await google_ads.execute_action( + "retrieve_ad_metrics", + {**BASE_INPUTS, "date_ranges": ["2025-05-14_2025-05-20"]}, + mock_context_no_token, + ) + assert result.type == ResultType.ACTION_ERROR + + @pytest.mark.asyncio + async def test_api_error(self, mock_context, mock_gads_client): + mock_gads_client.get_service.return_value.search.side_effect = Exception("API failure") + + result = await google_ads.execute_action( + "retrieve_ad_metrics", + {**BASE_INPUTS, "date_ranges": ["2025-05-14_2025-05-20"]}, + mock_context, + ) + assert result.type != ResultType.ACTION + assert "API failure" in str(result.result) + + @pytest.mark.asyncio + async def test_returns_ad_data_from_proto_rows(self, mock_context, mock_gads_client): + mock_row = MagicMock() + mock_gads_client.get_service.return_value.search.return_value = [mock_row] + + with patch("proto.Message.to_dict") as mock_to_dict: + mock_to_dict.return_value = _AD_PROTO_ROW + + result = await google_ads.execute_action( + "retrieve_ad_metrics", + {**BASE_INPUTS, "date_ranges": ["2025-05-14_2025-05-20"]}, + mock_context, + ) + + assert result.type == ResultType.ACTION + assert len(result.result.data["results"]) == 1 + assert len(result.result.data["results"][0]["data"]) == 1 + ad_entry = result.result.data["results"][0]["data"][0] + assert ad_entry["ad_id"] == "789" + assert ad_entry["ad_name"] == "Test Ad" + assert ad_entry["cost"] == 25.0 + + +# =========================================================================== +# 2. create_responsive_search_ad +# =========================================================================== + + +def _setup_create_rsa_mocks(mock_gads_client): + mock_service = mock_gads_client.get_service.return_value + + result_mock = MagicMock() + result_mock.resource_name = "customers/123/adGroupAds/456~789" + mock_service.mutate_ad_group_ads.return_value.results = [result_mock] + + mock_gads_client.get_type.return_value = MagicMock() + mock_gads_client.enums.AdGroupAdStatusEnum.PAUSED = "PAUSED" + + return mock_service + + +_RSA_BASE_INPUTS = { + **BASE_INPUTS, + "ad_group_id": "456", + "headlines": ["Headline One", "Headline Two", "Headline Three"], + "descriptions": ["Description one here.", "Description two here."], + "final_url": "https://example.com", +} + + +class TestCreateResponsiveSearchAd: + @pytest.mark.asyncio + async def test_missing_required_fields(self, mock_context, mock_gads_client): + result = await google_ads.execute_action( + "create_responsive_search_ad", + { + **BASE_INPUTS, + "headlines": ["H1", "H2", "H3"], + "descriptions": ["D1", "D2"], + "final_url": "https://example.com", + }, + mock_context, + ) + assert result.type != ResultType.ACTION + + @pytest.mark.asyncio + async def test_too_few_headlines(self, mock_context, mock_gads_client): + result = await google_ads.execute_action( + "create_responsive_search_ad", + { + **BASE_INPUTS, + "ad_group_id": "456", + "headlines": ["Only One", "Only Two"], + "descriptions": ["Desc one.", "Desc two."], + "final_url": "https://example.com", + }, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + assert "3" in result.result.message or "headlines" in result.result.message.lower() + + @pytest.mark.asyncio + async def test_too_few_descriptions(self, mock_context, mock_gads_client): + result = await google_ads.execute_action( + "create_responsive_search_ad", + { + **BASE_INPUTS, + "ad_group_id": "456", + "headlines": ["Headline One", "Headline Two", "Headline Three"], + "descriptions": ["Only one description."], + "final_url": "https://example.com", + }, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + assert "2" in result.result.message or "descriptions" in result.result.message.lower() + + @pytest.mark.asyncio + async def test_creates_ad_successfully(self, mock_context, mock_gads_client): + _setup_create_rsa_mocks(mock_gads_client) + + result = await google_ads.execute_action( + "create_responsive_search_ad", + _RSA_BASE_INPUTS, + mock_context, + ) + assert result.type == ResultType.ACTION + assert "ad_id" in result.result.data + assert result.result.data["ad_id"] == "789" + + @pytest.mark.asyncio + async def test_auth_error(self, mock_context_no_token): + result = await google_ads.execute_action( + "create_responsive_search_ad", + _RSA_BASE_INPUTS, + mock_context_no_token, + ) + assert result.type == ResultType.ACTION_ERROR + + @pytest.mark.asyncio + async def test_api_error(self, mock_context, mock_gads_client): + _setup_create_rsa_mocks(mock_gads_client) + mock_gads_client.get_service.return_value.mutate_ad_group_ads.side_effect = Exception("mutate failed") + + result = await google_ads.execute_action( + "create_responsive_search_ad", + _RSA_BASE_INPUTS, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + + +# =========================================================================== +# 3. update_ad +# =========================================================================== + + +def _setup_update_ad_mocks(mock_gads_client): + mock_service = mock_gads_client.get_service.return_value + + update_result = MagicMock() + update_result.resource_name = "customers/9876543210/adGroupAds/456~789" + mock_service.mutate_ad_group_ads.return_value.results = [update_result] + mock_service.ad_group_ad_path.return_value = "customers/9876543210/adGroupAds/456~789" + + mock_gads_client.get_type.return_value = MagicMock() + mock_gads_client.enums.AdGroupAdStatusEnum.ENABLED = "ENABLED" + mock_gads_client.enums.AdGroupAdStatusEnum.PAUSED = "PAUSED" + + return mock_service + + +class TestUpdateAd: + @pytest.mark.asyncio + async def test_missing_ad_group_id(self, mock_context, mock_gads_client): + result = await google_ads.execute_action( + "update_ad", + {**BASE_INPUTS, "ad_id": "789", "status": "ENABLED"}, + mock_context, + ) + assert result.type != ResultType.ACTION + + @pytest.mark.asyncio + async def test_missing_ad_id(self, mock_context, mock_gads_client): + result = await google_ads.execute_action( + "update_ad", + {**BASE_INPUTS, "ad_group_id": "456", "status": "ENABLED"}, + mock_context, + ) + assert result.type != ResultType.ACTION + + @pytest.mark.asyncio + async def test_updates_ad_successfully(self, mock_context, mock_gads_client): + _setup_update_ad_mocks(mock_gads_client) + + result = await google_ads.execute_action( + "update_ad", + {**BASE_INPUTS, "ad_group_id": "456", "ad_id": "789", "status": "ENABLED"}, + mock_context, + ) + assert result.type == ResultType.ACTION + assert "ad_resource_name" in result.result.data + assert result.result.data["ad_id"] == "789" + assert result.result.data["status"] == "ENABLED" + + @pytest.mark.asyncio + async def test_api_error(self, mock_context, mock_gads_client): + _setup_update_ad_mocks(mock_gads_client) + mock_gads_client.get_service.return_value.mutate_ad_group_ads.side_effect = Exception("update failed") + + result = await google_ads.execute_action( + "update_ad", + {**BASE_INPUTS, "ad_group_id": "456", "ad_id": "789", "status": "PAUSED"}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + + @pytest.mark.asyncio + async def test_auth_error(self, mock_context_no_token): + result = await google_ads.execute_action( + "update_ad", + {**BASE_INPUTS, "ad_group_id": "456", "ad_id": "789", "status": "ENABLED"}, + mock_context_no_token, + ) + assert result.type == ResultType.ACTION_ERROR + + +# =========================================================================== +# 4. remove_ad +# =========================================================================== + + +def _setup_remove_ad_mocks(mock_gads_client): + mock_service = mock_gads_client.get_service.return_value + + remove_result = MagicMock() + remove_result.resource_name = "customers/9876543210/adGroupAds/456~789" + mock_service.mutate_ad_group_ads.return_value.results = [remove_result] + mock_service.ad_group_ad_path.return_value = "customers/9876543210/adGroupAds/456~789" + + mock_gads_client.get_type.return_value = MagicMock() + + return mock_service + + +class TestRemoveAd: + @pytest.mark.asyncio + async def test_missing_ids(self, mock_context, mock_gads_client): + result = await google_ads.execute_action("remove_ad", {**BASE_INPUTS}, mock_context) + assert result.type != ResultType.ACTION + + @pytest.mark.asyncio + async def test_removes_ad_successfully(self, mock_context, mock_gads_client): + _setup_remove_ad_mocks(mock_gads_client) + + result = await google_ads.execute_action( + "remove_ad", + {**BASE_INPUTS, "ad_group_id": "456", "ad_id": "789"}, + mock_context, + ) + assert result.type == ResultType.ACTION + assert result.result.data["status"] == "REMOVED" + assert "removed_ad_resource_name" in result.result.data + assert result.result.data["ad_id"] == "789" + + @pytest.mark.asyncio + async def test_api_error(self, mock_context, mock_gads_client): + _setup_remove_ad_mocks(mock_gads_client) + mock_gads_client.get_service.return_value.mutate_ad_group_ads.side_effect = Exception("remove failed") + + result = await google_ads.execute_action( + "remove_ad", + {**BASE_INPUTS, "ad_group_id": "456", "ad_id": "789"}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + assert "remove failed" in result.result.message diff --git a/google-ads/tests/test_google_ads_campaigns_unit.py b/google-ads/tests/test_google_ads_campaigns_unit.py new file mode 100644 index 00000000..aed90cf9 --- /dev/null +++ b/google-ads/tests/test_google_ads_campaigns_unit.py @@ -0,0 +1,486 @@ +import os +import sys +import importlib + +os.environ.setdefault("ADWORDS_DEVELOPER_TOKEN", "test_developer_token") # nosec B105 +os.environ.setdefault("ADWORDS_CLIENT_ID", "test_client_id") # nosec B105 +os.environ.setdefault("ADWORDS_CLIENT_SECRET", "test_client_secret") # nosec B105 + +_parent = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +_deps = os.path.abspath(os.path.join(os.path.dirname(__file__), "../dependencies")) +sys.path.insert(0, _parent) +sys.path.insert(0, _deps) + +import pytest # noqa: E402 +from unittest.mock import AsyncMock, MagicMock, patch # noqa: E402 +from autohive_integrations_sdk.integration import ResultType # noqa: E402 + +_spec = importlib.util.spec_from_file_location("google_ads_mod", os.path.join(_parent, "google_ads.py")) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + +google_ads = _mod.google_ads + +pytestmark = pytest.mark.unit + +BASE_INPUTS = {"login_customer_id": "1234567890", "customer_id": "9876543210"} + + +@pytest.fixture +def mock_context(): + ctx = MagicMock(name="ExecutionContext") + ctx.fetch = AsyncMock(name="fetch") + ctx.auth = {"auth_type": "PlatformOauth2", "credentials": {"access_token": "test_access_token"}} # nosec B105 + return ctx + + +@pytest.fixture +def mock_context_no_token(): + ctx = MagicMock(name="ExecutionContext") + ctx.fetch = AsyncMock(name="fetch") + ctx.auth = {"credentials": {}} + return ctx + + +@pytest.fixture +def mock_gads_client(): + with patch.object(_mod, "_get_google_ads_client") as mock_factory: + client = MagicMock(name="GoogleAdsClient") + mock_factory.return_value = client + yield client + + +_PROTO_TO_DICT_RETURN = { + "campaign": { + "id": "123", + "name": "Test", + "status": "ENABLED", + "advertising_channel_type": "SEARCH", + "bidding_strategy_type": "TARGET_SPEND", + "optimization_score": 0.9, + }, + "metrics": { + "interactions": "100", + "interaction_rate": 0.1, + "average_cost": 1000000, + "cost_micros": 5000000, + "impressions": "1000", + "clicks": "100", + "conversions_value": 10.0, + "all_conversions": 5.0, + "average_cpc": 50000, + "cost_per_conversion": 1000000, + }, + "customer": {"currency_code": "USD", "descriptive_name": "Test Account"}, + "campaign_budget": {"amount_micros": 10000000}, +} + + +# =========================================================================== +# 1. retrieve_campaign_metrics +# =========================================================================== + + +class TestRetrieveCampaignMetrics: + @pytest.mark.asyncio + async def test_missing_date_ranges(self, mock_context, mock_gads_client): + """Omitting date_ranges must return an ActionError mentioning date_ranges.""" + result = await google_ads.execute_action( + "retrieve_campaign_metrics", + {**BASE_INPUTS}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + assert "date_ranges" in result.result.message + + @pytest.mark.asyncio + async def test_returns_empty_results_for_no_campaigns(self, mock_context, mock_gads_client): + """When the API returns no rows the results list should contain an entry with empty data.""" + mock_gads_client.get_service.return_value.search.return_value = [] + + result = await google_ads.execute_action( + "retrieve_campaign_metrics", + {**BASE_INPUTS, "date_ranges": ["2025-05-14_2025-05-20"]}, + mock_context, + ) + assert result.type == ResultType.ACTION + assert "results" in result.result.data + assert len(result.result.data["results"]) == 1 + assert result.result.data["results"][0]["data"] == [] + + @pytest.mark.asyncio + async def test_returns_campaign_data(self, mock_context, mock_gads_client): + """A single API row should surface as a campaign entry in the results.""" + mock_row = MagicMock() + mock_gads_client.get_service.return_value.search.return_value = [mock_row] + + with patch("proto.Message.to_dict") as mock_to_dict: + mock_to_dict.return_value = _PROTO_TO_DICT_RETURN + + result = await google_ads.execute_action( + "retrieve_campaign_metrics", + {**BASE_INPUTS, "date_ranges": ["2025-05-14_2025-05-20"]}, + mock_context, + ) + + assert result.type == ResultType.ACTION + assert len(result.result.data["results"]) == 1 + assert len(result.result.data["results"][0]["data"]) == 1 + campaign_entry = result.result.data["results"][0]["data"][0] + assert campaign_entry["Campaign ID"] == "123" + assert campaign_entry["Campaign"] == "Test" + + @pytest.mark.asyncio + async def test_campaign_metrics_cost_conversion(self, mock_context, mock_gads_client): + """cost_micros of 5_000_000 should convert to Cost == 5.0.""" + mock_row = MagicMock() + mock_gads_client.get_service.return_value.search.return_value = [mock_row] + + with patch("proto.Message.to_dict") as mock_to_dict: + mock_to_dict.return_value = _PROTO_TO_DICT_RETURN + + result = await google_ads.execute_action( + "retrieve_campaign_metrics", + {**BASE_INPUTS, "date_ranges": ["2025-05-14_2025-05-20"]}, + mock_context, + ) + + assert result.type == ResultType.ACTION + entry = result.result.data["results"][0]["data"][0] + assert entry["Cost"] == 5.0 + + @pytest.mark.asyncio + async def test_auth_error(self, mock_context_no_token): + """Missing refresh_token must return an ActionError.""" + result = await google_ads.execute_action( + "retrieve_campaign_metrics", + {**BASE_INPUTS, "date_ranges": ["2025-05-14_2025-05-20"]}, + mock_context_no_token, + ) + assert result.type == ResultType.ACTION_ERROR + + @pytest.mark.asyncio + async def test_api_error(self, mock_context, mock_gads_client): + """An exception raised by the API search is caught per-date-range; the action + still returns ActionResult, but the date-range entry includes an 'error' key.""" + mock_gads_client.get_service.return_value.search.side_effect = Exception("API failure") + + result = await google_ads.execute_action( + "retrieve_campaign_metrics", + {**BASE_INPUTS, "date_ranges": ["2025-05-14_2025-05-20"]}, + mock_context, + ) + assert result.type == ResultType.ACTION + assert "error" in result.result.data["results"][0] + + @pytest.mark.asyncio + async def test_missing_required_fields(self, mock_context, mock_gads_client): + """Omitting login_customer_id must return a validation error.""" + result = await google_ads.execute_action( + "retrieve_campaign_metrics", + {"customer_id": "9876543210", "date_ranges": ["2025-05-14_2025-05-20"]}, + mock_context, + ) + assert result.type != ResultType.ACTION + + +# =========================================================================== +# 2. create_campaign +# =========================================================================== + + +def _setup_create_campaign_mocks(mock_gads_client): + mock_service = mock_gads_client.get_service.return_value + + budget_result = MagicMock() + budget_result.resource_name = "customers/123/campaignBudgets/456" + mock_service.mutate_campaign_budgets.return_value.results = [budget_result] + + campaign_result = MagicMock() + campaign_result.resource_name = "customers/123/campaigns/789" + mock_service.mutate_campaigns.return_value.results = [campaign_result] + + mock_gads_client.get_type.return_value = MagicMock() + mock_gads_client.enums.BudgetDeliveryMethodEnum.STANDARD = "STANDARD" + mock_gads_client.enums.AdvertisingChannelTypeEnum.SEARCH = "SEARCH" + mock_gads_client.enums.CampaignStatusEnum.PAUSED = "PAUSED" + mock_gads_client.enums.EuPoliticalAdvertisingStatusEnum.DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING = "NO" + + return mock_service + + +class TestCreateCampaign: + @pytest.mark.asyncio + async def test_missing_campaign_name(self, mock_context, mock_gads_client): + """Omitting campaign_name must return an error.""" + result = await google_ads.execute_action( + "create_campaign", + {**BASE_INPUTS, "budget_amount_micros": 1000000}, + mock_context, + ) + assert result.type != ResultType.ACTION + assert "campaign_name" in str(result.result) + + @pytest.mark.asyncio + async def test_missing_budget_amount_micros(self, mock_context, mock_gads_client): + """Omitting budget_amount_micros must return an error.""" + result = await google_ads.execute_action( + "create_campaign", + {**BASE_INPUTS, "campaign_name": "My Campaign"}, + mock_context, + ) + assert result.type != ResultType.ACTION + assert "budget_amount_micros" in str(result.result) + + @pytest.mark.asyncio + async def test_creates_campaign_successfully(self, mock_context, mock_gads_client): + """Happy path: creates budget + campaign and returns resource names.""" + _setup_create_campaign_mocks(mock_gads_client) + + result = await google_ads.execute_action( + "create_campaign", + { + **BASE_INPUTS, + "campaign_name": "My Campaign", + "budget_amount_micros": 1000000, + }, + mock_context, + ) + assert result.type == ResultType.ACTION + assert "campaign_resource_name" in result.result.data + assert "budget_resource_name" in result.result.data + assert "campaign_id" in result.result.data + assert result.result.data["status"] == "PAUSED" + + @pytest.mark.asyncio + async def test_campaign_id_extracted_from_resource_name(self, mock_context, mock_gads_client): + """campaign_id must be the last path segment of the campaign resource name.""" + _setup_create_campaign_mocks(mock_gads_client) + + result = await google_ads.execute_action( + "create_campaign", + { + **BASE_INPUTS, + "campaign_name": "My Campaign", + "budget_amount_micros": 1000000, + }, + mock_context, + ) + assert result.type == ResultType.ACTION + assert result.result.data["campaign_id"] == "789" + + @pytest.mark.asyncio + async def test_api_error_returns_action_error(self, mock_context, mock_gads_client): + """An exception from mutate_campaigns must propagate as an ActionError.""" + _setup_create_campaign_mocks(mock_gads_client) + mock_gads_client.get_service.return_value.mutate_campaigns.side_effect = Exception("mutate failed") + + result = await google_ads.execute_action( + "create_campaign", + { + **BASE_INPUTS, + "campaign_name": "My Campaign", + "budget_amount_micros": 1000000, + }, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + assert "mutate failed" in result.result.message + + @pytest.mark.asyncio + async def test_auth_error(self, mock_context_no_token): + """Missing refresh_token must return an ActionError.""" + result = await google_ads.execute_action( + "create_campaign", + { + **BASE_INPUTS, + "campaign_name": "My Campaign", + "budget_amount_micros": 1000000, + }, + mock_context_no_token, + ) + assert result.type == ResultType.ACTION_ERROR + + @pytest.mark.asyncio + async def test_budget_api_error_returns_action_error(self, mock_context, mock_gads_client): + """An exception from mutate_campaign_budgets must also surface as ActionError.""" + _setup_create_campaign_mocks(mock_gads_client) + mock_gads_client.get_service.return_value.mutate_campaign_budgets.side_effect = Exception("budget failed") + + result = await google_ads.execute_action( + "create_campaign", + { + **BASE_INPUTS, + "campaign_name": "My Campaign", + "budget_amount_micros": 1000000, + }, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + assert "budget failed" in result.result.message + + +# =========================================================================== +# 3. update_campaign +# =========================================================================== + + +def _setup_update_campaign_mocks(mock_gads_client): + mock_service = mock_gads_client.get_service.return_value + + update_result = MagicMock() + update_result.resource_name = "customers/123/campaigns/789" + mock_service.mutate_campaigns.return_value.results = [update_result] + mock_service.campaign_path.return_value = "customers/9876543210/campaigns/789" + + mock_gads_client.get_type.return_value = MagicMock() + mock_gads_client.enums.CampaignStatusEnum.ENABLED = "ENABLED" + mock_gads_client.enums.CampaignStatusEnum.PAUSED = "PAUSED" + + return mock_service + + +class TestUpdateCampaign: + @pytest.mark.asyncio + async def test_missing_campaign_id(self, mock_context, mock_gads_client): + """Omitting campaign_id must return an error.""" + result = await google_ads.execute_action( + "update_campaign", + {**BASE_INPUTS, "status": "ENABLED"}, + mock_context, + ) + assert result.type != ResultType.ACTION + assert "campaign_id" in str(result.result) + + @pytest.mark.asyncio + async def test_updates_campaign_successfully(self, mock_context, mock_gads_client): + """Happy path: updating status returns ActionResult with resource name.""" + _setup_update_campaign_mocks(mock_gads_client) + + result = await google_ads.execute_action( + "update_campaign", + {**BASE_INPUTS, "campaign_id": "789", "status": "ENABLED"}, + mock_context, + ) + assert result.type == ResultType.ACTION + assert "campaign_resource_name" in result.result.data + assert result.result.data["status"] == "ENABLED" + + @pytest.mark.asyncio + async def test_update_campaign_name_only(self, mock_context, mock_gads_client): + """Updating name only (no status) should return 'unchanged' in status field.""" + _setup_update_campaign_mocks(mock_gads_client) + + result = await google_ads.execute_action( + "update_campaign", + {**BASE_INPUTS, "campaign_id": "789", "name": "Renamed Campaign"}, + mock_context, + ) + assert result.type == ResultType.ACTION + assert result.result.data["status"] == "unchanged" + + @pytest.mark.asyncio + async def test_api_error(self, mock_context, mock_gads_client): + """An exception from mutate_campaigns must return an ActionError.""" + _setup_update_campaign_mocks(mock_gads_client) + mock_gads_client.get_service.return_value.mutate_campaigns.side_effect = Exception("update failed") + + result = await google_ads.execute_action( + "update_campaign", + {**BASE_INPUTS, "campaign_id": "789", "status": "PAUSED"}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + assert "update failed" in result.result.message + + @pytest.mark.asyncio + async def test_auth_error(self, mock_context_no_token): + """Missing refresh_token must return an ActionError.""" + result = await google_ads.execute_action( + "update_campaign", + {**BASE_INPUTS, "campaign_id": "789", "status": "ENABLED"}, + mock_context_no_token, + ) + assert result.type == ResultType.ACTION_ERROR + + +# =========================================================================== +# 4. remove_campaign +# =========================================================================== + + +def _setup_remove_campaign_mocks(mock_gads_client): + mock_service = mock_gads_client.get_service.return_value + + remove_result = MagicMock() + remove_result.resource_name = "customers/123/campaigns/789" + mock_service.mutate_campaigns.return_value.results = [remove_result] + mock_service.campaign_path.return_value = "customers/9876543210/campaigns/789" + + mock_gads_client.get_type.return_value = MagicMock() + + return mock_service + + +class TestRemoveCampaign: + @pytest.mark.asyncio + async def test_missing_campaign_id(self, mock_context, mock_gads_client): + """Omitting campaign_id must return an error.""" + result = await google_ads.execute_action( + "remove_campaign", + {**BASE_INPUTS}, + mock_context, + ) + assert result.type != ResultType.ACTION + assert "campaign_id" in str(result.result) + + @pytest.mark.asyncio + async def test_removes_campaign_successfully(self, mock_context, mock_gads_client): + """Happy path: remove_campaign returns REMOVED status and resource name.""" + _setup_remove_campaign_mocks(mock_gads_client) + + result = await google_ads.execute_action( + "remove_campaign", + {**BASE_INPUTS, "campaign_id": "789"}, + mock_context, + ) + assert result.type == ResultType.ACTION + assert result.result.data["status"] == "REMOVED" + assert "removed_campaign_resource_name" in result.result.data + + @pytest.mark.asyncio + async def test_removed_resource_name_matches_api_response(self, mock_context, mock_gads_client): + """The removed resource name in data should match what the API returned.""" + _setup_remove_campaign_mocks(mock_gads_client) + + result = await google_ads.execute_action( + "remove_campaign", + {**BASE_INPUTS, "campaign_id": "789"}, + mock_context, + ) + assert result.type == ResultType.ACTION + assert result.result.data["removed_campaign_resource_name"] == "customers/123/campaigns/789" + + @pytest.mark.asyncio + async def test_api_error(self, mock_context, mock_gads_client): + """An exception from mutate_campaigns must return an ActionError.""" + _setup_remove_campaign_mocks(mock_gads_client) + mock_gads_client.get_service.return_value.mutate_campaigns.side_effect = Exception("remove failed") + + result = await google_ads.execute_action( + "remove_campaign", + {**BASE_INPUTS, "campaign_id": "789"}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + assert "remove failed" in result.result.message + + @pytest.mark.asyncio + async def test_auth_error(self, mock_context_no_token): + """Missing refresh_token must return an ActionError.""" + result = await google_ads.execute_action( + "remove_campaign", + {**BASE_INPUTS, "campaign_id": "789"}, + mock_context_no_token, + ) + assert result.type == ResultType.ACTION_ERROR diff --git a/google-ads/tests/test_google_ads_helpers_unit.py b/google-ads/tests/test_google_ads_helpers_unit.py new file mode 100644 index 00000000..405e892e --- /dev/null +++ b/google-ads/tests/test_google_ads_helpers_unit.py @@ -0,0 +1,219 @@ +import os +import sys +import importlib + +os.environ.setdefault("ADWORDS_DEVELOPER_TOKEN", "test_developer_token") # nosec B105 +os.environ.setdefault("ADWORDS_CLIENT_ID", "test_client_id") # nosec B105 +os.environ.setdefault("ADWORDS_CLIENT_SECRET", "test_client_secret") # nosec B105 + +_parent = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +_deps = os.path.abspath(os.path.join(os.path.dirname(__file__), "../dependencies")) +sys.path.insert(0, _parent) +sys.path.insert(0, _deps) + +import pytest # noqa: E402 +from datetime import datetime, timedelta # noqa: E402 + +_spec = importlib.util.spec_from_file_location("google_ads_mod", os.path.join(_parent, "google_ads.py")) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + +parse_date_range = _mod.parse_date_range +micros_to_currency = _mod.micros_to_currency +_calculate_safe_rate = _mod._calculate_safe_rate +_get_ad_text_assets = _mod._get_ad_text_assets + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# parse_date_range +# --------------------------------------------------------------------------- + + +class TestParseDateRange: + def test_last_7_days_spaced(self): + result = parse_date_range("last 7 days") + today = datetime.now().date() + expected_end = today - timedelta(days=1) + expected_start = expected_end - timedelta(days=6) + assert result == { + "start_date": expected_start.strftime("%Y-%m-%d"), + "end_date": expected_end.strftime("%Y-%m-%d"), + } + + def test_last_7_days_underscored(self): + # "last_7_days" is treated the same as "last 7 days" + result_spaced = parse_date_range("last 7 days") + result_underscored = parse_date_range("last_7_days") + assert result_spaced == result_underscored + + def test_explicit_date_range_with_underscore(self): + result = parse_date_range("2025-05-14_2025-05-20") + assert result == {"start_date": "2025-05-14", "end_date": "2025-05-20"} + + def test_single_date_slash_format(self): + result = parse_date_range("14/05/2025") + assert result == {"start_date": "2025-05-14", "end_date": "2025-05-14"} + + def test_invalid_format_raises_value_error(self): + with pytest.raises(ValueError, match="Invalid date range format"): + parse_date_range("not-a-date") + + def test_invalid_slash_date_raises_value_error(self): + with pytest.raises(ValueError): + parse_date_range("99/99/9999") + + def test_last_7_days_returns_dict_keys(self): + result = parse_date_range("last 7 days") + assert "start_date" in result + assert "end_date" in result + + def test_explicit_range_start_before_end(self): + result = parse_date_range("2025-01-01_2025-01-31") + assert result["start_date"] <= result["end_date"] + + +# --------------------------------------------------------------------------- +# micros_to_currency +# --------------------------------------------------------------------------- + + +class TestMicrosToCurrency: + def test_one_million_micros_equals_one_dollar(self): + assert micros_to_currency(1_000_000) == 1.0 + + def test_partial_micros(self): + assert micros_to_currency(2_500_000) == 2.5 + + def test_zero_micros(self): + assert micros_to_currency(0) == 0.0 + + def test_none_returns_na_string(self): + assert micros_to_currency(None) == "N/A" + + def test_returns_float_type(self): + result = micros_to_currency(1_000_000) + assert isinstance(result, float) + + def test_large_value(self): + assert micros_to_currency(10_000_000) == 10.0 + + +# --------------------------------------------------------------------------- +# _calculate_safe_rate +# --------------------------------------------------------------------------- + + +class TestCalculateSafeRate: + def test_basic_division(self): + assert _calculate_safe_rate(10, 100) == pytest.approx(0.1) + + def test_zero_numerator(self): + assert _calculate_safe_rate(0, 100) == 0.0 + + def test_zero_denominator_returns_zero(self): + assert _calculate_safe_rate(10, 0) == 0.0 + + def test_non_numeric_numerator_returns_zero(self): + assert _calculate_safe_rate("abc", 100) == 0.0 + + def test_non_numeric_denominator_returns_zero(self): + assert _calculate_safe_rate(10, "abc") == 0.0 + + def test_both_non_numeric_returns_zero(self): + assert _calculate_safe_rate("x", "y") == 0.0 + + def test_float_inputs(self): + assert _calculate_safe_rate(1.5, 3.0) == pytest.approx(0.5) + + def test_string_numeric_inputs_are_coerced(self): + # Numeric strings should be coerced to floats successfully + assert _calculate_safe_rate("10", "100") == pytest.approx(0.1) + + +# --------------------------------------------------------------------------- +# _get_ad_text_assets +# --------------------------------------------------------------------------- + + +class TestGetAdTextAssets: + def test_rsa_extracts_headlines_and_descriptions(self): + ad_data = { + "type": "RESPONSIVE_SEARCH_AD", + "responsive_search_ad": { + "headlines": [ + {"text": "Headline One"}, + {"text": "Headline Two"}, + {"text": "Headline Three"}, + ], + "descriptions": [ + {"text": "Description One"}, + {"text": "Description Two"}, + ], + }, + } + result = _get_ad_text_assets(ad_data) + assert result["headlines"] == ["Headline One", "Headline Two", "Headline Three"] + assert result["descriptions"] == ["Description One", "Description Two"] + + def test_rsa_skips_empty_text_entries(self): + ad_data = { + "type": "RESPONSIVE_SEARCH_AD", + "responsive_search_ad": { + "headlines": [{"text": "Real Headline"}, {"text": ""}, {}], + "descriptions": [{"text": "Real Desc"}, {}], + }, + } + result = _get_ad_text_assets(ad_data) + assert result["headlines"] == ["Real Headline"] + assert result["descriptions"] == ["Real Desc"] + + def test_expanded_text_ad_extracts_parts(self): + ad_data = { + "type": "EXPANDED_TEXT_AD", + "expanded_text_ad": { + "headline_part1": "Part One", + "headline_part2": "Part Two", + "headline_part3": "Part Three", + "description": "Main description", + "description2": "Second description", + }, + } + result = _get_ad_text_assets(ad_data) + assert "Part One" in result["headlines"] + assert "Part Two" in result["headlines"] + assert "Part Three" in result["headlines"] + assert "Main description" in result["descriptions"] + assert "Second description" in result["descriptions"] + + def test_expanded_text_ad_partial_parts(self): + ad_data = { + "type": "EXPANDED_TEXT_AD", + "expanded_text_ad": { + "headline_part1": "Only Headline", + "description": "Only Description", + }, + } + result = _get_ad_text_assets(ad_data) + assert result["headlines"] == ["Only Headline"] + assert result["descriptions"] == ["Only Description"] + + def test_unknown_type_returns_empty_lists(self): + ad_data = {"type": "SOME_UNKNOWN_TYPE"} + result = _get_ad_text_assets(ad_data) + assert result["headlines"] == [] + assert result["descriptions"] == [] + + def test_missing_type_returns_empty_lists(self): + result = _get_ad_text_assets({}) + assert result["headlines"] == [] + assert result["descriptions"] == [] + + def test_return_type_is_dict_with_expected_keys(self): + result = _get_ad_text_assets({"type": "RESPONSIVE_SEARCH_AD"}) + assert isinstance(result, dict) + assert "headlines" in result + assert "descriptions" in result + assert isinstance(result["headlines"], list) + assert isinstance(result["descriptions"], list) diff --git a/google-ads/tests/test_google_ads_integration.py b/google-ads/tests/test_google_ads_integration.py new file mode 100644 index 00000000..5c4d1212 --- /dev/null +++ b/google-ads/tests/test_google_ads_integration.py @@ -0,0 +1,494 @@ +""" +End-to-end integration tests for the Google Ads integration. + +These tests call the real Google Ads API and require platform OAuth credentials +and Google Ads account IDs in environment variables (via .env or export). + +Token extraction recipe: +1. Connect Google Ads in Autohive using the platform OAuth flow. +2. Copy the short-lived OAuth access token into GOOGLE_ADS_ACCESS_TOKEN. +3. Set ADWORDS_DEVELOPER_TOKEN from the Google Ads API Center. +4. Set GOOGLE_ADS_LOGIN_CUSTOMER_ID and GOOGLE_ADS_CUSTOMER_ID without dashes. +5. Optional destructive tests also need GOOGLE_ADS_TEST_CAMPAIGN_ID and + GOOGLE_ADS_TEST_AD_GROUP_ID for ad group, keyword, and negative keyword flows. + +Run with: + pytest google-ads/tests/test_google_ads_integration.py -m "integration and not destructive" + +Run destructive tests deliberately only when the connected account is safe to mutate: + pytest google-ads/tests/test_google_ads_integration.py -m "integration and destructive" + +Never runs in CI -- the default pytest marker filter (-m unit) excludes these, +and the file naming (test_*_integration.py) is not matched by python_files. +""" + +import os +import sys +import importlib.util + +_parent = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +_deps = os.path.abspath(os.path.join(os.path.dirname(__file__), "../dependencies")) +sys.path.insert(0, _parent) +sys.path.insert(0, _deps) + +import pytest # noqa: E402 +from autohive_integrations_sdk.integration import ResultType # noqa: E402 + +_spec = importlib.util.spec_from_file_location("google_ads_mod", os.path.join(_parent, "google_ads.py")) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + +google_ads = _mod.google_ads + +pytestmark = pytest.mark.integration + +TEST_CAMPAIGN_ID = os.environ.get("GOOGLE_ADS_TEST_CAMPAIGN_ID", "") +TEST_AD_GROUP_ID = os.environ.get("GOOGLE_ADS_TEST_AD_GROUP_ID", "") + + +def require_campaign_id(): + if not TEST_CAMPAIGN_ID: + pytest.skip("GOOGLE_ADS_TEST_CAMPAIGN_ID not set") + + +def require_ad_group_id(): + if not TEST_AD_GROUP_ID: + pytest.skip("GOOGLE_ADS_TEST_AD_GROUP_ID not set") + + +@pytest.fixture +def live_credentials(env_credentials): + access_token = env_credentials("GOOGLE_ADS_ACCESS_TOKEN") + login_customer_id = env_credentials("GOOGLE_ADS_LOGIN_CUSTOMER_ID") + customer_id = env_credentials("GOOGLE_ADS_CUSTOMER_ID") + developer_token = env_credentials("ADWORDS_DEVELOPER_TOKEN") + + if not access_token: + pytest.skip("GOOGLE_ADS_ACCESS_TOKEN not set — skipping integration tests") + if not login_customer_id or not customer_id: + pytest.skip("GOOGLE_ADS_LOGIN_CUSTOMER_ID and GOOGLE_ADS_CUSTOMER_ID must be set") + if not developer_token: + pytest.skip("ADWORDS_DEVELOPER_TOKEN not set") + + return { + "access_token": access_token, + "login_customer_id": login_customer_id, + "customer_id": customer_id, + } + + +@pytest.fixture +def live_context(live_credentials, make_context): + return make_context( + auth={ + "auth_type": "PlatformOauth2", + "credentials": {"access_token": live_credentials["access_token"]}, + } + ) + + +@pytest.fixture +def base_inputs(live_credentials): + return { + "login_customer_id": live_credentials["login_customer_id"], + "customer_id": live_credentials["customer_id"], + } + + +# =========================================================================== +# Read-Only Tests +# =========================================================================== + + +class TestGetAccessibleAccounts: + @pytest.mark.asyncio + async def test_returns_accounts_list(self, live_context): + result = await google_ads.execute_action("get_accessible_accounts", {}, live_context) + assert result.type == ResultType.ACTION + assert "accounts" in result.result.data + assert isinstance(result.result.data["accounts"], list) + + @pytest.mark.asyncio + async def test_accounts_have_expected_fields(self, live_context): + result = await google_ads.execute_action("get_accessible_accounts", {}, live_context) + assert result.type == ResultType.ACTION + if not result.result.data.get("accounts"): + pytest.skip("No accounts returned") + for account in result.result.data["accounts"]: + assert "customer_id" in account + assert "resource_name" in account + assert "descriptive_name" in account + assert "currency_code" in account + + +class TestRetrieveCampaignMetrics: + @pytest.mark.asyncio + async def test_with_last_7_days_range(self, live_context, base_inputs): + result = await google_ads.execute_action( + "retrieve_campaign_metrics", + {**base_inputs, "date_ranges": ["last 7 days"]}, + live_context, + ) + assert result.type == ResultType.ACTION + assert "results" in result.result.data + + @pytest.mark.asyncio + async def test_results_have_date_range_and_data_keys(self, live_context, base_inputs): + result = await google_ads.execute_action( + "retrieve_campaign_metrics", + {**base_inputs, "date_ranges": ["last 7 days"]}, + live_context, + ) + assert result.type == ResultType.ACTION + for entry in result.result.data["results"]: + assert "date_range" in entry + assert "data" in entry + assert isinstance(entry["data"], list) + + +class TestRetrieveKeywordMetrics: + @pytest.mark.asyncio + async def test_with_date_range_and_campaign_id(self, live_context, base_inputs): + require_campaign_id() + result = await google_ads.execute_action( + "retrieve_keyword_metrics", + { + **base_inputs, + "date_ranges": ["last 7 days"], + "ad_group_ids": [], + "campaign_ids": [TEST_CAMPAIGN_ID], + }, + live_context, + ) + assert result.type == ResultType.ACTION + assert "results" in result.result.data + + @pytest.mark.asyncio + async def test_results_structure(self, live_context, base_inputs): + require_campaign_id() + result = await google_ads.execute_action( + "retrieve_keyword_metrics", + { + **base_inputs, + "date_ranges": ["last 7 days"], + "ad_group_ids": [], + "campaign_ids": [TEST_CAMPAIGN_ID], + }, + live_context, + ) + assert result.type == ResultType.ACTION + for entry in result.result.data["results"]: + assert "date_range" in entry + assert "data" in entry + + +class TestRetrieveAdGroupMetrics: + @pytest.mark.asyncio + async def test_returns_results(self, live_context, base_inputs): + result = await google_ads.execute_action( + "retrieve_ad_group_metrics", + {**base_inputs, "date_ranges": ["last 7 days"]}, + live_context, + ) + assert result.type == ResultType.ACTION + assert "results" in result.result.data + + @pytest.mark.asyncio + async def test_result_entries_have_correct_structure(self, live_context, base_inputs): + result = await google_ads.execute_action( + "retrieve_ad_group_metrics", + {**base_inputs, "date_ranges": ["last 7 days"]}, + live_context, + ) + assert result.type == ResultType.ACTION + for entry in result.result.data["results"]: + assert "date_range" in entry + assert "data" in entry + assert isinstance(entry["data"], list) + + +class TestRetrieveAdMetrics: + @pytest.mark.asyncio + async def test_returns_results(self, live_context, base_inputs): + result = await google_ads.execute_action( + "retrieve_ad_metrics", + {**base_inputs, "date_ranges": ["last 7 days"]}, + live_context, + ) + assert result.type == ResultType.ACTION + assert "results" in result.result.data + + @pytest.mark.asyncio + async def test_result_entries_have_correct_structure(self, live_context, base_inputs): + result = await google_ads.execute_action( + "retrieve_ad_metrics", + {**base_inputs, "date_ranges": ["last 7 days"]}, + live_context, + ) + assert result.type == ResultType.ACTION + for entry in result.result.data["results"]: + assert "date_range" in entry + assert "data" in entry + assert isinstance(entry["data"], list) + + +class TestRetrieveSearchTerms: + @pytest.mark.asyncio + async def test_with_date_range(self, live_context, base_inputs): + result = await google_ads.execute_action( + "retrieve_search_terms", + {**base_inputs, "date_ranges": ["last 7 days"]}, + live_context, + ) + assert result.type == ResultType.ACTION + assert "results" in result.result.data + + @pytest.mark.asyncio + async def test_result_entries_have_correct_structure(self, live_context, base_inputs): + result = await google_ads.execute_action( + "retrieve_search_terms", + {**base_inputs, "date_ranges": ["last 7 days"]}, + live_context, + ) + assert result.type == ResultType.ACTION + for entry in result.result.data["results"]: + assert "date_range" in entry + assert "data" in entry + + +class TestGetActiveAdUrls: + @pytest.mark.asyncio + async def test_returns_active_ads_and_total_count(self, live_context, base_inputs): + result = await google_ads.execute_action("get_active_ad_urls", base_inputs, live_context) + assert result.type == ResultType.ACTION + assert "active_ads" in result.result.data + assert "total_count" in result.result.data + assert isinstance(result.result.data["active_ads"], list) + assert isinstance(result.result.data["total_count"], int) + assert result.result.data["total_count"] == len(result.result.data["active_ads"]) + + +class TestGenerateKeywordIdeas: + @pytest.mark.asyncio + async def test_with_seed_keywords_returns_keyword_ideas(self, live_context, base_inputs): + result = await google_ads.execute_action( + "generate_keyword_ideas", + {**base_inputs, "seed_keywords": ["digital marketing", "seo tools"]}, + live_context, + ) + assert result.type == ResultType.ACTION + assert "keyword_ideas" in result.result.data + assert isinstance(result.result.data["keyword_ideas"], list) + + @pytest.mark.asyncio + async def test_keyword_idea_entries_have_correct_fields(self, live_context, base_inputs): + result = await google_ads.execute_action( + "generate_keyword_ideas", + {**base_inputs, "seed_keywords": ["digital marketing"]}, + live_context, + ) + assert result.type == ResultType.ACTION + if not result.result.data.get("keyword_ideas"): + pytest.skip("No keyword ideas returned") + for idea in result.result.data["keyword_ideas"]: + assert "keyword" in idea + assert "avg_monthly_searches" in idea + assert "competition" in idea + + +class TestGenerateKeywordHistoricalMetrics: + @pytest.mark.asyncio + async def test_with_keywords_list_returns_keyword_metrics(self, live_context, base_inputs): + result = await google_ads.execute_action( + "generate_keyword_historical_metrics", + {**base_inputs, "keywords": ["digital marketing", "online advertising"]}, + live_context, + ) + assert result.type == ResultType.ACTION + assert "keyword_metrics" in result.result.data + assert isinstance(result.result.data["keyword_metrics"], list) + + @pytest.mark.asyncio + async def test_keyword_metric_entries_have_correct_fields(self, live_context, base_inputs): + result = await google_ads.execute_action( + "generate_keyword_historical_metrics", + {**base_inputs, "keywords": ["digital marketing"]}, + live_context, + ) + assert result.type == ResultType.ACTION + if not result.result.data.get("keyword_metrics"): + pytest.skip("No keyword metrics returned") + for metric in result.result.data["keyword_metrics"]: + assert "keyword" in metric + assert "avg_monthly_searches" in metric + assert "competition" in metric + assert "monthly_search_volumes" in metric + + +# =========================================================================== +# Destructive Tests +# =========================================================================== + + +class TestCampaignLifecycle: + @pytest.mark.asyncio + @pytest.mark.destructive + async def test_create_update_remove_campaign(self, live_context, base_inputs): + create_result = await google_ads.execute_action( + "create_campaign", + { + **base_inputs, + "campaign_name": "Integration Test Campaign — Delete Me", + "budget_amount_micros": 10_000_000, + "bidding_strategy": "MANUAL_CPC", + }, + live_context, + ) + assert create_result.type == ResultType.ACTION + assert "campaign_id" in create_result.result.data + campaign_id = create_result.result.data["campaign_id"] + + update_result = await google_ads.execute_action( + "update_campaign", + { + **base_inputs, + "campaign_id": campaign_id, + "name": "Integration Test Campaign — Renamed", + }, + live_context, + ) + assert update_result.type == ResultType.ACTION + assert "campaign_resource_name" in update_result.result.data + + remove_result = await google_ads.execute_action( + "remove_campaign", + {**base_inputs, "campaign_id": campaign_id}, + live_context, + ) + assert remove_result.type == ResultType.ACTION + assert remove_result.result.data["status"] == "REMOVED" + + +class TestAdGroupLifecycle: + @pytest.mark.asyncio + @pytest.mark.destructive + async def test_create_update_remove_ad_group(self, live_context, base_inputs): + require_campaign_id() + + create_result = await google_ads.execute_action( + "create_ad_group", + { + **base_inputs, + "campaign_id": TEST_CAMPAIGN_ID, + "ad_group_name": "Integration Test Ad Group — Delete Me", + "cpc_bid_micros": 1_000_000, + "status": "PAUSED", + }, + live_context, + ) + assert create_result.type == ResultType.ACTION + assert "ad_group_id" in create_result.result.data + ad_group_id = create_result.result.data["ad_group_id"] + + update_result = await google_ads.execute_action( + "update_ad_group", + { + **base_inputs, + "ad_group_id": ad_group_id, + "name": "Integration Test Ad Group — Renamed", + }, + live_context, + ) + assert update_result.type == ResultType.ACTION + assert "ad_group_resource_name" in update_result.result.data + + remove_result = await google_ads.execute_action( + "remove_ad_group", + {**base_inputs, "ad_group_id": ad_group_id}, + live_context, + ) + assert remove_result.type == ResultType.ACTION + assert remove_result.result.data["status"] == "REMOVED" + + +class TestKeywordLifecycle: + @pytest.mark.asyncio + @pytest.mark.destructive + async def test_add_and_remove_keywords(self, live_context, base_inputs): + require_ad_group_id() + + add_result = await google_ads.execute_action( + "add_keywords", + { + **base_inputs, + "ad_group_id": TEST_AD_GROUP_ID, + "keywords": [ + { + "text": "integration test keyword delete me", + "match_type": "BROAD", + } + ], + }, + live_context, + ) + assert add_result.type == ResultType.ACTION + assert "added_keywords" in add_result.result.data + kw = add_result.result.data["added_keywords"][0] + assert "resource_name" in kw + + resource_name = kw["resource_name"] + criterion_id = resource_name.split("~")[-1] if "~" in resource_name else None + if not criterion_id: + pytest.skip("Could not extract criterion_id from resource_name") + + remove_result = await google_ads.execute_action( + "remove_keyword", + { + **base_inputs, + "ad_group_id": TEST_AD_GROUP_ID, + "criterion_id": criterion_id, + }, + live_context, + ) + assert remove_result.type == ResultType.ACTION + assert remove_result.result.data["status"] == "REMOVED" + + +class TestNegativeKeywords: + @pytest.mark.asyncio + @pytest.mark.destructive + async def test_add_negative_keywords_to_campaign(self, live_context, base_inputs): + require_campaign_id() + + result = await google_ads.execute_action( + "add_negative_keywords_to_campaign", + { + **base_inputs, + "campaign_id": TEST_CAMPAIGN_ID, + "keywords": [{"text": "integration test negative kw", "match_type": "BROAD"}], + }, + live_context, + ) + assert result.type == ResultType.ACTION + assert "added_negative_keywords" in result.result.data + assert result.result.data["campaign_id"] == TEST_CAMPAIGN_ID + assert result.result.data["status"] == "success" + + @pytest.mark.asyncio + @pytest.mark.destructive + async def test_add_negative_keywords_to_ad_group(self, live_context, base_inputs): + require_ad_group_id() + + result = await google_ads.execute_action( + "add_negative_keywords_to_ad_group", + { + **base_inputs, + "ad_group_id": TEST_AD_GROUP_ID, + "keywords": [{"text": "integration test neg kw ad group", "match_type": "PHRASE"}], + }, + live_context, + ) + assert result.type == ResultType.ACTION + assert "added_negative_keywords" in result.result.data + assert result.result.data["ad_group_id"] == TEST_AD_GROUP_ID + assert result.result.data["status"] == "success" diff --git a/google-ads/tests/test_google_ads_keyword_planner_unit.py b/google-ads/tests/test_google_ads_keyword_planner_unit.py new file mode 100644 index 00000000..054d4769 --- /dev/null +++ b/google-ads/tests/test_google_ads_keyword_planner_unit.py @@ -0,0 +1,302 @@ +import os +import sys +import importlib + +os.environ.setdefault("ADWORDS_DEVELOPER_TOKEN", "test_developer_token") # nosec B105 +os.environ.setdefault("ADWORDS_CLIENT_ID", "test_client_id") # nosec B105 +os.environ.setdefault("ADWORDS_CLIENT_SECRET", "test_client_secret") # nosec B105 + +_parent = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +_deps = os.path.abspath(os.path.join(os.path.dirname(__file__), "../dependencies")) +sys.path.insert(0, _parent) +sys.path.insert(0, _deps) + +import pytest # noqa: E402 +from unittest.mock import AsyncMock, MagicMock, patch # noqa: E402 +from autohive_integrations_sdk.integration import ResultType # noqa: E402 + +_spec = importlib.util.spec_from_file_location("google_ads_mod", os.path.join(_parent, "google_ads.py")) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + +google_ads = _mod.google_ads + +pytestmark = pytest.mark.unit + +BASE_INPUTS = {"login_customer_id": "1234567890", "customer_id": "9876543210"} + + +@pytest.fixture +def mock_context(): + ctx = MagicMock(name="ExecutionContext") + ctx.fetch = AsyncMock(name="fetch") + ctx.auth = {"auth_type": "PlatformOauth2", "credentials": {"access_token": "test_access_token"}} # nosec B105 + return ctx + + +@pytest.fixture +def mock_gads_client(): + with patch.object(_mod, "_get_google_ads_client") as mock_factory: + client = MagicMock(name="GoogleAdsClient") + mock_factory.return_value = client + yield client + + +# --------------------------------------------------------------------------- +# generate_keyword_ideas +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_generate_keyword_ideas_missing_seed_and_url(mock_context, mock_gads_client): + result = await google_ads.execute_action("generate_keyword_ideas", {**BASE_INPUTS}, mock_context) + assert result.type == ResultType.ACTION_ERROR + + +@pytest.mark.asyncio +async def test_generate_keyword_ideas_auth_error(mock_context): + mock_context.auth = {"credentials": {}} + result = await google_ads.execute_action( + "generate_keyword_ideas", + {**BASE_INPUTS, "seed_keywords": ["shoes"]}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + + +@pytest.mark.asyncio +async def test_generate_keyword_ideas_api_error(mock_context, mock_gads_client): + mock_gads_client.get_service.return_value.generate_keyword_ideas.side_effect = Exception("Service unavailable") + mock_gads_client.get_type.return_value = MagicMock() + + result = await google_ads.execute_action( + "generate_keyword_ideas", + {**BASE_INPUTS, "seed_keywords": ["shoes"]}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + assert "Service unavailable" in result.result.message + + +@pytest.mark.asyncio +async def test_generate_keyword_ideas_returns_keyword_ideas(mock_context, mock_gads_client): + mock_idea = MagicMock() + mock_idea.text = "digital marketing" + mock_idea.keyword_idea_metrics.avg_monthly_searches = 1000 + mock_idea.keyword_idea_metrics.competition.name = "HIGH" + mock_idea.keyword_idea_metrics.competition_index = 75 + mock_idea.keyword_idea_metrics.low_top_of_page_bid_micros = 500000 + mock_idea.keyword_idea_metrics.high_top_of_page_bid_micros = 2000000 + + mock_gads_client.get_service.return_value.generate_keyword_ideas.return_value = [mock_idea] + mock_gads_client.get_type.return_value = MagicMock() + + result = await google_ads.execute_action( + "generate_keyword_ideas", + {**BASE_INPUTS, "seed_keywords": ["marketing"]}, + mock_context, + ) + + assert result.type == ResultType.ACTION + assert "keyword_ideas" in result.result.data + assert len(result.result.data["keyword_ideas"]) == 1 + idea = result.result.data["keyword_ideas"][0] + assert idea["keyword"] == "digital marketing" + assert idea["avg_monthly_searches"] == 1000 + assert idea["competition"] == "HIGH" + assert result.result.data["total_results"] == 1 + + +@pytest.mark.asyncio +async def test_generate_keyword_ideas_seed_keywords_only(mock_context, mock_gads_client): + """When only seed_keywords provided, keyword_seed branch is used.""" + mock_gads_client.get_service.return_value.generate_keyword_ideas.return_value = [] + request_mock = MagicMock() + mock_gads_client.get_type.return_value = request_mock + + result = await google_ads.execute_action( + "generate_keyword_ideas", + {**BASE_INPUTS, "seed_keywords": ["running shoes"]}, + mock_context, + ) + + assert result.type == ResultType.ACTION + assert result.result.data["keyword_ideas"] == [] + request_mock.keyword_seed.keywords.extend.assert_called_once_with(["running shoes"]) + + +@pytest.mark.asyncio +async def test_generate_keyword_ideas_page_url_only(mock_context, mock_gads_client): + """When only page_url provided, url_seed branch is used.""" + mock_gads_client.get_service.return_value.generate_keyword_ideas.return_value = [] + request_mock = MagicMock() + mock_gads_client.get_type.return_value = request_mock + + result = await google_ads.execute_action( + "generate_keyword_ideas", + {**BASE_INPUTS, "page_url": "https://example.com"}, + mock_context, + ) + + assert result.type == ResultType.ACTION + assert request_mock.url_seed.url == "https://example.com" + + +@pytest.mark.asyncio +async def test_generate_keyword_ideas_both_seed_and_url(mock_context, mock_gads_client): + """When both seed_keywords and page_url provided, keyword_and_url_seed branch is used.""" + mock_gads_client.get_service.return_value.generate_keyword_ideas.return_value = [] + request_mock = MagicMock() + mock_gads_client.get_type.return_value = request_mock + + result = await google_ads.execute_action( + "generate_keyword_ideas", + {**BASE_INPUTS, "seed_keywords": ["shoes"], "page_url": "https://example.com"}, + mock_context, + ) + + assert result.type == ResultType.ACTION + assert request_mock.keyword_and_url_seed.url == "https://example.com" + request_mock.keyword_and_url_seed.keywords.extend.assert_called_once_with(["shoes"]) + + +# --------------------------------------------------------------------------- +# generate_keyword_historical_metrics +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_generate_keyword_historical_metrics_missing_keywords(mock_context, mock_gads_client): + result = await google_ads.execute_action("generate_keyword_historical_metrics", {**BASE_INPUTS}, mock_context) + assert result.type != ResultType.ACTION + assert "keywords" in str(result.result) + + +@pytest.mark.asyncio +async def test_generate_keyword_historical_metrics_auth_error(mock_context): + mock_context.auth = {"credentials": {}} + result = await google_ads.execute_action( + "generate_keyword_historical_metrics", + {**BASE_INPUTS, "keywords": ["shoes"]}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + + +@pytest.mark.asyncio +async def test_generate_keyword_historical_metrics_api_error(mock_context, mock_gads_client): + mock_gads_client.get_service.return_value.generate_keyword_historical_metrics.side_effect = Exception( + "Quota exceeded" + ) + mock_gads_client.get_type.return_value = MagicMock() + + result = await google_ads.execute_action( + "generate_keyword_historical_metrics", + {**BASE_INPUTS, "keywords": ["shoes"]}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + assert "Quota exceeded" in result.result.message + + +@pytest.mark.asyncio +async def test_generate_keyword_historical_metrics_returns_data(mock_context, mock_gads_client): + mock_kw_result = MagicMock() + mock_kw_result.text = "running shoes" + metrics = mock_kw_result.keyword_metrics + metrics.avg_monthly_searches = 5000 + metrics.competition.name = "MEDIUM" + metrics.competition_index = 50 + metrics.low_top_of_page_bid_micros = 300000 + metrics.high_top_of_page_bid_micros = 1500000 + metrics.monthly_search_volumes = [] + + response_mock = MagicMock() + response_mock.results = [mock_kw_result] + mock_gads_client.get_service.return_value.generate_keyword_historical_metrics.return_value = response_mock + mock_gads_client.get_type.return_value = MagicMock() + + result = await google_ads.execute_action( + "generate_keyword_historical_metrics", + {**BASE_INPUTS, "keywords": ["running shoes"]}, + mock_context, + ) + + assert result.type == ResultType.ACTION + assert "keyword_metrics" in result.result.data + assert len(result.result.data["keyword_metrics"]) == 1 + km = result.result.data["keyword_metrics"][0] + assert km["keyword"] == "running shoes" + assert km["avg_monthly_searches"] == 5000 + assert km["competition"] == "MEDIUM" + + +# --------------------------------------------------------------------------- +# generate_keyword_forecast +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_generate_keyword_forecast_missing_keywords(mock_context, mock_gads_client): + result = await google_ads.execute_action("generate_keyword_forecast", {**BASE_INPUTS}, mock_context) + assert result.type != ResultType.ACTION + assert "keywords" in str(result.result) + + +@pytest.mark.asyncio +async def test_generate_keyword_forecast_auth_error(mock_context): + mock_context.auth = {"credentials": {}} + result = await google_ads.execute_action( + "generate_keyword_forecast", + {**BASE_INPUTS, "keywords": [{"text": "shoes", "match_type": "BROAD"}]}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + + +@pytest.mark.asyncio +async def test_generate_keyword_forecast_api_error(mock_context, mock_gads_client): + mock_gads_client.get_service.return_value.generate_keyword_forecast_metrics.side_effect = Exception( + "Forecast unavailable" + ) + mock_gads_client.get_type.return_value = MagicMock() + + result = await google_ads.execute_action( + "generate_keyword_forecast", + {**BASE_INPUTS, "keywords": [{"text": "shoes", "match_type": "BROAD"}]}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + assert "Forecast unavailable" in result.result.message + + +@pytest.mark.asyncio +async def test_generate_keyword_forecast_returns_forecast(mock_context, mock_gads_client): + forecast_metrics = MagicMock() + forecast_metrics.impressions = 10000 + forecast_metrics.clicks = 500 + forecast_metrics.cost_micros = 2500000 + forecast_metrics.average_cpc_micros = 5000 + + response_mock = MagicMock() + response_mock.campaign_forecast_metrics = forecast_metrics + mock_gads_client.get_service.return_value.generate_keyword_forecast_metrics.return_value = response_mock + mock_gads_client.get_type.return_value = MagicMock() + + result = await google_ads.execute_action( + "generate_keyword_forecast", + { + **BASE_INPUTS, + "keywords": [{"text": "digital marketing", "match_type": "BROAD"}], + "forecast_days": 30, + }, + mock_context, + ) + + assert result.type == ResultType.ACTION + assert "forecast_period" in result.result.data + assert "campaign_metrics" in result.result.data + assert result.result.data["keywords_count"] == 1 + assert result.result.data["campaign_metrics"]["impressions"] == 10000 + assert result.result.data["campaign_metrics"]["clicks"] == 500 + assert result.result.data["forecast_period"]["days"] == 30 diff --git a/google-ads/tests/test_google_ads_keywords_unit.py b/google-ads/tests/test_google_ads_keywords_unit.py new file mode 100644 index 00000000..1b7fb78f --- /dev/null +++ b/google-ads/tests/test_google_ads_keywords_unit.py @@ -0,0 +1,484 @@ +import os +import sys +import importlib + +os.environ.setdefault("ADWORDS_DEVELOPER_TOKEN", "test_developer_token") # nosec B105 +os.environ.setdefault("ADWORDS_CLIENT_ID", "test_client_id") # nosec B105 +os.environ.setdefault("ADWORDS_CLIENT_SECRET", "test_client_secret") # nosec B105 + +_parent = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +_deps = os.path.abspath(os.path.join(os.path.dirname(__file__), "../dependencies")) +sys.path.insert(0, _parent) +sys.path.insert(0, _deps) + +import pytest # noqa: E402 +from unittest.mock import AsyncMock, MagicMock, patch # noqa: E402 +from autohive_integrations_sdk.integration import ResultType # noqa: E402 + +_spec = importlib.util.spec_from_file_location("google_ads_mod", os.path.join(_parent, "google_ads.py")) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + +google_ads = _mod.google_ads + +pytestmark = pytest.mark.unit + +BASE_INPUTS = {"login_customer_id": "1234567890", "customer_id": "9876543210"} + + +@pytest.fixture +def mock_context(): + ctx = MagicMock(name="ExecutionContext") + ctx.fetch = AsyncMock(name="fetch") + ctx.auth = {"auth_type": "PlatformOauth2", "credentials": {"access_token": "test_access_token"}} # nosec B105 + return ctx + + +@pytest.fixture +def mock_context_no_token(): + ctx = MagicMock(name="ExecutionContext") + ctx.fetch = AsyncMock(name="fetch") + ctx.auth = {"credentials": {}} + return ctx + + +@pytest.fixture +def mock_gads_client(): + with patch.object(_mod, "_get_google_ads_client") as mock_factory: + client = MagicMock(name="GoogleAdsClient") + mock_factory.return_value = client + yield client + + +_KEYWORD_PROTO_ROW = { + "campaign": {"id": "123", "name": "Test Campaign"}, + "ad_group": {"id": "456", "name": "Test Ad Group"}, + "ad_group_criterion": { + "criterion_id": "789", + "status": "ENABLED", + "keyword": {"text": "test keyword", "match_type": "BROAD"}, + "quality_info": {"quality_score": 7}, + }, + "metrics": { + "impressions": 500, + "clicks": 25, + "cost_micros": 12500000, + "all_conversions": 2.0, + "conversion_rate": 0.08, + "interaction_rate": 0.05, + "average_cpc": 500000, + }, +} + + +# =========================================================================== +# 1. retrieve_keyword_metrics +# =========================================================================== + + +_KW_METRICS_INPUTS = {**BASE_INPUTS, "ad_group_ids": ["456"], "campaign_ids": ["123"]} + + +class TestRetrieveKeywordMetrics: + @pytest.mark.asyncio + async def test_missing_date_ranges(self, mock_context, mock_gads_client): + result = await google_ads.execute_action("retrieve_keyword_metrics", {**_KW_METRICS_INPUTS}, mock_context) + assert result.type != ResultType.ACTION + assert "date_ranges" in str(result.result) + + @pytest.mark.asyncio + async def test_returns_empty_results(self, mock_context, mock_gads_client): + mock_gads_client.get_service.return_value.search.return_value = [] + + result = await google_ads.execute_action( + "retrieve_keyword_metrics", + {**_KW_METRICS_INPUTS, "date_ranges": ["2025-05-14_2025-05-20"]}, + mock_context, + ) + assert result.type == ResultType.ACTION + assert "results" in result.result.data + assert len(result.result.data["results"]) == 1 + assert result.result.data["results"][0]["data"] == [] + + @pytest.mark.asyncio + async def test_auth_error(self, mock_context_no_token): + result = await google_ads.execute_action( + "retrieve_keyword_metrics", + {**_KW_METRICS_INPUTS, "date_ranges": ["2025-05-14_2025-05-20"]}, + mock_context_no_token, + ) + assert result.type != ResultType.ACTION + + @pytest.mark.asyncio + async def test_returns_keyword_data_from_proto_rows(self, mock_context, mock_gads_client): + mock_row = MagicMock() + mock_gads_client.get_service.return_value.search.return_value = [mock_row] + + with patch("proto.Message.to_dict") as mock_to_dict: + mock_to_dict.return_value = _KEYWORD_PROTO_ROW + + result = await google_ads.execute_action( + "retrieve_keyword_metrics", + {**_KW_METRICS_INPUTS, "date_ranges": ["2025-05-14_2025-05-20"]}, + mock_context, + ) + + assert result.type == ResultType.ACTION + assert len(result.result.data["results"]) == 1 + kw_entry = result.result.data["results"][0]["data"][0] + assert kw_entry["Keyword"] == "test keyword" + assert kw_entry["Keyword ID"] == "789" + assert kw_entry["Cost"] == 12.5 + + +# =========================================================================== +# 2. add_keywords +# =========================================================================== + + +def _setup_add_keywords_mocks(mock_gads_client): + mock_service = mock_gads_client.get_service.return_value + + result_mock = MagicMock() + result_mock.resource_name = "customers/123/adGroupCriteria/456~789" + mock_service.mutate_ad_group_criteria.return_value.results = [result_mock] + + mock_gads_client.get_type.return_value = MagicMock() + mock_gads_client.enums.AdGroupCriterionStatusEnum.ENABLED = "ENABLED" + mock_gads_client.enums.KeywordMatchTypeEnum.BROAD = "BROAD" + mock_gads_client.enums.KeywordMatchTypeEnum.EXACT = "EXACT" + mock_gads_client.enums.KeywordMatchTypeEnum.PHRASE = "PHRASE" + + return mock_service + + +class TestAddKeywords: + @pytest.mark.asyncio + async def test_missing_ad_group_id_and_keywords(self, mock_context, mock_gads_client): + result = await google_ads.execute_action("add_keywords", {**BASE_INPUTS}, mock_context) + assert result.type != ResultType.ACTION + + @pytest.mark.asyncio + async def test_adds_keywords_successfully(self, mock_context, mock_gads_client): + _setup_add_keywords_mocks(mock_gads_client) + + result = await google_ads.execute_action( + "add_keywords", + { + **BASE_INPUTS, + "ad_group_id": "456", + "keywords": [{"text": "test keyword", "match_type": "BROAD"}], + }, + mock_context, + ) + assert result.type == ResultType.ACTION + assert "added_keywords" in result.result.data + assert len(result.result.data["added_keywords"]) == 1 + kw = result.result.data["added_keywords"][0] + assert kw["keyword_text"] == "test keyword" + assert "resource_name" in kw + + @pytest.mark.asyncio + async def test_api_error(self, mock_context, mock_gads_client): + _setup_add_keywords_mocks(mock_gads_client) + mock_gads_client.get_service.return_value.mutate_ad_group_criteria.side_effect = Exception("mutate failed") + + result = await google_ads.execute_action( + "add_keywords", + { + **BASE_INPUTS, + "ad_group_id": "456", + "keywords": [{"text": "test", "match_type": "BROAD"}], + }, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + + @pytest.mark.asyncio + async def test_auth_error(self, mock_context_no_token): + result = await google_ads.execute_action( + "add_keywords", + { + **BASE_INPUTS, + "ad_group_id": "456", + "keywords": [{"text": "test", "match_type": "BROAD"}], + }, + mock_context_no_token, + ) + assert result.type == ResultType.ACTION_ERROR + + +# =========================================================================== +# 3. add_negative_keywords_to_campaign +# =========================================================================== + + +def _setup_add_neg_campaign_mocks(mock_gads_client): + mock_service = mock_gads_client.get_service.return_value + + result_mock = MagicMock() + result_mock.resource_name = "customers/123/campaignCriteria/456~789" + mock_service.mutate_campaign_criteria.return_value.results = [result_mock] + mock_service.campaign_path.return_value = "customers/9876543210/campaigns/456" + + mock_gads_client.get_type.return_value = MagicMock() + mock_gads_client.enums.KeywordMatchTypeEnum.BROAD = "BROAD" + mock_gads_client.enums.KeywordMatchTypeEnum.EXACT = "EXACT" + mock_gads_client.enums.KeywordMatchTypeEnum.PHRASE = "PHRASE" + + return mock_service + + +class TestAddNegativeKeywordsToCampaign: + @pytest.mark.asyncio + async def test_missing_required_ids(self, mock_context, mock_gads_client): + result = await google_ads.execute_action("add_negative_keywords_to_campaign", {**BASE_INPUTS}, mock_context) + assert result.type != ResultType.ACTION + + @pytest.mark.asyncio + async def test_adds_negative_keywords_successfully(self, mock_context, mock_gads_client): + _setup_add_neg_campaign_mocks(mock_gads_client) + + result = await google_ads.execute_action( + "add_negative_keywords_to_campaign", + { + **BASE_INPUTS, + "campaign_id": "456", + "keywords": [{"text": "free", "match_type": "BROAD"}], + }, + mock_context, + ) + assert result.type == ResultType.ACTION + assert "added_negative_keywords" in result.result.data + assert result.result.data["campaign_id"] == "456" + assert result.result.data["status"] == "success" + + @pytest.mark.asyncio + async def test_api_error(self, mock_context, mock_gads_client): + _setup_add_neg_campaign_mocks(mock_gads_client) + mock_gads_client.get_service.return_value.mutate_campaign_criteria.side_effect = Exception("mutate failed") + + result = await google_ads.execute_action( + "add_negative_keywords_to_campaign", + { + **BASE_INPUTS, + "campaign_id": "456", + "keywords": [{"text": "free", "match_type": "BROAD"}], + }, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + + @pytest.mark.asyncio + async def test_auth_error(self, mock_context_no_token): + result = await google_ads.execute_action( + "add_negative_keywords_to_campaign", + {**BASE_INPUTS, "campaign_id": "456", "keywords": [{"text": "free"}]}, + mock_context_no_token, + ) + assert result.type == ResultType.ACTION_ERROR + + +# =========================================================================== +# 4. add_negative_keywords_to_ad_group +# =========================================================================== + + +def _setup_add_neg_ad_group_mocks(mock_gads_client): + mock_service = mock_gads_client.get_service.return_value + + result_mock = MagicMock() + result_mock.resource_name = "customers/123/adGroupCriteria/456~789" + mock_service.mutate_ad_group_criteria.return_value.results = [result_mock] + mock_service.ad_group_path.return_value = "customers/9876543210/adGroups/456" + + mock_gads_client.get_type.return_value = MagicMock() + mock_gads_client.enums.KeywordMatchTypeEnum.BROAD = "BROAD" + mock_gads_client.enums.KeywordMatchTypeEnum.EXACT = "EXACT" + mock_gads_client.enums.KeywordMatchTypeEnum.PHRASE = "PHRASE" + + return mock_service + + +class TestAddNegativeKeywordsToAdGroup: + @pytest.mark.asyncio + async def test_missing_required_ids(self, mock_context, mock_gads_client): + result = await google_ads.execute_action("add_negative_keywords_to_ad_group", {**BASE_INPUTS}, mock_context) + assert result.type != ResultType.ACTION + + @pytest.mark.asyncio + async def test_adds_negative_keywords_successfully(self, mock_context, mock_gads_client): + _setup_add_neg_ad_group_mocks(mock_gads_client) + + result = await google_ads.execute_action( + "add_negative_keywords_to_ad_group", + { + **BASE_INPUTS, + "ad_group_id": "456", + "keywords": [{"text": "cheap", "match_type": "EXACT"}], + }, + mock_context, + ) + assert result.type == ResultType.ACTION + assert "added_negative_keywords" in result.result.data + assert result.result.data["ad_group_id"] == "456" + assert result.result.data["status"] == "success" + + @pytest.mark.asyncio + async def test_api_error(self, mock_context, mock_gads_client): + _setup_add_neg_ad_group_mocks(mock_gads_client) + mock_gads_client.get_service.return_value.mutate_ad_group_criteria.side_effect = Exception("mutate failed") + + result = await google_ads.execute_action( + "add_negative_keywords_to_ad_group", + {**BASE_INPUTS, "ad_group_id": "456", "keywords": [{"text": "cheap"}]}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + + @pytest.mark.asyncio + async def test_auth_error(self, mock_context_no_token): + result = await google_ads.execute_action( + "add_negative_keywords_to_ad_group", + {**BASE_INPUTS, "ad_group_id": "456", "keywords": [{"text": "cheap"}]}, + mock_context_no_token, + ) + assert result.type == ResultType.ACTION_ERROR + + +# =========================================================================== +# 5. update_keyword +# =========================================================================== + + +def _setup_update_keyword_mocks(mock_gads_client): + mock_service = mock_gads_client.get_service.return_value + + update_result = MagicMock() + update_result.resource_name = "customers/9876543210/adGroupCriteria/456~789" + mock_service.mutate_ad_group_criteria.return_value.results = [update_result] + mock_service.ad_group_criterion_path.return_value = "customers/9876543210/adGroupCriteria/456~789" + + mock_gads_client.get_type.return_value = MagicMock() + mock_gads_client.enums.AdGroupCriterionStatusEnum.ENABLED = "ENABLED" + mock_gads_client.enums.AdGroupCriterionStatusEnum.PAUSED = "PAUSED" + + return mock_service + + +class TestUpdateKeyword: + @pytest.mark.asyncio + async def test_missing_ad_group_and_criterion(self, mock_context, mock_gads_client): + result = await google_ads.execute_action("update_keyword", {**BASE_INPUTS, "status": "PAUSED"}, mock_context) + assert result.type != ResultType.ACTION + + @pytest.mark.asyncio + async def test_updates_keyword_successfully(self, mock_context, mock_gads_client): + _setup_update_keyword_mocks(mock_gads_client) + + result = await google_ads.execute_action( + "update_keyword", + { + **BASE_INPUTS, + "ad_group_id": "456", + "criterion_id": "789", + "status": "PAUSED", + }, + mock_context, + ) + assert result.type == ResultType.ACTION + assert "criterion_id" in result.result.data + assert result.result.data["criterion_id"] == "789" + assert result.result.data["status"] == "PAUSED" + + @pytest.mark.asyncio + async def test_api_error(self, mock_context, mock_gads_client): + _setup_update_keyword_mocks(mock_gads_client) + mock_gads_client.get_service.return_value.mutate_ad_group_criteria.side_effect = Exception("update failed") + + result = await google_ads.execute_action( + "update_keyword", + { + **BASE_INPUTS, + "ad_group_id": "456", + "criterion_id": "789", + "status": "ENABLED", + }, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + + @pytest.mark.asyncio + async def test_auth_error(self, mock_context_no_token): + result = await google_ads.execute_action( + "update_keyword", + { + **BASE_INPUTS, + "ad_group_id": "456", + "criterion_id": "789", + "status": "PAUSED", + }, + mock_context_no_token, + ) + assert result.type == ResultType.ACTION_ERROR + + +# =========================================================================== +# 6. remove_keyword +# =========================================================================== + + +def _setup_remove_keyword_mocks(mock_gads_client): + mock_service = mock_gads_client.get_service.return_value + + remove_result = MagicMock() + remove_result.resource_name = "customers/9876543210/adGroupCriteria/456~789" + mock_service.mutate_ad_group_criteria.return_value.results = [remove_result] + mock_service.ad_group_criterion_path.return_value = "customers/9876543210/adGroupCriteria/456~789" + + mock_gads_client.get_type.return_value = MagicMock() + + return mock_service + + +class TestRemoveKeyword: + @pytest.mark.asyncio + async def test_missing_ids(self, mock_context, mock_gads_client): + result = await google_ads.execute_action("remove_keyword", {**BASE_INPUTS}, mock_context) + assert result.type != ResultType.ACTION + + @pytest.mark.asyncio + async def test_removes_keyword_successfully(self, mock_context, mock_gads_client): + _setup_remove_keyword_mocks(mock_gads_client) + + result = await google_ads.execute_action( + "remove_keyword", + {**BASE_INPUTS, "ad_group_id": "456", "criterion_id": "789"}, + mock_context, + ) + assert result.type == ResultType.ACTION + assert result.result.data["status"] == "REMOVED" + assert "removed_keyword_resource_name" in result.result.data + assert result.result.data["criterion_id"] == "789" + + @pytest.mark.asyncio + async def test_api_error(self, mock_context, mock_gads_client): + _setup_remove_keyword_mocks(mock_gads_client) + mock_gads_client.get_service.return_value.mutate_ad_group_criteria.side_effect = Exception("remove failed") + + result = await google_ads.execute_action( + "remove_keyword", + {**BASE_INPUTS, "ad_group_id": "456", "criterion_id": "789"}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + assert "remove failed" in result.result.message + + @pytest.mark.asyncio + async def test_auth_error(self, mock_context_no_token): + result = await google_ads.execute_action( + "remove_keyword", + {**BASE_INPUTS, "ad_group_id": "456", "criterion_id": "789"}, + mock_context_no_token, + ) + assert result.type == ResultType.ACTION_ERROR diff --git a/google-ads/tests/test_google_ads_search_terms_unit.py b/google-ads/tests/test_google_ads_search_terms_unit.py new file mode 100644 index 00000000..701fb504 --- /dev/null +++ b/google-ads/tests/test_google_ads_search_terms_unit.py @@ -0,0 +1,269 @@ +import os +import sys +import importlib + +os.environ.setdefault("ADWORDS_DEVELOPER_TOKEN", "test_developer_token") # nosec B105 +os.environ.setdefault("ADWORDS_CLIENT_ID", "test_client_id") # nosec B105 +os.environ.setdefault("ADWORDS_CLIENT_SECRET", "test_client_secret") # nosec B105 + +_parent = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +_deps = os.path.abspath(os.path.join(os.path.dirname(__file__), "../dependencies")) +sys.path.insert(0, _parent) +sys.path.insert(0, _deps) + +import pytest # noqa: E402 +from unittest.mock import AsyncMock, MagicMock, patch # noqa: E402 +from autohive_integrations_sdk.integration import ResultType # noqa: E402 + +_spec = importlib.util.spec_from_file_location("google_ads_mod", os.path.join(_parent, "google_ads.py")) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + +google_ads = _mod.google_ads + +pytestmark = pytest.mark.unit + +BASE_INPUTS = {"login_customer_id": "1234567890", "customer_id": "9876543210"} + + +@pytest.fixture +def mock_context(): + ctx = MagicMock(name="ExecutionContext") + ctx.fetch = AsyncMock(name="fetch") + ctx.auth = {"auth_type": "PlatformOauth2", "credentials": {"access_token": "test_access_token"}} # nosec B105 + return ctx + + +@pytest.fixture +def mock_gads_client(): + with patch.object(_mod, "_get_google_ads_client") as mock_factory: + client = MagicMock(name="GoogleAdsClient") + mock_factory.return_value = client + yield client + + +# --------------------------------------------------------------------------- +# retrieve_search_terms +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_retrieve_search_terms_missing_date_ranges(mock_context, mock_gads_client): + result = await google_ads.execute_action("retrieve_search_terms", {**BASE_INPUTS}, mock_context) + assert result.type != ResultType.ACTION + assert "date_ranges" in str(result.result) + + +@pytest.mark.asyncio +async def test_retrieve_search_terms_auth_error(mock_context): + mock_context.auth = {"credentials": {}} + result = await google_ads.execute_action( + "retrieve_search_terms", + {**BASE_INPUTS, "date_ranges": ["2025-05-14_2025-05-20"]}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + + +@pytest.mark.asyncio +async def test_retrieve_search_terms_returns_empty_results(mock_context, mock_gads_client): + mock_gads_client.get_service.return_value.search.return_value = [] + result = await google_ads.execute_action( + "retrieve_search_terms", + {**BASE_INPUTS, "date_ranges": ["2025-05-14_2025-05-20"]}, + mock_context, + ) + assert result.type == ResultType.ACTION + assert "results" in result.result.data + assert isinstance(result.result.data["results"], list) + assert result.result.data["results"][0]["data"] == [] + + +@pytest.mark.asyncio +async def test_retrieve_search_terms_returns_search_term_data(mock_context, mock_gads_client): + row = MagicMock() + mock_gads_client.get_service.return_value.search.return_value = [row] + + with patch("proto.Message.to_dict") as mock_to_dict: + mock_to_dict.return_value = { + "search_term_view": {"search_term": "test query", "status": "ADDED"}, + "segments": {"keyword": {"info": {"text": "test keyword", "match_type": "BROAD"}}}, + "ad_group": {"id": "1", "name": "Test Ad Group"}, + "campaign": {"id": "2", "name": "Test Campaign"}, + "metrics": { + "impressions": "100", + "clicks": "10", + "ctr": 0.1, + "average_cpc": 500000, + "cost_micros": 5000000, + "conversions": 1.0, + "conversions_value": 10.0, + }, + } + + result = await google_ads.execute_action( + "retrieve_search_terms", + {**BASE_INPUTS, "date_ranges": ["2025-05-14_2025-05-20"]}, + mock_context, + ) + + assert result.type == ResultType.ACTION + data = result.result.data["results"][0]["data"] + assert len(data) == 1 + entry = data[0] + assert entry["search_term"] == "test query" + assert entry["status"] == "ADDED" + assert entry["matched_keyword"] == "test keyword" + assert entry["match_type"] == "BROAD" + assert entry["ad_group_id"] == "1" + assert entry["ad_group_name"] == "Test Ad Group" + assert entry["campaign_id"] == "2" + assert entry["campaign_name"] == "Test Campaign" + + +@pytest.mark.asyncio +async def test_retrieve_search_terms_api_error(mock_context, mock_gads_client): + mock_gads_client.get_service.return_value.search.side_effect = Exception("Internal server error") + result = await google_ads.execute_action( + "retrieve_search_terms", + {**BASE_INPUTS, "date_ranges": ["2025-05-14_2025-05-20"]}, + mock_context, + ) + assert result.type != ResultType.ACTION + assert "Internal server error" in str(result.result) + + +@pytest.mark.asyncio +async def test_retrieve_search_terms_campaign_filter(mock_context, mock_gads_client): + """campaign_ids filter is accepted and search is called.""" + mock_gads_client.get_service.return_value.search.return_value = [] + result = await google_ads.execute_action( + "retrieve_search_terms", + { + **BASE_INPUTS, + "date_ranges": ["2025-05-14_2025-05-20"], + "campaign_ids": ["111", "222"], + }, + mock_context, + ) + assert result.type == ResultType.ACTION + mock_gads_client.get_service.return_value.search.assert_called_once() + call_args = mock_gads_client.get_service.return_value.search.call_args + assert "111" in call_args.kwargs.get("query", "") or "111" in str(call_args) + + +# --------------------------------------------------------------------------- +# get_active_ad_urls +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_active_ad_urls_returns_active_ads(mock_context, mock_gads_client): + mock_gads_client.get_service.return_value.search.return_value = [] + result = await google_ads.execute_action("get_active_ad_urls", {**BASE_INPUTS}, mock_context) + assert result.type == ResultType.ACTION + assert "active_ads" in result.result.data + assert "total_count" in result.result.data + assert result.result.data["total_count"] == 0 + assert result.result.data["active_ads"] == [] + + +@pytest.mark.asyncio +async def test_get_active_ad_urls_auth_error(mock_context): + mock_context.auth = {"credentials": {}} + result = await google_ads.execute_action("get_active_ad_urls", {**BASE_INPUTS}, mock_context) + assert result.type == ResultType.ACTION_ERROR + + +@pytest.mark.asyncio +async def test_get_active_ad_urls_api_error(mock_context, mock_gads_client): + mock_gads_client.get_service.return_value.search.side_effect = Exception("Network error") + result = await google_ads.execute_action("get_active_ad_urls", {**BASE_INPUTS}, mock_context) + assert result.type == ResultType.ACTION_ERROR + assert "Network error" in result.result.message + + +@pytest.mark.asyncio +async def test_get_active_ad_urls_url_filter_applied(mock_context, mock_gads_client): + """Only ads whose final_urls contain the filter string should be returned.""" + row_matching = MagicMock() + row_non_matching = MagicMock() + mock_gads_client.get_service.return_value.search.return_value = [ + row_matching, + row_non_matching, + ] + + matching_data = { + "campaign": {"id": "1", "name": "Camp A", "status": "ENABLED"}, + "ad_group": {"id": "10", "name": "AG A", "status": "ENABLED"}, + "ad_group_ad": { + "status": "ENABLED", + "ad": { + "id": "100", + "name": "Ad A", + "type": "RESPONSIVE_SEARCH_AD", + "final_urls": ["https://example.com/landing"], + "final_mobile_urls": [], + "tracking_url_template": "", + }, + }, + } + non_matching_data = { + "campaign": {"id": "2", "name": "Camp B", "status": "ENABLED"}, + "ad_group": {"id": "20", "name": "AG B", "status": "ENABLED"}, + "ad_group_ad": { + "status": "ENABLED", + "ad": { + "id": "200", + "name": "Ad B", + "type": "RESPONSIVE_SEARCH_AD", + "final_urls": ["https://other.com/page"], + "final_mobile_urls": [], + "tracking_url_template": "", + }, + }, + } + + with patch("proto.Message.to_dict") as mock_to_dict: + mock_to_dict.side_effect = [matching_data, non_matching_data] + + result = await google_ads.execute_action( + "get_active_ad_urls", + {**BASE_INPUTS, "url_filter": "example.com"}, + mock_context, + ) + + assert result.type == ResultType.ACTION + assert result.result.data["total_count"] == 1 + assert result.result.data["active_ads"][0]["ad_id"] == "100" + assert "https://example.com/landing" in result.result.data["active_ads"][0]["final_urls"] + + +@pytest.mark.asyncio +async def test_get_active_ad_urls_no_filter_returns_all(mock_context, mock_gads_client): + """Without url_filter all ads are returned.""" + row1 = MagicMock() + row2 = MagicMock() + mock_gads_client.get_service.return_value.search.return_value = [row1, row2] + + ad_data_template = { + "campaign": {"id": "1", "name": "Camp", "status": "ENABLED"}, + "ad_group": {"id": "10", "name": "AG", "status": "ENABLED"}, + "ad_group_ad": { + "status": "ENABLED", + "ad": { + "id": "100", + "name": "Ad", + "type": "RESPONSIVE_SEARCH_AD", + "final_urls": ["https://any.com/page"], + "final_mobile_urls": [], + "tracking_url_template": "", + }, + }, + } + + with patch("proto.Message.to_dict", return_value=ad_data_template): + result = await google_ads.execute_action("get_active_ad_urls", {**BASE_INPUTS}, mock_context) + + assert result.type == ResultType.ACTION + assert result.result.data["total_count"] == 2