diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index 2306458..31ab888 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -53,6 +53,7 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog | `get_negative_keyword_list_keywords` | List the keywords inside a specific shared negative keyword list | `shared_set_id` (required) | | `get_negative_keyword_list_campaigns` | List which campaigns a shared negative keyword list is attached to | `shared_set_id` (optional) | | `get_recommendations` | Google's auto-generated recommendations with estimated impact — budget, keyword, bid strategy, ad copy suggestions | `recommendation_types` (optional filter), `campaign_id` (optional) | +| `get_recommendation_subscriptions` | Which recommendation types Google auto-applies without a preview: type, status (ENABLED/PAUSED), create/modify dates. `insights[]` names every ENABLED type and flags one modified in the last 30 days. A type not listed is not auto-applied | none | | `get_pmax_performance` | Performance Max campaign metrics with network breakdown + asset group ad strength | `date_range_start`, `date_range_end` | | `get_pmax_assets` | Per-asset details for PMax — field type, serving status, content. Use with `get_detailed_asset_performance` for quality signals | `campaign_id` (optional) | | `get_asset_group_signals` | A PMax asset group's search themes and audience signal (with `signal_id` and approval status), plus the account's audiences that can become its signal | `asset_group_id` | @@ -318,6 +319,7 @@ Conversions that happen offline (a sales call, a lead that turned into a deal) g | `add_to_negative_keyword_list` | Append keywords to an EXISTING shared negative keyword list (does NOT add) | `shared_set_id` (from `get_negative_keyword_lists`), keyword list, `match_type` | | `attach_shared_set_to_campaigns` | Attach an EXISTING shared set (e.g. shared negative keyword list) to one or more campaigns. Use after creating a campaign to inherit pre-built negatives. | `shared_set_id` (from `get_negative_keyword_lists`), `campaign_ids` list | | `detach_shared_set_from_campaigns` | Detach a shared set from one or more campaigns. Removes only the linkage; the shared set and its keywords stay intact. | `shared_set_id`, `campaign_ids` list | +| `draft_recommendation_subscriptions` | Pause auto-apply for recommendation types. Can only set PAUSED; re-enabling is not supported and stays in the Google Ads UI. Each type needs an ENABLED subscription. Single confirmation; the dry run uses validate_only | `recommendation_types` | | `draft_dismiss_recommendations` | Dismiss Google recommendations the user has decided not to take. Nothing in the account changes and Google can show them again later; unknown or already dismissed IDs are refused. No validate-only mode: the dry run re-reads the account and checks each one is still active | `recommendation_ids` (`recommendation.id` from `get_recommendations`) | | `draft_demographic_targeting` | Propose demographic criteria (age, gender, parental status, income range) at ad group or campaign level. Defaults to EXCLUSION (`negative=True`). | exactly one of `ad_group_id` or `campaign_id`, at least one of `age_ranges`/`genders`/`parental_statuses`/`income_ranges`, optional `negative` (default True) | | `draft_audience_attachment` | Attach audiences to a campaign or ad group as OBSERVATION (default: bids and reporting, reach unchanged), TARGETING (ads only reach the audiences; double confirmation) or exclusion. Refuses incompatible pairings, Search lists too small to target, audiences at both campaign and ad group level, and Performance Max | exactly one of `campaign_id` or `ad_group_id`; any of `user_list_ids`, `segment_ids`, `custom_audience_ids`, `combined_audience_ids`; `mode` observation/targeting; `exclude` | @@ -660,6 +662,7 @@ Reddit's structure is campaign → ad group (budget, bid, pixel, targeting) → 5. For keyword recommendations, call `get_keyword_performance` to check existing keyword overlap before acting 6. Present recommendations grouped by type with your assessment of each — the user should understand which ones are genuinely helpful vs which ones just increase Google's revenue 7. For the ones the user decides not to take, offer `draft_dismiss_recommendations` with their IDs, so they stop reappearing. Dismiss only what the user turned down: a dismissal is not a verdict you make on their behalf +8. Call `get_recommendation_subscriptions` too: an ENABLED subscription means Google applies that type on its own, without any preview. Tell the user, and offer `draft_recommendation_subscriptions` to pause it. Never suggest re-enabling auto-apply; if the user wants it, it is done in the Google Ads UI ### When user asks about audience performance or targeting diff --git a/.cursor/rules/adloop.mdc b/.cursor/rules/adloop.mdc index 99c2215..8197b63 100644 --- a/.cursor/rules/adloop.mdc +++ b/.cursor/rules/adloop.mdc @@ -55,6 +55,7 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog | `get_negative_keyword_list_keywords` | List the keywords inside a specific shared negative keyword list | `shared_set_id` (required) | | `get_negative_keyword_list_campaigns` | List which campaigns a shared negative keyword list is attached to | `shared_set_id` (optional) | | `get_recommendations` | Google's auto-generated recommendations with estimated impact — budget, keyword, bid strategy, ad copy suggestions | `recommendation_types` (optional filter), `campaign_id` (optional) | +| `get_recommendation_subscriptions` | Which recommendation types Google auto-applies without a preview: type, status (ENABLED/PAUSED), create/modify dates. `insights[]` names every ENABLED type and flags one modified in the last 30 days. A type not listed is not auto-applied | none | | `get_pmax_performance` | Performance Max campaign metrics with network breakdown + asset group ad strength | `date_range_start`, `date_range_end` | | `get_pmax_assets` | Per-asset details for PMax — field type, serving status, content. Use with `get_detailed_asset_performance` for quality signals | `campaign_id` (optional) | | `get_asset_group_signals` | A PMax asset group's search themes and audience signal (with `signal_id` and approval status), plus the account's audiences that can become its signal | `asset_group_id` | @@ -320,6 +321,7 @@ Conversions that happen offline (a sales call, a lead that turned into a deal) g | `add_to_negative_keyword_list` | Append keywords to an EXISTING shared negative keyword list (does NOT add) | `shared_set_id` (from `get_negative_keyword_lists`), keyword list, `match_type` | | `attach_shared_set_to_campaigns` | Attach an EXISTING shared set (e.g. shared negative keyword list) to one or more campaigns. Use after creating a campaign to inherit pre-built negatives. | `shared_set_id` (from `get_negative_keyword_lists`), `campaign_ids` list | | `detach_shared_set_from_campaigns` | Detach a shared set from one or more campaigns. Removes only the linkage; the shared set and its keywords stay intact. | `shared_set_id`, `campaign_ids` list | +| `draft_recommendation_subscriptions` | Pause auto-apply for recommendation types. Can only set PAUSED; re-enabling is not supported and stays in the Google Ads UI. Each type needs an ENABLED subscription. Single confirmation; the dry run uses validate_only | `recommendation_types` | | `draft_dismiss_recommendations` | Dismiss Google recommendations the user has decided not to take. Nothing in the account changes and Google can show them again later; unknown or already dismissed IDs are refused. No validate-only mode: the dry run re-reads the account and checks each one is still active | `recommendation_ids` (`recommendation.id` from `get_recommendations`) | | `draft_demographic_targeting` | Propose demographic criteria (age, gender, parental status, income range) at ad group or campaign level. Defaults to EXCLUSION (`negative=True`). | exactly one of `ad_group_id` or `campaign_id`, at least one of `age_ranges`/`genders`/`parental_statuses`/`income_ranges`, optional `negative` (default True) | | `draft_audience_attachment` | Attach audiences to a campaign or ad group as OBSERVATION (default: bids and reporting, reach unchanged), TARGETING (ads only reach the audiences; double confirmation) or exclusion. Refuses incompatible pairings, Search lists too small to target, audiences at both campaign and ad group level, and Performance Max | exactly one of `campaign_id` or `ad_group_id`; any of `user_list_ids`, `segment_ids`, `custom_audience_ids`, `combined_audience_ids`; `mode` observation/targeting; `exclude` | @@ -662,6 +664,7 @@ Reddit's structure is campaign → ad group (budget, bid, pixel, targeting) → 5. For keyword recommendations, call `get_keyword_performance` to check existing keyword overlap before acting 6. Present recommendations grouped by type with your assessment of each — the user should understand which ones are genuinely helpful vs which ones just increase Google's revenue 7. For the ones the user decides not to take, offer `draft_dismiss_recommendations` with their IDs, so they stop reappearing. Dismiss only what the user turned down: a dismissal is not a verdict you make on their behalf +8. Call `get_recommendation_subscriptions` too: an ENABLED subscription means Google applies that type on its own, without any preview. Tell the user, and offer `draft_recommendation_subscriptions` to pause it. Never suggest re-enabling auto-apply; if the user wants it, it is done in the Google Ads UI ### When user asks about audience performance or targeting diff --git a/src/adloop/ads/recommendation_subscriptions.py b/src/adloop/ads/recommendation_subscriptions.py new file mode 100644 index 0000000..873d94c --- /dev/null +++ b/src/adloop/ads/recommendation_subscriptions.py @@ -0,0 +1,202 @@ +"""Read and pause Google Ads auto-apply recommendation subscriptions. + +A subscription makes Google apply every new recommendation of one type +without asking, so a change reaches the account with no preview. The v25 API +(verified against the SDK): + + recommendation_subscription resource_name, type, status (ENABLED | PAUSED), + create_date_time, modify_date_time + RecommendationSubscriptionService.MutateRecommendationSubscription + MutateRecommendationSubscriptionRequest{customer_id, operations[], + partial_failure, validate_only, response_content_type} + +A type with no subscription row is not auto-applied. The write side can only +set a subscription to PAUSED: re-enabling auto-apply is a decision for the +Google Ads UI, because it lets Google change the account without a preview, +which is what AdLoop's flow exists to prevent. Pausing reduces automation, so +a single confirmation is enough. The request has ``validate_only``, so the dry +run goes through the validate-only client like other Google Ads writes. +""" + +from __future__ import annotations + +import re +from datetime import date, datetime, timezone +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from adloop.config import AdLoopConfig + +OPERATION = "pause_recommendation_subscriptions" + +# A subscription modified this recently is reported as a change, not only as a +# standing setting: someone (or something) turned it on or touched it lately. +RECENT_DAYS = 30 + +_TYPE_RE = re.compile(r"^[A-Z][A-Z0-9_]*$") + +_QUERY = """ + SELECT recommendation_subscription.resource_name, + recommendation_subscription.type, + recommendation_subscription.status, + recommendation_subscription.create_date_time, + recommendation_subscription.modify_date_time + FROM recommendation_subscription +""" + + +def _read(config: AdLoopConfig, customer_id: str) -> list[dict]: + from adloop.ads.gaql import execute_query + + return [ + { + "type": row.get("recommendation_subscription.type", ""), + "status": row.get("recommendation_subscription.status", ""), + "resource_name": row.get("recommendation_subscription.resource_name", ""), + "create_date_time": row.get("recommendation_subscription.create_date_time", ""), + "modify_date_time": row.get("recommendation_subscription.modify_date_time", ""), + } + for row in execute_query(config, customer_id, _QUERY) + ] + + +def _day(value: str) -> date | None: + """``2026-10-27 09:14:03`` (account time zone) -> the date, or None.""" + try: + return date.fromisoformat(str(value or "")[:10]) + except ValueError: + return None + + +def get_recommendation_subscriptions( + config: AdLoopConfig, *, customer_id: str = "", today: date | None = None +) -> dict: + """Read-only: every auto-apply subscription with its status and dates.""" + subscriptions = sorted(_read(config, customer_id), key=lambda s: s["type"]) + enabled = [s for s in subscriptions if s["status"] == "ENABLED"] + today = today or datetime.now(timezone.utc).date() + + insights: list[str] = [] + if enabled: + insights.append( + f"{len(enabled)} recommendation type(s) auto-apply without a preview: " + + ", ".join(s["type"] for s in enabled) + + ". Google changes the account for these on its own. " + "draft_recommendation_subscriptions pauses them." + ) + for sub in enabled: + modified = _day(sub["modify_date_time"]) + if modified and (today - modified).days <= RECENT_DAYS: + insights.append( + f"{sub['type']} auto-apply was turned on or changed on " + f"{sub['modify_date_time']}, within the last {RECENT_DAYS} days. " + "Confirm that was intended." + ) + if not enabled: + insights.append("No recommendation type is set to auto-apply.") + + return { + "subscriptions": subscriptions, + "total_subscriptions": len(subscriptions), + "enabled_count": len(enabled), + "insights": insights, + } + + +def draft_recommendation_subscriptions( + config: AdLoopConfig, + *, + customer_id: str = "", + recommendation_types: list[str] | None = None, +) -> dict: + """Draft pausing auto-apply for recommendation types — returns PREVIEW. + + Pausing is the only change this can make. Each type must have an ENABLED + subscription; a type with none, or one already paused, is refused by name + so the preview only lists what the apply will change. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation(OPERATION, config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + types: list[str] = [] + errors: list[str] = [] + for raw in recommendation_types or []: + value = str(raw or "").strip().upper() + if not value: + continue + if not _TYPE_RE.match(value): + errors.append(f"'{raw}' is not a RecommendationType name") + elif value not in types: + types.append(value) + if not types and not errors: + errors.append("At least one recommendation type is required") + if errors: + return {"error": "Validation failed", "details": errors} + + current = {s["type"]: s for s in _read(config, customer_id)} + not_enabled = [t for t in types if current.get(t, {}).get("status") != "ENABLED"] + if not_enabled: + return { + "error": "Validation failed", + "details": [ + "Not auto-applied in this account (no subscription, or already " + "paused): " + ", ".join(not_enabled) + + ". get_recommendation_subscriptions lists the subscriptions." + ], + } + + subscriptions = [ + { + "type": t, + "resource_name": current[t]["resource_name"], + "current_status": "ENABLED", + "status": "PAUSED", + "modify_date_time": current[t]["modify_date_time"], + } + for t in types + ] + plan = ChangePlan( + operation=OPERATION, + entity_type="recommendation_subscription", + entity_id=",".join(types), + customer_id=customer_id, + changes={ + "subscriptions": subscriptions, + "count": len(subscriptions), + "note": ( + "Pausing stops Google applying these recommendation types on its " + "own. Recommendations still appear in get_recommendations. " + "Re-enabling auto-apply is done in the Google Ads UI." + ), + }, + ) + store_plan(plan) + return plan.to_preview() + + +def _apply_pause_recommendation_subscriptions( + client: object, cid: str, changes: dict +) -> dict: + """Set each planned subscription to PAUSED in one request.""" + from google.protobuf import field_mask_pb2 + + service = client.get_service("RecommendationSubscriptionService") + request = client.get_type("MutateRecommendationSubscriptionRequest") + request.customer_id = cid + paused_status = client.enums.RecommendationSubscriptionStatusEnum.PAUSED + for sub in changes["subscriptions"]: + # Hard-coded: whatever a stored plan says, this path only pauses. + operation = client.get_type("RecommendationSubscriptionOperation") + operation.update.resource_name = sub["resource_name"] + operation.update.status = paused_status + operation.update_mask.CopyFrom(field_mask_pb2.FieldMask(paths=["status"])) + request.operations.append(operation) + + response = service.mutate_recommendation_subscription(request=request) + paused = [result.resource_name for result in response.results] + return {"paused": paused, "paused_count": len(paused)} diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index 90acf56..6eb67c1 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -4101,6 +4101,9 @@ def _dispatch_ads_plan(client: object, cid: str, plan: object) -> dict: from adloop.ads.pmax import apply_asset_group_signals as _apply_asset_group_signals from adloop.ads.pmax import apply_pmax_product_filter as _apply_pmax_product_filter from adloop.ads.recommendations import _apply_dismiss_recommendations + from adloop.ads.recommendation_subscriptions import ( + _apply_pause_recommendation_subscriptions, + ) # Custom conversion goals live in their own module for the same reason. from adloop.ads.custom_conversion_goals import ( _apply_assign_custom_conversion_goal, @@ -4136,6 +4139,7 @@ def _dispatch_ads_plan(client: object, cid: str, plan: object) -> dict: "detach_brand_list_from_campaigns": _apply_detach_brand_list_from_campaigns, "update_ai_max_settings": _apply_ai_max_settings, "dismiss_recommendations": _apply_dismiss_recommendations, + "pause_recommendation_subscriptions": _apply_pause_recommendation_subscriptions, "create_custom_conversion_goal": _apply_create_custom_conversion_goal, "update_custom_conversion_goal": _apply_update_custom_conversion_goal, "assign_custom_conversion_goal": _apply_assign_custom_conversion_goal, diff --git a/src/adloop/rules/adloop.md b/src/adloop/rules/adloop.md index 2306458..31ab888 100644 --- a/src/adloop/rules/adloop.md +++ b/src/adloop/rules/adloop.md @@ -53,6 +53,7 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog | `get_negative_keyword_list_keywords` | List the keywords inside a specific shared negative keyword list | `shared_set_id` (required) | | `get_negative_keyword_list_campaigns` | List which campaigns a shared negative keyword list is attached to | `shared_set_id` (optional) | | `get_recommendations` | Google's auto-generated recommendations with estimated impact — budget, keyword, bid strategy, ad copy suggestions | `recommendation_types` (optional filter), `campaign_id` (optional) | +| `get_recommendation_subscriptions` | Which recommendation types Google auto-applies without a preview: type, status (ENABLED/PAUSED), create/modify dates. `insights[]` names every ENABLED type and flags one modified in the last 30 days. A type not listed is not auto-applied | none | | `get_pmax_performance` | Performance Max campaign metrics with network breakdown + asset group ad strength | `date_range_start`, `date_range_end` | | `get_pmax_assets` | Per-asset details for PMax — field type, serving status, content. Use with `get_detailed_asset_performance` for quality signals | `campaign_id` (optional) | | `get_asset_group_signals` | A PMax asset group's search themes and audience signal (with `signal_id` and approval status), plus the account's audiences that can become its signal | `asset_group_id` | @@ -318,6 +319,7 @@ Conversions that happen offline (a sales call, a lead that turned into a deal) g | `add_to_negative_keyword_list` | Append keywords to an EXISTING shared negative keyword list (does NOT add) | `shared_set_id` (from `get_negative_keyword_lists`), keyword list, `match_type` | | `attach_shared_set_to_campaigns` | Attach an EXISTING shared set (e.g. shared negative keyword list) to one or more campaigns. Use after creating a campaign to inherit pre-built negatives. | `shared_set_id` (from `get_negative_keyword_lists`), `campaign_ids` list | | `detach_shared_set_from_campaigns` | Detach a shared set from one or more campaigns. Removes only the linkage; the shared set and its keywords stay intact. | `shared_set_id`, `campaign_ids` list | +| `draft_recommendation_subscriptions` | Pause auto-apply for recommendation types. Can only set PAUSED; re-enabling is not supported and stays in the Google Ads UI. Each type needs an ENABLED subscription. Single confirmation; the dry run uses validate_only | `recommendation_types` | | `draft_dismiss_recommendations` | Dismiss Google recommendations the user has decided not to take. Nothing in the account changes and Google can show them again later; unknown or already dismissed IDs are refused. No validate-only mode: the dry run re-reads the account and checks each one is still active | `recommendation_ids` (`recommendation.id` from `get_recommendations`) | | `draft_demographic_targeting` | Propose demographic criteria (age, gender, parental status, income range) at ad group or campaign level. Defaults to EXCLUSION (`negative=True`). | exactly one of `ad_group_id` or `campaign_id`, at least one of `age_ranges`/`genders`/`parental_statuses`/`income_ranges`, optional `negative` (default True) | | `draft_audience_attachment` | Attach audiences to a campaign or ad group as OBSERVATION (default: bids and reporting, reach unchanged), TARGETING (ads only reach the audiences; double confirmation) or exclusion. Refuses incompatible pairings, Search lists too small to target, audiences at both campaign and ad group level, and Performance Max | exactly one of `campaign_id` or `ad_group_id`; any of `user_list_ids`, `segment_ids`, `custom_audience_ids`, `combined_audience_ids`; `mode` observation/targeting; `exclude` | @@ -660,6 +662,7 @@ Reddit's structure is campaign → ad group (budget, bid, pixel, targeting) → 5. For keyword recommendations, call `get_keyword_performance` to check existing keyword overlap before acting 6. Present recommendations grouped by type with your assessment of each — the user should understand which ones are genuinely helpful vs which ones just increase Google's revenue 7. For the ones the user decides not to take, offer `draft_dismiss_recommendations` with their IDs, so they stop reappearing. Dismiss only what the user turned down: a dismissal is not a verdict you make on their behalf +8. Call `get_recommendation_subscriptions` too: an ENABLED subscription means Google applies that type on its own, without any preview. Tell the user, and offer `draft_recommendation_subscriptions` to pause it. Never suggest re-enabling auto-apply; if the user wants it, it is done in the Google Ads UI ### When user asks about audience performance or targeting diff --git a/src/adloop/server.py b/src/adloop/server.py index d6668c5..2c2447d 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -1610,6 +1610,69 @@ def draft_dismiss_recommendations( ) +@_tool(title="Auto-apply recommendation subscriptions", annotations=_READONLY, tags={"ads"}) +@_safe +def get_recommendation_subscriptions(customer_id: str = "") -> dict: + """List the recommendation types Google applies automatically (auto-apply). + + A subscription makes Google apply every new recommendation of its type + without asking, so changes reach the account with no preview. Returns each + subscription's type, status (ENABLED or PAUSED), create_date_time and + modify_date_time. A type that is not listed is not auto-applied. + insights[] names every ENABLED type and flags one modified in the last 30 + days, so a recurring audit can catch auto-apply being switched on. + + Args: + customer_id: Google Ads customer ID, digits with or without dashes + (e.g. "123-456-7890"). Defaults to the configured ads.customer_id. + """ + from adloop.ads.recommendation_subscriptions import ( + get_recommendation_subscriptions as _impl, + ) + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + ) + + +@_tool(title="Draft pausing auto-apply recommendations", annotations=_WRITE, tags={"ads"}) +@_safe +def draft_recommendation_subscriptions( + recommendation_types: _StrList, + customer_id: str = "", +) -> dict: + """Pause auto-apply for recommendation types — returns a PREVIEW. + + Pausing is the only change this tool can make: it sets each type's + subscription to PAUSED, so Google stops applying those recommendations on + its own. Their recommendations still appear in get_recommendations. + Re-enabling auto-apply is deliberately not supported; it lets Google change + the account without a preview, so it stays a decision for the Google Ads + UI. Pausing reduces automation, so a single confirmation is enough. + + Each type must have an ENABLED subscription (see + get_recommendation_subscriptions); a type with none, or one already + paused, is refused by name. The dry run sends the request to Google with + validate_only. The returned plan_id is applied with confirm_and_apply. + + Args: + recommendation_types: RecommendationType names with an ENABLED + subscription, e.g. ["KEYWORD", "USE_BROAD_MATCH_KEYWORD"]. + Duplicates are collapsed. + customer_id: Ads account ID. Defaults to the configured account. + """ + from adloop.ads.recommendation_subscriptions import ( + draft_recommendation_subscriptions as _impl, + ) + + return _impl( + current_config(), + customer_id=customer_id or current_config().ads.customer_id, + recommendation_types=recommendation_types, + ) + + @_tool(title="Performance Max performance", annotations=_READONLY, tags={"ads"}) @_safe def get_pmax_performance( diff --git a/tests/test_recommendation_subscriptions.py b/tests/test_recommendation_subscriptions.py new file mode 100644 index 0000000..8bc060c --- /dev/null +++ b/tests/test_recommendation_subscriptions.py @@ -0,0 +1,248 @@ +"""Auto-apply recommendation subscriptions: read, and pause only (#107).""" + +from __future__ import annotations + +from datetime import date +from types import SimpleNamespace + +import pytest + +from adloop.ads import recommendation_subscriptions as subs +from adloop.ads import write +from adloop.ads.validate_only import ValidateOnlyClient +from adloop.config import AdLoopConfig, AdsConfig, SafetyConfig +from adloop.safety import preview as preview_store +from tests.test_ads_write import _FakeClient + +CID = "1234567890" +TODAY = date(2026, 10, 27) + + +@pytest.fixture(autouse=True) +def _plans(): + preview_store.set_plan_store(preview_store.InMemoryPlanStore()) + yield + preview_store.set_plan_store(preview_store.InMemoryPlanStore()) + + +@pytest.fixture +def config(tmp_path) -> AdLoopConfig: + return AdLoopConfig( + ads=AdsConfig(customer_id=CID), + safety=SafetyConfig(log_file=str(tmp_path / "audit.log")), + ) + + +def _row(rec_type, status, modified="2026-01-05 10:00:00"): + return { + "recommendation_subscription.resource_name": ( + f"customers/{CID}/recommendationSubscriptions/{rec_type}" + ), + "recommendation_subscription.type": rec_type, + "recommendation_subscription.status": status, + "recommendation_subscription.create_date_time": "2025-12-01 09:00:00", + "recommendation_subscription.modify_date_time": modified, + } + + +@pytest.fixture +def account(monkeypatch): + rows = [ + _row("USE_BROAD_MATCH_KEYWORD", "ENABLED", modified="2026-10-20 08:30:00"), + _row("KEYWORD", "ENABLED"), + _row("RESPONSIVE_SEARCH_AD", "PAUSED"), + ] + queries: list[str] = [] + + def execute_query(_config, _cid, query): + queries.append(query) + return list(rows) + + monkeypatch.setattr("adloop.ads.gaql.execute_query", execute_query) + return SimpleNamespace(rows=rows, queries=queries) + + +class TestRead: + def test_lists_every_subscription_with_dates(self, config, account): + result = subs.get_recommendation_subscriptions(config, customer_id=CID, today=TODAY) + + assert [s["type"] for s in result["subscriptions"]] == [ + "KEYWORD", "RESPONSIVE_SEARCH_AD", "USE_BROAD_MATCH_KEYWORD", + ] + assert result["subscriptions"][0] == { + "type": "KEYWORD", + "status": "ENABLED", + "resource_name": f"customers/{CID}/recommendationSubscriptions/KEYWORD", + "create_date_time": "2025-12-01 09:00:00", + "modify_date_time": "2026-01-05 10:00:00", + } + assert result["enabled_count"] == 2 + assert "FROM recommendation_subscription" in account.queries[0] + + def test_insights_name_enabled_types_and_recent_changes(self, config, account): + insights = subs.get_recommendation_subscriptions( + config, customer_id=CID, today=TODAY + )["insights"] + + assert insights[0].startswith( + "2 recommendation type(s) auto-apply without a preview: KEYWORD, USE_BROAD_MATCH_KEYWORD." + ) + # Only the one modified within RECENT_DAYS is flagged as a change. + assert len(insights) == 2 + assert insights[1].startswith("USE_BROAD_MATCH_KEYWORD auto-apply was turned on or changed") + + def test_nothing_auto_applied(self, config, account): + account.rows[:] = [_row("KEYWORD", "PAUSED")] + + result = subs.get_recommendation_subscriptions(config, customer_id=CID, today=TODAY) + + assert result["insights"] == ["No recommendation type is set to auto-apply."] + + def test_an_unparseable_date_is_not_flagged(self, config, account): + account.rows[:] = [_row("KEYWORD", "ENABLED", modified="")] + + insights = subs.get_recommendation_subscriptions( + config, customer_id=CID, today=TODAY + )["insights"] + + assert len(insights) == 1 + + +class TestDraft: + def test_preview_lists_what_will_be_paused(self, config, account): + result = subs.draft_recommendation_subscriptions( + config, customer_id=CID, recommendation_types=["use_broad_match_keyword", "KEYWORD", "KEYWORD"], + ) + + assert result["operation"] == "pause_recommendation_subscriptions" + assert result["requires_double_confirm"] is False + changes = result["changes"] + assert changes["count"] == 2 + assert changes["subscriptions"][0] == { + "type": "USE_BROAD_MATCH_KEYWORD", + "resource_name": f"customers/{CID}/recommendationSubscriptions/USE_BROAD_MATCH_KEYWORD", + "current_status": "ENABLED", + "status": "PAUSED", + "modify_date_time": "2026-10-20 08:30:00", + } + + @pytest.mark.parametrize("rec_type", ["RESPONSIVE_SEARCH_AD", "SITELINK_ASSET"]) + def test_already_paused_or_not_subscribed_is_refused_by_name(self, config, account, rec_type): + result = subs.draft_recommendation_subscriptions( + config, customer_id=CID, recommendation_types=["KEYWORD", rec_type], + ) + + assert result["error"] == "Validation failed" + assert rec_type in result["details"][0] + assert "KEYWORD," not in result["details"][0] + + @pytest.mark.parametrize("bad", [[], [""], ["keyword; DROP"]]) + def test_bad_input_is_refused_before_any_read(self, config, bad): + # The suite-wide no_live_gaql fixture raises if anything is read. + result = subs.draft_recommendation_subscriptions( + config, customer_id=CID, recommendation_types=bad, + ) + + assert result["error"] == "Validation failed" + + def test_there_is_no_way_to_ask_for_enabled(self): + import inspect + + params = inspect.signature(subs.draft_recommendation_subscriptions).parameters + assert set(params) == {"config", "customer_id", "recommendation_types"} + + def test_blocked_operation_is_refused(self, account): + config = AdLoopConfig( + ads=AdsConfig(customer_id=CID), + safety=SafetyConfig(blocked_operations=["pause_recommendation_subscriptions"]), + ) + + result = subs.draft_recommendation_subscriptions( + config, customer_id=CID, recommendation_types=["KEYWORD"], + ) + + assert "error" in result and "Validation failed" not in result["error"] + + +class _SubscriptionService: + def __init__(self): + self.requests = [] + + def mutate_recommendation_subscription(self, request): + self.requests.append(request) + return SimpleNamespace( + results=[SimpleNamespace(resource_name=op.update.resource_name) for op in request.operations] + ) + + +def _changes(*types, status="PAUSED"): + return {"subscriptions": [ + {"type": t, "resource_name": f"customers/{CID}/recommendationSubscriptions/{t}", + "current_status": "ENABLED", "status": status} + for t in types + ]} + + +class TestApply: + def test_one_request_pausing_every_type_under_a_status_mask(self): + service = _SubscriptionService() + client = _FakeClient({"RecommendationSubscriptionService": service}) + + result = subs._apply_pause_recommendation_subscriptions( + client, CID, _changes("KEYWORD", "USE_BROAD_MATCH_KEYWORD") + ) + + (request,) = service.requests + assert request.customer_id == CID + paused = client.enums.RecommendationSubscriptionStatusEnum.PAUSED + for operation in request.operations: + assert operation.update.status == paused + assert list(operation.update_mask.paths) == ["status"] + assert result["paused_count"] == 2 + + def test_a_tampered_plan_still_only_pauses(self): + service = _SubscriptionService() + client = _FakeClient({"RecommendationSubscriptionService": service}) + + subs._apply_pause_recommendation_subscriptions(client, CID, _changes("KEYWORD", status="ENABLED")) + + (operation,) = service.requests[0].operations + assert operation.update.status == client.enums.RecommendationSubscriptionStatusEnum.PAUSED + + def test_the_dry_run_sends_it_validate_only(self): + service = _SubscriptionService() + client = ValidateOnlyClient(_FakeClient({"RecommendationSubscriptionService": service})) + + subs._apply_pause_recommendation_subscriptions(client, CID, _changes("KEYWORD")) + + assert service.requests[0].validate_only is True + assert client.validated_calls == 1 + + def test_the_operation_is_dispatched(self): + service = _SubscriptionService() + plan = SimpleNamespace( + operation="pause_recommendation_subscriptions", + apply_payload=lambda: _changes("KEYWORD"), + ) + + result = write._dispatch_ads_plan( + _FakeClient({"RecommendationSubscriptionService": service}), CID, plan + ) + + assert result["paused_count"] == 1 + + +class TestTools: + @pytest.mark.asyncio + async def test_annotations_and_documented_parameters(self): + from adloop.server import mcp + + tools = {t.name: t for t in await mcp.list_tools()} + read = tools["get_recommendation_subscriptions"] + draft = tools["draft_recommendation_subscriptions"] + assert read.annotations.read_only_hint is True + assert draft.annotations.read_only_hint is not True + assert read.parameters["properties"]["customer_id"].get("description") + for param in ("recommendation_types", "customer_id"): + assert draft.parameters["properties"][param].get("description"), param + assert "status" not in draft.parameters["properties"]