diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index c14935f..9817978 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -70,6 +70,7 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog | `get_brand_list_brands` | Contents of a brand list, with `entity_id` (the MID) and `criterion_id` (needed for removal) | `shared_set_id` (required) | | `get_brand_list_campaigns` | Which campaigns a brand list is attached to, and whether each attachment excludes (`role: excluded`) or targets (`role: targeted`) | `shared_set_id` (optional) | | `get_ai_max_settings` | The AI Max knobs of Search campaigns: `enable_ai_max`, `bundling_required`, the full `asset_automation_settings` list, and each ad group's `disable_search_term_matching` | `campaign_id` (optional) | +| `get_campaign_settings` | Every setting that decides how far a Search campaign reaches, one flat record per campaign: networks, location option, campaign-level broad match, bidding and budget, tracking template / URL suffix / custom parameters, and the AI Max state. `widening_settings` and `insights[]` name anything that lets ads reach beyond the chosen keywords and locations. The one call for a recurring "did Google switch something on?" audit | `campaign_id` (optional) | | `get_custom_conversion_goals` | Custom conversion goals (named sets of conversion actions) and one campaign's goal config | `campaign_id` (optional) | | `run_gaql` | Custom queries not covered by other tools | `query`, `format` (table/json/csv) | diff --git a/.cursor/rules/adloop.mdc b/.cursor/rules/adloop.mdc index cd519aa..23b076c 100644 --- a/.cursor/rules/adloop.mdc +++ b/.cursor/rules/adloop.mdc @@ -72,6 +72,7 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog | `get_brand_list_brands` | Contents of a brand list, with `entity_id` (the MID) and `criterion_id` (needed for removal) | `shared_set_id` (required) | | `get_brand_list_campaigns` | Which campaigns a brand list is attached to, and whether each attachment excludes (`role: excluded`) or targets (`role: targeted`) | `shared_set_id` (optional) | | `get_ai_max_settings` | The AI Max knobs of Search campaigns: `enable_ai_max`, `bundling_required`, the full `asset_automation_settings` list, and each ad group's `disable_search_term_matching` | `campaign_id` (optional) | +| `get_campaign_settings` | Every setting that decides how far a Search campaign reaches, one flat record per campaign: networks, location option, campaign-level broad match, bidding and budget, tracking template / URL suffix / custom parameters, and the AI Max state. `widening_settings` and `insights[]` name anything that lets ads reach beyond the chosen keywords and locations. The one call for a recurring "did Google switch something on?" audit | `campaign_id` (optional) | | `get_custom_conversion_goals` | Custom conversion goals (named sets of conversion actions) and one campaign's goal config | `campaign_id` (optional) | | `run_gaql` | Custom queries not covered by other tools | `query`, `format` (table/json/csv) | diff --git a/README.md b/README.md index 4d3c02b..bf347a1 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ The best features come from real workflows. If you're using AdLoop and find your | `get_brand_list_brands` | List the brands inside a list, with the Commercial KG MID and the criterion ID for removals | | `get_brand_list_campaigns` | Which campaigns a brand list is attached to, and whether each attachment excludes or targets | | `get_ai_max_settings` | AI Max knobs per campaign and ad group — `enable_ai_max`, `bundling_required`, the full `asset_automation_settings` list and each ad group's `disable_search_term_matching` | +| `get_campaign_settings` | One flat record per Search campaign — networks, location option, campaign-level broad match, bidding, budget, URL options and AI Max — with the settings that widen reach flagged | | `get_custom_conversion_goals` | Custom conversion goals with their actions, plus one campaign's goal config | | `run_gaql` | Arbitrary GAQL queries for anything else | diff --git a/src/adloop/ads/campaign_settings.py b/src/adloop/ads/campaign_settings.py new file mode 100644 index 0000000..0eb9121 --- /dev/null +++ b/src/adloop/ads/campaign_settings.py @@ -0,0 +1,222 @@ +"""One read for the settings that decide how far a Search campaign reaches. + +A recurring audit ("has Google switched anything back on?") otherwise needs +``get_ai_max_settings`` plus hand-written GAQL for networks, location option, +campaign-level broad match and URL options. Every field below was checked +against the v25 ``campaign`` resource: + + campaign.network_settings.target_google_search / target_search_network / + target_content_network / target_partner_search_network + campaign.geo_target_type_setting.positive_geo_target_type PRESENCE_OR_INTEREST, + SEARCH_INTEREST, PRESENCE + campaign.geo_target_type_setting.negative_geo_target_type PRESENCE_OR_INTEREST, PRESENCE + campaign.keyword_match_type BROAD, or unset + campaign.tracking_url_template, final_url_suffix, url_custom_parameters + campaign.bidding_strategy_type and the per-strategy targets + campaign_budget.amount_micros, explicitly_shared + +The AI Max half comes from ``ai_max.read_ai_max_state`` unchanged, so both tools +always agree. Read-only: nothing here mutates. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adloop.config import AdLoopConfig + +_SETTINGS_SELECT = """ + SELECT campaign.id, campaign.name, campaign.status, + campaign.advertising_channel_type, + campaign.bidding_strategy_type, campaign.bidding_strategy, + campaign.target_spend.cpc_bid_ceiling_micros, + campaign.maximize_conversions.target_cpa_micros, + campaign.target_cpa.target_cpa_micros, + campaign.target_roas.target_roas, + campaign.maximize_conversion_value.target_roas, + campaign.network_settings.target_google_search, + campaign.network_settings.target_search_network, + campaign.network_settings.target_content_network, + campaign.network_settings.target_partner_search_network, + campaign.geo_target_type_setting.positive_geo_target_type, + campaign.geo_target_type_setting.negative_geo_target_type, + campaign.keyword_match_type, + campaign.tracking_url_template, campaign.final_url_suffix, + campaign.url_custom_parameters, + campaign_budget.amount_micros, campaign_budget.explicitly_shared + FROM campaign + WHERE {where} + ORDER BY campaign.name +""" + +_WHERE_ALL = ( + "campaign.status != 'REMOVED' AND campaign.advertising_channel_type = 'SEARCH'" +) + +# Google's default; it also reaches people elsewhere who search *about* the +# targeted locations. +_WIDE_LOCATION_OPTIONS = {"PRESENCE_OR_INTEREST", "SEARCH_INTEREST"} + + +def _money(micros: object) -> float | None: + """Micros to account currency; 0 or missing means "not set".""" + if not micros: + return None + return round(int(micros) / 1_000_000, 2) + + +def _target_cpa(row: dict) -> float | None: + return _money( + row.get("campaign.target_cpa.target_cpa_micros") + or row.get("campaign.maximize_conversions.target_cpa_micros") + ) + + +def _target_roas(row: dict) -> float | None: + value = row.get("campaign.target_roas.target_roas") or row.get( + "campaign.maximize_conversion_value.target_roas" + ) + return value or None + + +def _custom_parameters(value: object) -> list[dict]: + if not isinstance(value, list): + return [] + return [ + {"key": item.get("key", ""), "value": item.get("value", "")} + for item in value + if isinstance(item, dict) + ] + + +def _widening(campaign: dict) -> list[str]: + """The settings on this campaign that let ads reach beyond what was chosen.""" + found: list[str] = [] + if campaign["search_partners"]: + found.append("search partners on") + if campaign["display_network"]: + found.append("Display network on") + if campaign["location_option"] in _WIDE_LOCATION_OPTIONS: + found.append(f"location option {campaign['location_option']}") + if campaign["keyword_match_type"] == "BROAD": + found.append("campaign-level broad match on") + if campaign["enable_ai_max"]: + found.append("AI Max on") + for automation, status in campaign["asset_automation"].items(): + if status == "OPTED_IN": + found.append(f"{automation} opted in") + return found + + +def get_campaign_settings( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", +) -> dict: + """Read-only, one flat record per Search campaign. See the module docstring.""" + from adloop.ads.client import get_ads_client, normalize_customer_id + from adloop.ads.currency import get_currency_code + + campaign_id = str(campaign_id or "").strip() + if campaign_id and not campaign_id.isdigit(): + return {"error": "campaign_id must be a numeric ID"} + + cid = normalize_customer_id(customer_id or config.ads.customer_id) + client = get_ads_client(config) + result = read_campaign_settings(client, cid, campaign_id=campaign_id) + result["currency"] = get_currency_code(config, cid) + return result + + +def read_campaign_settings(client: object, cid: str, *, campaign_id: str = "") -> dict: + """Run both reads and merge them per campaign.""" + from adloop.ads.ai_max import _search, read_ai_max_state + + if campaign_id and not str(campaign_id).isdigit(): + # The id goes into the query text; anything else must not reach GAQL. + raise ValueError("campaign_id must be a numeric ID") + where = f"campaign.id = {campaign_id}" if campaign_id else _WHERE_ALL + rows = _search( + client.get_service("GoogleAdsService"), cid, _SETTINGS_SELECT.format(where=where) + ) + ai_max = { + c["campaign_id"]: c + for c in read_ai_max_state(client, cid, campaign_id=campaign_id)["campaigns"] + } + + campaigns: list[dict] = [] + for row in rows: + key = str(row.get("campaign.id", "")) + if not key or (campaign_id and key != str(campaign_id)): + continue + ai = ai_max.get(key, {}) + campaign = { + "campaign_id": key, + "campaign_name": row.get("campaign.name"), + "status": row.get("campaign.status"), + "advertising_channel_type": row.get("campaign.advertising_channel_type"), + "bidding_strategy_type": row.get("campaign.bidding_strategy_type"), + "portfolio_bidding_strategy": row.get("campaign.bidding_strategy") or None, + "cpc_bid_ceiling": _money( + row.get("campaign.target_spend.cpc_bid_ceiling_micros") + ), + "target_cpa": _target_cpa(row), + "target_roas": _target_roas(row), + "daily_budget": _money(row.get("campaign_budget.amount_micros")), + "budget_shared": bool(row.get("campaign_budget.explicitly_shared")), + "google_search": bool(row.get("campaign.network_settings.target_google_search")), + "search_partners": bool( + row.get("campaign.network_settings.target_search_network") + ), + "display_network": bool( + row.get("campaign.network_settings.target_content_network") + ), + "location_option": row.get( + "campaign.geo_target_type_setting.positive_geo_target_type" + ), + "negative_location_option": row.get( + "campaign.geo_target_type_setting.negative_geo_target_type" + ), + "keyword_match_type": row.get("campaign.keyword_match_type"), + "tracking_url_template": row.get("campaign.tracking_url_template") or "", + "final_url_suffix": row.get("campaign.final_url_suffix") or "", + "url_custom_parameters": _custom_parameters( + row.get("campaign.url_custom_parameters") + ), + "enable_ai_max": bool(ai.get("enable_ai_max")), + "asset_automation": { + item["asset_automation_type"]: item.get("asset_automation_status", "") + for item in ai.get("asset_automation_settings", []) + }, + # Search term matching only acts under AI Max; listing every ad + # group's default False otherwise would be noise. + "ad_groups_with_search_term_matching": [ + g["ad_group_name"] + for g in ai.get("ad_groups", []) + if g.get("disable_search_term_matching") is False + ] if ai.get("enable_ai_max") else [], + } + campaign["widening_settings"] = _widening(campaign) + campaigns.append(campaign) + + insights: list[str] = [] + for campaign in campaigns: + if campaign["widening_settings"]: + insights.append( + f"{campaign['campaign_name']} ({campaign['campaign_id']}): " + + ", ".join(campaign["widening_settings"]) + + ". These let the ads reach beyond the keywords and locations " + "chosen; confirm they are intended." + ) + if campaigns and not insights: + insights.append("No setting that widens reach is on in any campaign returned.") + if not campaigns: + insights.append("No non-removed Search campaign matched.") + + return { + "campaigns": campaigns, + "total_campaigns": len(campaigns), + "insights": insights, + } diff --git a/src/adloop/rules/adloop.md b/src/adloop/rules/adloop.md index c14935f..9817978 100644 --- a/src/adloop/rules/adloop.md +++ b/src/adloop/rules/adloop.md @@ -70,6 +70,7 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog | `get_brand_list_brands` | Contents of a brand list, with `entity_id` (the MID) and `criterion_id` (needed for removal) | `shared_set_id` (required) | | `get_brand_list_campaigns` | Which campaigns a brand list is attached to, and whether each attachment excludes (`role: excluded`) or targets (`role: targeted`) | `shared_set_id` (optional) | | `get_ai_max_settings` | The AI Max knobs of Search campaigns: `enable_ai_max`, `bundling_required`, the full `asset_automation_settings` list, and each ad group's `disable_search_term_matching` | `campaign_id` (optional) | +| `get_campaign_settings` | Every setting that decides how far a Search campaign reaches, one flat record per campaign: networks, location option, campaign-level broad match, bidding and budget, tracking template / URL suffix / custom parameters, and the AI Max state. `widening_settings` and `insights[]` name anything that lets ads reach beyond the chosen keywords and locations. The one call for a recurring "did Google switch something on?" audit | `campaign_id` (optional) | | `get_custom_conversion_goals` | Custom conversion goals (named sets of conversion actions) and one campaign's goal config | `campaign_id` (optional) | | `run_gaql` | Custom queries not covered by other tools | `query`, `format` (table/json/csv) | diff --git a/src/adloop/server.py b/src/adloop/server.py index 76f29ef..519f17a 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -3980,6 +3980,43 @@ def get_ai_max_settings( campaign_id=campaign_id, ) + +@_tool(title="Campaign settings", annotations=_READONLY, tags={"ads"}) +@_safe +def get_campaign_settings( + campaign_id: str = "", + customer_id: str = "", +) -> dict: + """Read every setting that decides how far a Search campaign reaches, in one call. + + Returns one flat record per campaign: status, bidding strategy and its + target or CPC ceiling, daily budget and whether it is shared, the Google + Search / search partner / Display network flags, the location option + (positive_geo_target_type) and its negative counterpart, campaign-level + broad match (keyword_match_type), the tracking template, final URL suffix + and custom parameters, and the AI Max state get_ai_max_settings reports + (enable_ai_max, asset automation per type). Money is in the account + currency (`currency`). + + Each campaign lists its `widening_settings`: search partners or Display on, + a presence-or-interest location option, broad match on, AI Max on, an + automation opted in. `insights[]` names the campaigns that have any. Made + for recurring audits: the same call every week diffs cleanly. + + Args: + campaign_id: Numeric campaign ID. When omitted, every non-removed + Search campaign in the account is listed. + customer_id: Google Ads customer ID (digits, dashes optional). Defaults to + the configured account. + """ + from adloop.ads.campaign_settings import get_campaign_settings as _impl + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + campaign_id=campaign_id, + ) + @_tool(title="Draft AI Max settings", annotations=_WRITE, tags={"ads"}) @_safe def draft_ai_max_settings( diff --git a/tests/test_campaign_settings.py b/tests/test_campaign_settings.py new file mode 100644 index 0000000..a7423da --- /dev/null +++ b/tests/test_campaign_settings.py @@ -0,0 +1,292 @@ +"""Tests for get_campaign_settings (one read for every setting that widens reach).""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from adloop.ads import ai_max, campaign_settings +from adloop.config import AdLoopConfig, AdsConfig + +CUSTOMER = "1234567890" +NARROW = 21001 +WIDE = 21002 + + +@pytest.fixture +def config() -> AdLoopConfig: + return AdLoopConfig(ads=AdsConfig(customer_id=CUSTOMER)) + + +def _settings_row(campaign_id=NARROW, *, name="Search — narrow", **overrides): + """A campaign the way an exact/phrase, presence-only build leaves it.""" + row = { + "campaign.id": campaign_id, + "campaign.name": name, + "campaign.status": "PAUSED", + "campaign.advertising_channel_type": "SEARCH", + "campaign.bidding_strategy_type": "TARGET_SPEND", + "campaign.bidding_strategy": "", + "campaign.target_spend.cpc_bid_ceiling_micros": 15_000_000, + "campaign.maximize_conversions.target_cpa_micros": 0, + "campaign.target_cpa.target_cpa_micros": 0, + "campaign.target_roas.target_roas": 0, + "campaign.maximize_conversion_value.target_roas": 0, + "campaign.network_settings.target_google_search": True, + "campaign.network_settings.target_search_network": False, + "campaign.network_settings.target_content_network": False, + "campaign.network_settings.target_partner_search_network": False, + "campaign.geo_target_type_setting.positive_geo_target_type": "PRESENCE", + "campaign.geo_target_type_setting.negative_geo_target_type": "PRESENCE", + "campaign.keyword_match_type": "UNSPECIFIED", + "campaign.tracking_url_template": "{lpurl}?utm_source=google", + "campaign.final_url_suffix": "", + "campaign.url_custom_parameters": [{"key": "grp", "value": "b2c"}], + "campaign_budget.amount_micros": 15_000_000, + "campaign_budget.explicitly_shared": False, + } + row.update(overrides) + return row + + +def _ai_campaign_row(campaign_id=NARROW, *, enable=False, automation=("OPTED_OUT", "OPTED_OUT")): + text, url = automation + return { + "campaign.id": campaign_id, + "campaign.name": "x", + "campaign.status": "PAUSED", + "campaign.advertising_channel_type": "SEARCH", + "campaign.ai_max_setting.enable_ai_max": enable, + "campaign.ai_max_setting.bundling_required": "UNSPECIFIED", + "campaign.asset_automation_settings": [ + {"asset_automation_type": "TEXT_ASSET_AUTOMATION", "asset_automation_status": text}, + { + "asset_automation_type": "FINAL_URL_EXPANSION_TEXT_ASSET_AUTOMATION", + "asset_automation_status": url, + }, + ], + } + + +def _ad_group_row(campaign_id, group_id, *, name, disable=False): + return { + "campaign.id": campaign_id, + "ad_group.id": group_id, + "ad_group.name": name, + "ad_group.status": "ENABLED", + "ad_group.ai_max_ad_group_setting.disable_search_term_matching": disable, + } + + +def _patch(monkeypatch, settings, ai_campaigns, ad_groups=()): + queries: list[str] = [] + + def fake_search(_service, _cid, query): + queries.append(query) + if "geo_target_type_setting" in query: + return list(settings) + if "FROM campaign" in query: + return list(ai_campaigns) + return list(ad_groups) + + monkeypatch.setattr(ai_max, "_search", fake_search) + return queries + + +def _client(): + return SimpleNamespace(get_service=lambda _name: SimpleNamespace(search=lambda **_kw: [])) + + +class TestRead: + def test_flat_record_per_campaign(self, monkeypatch): + _patch(monkeypatch, [_settings_row()], [_ai_campaign_row()]) + + result = campaign_settings.read_campaign_settings(_client(), CUSTOMER) + + (campaign,) = result["campaigns"] + assert campaign["campaign_id"] == str(NARROW) + assert campaign["cpc_bid_ceiling"] == 15.0 + assert campaign["daily_budget"] == 15.0 + assert campaign["target_cpa"] is None and campaign["target_roas"] is None + assert campaign["portfolio_bidding_strategy"] is None + assert campaign["google_search"] is True + assert campaign["search_partners"] is False + assert campaign["display_network"] is False + assert campaign["location_option"] == "PRESENCE" + assert campaign["keyword_match_type"] == "UNSPECIFIED" + assert campaign["tracking_url_template"] == "{lpurl}?utm_source=google" + assert campaign["url_custom_parameters"] == [{"key": "grp", "value": "b2c"}] + assert campaign["enable_ai_max"] is False + assert campaign["asset_automation"] == { + "TEXT_ASSET_AUTOMATION": "OPTED_OUT", + "FINAL_URL_EXPANSION_TEXT_ASSET_AUTOMATION": "OPTED_OUT", + } + assert campaign["widening_settings"] == [] + assert result["insights"] == [ + "No setting that widens reach is on in any campaign returned." + ] + + def test_every_widening_setting_is_named(self, monkeypatch): + _patch( + monkeypatch, + [ + _settings_row( + WIDE, + name="Search — wide", + **{ + "campaign.network_settings.target_search_network": True, + "campaign.network_settings.target_content_network": True, + "campaign.geo_target_type_setting.positive_geo_target_type": ( + "PRESENCE_OR_INTEREST" + ), + "campaign.keyword_match_type": "BROAD", + }, + ) + ], + [_ai_campaign_row(WIDE, enable=True, automation=("OPTED_IN", "OPTED_OUT"))], + [ + _ad_group_row(WIDE, 1, name="Matching"), + _ad_group_row(WIDE, 2, name="Locked", disable=True), + ], + ) + + result = campaign_settings.read_campaign_settings(_client(), CUSTOMER) + + (campaign,) = result["campaigns"] + assert campaign["widening_settings"] == [ + "search partners on", + "Display network on", + "location option PRESENCE_OR_INTEREST", + "campaign-level broad match on", + "AI Max on", + "TEXT_ASSET_AUTOMATION opted in", + ] + assert campaign["ad_groups_with_search_term_matching"] == ["Matching"] + assert len(result["insights"]) == 1 + assert result["insights"][0].startswith(f"Search — wide ({WIDE}): search partners on") + + def test_search_interest_counts_as_widening_too(self, monkeypatch): + _patch( + monkeypatch, + [_settings_row(**{ + "campaign.geo_target_type_setting.positive_geo_target_type": "SEARCH_INTEREST" + })], + [_ai_campaign_row()], + ) + + (campaign,) = campaign_settings.read_campaign_settings(_client(), CUSTOMER)["campaigns"] + + assert campaign["widening_settings"] == ["location option SEARCH_INTEREST"] + + def test_search_term_matching_is_not_listed_without_ai_max(self, monkeypatch): + """Every ad group defaults to False; it only acts under AI Max.""" + _patch( + monkeypatch, + [_settings_row()], + [_ai_campaign_row()], + [_ad_group_row(NARROW, 1, name="Alternative")], + ) + + (campaign,) = campaign_settings.read_campaign_settings(_client(), CUSTOMER)["campaigns"] + + assert campaign["ad_groups_with_search_term_matching"] == [] + assert campaign["widening_settings"] == [] + + def test_targets_and_portfolio_are_reported(self, monkeypatch): + _patch( + monkeypatch, + [_settings_row(**{ + "campaign.bidding_strategy_type": "MAXIMIZE_CONVERSIONS", + "campaign.bidding_strategy": f"customers/{CUSTOMER}/biddingStrategies/9", + "campaign.target_spend.cpc_bid_ceiling_micros": 0, + "campaign.maximize_conversions.target_cpa_micros": 85_500_000, + "campaign_budget.explicitly_shared": True, + })], + [_ai_campaign_row()], + ) + + (campaign,) = campaign_settings.read_campaign_settings(_client(), CUSTOMER)["campaigns"] + + assert campaign["target_cpa"] == 85.5 + assert campaign["cpc_bid_ceiling"] is None + assert campaign["portfolio_bidding_strategy"].endswith("/biddingStrategies/9") + assert campaign["budget_shared"] is True + + def test_one_campaign_filters_both_queries_and_drops_strays(self, monkeypatch): + queries = _patch( + monkeypatch, + [_settings_row(), _settings_row(WIDE, name="stray")], + [_ai_campaign_row()], + ) + + result = campaign_settings.read_campaign_settings( + _client(), CUSTOMER, campaign_id=str(NARROW) + ) + + assert [c["campaign_id"] for c in result["campaigns"]] == [str(NARROW)] + assert f"campaign.id = {NARROW}" in queries[0] + + def test_no_campaigns(self, monkeypatch): + _patch(monkeypatch, [], []) + + result = campaign_settings.read_campaign_settings(_client(), CUSTOMER) + + assert result["total_campaigns"] == 0 + assert result["insights"] == ["No non-removed Search campaign matched."] + + def test_the_query_only_selects_fields_that_exist_on_v25(self): + """Every field must be a real campaign or campaign_budget field in v25, + checked against the SDK's own proto rather than a hand-kept list.""" + from google.ads.googleads.v25.resources.types.campaign import Campaign + from google.ads.googleads.v25.resources.types.campaign_budget import CampaignBudget + + from adloop.ads.gaql import _parse_select_fields + + roots = {"campaign": Campaign, "campaign_budget": CampaignBudget} + for field in _parse_select_fields(campaign_settings._SETTINGS_SELECT): + resource, *path = field.split(".") + message = roots[resource] + for part in path: + assert part in message.meta.fields, field + inner = message.meta.fields[part].message + if inner is not None and part != path[-1]: + message = inner + + +class TestTool: + def test_a_non_numeric_campaign_id_never_reaches_gaql(self, config, monkeypatch): + def _no_client(*_args, **_kwargs): + raise AssertionError("no client may be built for invalid input") + + monkeypatch.setattr("adloop.ads.client.get_ads_client", _no_client) + + result = campaign_settings.get_campaign_settings( + config, campaign_id="1 OR campaign.id > 0" + ) + + assert result == {"error": "campaign_id must be a numeric ID"} + + def test_read_rejects_a_non_numeric_campaign_id(self): + with pytest.raises(ValueError, match="numeric"): + campaign_settings.read_campaign_settings(_client(), CUSTOMER, campaign_id="abc") + + def test_currency_is_attached(self, config, monkeypatch): + _patch(monkeypatch, [_settings_row()], [_ai_campaign_row()]) + monkeypatch.setattr("adloop.ads.client.get_ads_client", lambda _config: _client()) + monkeypatch.setattr("adloop.ads.currency.get_currency_code", lambda _c, _cid: "USD") + + result = campaign_settings.get_campaign_settings(config) + + assert result["currency"] == "USD" + assert result["total_campaigns"] == 1 + + @pytest.mark.asyncio + async def test_registered_read_only_with_documented_parameters(self): + from adloop.server import mcp + + tools = {t.name: t for t in await mcp.list_tools()} + tool = tools["get_campaign_settings"] + assert tool.annotations.read_only_hint is True + for param in ("campaign_id", "customer_id"): + assert tool.parameters["properties"][param].get("description"), param