diff --git a/src/adloop/ads/conversion_actions.py b/src/adloop/ads/conversion_actions.py new file mode 100644 index 0000000..a6bd276 --- /dev/null +++ b/src/adloop/ads/conversion_actions.py @@ -0,0 +1,558 @@ +"""Conversion-action write tools — Google Ads ConversionActionService. + +All operations follow the AdLoop safety pattern: + 1. draft_* → creates a ChangePlan, stores it, returns plan_id + 2. confirm_and_apply(plan_id) → executes via the Google Ads API + +Supported types (conversion_action.type): + AD_CALL — calls from Call assets in ads + WEBSITE_CALL — Google Forwarding Number calls (uses + phone_call_duration_seconds threshold) + WEBPAGE — page-load conversions with code-based tracking + WEBPAGE_CODELESS — page-load conversions detected by Ads (no snippet) + GOOGLE_ANALYTICS_4_CUSTOM — imported from GA4 (custom event) + GOOGLE_ANALYTICS_4_PURCHASE — imported from GA4 (purchase event) + UPLOAD_CALLS, UPLOAD_CLICKS — offline imports + +NOT supported here (Google manages them — mutations are rejected with +MUTATE_NOT_ALLOWED): + SMART_CAMPAIGN_* — auto-created by Smart Campaigns + GOOGLE_HOSTED — auto-created by Google Business Profile / LSA links +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from adloop.ads.enums import enum_names + +if TYPE_CHECKING: + from adloop.config import AdLoopConfig + + +# Pulled dynamically from the google-ads SDK at the API version we're +# pinned to (see adloop.ads.client.GOOGLE_ADS_API_VERSION). Keeps the +# validators in sync with whatever the SDK supports — no hand-maintained +# parallel lists to drift. +_VALID_TYPES = enum_names("ConversionActionTypeEnum") +_VALID_CATEGORIES = enum_names("ConversionActionCategoryEnum") +_VALID_COUNTING_TYPES = enum_names("ConversionActionCountingTypeEnum") +_VALID_ATTRIBUTION_MODELS = enum_names("AttributionModelEnum") + +# These types ARE in ConversionActionTypeEnum but Google rejects mutations +# on them with MUTATE_NOT_ALLOWED (they're auto-created by Smart Campaigns, +# Local Services, and Business Profile links). We don't filter them from +# `_VALID_TYPES` — the SDK accepts them syntactically — but warn callers. +_AUTO_MANAGED_TYPES = frozenset({ + "SMART_CAMPAIGN_TRACKED_CALLS", + "SMART_CAMPAIGN_MAP_DIRECTIONS", + "SMART_CAMPAIGN_MAP_CLICKS_TO_CALL", + "SMART_CAMPAIGN_AD_CLICKS_TO_CALL", + "GOOGLE_HOSTED", +}) + + +# --------------------------------------------------------------------------- +# Validators +# --------------------------------------------------------------------------- + + +def _validate_create_inputs( + *, + name: str, + type_: str, + category: str, + counting_type: str, + default_value: float, + currency_code: str, + phone_call_duration_seconds: int, + click_through_window_days: int, + view_through_window_days: int, + attribution_model: str, +) -> list[str]: + errors: list[str] = [] + if not name or not name.strip(): + errors.append("name is required") + if type_ not in _VALID_TYPES: + errors.append( + f"type '{type_}' invalid; valid: {sorted(_VALID_TYPES)}" + ) + if category and category not in _VALID_CATEGORIES: + errors.append( + f"category '{category}' invalid; valid: {sorted(_VALID_CATEGORIES)}" + ) + if counting_type and counting_type not in _VALID_COUNTING_TYPES: + errors.append( + f"counting_type '{counting_type}' invalid; valid: " + f"{sorted(_VALID_COUNTING_TYPES)}" + ) + if default_value < 0: + errors.append("default_value must be >= 0") + if currency_code and len(currency_code) != 3: + errors.append( + f"currency_code '{currency_code}' must be a 3-letter ISO code" + ) + if phone_call_duration_seconds and phone_call_duration_seconds < 0: + errors.append("phone_call_duration_seconds must be >= 0") + if (click_through_window_days + and not (1 <= click_through_window_days <= 90)): + errors.append( + "click_through_window_days must be between 1 and 90" + ) + if (view_through_window_days + and not (1 <= view_through_window_days <= 30)): + errors.append( + "view_through_window_days must be between 1 and 30" + ) + if attribution_model and attribution_model not in _VALID_ATTRIBUTION_MODELS: + errors.append( + f"attribution_model '{attribution_model}' invalid; valid: " + f"{sorted(_VALID_ATTRIBUTION_MODELS)}" + ) + return errors + + +def _validate_update_inputs( + *, + counting_type: str, + default_value: float, + currency_code: str, + phone_call_duration_seconds: int, + click_through_window_days: int, + view_through_window_days: int, + attribution_model: str, +) -> list[str]: + errors: list[str] = [] + if counting_type and counting_type not in _VALID_COUNTING_TYPES: + errors.append( + f"counting_type '{counting_type}' invalid; valid: " + f"{sorted(_VALID_COUNTING_TYPES)}" + ) + if default_value < 0: + errors.append("default_value must be >= 0") + if currency_code and len(currency_code) != 3: + errors.append( + f"currency_code '{currency_code}' must be a 3-letter ISO code" + ) + if phone_call_duration_seconds and phone_call_duration_seconds < 0: + errors.append("phone_call_duration_seconds must be >= 0") + if (click_through_window_days + and not (1 <= click_through_window_days <= 90)): + errors.append( + "click_through_window_days must be between 1 and 90" + ) + if (view_through_window_days + and not (1 <= view_through_window_days <= 30)): + errors.append( + "view_through_window_days must be between 1 and 30" + ) + if attribution_model and attribution_model not in _VALID_ATTRIBUTION_MODELS: + errors.append( + f"attribution_model '{attribution_model}' invalid; valid: " + f"{sorted(_VALID_ATTRIBUTION_MODELS)}" + ) + return errors + + +# --------------------------------------------------------------------------- +# Draft tools (return PREVIEW + plan_id) +# --------------------------------------------------------------------------- + + +def draft_create_conversion_action( + config: AdLoopConfig, + *, + customer_id: str = "", + name: str, + type_: str, + category: str = "DEFAULT", + default_value: float = 0, + currency_code: str = "USD", + always_use_default_value: bool = False, + counting_type: str = "ONE_PER_CLICK", + phone_call_duration_seconds: int = 0, + primary_for_goal: bool = True, + include_in_conversions_metric: bool = True, + click_through_window_days: int = 0, + view_through_window_days: int = 0, + attribution_model: str = "", +) -> dict: + """Draft a new ConversionAction — returns a PREVIEW. + + type_: the ConversionAction.type enum value (AD_CALL, WEBSITE_CALL, + WEBPAGE, WEBPAGE_CODELESS, GOOGLE_ANALYTICS_4_CUSTOM, etc.). + category: the conversion category (PHONE_CALL_LEAD, SUBMIT_LEAD_FORM, + PURCHASE, etc.). Defaults to DEFAULT. + default_value: monetary value attributed to each conversion. + always_use_default_value: when True, transaction values from the + snippet/import are ignored and default_value is used instead. When + False with a positive default_value, Google treats default_value as + a fallback ("tag value with fallback"). Passing a positive + default_value with this flag False is a legal config — the draft + surfaces a warning (see below) but does NOT flip the flag for you. + counting_type: ONE_PER_CLICK (recommended for lead gen — one click, + one conversion no matter how many events fire) or MANY_PER_CLICK + (better for ecommerce where multiple purchases per click are real). + phone_call_duration_seconds: ONLY meaningful for PHONE_CALL_LEAD + category. The call must last at least this many seconds to count. + primary_for_goal: True = drives Smart Bidding optimization; + False = Secondary (records but doesn't affect bidding). + include_in_conversions_metric: True (default) = appears in the + "Conversions" column; False = "All conversions" only. NOTE: this is + IMMUTABLE on create — Google derives it from the category and rejects + any value set in the create mutate. To change it, use + draft_update_conversion_action after the create succeeds. + click_through_window_days / view_through_window_days: attribution + windows. 30/1 is the typical lead-gen pair. + attribution_model: leave empty for the default. For data-driven, + pass GOOGLE_SEARCH_ATTRIBUTION_DATA_DRIVEN. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("create_conversion_action", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors = _validate_create_inputs( + name=name, + type_=type_, + category=category, + counting_type=counting_type, + default_value=default_value, + currency_code=currency_code, + phone_call_duration_seconds=phone_call_duration_seconds, + click_through_window_days=click_through_window_days, + view_through_window_days=view_through_window_days, + attribution_model=attribution_model, + ) + if errors: + return {"error": "Validation failed", "details": errors} + + warnings: list[str] = [] + + # A positive default_value paired with always_use_default_value=False is a + # LEGAL config: Google treats default_value as a fallback when the + # snippet/import supplies no value ("tag value with fallback"). We used to + # silently force the flag to True, which turned that fallback config into + # an unconditional override — a real change in accounting the caller never + # asked for. Surface it as a preview warning instead and leave the flag + # exactly as the caller set it. + if default_value > 0 and not always_use_default_value: + warnings.append( + "default_value is set but always_use_default_value is False: " + "Google will treat default_value as a FALLBACK, used only when " + "the tag/import provides no value. If you want default_value to " + "override every conversion's value, set " + "always_use_default_value=True explicitly." + ) + + if type_ in _AUTO_MANAGED_TYPES: + warnings.append( + f"type '{type_}' is auto-managed by Google (Smart Campaigns / " + "Business Profile). Mutations are rejected with MUTATE_NOT_ALLOWED." + ) + + plan = ChangePlan( + operation="create_conversion_action", + entity_type="conversion_action", + entity_id="", + customer_id=customer_id, + changes={ + "name": name.strip(), + "type": type_, + "category": category, + "default_value": float(default_value), + "currency_code": currency_code.upper(), + "always_use_default_value": bool(always_use_default_value), + "counting_type": counting_type, + "phone_call_duration_seconds": int(phone_call_duration_seconds or 0), + "primary_for_goal": bool(primary_for_goal), + "include_in_conversions_metric": bool(include_in_conversions_metric), + "click_through_window_days": int(click_through_window_days or 0), + "view_through_window_days": int(view_through_window_days or 0), + "attribution_model": attribution_model, + }, + ) + store_plan(plan) + preview = plan.to_preview() + if warnings: + preview["warnings"] = warnings + return preview + + +def draft_update_conversion_action( + config: AdLoopConfig, + *, + customer_id: str = "", + conversion_action_id: str, + name: str = "", + primary_for_goal: bool | None = None, + default_value: float = 0, + currency_code: str = "", + always_use_default_value: bool | None = None, + counting_type: str = "", + phone_call_duration_seconds: int = 0, + include_in_conversions_metric: bool | None = None, + click_through_window_days: int = 0, + view_through_window_days: int = 0, + attribution_model: str = "", +) -> dict: + """Draft a partial UPDATE of an existing ConversionAction — returns PREVIEW. + + Only the parameters you pass non-empty/non-default will be sent to the + API. Use this to rename, demote a Primary to Secondary, change value, + adjust the call-duration threshold, or change attribution settings. + include_in_conversions_metric IS mutable here (unlike on create). + + conversion_action_id: numeric ID. Find via: + SELECT conversion_action.id, conversion_action.name FROM conversion_action + + Note: Google rejects mutations on SMART_CAMPAIGN_* and GOOGLE_HOSTED + types with MUTATE_NOT_ALLOWED. Catch and report this at apply time. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("update_conversion_action", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + if not conversion_action_id: + return {"error": "conversion_action_id is required"} + + errors = _validate_update_inputs( + counting_type=counting_type, + default_value=default_value, + currency_code=currency_code, + phone_call_duration_seconds=phone_call_duration_seconds, + click_through_window_days=click_through_window_days, + view_through_window_days=view_through_window_days, + attribution_model=attribution_model, + ) + if errors: + return {"error": "Validation failed", "details": errors} + + # Track which fields the caller actually wants to update so we build + # the right field_mask at apply time. + changes: dict = {"conversion_action_id": str(conversion_action_id)} + if name: + changes["name"] = name.strip() + if primary_for_goal is not None: + changes["primary_for_goal"] = bool(primary_for_goal) + if default_value: + changes["default_value"] = float(default_value) + if currency_code: + changes["currency_code"] = currency_code.upper() + if always_use_default_value is not None: + changes["always_use_default_value"] = bool(always_use_default_value) + if counting_type: + changes["counting_type"] = counting_type + if phone_call_duration_seconds: + changes["phone_call_duration_seconds"] = int(phone_call_duration_seconds) + if include_in_conversions_metric is not None: + changes["include_in_conversions_metric"] = bool( + include_in_conversions_metric + ) + if click_through_window_days: + changes["click_through_window_days"] = int(click_through_window_days) + if view_through_window_days: + changes["view_through_window_days"] = int(view_through_window_days) + if attribution_model: + changes["attribution_model"] = attribution_model + + if len(changes) == 1: # only conversion_action_id + return {"error": "No fields to update"} + + warnings: list[str] = [] + # Same fallback-vs-override nuance as create: on update, a positive + # default_value with always_use_default_value explicitly set to False is + # legal (fallback). Warn rather than silently overriding intent. + if changes.get("default_value", 0) > 0 and ( + changes.get("always_use_default_value") is False + ): + warnings.append( + "default_value is set but always_use_default_value is False: " + "Google will treat default_value as a FALLBACK, used only when " + "the tag/import provides no value. Set " + "always_use_default_value=True explicitly to override every " + "conversion's value." + ) + + plan = ChangePlan( + operation="update_conversion_action", + entity_type="conversion_action", + entity_id=str(conversion_action_id), + customer_id=customer_id, + changes=changes, + ) + store_plan(plan) + preview = plan.to_preview() + if warnings: + preview["warnings"] = warnings + return preview + + +def draft_remove_conversion_action( + config: AdLoopConfig, + *, + customer_id: str = "", + conversion_action_id: str, +) -> dict: + """Draft a REMOVAL of a ConversionAction — returns PREVIEW. + + Removed conversion actions stop counting and disappear from goal lists. + Historical data is preserved. SMART_CAMPAIGN_* and GOOGLE_HOSTED types + cannot be removed via API (Google manages them); the apply will fail + with MUTATE_NOT_ALLOWED for those. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("remove_conversion_action", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + if not conversion_action_id: + return {"error": "conversion_action_id is required"} + + plan = ChangePlan( + operation="remove_conversion_action", + entity_type="conversion_action", + entity_id=str(conversion_action_id), + customer_id=customer_id, + changes={"conversion_action_id": str(conversion_action_id)}, + ) + store_plan(plan) + preview = plan.to_preview() + preview["warnings"] = [ + "Removing a ConversionAction is irreversible. Smart Campaign / GBP-" + "managed types reject mutation with MUTATE_NOT_ALLOWED." + ] + return preview + + +# --------------------------------------------------------------------------- +# Apply handlers +# --------------------------------------------------------------------------- + + +def _apply_create_conversion_action(client: object, cid: str, changes: dict) -> dict: + """Create a new ConversionAction.""" + svc = client.get_service("ConversionActionService") + op = client.get_type("ConversionActionOperation") + ca = op.create + ca.name = changes["name"] + ca.type_ = getattr(client.enums.ConversionActionTypeEnum, changes["type"]) + ca.category = getattr( + client.enums.ConversionActionCategoryEnum, changes["category"] + ) + ca.status = client.enums.ConversionActionStatusEnum.ENABLED + ca.counting_type = getattr( + client.enums.ConversionActionCountingTypeEnum, changes["counting_type"] + ) + ca.value_settings.default_value = changes["default_value"] + ca.value_settings.default_currency_code = changes["currency_code"] + ca.value_settings.always_use_default_value = changes["always_use_default_value"] + ca.primary_for_goal = changes["primary_for_goal"] + # NOTE: include_in_conversions_metric is IMMUTABLE on create — Google + # derives it from the conversion category and rejects any value set in + # the create mutate (IMMUTABLE_FIELD). To change it, use + # draft_update_conversion_action after the create succeeds. + if changes.get("phone_call_duration_seconds"): + ca.phone_call_duration_seconds = changes["phone_call_duration_seconds"] + if changes.get("click_through_window_days"): + ca.click_through_lookback_window_days = changes["click_through_window_days"] + if changes.get("view_through_window_days"): + ca.view_through_lookback_window_days = changes["view_through_window_days"] + if changes.get("attribution_model"): + ca.attribution_model_settings.attribution_model = getattr( + client.enums.AttributionModelEnum, changes["attribution_model"] + ) + + response = svc.mutate_conversion_actions( + customer_id=cid, operations=[op] + ) + return {"resource_name": response.results[0].resource_name} + + +def _apply_update_conversion_action(client: object, cid: str, changes: dict) -> dict: + """Partial update of an existing ConversionAction. + + Builds a FieldMask listing only the fields the caller wanted to update. + """ + from google.protobuf import field_mask_pb2 + + svc = client.get_service("ConversionActionService") + op = client.get_type("ConversionActionOperation") + ca = op.update + ca.resource_name = svc.conversion_action_path( + cid, changes["conversion_action_id"] + ) + + paths: list[str] = [] + + if "name" in changes: + ca.name = changes["name"] + paths.append("name") + if "primary_for_goal" in changes: + ca.primary_for_goal = changes["primary_for_goal"] + paths.append("primary_for_goal") + if "default_value" in changes: + ca.value_settings.default_value = changes["default_value"] + paths.append("value_settings.default_value") + if "currency_code" in changes: + ca.value_settings.default_currency_code = changes["currency_code"] + paths.append("value_settings.default_currency_code") + if "always_use_default_value" in changes: + ca.value_settings.always_use_default_value = changes["always_use_default_value"] + paths.append("value_settings.always_use_default_value") + if "counting_type" in changes: + ca.counting_type = getattr( + client.enums.ConversionActionCountingTypeEnum, changes["counting_type"] + ) + paths.append("counting_type") + if "phone_call_duration_seconds" in changes: + ca.phone_call_duration_seconds = changes["phone_call_duration_seconds"] + paths.append("phone_call_duration_seconds") + if "include_in_conversions_metric" in changes: + ca.include_in_conversions_metric = changes["include_in_conversions_metric"] + paths.append("include_in_conversions_metric") + if "click_through_window_days" in changes: + ca.click_through_lookback_window_days = changes["click_through_window_days"] + paths.append("click_through_lookback_window_days") + if "view_through_window_days" in changes: + ca.view_through_lookback_window_days = changes["view_through_window_days"] + paths.append("view_through_lookback_window_days") + if "attribution_model" in changes: + ca.attribution_model_settings.attribution_model = getattr( + client.enums.AttributionModelEnum, changes["attribution_model"] + ) + paths.append("attribution_model_settings.attribution_model") + + op.update_mask.CopyFrom(field_mask_pb2.FieldMask(paths=paths)) + response = svc.mutate_conversion_actions( + customer_id=cid, operations=[op] + ) + return {"resource_name": response.results[0].resource_name} + + +def _apply_remove_conversion_action(client: object, cid: str, changes: dict) -> dict: + """Remove a ConversionAction (sets status=REMOVED).""" + svc = client.get_service("ConversionActionService") + op = client.get_type("ConversionActionOperation") + op.remove = svc.conversion_action_path( + cid, changes["conversion_action_id"] + ) + response = svc.mutate_conversion_actions( + customer_id=cid, operations=[op] + ) + return {"resource_name": response.results[0].resource_name} diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index ed25b96..7117b26 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -2533,6 +2533,14 @@ def _execute_plan(config: AdLoopConfig, plan: object) -> dict: client = get_ads_client(config) cid = normalize_customer_id(plan.customer_id) + # Conversion-action CRUD lives in its own module; import lazily so the + # dispatch table (and this module) don't take the dependency at import time. + from adloop.ads.conversion_actions import ( + _apply_create_conversion_action, + _apply_remove_conversion_action, + _apply_update_conversion_action, + ) + dispatch = { "create_campaign": _apply_create_campaign, "create_ad_group": _apply_create_ad_group, @@ -2554,6 +2562,9 @@ def _execute_plan(config: AdLoopConfig, plan: object) -> dict: "create_structured_snippets": _apply_create_structured_snippets, "create_image_assets": _apply_create_image_assets, "create_sitelinks": _apply_create_sitelinks, + "create_conversion_action": _apply_create_conversion_action, + "update_conversion_action": _apply_update_conversion_action, + "remove_conversion_action": _apply_remove_conversion_action, } handler = dispatch.get(plan.operation) diff --git a/src/adloop/server.py b/src/adloop/server.py index 5ebe0ff..c692b58 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -2070,6 +2070,130 @@ def draft_key_event( ) +@mcp.tool(annotations=_WRITE) +@_safe +def draft_create_conversion_action( + name: str, + type_: str, + category: str = "DEFAULT", + default_value: float = 0, + currency_code: str = "USD", + always_use_default_value: bool = False, + counting_type: str = "ONE_PER_CLICK", + phone_call_duration_seconds: int = 0, + primary_for_goal: bool = True, + include_in_conversions_metric: bool = True, + click_through_window_days: int = 0, + view_through_window_days: int = 0, + attribution_model: str = "", + customer_id: str = "", +) -> dict: + """Draft a new Google Ads ConversionAction — returns a PREVIEW. + + type_: AD_CALL, WEBSITE_CALL, WEBPAGE, WEBPAGE_CODELESS, + GOOGLE_ANALYTICS_4_CUSTOM, etc. category: PHONE_CALL_LEAD, + SUBMIT_LEAD_FORM, PURCHASE, ... (default DEFAULT). + + A positive default_value with always_use_default_value=False is a legal + "fallback" config — the preview warns but does NOT flip the flag. + include_in_conversions_metric is IMMUTABLE on create (change it later via + draft_update_conversion_action). Call confirm_and_apply with the returned + plan_id to execute. + """ + from adloop.ads.conversion_actions import ( + draft_create_conversion_action as _impl, + ) + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + name=name, + type_=type_, + category=category, + default_value=default_value, + currency_code=currency_code, + always_use_default_value=always_use_default_value, + counting_type=counting_type, + phone_call_duration_seconds=phone_call_duration_seconds, + primary_for_goal=primary_for_goal, + include_in_conversions_metric=include_in_conversions_metric, + click_through_window_days=click_through_window_days, + view_through_window_days=view_through_window_days, + attribution_model=attribution_model, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_update_conversion_action( + conversion_action_id: str, + name: str = "", + primary_for_goal: bool | None = None, + default_value: float = 0, + currency_code: str = "", + always_use_default_value: bool | None = None, + counting_type: str = "", + phone_call_duration_seconds: int = 0, + include_in_conversions_metric: bool | None = None, + click_through_window_days: int = 0, + view_through_window_days: int = 0, + attribution_model: str = "", + customer_id: str = "", +) -> dict: + """Draft a partial UPDATE of an existing ConversionAction — returns PREVIEW. + + Only the parameters you pass non-empty/non-default are sent to the API. + Use to rename, demote a Primary to Secondary, change value, adjust the + call-duration threshold, or change attribution. Find the ID via: + SELECT conversion_action.id, conversion_action.name FROM conversion_action. + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.conversion_actions import ( + draft_update_conversion_action as _impl, + ) + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + conversion_action_id=conversion_action_id, + name=name, + primary_for_goal=primary_for_goal, + default_value=default_value, + currency_code=currency_code, + always_use_default_value=always_use_default_value, + counting_type=counting_type, + phone_call_duration_seconds=phone_call_duration_seconds, + include_in_conversions_metric=include_in_conversions_metric, + click_through_window_days=click_through_window_days, + view_through_window_days=view_through_window_days, + attribution_model=attribution_model, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_remove_conversion_action( + conversion_action_id: str, + customer_id: str = "", +) -> dict: + """Draft a REMOVAL of a ConversionAction — returns a PREVIEW. + + Removal stops counting and drops the action from goal lists; historical + data is preserved but the action is irreversible. SMART_CAMPAIGN_* and + GOOGLE_HOSTED types reject removal with MUTATE_NOT_ALLOWED. Call + confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.conversion_actions import ( + draft_remove_conversion_action as _impl, + ) + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + conversion_action_id=conversion_action_id, + ) + + @mcp.tool(annotations=_READONLY) @_safe def validate_tracking( diff --git a/tests/test_conversion_actions.py b/tests/test_conversion_actions.py new file mode 100644 index 0000000..84ca36f --- /dev/null +++ b/tests/test_conversion_actions.py @@ -0,0 +1,600 @@ +"""Tests for conversion-action write tools (create / update / remove).""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from google.ads.googleads.client import GoogleAdsClient + +from adloop.ads import conversion_actions, write +from adloop.ads.client import GOOGLE_ADS_API_VERSION +from adloop.config import AdLoopConfig, AdsConfig, SafetyConfig +from adloop.safety import preview as preview_store + + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + + +class _FakeResult: + def __init__(self, resource_name: str = ""): + self.resource_name = resource_name + + +class _FakeConversionActionService: + def __init__(self, results: list[_FakeResult] | None = None): + self.operations: list = [] + self.results_to_return = results or [] + + def conversion_action_path(self, customer_id: str, ca_id: str) -> str: + return f"customers/{customer_id}/conversionActions/{ca_id}" + + def mutate_conversion_actions( + self, customer_id: str, operations: list + ) -> object: + self.operations = operations + return SimpleNamespace(results=self.results_to_return) + + +class _FakeClient: + def __init__(self, services: dict[str, object] | None = None): + self._base = GoogleAdsClient( + credentials=None, + developer_token="test-token", + use_proto_plus=True, + version=GOOGLE_ADS_API_VERSION, + ) + self.enums = self._base.enums + self.get_type = self._base.get_type + self._services = services or {} + + def get_service(self, name: str) -> object: + return self._services[name] + + +@pytest.fixture(autouse=True) +def clear_pending_plans(): + # v0.12 runtime: plans live in a swappable store, scoped per tenant. + preview_store.set_plan_store(preview_store.InMemoryPlanStore()) + yield + preview_store.set_plan_store(preview_store.InMemoryPlanStore()) + + +@pytest.fixture +def config() -> AdLoopConfig: + return AdLoopConfig( + ads=AdsConfig(customer_id="123-456-7890"), + safety=SafetyConfig(require_dry_run=True), + ) + + +def _stored_plan(result: dict): + """Fetch the stored ChangePlan for a draft result (current tenant).""" + plan = preview_store.get_plan(result["plan_id"]) + assert plan is not None, "plan was not stored" + return plan + + +# --------------------------------------------------------------------------- +# Validation tests for draft_create_conversion_action +# --------------------------------------------------------------------------- + + +class TestDraftCreateConversionActionValidation: + def _ok_args(self, **overrides): + defaults = dict( + customer_id="1234567890", + name="Calls from Ads", + type_="AD_CALL", + category="PHONE_CALL_LEAD", + default_value=250, + currency_code="USD", + ) + defaults.update(overrides) + return defaults + + def test_happy_path(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args() + ) + assert "error" not in result + plan = _stored_plan(result) + assert plan.changes["name"] == "Calls from Ads" + assert plan.changes["type"] == "AD_CALL" + assert plan.changes["default_value"] == 250.0 + assert plan.changes["currency_code"] == "USD" + assert plan.changes["counting_type"] == "ONE_PER_CLICK" + assert plan.changes["primary_for_goal"] is True + + def test_name_required(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(name="") + ) + assert result["error"] == "Validation failed" + assert any("name is required" in d for d in result["details"]) + + def test_invalid_type(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(type_="MADE_UP_TYPE") + ) + assert result["error"] == "Validation failed" + assert any("MADE_UP_TYPE" in d for d in result["details"]) + + def test_invalid_category(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(category="WRONG_CATEGORY") + ) + assert result["error"] == "Validation failed" + assert any("WRONG_CATEGORY" in d for d in result["details"]) + + def test_invalid_counting_type(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(counting_type="WRONG") + ) + assert result["error"] == "Validation failed" + assert any("counting_type" in d for d in result["details"]) + + def test_negative_default_value_rejected(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(default_value=-1) + ) + assert result["error"] == "Validation failed" + assert any("default_value" in d for d in result["details"]) + + def test_invalid_currency_length(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(currency_code="USDX") + ) + assert result["error"] == "Validation failed" + assert any("currency_code" in d for d in result["details"]) + + def test_invalid_click_through_window(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(click_through_window_days=120) + ) + assert result["error"] == "Validation failed" + assert any("click_through_window_days" in d for d in result["details"]) + + def test_invalid_view_through_window(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(view_through_window_days=60) + ) + assert result["error"] == "Validation failed" + assert any("view_through_window_days" in d for d in result["details"]) + + def test_invalid_attribution_model(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(attribution_model="MAGIC") + ) + assert result["error"] == "Validation failed" + assert any("attribution_model" in d for d in result["details"]) + + def test_phone_call_duration_threshold_persisted(self, config): + result = conversion_actions.draft_create_conversion_action( + config, + **self._ok_args( + type_="WEBSITE_CALL", + phone_call_duration_seconds=90, + ), + ) + plan = _stored_plan(result) + assert plan.changes["phone_call_duration_seconds"] == 90 + + def test_default_value_with_fallback_flag_warns_not_flips(self, config): + """Maintainer fix #2: a positive default_value paired with + always_use_default_value=False is a LEGAL "tag value with fallback" + config. The draft must emit a PREVIEW WARNING and leave the flag + exactly as the caller set it — NOT silently force it to True (which + would turn a fallback into an unconditional override).""" + result = conversion_actions.draft_create_conversion_action( + config, + **self._ok_args(default_value=400, always_use_default_value=False), + ) + assert "error" not in result + # The flag is NOT force-set. + plan = _stored_plan(result) + assert plan.changes["default_value"] == 400.0 + assert plan.changes["always_use_default_value"] is False + # A warning surfaces the fallback-vs-override behavior. + assert "warnings" in result + assert any( + "fallback" in w.lower() and "always_use_default_value" in w + for w in result["warnings"] + ) + + def test_zero_default_value_no_warning(self, config): + """No warning when default_value is 0 — callers may legitimately + want to use snippet/import-provided values.""" + result = conversion_actions.draft_create_conversion_action( + config, + **self._ok_args(default_value=0, always_use_default_value=False), + ) + assert "error" not in result + plan = _stored_plan(result) + assert plan.changes["always_use_default_value"] is False + assert "warnings" not in result + + def test_explicit_always_use_default_value_true_no_warning(self, config): + """Explicit True is preserved and produces no fallback warning.""" + result = conversion_actions.draft_create_conversion_action( + config, + **self._ok_args(default_value=500, always_use_default_value=True), + ) + assert "error" not in result + plan = _stored_plan(result) + assert plan.changes["always_use_default_value"] is True + assert "warnings" not in result + + +# --------------------------------------------------------------------------- +# draft_update_conversion_action +# --------------------------------------------------------------------------- + + +class TestDraftUpdateConversionAction: + def test_id_required(self, config): + result = conversion_actions.draft_update_conversion_action( + config, customer_id="1", conversion_action_id="" + ) + assert "conversion_action_id is required" in result["error"] + + def test_no_fields_to_update_rejected(self, config): + result = conversion_actions.draft_update_conversion_action( + config, + customer_id="1", + conversion_action_id="6797442210", + ) + assert "No fields to update" in result["error"] + + def test_partial_update_only_includes_specified(self, config): + result = conversion_actions.draft_update_conversion_action( + config, + customer_id="1", + conversion_action_id="6797442210", + name="Calls from Ads (>=90s)", + primary_for_goal=False, + default_value=250, + currency_code="USD", + ) + plan = _stored_plan(result) + # specified fields present + assert plan.changes["name"] == "Calls from Ads (>=90s)" + assert plan.changes["primary_for_goal"] is False + assert plan.changes["default_value"] == 250.0 + assert plan.changes["currency_code"] == "USD" + # unspecified fields absent + assert "counting_type" not in plan.changes + assert "click_through_window_days" not in plan.changes + + def test_promote_to_primary(self, config): + result = conversion_actions.draft_update_conversion_action( + config, + customer_id="1", + conversion_action_id="6797442210", + primary_for_goal=True, + ) + plan = _stored_plan(result) + assert plan.changes["primary_for_goal"] is True + + def test_demote_to_secondary(self, config): + result = conversion_actions.draft_update_conversion_action( + config, + customer_id="1", + conversion_action_id="6797442210", + primary_for_goal=False, + ) + plan = _stored_plan(result) + assert plan.changes["primary_for_goal"] is False + + def test_invalid_counting_type_rejected(self, config): + result = conversion_actions.draft_update_conversion_action( + config, + customer_id="1", + conversion_action_id="6797442210", + counting_type="BAD", + ) + assert result["error"] == "Validation failed" + + def test_phone_duration_persisted(self, config): + result = conversion_actions.draft_update_conversion_action( + config, + customer_id="1", + conversion_action_id="6797442210", + phone_call_duration_seconds=90, + ) + plan = _stored_plan(result) + assert plan.changes["phone_call_duration_seconds"] == 90 + + def test_include_in_conversions_metric_is_mutable_on_update(self, config): + """Unlike create (where it's IMMUTABLE), the update path accepts + include_in_conversions_metric and passes it through.""" + result = conversion_actions.draft_update_conversion_action( + config, + customer_id="1", + conversion_action_id="6797442210", + include_in_conversions_metric=False, + ) + plan = _stored_plan(result) + assert plan.changes["include_in_conversions_metric"] is False + + def test_fallback_flag_warns_on_update(self, config): + """Fix #2 also applies on update: positive default_value with the + flag explicitly False warns rather than overriding intent.""" + result = conversion_actions.draft_update_conversion_action( + config, + customer_id="1", + conversion_action_id="6797442210", + default_value=300, + always_use_default_value=False, + ) + plan = _stored_plan(result) + assert plan.changes["always_use_default_value"] is False + assert plan.changes["default_value"] == 300.0 + assert "warnings" in result + assert any("fallback" in w.lower() for w in result["warnings"]) + + +# --------------------------------------------------------------------------- +# draft_remove_conversion_action +# --------------------------------------------------------------------------- + + +class TestDraftRemoveConversionAction: + def test_id_required(self, config): + result = conversion_actions.draft_remove_conversion_action( + config, customer_id="1", conversion_action_id="" + ) + assert "conversion_action_id is required" in result["error"] + + def test_emits_irreversible_warning(self, config): + result = conversion_actions.draft_remove_conversion_action( + config, customer_id="1", conversion_action_id="6797442210" + ) + assert "warnings" in result + assert any("irreversible" in w.lower() for w in result["warnings"]) + plan = _stored_plan(result) + assert plan.operation == "remove_conversion_action" + assert plan.entity_id == "6797442210" + + +# --------------------------------------------------------------------------- +# Apply handlers — exercised against fake services +# --------------------------------------------------------------------------- + + +class TestApplyCreateConversionAction: + def test_websitecall_with_duration_threshold(self): + ca_svc = _FakeConversionActionService( + [_FakeResult("customers/1/conversionActions/100")] + ) + client = _FakeClient({"ConversionActionService": ca_svc}) + + conversion_actions._apply_create_conversion_action( + client, + "1", + { + "name": "Website Call (GFN >=90s)", + "type": "WEBSITE_CALL", + "category": "PHONE_CALL_LEAD", + "default_value": 250.0, + "currency_code": "USD", + "always_use_default_value": True, + "counting_type": "ONE_PER_CLICK", + "phone_call_duration_seconds": 90, + "primary_for_goal": True, + "include_in_conversions_metric": True, + "click_through_window_days": 30, + "view_through_window_days": 1, + "attribution_model": "GOOGLE_SEARCH_ATTRIBUTION_DATA_DRIVEN", + }, + ) + + assert len(ca_svc.operations) == 1 + ca = ca_svc.operations[0].create + assert ca.name == "Website Call (GFN >=90s)" + assert ca.type_ == client.enums.ConversionActionTypeEnum.WEBSITE_CALL + assert ca.category == client.enums.ConversionActionCategoryEnum.PHONE_CALL_LEAD + assert ca.value_settings.default_value == 250.0 + assert ca.value_settings.default_currency_code == "USD" + assert ca.value_settings.always_use_default_value is True + assert ca.counting_type == client.enums.ConversionActionCountingTypeEnum.ONE_PER_CLICK + assert ca.primary_for_goal is True + assert ca.phone_call_duration_seconds == 90 + assert ca.click_through_lookback_window_days == 30 + assert ca.view_through_lookback_window_days == 1 + + def test_does_not_set_include_in_conversions_metric_on_create(self): + """Regression: Google's API treats include_in_conversions_metric + as IMMUTABLE on create (derived from category). Setting it in the + create mutate raises IMMUTABLE_FIELD. The apply function must + leave the proto field unset; callers who need to change it must + use draft_update_conversion_action after the create succeeds.""" + ca_svc = _FakeConversionActionService( + [_FakeResult("customers/1/conversionActions/100")] + ) + client = _FakeClient({"ConversionActionService": ca_svc}) + + conversion_actions._apply_create_conversion_action( + client, + "1", + { + "name": "Example Co - Call from Ad", + "type": "AD_CALL", + "category": "PHONE_CALL_LEAD", + "default_value": 400.0, + "currency_code": "USD", + "always_use_default_value": True, + "counting_type": "ONE_PER_CLICK", + "phone_call_duration_seconds": 0, + "primary_for_goal": False, + # Caller passes True (the tool's default), but the apply + # function must NOT propagate it into the proto on create. + "include_in_conversions_metric": True, + "click_through_window_days": 30, + "view_through_window_days": 0, + "attribution_model": "", + }, + ) + + assert len(ca_svc.operations) == 1 + ca = ca_svc.operations[0].create + # The proto3-optional field must not be explicitly set, otherwise + # Google rejects the mutate with IMMUTABLE_FIELD. + assert not ca._pb.HasField("include_in_conversions_metric") + + +class TestApplyUpdateConversionAction: + def test_partial_update_fieldmask(self): + ca_svc = _FakeConversionActionService( + [_FakeResult("customers/1/conversionActions/6797442210")] + ) + client = _FakeClient({"ConversionActionService": ca_svc}) + + conversion_actions._apply_update_conversion_action( + client, + "1", + { + "conversion_action_id": "6797442210", + "name": "Calls from Ads (>=90s)", + "default_value": 250.0, + "currency_code": "USD", + "always_use_default_value": True, + "counting_type": "ONE_PER_CLICK", + "primary_for_goal": True, + }, + ) + + op = ca_svc.operations[0] + ca = op.update + assert ca.resource_name == "customers/1/conversionActions/6797442210" + assert ca.name == "Calls from Ads (>=90s)" + assert ca.value_settings.default_value == 250.0 + assert ca.counting_type == client.enums.ConversionActionCountingTypeEnum.ONE_PER_CLICK + assert ca.primary_for_goal is True + # Field mask reflects exactly the keys we set + mask_paths = list(op.update_mask.paths) + assert "name" in mask_paths + assert "value_settings.default_value" in mask_paths + assert "value_settings.default_currency_code" in mask_paths + assert "value_settings.always_use_default_value" in mask_paths + assert "counting_type" in mask_paths + assert "primary_for_goal" in mask_paths + # Fields we didn't pass shouldn't be in the mask + assert "phone_call_duration_seconds" not in mask_paths + + def test_update_only_phone_duration(self): + ca_svc = _FakeConversionActionService( + [_FakeResult("customers/1/conversionActions/6797442210")] + ) + client = _FakeClient({"ConversionActionService": ca_svc}) + + conversion_actions._apply_update_conversion_action( + client, + "1", + { + "conversion_action_id": "6797442210", + "phone_call_duration_seconds": 90, + }, + ) + + op = ca_svc.operations[0] + ca = op.update + assert ca.phone_call_duration_seconds == 90 + mask_paths = list(op.update_mask.paths) + assert mask_paths == ["phone_call_duration_seconds"] + + def test_update_include_in_conversions_metric_fieldmask(self): + ca_svc = _FakeConversionActionService( + [_FakeResult("customers/1/conversionActions/6797442210")] + ) + client = _FakeClient({"ConversionActionService": ca_svc}) + + conversion_actions._apply_update_conversion_action( + client, + "1", + { + "conversion_action_id": "6797442210", + "include_in_conversions_metric": False, + }, + ) + + op = ca_svc.operations[0] + ca = op.update + assert ca.include_in_conversions_metric is False + assert list(op.update_mask.paths) == ["include_in_conversions_metric"] + + +class TestApplyRemoveConversionAction: + def test_remove_sets_resource_name(self): + ca_svc = _FakeConversionActionService( + [_FakeResult("customers/1/conversionActions/6797442210")] + ) + client = _FakeClient({"ConversionActionService": ca_svc}) + + conversion_actions._apply_remove_conversion_action( + client, + "1", + {"conversion_action_id": "6797442210"}, + ) + + op = ca_svc.operations[0] + assert op.remove == "customers/1/conversionActions/6797442210" + + +# --------------------------------------------------------------------------- +# MCP tool registration + dispatch wiring +# --------------------------------------------------------------------------- + + +class TestMCPRegistration: + @pytest.fixture(scope="class") + @classmethod + def tools_by_name(cls): + import asyncio + from adloop.server import mcp + + async def _list(): + return await mcp.list_tools() + + tools = asyncio.run(_list()) + return {t.name: t for t in tools} + + def test_three_conversion_action_tools_registered(self, tools_by_name): + for name in ( + "draft_create_conversion_action", + "draft_update_conversion_action", + "draft_remove_conversion_action", + ): + assert name in tools_by_name, f"{name} not registered" + + def test_create_required_params(self, tools_by_name): + required = ( + tools_by_name["draft_create_conversion_action"] + .parameters.get("required", []) + ) + assert "name" in required + assert "type_" in required + + def test_update_requires_id(self, tools_by_name): + required = ( + tools_by_name["draft_update_conversion_action"] + .parameters.get("required", []) + ) + assert "conversion_action_id" in required + + def test_remove_requires_id(self, tools_by_name): + required = ( + tools_by_name["draft_remove_conversion_action"] + .parameters.get("required", []) + ) + assert "conversion_action_id" in required + + def test_dispatch_routes(self): + """_execute_plan must map the three CRUD operations to the module's + apply handlers (same dispatch-dict style as the other Ads writes).""" + import inspect + src = inspect.getsource(write._execute_plan) + assert '"create_conversion_action": _apply_create_conversion_action' in src + assert '"update_conversion_action": _apply_update_conversion_action' in src + assert '"remove_conversion_action": _apply_remove_conversion_action' in src