Prima Get an API key

Module kit / Ads

Google Ads Campaign Management

ads/google-ads-campaign-management

Kind backend
Domain ads
Maturity prod
Reusable extract
Stack fastapi-0.118.2, httpx-0.28.1, pydantic-2.12.0, sqlalchemy-2.0-sync
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping, _shared/error-envelope, _shared/test-layout
Frontends ads/ui/ads-manager
Reference implementation
  • alchemy_labs/backend/services/google_ads_manage.py
  • alchemy_labs/backend/services/google_ads_campaign_service.py
  • alchemy_labs/backend/api/routers/google_ads_management_router.py
  • alchemy_labs/backend/schemas/google_ads_management_schemas.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

Once a Google Ads campaign already exists, this module lets a connected account be managed against the raw v23 REST API without hand-writing GAQL or mutate JSON: switching a campaign's bidding scheme, curating negative keywords at the campaign and shared-set level, running CRUD on conversion actions, and building ad-hoc performance reports. The invariant that makes it trustworthy is that no caller-supplied string ever reaches a GAQL query or a resource-name path unless it first passed a closed whitelist or a strict validator: build_gaql() only ever emits field names from the GAQL_LEVELS/GAQL_METRICS/GAQL_SEGMENTS maps and interpolates only int()-validated numbers or ^\d{4}-\d{2}-\d{2}$-matched dates (module docstring, google_ads_manage.py 14 to 16), and build_bidding_update() refuses to return unless exactly one bidding-scheme oneof key is present on the update object (google_ads_campaign_service.py 141 to 145).

When to use

Read it before touching build_bidding_update, build_gaql, GoogleAdsClient.mutate/.search/.call, batch_mutate_result/parse_partial_failure, or any method on GoogleAdsCampaignService; before changing which GAQL fields are selectable, what counts as a valid bidding strategy, or how a partial-failure mutate is reported back. Trigger phrases: "bidding scheme update", "single oneof + updateMask", "campaign negative keywords", "shared negative-keyword set", "conversion action CRUD", "GAQL whitelist", "build_gaql", "add negative keywords", "attach shared set to campaign", "switch bidding strategy", "customer_id validation", "Phase-2 scaffold", "501 not implemented yet".

Not this recipe: campaign/ad-group/ad creation and the cross-platform orchestrator that drives it (api/routers/campaign_setup_router.py, a separate router); the pre-existing unified ads connection/account-listing surface this module borrows workspace resolution from (api/routers/ads_router.py, _require_workspace/_get_tracking_settings/_get_google_refresh_token, a neighbouring recipe, not rebuilt here); Meta and LinkedIn ads management (api/routers/ads_meta_router.py, api/routers/linkedin_ads_router.py); the five Phase-2 surfaces this same service file scaffolds but does not implement - assets/extensions, audiences, Customer Match upload, Performance Max/Display/Shopping, experiments - every one of them raises NotImplementedError unconditionally at the service layer and answers HTTP 501 at the router (google_ads_campaign_service.py 478 to 525; google_ads_management_router.py 516 to 595), a future recipe once built, not this one; the OAuth connect flow and refresh-token storage itself (ads_router.py reads workspace_settings.settings.tracking and decrypts it via decrypt_tracking_tokens, it does not write it, and the encryption scheme is out of this recipe's scope).

Stack assumptions

At 62ba4fc: fastapi==0.118.2, httpx==0.28.1 (async REST calls to https://googleads.googleapis.com/v23, GOOGLE_ADS_API_VERSION/GOOGLE_ADS_API_BASE, google_ads_manage.py 35 to 36), pydantic==2.12.0 (every request/response schema in schemas/google_ads_management_schemas.py), SQLAlchemy==2.0.43 sync Session - but only at the router's _build_service helper, which does one db.query(WorkspaceSettings) to resolve a stored refresh token (google_ads_management_router.py 70 to 94, delegating to ads_router.py 232 to 244); the two service files this recipe is centered on (google_ads_manage.py, google_ads_campaign_service.py) touch no ORM and own no table. Both service files are deliberately self-contained: the manage-client module's own docstring says it does NOT import from api.routers.ads_router or services.google_ads_service, token refresh and mutate/search plumbing are re-implemented here rather than shared, "so this module stays isolated" (google_ads_manage.py 1 to 17); the PII-hashing helpers in the campaign-service file are likewise a verbatim copy of services/google_ads_service.py lines 102 to 174, not an import (google_ads_campaign_service.py 12 to 13, 40 to 82).

Foundations assumed: _shared/fastapi-project-layout for the app-mount pattern (mount_router, app_fastapi.py 866); _shared/auth-dependency-pattern for CurrentUser/DatabaseSession (api/dependencies.py 565, 572); _shared/workspace-scoping for the X-Workspace-ID header → get_workspace_id(request) → _require_workspace chain (ads_router.py 247 to 252); _shared/error-envelope for the {source, google_status, message, request_id} detail shape every Google-origin error is remapped into (_google_http_error, google_ads_management_router.py 97 to 107); _shared/test-layout for where tests/test_google_ads_manage.py sits and why it needs neither database nor network.

Environment, all in core/config.py: GOOGLE_ADS_DEVELOPER_TOKEN (SecretStr, line 1000; unset raises GoogleAdsApiError(500, "GOOGLE_ADS_DEVELOPER_TOKEN not configured") from default_developer_token(), google_ads_manage.py 132 to 136); GOOGLE_ADS_CLIENT_ID/GOOGLE_ADS_CLIENT_SECRET (1002 to 1003), falling back to the Calendar integration's GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET (619 to 620) when the Ads-specific pair is unset (_oauth_client_credentials, google_ads_manage.py 119 to 129); both missing raises GoogleAdsApiError(500, "Google OAuth client credentials not configured"). Fail behaviour: nothing in either service file swallows an exception the way the billing-credits engine's outer wrapper does; a ValueError (bad strategy, bad ID, bad GAQL param) and a GoogleAdsApiError (any non-200 from Google) both propagate up to the router, which is the only layer that catches them and turns them into an HTTPException (400 for ValueError, e.http_status for GoogleAdsApiError, google_ads_management_router.py throughout).

Data model

This module owns no table and runs no migration. Two things stand in for a data model:

The GAQL whitelist maps, the only vocabulary a report or a filter can be built from (google_ads_manage.py 357 to 482):

GAQL_LEVELS      customer | campaign | ad_group | ad | keyword | search_term
                 each maps to a `from` clause, a fixed attribute list, an optional
                 status_field, and whether it supports a campaign_ids/ad_group_ids filter
GAQL_METRICS     17 names (impressions, clicks, cost_micros, conversions, conversions_value,
                 all_conversions, all_conversions_value, ctr, average_cpc, average_cpm,
                 cost_per_conversion, conversions_from_interactions_rate,
                 view_through_conversions, interactions, search_impression_share,
                 search_budget_lost_impression_share, search_rank_lost_impression_share)
GAQL_SEGMENTS    11 names (date, week, month, quarter, year, device, day_of_week, hour,
                 ad_network_type, click_type, conversion_action_name)
GAQL_DATE_PRESETS 9 literal presets (TODAY .. LAST_MONTH)
GAQL_STATUS_VALUES {ENABLED, PAUSED, REMOVED}

The mutate/create JSON shapes this module builds and sends to Google, never persisted locally: a campaign bidding update ({resourceName, maximizeConversions|maximizeConversionValue|manualCpc} + updateMask, google_ads_campaign_service.py 89 to 147); a campaignCriteria/sharedCriteria create ({campaign|sharedSet, negative, keyword: {text, matchType}}, 206 to 321); a conversionActions create/update ({name, category, type, status, countingType, clickThroughLookbackWindowDays, viewThroughLookbackWindowDays, valueSettings: {defaultValue, defaultCurrencyCode, alwaysUseDefaultValue}, primaryForGoal?}, 381 to 464).

What is NOT stored anywhere in this recipe: no local copy of any campaign, keyword, conversion action or report row - every read is a live googleAds:search call, nothing is cached beyond the access token (see Security & tenancy); no raw PII at rest - normalize_email/normalize_phone/sha256_hex exist for the Customer Match Phase-2 path but that path is unimplemented, so nothing in this recipe currently hashes or uploads a real email or phone number (google_ads_campaign_service.py 43 to 82, called from nowhere else in the file at this commit, confirmed by grep -n "normalize_email\|normalize_phone\|sha256_hex" google_ads_campaign_service.py matching only the three defs).

The refresh-token storage this module reads from but does not own: workspace_settings.settings.tracking, either a legacy google_ads_refresh_token string or a google_ads_connections list of {refresh_token, customer_ids, label} (ads_router.py 259 to 287); which one governs is a neighbouring recipe's data model, cited here only because _build_service depends on it.

API surface

Every route below is mounted at /api/ads/google (app_fastapi.py 114, 866: mount_router(app, google_ads_management_router.router, prefix="/api/ads/google", tags=["Google Ads Management"], surfaces=LABS_SURFACES)), behind CurrentUser (Depends(get_current_user)) and a sync DatabaseSession, with tenancy resolved per-request by _build_service(db, request, customer_id) (google_ads_management_router.py 70 to 94): X-Workspace-ID → _require_workspace (400 if missing) → _get_tracking_settings → strip_customer_id (400 ValueError) → _get_google_refresh_token (400 "Google Ads not connected" on RuntimeError) → GoogleAdsClient(...). A GoogleAdsApiError raised inside a handler is always remapped by _google_http_error to HTTPException(status_code=e.http_status, detail={"source": "google_ads", "google_status": e.status_code, "message": e.message, "request_id": e.request_id}) (97 to 107); e.http_status passes 400/401/403/404/409/429 through as-is and maps everything else to 502 (GoogleAdsApiError.http_status, google_ads_manage.py 69 to 74).

GroupRouteStatus codesNotes
ReportingPOST /reports200, 400 (bad GAQL param), Google-mappedArbitrary ReportRequest; builds GAQL then client.search (121 to 138)
ReportingGET /reports/campaigns200, Google-mappedConvenience wrapper, level=campaign (168 to 178)
ReportingGET /reports/keywords200, Google-mappedlevel=keyword (181 to 191)
ReportingGET /reports/search-terms200, Google-mappedlevel=search_term (194 to 204)
ReportingGET /reports/timeseries200, 400 (level not in customer/campaign/ad_group)segments=[date] (207 to 222)
BiddingPATCH /campaigns/{campaign_id}/bidding200, 400 (bad strategy/id), Google-mappedbuild_bidding_update + client.mutate (229 to 249)
Campaign negativesGET /campaigns/{campaign_id}/negative-keywords200, 400, Google-mappedGAQL over campaign_criterion (256 to 270)
Campaign negativesPOST /campaigns/{campaign_id}/negative-keywords200 (BatchMutateResult), 400, Google-mappedpartial-failure batch create (273 to 290)
Campaign negativesDELETE /campaigns/{campaign_id}/negative-keywords/{criterion_id}200, 400, Google-mapped(293 to 311)
Shared setsGET /shared-sets200, Google-mapped(318 to 329)
Shared setsPOST /shared-sets200, Google-mapped(332 to 343)
Shared setsGET /shared-sets/{shared_set_id}/keywords200, 400, Google-mapped(346 to 360)
Shared setsPOST /shared-sets/{shared_set_id}/keywords200 (BatchMutateResult), 400, Google-mapped(363 to 380)
Shared setsDELETE /shared-sets/{shared_set_id}/keywords/{criterion_id}200, 400, Google-mapped(383 to 401)
Shared setsPOST /campaigns/{campaign_id}/shared-sets200, 400, Google-mappedattach (404 to 420)
Shared setsDELETE /campaigns/{campaign_id}/shared-sets/{shared_set_id}200, 400, Google-mappeddetach (423 to 441)
Conversion actionsGET /conversion-actions200, Google-mapped(448 to 459)
Conversion actionsPOST /conversion-actions200 (ConversionActionResponse), Google-mapped(462 to 474)
Conversion actionsPATCH /conversion-actions/{conversion_action_id}200, 400 (no fields to update / bad id), Google-mapped(477 to 492)
Conversion actionsDELETE /conversion-actions/{conversion_action_id}200, 400, Google-mapped(495 to 509)
Phase-2POST /assets501_not_implemented("Assets/extensions") (523 to 535)
Phase-2POST /audiences501(538 to 550)
Phase-2POST /audiences/customer-match501(553 to 565)
Phase-2POST /pmax/asset-groups501(568 to 580)
Phase-2POST /experiments501(583 to 595)

The three entry points the router calls into, the actual reimplementable surface: GoogleAdsCampaignService (one instance per (customer_id, refresh_token) connection, google_ads_campaign_service.py 150 to 158) exposes update_bidding, list_campaign_negative_keywords/add_campaign_negative_keywords/remove_campaign_negative_keyword, list_shared_sets/create_shared_set/list_shared_set_keywords/add_shared_set_keywords/remove_shared_set_keyword, attach_shared_set_to_campaign/detach_shared_set_from_campaign, list_conversion_actions/create_conversion_action/update_conversion_action/remove_conversion_action; GoogleAdsClient.mutate/.search/.call (google_ads_manage.py 264 to 310) are the only three ways any of the above reach Google; build_gaql(report_request) (499 to 616) is a pure function, no I/O.

Application logic

build_bidding_update (google_ads_campaign_service.py 89 to 147), in commit order: validate campaign_id numeric (96); branch on strategy - TARGET_CPA folds into maximizeConversions.targetCpaMicros (v23 has no standalone Target CPA object, 107 to 112), TARGET_ROAS folds into maximizeConversionValue.targetRoas (113 to 118), MAXIMIZE_CONVERSIONS/MAXIMIZE_CONVERSION_VALUE set an empty oneof object (119 to 124), MANUAL_CPC sets {enhancedCpcEnabled} and refuses a cpc_bid_ceiling_micros alongside it (125 to 129); an optional CPC ceiling is grafted onto the automated schemes only, appending to the updateMask (133 to 139); the function's own closing assertion counts how many of six possible oneof keys ended up on update and raises unless exactly one did (141 to 145) - this assertion is what actually enforces the "single oneof" invariant, not the branch structure above it.

Negative keywords, campaign and shared-set (206 to 350): list reads via a fixed GAQL against campaign_criterion/shared_criterion filtered type = 'KEYWORD' (183 to 204, 273 to 291); add is a batch create operation per keyword sent through client.mutate(..., partial_failure=True), normalized by batch_mutate_result into {created, failed, validate_only} (206 to 226, 293 to 312); remove builds the tilde-joined resource name (customers/{cid}/campaignCriteria/{campaign_id}~{criterion_id}, likewise for sharedCriteria) and issues a single remove mutate (228 to 235, 314 to 321); attach/detach do the same for campaignSharedSets ({campaign_id}~{shared_set_id}, 323 to 350).

Conversion actions (354 to 472): create assembles a flat create dict plus a nested valueSettings sub-object, primaryForGoal only when not None (381 to 407); update builds mask_parts incrementally, one push per non-None field on the request, including three separate valueSettings.* mask entries when any value-settings field is present (409 to 448), and raises ValueError("No fields to update") if nothing was set (450 to 451, the one guard this handler has that the others don't); remove is a single remove mutate on the conversionActions resource name (466 to 472).

build_gaql (499 to 616): accept a pydantic model (via model_dump()) or a plain mapping (511 to 516); resolve level, reject anything outside GAQL_LEVELS (518 to 521); SELECT starts from the level's fixed attribute list, appends each requested segment/metric only after checking it against GAQL_SEGMENTS/GAQL_METRICS (526 to 538), customer-level reports get a default metric triple when none was requested (533 to 534), fields are de-duplicated preserving order (541 to 542); WHERE clauses are built in order - an explicit start_date/end_date pair (both required together, each checked against _DATE_RE) takes priority over a date_range preset checked against GAQL_DATE_PRESETS (547 to 560), then campaign_ids/ad_group_ids (each int()-validated via _validated_int_list, gated by the level's supports_campaign_filter/supports_ad_group_filter flags, 562 to 574), then statuses (each checked against GAQL_STATUS_VALUES, gated by the level's status_field, 576 to 584); order_by must resolve to a name already in select_fields - a metric or segment alias, or the literal already-selected field - or the request is rejected (586 to 600); limit is int()-validated and bounded 1..10000, a bool is explicitly rejected as a disguised int (602 to 609, isinstance(limit_raw, bool)); the final string is assembled SELECT ... FROM ... [WHERE ...] [ORDER BY ...] LIMIT n (611 to 616).

GoogleAdsClient (173 to 310), the transport every method above runs on: _get_access_token checks the per-refresh-token cache first, exchanges the refresh token at Google's OAuth endpoint on a miss, and re-caches with a 300-second safety buffer subtracted from the reported expires_in (211 to 233, _TOKEN_EXPIRY_SAFETY_SECONDS 146); _post sends the request, and on a 401 invalidates the cache entry and retries exactly once with a forced-fresh token before giving up (247 to 260); mutate validates resource_type against _RESOURCE_TYPE_RE before ever touching the URL (272 to 273); search pages via nextPageToken, never sends pageSize (removed from the API in v17+, page size is a client-side cap on accumulated rows, 281 to 300); call validates method_path against _METHOD_PATH_RE (308 to 309), the generic escape hatch offlineUserDataJobs:create/offlineUserDataJobs/{id}:addOperations and the top-level googleAds:mutate are reached only through it, and only the Phase-2 scaffolds' docstrings describe using it (487 to 525) - nothing in this commit calls client.call from implemented code.

Invariants

  • A bidding update carries exactly one bidding-scheme oneof, enforced by counting the keys actually present on the built object, not by the branch structure that built it (build_bidding_update 141 to 145; TestBiddingMapping.test_exactly_one_oneof, parametrized over all five strategies).
  • build_gaql never emits a field name outside GAQL_LEVELS/GAQL_METRICS/GAQL_SEGMENTS, and the only caller-supplied values reaching the query text are int()-validated integers, ^\d{4}-\d{2}-\d{2}$-matched dates, and enum values checked against closed sets (module docstring 501 to 509; TestBuildGaql.test_rejects_unknown_metric, .test_rejects_unknown_segment, .test_rejects_unknown_level, .test_rejects_sql_in_date, .test_rejects_non_integer_campaign_ids, .test_rejects_unknown_date_preset, .test_rejects_bad_status).
  • order_by must resolve to a field already in the SELECT list; a metric or segment name that was not also requested is refused rather than silently added (589 to 596; TestBuildGaql.test_rejects_order_by_not_selected).
  • mutate's resource_type and call's method_path are checked against closed regexes before either reaches the URL path, so campaigns/../secrets or ../../oauth2:steal never becomes a request (_RESOURCE_TYPE_RE/_METHOD_PATH_RE, 43 to 46, 272 to 273, 308 to 309; TestGoogleAdsClient.test_mutate_rejects_bad_resource_type, .test_call_rejects_bad_method_path).
  • Every numeric id this module accepts (customer_id, campaign_id, shared_set_id, criterion_id, conversion_action_id) is validated digits-only before it is interpolated into a resource name (strip_customer_id 109 to 116, validate_numeric_id 31 to 36; TestStripCustomerId.test_rejects_non_numeric parametrized over five malformed ids including "123; DROP TABLE"; TestBiddingMapping.test_rejects_non_numeric_campaign_id).
  • Access tokens are cached per SHA-256 hash of the refresh token, never in one shared slot, so two connections' tokens can never cross (module docstring 9 to 13; _token_cache_key 149 to 150; TestGoogleAdsClient.test_token_cache_is_per_connection, asserting ["Bearer token-a", "Bearer token-b"] from two connections sharing one mock handler).
  • search never sends pageSize on any page request and stops once nextPageToken is absent or the accumulated rows reach the caller's cap (281 to 300; TestGoogleAdsClient.test_search_paginates_with_page_token, which asserts all("pageSize" not in p for p in seen_payloads) across both pages).

Not independently tested, and not determinable from this commit alone: that the 401-then-retry in _post (253 to 257) is bounded to exactly one retry rather than only "eventually succeeds" - test_expired_cache_entry_triggers_refresh proves the refresh path works, not that a second consecutive 401 does not loop; state this plainly rather than inventing a retry-limit guarantee the code does not visibly enforce beyond the single if resp.status_code == 401 (no loop, so it is in fact bounded by construction, but no test exercises a double-401 to confirm the second failure surfaces as parse_google_error rather than retrying again).

Frontend

The module that would render this, by id:

ads/ui/ads-manager   the Google Ads management surface (bidding controls, negative-
                      keyword editor, shared-set picker, conversion-action list, GAQL
                      report builder) this service backs; at 62ba4fc the `ads` domain
                      in this kit carries zero recipes, so this is a forward reference
                      to a companion frontend recipe that does not exist yet in this
                      repo, not a claim that one has been written

Minimum admin/debug surface: no dedicated route or SQL query exists (this module keeps no local rows to query). The closest thing to a debug surface is the reporting endpoints themselves: GET /api/ads/google/reports/campaigns?customer_id=... for a live campaign snapshot, and the two negative-keyword/shared-set GET listing endpoints, all of which round-trip to Google on every call, there is nothing cached to inspect locally.

Security & tenancy

Auth dependency: CurrentUser (Depends(get_current_user), api/dependencies.py 565) on every route in this router, no exemption, no public surface unlike the webhook-shaped recipes in other domains. DatabaseSession is a sync Session (572), used only to resolve the workspace's stored Google connection.

Workspace derivation: X-Workspace-ID header → get_workspace_id(request) → _require_workspace raises 400 if absent (ads_router.py 247 to 252); never inferred from the Google customer_id itself, which is caller-supplied and only used to look up which stored refresh token to use, not as a tenancy boundary on its own - the actual boundary is which refresh tokens _get_tracking_settings finds under this workspace's row.

Fail direction: closed at every layer measured - no workspace header, no route proceeds (400); no matching Google Ads connection for the resolved customer_id, 400 "Google Ads not connected" rather than falling back to some other tenant's token (_get_google_refresh_token raising RuntimeError, caught at google_ads_management_router.py 80 to 83); every Phase-2 surface is closed by construction, NotImplementedError is raised unconditionally before any network call is attempted, there is no partial or "best effort" version of assets/audiences/Customer Match/PMax/experiments in this commit.

Secrets at rest: the developer token is read via settings.GOOGLE_ADS_DEVELOPER_TOKEN.get_secret_value() only at header-build time, never logged (google_ads_manage.py 206 to 209, 235 to 243); the refresh token is read from workspace_settings.settings.tracking, decrypted through decrypt_tracking_tokens (ads_router.py 243 to 244) - the encryption/decryption mechanism itself belongs to a different recipe not covered here (no _shared foundation in this kit documents services.security.oauth_token_crypto at this commit; genuine gap, not guessed at). Neither service file writes a Google error response body to any persistent store; parse_google_error truncates the raw response text to 500 characters and keeps it only in the raised exception (google_ads_manage.py 77 to 102).

Rate limits: none dedicated to this router - no rate-limit dependency is imported by google_ads_management_router.py. Google's own 429 passes straight through as this module's own 429 (GoogleAdsApiError.http_status, 72 to 73), there is no local throttling ahead of it.

Attacker-controlled inputs: every path-segment id (campaign_id, shared_set_id, criterion_id, conversion_action_id) is regex-validated numeric before interpolation (validate_numeric_id/_numeric_or_400); customer_id is dash-stripped and regex-validated (strip_customer_id); every GAQL field name is a closed-whitelist lookup, never free text; dates are regex-validated. Keyword text, conversion-action names, and bidding micros/ROAS values reach Google as JSON body values rather than being string-interpolated into GAQL, but they are attacker-controlled and this module's own validation on them is limited to what the pydantic schemas enforce (NegativeKeywordSpec.text 1 to 80 chars stripped non-blank, ConversionActionCreateRequest.name 1 to 255 chars, micros/ROAS gt=0, schemas/google_ads_management_schemas.py 122 to 202) - the actual content-level defense on those fields is Google's own API validation, not a check this module performs itself; worth naming as a boundary, not a defect.

Tests

Run 2026-09-12 against the alchemy_labs .venv (Python, no database, no network) at 62ba4fc:

cd backend && .venv/bin/python -m pytest tests/test_google_ads_manage.py -q --no-cov -o addopts="" -p no:cacheprovider

prints 61 passed (0 failed, 32 pydantic-deprecation warnings unrelated to this recipe, all from core/billing_config.py's Field(..., env=...) usage elsewhere in the app). grep -c "^ def test_" tests/test_google_ads_manage.py prints 57; grep -c "@pytest.mark.parametrize" tests/test_google_ads_manage.py prints 1 (TestStripCustomerId.test_rejects_non_numeric, five malformed ids), which is what takes 57 collected defs to 61 passed.

Hermetic, entirely: the test file's own docstring states "no network, no DB" (1 to 10); GoogleAdsClient is exercised exclusively through httpx.MockTransport handlers (_make_client, 378 to 386), no pytest-asyncio plugin is used, a bespoke run(coro) helper wraps asyncio.new_event_loop().run_until_complete (46 to 48). Known-answer vectors: sha256_hex("a") == "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb" (test_sha256); normalize_phone("079 123 45 67") == "+41791234567" (Swiss-default E.164, test_swiss_phone_normalization); normalize_email(" John.Doe+promo@GMAIL.com ") == "johndoe@gmail.com" (Gmail dot/plus stripping, test_gmail_normalization) alongside normalize_email("john.doe@ikigai2.com") == "john.doe@ikigai2.com" (non-Gmail keeps dots, test_non_gmail_keeps_dots).

What a new app adds and this commit lacks, stated plainly rather than guessed at: no test in this file imports google_ads_management_router or FastAPI's TestClient (confirmed: grep -n "TestClient\|google_ads_management_router" tests/test_google_ads_manage.py returns nothing), so every HTTP-level behavior this recipe's API surface table documents - the 400/501 mappings, the _google_http_error envelope shape, the X-Workspace-ID gate - is exercised only by reading the router's source, not by a passing test; no live or recorded-cassette test confirms Google's real v23 REST API actually accepts the JSON shapes build_bidding_update/the negative-keyword and conversion-action builders produce, everything here is validated against a mocked transport whose responses were hand-written by the same author as the code; the five Phase-2 methods have no test coverage at all, each is one NotImplementedError line with nothing to assert beyond "it raises."

Deploy & ops

Config keys, all in core/config.py: GOOGLE_ADS_DEVELOPER_TOKEN (1000, SecretStr), GOOGLE_ADS_CLIENT_ID/GOOGLE_ADS_CLIENT_SECRET (1002 to 1003), falling back to GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET (619 to 620, shared with the Calendar integration). No migrations: this module owns no table. No scheduler job: grep -rn "google_ads_management\|api/ads/google" services/scheduled_jobs.py returns nothing, there is no background job for this surface. No Argus-style check was found either: no health-check entry names this router or its prefix in this commit; a genuine gap, stated rather than invented.

Mount: mount_router(app, google_ads_management_router.router, prefix="/api/ads/google", tags=["Google Ads Management"], surfaces=LABS_SURFACES) (app_fastapi.py 866), in the same block and with the same surfaces=LABS_SURFACES gate as every other production router mounted alongside it (workspace, billing, commerce, connections routers, 828 to 838) - there is no feature flag, no staging-only gate, and no dark/flag-off marker distinguishing this router from the rest of the production app. The feature landed in commit 293534d3e13dd82edd58b413a4ac627deaff65ac (2026-07-10) and has 272 further commits between it and the commit this recipe measures (62ba4fc, 2026-09-12), all on main, none touching these five files again - this is why maturity: prod here is a measured claim, not a default assumption (see Gotchas for the one place the code's own comments say otherwise). Rollback: nothing to unwind beyond the deployed code and the process-memory access-token cache (_TOKEN_CACHE, a plain module-level dict, google_ads_manage.py 145), which is empty again on the next process start regardless.

Gotchas

  • 2026-09-12 (measured at 62ba4fc): the router module's own docstring says "this router is NOT auto-mounted - mount it in app_fastapi.py at /api/ads/google" as an instruction to a future integrator (google_ads_management_router.py 4 to 8), but app_fastapi.py already does exactly that at lines 114 and 866, and has since the introducing commit. The docstring is stale, not a description of a staging or dark-launch state; do not read it as evidence this router is unmounted anywhere.
  • 2026-09-12 (measured at 62ba4fc): the default MCC (manager account) id used for delegated login-customer-id resolution is a literal numeric string duplicated in two files independently - DEFAULT_MCC_ACCOUNT_ID in both google_ads_manage.py (line 41) and api/routers/ads_router.py (line 348) - rather than imported from one place. (This recipe does not repeat the real value here; treat it as a fictional placeholder such as 0000000000 in any rebuild's prose or sample data.) A rebuild that changes it in one file and not the other silently desyncs which account delegated calls default to.
  • 2026-09-12 (measured at 62ba4fc): the Phase-2 scaffolds (create_asset, create_audience, customer_match_upload, create_pmax_campaign, create_experiment) each carry a substantial docstring describing the exact mutate/call shape a real implementation would use (google_ads_campaign_service.py 478 to 525) but every one of them is a single raise NotImplementedError with no other code; treat the docstrings as design notes, not as evidence of partial functionality.

Reference implementation

Paths relative to the repo root; all at alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 (feature introduced by 293534d3e13dd82edd58b413a4ac627deaff65ac, 2026-07-10, 272 commits ancestor of the measured commit).

  • alchemy_labs/backend/services/google_ads_manage.py (617 lines): module docstring (1 to 17); errors and parse_google_error (53 to 102); strip_customer_id/_oauth_client_credentials/default_developer_token (109 to 136); token cache (139 to 167); GoogleAdsClient (173 to 310: _get_access_token 211 to 233, _headers 235 to 243, _post 247 to 260, mutate 264 to 279, search 281 to 300, call 302 to 310); parse_partial_failure/batch_mutate_result (317 to 350); GAQL whitelist maps (357 to 482); build_gaql (499 to 616).
  • alchemy_labs/backend/services/google_ads_campaign_service.py (525 lines): module docstring (1 to 14); validate_numeric_id (31 to 36); PII normalization helpers (43 to 82); build_bidding_update (89 to 147); GoogleAdsCampaignService: update_bidding (162 to 179), campaign negative keywords (183 to 235), shared negative-keyword sets (239 to 350), conversion actions (354 to 472), Phase-2 scaffolds (478 to 525).
  • alchemy_labs/backend/api/routers/google_ads_management_router.py (595 lines): mount docstring (1 to 16); _build_service (70 to 94); _google_http_error (97 to 107); reporting routes (121 to 222); bidding (229 to 249); campaign negatives (256 to 311); shared sets (318 to 441); conversion actions (448 to 509); Phase-2 501s (512 to 595).
  • alchemy_labs/backend/schemas/google_ads_management_schemas.py (282 lines): shared (21 to 31); reporting (38 to 79); bidding (86 to 112); negative keywords / shared sets (119 to 157); conversion actions (164 to 223); Phase-2 request shapes (227 to 282).
  • alchemy_labs/backend/app_fastapi.py: router import (114); mount (866).
  • alchemy_labs/backend/api/routers/ads_router.py: _get_tracking_settings (232 to 244); _require_workspace (247 to 252); _get_google_refresh_token (259 to 287); DEFAULT_MCC_ACCOUNT_ID (348, see Gotchas).
  • alchemy_labs/backend/core/config.py: GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET (619 to 620); GOOGLE_ADS_DEVELOPER_TOKEN (1000); GOOGLE_ADS_CLIENT_ID/GOOGLE_ADS_CLIENT_SECRET (1002 to 1003).
  • alchemy_labs/backend/tests/test_google_ads_manage.py (509 lines, whole file in scope).

Deviations of the reference from an ideal implementation, all dated 2026-09-12: the router-level docstring's "not auto-mounted" claim is stale (Gotchas); the five Phase-2 surfaces are scaffolded in comment form only; nothing in this commit exercises the router or a live Google endpoint, only the two service files are tested, and only against a hand-written mock transport.

Acceptance checks

Run from alchemy_labs/backend at 62ba4fc with the repo's own .venv. Expected observations are exact, as measured 2026-09-12.

  1. .venv/bin/python -m pytest tests/test_google_ads_manage.py -q --no-cov -o addopts="" -p no:cacheprovider prints 61 passed (plus warnings, no failures).
  2. grep -c "^ def test_" tests/test_google_ads_manage.py prints 57; grep -c "@pytest.mark.parametrize" tests/test_google_ads_manage.py prints 1.
  3. grep -n "^def build_gaql" services/google_ads_manage.py prints line 499; grep -n "^from services.google_ads_manage import" services/google_ads_campaign_service.py prints line 21, confirming the campaign-service layer imports the GAQL/mutate plumbing rather than reimplementing it.
  4. grep -c "raise NotImplementedError" services/google_ads_campaign_service.py prints 5; grep -c "_not_implemented(" api/routers/google_ads_management_router.py prints 6 (the helper's one def plus five call sites).
  5. grep -n "google_ads_management_router" app_fastapi.py prints exactly two lines: 114 (import) and 866 (mount_router(..., surfaces=LABS_SURFACES)), confirming the router is mounted despite its own docstring saying otherwise.
  6. grep -n "TestClient\|google_ads_management_router" tests/test_google_ads_manage.py prints nothing, confirming no test in this file exercises the router directly (see Tests).
  7. git log --oneline 293534d3e13dd82edd58b413a4ac627deaff65ac..62ba4fc41009b454e1c033e5036c7e8577db2061 | wc -l prints 272, confirming the feature has been on main well before the measured commit rather than freshly landed.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.