From 7429b50cc34d2dc5dd1d0d71a90ee1ea920d9f8b Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 03:16:12 +0200 Subject: [PATCH] feat(ads): wrap the Google Ads surface client.ads.google covers what only Google has: keywords and keyword ideas, search terms, bid strategies, ad schedule, negative keyword lists, assets, Performance Max asset groups, Local Services leads, conversions, and query for a raw GAQL read. Campaigns, ad groups, ads, audiences and insights stay on client.ads, where they dispatch by connection. client.ads.authorize_google starts the connection. --- README.md | 27 ++ src/fopost/__init__.py | 24 ++ src/fopost/models.py | 109 ++++++ src/fopost/resources/ads.py | 17 +- src/fopost/resources/google_ads.py | 539 +++++++++++++++++++++++++++++ tests/test_google_ads.py | 103 ++++++ 6 files changed, 818 insertions(+), 1 deletion(-) create mode 100644 src/fopost/resources/google_ads.py create mode 100644 tests/test_google_ads.py diff --git a/README.md b/README.md index 828b424..014a016 100644 --- a/README.md +++ b/README.md @@ -245,3 +245,30 @@ uv run mypy MIT Questions or a problem: [fopost.com/contact](https://fopost.com/contact). + +### Google Ads + +Campaigns, ad groups, ads, audiences and insights are on `client.ads` and dispatch by +connection. What only Google has is under `client.ads.google`: + +```python +keywords = client.ads.google.keywords( + connection_id="c4d5e6f7-…", + customer_id="1234567890", +) + +client.ads.google.create_keyword( + workspace_id="7d2b8c11-…", + connection_id="c4d5e6f7-…", + customer_id="1234567890", + ad_group_id="1234567890~adGroup~77", + text="running shoes", + match_type="EXACT", +) +``` + +Also `keyword_ideas`, `keyword_metrics`, `search_terms`, `bid_strategies`, +`ad_schedule` and `set_ad_schedule`, the negative keyword lists, `assets` and +`asset_groups`, `local_services_leads`, the conversion methods, and `query` for a raw +read-only GAQL SELECT. Changes need the `publish` scope as well as `ads`; `customer_id` +has to name an account the connection's grant reaches. diff --git a/src/fopost/__init__.py b/src/fopost/__init__.py index 6ec2553..bc3c5ae 100644 --- a/src/fopost/__init__.py +++ b/src/fopost/__init__.py @@ -57,6 +57,18 @@ Delivery, ExternalAd, FeedLead, + GoogleAdScheduleSlot, + GoogleAsset, + GoogleAssetGroup, + GoogleAssetLink, + GoogleAssetsResult, + GoogleBidStrategy, + GoogleConversionAction, + GoogleKeyword, + GoogleKeywordIdea, + GoogleLocalServicesLead, + GoogleSearchTerm, + GoogleSharedSet, InboxAccount, InboxAccountRef, InboxApproval, @@ -208,4 +220,16 @@ "LeadsFeedPage", "NetworkAd", "ReachEstimate", + "GoogleAdScheduleSlot", + "GoogleAsset", + "GoogleAssetGroup", + "GoogleAssetLink", + "GoogleAssetsResult", + "GoogleBidStrategy", + "GoogleConversionAction", + "GoogleKeyword", + "GoogleKeywordIdea", + "GoogleLocalServicesLead", + "GoogleSearchTerm", + "GoogleSharedSet", ] diff --git a/src/fopost/models.py b/src/fopost/models.py index 7a0b4ec..9490d1c 100644 --- a/src/fopost/models.py +++ b/src/fopost/models.py @@ -999,3 +999,112 @@ class ValidateMediaResult(FopostModel): size: int | None = None mime_type: str | None = None type: str | None = None + + +# ─── Google Ads ──────────────────────────────────────────────────── + + +class GoogleKeyword(FopostModel): + """``~keyword~~`` as ``id``.""" + + id: str + ad_group_id: str + text: str + match_type: str + status: str + #: Account currency, minor units. + cpc_bid_minor: int | None = None + negative: bool = False + + +class GoogleKeywordIdea(FopostModel): + text: str + avg_monthly_searches: int = 0 + competition: str | None = None + low_top_of_page_bid_minor: int | None = None + high_top_of_page_bid_minor: int | None = None + + +class GoogleSearchTerm(FopostModel): + term: str + ad_group_id: str | None = None + status: str | None = None + metrics: dict[str, Any] = {} + + +class GoogleBidStrategy(FopostModel): + id: str + name: str + type: str + status: str + campaign_count: int = 0 + + +class GoogleAdScheduleSlot(FopostModel): + id: str + day_of_week: str + start_hour: int = 0 + end_hour: int = 0 + bid_modifier: float | None = None + + +class GoogleSharedSet(FopostModel): + id: str + name: str + type: str + member_count: int = 0 + + +class GoogleAsset(FopostModel): + id: str + name: str | None = None + type: str + #: What a sitelink, callout or snippet renders. + text: str | None = None + final_url: str | None = None + + +class GoogleAssetLink(FopostModel): + """Where an asset is attached; an asset with no links serves nowhere.""" + + id: str + asset_id: str + level: str + owner_id: str | None = None + field_type: str + status: str + + +class GoogleAssetsResult(FopostModel): + assets: list[GoogleAsset] = [] + links: list[GoogleAssetLink] = [] + + +class GoogleAssetGroup(FopostModel): + id: str + campaign_id: str + name: str + status: str + final_urls: list[str] = [] + + +class GoogleLocalServicesLead(FopostModel): + id: str + category: str | None = None + service: str | None = None + contact_name: str | None = None + phone: str | None = None + email: str | None = None + status: str | None = None + type: str | None = None + created_at: str | None = None + + +class GoogleConversionAction(FopostModel): + id: str + name: str + category: str + status: str + type: str + counting_type: str | None = None + value_minor: int | None = None diff --git a/src/fopost/resources/ads.py b/src/fopost/resources/ads.py index a9fd434..3a709e0 100644 --- a/src/fopost/resources/ads.py +++ b/src/fopost/resources/ads.py @@ -1,4 +1,4 @@ -"""``client.ads`` — Meta ads, audiences and lead forms. +"""``client.ads`` — ads, audiences and lead forms across the ad networks. Every method needs the ``ads`` scope; ``boost``, ``create``, ``set_status``, ``delete``, ``bulk_set_status`` and every create, update, delete or duplicate on @@ -37,11 +37,17 @@ TargetingOption, ) from ._base import UNSET, Resource, drop_unset, parse_list +from .google_ads import GoogleAdsResource __all__ = ["AdsResource"] class AdsResource(Resource): + def __init__(self, http: Any) -> None: + super().__init__(http) + #: The Search surface no other network has: keywords, assets, conversions, GAQL. + self.google = GoogleAdsResource(http) + def list(self, *, workspace_id: str | None = None) -> builtins.list[Ad]: """Boosts and ads created through FoPost, with insights from their last refresh.""" return parse_list(Ad, unwrap(self._http.get("/ads", {"workspace_id": workspace_id}))) @@ -83,6 +89,15 @@ def authorize_meta( url = result.get("url") if isinstance(result, dict) else None return str(url) if url else "" + def authorize_google(self, *, workspace_id: str, return_to: str | None = None) -> str: + """The Google login URL; the caller finishes it in their own browser.""" + body: dict[str, Any] = {"workspaceId": workspace_id} + if return_to is not None: + body["returnTo"] = return_to + result = unwrap(self._http.post("/ads/connections/google/authorize", body)) + url = result.get("url") if isinstance(result, dict) else None + return str(url) if url else "" + def delete_connection(self, connection_id: str, *, workspace_id: str) -> None: """Also deletes every ad record created through the connection.""" self._http.request( diff --git a/src/fopost/resources/google_ads.py b/src/fopost/resources/google_ads.py new file mode 100644 index 0000000..31b98a7 --- /dev/null +++ b/src/fopost/resources/google_ads.py @@ -0,0 +1,539 @@ +"""``client.ads.google`` — the Google Ads surface no other network has. + +Campaigns, ad groups, ads, audiences and insights live on ``client.ads`` and +dispatch by connection. What is here — keywords, assets, Performance Max asset +groups, Local Services leads, conversions and raw GAQL — is Google only, and a +connection on another network answers 400. + +Every method needs the ``ads`` scope; anything that changes what a live account +serves or bids also needs ``publish``. ``customer_id`` is digits only and has +to name an account the connection's grant reaches: any other answers 404. +""" + +from __future__ import annotations + +import builtins +from collections.abc import Mapping, Sequence +from typing import Any + +from .._http import unwrap +from ..models import ( + GoogleAdScheduleSlot, + GoogleAssetGroup, + GoogleAssetsResult, + GoogleBidStrategy, + GoogleConversionAction, + GoogleKeyword, + GoogleKeywordIdea, + GoogleLocalServicesLead, + GoogleSearchTerm, + GoogleSharedSet, +) +from ._base import Resource, parse_list + +__all__ = ["GoogleAdsResource"] + + +class GoogleAdsResource(Resource): + # ── Keywords ── + + def keywords( + self, + *, + connection_id: str, + customer_id: str, + workspace_id: str | None = None, + ad_group_id: str | None = None, + ) -> builtins.list[GoogleKeyword]: + """Keywords on the account, or on one ad group.""" + return parse_list( + GoogleKeyword, + unwrap( + self._http.get( + "/ads/google/keywords", + _query(workspace_id, connection_id, customer_id, ad_group_id=ad_group_id), + ) + ), + ) + + def create_keyword( + self, + *, + workspace_id: str, + connection_id: str, + customer_id: str, + ad_group_id: str, + text: str, + match_type: str, + cpc_bid_minor: int | None = None, + ) -> str: + """The new keyword's id. Needs ``publish`` as well as ``ads``.""" + body = _scope(workspace_id, connection_id, customer_id) + body["adGroupId"] = ad_group_id + body["text"] = text + body["matchType"] = match_type + if cpc_bid_minor is not None: + body["cpcBidMinor"] = cpc_bid_minor + return _id(unwrap(self._http.post("/ads/google/keywords", body))) + + def update_keyword( + self, + keyword_id: str, + *, + workspace_id: str, + connection_id: str, + customer_id: str, + status: str | None = None, + cpc_bid_minor: int | None = None, + ) -> str: + """``status`` is ``active`` or ``paused``. Needs ``publish`` as well as ``ads``.""" + body = _scope(workspace_id, connection_id, customer_id) + if status is not None: + body["status"] = status + if cpc_bid_minor is not None: + body["cpcBidMinor"] = cpc_bid_minor + return _id( + unwrap(self._http.request("PATCH", f"/ads/google/keywords/{keyword_id}", json=body)) + ) + + def delete_keyword( + self, keyword_id: str, *, workspace_id: str, connection_id: str, customer_id: str + ) -> None: + """Needs ``publish`` as well as ``ads``.""" + self._http.delete( + f"/ads/google/keywords/{keyword_id}", + json=_scope(workspace_id, connection_id, customer_id), + ) + + def keyword_ideas( + self, + *, + workspace_id: str, + connection_id: str, + customer_id: str, + seeds: Sequence[str] | None = None, + url: str | None = None, + language_id: str | None = None, + geo_target_ids: Sequence[str] | None = None, + ) -> builtins.list[GoogleKeywordIdea]: + """Ideas from seed keywords, a landing page, or both.""" + body = _scope(workspace_id, connection_id, customer_id) + if seeds is not None: + body["seeds"] = list(seeds) + if url is not None: + body["url"] = url + if language_id is not None: + body["languageId"] = language_id + if geo_target_ids is not None: + body["geoTargetIds"] = list(geo_target_ids) + return parse_list( + GoogleKeywordIdea, unwrap(self._http.post("/ads/google/keyword-ideas", body)) + ) + + def keyword_metrics( + self, + *, + workspace_id: str, + connection_id: str, + customer_id: str, + keywords: Sequence[str], + ) -> builtins.list[GoogleKeywordIdea]: + body = _scope(workspace_id, connection_id, customer_id) + body["keywords"] = list(keywords) + return parse_list( + GoogleKeywordIdea, unwrap(self._http.post("/ads/google/keyword-metrics", body)) + ) + + def search_terms( + self, + *, + connection_id: str, + customer_id: str, + since: str, + until: str, + workspace_id: str | None = None, + ) -> builtins.list[GoogleSearchTerm]: + """What people actually searched, with the metrics each term earned.""" + return parse_list( + GoogleSearchTerm, + unwrap( + self._http.get( + "/ads/google/search-terms", + _query(workspace_id, connection_id, customer_id, since=since, until=until), + ) + ), + ) + + # ── Bid strategies and ad schedule ── + + def bid_strategies( + self, *, connection_id: str, customer_id: str, workspace_id: str | None = None + ) -> builtins.list[GoogleBidStrategy]: + return parse_list( + GoogleBidStrategy, + unwrap( + self._http.get( + "/ads/google/bid-strategies", _query(workspace_id, connection_id, customer_id) + ) + ), + ) + + def create_bid_strategy( + self, + *, + workspace_id: str, + connection_id: str, + customer_id: str, + name: str, + type: str, + target_minor: int | None = None, + ) -> str: + """Needs ``publish`` as well as ``ads``.""" + body = _scope(workspace_id, connection_id, customer_id) + body["name"] = name + body["type"] = type + if target_minor is not None: + body["targetMinor"] = target_minor + return _id(unwrap(self._http.post("/ads/google/bid-strategies", body))) + + def ad_schedule( + self, + *, + connection_id: str, + customer_id: str, + campaign_id: str, + workspace_id: str | None = None, + ) -> builtins.list[GoogleAdScheduleSlot]: + return parse_list( + GoogleAdScheduleSlot, + unwrap( + self._http.get( + "/ads/google/ad-schedule", + _query(workspace_id, connection_id, customer_id, campaign_id=campaign_id), + ) + ), + ) + + def set_ad_schedule( + self, + *, + workspace_id: str, + connection_id: str, + customer_id: str, + campaign_id: str, + slots: Sequence[Mapping[str, Any]], + ) -> int: + """Replaces every slot on the campaign. Needs ``publish`` as well as ``ads``.""" + body = _scope(workspace_id, connection_id, customer_id) + body["campaignId"] = campaign_id + body["slots"] = [dict(slot) for slot in slots] + result = unwrap(self._http.put("/ads/google/ad-schedule", body)) + return int(result.get("slots", 0)) if isinstance(result, dict) else 0 + + # ── Negative keyword lists ── + + def negative_keyword_lists( + self, *, connection_id: str, customer_id: str, workspace_id: str | None = None + ) -> builtins.list[GoogleSharedSet]: + return parse_list( + GoogleSharedSet, + unwrap( + self._http.get( + "/ads/google/negative-keywords", + _query(workspace_id, connection_id, customer_id), + ) + ), + ) + + def create_negative_keyword_list( + self, *, workspace_id: str, connection_id: str, customer_id: str, name: str + ) -> str: + """Needs ``publish`` as well as ``ads``.""" + body = _scope(workspace_id, connection_id, customer_id) + body["name"] = name + return _id(unwrap(self._http.post("/ads/google/negative-keywords", body))) + + def add_negative_keywords( + self, + *, + workspace_id: str, + connection_id: str, + customer_id: str, + shared_set_id: str, + keywords: Sequence[Mapping[str, Any]], + ) -> int: + """How many were added. Needs ``publish`` as well as ``ads``.""" + body = _scope(workspace_id, connection_id, customer_id) + body["sharedSetId"] = shared_set_id + body["keywords"] = [dict(keyword) for keyword in keywords] + result = unwrap(self._http.post("/ads/google/negative-keywords/keywords", body)) + return int(result.get("added", 0)) if isinstance(result, dict) else 0 + + def attach_negative_keyword_list( + self, + *, + workspace_id: str, + connection_id: str, + customer_id: str, + shared_set_id: str, + campaign_id: str, + ) -> None: + """Needs ``publish`` as well as ``ads``.""" + body = _scope(workspace_id, connection_id, customer_id) + body["sharedSetId"] = shared_set_id + body["campaignId"] = campaign_id + self._http.post("/ads/google/negative-keywords/attach", body) + + # ── Assets ── + + def assets( + self, *, connection_id: str, customer_id: str, workspace_id: str | None = None + ) -> GoogleAssetsResult: + """Sitelinks, callouts and snippets, with the links that put each under an ad.""" + data = unwrap( + self._http.get("/ads/google/assets", _query(workspace_id, connection_id, customer_id)) + ) + return GoogleAssetsResult.model_validate(data if isinstance(data, dict) else {}) + + def create_asset( + self, + *, + workspace_id: str, + connection_id: str, + customer_id: str, + spec: Mapping[str, Any], + ) -> str: + """``spec`` is a sitelink, callout or snippet. Needs ``publish`` as well as ``ads``.""" + body = _scope(workspace_id, connection_id, customer_id) + body["spec"] = dict(spec) + return _id(unwrap(self._http.post("/ads/google/assets", body))) + + def attach_asset( + self, + *, + workspace_id: str, + connection_id: str, + customer_id: str, + asset_id: str, + field_type: str, + campaign_id: str | None = None, + ) -> None: + """Attaches to the account when ``campaign_id`` is left out. Needs ``publish``.""" + body = _scope(workspace_id, connection_id, customer_id) + body["assetId"] = asset_id + body["fieldType"] = field_type + if campaign_id is not None: + body["campaignId"] = campaign_id + self._http.post("/ads/google/assets/attach", body) + + def delete_asset( + self, asset_id: str, *, workspace_id: str, connection_id: str, customer_id: str + ) -> None: + """Removes the links that put it under an ad; on Google the asset itself is permanent.""" + self._http.delete( + f"/ads/google/assets/{asset_id}", + json=_scope(workspace_id, connection_id, customer_id), + ) + + # ── Performance Max asset groups ── + + def asset_groups( + self, + *, + connection_id: str, + customer_id: str, + workspace_id: str | None = None, + campaign_id: str | None = None, + ) -> builtins.list[GoogleAssetGroup]: + return parse_list( + GoogleAssetGroup, + unwrap( + self._http.get( + "/ads/google/asset-groups", + _query(workspace_id, connection_id, customer_id, campaign_id=campaign_id), + ) + ), + ) + + def create_asset_group( + self, + *, + workspace_id: str, + connection_id: str, + customer_id: str, + campaign_id: str, + name: str, + final_urls: Sequence[str], + status: str | None = None, + ) -> str: + """Starts paused unless ``status`` says otherwise. Needs ``publish``.""" + body = _scope(workspace_id, connection_id, customer_id) + body["campaignId"] = campaign_id + body["name"] = name + body["finalUrls"] = list(final_urls) + if status is not None: + body["status"] = status + return _id(unwrap(self._http.post("/ads/google/asset-groups", body))) + + def update_asset_group( + self, + asset_group_id: str, + *, + workspace_id: str, + connection_id: str, + customer_id: str, + name: str | None = None, + status: str | None = None, + ) -> str: + """Needs ``publish`` as well as ``ads``.""" + body = _scope(workspace_id, connection_id, customer_id) + if name is not None: + body["name"] = name + if status is not None: + body["status"] = status + return _id( + unwrap( + self._http.request("PATCH", f"/ads/google/asset-groups/{asset_group_id}", json=body) + ) + ) + + def delete_asset_group( + self, asset_group_id: str, *, workspace_id: str, connection_id: str, customer_id: str + ) -> None: + """Needs ``publish`` as well as ``ads``.""" + self._http.delete( + f"/ads/google/asset-groups/{asset_group_id}", + json=_scope(workspace_id, connection_id, customer_id), + ) + + # ── Local Services leads ── + + def local_services_leads( + self, + *, + connection_id: str, + customer_id: str, + since: str, + until: str, + workspace_id: str | None = None, + ) -> builtins.list[GoogleLocalServicesLead]: + """Read live on every call and never stored by FoPost.""" + return parse_list( + GoogleLocalServicesLead, + unwrap( + self._http.get( + "/ads/google/local-services", + _query(workspace_id, connection_id, customer_id, since=since, until=until), + ) + ), + ) + + # ── Conversions ── + + def conversion_actions( + self, *, connection_id: str, customer_id: str, workspace_id: str | None = None + ) -> builtins.list[GoogleConversionAction]: + return parse_list( + GoogleConversionAction, + unwrap( + self._http.get( + "/ads/google/conversions", _query(workspace_id, connection_id, customer_id) + ) + ), + ) + + def create_conversion_action( + self, + *, + workspace_id: str, + connection_id: str, + customer_id: str, + name: str, + category: str, + value_minor: int | None = None, + counting_type: str | None = None, + ) -> str: + """Needs ``publish`` as well as ``ads``.""" + body = _scope(workspace_id, connection_id, customer_id) + body["name"] = name + body["category"] = category + if value_minor is not None: + body["valueMinor"] = value_minor + if counting_type is not None: + body["countingType"] = counting_type + return _id(unwrap(self._http.post("/ads/google/conversions", body))) + + def upload_conversions( + self, + *, + workspace_id: str, + connection_id: str, + customer_id: str, + conversions: Sequence[Mapping[str, Any]], + ) -> int: + """Offline conversions, matched to a click. Needs ``publish`` as well as ``ads``.""" + body = _scope(workspace_id, connection_id, customer_id) + body["conversions"] = [dict(conversion) for conversion in conversions] + result = unwrap(self._http.post("/ads/google/conversions/upload", body)) + return int(result.get("uploaded", 0)) if isinstance(result, dict) else 0 + + def upload_conversion_adjustments( + self, + *, + workspace_id: str, + connection_id: str, + customer_id: str, + adjustments: Sequence[Mapping[str, Any]], + ) -> int: + """Needs ``publish`` as well as ``ads``.""" + body = _scope(workspace_id, connection_id, customer_id) + body["adjustments"] = [dict(adjustment) for adjustment in adjustments] + result = unwrap(self._http.post("/ads/google/conversions/adjustments", body)) + return int(result.get("uploaded", 0)) if isinstance(result, dict) else 0 + + # ── GAQL ── + + def query( + self, + *, + connection_id: str, + customer_id: str, + query: str, + workspace_id: str | None = None, + ) -> builtins.list[dict[str, Any]]: + """A read-only GAQL SELECT; rows come back exactly as Google returns them.""" + body: dict[str, Any] = { + "connectionId": connection_id, + "customerId": customer_id, + "query": query, + } + if workspace_id is not None: + body["workspaceId"] = workspace_id + result = unwrap(self._http.post("/ads/insights/query", body)) + rows = result.get("rows") if isinstance(result, dict) else None + return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else [] + + +def _scope(workspace_id: str, connection_id: str, customer_id: str) -> dict[str, Any]: + return { + "workspaceId": workspace_id, + "connectionId": connection_id, + "customerId": customer_id, + } + + +def _query( + workspace_id: str | None, connection_id: str, customer_id: str, **extra: Any +) -> dict[str, Any]: + params: dict[str, Any] = { + "workspace_id": workspace_id, + "connection_id": connection_id, + "customer_id": customer_id, + } + params.update(extra) + return params + + +def _id(result: Any) -> str: + value = result.get("id") if isinstance(result, dict) else None + return str(value) if value else "" diff --git a/tests/test_google_ads.py b/tests/test_google_ads.py new file mode 100644 index 0000000..95ea564 --- /dev/null +++ b/tests/test_google_ads.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import json + +import httpx +import respx + +from fopost import Fopost +from tests.conftest import BASE_URL + +SCOPE = {"workspace_id": "ws_1", "connection_id": "conn_1", "customer_id": "1234567890"} + +KEYWORD_FIXTURE = { + "id": "1234567890~keyword~77~99", + "adGroupId": "1234567890~adGroup~77", + "text": "running shoes", + "matchType": "EXACT", + "status": "ENABLED", + "cpcBidMinor": 180, + "negative": False, +} + + +@respx.mock +def test_keywords_name_the_connection_and_the_customer(client: Fopost) -> None: + route = respx.get(f"{BASE_URL}/ads/google/keywords").mock( + return_value=httpx.Response(200, json={"data": [KEYWORD_FIXTURE]}) + ) + keywords = client.ads.google.keywords( + connection_id="conn_1", customer_id="1234567890", ad_group_id="1234567890~adGroup~77" + ) + assert keywords[0].cpc_bid_minor == 180 + assert keywords[0].match_type == "EXACT" + params = route.calls.last.request.url.params + assert params["connection_id"] == "conn_1" + assert params["customer_id"] == "1234567890" + assert params["ad_group_id"] == "1234567890~adGroup~77" + + +@respx.mock +def test_create_keyword_sends_the_camel_case_body(client: Fopost) -> None: + route = respx.post(f"{BASE_URL}/ads/google/keywords").mock( + return_value=httpx.Response(201, json={"data": {"id": "1234567890~keyword~77~99"}}) + ) + created = client.ads.google.create_keyword( + **SCOPE, ad_group_id="1234567890~adGroup~77", text="running shoes", match_type="EXACT" + ) + assert created == "1234567890~keyword~77~99" + body = json.loads(route.calls.last.request.content) + assert body["adGroupId"] == "1234567890~adGroup~77" + assert body["matchType"] == "EXACT" + assert body["customerId"] == "1234567890" + + +@respx.mock +def test_delete_carries_the_scope_in_the_body(client: Fopost) -> None: + route = respx.delete(f"{BASE_URL}/ads/google/assets/1234567890~asset~4321").mock( + return_value=httpx.Response(204) + ) + client.ads.google.delete_asset("1234567890~asset~4321", **SCOPE) + body = json.loads(route.calls.last.request.content) + assert body == { + "workspaceId": "ws_1", + "connectionId": "conn_1", + "customerId": "1234567890", + } + + +@respx.mock +def test_ad_schedule_is_replaced_with_put(client: Fopost) -> None: + route = respx.put(f"{BASE_URL}/ads/google/ad-schedule").mock( + return_value=httpx.Response(200, json={"data": {"slots": 2}}) + ) + slots = client.ads.google.set_ad_schedule( + **SCOPE, + campaign_id="1234567890~campaign~55", + slots=[{"dayOfWeek": "MONDAY", "startHour": 9, "endHour": 18}], + ) + assert slots == 2 + assert route.calls.last.request.method == "PUT" + + +@respx.mock +def test_query_returns_rows_as_google_sends_them(client: Fopost) -> None: + respx.post(f"{BASE_URL}/ads/insights/query").mock( + return_value=httpx.Response(200, json={"data": {"rows": [{"campaign": {"id": "55"}}]}}) + ) + rows = client.ads.google.query( + connection_id="conn_1", + customer_id="1234567890", + query="SELECT campaign.id FROM campaign", + ) + assert rows == [{"campaign": {"id": "55"}}] + + +@respx.mock +def test_authorize_google_has_its_own_route(client: Fopost) -> None: + route = respx.post(f"{BASE_URL}/ads/connections/google/authorize").mock( + return_value=httpx.Response(200, json={"data": {"url": "https://accounts.google.com/o/x"}}) + ) + url = client.ads.authorize_google(workspace_id="ws_1") + assert url == "https://accounts.google.com/o/x" + assert route.called