diff --git a/docs/knowledge/code/index.md b/docs/knowledge/code/index.md index 96a9f594..7b7d2e6d 100644 --- a/docs/knowledge/code/index.md +++ b/docs/knowledge/code/index.md @@ -6,6 +6,7 @@ * [internal/infrastructure/postgres](internal-infrastructure-postgres.md) - PostgreSQL pool (otelpgx), migrations, repositories, and Ready() for readiness probes. * [internal/middleware](internal-middleware.md) - Package middleware provides HTTP middleware for the service. * [internal/platform/reddit](internal-platform-reddit.md) - Reddit Ads API v3 client: OAuth2 token refresh and Campaign -> Ad Group -> Ad creation. +* [internal/platform/meta](internal-platform-meta.md) - Meta (Facebook/Instagram) Ads Graph API client: Campaign -> Ad Set -> Ad creation with objective mapping and geo/budget validation. * [internal/platform/twitter](internal-platform-twitter.md) - X (Twitter) Ads v12 client: OAuth 1.0a signing and the campaign -> line_item -> promoted_tweet creation flow. * [internal/service](internal-service.md) - Campaign service business logic, including Readyz (DB-backed readiness) and Livez (process-only liveness). * [pkg/constants](pkg-constants.md) - Application-wide constants, including PG* and DATABASE_URL environment variable names. diff --git a/docs/knowledge/code/internal-platform-meta.md b/docs/knowledge/code/internal-platform-meta.md new file mode 100644 index 00000000..c1244660 --- /dev/null +++ b/docs/knowledge/code/internal-platform-meta.md @@ -0,0 +1,93 @@ +--- +type: "Go Package" +title: "internal/platform/meta" +description: "Meta (Facebook/Instagram) Ads Graph API client: Campaign -> Ad Set -> Ad creation with objective mapping and geo/budget validation." +resource: "internal/platform/meta" +tags: + - platform-client + - meta + - facebook-ads + - graph-api + - go-package +timestamp: "2026-07-13T19:22:00Z" +--- + +# internal/platform/meta + +Package meta provides a Go client for the Meta (Facebook/Instagram) Ads Graph +API, ported from the upstream TypeScript `meta-ads.service.ts` client. +Credentials and account configuration are injected via `NewClient`; the client +never reads the process environment and uses only the standard library. + +Authentication is a Graph API Bearer access token. `CreateCampaign` drives the +Campaign -> Ad Set -> Ad(s) hierarchy, creating everything PAUSED, with +objective->parameter mapping (awareness/traffic/engagement/leads/conversions), +placement/promoted-object building, and UTM URL construction that preserves any +URL fragment. + +The `leads` objective INTENTIONALLY DIVERGES from the `@lfx-one/shared` TS +contract (`campaign.constants.ts` maps leads -> LEAD_GENERATION with a page_id +promoted object). LEAD_GENERATION optimization requires the ad creative to carry +an on-Facebook instant lead form (`lead_gen_form_id`), which this client does not +construct — it only builds a website-click creative pointing at the registration +URL. Adopting LEAD_GENERATION would fail at ad-set/ad creation, after the paid +campaign already exists. To stay fail-safe, `leads` runs an interim WEBSITE-TRAFFIC +campaign — OUTCOME_TRAFFIC optimizing for LINK_CLICKS to the registration +(lead-capture) URL, with no promoted object. OUTCOME_TRAFFIC is used (not +OUTCOME_LEADS) because OUTCOME_LEADS + LINK_CLICKS requires a `pixel_id` + +`custom_event_type` that this interim flow does not supply — that pairing would +create the campaign then fail at the ad set, orphaning it. OUTCOME_TRAFFIC +supports LINK_CLICKS with no pixel requirement, so the flow is spendable +end-to-end. Full LEAD_GENERATION / instant-form (or OUTCOME_LEADS + pixel) parity +with the TS contract is deferred (LFXV2-2665). + +Inputs are validated up front, before any mutating call: geo targets are checked +against ISO 3166-1 alpha-2 and comprehensively-sanctioned countries are +excluded; per-variant copy is rejected up front when it exceeds Meta's limits +(primary text 125, headline 40, description 30 characters, counted by rune) so +over-limit copy fails before any paid campaign/ad-set exists rather than at +non-fatal creative creation; `CampaignInput.Budget` is denominated in the ad +ACCOUNT's own currency — the client does NO foreign-exchange conversion, so the +caller must pass an amount already in that currency — and it is bounded +(rejecting rounds-to-zero and overflow-scale values) then converted to minor +units by multiplying by the account's minor-unit offset +(`AccountConfig.CurrencyOffset`) rather than a hardcoded ×100. That offset is +DERIVED from the account's ISO 4217 currency code, not fetched: the Meta AdAccount +node exposes only `currency` (the ISO code) — it does NOT expose a +`currency_offset` field (only the separate Currency node does). CreateCampaign +maps the code through an AUTHORITATIVE supported-currency table +(`currencyMinorUnitOffset`), which is the single source of truth: the zero-decimal +currencies (JPY, KRW, CLP, VND, and the rest of the standard set) map to 1, and the +enumerated two-decimal currencies (USD, EUR, GBP, and the other supported majors) +map to 100. There is NO fall-through default — a code absent from the table +(blank, or a well-formed-but-unknown code such as `ZZZ`) is treated as unsupported. +The offset is never guessed: when `AccountConfig.CurrencyOffset` is unset (zero) — the normal +case for a dispatch built from a persisted connection, which carries only +account/page/app IDs — CreateCampaign fetches the account's `currency` (ISO code) +from the ad-account object during the account preflight, BEFORE any mutating call, +derives the offset from it, and fails closed if the currency is unknown or absent. +Silently defaulting to 100 would encode a zero-decimal-currency +(JPY/KRW/CLP) budget 100× too high, and a warning after resource creation cannot +prevent that budget from being activated. The account currency is +authoritative: a caller MAY set a positive `CurrencyOffset` as a FALLBACK, but if +the preflight returns a recognized currency whose true offset differs, the request +is REJECTED (a stale override would mis-scale the budget). The explicit offset is +only used when the preflight fails or its currency isn't in the supported map. The +preflight GET always runs (it also verifies access). A negative offset is +rejected as malformed. The preflight also reads `account_status`: a +successful GET is not treated as "active" — if the account is in a known-inactive +state (disabled, closed, pending review/settlement, etc.) CreateCampaign fails +BEFORE any mutating call rather than creating a paid campaign Meta would reject +later; an unreported status (0) or any value not known to be bad is allowed +through. `CampaignInput.Project` is +also required (rejected up front if empty/whitespace): the campaign name's +Project segment must be the caller-supplied canonical LFX project slug, so the +client never silently substitutes a placeholder that could mis-attribute a +campaign to the wrong project. +Dates are parsed strictly (impossible calendar dates rejected) and a +past start date is refused, with a same-day ad-set `start_time` nudged to +now+buffer. `doRequest` retries HTTP 429 and Graph rate-limit envelope codes +(4/17/32/341/613/80004) with bounded backoff, draining the body before close, and a +truncated response body is surfaced rather than reported as a false success. + +See [internal/platform/meta](../../../internal/platform/meta). diff --git a/docs/knowledge/log.md b/docs/knowledge/log.md index c4ae1a27..ff311548 100644 --- a/docs/knowledge/log.md +++ b/docs/knowledge/log.md @@ -2,6 +2,10 @@ ## 2026-07-13 +**Creation** — Added OKF concept doc for internal/platform/meta (Meta Ads Graph +API client) with `tags`/`timestamp` frontmatter (queryable fields per OKF v0.1 +§4.1), listed in the code index. + **Update** — Added OKF-recommended `tags` and `timestamp` frontmatter to the internal/platform/reddit concept doc (queryable fields per OKF v0.1 §4.1). @@ -18,6 +22,7 @@ errors instead of masking them as not-found. Added the **Update** — Mount connection routes in the HTTP server (LFXV2-2556): the `cmd/campaign-service` concept now notes that every container-wired service must also be mounted in `server.go`, or its routes 404 despite compiling. + **Creation** — Added the `internal/platform/reddit` concept doc for the new Reddit Ads API v3 client (OAuth2 token refresh + Campaign -> Ad Group -> Ad creation) and listed it in the code index. diff --git a/internal/platform/meta/client.go b/internal/platform/meta/client.go new file mode 100644 index 00000000..52c8068e --- /dev/null +++ b/internal/platform/meta/client.go @@ -0,0 +1,1992 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Package meta implements a Go client for the Meta (Facebook/Instagram) Ads +// platform, ported from the upstream TypeScript meta-ads.service.ts. +// +// The client speaks to the Meta Graph API using a Bearer access token and +// creates a Campaign -> Ad Set -> Ad(s) hierarchy. Credentials and account +// configuration are injected via NewClient; nothing in this package reads the +// process environment. +package meta + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "syscall" + "time" + "unicode/utf8" +) + +// --------------------------------------------------------------------------- +// Constants (mirrored from meta.constants.ts and @lfx-one/shared/constants) +// --------------------------------------------------------------------------- + +const ( + // DefaultBaseURL is the Meta Graph API base URL (mirrors META_BASE_URL). + DefaultBaseURL = "https://graph.facebook.com/v25.0" + // DefaultAdsManagerURL is the Meta Ads Manager base URL (mirrors META_ADS_MANAGER_URL). + DefaultAdsManagerURL = "https://adsmanager.facebook.com" + // DefaultRequestTimeout mirrors META_REQUEST_TIMEOUT_MS (30s). + DefaultRequestTimeout = 30 * time.Second + + // retryMax is the number of times a 429 (rate-limited) request is retried + // before giving up. Mirrors the resilience the Twitter client applies. + retryMax = 3 + // retryBaseDelay is the base for exponential backoff when the API returns a + // 429 without a usable Retry-After header (1s, 2s, 4s, ...). + retryBaseDelay = 1 * time.Second + // maxRetryWait caps how long a single 429 backoff waits, so an outsized + // Retry-After value can't stall a request past the point of usefulness. + maxRetryWait = 60 * time.Second + // maxResponseBody bounds how much of any response body is read into memory, + // far above any legitimate Graph API response, to prevent memory exhaustion + // while not truncating a normal success or error body. + maxResponseBody = 10 << 20 // 10 MiB + // adSetStartBuffer is added to "now" when a campaign starts today, so the ad + // set start_time isn't already in the past by the time Meta receives it. + // + // It MUST comfortably exceed doRequest's worst-case retry budget: with the + // default client a single doRequest can span up to (retryMax+1)=4 attempts, + // each bounded by DefaultRequestTimeout (30s), plus up to retryMax=3 + // Retry-After waits each capped at maxRetryWait (60s) — i.e. roughly + // 4×30s + 3×60s ≈ 5 minutes. If the buffer were only ~5 minutes, the ad-set + // POST on the LAST retry could carry a start_time that has already slipped + // into the past (or, at a day boundary, onto the wrong day), which Meta + // rejects. 10 minutes clears that ~5-minute worst case with headroom for + // scheduling/network latency before the request actually reaches Meta. + adSetStartBuffer = 10 * time.Minute + // Per-variant copy limits (in runes), mirroring the repo contract in + // docs/api-catalog.md. Over-limit copy is rejected up front so it fails before + // any paid campaign/ad-set resource is created rather than at creative + // creation (which is non-fatal and would leave an orphaned paid campaign). + maxPrimaryTextChars = 125 + maxHeadlineChars = 40 + maxDescriptionChars = 30 + // maxCreativeNameChars is Meta's cap on an ad-creative name. The creative name + // is composed (" - Variant N"), so the COMPOSED value is validated + // up front against this before any mutating call. + maxCreativeNameChars = 255 +) + +// --------------------------------------------------------------------------- +// Objective -> parameter mapping (mirrors META_OBJECTIVE_PARAMS) +// --------------------------------------------------------------------------- + +// PromotedObjectType identifies which promoted_object shape an objective needs. +type PromotedObjectType string + +const ( + // PromotedObjectNone means the objective needs no promoted_object. + PromotedObjectNone PromotedObjectType = "" + // PromotedObjectPageID means the promoted_object carries a page_id. + PromotedObjectPageID PromotedObjectType = "page_id" + // PromotedObjectPixelID means the promoted_object carries a pixel_id. + PromotedObjectPixelID PromotedObjectType = "pixel_id" +) + +// ObjectiveParams describes the Meta API parameters for a marketing objective. +type ObjectiveParams struct { + CampaignObjective string + OptimizationGoal string + PromotedObjectType PromotedObjectType +} + +// objectiveParams maps the user-facing objective to Meta Graph API v25.0 +// ODAX outcome objectives, optimization goals, and promoted-object needs. +// Mirrors META_OBJECTIVE_PARAMS from @lfx-one/shared/constants, WITH ONE +// INTENTIONAL EXCEPTION: "leads" maps to OUTCOME_TRAFFIC/LINK_CLICKS/none here +// rather than the shared LEAD_GENERATION/page_id, because this client builds only +// a website-click creative and never constructs an on-Facebook instant lead form +// (see the "leads" entry's comment and LFXV2-2665). +var objectiveParams = map[string]ObjectiveParams{ + "awareness": { + CampaignObjective: "OUTCOME_AWARENESS", + OptimizationGoal: "REACH", + PromotedObjectType: PromotedObjectNone, + }, + "traffic": { + CampaignObjective: "OUTCOME_TRAFFIC", + OptimizationGoal: "LINK_CLICKS", + PromotedObjectType: PromotedObjectNone, + }, + "engagement": { + CampaignObjective: "OUTCOME_ENGAGEMENT", + OptimizationGoal: "POST_ENGAGEMENT", + PromotedObjectType: PromotedObjectPageID, + }, + // "leads" INTENTIONALLY DIVERGES from the @lfx-one/shared TS contract + // (campaign.constants.ts META_OBJECTIVE_PARAMS), which maps leads -> + // LEAD_GENERATION with a page_id promoted object. This is a deliberate, + // documented divergence — NOT an oversight or a bug. That shared mapping assumes + // an on-Facebook instant lead form: LEAD_GENERATION requires the ad's creative + // to reference a lead_gen_form_id (an instant form). This Go client only builds + // a website-click creative (object_story_spec.link_data pointing at the + // registration URL — see createVariantAd); it never constructs an instant lead + // form, so LEAD_GENERATION would fail at ad-set/ad creation. + // + // The interim mapping runs a WEBSITE-TRAFFIC campaign: OUTCOME_TRAFFIC + // optimizing for LINK_CLICKS to the registration (lead-capture) URL, with no + // promoted object. OUTCOME_TRAFFIC is the objective that cleanly supports + // LINK_CLICKS optimization with NO pixel/promoted-object requirement, so the + // ad-set POST always succeeds (a consistent, spendable configuration + // end-to-end). OUTCOME_LEADS + LINK_CLICKS is avoided precisely because Meta + // requires a pixel_id + custom_event_type for that pairing, which this interim + // flow does not supply — it would create the campaign then fail at the ad set, + // orphaning a paid resource. + // + // Full LEAD_GENERATION / instant-form (or OUTCOME_LEADS + pixel) parity with the + // shared TS contract is INTENTIONALLY OUT OF SCOPE for this PR and tracked as a + // follow-up (LFXV2-2665). + "leads": { + CampaignObjective: "OUTCOME_TRAFFIC", + OptimizationGoal: "LINK_CLICKS", + PromotedObjectType: PromotedObjectNone, + }, + "conversions": { + CampaignObjective: "OUTCOME_SALES", + OptimizationGoal: "OFFSITE_CONVERSIONS", + PromotedObjectType: PromotedObjectPixelID, + }, +} + +// objectiveLabels mirrors OBJECTIVE_LABELS. +var objectiveLabels = map[string]string{ + "awareness": "Awareness", + "traffic": "Traffic", + "engagement": "Engagement", + "leads": "Leads", + "conversions": "Conversions", +} + +// ObjectiveParamsFor returns the Meta parameters for the given objective and +// whether the objective is known. Exposed to support mapping-correctness tests. +func ObjectiveParamsFor(objective string) (ObjectiveParams, bool) { + p, ok := objectiveParams[objective] + return p, ok +} + +// --------------------------------------------------------------------------- +// Placements (mirrors MetaPlacement + META_DEFAULT_PLACEMENTS) +// --------------------------------------------------------------------------- + +// Placement toggles the ad placements requested for an ad set. Each field is a +// pointer so callers can leave a placement unset and fall back to the default. +type Placement struct { + FacebookFeed *bool + InstagramFeed *bool + Stories *bool + Reels *bool + AudienceNetwork *bool + MessengerInbox *bool +} + +// defaultPlacements mirrors META_DEFAULT_PLACEMENTS: feed placements on, +// stories/reels/audience-network/messenger off. +var defaultPlacements = Placement{ + FacebookFeed: boolPtr(true), + InstagramFeed: boolPtr(true), + Stories: boolPtr(false), + Reels: boolPtr(false), + AudienceNetwork: boolPtr(false), + MessengerInbox: boolPtr(false), +} + +func boolPtr(b bool) *bool { return &b } + +// mergePlacements applies caller overrides on top of the defaults, matching +// the TS spread `{ ...META_DEFAULT_PLACEMENTS, ...placements }`. +func mergePlacements(over Placement) Placement { + out := defaultPlacements + if over.FacebookFeed != nil { + out.FacebookFeed = over.FacebookFeed + } + if over.InstagramFeed != nil { + out.InstagramFeed = over.InstagramFeed + } + if over.Stories != nil { + out.Stories = over.Stories + } + if over.Reels != nil { + out.Reels = over.Reels + } + if over.AudienceNetwork != nil { + out.AudienceNetwork = over.AudienceNetwork + } + if over.MessengerInbox != nil { + out.MessengerInbox = over.MessengerInbox + } + return out +} + +func deref(b *bool) bool { return b != nil && *b } + +// --------------------------------------------------------------------------- +// Credentials, account config, and client +// --------------------------------------------------------------------------- + +// Credentials holds the Meta Graph API Bearer access token. Injected, never +// read from the environment. +type Credentials struct { + AccessToken string +} + +// AccountConfig identifies the Meta ad account and Facebook Page to operate on. +type AccountConfig struct { + // AccountID is the ad account id, e.g. "act_193556282970417". + AccountID string + // PageID is the Facebook Page id used for creatives and promoted objects. + PageID string + // Label is an optional human-readable account label. + Label string + // CurrencyOffset is an OPTIONAL override of the ad account's minor-unit + // offset: the factor that converts a whole-currency-unit budget into + // the minor units Meta expects. Meta budgets are ALWAYS expressed in minor + // units scaled by the ACCOUNT's currency, which is NOT universally 100 — + // zero-decimal currencies such as JPY, KRW, and CLP use an offset of 1 (no + // minor unit), while most (USD, EUR, GBP) use 100. + // + // When left unset (zero), CreateCampaign fetches the account's ISO 4217 currency + // CODE from Meta during the account preflight (GET on the ad-account object with + // fields=name,account_status,currency) BEFORE any mutating call and DERIVES the + // offset from it via a reference table (100 for two-decimal currencies, 1 for + // zero-decimal ones like JPY/KRW/CLP). The AdAccount node does NOT expose a + // currency_offset field — only the ISO code — so the scale is derived, not + // fetched. If the currency is unknown or absent, CreateCampaign fails BEFORE + // mutation rather than guessing 100 — a silent default would encode a + // zero-decimal-currency (JPY/KRW/CLP) budget 100× too high, and a warning after + // resource creation cannot prevent that budget from being activated. + // + // A caller MAY set this field to a positive value as a FALLBACK for when the + // account preflight can't identify the currency. The account currency is + // authoritative: if the preflight returns a RECOGNIZED currency whose true + // offset DIFFERS from this explicit value, CreateCampaign REJECTS the request + // (a stale override would mis-scale the budget, e.g. 100 on a JPY account). The + // explicit value is only used when the preflight fails or its currency is not + // in the supported-currency map. The preflight GET always runs (it also + // verifies account access). A negative value is rejected as malformed. + CurrencyOffset int64 +} + +// Client is a Meta Ads Graph API client. +type Client struct { + creds Credentials + account AccountConfig + httpClient *http.Client + baseURL string + adsManagerURL string + // timeNow allows tests to control the clock used for 429 backoff. + // Defaults to time.Now. + timeNow func() time.Time + // retryBaseDelay is the base for exponential 429 backoff. Defaults to the + // retryBaseDelay const; tests may shrink it to keep runs fast. + retryBaseDelay time.Duration +} + +// Option customizes a Client. +type Option func(*Client) + +// WithHTTPClient overrides the HTTP client (useful for tests / timeouts). +func WithHTTPClient(h *http.Client) Option { + return func(c *Client) { + // Ignore a nil client so the safe default installed by NewClient isn't + // replaced with nil (which would panic on the next request). + if h != nil { + c.httpClient = h + } + } +} + +// WithBaseURL overrides the Graph API base URL (useful for tests). +func WithBaseURL(u string) Option { + return func(c *Client) { c.baseURL = strings.TrimRight(u, "/") } +} + +// WithAdsManagerURL overrides the Ads Manager base URL. +func WithAdsManagerURL(u string) Option { + return func(c *Client) { c.adsManagerURL = strings.TrimRight(u, "/") } +} + +// WithClock overrides the time source used for 429 backoff. For tests. +func WithClock(now func() time.Time) Option { + return func(c *Client) { + if now != nil { + c.timeNow = now + } + } +} + +// withRetryBaseDelay overrides the exponential-backoff base for 429 retries. +// Unexported: only tests use it, to keep retry runs fast. +func withRetryBaseDelay(d time.Duration) Option { + return func(c *Client) { + if d > 0 { + c.retryBaseDelay = d + } + } +} + +// NewClient constructs a Client from injected credentials and account config. +func NewClient(creds Credentials, account AccountConfig, opts ...Option) *Client { + // Trim credential/account fields once at construction so validation (which + // uses TrimSpace) and request building (which used the raw values in URLs like + // "/"+accountID) can't disagree — surrounding whitespace would otherwise pass + // validation but produce malformed requests. + creds.AccessToken = strings.TrimSpace(creds.AccessToken) + account.AccountID = strings.TrimSpace(account.AccountID) + account.PageID = strings.TrimSpace(account.PageID) + // NOTE: CurrencyOffset is NOT coerced here. It is not defaulted in NewClient so + // the zero value remains distinguishable as "unset": when unset, CreateCampaign + // derives the offset from the account's ISO currency code fetched during the + // account preflight (see AccountConfig.CurrencyOffset). A negative offset is + // rejected as malformed at budget-conversion time. + c := &Client{ + creds: creds, + account: account, + httpClient: &http.Client{Timeout: DefaultRequestTimeout}, + baseURL: DefaultBaseURL, + adsManagerURL: DefaultAdsManagerURL, + timeNow: time.Now, + retryBaseDelay: retryBaseDelay, + } + for _, o := range opts { + o(c) + } + return c +} + +// --------------------------------------------------------------------------- +// HTTP helper (mirrors metaRequest) +// --------------------------------------------------------------------------- + +// createResponse mirrors the TS MetaCreateResponse: every create call returns +// at least an id field. +type createResponse struct { + ID string `json:"id"` +} + +// accountPreflight models the fields read from the ad-account object during the +// account preflight (GET /act_?fields=name,account_status,currency). The +// AdAccount node exposes the ISO 4217 currency CODE only — it does NOT expose a +// currency_offset field (only the separate Currency node does). The minor-unit +// multiplier used to encode the budget is derived from this code via +// currencyMinorUnitOffset before any mutating call. +type accountPreflight struct { + Name string `json:"name"` + AccountStatus int `json:"account_status"` + Currency string `json:"currency"` +} + +// metaAccountStatusActive is Meta's account_status value for an ACTIVE ad account. +const metaAccountStatusActive = 1 + +// inactiveAccountStatusLabels maps the well-known non-active Meta account_status +// values to a human-readable reason. A campaign created against an account in one +// of these states would only fail at a later mutating call, so CreateCampaign +// refuses BEFORE any paid resource is created when the preflight reports one of +// these. account_status 0 (absent/unreported) and any value not listed here are +// treated as "not known-bad" and allowed through — this is a conservative block +// on definitively-disabled accounts, not a positive allowlist. +var inactiveAccountStatusLabels = map[int]string{ + 2: "disabled", + 3: "unsettled", + 7: "pending risk review", + 8: "pending settlement", + 9: "in grace period", + 100: "pending closure", + 101: "closed", + // NOTE: 201 (ANY_ACTIVE) and 202 (ANY_CLOSED) are Meta AGGREGATE/filter values, + // not per-account statuses — a real ad-account's account_status is never 201/202. + // 201 in particular denotes an ACTIVE aggregate, so listing it here would reject + // an active account. They are intentionally omitted from this known-bad map. +} + +// currencyMinorUnitOffset is the AUTHORITATIVE map of the Meta ad-account +// currencies this client supports, each mapped to the factor that converts a +// whole-currency-unit budget into the minor units Meta expects. This map — NOT a +// default — is the single source of truth: a code that is not present is treated +// as UNSUPPORTED and fails before any mutating call (see currencyOffsetFor). +// +// The AdAccount node exposes only the ISO 4217 currency CODE (not a +// currency_offset field), so the offset is derived from this map rather than +// fetched. Two groups of entries: +// +// - offset 1: the zero-decimal (no minor unit) currencies. Meta bills these in +// whole units, so a budget must NOT be multiplied by 100 for them (the +// JPY/KRW 100× over-spend bug). +// - offset 100: the common two-decimal currencies Meta supports. +// +// A blank/absent code, or a well-formed-but-unrecognized one (e.g. a new or +// malformed code like "ZZZ"), returns ok=false from currencyOffsetFor so the +// caller fails BEFORE mutation instead of guessing 100 — which could silently +// encode a zero-decimal budget 100× too high. When a genuinely-supported currency +// is missing here, add it to this map (with the correct factor) rather than +// relying on a fall-through default. +// +// Three-decimal currencies are intentionally NOT special-cased: Meta bills ads in +// whole minor units, so two-decimal vs zero-decimal is the distinction that +// matters for budget encoding here — a three-decimal code is simply absent (and +// therefore rejected) until it is added deliberately with a verified factor. +var currencyMinorUnitOffset = map[string]int64{ + // Zero-decimal currencies (offset 1): no minor unit, billed in whole units. + "BIF": 1, // Burundian Franc + "CLP": 1, // Chilean Peso + "DJF": 1, // Djiboutian Franc + "GNF": 1, // Guinean Franc + "ISK": 1, // Icelandic Krona + "JPY": 1, // Japanese Yen + "KMF": 1, // Comorian Franc + "KRW": 1, // South Korean Won + "MGA": 1, // Malagasy Ariary (5-subunit, but Meta treats as integer minor) + "PYG": 1, // Paraguayan Guarani + "RWF": 1, // Rwandan Franc + "UGX": 1, // Ugandan Shilling + "VND": 1, // Vietnamese Dong + "VUV": 1, // Vanuatu Vatu + "XAF": 1, // Central African CFA Franc + "XOF": 1, // West African CFA Franc + "XPF": 1, // CFP Franc + // These are ALSO offset-1 for the Meta Marketing API despite having minor + // units in general ISO usage — Meta bills ad amounts in whole units for them. + // Verified against developers.facebook.com/docs/marketing-api/currencies. + "IDR": 1, // Indonesian Rupiah + "HUF": 1, // Hungarian Forint + "COP": 1, // Colombian Peso + "CRC": 1, // Costa Rican Colon + "TWD": 1, // New Taiwan Dollar + + // Two-decimal currencies (offset 100): the common ISO 4217 codes Meta + // supports as ad-account currencies. A code outside this set is rejected, not + // assumed to be two-decimal. + "USD": 100, // US Dollar + "EUR": 100, // Euro + "GBP": 100, // Pound Sterling + "AUD": 100, // Australian Dollar + "CAD": 100, // Canadian Dollar + "CHF": 100, // Swiss Franc + "CNY": 100, // Chinese Yuan + "DKK": 100, // Danish Krone + "HKD": 100, // Hong Kong Dollar + "INR": 100, // Indian Rupee + "MXN": 100, // Mexican Peso + "NOK": 100, // Norwegian Krone + "NZD": 100, // New Zealand Dollar + "PLN": 100, // Polish Zloty + "SEK": 100, // Swedish Krona + "SGD": 100, // Singapore Dollar + "THB": 100, // Thai Baht + "TRY": 100, // Turkish Lira + "ZAR": 100, // South African Rand + "BRL": 100, // Brazilian Real + "ILS": 100, // Israeli New Shekel + "PHP": 100, // Philippine Peso + "MYR": 100, // Malaysian Ringgit + "AED": 100, // UAE Dirham + "SAR": 100, // Saudi Riyal + "CZK": 100, // Czech Koruna + "RON": 100, // Romanian Leu + "ARS": 100, // Argentine Peso + "BDT": 100, // Bangladeshi Taka + "BOB": 100, // Bolivian Boliviano + "DZD": 100, // Algerian Dinar + "EGP": 100, // Egyptian Pound + "GTQ": 100, // Guatemalan Quetzal + "HNL": 100, // Honduran Lempira + "KES": 100, // Kenyan Shilling + "MOP": 100, // Macanese Pataca + "NGN": 100, // Nigerian Naira + "NIO": 100, // Nicaraguan Cordoba + "PEN": 100, // Peruvian Sol + "PKR": 100, // Pakistani Rupee + "QAR": 100, // Qatari Riyal + "UYU": 100, // Uruguayan Peso +} + +// currencyOffsetFor derives the minor-unit multiplier for an ISO 4217 currency +// code returned by the account preflight, using currencyMinorUnitOffset as the +// authoritative supported-currency set. It returns (offset, true) only for a code +// present in that map, and (0, false) for a blank/absent code OR a well-formed +// code that is not in the map (an unknown/malformed currency such as "ZZZ"). The +// caller must fail before mutation on a false result rather than guessing 100 — +// which for a zero-decimal currency would over-encode the budget 100×. +func currencyOffsetFor(currency string) (int64, bool) { + code := strings.ToUpper(strings.TrimSpace(currency)) + if code == "" { + return 0, false + } + off, ok := currencyMinorUnitOffset[code] + return off, ok +} + +// graphErrorEnvelope models the Graph API error body: {"error": {...}}. +type graphErrorEnvelope struct { + Error *graphError `json:"error"` +} + +type graphError struct { + Message string `json:"message"` + Type string `json:"type"` + Code int `json:"code"` + FBTraceID string `json:"fbtrace_id"` +} + +// graphRateLimitCodes are Graph/Marketing API error codes that indicate +// throttling, which Meta commonly returns as an HTTP 400 (not a 429): 4 = +// application request-limit reached, 17 = user request-limit reached, 32 = +// page-level throttling, 341 = temporary app-level limit, 613 = ad-account +// rate limit, 80004 = ad-account/business-use-case throttling (Marketing API). +// These are retried with the same backoff as a 429. +var graphRateLimitCodes = map[int]bool{4: true, 17: true, 32: true, 341: true, 613: true, 80004: true} + +// APIError is returned when the Meta API responds with a non-2xx status. +type APIError struct { + StatusCode int + Method string + Path string + // Message is the Graph API error message when present, else the raw body. + Message string + // Type, Code, and FBTraceID carry the Graph error envelope's diagnostic + // fields. They let callers distinguish invalid-params from auth failures + // (which often share HTTP 400/400) and quote Meta's trace id in support + // tickets. They are zero-valued when the body isn't a Graph error envelope. + Type string + Code int + FBTraceID string +} + +func (e *APIError) Error() string { + // Mirror the TS behavior of not leaking full bodies to callers while still + // surfacing status; include the parsed message when available, plus the Graph + // diagnostic fields (type/code/fbtrace_id) when present — fbtrace_id in + // particular is essential when opening a Meta support ticket. + var b strings.Builder + if e.Message != "" { + fmt.Fprintf(&b, "meta API request failed (%d): %s", e.StatusCode, e.Message) + } else { + fmt.Fprintf(&b, "meta API request failed (%d) with no error details in the response body", e.StatusCode) + } + if e.Type != "" { + fmt.Fprintf(&b, " (type: %s", e.Type) + if e.Code != 0 { + fmt.Fprintf(&b, ", code: %d", e.Code) + } + b.WriteString(")") + } else if e.Code != 0 { + fmt.Fprintf(&b, " (code: %d)", e.Code) + } + if e.FBTraceID != "" { + fmt.Fprintf(&b, " [fbtrace_id: %s]", e.FBTraceID) + } + return b.String() +} + +// transportError wraps a failure of the HTTP round-trip itself (httpClient.Do) +// that happened AFTER the request was plausibly sent (mid-flight timeout, +// unexpected EOF, connection reset): the server may or may not have processed +// it, so the outcome is AMBIGUOUS. This is distinct from a pre-send failure +// (access token missing, body encode, request build, or a pre-connect dial +// error — see isPreSendDialError), where the request never reached Meta and a +// mutation definitely did not happen. Callers use it to decide whether a failed +// create is "may exist" (ambiguous) vs "not created". Mirrors the reddit client. +type transportError struct { + Method string + Path string + Err error +} + +func (e *transportError) Error() string { + return fmt.Sprintf("meta API %s %s: %v", e.Method, e.Path, e.Err) +} +func (e *transportError) Unwrap() error { return e.Err } + +// isPreSendDialError reports whether a httpClient.Do error clearly happened +// BEFORE any request bytes could have reached Meta (DNS resolution failure, +// connection refused, or no route/network unreachable). Such a failure means the +// request was NOT sent, so it must NOT be treated as an ambiguous "may exist" +// transportError. A failure AFTER a connection is established (mid-flight +// timeout, unexpected EOF) is genuinely ambiguous and IS wrapped as +// transportError. Mirrors the reddit client. +func isPreSendDialError(err error) bool { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return true + } + if errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.EHOSTUNREACH) || errors.Is(err, syscall.ENETUNREACH) { + return true + } + return false +} + +// createOutcomeAmbiguous reports whether a failed mutating request MAY have been +// applied by Meta despite the error — i.e. the request plausibly reached the +// server and its outcome is unknowable. It is the single source of truth shared +// by the campaign and ad/creative create paths so they classify identically: +// - transportError: the round-trip failed AFTER a connection was established +// (a pre-connect dial error is NOT wrapped as transportError, so it never +// reaches here), so the request may have been received; +// - *APIError with a 5xx status: Meta received it and may have committed the +// mutation before erroring. +// +// A definite 4xx (Meta rejected it), or any pre-send failure (token missing, +// body encode/build, a pre-connect dial error), means NOT applied → returns +// false so the caller returns a clean (nil, err) / "failed" rather than "may +// exist". Mirrors the reddit client's createOutcomeAmbiguous. +func createOutcomeAmbiguous(err error) bool { + var te *transportError + if errors.As(err, &te) { + return true + } + var ae *APIError + return errors.As(err, &ae) && ae.StatusCode >= 500 +} + +// doRequest performs a Graph API call and decodes the JSON body into out. +// It honors ctx via http.NewRequestWithContext. A 429 (rate-limited) response is +// retried up to retryMax times with a bounded backoff (honoring Retry-After when +// present), since CreateCampaign issues several sequential Graph API calls that +// can trip Meta's per-app/account rate limits mid-flow. +func (c *Client) doRequest(ctx context.Context, method, path string, body map[string]any, out any) error { + if c.creds.AccessToken == "" { + return fmt.Errorf("meta access token is not configured") + } + + var encoded []byte + if body != nil && method == http.MethodPost { + var err error + encoded, err = json.Marshal(body) + if err != nil { + return fmt.Errorf("encode request body: %w", err) + } + } + + for attempt := 0; attempt <= retryMax; attempt++ { + var reqBody io.Reader + if encoded != nil { + reqBody = bytes.NewReader(encoded) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.creds.AccessToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + // A Do error that clearly happened BEFORE the request could be sent (DNS + // failure, connection refused, no route) means NOT sent — return it plain + // so callers treat a create as "not applied". A failure after a connection + // was established (mid-flight timeout, EOF) is genuinely ambiguous: wrap it + // as transportError so callers treat a create as "may exist". Mirrors the + // reddit client. + if isPreSendDialError(err) { + return fmt.Errorf("meta API %s %s: %w", method, path, err) + } + return &transportError{Method: method, Path: path, Err: err} + } + + // Read one byte past the cap so a truncation is detectable: io.LimitReader + // returns EOF (not an error) at the limit, so an oversized body would + // otherwise be silently truncated and mis-parsed as a valid short response. + raw, readErr := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody+1)) + if readErr == nil && int64(len(raw)) > maxResponseBody { + _ = resp.Body.Close() + return fmt.Errorf("meta API %s %s: response exceeds %d bytes", method, path, maxResponseBody) + } + retryAfter := c.parseRetryAfter(resp) + status := resp.StatusCode + _ = resp.Body.Close() + + // Meta reports throttling either as HTTP 429 or, commonly, as HTTP 400 with + // a Graph error envelope whose code is a known rate-limit code. Treat both + // as retryable with the same bounded backoff. The envelope is only consumed + // on the non-2xx paths (throttle detection here and the error/abort branches + // below), so only unmarshal it then — a 2xx success body never populates it. + var env graphErrorEnvelope + if status < 200 || status >= 300 { + _ = json.Unmarshal(raw, &env) + } + throttled := status == http.StatusTooManyRequests || + (status < 200 || status >= 300) && env.Error != nil && graphRateLimitCodes[env.Error.Code] + + // A read error (e.g. connection closed early on a mismatched Content-Length) + // must not be treated as a complete response: even if the partial body + // happens to parse, propagate the error rather than reporting a false + // success. But do NOT short-circuit a throttled response we're about to + // retry (its body is discarded anyway) — only fail when we would otherwise + // consume this response as final. + if readErr != nil && (!throttled || attempt >= retryMax) { + // A read failure on a 2xx is AMBIGUOUS: Meta committed the mutation but we + // couldn't read the result — wrap it as transportError so a create is + // treated as "may exist". A read failure on a non-2xx isn't a committed + // mutation, so return it plain. Mirrors the reddit client wrapping 2xx + // read/decode failures as transportError. + if status >= 200 && status < 300 { + return &transportError{Method: method, Path: path, Err: fmt.Errorf("read response body: %w", readErr)} + } + return fmt.Errorf("meta API %s %s: read response body: %w", method, path, readErr) + } + + if throttled && attempt < retryMax { + if retryAfter > 0 { + // The server DECLARED when the limit clears. If that exceeds our cap, + // sleeping only maxRetryWait would retry while Meta is still throttling + // — burning attempts and stalling this synchronous flow — so ABORT with + // the rate-limit error instead of clamping (mirrors the twitter/reddit + // clients). Only when the server gives no usable reset do we fall back to + // a capped exponential backoff. + if retryAfter > maxRetryWait { + // Preserve the Graph envelope's diagnostics (Type/Code/FBTraceID and + // original message) on the abort — support may need them exactly when a + // rate limit is hit — rather than discarding them for a bare message. + // Report the RAW Retry-After header as authoritative: parseRetryAfter + // CLAMPS an oversized reset to maxRetryWait+1s (a sentinel used only to + // trip this cap comparison), so `retryAfter` here can read "1m1s" even + // when the server sent "600" or a far-future HTTP-date. The raw header + // is what actually needs to be debugged against upstream. + rawRetryAfter := strings.TrimSpace(resp.Header.Get("Retry-After")) + abortErr := &APIError{ + StatusCode: status, Method: method, Path: path, + Message: fmt.Sprintf("rate-limit reset (Retry-After: %q) exceeds max wait %s; aborting", rawRetryAfter, maxRetryWait), + } + if env.Error != nil { + abortErr.Type = env.Error.Type + abortErr.Code = env.Error.Code + abortErr.FBTraceID = env.Error.FBTraceID + if env.Error.Message != "" { + abortErr.Message = fmt.Sprintf("%s (Graph: %s)", abortErr.Message, env.Error.Message) + } + } + return abortErr + } + if err := sleepCtx(ctx, retryAfter); err != nil { + return err + } + continue + } + // No server-declared reset: capped exponential backoff. + wait := c.retryBaseDelay * time.Duration(1< maxRetryWait { + wait = maxRetryWait + } + if err := sleepCtx(ctx, wait); err != nil { + return err + } + continue + } + + if status < 200 || status >= 300 { + apiErr := &APIError{StatusCode: status, Method: method, Path: path} + if env.Error != nil { + // Preserve the Graph envelope's diagnostic fields so callers can + // distinguish invalid-params vs auth failures and quote the trace id. + apiErr.Type = env.Error.Type + apiErr.Code = env.Error.Code + apiErr.FBTraceID = env.Error.FBTraceID + } + if env.Error != nil && env.Error.Message != "" { + apiErr.Message = env.Error.Message + } else if snippet := strings.TrimSpace(string(raw)); snippet != "" { + // Non-Graph or malformed error body: surface a truncated snippet of + // the raw body so the real reason isn't lost. + apiErr.Message = truncate(snippet, 300) + } + return apiErr + } + + if out != nil { + if err := json.Unmarshal(raw, out); err != nil { + // A 2xx we can't decode is AMBIGUOUS: Meta committed the mutation but we + // can't read the id. Wrap as transportError so a create is treated as + // "may exist". Mirrors the reddit client. + return &transportError{Method: method, Path: path, Err: fmt.Errorf("decode response: %w", err)} + } + } + return nil + } + + return &APIError{StatusCode: http.StatusTooManyRequests, Method: method, Path: path, + Message: fmt.Sprintf("exhausted %d retries after rate limiting", retryMax)} +} + +// parseRetryAfter returns how long to wait before retrying a 429, or 0 if no +// usable header is present. Meta returns Retry-After either as a delay in seconds +// or as an HTTP-date; both forms are honored. Never returns a negative duration. +func (c *Client) parseRetryAfter(resp *http.Response) time.Duration { + v := strings.TrimSpace(resp.Header.Get("Retry-After")) + if v == "" { + return 0 + } + // Delay-seconds form. ParseInt into an int64 (not Atoi, whose platform int can + // overflow on 32-bit and silently drop a real, if outsized, value) and CLAMP + // before multiplying: time.Duration(n)*time.Second wraps NEGATIVE for n beyond + // ~9.2e9, which would make the caller retry far too early. Any n strictly above + // the max-wait ceiling (in seconds) already exceeds the cap, so report a + // duration just over maxRetryWait and let the caller's own cap apply — never + // perform the wrapping multiply. Mirrors internal/platform/twitter/client.go. + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + if n <= 0 { + return 0 + } + if n > int64(maxRetryWait/time.Second) { + return maxRetryWait + time.Second + } + return time.Duration(n) * time.Second + } + if t, err := http.ParseTime(v); err == nil { + if d := t.Sub(c.timeNow()); d > 0 { + // Clamp an outsized HTTP-date reset the same way, so a far-future date + // can't wait past the point of usefulness (the caller also caps to + // maxRetryWait, but keep the two branches consistent). + if d > maxRetryWait { + return maxRetryWait + time.Second + } + return d + } + } + return 0 +} + +// sleepCtx waits for d, returning early if ctx is cancelled. +func sleepCtx(ctx context.Context, d time.Duration) error { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} + +// --------------------------------------------------------------------------- +// Validation helpers (mirror the TS helpers) +// --------------------------------------------------------------------------- + +var geoCodeRE = regexp.MustCompile(`^[A-Z]{2}$`) + +var dateRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) + +// accountIDRE matches a Meta ad-account id in its documented "act_" form. +// AccountID is interpolated into every Graph path ("/"+accountID+"/campaigns"), +// so a non-empty check is not enough: a value carrying '/', '?', '#', '..', or +// whitespace could redirect a request to a different endpoint. Anchored so the +// whole value must match. Mirrors the anchored-regex approach in +// internal/platform/twitter/client.go (accountIDRe). +var accountIDRE = regexp.MustCompile(`^act_[0-9]+$`) + +// numericIDRE matches a purely numeric Meta object id (Page id, Pixel id). Meta +// object ids are decimal strings; validating the format up front stops a malformed +// id (e.g. "PIX9") from creating a campaign/ad set that then fails at creative or +// promoted-object time, leaving an orphaned paid resource. +var numericIDRE = regexp.MustCompile(`^[0-9]+$`) + +func validateRegistrationURL(raw string) error { + raw = strings.TrimSpace(raw) + parsed, err := url.Parse(raw) + // Require an absolute URL with a real hostname. parsed.Host can be a + // port-only authority (e.g. "https://:443" parses to Host==":443" with an + // empty Hostname()), which is not a valid destination — check Hostname(). + if err != nil || !parsed.IsAbs() || parsed.Hostname() == "" { + return fmt.Errorf("registration URL is not a valid URL") + } + // Reject embedded userinfo (user[:password]@host): an ad destination never + // needs URL credentials, and buildUTMURL would otherwise forward the password + // to Meta as the creative click URL and echo it in the success step, leaking a + // basic-auth secret. Mirrors the reddit client's validateRegistrationURL. + if parsed.User != nil { + return fmt.Errorf("registration URL must not contain embedded credentials (userinfo)") + } + if parsed.Scheme != "https" { + return fmt.Errorf("registration URL must use HTTPS") + } + // url.Parse does not validate the query. buildUTMURL rebuilds the URL via + // u.Query() (which SILENTLY drops a pair it can't parse — e.g. one containing an + // unescaped ';' or bad percent-encoding), so the ad's click URL could differ + // from what the caller supplied. Reject a query that ParseQuery can't cleanly + // parse, before any mutating call. + if _, qerr := url.ParseQuery(parsed.RawQuery); qerr != nil { + return fmt.Errorf("registration URL has a malformed query string") + } + return nil +} + +// validateGeoTargets uppercases, trims, and filters to ISO-2 codes; defaults to +// ["US"] when nothing valid remains (mirrors validateGeoTargets). +func validateGeoTargets(geoTargets []string) []string { + valid := make([]string, 0, len(geoTargets)) + for _, g := range geoTargets { + up := strings.ToUpper(strings.TrimSpace(g)) + // Check shape and ISO 3166-1 alpha-2 membership (so a well-shaped but bogus + // code like "XX"/"ZZ" is dropped), and exclude countries Meta does not allow + // as ad targets (see metaIneligibleCountries) — ISO membership is not the + // same as Meta targeting eligibility. + if geoCodeRE.MatchString(up) && iso3166Alpha2[up] && !metaIneligibleCountries[up] { + valid = append(valid, up) + } + } + if len(valid) == 0 { + return []string{"US"} + } + return valid +} + +// metaIneligibleCountries are ISO 3166-1 codes that are NOT valid Meta ad-targeting +// countries; ISO membership alone would otherwise let them through and be rejected +// only after the campaign is created. This is deliberately a curated exclusion list +// rather than a positive allowlist: ISO 3166-1 assigns codes for uninhabited and +// special territories that carry no Meta ad market, and for a handful of countries +// Meta/OFAC exclude on policy grounds. It covers the two known leak classes: +// +// 1. Policy/sanctions exclusions. CU/IR/KP remain under active comprehensive OFAC +// sanctions programs. RU is excluded because Meta's ads policy bans targeting +// Russia; SY is kept excluded pending confirmation of Meta's current targeting +// eligibility (OFAC terminated its comprehensive Syria program effective +// 2025-07-01, so that is no longer the basis). +// 2. Uninhabited / non-targetable territories that are assigned ISO codes but are +// not Meta ad-geolocation countries (no resident audience to target), so a +// campaign targeting them would be created and then fail at the ad-set step. +// +// NOTE: this is best-effort, not Meta's authoritative ad-geolocation set. If a +// still-ISO-valid but non-targetable code slips through, Meta rejects the ad-set +// POST (after the PAUSED campaign is created) and the returned error surfaces the +// created campaign id for cleanup. A maintained targetable-country allowlist would +// be stricter; that is intentionally deferred to keep this list auditable. +var metaIneligibleCountries = map[string]bool{ + "CU": true, // Cuba (comprehensively sanctioned) + "IR": true, // Iran (comprehensively sanctioned) + "KP": true, // North Korea (comprehensively sanctioned) + "RU": true, // Russia (Meta ads policy prohibits targeting; not OFAC-comprehensive) + "SY": true, // Syria (Meta ads-eligibility caution; not OFAC-comprehensive as of 2025-07-01) + // Uninhabited / non-targetable ISO territories (no Meta ad market). + "AQ": true, // Antarctica (no resident population) + "BV": true, // Bouvet Island (uninhabited) + "HM": true, // Heard Island and McDonald Islands (uninhabited) + "TF": true, // French Southern Territories (no permanent population) + "GS": true, // South Georgia and the South Sandwich Islands (no permanent population) + "UM": true, // United States Minor Outlying Islands (no permanent population) +} + +// iso3166Alpha2 (the large ISO 3166-1 alpha-2 lookup table) lives in +// countries.go to keep the static data out of the core client logic. + +// regulatedCountries require a Universal Ads Declaration / regional compliance +// and are excluded from API targeting (mirrors REGULATED_COUNTRIES). +var regulatedCountries = map[string]bool{"SG": true, "TW": true, "KR": true} + +// geoToRegion mirrors GEO_TO_REGION. +var geoToRegion = map[string]string{ + "US": "NA", "CA": "NA", "MX": "NA", + "GB": "EMEA", "DE": "EMEA", "FR": "EMEA", "NL": "EMEA", "SE": "EMEA", + "CH": "EMEA", "ES": "EMEA", "IT": "EMEA", "AT": "EMEA", "BE": "EMEA", "IL": "EMEA", + "IN": "India", + "JP": "APAC", "KR": "APAC", "SG": "APAC", "AU": "APAC", "CN": "APAC", "TW": "APAC", "HK": "APAC", + "BR": "LATAM", +} + +func resolveRegion(geoTargets []string) string { + if len(geoTargets) == 0 { + return "Global" + } + primary := strings.ToUpper(geoTargets[0]) + if r, ok := geoToRegion[primary]; ok { + return r + } + return "Global" +} + +// --------------------------------------------------------------------------- +// Objective / placement / name / UTM builders +// --------------------------------------------------------------------------- + +func buildPromotedObject(objective, pageID, pixelID string) (map[string]any, error) { + params, ok := objectiveParams[objective] + if !ok { + // Defensive: an unknown objective should never reach here (CreateCampaign + // validates it up front), but silently treating it as "no promoted object" + // would be a subtle mis-config if a future caller/refactor bypasses that. + return nil, fmt.Errorf("unknown objective %q", objective) + } + switch params.PromotedObjectType { + case PromotedObjectPageID: + return map[string]any{"page_id": pageID}, nil + case PromotedObjectPixelID: + trimmed := strings.TrimSpace(pixelID) + if trimmed == "" { + return nil, fmt.Errorf("pixelID must be a non-empty string for '%s' objective", objective) + } + // An empty-only check lets a malformed pixel id (e.g. "PIX9") through; the + // campaign would then be created and Meta would reject the promoted object at + // ad-set creation, leaving an orphan. Meta Pixel ids are numeric, so validate + // the format here — buildPromotedObject runs before any mutating call. + if !numericIDRE.MatchString(trimmed) { + return nil, fmt.Errorf("pixelID %q is malformed for '%s' objective: Meta Pixel IDs are numeric strings", trimmed, objective) + } + return map[string]any{"pixel_id": trimmed, "custom_event_type": "PURCHASE"}, nil + default: + return nil, nil + } +} + +func buildPlacementTargeting(over Placement) (map[string]any, error) { + pl := mergePlacements(over) + + var publisherPlatforms, facebookPositions, instagramPositions []string + // Track membership in a set so addPlatform is O(1) rather than a linear scan + // of publisherPlatforms on every call (the slice preserves insertion order). + seenPlatforms := make(map[string]struct{}) + addPlatform := func(p string) { + if _, ok := seenPlatforms[p]; !ok { + seenPlatforms[p] = struct{}{} + publisherPlatforms = append(publisherPlatforms, p) + } + } + + if deref(pl.FacebookFeed) { + addPlatform("facebook") + facebookPositions = append(facebookPositions, "feed") + } + if deref(pl.InstagramFeed) { + addPlatform("instagram") + instagramPositions = append(instagramPositions, "stream") + } + if deref(pl.Stories) { + addPlatform("facebook") + addPlatform("instagram") + facebookPositions = append(facebookPositions, "story") + instagramPositions = append(instagramPositions, "story") + } + if deref(pl.Reels) { + addPlatform("facebook") + addPlatform("instagram") + facebookPositions = append(facebookPositions, "facebook_reels") + instagramPositions = append(instagramPositions, "reels") + } + if deref(pl.AudienceNetwork) { + addPlatform("audience_network") + } + if deref(pl.MessengerInbox) { + // Messenger Inbox was removed as a Meta Ads placement in November 2025, so + // "messenger" / "messenger_home" is not valid on Graph API v25.0: it would + // pass here and then fail at the ad-set call, after the campaign (a paid + // resource) already exists. Reject up front instead. + return nil, fmt.Errorf("messengerInbox placement is no longer supported by Meta Ads (removed November 2025); do not enable it") + } + + if len(publisherPlatforms) == 0 { + return nil, fmt.Errorf("at least one placement must be enabled (facebookFeed, instagramFeed, stories, reels, or audienceNetwork)") + } + + targeting := map[string]any{"publisher_platforms": publisherPlatforms} + if len(facebookPositions) > 0 { + targeting["facebook_positions"] = facebookPositions + } + if len(instagramPositions) > 0 { + targeting["instagram_positions"] = instagramPositions + } + return targeting, nil +} + +func objectiveLabel(objective string) string { + if l, ok := objectiveLabels[objective]; ok { + return l + } + return objective +} + +// buildCampaignName mirrors buildMetaCampaignName using the (already +// geo-filtered) targets to resolve the region segment. The caller (CreateCampaign) +// validates in.Project is non-empty before this is reached, so there is no +// silent-substitution fallback here: the naming contract's Project segment is the +// caller-supplied canonical LFX slug (docs/api-catalog.md). Substituting a +// placeholder (e.g. "tlf") for an omitted project could mis-attribute a +// non-Linux-Foundation campaign to the wrong project. +func buildCampaignName(in CampaignInput, geoTargets []string) string { + // Segments are trimmed as well as pipe-stripped: validation TrimSpaces its + // checks, so " cncf " passes validation — but the attribution pipeline joins + // the Project segment exactly, and a padded slug would not match. + event := strings.ReplaceAll(strings.TrimSpace(in.EventName), "|", "-") + region := resolveRegion(geoTargets) + objective := objectiveLabel(defaultObjective(strings.ToLower(strings.TrimSpace(in.Objective)))) + project := strings.ReplaceAll(strings.TrimSpace(in.Project), "|", "-") + return fmt.Sprintf("Events | %s | %s | %s | Intent | Social | %s | MoFU", event, region, objective, project) +} + +// metaUTMParams returns the exact set of utm_* parameters this client generates +// for a click URL. It is the single source of truth for both the real click URL +// (buildUTMURL) and the sanitized display URL (displayMetaUTMURL), so the display +// allowlist can never drift from what is actually sent to Meta. Mirrors the +// reddit client's redditUTMParams. +func metaUTMParams(in CampaignInput, variantIndex int) map[string]string { + eventName := strings.TrimSpace(in.EventName) + + slug := in.EventSlug + if slug == "" { + slug = collapseSpacesToDash(strings.ToLower(eventName)) + } + + campaign := in.HSToken + if campaign == "" { + campaign = slug + } + + return map[string]string{ + "utm_source": "meta", + "utm_medium": "paid-social", + "utm_campaign": campaign, + "utm_term": strings.ToLower(collapseSpacesToDash(eventName)), + "utm_content": fmt.Sprintf("variant-%d", variantIndex+1), + } +} + +// buildUTMURL mirrors buildMetaUtmUrl. It returns the REAL click URL sent to Meta +// (link_data.link): the caller's original query and fragment are preserved and +// the generated utm_* params are merged in. This is intentionally NOT sanitized — +// the ad must land on the caller's full destination. For a value safe to persist +// in Steps, use displayMetaUTMURL. +func buildUTMURL(in CampaignInput, variantIndex int) string { + // Trim defensively rather than trusting callers to pre-normalize: CreateCampaign + // trims RegistrationURL/EventName in place today, but this helper is also called + // directly from tests, and untrimmed inputs would otherwise reintroduce a + // leading/trailing dash in utm_term or a parse failure from a padded URL. + base := strings.TrimSpace(in.RegistrationURL) + + utm := metaUTMParams(in, variantIndex) + + // Parse the URL so UTM params merge into the existing query and any fragment + // stays at the very end (a fragment must not be pushed after the query). + parsed, err := url.Parse(base) + if err != nil { + // Fall back to naive concatenation if the URL can't be parsed; this + // preserves behavior for inputs that already passed validation. + params := url.Values{} + for k, v := range utm { + params.Set(k, v) + } + sep := "?" + if strings.Contains(base, "?") { + sep = "&" + } + return base + sep + params.Encode() + } + + // Normalize a trailing slash on the PATH only. Trimming the raw URL string + // (the old approach) corrupted URLs whose query or fragment ends in '/' + // (e.g. "?redirect=/" or "#/"). Trimming the path leaves query/fragment intact. + if parsed.Path != "/" { + parsed.Path = strings.TrimRight(parsed.Path, "/") + } + + q := parsed.Query() + for k, v := range utm { + q.Set(k, v) + } + parsed.RawQuery = q.Encode() + return parsed.String() +} + +// displayMetaUTMURL builds a click URL safe to persist in Steps / return to +// callers: it strips any userinfo and any PRE-EXISTING query parameters from the +// registration URL (which may carry secrets like ?token=...) and any fragment, +// keeping ONLY the generated utm_* parameters. The full URL — including the +// caller's original query — is still sent to Meta as the real link (buildUTMURL); +// only this display copy is sanitized. variantIndex mirrors buildUTMURL. Mirrors +// the reddit client's displayRedditUTMURL. +func displayMetaUTMURL(in CampaignInput, variantIndex int) string { + u, err := url.Parse(strings.TrimSpace(in.RegistrationURL)) + if err != nil { + // Fall back to a plain redaction (scheme+host+path) if the URL won't parse — + // never return the raw value with its secrets. + return redactURL(in.RegistrationURL) + } + u.User = nil // drop any basic-auth userinfo + u.Fragment = "" // a fragment can carry sensitive data; drop it for display + // Rebuild the query from ONLY the utm_* params THIS client generates (with our + // values), discarding the caller's entire original query. Filtering the merged + // query by a "utm_" prefix would be unsafe: a caller-supplied ?utm_secret=... or + // ?utm_source= would survive. An explicit allowlist from the shared + // generator (metaUTMParams) is the source of truth. + safe := url.Values{} + for k, v := range metaUTMParams(in, variantIndex) { + safe.Set(k, v) + } + u.RawQuery = safe.Encode() + return u.String() +} + +// redactURL returns a URL safe to persist in a result step: scheme://host/path +// only, dropping the query and fragment (which can carry sensitive tokens) and +// any userinfo. If the input does not parse as an absolute URL, only the portion +// before any '?' or '#' is kept, and a value that still contains userinfo ("@") +// is dropped entirely rather than risk echoing a credential. Mirrors the reddit +// client's redactURL. +func redactURL(raw string) string { + trimmed := strings.TrimSpace(raw) + if u, err := url.Parse(trimmed); err == nil && u.IsAbs() && u.Host != "" { + redacted := url.URL{Scheme: u.Scheme, Host: u.Host, Path: u.Path} + return redacted.String() + } + if i := strings.IndexAny(trimmed, "?#"); i >= 0 { + trimmed = trimmed[:i] + } + if strings.Contains(trimmed, "@") { + return "[unparseable-url-redacted]" + } + return trimmed +} + +var wsRE = regexp.MustCompile(`\s+`) + +// collapseSpacesToDash replaces runs of whitespace with a single dash, matching +// the TS `.replace(/\s+/g, '-')`. +func collapseSpacesToDash(s string) string { + return wsRE.ReplaceAllString(s, "-") +} + +// truncateErr renders an error's message for inclusion in a user-visible step, +// clamping it to a reasonable length without splitting a multi-byte rune. +func truncateErr(err error, max int) string { + if err == nil { + return "" + } + return truncate(err.Error(), max) +} + +// truncate clamps s to at most max runes, appending an ellipsis when it clips, +// without splitting a multi-byte rune. It walks runes only up to the cutoff +// rather than converting the whole string to []rune, so surfacing a large +// upstream error body (up to maxResponseBody) doesn't allocate/scan all of it. +func truncate(s string, max int) string { + count := 0 + for i := range s { + if count == max { + return s[:i] + "…" + } + count++ + } + // Fewer than (or exactly) max runes: no clipping, return as-is. + return s +} + +// adSetStartTime returns the ad set start_time (RFC3339-ish, Meta format) for a +// start date. When the start date is today, 00:00 UTC is already in the past by +// the time the request reaches Meta (which rejects a past start_time), so use +// now + a small buffer instead; otherwise use start-of-day for the future date. +func adSetStartTime(startDate, now time.Time) string { + startOfDay := startDate.UTC().Truncate(24 * time.Hour) + buffered := now.UTC().Add(adSetStartBuffer) + t := startOfDay + if buffered.After(startOfDay) { + t = buffered + } + return t.Format("2006-01-02T15:04:05-0700") +} + +func defaultObjective(objective string) string { + if objective == "" { + return "traffic" + } + return objective +} + +// --------------------------------------------------------------------------- +// Public input / result types (mirror MetaCampaignCreateRequest / *Result) +// --------------------------------------------------------------------------- + +// AdVariant is a single ad creative variant. +type AdVariant struct { + PrimaryText string + Headline string + Description string +} + +// CampaignInput mirrors MetaCampaignCreateRequest. +type CampaignInput struct { + EventName string + EventSlug string + Project string + RegistrationURL string + // Objective is one of awareness|traffic|engagement|leads|conversions. Empty + // defaults to "traffic". "leads" runs an interim website-traffic campaign + // (OUTCOME_TRAFFIC optimizing for LINK_CLICKS to the registration URL); it does + // not build an on-Facebook instant lead form. Full LEAD_GENERATION / instant- + // form parity (and status-toggling + analytics) are deferred to LFXV2-2665. + Objective string + GeoTargets []string + // Budget is the budget amount in whole units of the ad ACCOUNT's currency. + // IMPORTANT: this is NOT a USD amount and the client performs NO foreign- + // exchange conversion. Meta bills the ad set in the account's own currency, so + // the caller must supply an amount already denominated in that currency. The + // value is converted to minor units by multiplying by the account's minor-unit + // offset (resolved from AccountConfig.CurrencyOffset when set, otherwise derived + // from the ISO currency code fetched during the account preflight; 100 for most + // currencies, 1 for zero-decimal currencies like JPY) and sent as-is. + // (Renamed from BudgetUSD: + // the field never carried FX-converted USD — the old name implied a conversion + // this client does not do.) + Budget float64 + LifetimeBudget bool + StartDate string // YYYY-MM-DD + EndDate string // YYYY-MM-DD + Placements Placement + PixelID string + HSToken string + Variants []AdVariant +} + +// CampaignResult mirrors MetaCampaignCreateResult. +type CampaignResult struct { + Platform string + CampaignName string + CampaignID string + AdSetName string + AdSetID string + AdCount int + MetaURL string + Steps []string +} + +// --------------------------------------------------------------------------- +// CreateCampaign (mirrors executeMetaCampaignCreation) +// --------------------------------------------------------------------------- + +// CreateCampaign creates a PAUSED Meta campaign, ad set, and one ad per valid +// variant. It faithfully ports executeMetaCampaignCreation: per-variant ad +// failures are recorded in Steps rather than aborting the whole operation. +// +// PARTIAL-RESULT CONTRACT: on a downstream failure AFTER the campaign and/or ad +// set already exist, CreateCampaign returns a NON-NIL *CampaignResult (carrying +// the created CampaignID / AdSetID / CampaignName, plus the steps so far) TOGETHER +// WITH a non-nil error. This is deliberate so the orphaned paid resource is +// identifiable for cleanup/reconciliation. It also applies to an AMBIGUOUS +// campaign-create failure (a timeout or 5xx that may have committed the create +// before erroring): the result then carries the deterministic CampaignName even +// though no id was read. Callers MUST NOT follow the usual +// `if err != nil { return err }` pattern that discards the result: inspect the +// returned *CampaignResult (CampaignID / CampaignName) even when err != nil to +// reconcile or avoid duplicate creation, since Meta exposes no create idempotency +// key. Before the campaign POST plausibly reached Meta (a clear pre-create or +// validation failure), a failure returns (nil, err) as usual. +func (c *Client) CreateCampaign(ctx context.Context, in CampaignInput) (*CampaignResult, error) { + steps := []string{} + + if len(in.Variants) == 0 { + return nil, fmt.Errorf("at least one ad variant is required for Meta campaign creation") + } + + // Reject any variant missing primary text or headline by NAMING its index, + // rather than silently dropping it. Silent filtering would renumber the + // surviving variants, so the ad numbering, creative name ("Variant N"), and + // utm_content=variant-N would no longer line up with the caller's original + // input ordering — a surprising mismatch. A partially-specified variant is a + // caller error, so fail fast (consistent with every other up-front check here). + for i, v := range in.Variants { + if strings.TrimSpace(v.PrimaryText) == "" || strings.TrimSpace(v.Headline) == "" { + return nil, fmt.Errorf("variant %d must have non-empty primary text and headline", i+1) + } + } + validVariants := in.Variants + + // Enforce Meta's per-field copy limits (by rune count) up front, before any + // mutating call. Over-limit copy passes the blank checks above but would be + // rejected at (non-fatal) creative creation — after the paid campaign/ad-set + // already exist — leaving an orphaned campaign with no ads. Fail fast instead. + for i, v := range validVariants { + if n := utf8.RuneCountInString(v.PrimaryText); n > maxPrimaryTextChars { + return nil, fmt.Errorf("variant %d primary text is %d characters; Meta allows at most %d", i+1, n, maxPrimaryTextChars) + } + if n := utf8.RuneCountInString(v.Headline); n > maxHeadlineChars { + return nil, fmt.Errorf("variant %d headline is %d characters; Meta allows at most %d", i+1, n, maxHeadlineChars) + } + if n := utf8.RuneCountInString(v.Description); n > maxDescriptionChars { + return nil, fmt.Errorf("variant %d description is %d characters; Meta allows at most %d", i+1, n, maxDescriptionChars) + } + // The ad-creative NAME is composed as " - Variant N" and Meta caps + // ad-creative names at maxCreativeNameChars. Validate the COMPOSED name up + // front too — a long EventName would otherwise pass the copy checks, create + // the campaign + ad set, then fail at every creative (orphaning both). + creativeName := fmt.Sprintf("%s - Variant %d", strings.TrimSpace(in.EventName), i+1) + if n := utf8.RuneCountInString(creativeName); n > maxCreativeNameChars { + return nil, fmt.Errorf("variant %d ad-creative name is %d characters; Meta allows at most %d (shorten the event name)", i+1, n, maxCreativeNameChars) + } + } + + if err := validateRegistrationURL(in.RegistrationURL); err != nil { + return nil, err + } + + if math.IsNaN(in.Budget) || math.IsInf(in.Budget, 0) || in.Budget <= 0 { + return nil, fmt.Errorf("invalid budget: must be a positive number") + } + // NOTE: no fixed major-unit budget cap is applied here. A hardcoded ceiling (in + // whole currency units) wrongly rejected realistic budgets in low-value + // currencies — e.g. a few-thousand-USD-equivalent budget in VND (offset 1) + // exceeds a 100M major-unit cap while being a perfectly ordinary spend. The + // offset-aware overflow guard below (after the account currency offset is + // resolved) is the authoritative overflow check: it rejects only budgets whose + // SCALED minor-unit value would exceed int64, which is the value actually sent. + // A negative explicit offset is malformed and can be rejected here, before any + // network call. The unset (zero) case is resolved from the account preflight + // below (Step 1); the minor-unit conversion happens there, once the offset is + // known but still BEFORE any mutating call. + if c.account.CurrencyOffset < 0 { + return nil, fmt.Errorf("meta: AccountConfig.CurrencyOffset must not be negative (100 for most currencies, 1 for zero-decimal like JPY)") + } + + if !dateRE.MatchString(in.StartDate) { + return nil, fmt.Errorf("invalid start date format: %s — expected YYYY-MM-DD", in.StartDate) + } + if !dateRE.MatchString(in.EndDate) { + return nil, fmt.Errorf("invalid end date format: %s — expected YYYY-MM-DD", in.EndDate) + } + // Reject impossible calendar dates (e.g. 2026-13-40) that pass the shape check. + startDate, err := time.Parse("2006-01-02", in.StartDate) + if err != nil { + return nil, fmt.Errorf("invalid start date format: %s — expected YYYY-MM-DD", in.StartDate) + } + endDate, err := time.Parse("2006-01-02", in.EndDate) + if err != nil { + return nil, fmt.Errorf("invalid end date format: %s — expected YYYY-MM-DD", in.EndDate) + } + // Compare the parsed time.Time values rather than the raw strings: both are + // already parsed here, so !endDate.After(startDate) states the intent directly + // instead of relying on lexicographic ordering of the date strings. + if !endDate.After(startDate) { + return nil, fmt.Errorf("end date %s must be after start date %s", in.EndDate, in.StartDate) + } + // Reject a start date already in the past (compared by calendar day in UTC): + // Meta rejects a past schedule, but only after the campaign is created, so + // fail fast here before any mutating call. + today := c.timeNow().UTC().Truncate(24 * time.Hour) + if startDate.Before(today) { + return nil, fmt.Errorf("start date %s is in the past", in.StartDate) + } + + // AccountID is required to build every Graph endpoint (/{accountID}/campaigns + // etc.). An empty AccountID would produce malformed "//campaigns" requests, so + // fail fast before any mutating call rather than issuing a bad request. + if strings.TrimSpace(c.account.AccountID) == "" { + return nil, fmt.Errorf("AccountID is required to create a Meta campaign; configure an ad account for this client") + } + // A non-empty check is not enough: AccountID is interpolated into every Graph + // path, so a value with delimiters ('/', '?', '#'), '..', or control chars + // could redirect a request to a different endpoint. Validate the documented + // act_ format before any mutating call. Mirrors twitter/client.go's + // anchored accountIDRe check. + if !accountIDRE.MatchString(c.account.AccountID) { + return nil, fmt.Errorf("AccountID %q is malformed: expected the format act_ (e.g. act_193556282970417)", c.account.AccountID) + } + + // PageID is required for the creative flow (object_story_spec.page_id) and, + // for some objectives, the promoted_object. Fail fast before any mutating + // call so a missing PageID doesn't create a paid campaign that can't get ads. + if strings.TrimSpace(c.account.PageID) == "" { + return nil, fmt.Errorf("PageID is required to create Meta creatives; configure a Facebook Page for this account") + } + // A non-empty check is not enough: a malformed Page id would pass, then the + // campaign and ad set get created before the creative fails (non-fatally), + // leaving orphaned paid resources. Meta Page ids are numeric strings, so + // validate the format before the first POST. + if !numericIDRE.MatchString(c.account.PageID) { + return nil, fmt.Errorf("PageID %q is malformed: Meta Page IDs are numeric strings", c.account.PageID) + } + + // Project is required: the campaign name's Project segment must be the caller- + // supplied canonical LFX project slug (docs/api-catalog.md). Reject an empty or + // whitespace-only Project before any mutating call rather than silently + // substituting a placeholder (e.g. "tlf"), which could mis-attribute a + // non-Linux-Foundation campaign to the wrong project. + if strings.TrimSpace(in.Project) == "" { + return nil, fmt.Errorf("project is required: supply the canonical LFX project slug for the campaign name's Project segment") + } + + // EventName is required: it is the base-name segment of every generated name + // (campaign, ad set, creative, ad) and feeds downstream UTM/attribution. Reject + // an empty or whitespace-only EventName before any mutating call rather than + // creating paid resources with an empty base-name segment (e.g. " - Traffic"), + // which would also break attribution. + if strings.TrimSpace(in.EventName) == "" { + return nil, fmt.Errorf("event name is required: supply a non-empty base name for the campaign name and attribution segments") + } + // Normalize EventName to its trimmed form for the rest of the flow. Only + // buildCampaignName trims internally; the ad-set/creative/ad names and the UTM + // builder (utm_term) consume in.EventName raw, so a padded value like + // " KubeCon EU " would otherwise yield inconsistent names and a malformed + // utm_term=-kubecon-eu-. Trim once here so every consumer sees the same value. + in.EventName = strings.TrimSpace(in.EventName) + + // Normalize Objective in place (trim + lowercase) so every consumer sees the + // same value: objectiveParams keys are lowercase, so a padded/upper value like + // " Traffic" would otherwise fail the lookup as "unknown" even though it is + // valid, and a whitespace-only value would not be treated as empty (and so not + // default to "traffic"). buildCampaignName also reads in.Objective, so normalize + // before it is called. + in.Objective = strings.ToLower(strings.TrimSpace(in.Objective)) + + // Normalize RegistrationURL in place so validation and UTM construction see the + // same value: validateRegistrationURL trims before parsing, but buildUTMURL reads + // in.RegistrationURL directly — a padded URL like " https://x/ " would otherwise + // pass validation yet be concatenated un-trimmed into the creative click URL, + // producing a malformed parse. Trim once here, ahead of both consumers. + in.RegistrationURL = strings.TrimSpace(in.RegistrationURL) + + // Resolve the objective and validate deterministic inputs (placements and the + // promoted object) BEFORE the first mutating call, so an input error never + // creates a paid campaign. + objective := defaultObjective(in.Objective) + objParams, ok := objectiveParams[objective] + if !ok { + return nil, fmt.Errorf("unknown Meta objective: '%s'. Valid objectives: %s", objective, strings.Join(objectiveKeys(), ", ")) + } + placementTargeting, err := buildPlacementTargeting(in.Placements) + if err != nil { + return nil, err + } + promotedObject, err := buildPromotedObject(objective, c.account.PageID, in.PixelID) + if err != nil { + return nil, err + } + + accountID := c.account.AccountID + label := c.account.Label + if label == "" { + label = accountID + } + + // Step 1: Account preflight (GET the ad-account object). This both verifies + // access and fetches the account's ISO 4217 currency CODE — from which the + // minor-unit offset used to encode the budget is DERIVED (see below; the + // AdAccount node does not expose a currency_offset field). It runs BEFORE any + // mutating call, so an unknown/undeterminable currency fails before a paid + // resource exists. + // + // A genuine CALLER-context cancellation/deadline must short-circuit here — + // otherwise, for inputs that go on to fail the geo checks, CreateCampaign would + // return that geo-validation error and mask the fact that the caller cancelled. + // Distinguish the caller ctx (ctx.Err() != nil) from the client's own + // http.Client.Timeout, which surfaces as a DeadlineExceeded-wrapped error while + // the caller ctx is still live. + var acct accountPreflight + preflightErr := c.doRequest(ctx, http.MethodGet, "/"+accountID+"?fields=name,account_status,currency", nil, &acct) + if preflightErr != nil { + if ctx.Err() != nil { + return nil, fmt.Errorf("meta campaign aborted during account preflight: %w", ctx.Err()) + } + steps = append(steps, fmt.Sprintf("Account preflight warning: %s", truncateErr(preflightErr, 300))) + } else { + // The preflight fetched account_status; a successful GET is not the same as an + // ACTIVE account. If the account is in a known-inactive state, fail BEFORE any + // mutating call rather than creating a paid campaign that Meta would reject at a + // later step. A status of 0 (unreported) or any value not known to be bad is + // allowed through — this blocks only definitively-disabled accounts. + if reason, bad := inactiveAccountStatusLabels[acct.AccountStatus]; bad { + return nil, fmt.Errorf("meta ad account %s is not active (account_status %d: %s); resolve the account status in Meta Ads Manager before creating campaigns", accountID, acct.AccountStatus, reason) + } + if acct.AccountStatus == metaAccountStatusActive { + steps = append(steps, fmt.Sprintf("Account verified: %s (%s, active)", label, accountID)) + } else { + steps = append(steps, fmt.Sprintf("Account verified: %s (%s)", label, accountID)) + } + } + + // Resolve the currency offset used to convert the whole-currency-unit budget to + // Meta minor units (NOT an FX conversion — the caller's amount is already in the + // account's currency). Most currencies use 100; zero-decimal currencies + // (JPY/KRW/CLP) use 1. Precedence: the ACCOUNT CURRENCY is authoritative — if + // the preflight returns a recognized currency, its derived offset is used, and a + // conflicting explicit AccountConfig.CurrencyOffset is REJECTED (a stale + // override would mis-scale the budget). An explicit offset is only relied on as + // a FALLBACK when the preflight fails or its currency isn't in the + // supported-currency map. If neither yields a usable (positive) offset — the + // currency is unknown/absent AND no explicit offset — fail HERE, before any + // mutating call, rather than guessing 100, which would silently encode a + // zero-decimal budget 100× too high (a warning after resource creation cannot + // prevent that budget from being activated). + offset := c.account.CurrencyOffset + if offset == 0 { + if preflightErr != nil { + // Wrap with %w (not %s) so the underlying error chain is preserved and a + // caller can errors.As it back to *APIError like other Graph failures — a + // %s would flatten it to a string and break that unwrap. + return nil, fmt.Errorf("meta: could not determine the account currency because the account preflight failed; set AccountConfig.CurrencyOffset explicitly (100 for most currencies, 1 for zero-decimal like JPY/KRW/CLP): %w", preflightErr) + } + derived, ok := currencyOffsetFor(acct.Currency) + if !ok { + return nil, fmt.Errorf("meta: account preflight returned an unsupported or missing currency code (got %q); it is not in the supported-currency map, so set AccountConfig.CurrencyOffset explicitly (100 for most currencies, 1 for zero-decimal like JPY/KRW/CLP) rather than assuming a default that could encode a zero-decimal budget 100x too high", acct.Currency) + } + offset = derived + } else if preflightErr == nil { + // An explicit override is set AND the preflight returned a currency. If that + // currency is recognized and its true offset DIFFERS from the override, + // reject rather than trust the override: a stale override (e.g. a persisted + // CurrencyOffset:100 on an account whose currency is now JPY, true offset 1) + // would silently encode the budget 100× wrong. The account's actual currency + // is authoritative; only rely on the override when the preflight can't + // identify the currency (unrecognized/absent code -> derived !ok). + if derived, ok := currencyOffsetFor(acct.Currency); ok && derived != offset { + return nil, fmt.Errorf("meta: AccountConfig.CurrencyOffset (%d) conflicts with the account's currency %q (correct offset %d) reported by the preflight; the account currency is authoritative — remove or correct the explicit offset to avoid encoding the budget with the wrong minor-unit scale", offset, acct.Currency, derived) + } + } + + // Convert whole account-currency units to Meta minor units and reject budgets + // that round to zero minor units — all before any mutating call, so a + // zero/invalid budget never creates a bad ad set. + // + // Guard against int64 overflow of the SCALED value before converting. This is + // the ONLY budget-magnitude ceiling (there is no fixed major-unit cap): both + // Budget and the offset are otherwise unbounded, so a genuinely huge budget — or + // a bogus large explicit/preflight offset — could push the product past int64. + // Converting an out-of-range float to int64 is implementation-defined, so + // range-check the float product first rather than relying on the budgetMinor<1 + // check to catch a wrapped value. math.MaxInt64 is not exactly representable as a + // float64, so compare against float64(math.MaxInt64) (which rounds up); a scaled + // value at or above it (including +Inf from an absurd budget) is rejected as out + // of range for a currency amount. + scaled := math.Round(in.Budget * float64(offset)) + if scaled >= float64(math.MaxInt64) { + return nil, fmt.Errorf("budget too large after applying currency offset %d: exceeds the representable minor-unit range", offset) + } + budgetMinor := int64(scaled) + if budgetMinor < 1 { + return nil, fmt.Errorf("budget too small: must be at least one minor currency unit (offset %d)", offset) + } + + // Step 2: geo filtering + campaign creation. + // If the caller supplied geo targets but NONE survive validation (all bogus or + // sanctioned), fail rather than silently falling back to US and targeting a + // country they didn't ask for. An empty input legitimately defaults to US. + allGeo := validateGeoTargets(in.GeoTargets) + // "Supplied geos" means NON-BLANK entries: a caller passing only whitespace + // (e.g. []string{" "}) is semantically the same as passing none, which + // legitimately defaults to US — so the dropped/fallback checks below must not + // treat it as an explicit request and error with an empty "(dropped: )" list. + suppliedGeos := 0 + for _, g := range in.GeoTargets { + if strings.TrimSpace(g) != "" { + suppliedGeos++ + } + } + // Surface geos that were supplied but dropped by validateGeoTargets (bogus/ + // non-ISO codes, or Meta-ineligible/sanctioned countries like IR/CU/KP/RU) as + // an explicit step, so a caller who mixed eligible + ineligible codes isn't + // left believing an excluded country is being targeted. This mirrors the + // regulated-country (SG/TW/KR) step emitted below. Skip the note when the only + // difference is the empty-input US fallback. + var droppedGeos []string + if suppliedGeos > 0 { + kept := make(map[string]struct{}, len(allGeo)) + for _, g := range allGeo { + kept[g] = struct{}{} + } + seenDropped := make(map[string]struct{}) + for _, g := range in.GeoTargets { + up := strings.ToUpper(strings.TrimSpace(g)) + if up == "" { + continue + } + if _, ok := kept[up]; ok { + continue + } + if _, dup := seenDropped[up]; dup { + continue + } + seenDropped[up] = struct{}{} + droppedGeos = append(droppedGeos, up) + } + if len(droppedGeos) > 0 { + steps = append(steps, fmt.Sprintf("Geo targets dropped (invalid code or not eligible for Meta ad targeting, e.g. sanctioned/excluded countries): %s", strings.Join(droppedGeos, ", "))) + } + } + if suppliedGeos > 0 && len(allGeo) == 1 && allGeo[0] == "US" { + // Only a real problem if the caller didn't actually ask for US: this means + // every supplied geo was invalid or sanctioned and we fell back to US. + askedUS := false + for _, g := range in.GeoTargets { + if strings.EqualFold(strings.TrimSpace(g), "US") { + askedUS = true + break + } + } + if !askedUS { + // NAME the dropped geos in the error (the "Geo targets dropped" step is + // discarded when we return nil), so the caller learns exactly which codes + // were invalid/ineligible rather than a generic message. + return nil, fmt.Errorf("no usable geo targets: all supplied geos are invalid or ineligible for Meta ads targeting (dropped: %s) — refusing to silently fall back to US", strings.Join(droppedGeos, ", ")) + } + } + geoCountries := make([]string, 0, len(allGeo)) + skippedGeos := make([]string, 0) + for _, g := range allGeo { + if regulatedCountries[g] { + skippedGeos = append(skippedGeos, g) + } else { + geoCountries = append(geoCountries, g) + } + } + if len(geoCountries) == 0 { + return nil, fmt.Errorf("meta campaign skipped: all selected geo targets (%s) are regulated and excluded from API targeting; supply at least one eligible (non-regulated) geo target", strings.Join(skippedGeos, ", ")) + } + if len(skippedGeos) > 0 { + steps = append(steps, fmt.Sprintf("Geo targets skipped (require regional compliance declaration in Meta Ads Manager): %s", strings.Join(skippedGeos, ", "))) + } + + campaignName := buildCampaignName(in, geoCountries) + + var campaignResp createResponse + err = c.doRequest(ctx, http.MethodPost, "/"+accountID+"/campaigns", map[string]any{ + "name": campaignName, + "objective": objParams.CampaignObjective, + "status": "PAUSED", + "special_ad_categories": []string{}, + "is_adset_budget_sharing_enabled": false, + }, &campaignResp) + if err != nil { + // An AMBIGUOUS failure (transport/timeout or a 5xx) can occur AFTER Meta + // committed the create: the possibly-created PAUSED campaign has the + // deterministic campaignName, so return a partial result carrying it (like + // the no-id 2xx case below) plus an UNCONFIRMED step, rather than discarding + // the name and letting a retry duplicate it. A clear 4xx/validation error + // means nothing was created, so keep the plain (nil, err). Meta exposes no + // create idempotency key, so this reconcile-by-name is the safeguard + // (retry-safe idempotency is tracked in LFXV2-2665). Mirrors the reddit + // client's createOutcomeAmbiguous handling. + if createOutcomeAmbiguous(err) { + steps = append(steps, "Campaign creation outcome is UNCONFIRMED (timeout or server error); a PAUSED campaign may exist — verify by name in Meta Ads Manager") + return &CampaignResult{ + Platform: "meta-ads", + CampaignName: campaignName, + MetaURL: fmt.Sprintf("%s/adsmanager/manage/campaigns?act=%s", c.adsManagerURL, strings.TrimPrefix(accountID, "act_")), + Steps: steps, + }, fmt.Errorf("meta campaign creation UNCONFIRMED (a PAUSED campaign %q may exist): %w", campaignName, err) + } + return nil, err + } + campaignID := campaignResp.ID + if campaignID == "" { + // A 2xx with no id is a malformed success: Meta may have created a PAUSED + // campaign whose id we couldn't read. Return a partial result carrying the + // campaign NAME so an orphan is reconcilable by name (not discarded), with an + // UNCONFIRMED note. NOTE: the campaign POST is not retry-safe in general — + // Meta exposes no create idempotency key, so a lost/timed-out response can't + // be distinguished from a not-created one; true retry-safe idempotency is + // tracked in LFXV2-2665. This makes the malformed-success case reconcilable. + steps = append(steps, "Campaign creation returned no campaign ID (malformed response); a PAUSED campaign may exist — verify by name in Meta Ads Manager") + return &CampaignResult{ + Platform: "meta-ads", + CampaignName: campaignName, + MetaURL: fmt.Sprintf("%s/adsmanager/manage/campaigns?act=%s", c.adsManagerURL, strings.TrimPrefix(accountID, "act_")), + Steps: steps, + }, fmt.Errorf("meta campaign creation succeeded but returned no campaign ID (a PAUSED campaign %q may exist)", campaignName) + } + steps = append(steps, fmt.Sprintf("Campaign created: %s (%s, PAUSED)", campaignID, objectiveLabel(objective))) + + // Step 3: Ad set (budget, placements, and promoted object were validated up + // front, before the campaign was created). + adSetName := fmt.Sprintf("%s - %s", in.EventName, objectiveLabel(objective)) + + // partialResult builds a *CampaignResult carrying the resources already created + // (the PAUSED campaign, and the ad set once it exists) plus the steps so far. + // It is returned ALONGSIDE the error at every downstream failure point after the + // campaign POST succeeds, so an orphaned paid resource is identifiable by ID for + // cleanup/reconcile without parsing the human-readable error string, and a caller + // retry can reconcile instead of blindly re-creating. adSetID/adCount are captured + // by reference so the result reflects whatever exists at the failure point. + // Mirrors the twitter/reddit clients' partial-result helper. + var adSetID string + adCount := 0 + partialResult := func() *CampaignResult { + return &CampaignResult{ + Platform: "meta-ads", + CampaignName: campaignName, + CampaignID: campaignID, + AdSetName: adSetName, + AdSetID: adSetID, + AdCount: adCount, + MetaURL: fmt.Sprintf("%s/adsmanager/manage/campaigns?act=%s", c.adsManagerURL, strings.TrimPrefix(accountID, "act_")), + Steps: steps, + } + } + + targeting := map[string]any{"geo_locations": map[string]any{"countries": geoCountries}} + for k, v := range placementTargeting { + targeting[k] = v + } + + adSetBody := map[string]any{ + "name": adSetName, + "campaign_id": campaignID, + "status": "PAUSED", + "billing_event": "IMPRESSIONS", + "optimization_goal": objParams.OptimizationGoal, + "bid_strategy": "LOWEST_COST_WITHOUT_CAP", + "targeting": targeting, + "start_time": adSetStartTime(startDate, c.timeNow()), + "end_time": in.EndDate + "T23:59:59+0000", + } + + if promotedObject != nil { + adSetBody["promoted_object"] = promotedObject + } + + if in.LifetimeBudget { + adSetBody["lifetime_budget"] = budgetMinor + } else { + adSetBody["daily_budget"] = budgetMinor + } + + var adSetResp createResponse + if err := c.doRequest(ctx, http.MethodPost, "/"+accountID+"/adsets", adSetBody, &adSetResp); err != nil { + // The campaign was already created (PAUSED). Return a partial result carrying + // its id so the caller can identify/clean up the orphan without parsing the + // error string; auto-deleting here would race a retry that reuses it. + return partialResult(), fmt.Errorf("meta ad set creation failed (campaign %s created, PAUSED): %w", campaignID, err) + } + adSetID = adSetResp.ID + if adSetID == "" { + return partialResult(), fmt.Errorf("meta ad set creation succeeded but returned no ad set ID (campaign %s created, PAUSED)", campaignID) + } + budgetLabel := "daily" + if in.LifetimeBudget { + budgetLabel = "lifetime" + } + // Currency-neutral: Meta interprets the budget in the ad account's currency, + // which may not be USD, so don't prefix with '$'. + steps = append(steps, fmt.Sprintf("Ad set created: %s (%.2f %s budget, geo: %s)", adSetID, in.Budget, budgetLabel, strings.Join(geoCountries, ", "))) + + // Step 4: creative + ad per variant (per-variant failures are non-fatal). + for i, variant := range validVariants { + utmURL := buildUTMURL(in, i) + + adID, creativeID, verr := c.createVariantAd(ctx, in, variant, adSetID, utmURL, i) + if verr != nil { + // A cancelled or deadlined CALLER context is fatal: continuing would let + // us report a "successful" campaign after the caller's context died. Key + // the decision off the caller ctx directly (ctx.Err()), NOT errors.Is on + // the returned error: the client's own http.Client.Timeout also surfaces + // as a DeadlineExceeded-wrapped url error, but with a still-live caller + // ctx that per-creative timeout is an ordinary API failure and must stay + // non-fatal (skip + continue), like any other per-creative error. + if ctx.Err() != nil { + // If the creative was created before the ad call was cut short, surface + // its id in the fatal error too — otherwise this known orphaned creative + // is lost (the non-fatal path below already reports it). + if creativeID != "" { + return partialResult(), fmt.Errorf("meta campaign aborted while creating ad %d (campaign %s created, PAUSED; orphaned creative: %s): %w", i+1, campaignID, creativeID, ctx.Err()) + } + return partialResult(), fmt.Errorf("meta campaign aborted while creating ad %d (campaign %s created, PAUSED): %w", i+1, campaignID, ctx.Err()) + } + // An AMBIGUOUS ad/creative error (transport/timeout, 5xx, or a 2xx with no + // id — all surfaced by createVariantAd) means Meta MAY already have created + // the object, so a definite "create it manually" instruction could + // duplicate it. Record it as UNCONFIRMED (verify before recreating) instead. + // A clearly non-ambiguous error (a 4xx Meta rejection — nothing created) + // keeps the definite failure wording. Mirrors how the reddit client words + // UNCONFIRMED vs FAILED ad outcomes. Per-variant behavior is unchanged: + // record in Steps and continue. + if createOutcomeAmbiguous(verr) { + if creativeID != "" { + steps = append(steps, fmt.Sprintf("Ad/creative creation outcome UNCONFIRMED for variant %d; it may have been created — verify in Meta Ads Manager before recreating (orphaned creative: %s): %s", i+1, creativeID, truncateErr(verr, 300))) + } else { + steps = append(steps, fmt.Sprintf("Ad/creative creation outcome UNCONFIRMED for variant %d; it may have been created — verify in Meta Ads Manager before recreating: %s", i+1, truncateErr(verr, 300))) + } + continue + } + // If the creative was created before the ad failed, surface its id so the + // orphaned creative is visible (can be cleaned up / reused) rather than + // silently discarded. + if creativeID != "" { + steps = append(steps, fmt.Sprintf("Ad %d failed: %s (orphaned creative: %s)", i+1, truncateErr(verr, 300), creativeID)) + } else { + steps = append(steps, fmt.Sprintf("Ad %d failed: %s", i+1, truncateErr(verr, 300))) + } + continue + } + adCount++ + // Show the SANITIZED display URL in the human-readable step (Steps may be + // persisted/logged), not the full utmURL — which preserves the caller's + // original query/fragment and could leak a secret like ?token=... The real + // ad still uses the full utmURL as its click destination (createVariantAd). + steps = append(steps, fmt.Sprintf("Ad %d created: %s (creative: %s) → %s", i+1, adID, creativeID, displayMetaUTMURL(in, i))) + } + + if adCount == 0 && len(in.Variants) > 0 { + steps = append(steps, "No ads could be created — create them manually in Meta Ads Manager") + } + + // Success: partialResult() now carries the fully-created campaign, ad set, and + // ad count (same fields as a bespoke literal); reuse it so success and partial + // failure return an identically-shaped result. + return partialResult(), nil +} + +// createVariantAd creates the adcreative and ad for one variant, returning the +// ad id and creative id. +func (c *Client) createVariantAd(ctx context.Context, in CampaignInput, variant AdVariant, adSetID, utmURL string, i int) (adID, creativeID string, err error) { + linkData := map[string]any{ + "link": utmURL, + "message": variant.PrimaryText, + "name": variant.Headline, + "call_to_action": map[string]any{ + "type": "LEARN_MORE", + "value": map[string]any{"link": utmURL}, + }, + } + if variant.Description != "" { + linkData["description"] = variant.Description + } + + var creativeResp createResponse + if err = c.doRequest(ctx, http.MethodPost, "/"+c.account.AccountID+"/adcreatives", map[string]any{ + "name": fmt.Sprintf("%s - Variant %d", in.EventName, i+1), + "object_story_spec": map[string]any{ + "page_id": c.account.PageID, + "link_data": linkData, + }, + }, &creativeResp); err != nil { + return "", "", err + } + if creativeResp.ID == "" { + // A 2xx with no id is AMBIGUOUS: Meta may have created the creative but we + // couldn't read its id. Wrap as transportError so the caller classifies it + // as "may exist" (createOutcomeAmbiguous) rather than a definite failure. + return "", "", &transportError{Method: http.MethodPost, Path: "/adcreatives", Err: fmt.Errorf("creative creation returned no ID")} + } + + var adResp createResponse + if err = c.doRequest(ctx, http.MethodPost, "/"+c.account.AccountID+"/ads", map[string]any{ + "name": fmt.Sprintf("%s - Ad %d", in.EventName, i+1), + "adset_id": adSetID, + "creative": map[string]any{"creative_id": creativeResp.ID}, + "status": "PAUSED", + }, &adResp); err != nil { + // The creative was already created; return its id alongside the error so + // the (non-fatal) caller can record the orphaned creative rather than + // silently discarding it. + return "", creativeResp.ID, err + } + if adResp.ID == "" { + // A 2xx with no id is AMBIGUOUS: Meta may have created the ad but we couldn't + // read its id. Wrap as transportError so the caller classifies it as "may + // exist" (createOutcomeAmbiguous) rather than a definite failure. + return "", creativeResp.ID, &transportError{Method: http.MethodPost, Path: "/ads", Err: fmt.Errorf("ad creation returned no ID")} + } + return adResp.ID, creativeResp.ID, nil +} + +func objectiveKeys() []string { + // The objectives CreateCampaign accepts. All five are supported; 'leads' runs + // as a website-leads campaign (LINK_CLICKS to the registration URL). + return []string{"awareness", "traffic", "engagement", "leads", "conversions"} +} diff --git a/internal/platform/meta/client_test.go b/internal/platform/meta/client_test.go new file mode 100644 index 00000000..c849f1f6 --- /dev/null +++ b/internal/platform/meta/client_test.go @@ -0,0 +1,3327 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package meta + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" +) + +// fixedMetaClock pins the clock so date-based tests (StartDate/EndDate) stay +// valid regardless of the wall clock. Chosen before the test fixtures' dates. +func fixedMetaClock() func() time.Time { + return func() time.Time { return time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) } +} + +// urlWithUserinfo composes a URL with embedded userinfo at runtime, so the test +// source never contains a literal "user:pass@host" — which secretlint's +// basic-auth rule (MegaLinter) flags as a credential and fails CI on. The +// composed value still exercises the userinfo-rejection paths. +func urlWithUserinfo(scheme, user, pass, hostAndRest string) string { + cred := user + if pass != "" { + cred += ":" + pass + } + return scheme + "://" + cred + "@" + hostAndRest +} + +// roundTripFunc adapts a function to http.RoundTripper for tests that need to +// inject transport-level errors (e.g. a canceled context) deterministically. +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +// --------------------------------------------------------------------------- +// Objective -> parameter mapping +// --------------------------------------------------------------------------- + +func TestObjectiveParamsMapping(t *testing.T) { + cases := []struct { + objective string + campaign string + optGoal string + promoted PromotedObjectType + }{ + {"awareness", "OUTCOME_AWARENESS", "REACH", PromotedObjectNone}, + {"traffic", "OUTCOME_TRAFFIC", "LINK_CLICKS", PromotedObjectNone}, + {"engagement", "OUTCOME_ENGAGEMENT", "POST_ENGAGEMENT", PromotedObjectPageID}, + {"leads", "OUTCOME_TRAFFIC", "LINK_CLICKS", PromotedObjectNone}, + {"conversions", "OUTCOME_SALES", "OFFSITE_CONVERSIONS", PromotedObjectPixelID}, + } + for _, tc := range cases { + p, ok := ObjectiveParamsFor(tc.objective) + if !ok { + t.Fatalf("objective %q not found", tc.objective) + } + if p.CampaignObjective != tc.campaign { + t.Errorf("%s: campaign objective = %q, want %q", tc.objective, p.CampaignObjective, tc.campaign) + } + if p.OptimizationGoal != tc.optGoal { + t.Errorf("%s: optimization goal = %q, want %q", tc.objective, p.OptimizationGoal, tc.optGoal) + } + if p.PromotedObjectType != tc.promoted { + t.Errorf("%s: promoted type = %q, want %q", tc.objective, p.PromotedObjectType, tc.promoted) + } + } + + if _, ok := ObjectiveParamsFor("nonsense"); ok { + t.Errorf("unknown objective unexpectedly found") + } +} + +func TestBuildPromotedObject(t *testing.T) { + // page_id objective + po, err := buildPromotedObject("engagement", "PAGE1", "") + if err != nil { + t.Fatalf("engagement: %v", err) + } + if po["page_id"] != "PAGE1" { + t.Errorf("engagement promoted_object = %v", po) + } + + // pixel_id objective without pixel -> error + if _, err := buildPromotedObject("conversions", "PAGE1", " "); err == nil { + t.Errorf("expected error for conversions without pixelID") + } + + // pixel_id objective with a valid (numeric) pixel — surrounding whitespace is + // trimmed. Meta Pixel IDs are numeric strings. + po, err = buildPromotedObject("conversions", "PAGE1", " 1234567890 ") + if err != nil { + t.Fatalf("conversions: %v", err) + } + if po["pixel_id"] != "1234567890" || po["custom_event_type"] != "PURCHASE" { + t.Errorf("conversions promoted_object = %v", po) + } + + // pixel_id objective with a malformed (non-numeric) pixel -> error, so a bad + // pixel id fails before any campaign is created rather than at ad-set time. + if _, err := buildPromotedObject("conversions", "PAGE1", "PIX9"); err == nil { + t.Errorf("expected error for conversions with a non-numeric pixelID") + } + + // none objective + po, err = buildPromotedObject("traffic", "PAGE1", "") + if err != nil { + t.Fatalf("traffic: %v", err) + } + if po != nil { + t.Errorf("traffic promoted_object = %v, want nil", po) + } +} + +// --------------------------------------------------------------------------- +// Placement targeting +// --------------------------------------------------------------------------- + +func TestBuildPlacementTargetingDefaults(t *testing.T) { + // Defaults enable facebook feed + instagram feed only. + tg, err := buildPlacementTargeting(Placement{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + pp, _ := tg["publisher_platforms"].([]string) + if !contains(pp, "facebook") || !contains(pp, "instagram") { + t.Errorf("publisher_platforms = %v, want facebook+instagram", pp) + } + if contains(pp, "audience_network") { + t.Errorf("audience_network should be off by default") + } + fbPos, _ := tg["facebook_positions"].([]string) + if !contains(fbPos, "feed") { + t.Errorf("facebook_positions = %v, want feed", fbPos) + } +} + +func TestBuildPlacementTargetingNoneEnabled(t *testing.T) { + f := false + _, err := buildPlacementTargeting(Placement{ + FacebookFeed: &f, + InstagramFeed: &f, + }) + if err == nil { + t.Fatalf("expected error when no placement is enabled") + } + if !strings.Contains(err.Error(), "at least one placement") { + t.Errorf("error = %v", err) + } +} + +// --------------------------------------------------------------------------- +// Geo / region / validation helpers +// --------------------------------------------------------------------------- + +func TestValidateGeoTargets(t *testing.T) { + got := validateGeoTargets([]string{" us ", "gb", "zzz", "1", "de"}) + want := []string{"US", "GB", "DE"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("validateGeoTargets = %v, want %v", got, want) + } + if def := validateGeoTargets([]string{"!!!"}); strings.Join(def, ",") != "US" { + t.Errorf("default = %v, want [US]", def) + } +} + +func TestResolveRegion(t *testing.T) { + if r := resolveRegion([]string{"DE", "US"}); r != "EMEA" { + t.Errorf("region = %q, want EMEA", r) + } + if r := resolveRegion([]string{"ZZ"}); r != "Global" { + t.Errorf("region = %q, want Global", r) + } + if r := resolveRegion(nil); r != "Global" { + t.Errorf("region = %q, want Global", r) + } +} + +func TestValidateRegistrationURL(t *testing.T) { + if err := validateRegistrationURL("https://events.linuxfoundation.org/x"); err != nil { + t.Errorf("valid https url errored: %v", err) + } + if err := validateRegistrationURL("http://insecure.example"); err == nil { + t.Errorf("expected error for non-https") + } + if err := validateRegistrationURL("not a url"); err == nil { + t.Errorf("expected error for invalid url") + } + // Port-only / empty-hostname authorities parse with a non-empty Host but an + // empty Hostname(); they must be rejected as invalid destinations. + for _, raw := range []string{"https://:443", "https://:443/register", "https://", "//events.example.org/x"} { + if err := validateRegistrationURL(raw); err == nil { + t.Errorf("expected error for port-only/empty-hostname url %q", raw) + } + } +} + +func TestBuildCampaignName(t *testing.T) { + // The Project segment carries the canonical LFX slug (docs/api-catalog.md), + // so the fixture uses the canonical "cncf" rather than a display label. It is + // padded with whitespace to prove the builder trims segments: validation + // TrimSpaces its checks, so " cncf " passes validation, but the attribution + // pipeline joins the Project segment exactly. The name is the 8-segment + // convention ending in the Funnel (MoFU) segment. + name := buildCampaignName(CampaignInput{ + EventName: "Open|Source Summit", + Project: " cncf ", + Objective: "leads", + StartDate: "2026-08-01", + }, []string{"DE"}) + want := "Events | Open-Source Summit | EMEA | Leads | Intent | Social | cncf | MoFU" + if name != want { + t.Errorf("campaign name = %q, want %q", name, want) + } + + // buildCampaignName no longer substitutes a placeholder for an omitted Project: + // CreateCampaign rejects an empty Project up front, so this builder is only ever + // called with a non-empty caller-supplied slug. Verify the slug is placed + // verbatim (with '|' sanitized) into the Project segment. + named := buildCampaignName(CampaignInput{EventName: "Summit", Project: "kubernetes", Objective: "leads"}, []string{"DE"}) + if !strings.Contains(named, "| kubernetes |") { + t.Errorf("project segment = %q, want the caller-supplied slug 'kubernetes'", named) + } +} + +func TestBuildUTMURL(t *testing.T) { + u := buildUTMURL(CampaignInput{ + EventName: "KubeCon EU", + RegistrationURL: "https://events.example.org/kubecon/", + HSToken: "hs-123", + }, 0) + if !strings.HasPrefix(u, "https://events.example.org/kubecon?") { + t.Errorf("utm url = %q (bad base/trailing slash handling)", u) + } + for _, want := range []string{"utm_source=meta", "utm_medium=paid-social", "utm_campaign=hs-123", "utm_content=variant-1", "utm_term=kubecon-eu"} { + if !strings.Contains(u, want) { + t.Errorf("utm url %q missing %q", u, want) + } + } +} + +func TestBuildUTMURLPreservesFragment(t *testing.T) { + u := buildUTMURL(CampaignInput{ + EventName: "KubeCon EU", + RegistrationURL: "https://events.example.org/event#register", + HSToken: "hs-123", + }, 0) + + hashIdx := strings.Index(u, "#") + if hashIdx < 0 { + t.Fatalf("utm url %q dropped the fragment", u) + } + if !strings.HasSuffix(u, "#register") { + t.Errorf("utm url %q must keep #register at the very end", u) + } + // All utm params must land before the fragment, not after it. + beforeFragment := u[:hashIdx] + for _, want := range []string{"utm_source=meta", "utm_medium=paid-social", "utm_campaign=hs-123", "utm_content=variant-1", "utm_term=kubecon-eu"} { + if !strings.Contains(beforeFragment, want) { + t.Errorf("utm url %q missing %q before the fragment", u, want) + } + } +} + +func TestBuildUTMURLPreservesExistingQuery(t *testing.T) { + u := buildUTMURL(CampaignInput{ + EventName: "KubeCon EU", + RegistrationURL: "https://events.example.org/e?ref=abc#section", + HSToken: "hs-1", + }, 0) + if !strings.Contains(u, "ref=abc") { + t.Errorf("utm url %q dropped the existing query param ref=abc", u) + } + if !strings.HasSuffix(u, "#section") { + t.Errorf("utm url %q must keep #section at the end", u) + } +} + +// TestBuildUTMURLPreservesSlashInQueryValue guards against the whole-string +// TrimRight bug: a query value ending in '/' (e.g. ?redirect=/) must survive. +func TestBuildUTMURLPreservesSlashInQueryValue(t *testing.T) { + u := buildUTMURL(CampaignInput{ + EventName: "KubeCon EU", + RegistrationURL: "https://events.example.org/register?redirect=/", + HSToken: "hs-1", + }, 0) + + parsed, err := url.Parse(u) + if err != nil { + t.Fatalf("result url %q did not parse: %v", u, err) + } + if got := parsed.Query().Get("redirect"); got != "/" { + t.Errorf("redirect value = %q, want %q (trailing slash was stripped)", got, "/") + } + if !strings.Contains(u, "utm_source=meta") { + t.Errorf("utm url %q missing utm_source", u) + } +} + +// --------------------------------------------------------------------------- +// CreateCampaign happy path against an httptest server +// --------------------------------------------------------------------------- + +func TestCreateCampaignHappyPath(t *testing.T) { + authCapture := make(chan string, 8) + campaignCap := newBodyCapture() + adsetCap := newBodyCapture() + creativeCap := newBodyCapture() + adCap := newBodyCapture() + var creativeCount, adCount int32 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authCapture <- r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/act_777") && strings.Contains(r.URL.RawQuery, "account_status"): + _, _ = io.WriteString(w, `{"name":"LF Core","account_status":1}`) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/campaigns"): + campaignCap.set(decodeBody(t, r)) + _, _ = io.WriteString(w, `{"id":"camp_123"}`) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/adsets"): + adsetCap.set(decodeBody(t, r)) + _, _ = io.WriteString(w, `{"id":"adset_456"}`) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/adcreatives"): + creativeCap.set(decodeBody(t, r)) + n := atomic.AddInt32(&creativeCount, 1) + _, _ = io.WriteString(w, `{"id":"creative_`+strconv.Itoa(int(n))+`"}`) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/ads"): + adCap.set(decodeBody(t, r)) + n := atomic.AddInt32(&adCount, 1) + _, _ = io.WriteString(w, `{"id":"ad_`+strconv.Itoa(int(n))+`"}`) + default: + t.Errorf("unexpected request: %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c := NewClient( + Credentials{AccessToken: "tok-abc"}, + AccountConfig{AccountID: "act_777", PageID: "987654321", Label: "LF Core", CurrencyOffset: 100}, + WithBaseURL(srv.URL), + WithClock(fixedMetaClock()), + ) + + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "KubeCon", + Project: "tlf", + RegistrationURL: "https://events.example.org/kubecon", + Objective: "traffic", + GeoTargets: []string{"US", "DE"}, + Budget: 500, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{ + {PrimaryText: "Join us", Headline: "KubeCon 2026"}, + {PrimaryText: "Register", Headline: "See you there"}, + }, + }) + if err != nil { + t.Fatalf("CreateCampaign error: %v", err) + } + + // Read all captures after CreateCampaign has returned (all requests done). + gotAuth := "" + for done := false; !done; { + select { + case a := <-authCapture: + gotAuth = a + default: + done = true + } + } + campaignBody := campaignCap.get() + adsetBody := adsetCap.get() + creativeBody := creativeCap.get() + adBody := adCap.get() + + if gotAuth != "Bearer tok-abc" { + t.Errorf("Authorization header = %q", gotAuth) + } + if res.CampaignID != "camp_123" { + t.Errorf("campaign id = %q, want camp_123", res.CampaignID) + } + if res.AdSetID != "adset_456" { + t.Errorf("adset id = %q, want adset_456", res.AdSetID) + } + if res.AdCount != 2 { + t.Errorf("ad count = %d, want 2", res.AdCount) + } + if res.Platform != "meta-ads" { + t.Errorf("platform = %q", res.Platform) + } + if !strings.Contains(res.MetaURL, "act=777") { + t.Errorf("meta url = %q, want act=777 (act_ stripped)", res.MetaURL) + } + + // Campaign body assertions. + if campaignBody["objective"] != "OUTCOME_TRAFFIC" { + t.Errorf("campaign objective = %v", campaignBody["objective"]) + } + if campaignBody["status"] != "PAUSED" { + t.Errorf("campaign status = %v", campaignBody["status"]) + } + + // Ad set body assertions: daily budget in cents, geo filtered. + if adsetBody["daily_budget"] != float64(50000) { + t.Errorf("daily_budget = %v, want 50000", adsetBody["daily_budget"]) + } + if adsetBody["optimization_goal"] != "LINK_CLICKS" { + t.Errorf("optimization_goal = %v", adsetBody["optimization_goal"]) + } + + // Creative body assertions: object_story_spec.page_id and the UTM link/copy + // must be present so a regression that drops them is caught. creativeBody is + // the last (second) variant, "Register"/"See you there". + if creativeBody == nil { + t.Fatalf("no creative body captured") + } + oss, ok := creativeBody["object_story_spec"].(map[string]any) + if !ok { + t.Fatalf("creative object_story_spec missing or wrong type: %v", creativeBody["object_story_spec"]) + } + if oss["page_id"] != "987654321" { + t.Errorf("creative object_story_spec.page_id = %v, want 987654321", oss["page_id"]) + } + linkData, ok := oss["link_data"].(map[string]any) + if !ok { + t.Fatalf("creative link_data missing or wrong type: %v", oss["link_data"]) + } + link, _ := linkData["link"].(string) + if !strings.Contains(link, "utm_source=meta") { + t.Errorf("creative link = %q, want UTM link with utm_source=meta", link) + } + if !strings.HasPrefix(link, "https://events.example.org/kubecon") { + t.Errorf("creative link = %q, want registration URL base", link) + } + if linkData["message"] != "Register" { + t.Errorf("creative link_data.message = %v, want 'Register'", linkData["message"]) + } + if linkData["name"] != "See you there" { + t.Errorf("creative link_data.name = %v, want 'See you there'", linkData["name"]) + } + + // Ad body assertions: adset_id and creative_id must wire the ad to the ad set + // and creative just created. + if adBody == nil { + t.Fatalf("no ad body captured") + } + if adBody["adset_id"] != "adset_456" { + t.Errorf("ad adset_id = %v, want adset_456", adBody["adset_id"]) + } + creative, ok := adBody["creative"].(map[string]any) + if !ok { + t.Fatalf("ad creative field missing or wrong type: %v", adBody["creative"]) + } + if creative["creative_id"] != "creative_2" { + t.Errorf("ad creative.creative_id = %v, want creative_2", creative["creative_id"]) + } +} + +// TestCreateCampaignNormalizesEventName verifies that a padded EventName is +// trimmed for ALL generated names and the UTM term — not just the campaign name +// (which trims internally). A raw " KubeCon EU " would otherwise leak into the +// ad-set/creative/ad names and produce a malformed utm_term=-kubecon-eu-. +func TestCreateCampaignNormalizesEventName(t *testing.T) { + adsetCap := newBodyCapture() + creativeCap := newBodyCapture() + adCap := newBodyCapture() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + _, _ = io.WriteString(w, `{"name":"x","currency":"USD"}`) + case strings.HasSuffix(r.URL.Path, "/campaigns"): + _, _ = io.WriteString(w, `{"id":"camp_1"}`) + case strings.HasSuffix(r.URL.Path, "/adsets"): + adsetCap.set(decodeBody(t, r)) + _, _ = io.WriteString(w, `{"id":"adset_1"}`) + case strings.HasSuffix(r.URL.Path, "/adcreatives"): + creativeCap.set(decodeBody(t, r)) + _, _ = io.WriteString(w, `{"id":"creative_1"}`) + case strings.HasSuffix(r.URL.Path, "/ads"): + adCap.set(decodeBody(t, r)) + _, _ = io.WriteString(w, `{"id":"ad_1"}`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c := NewClient(Credentials{AccessToken: "t"}, + AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, + WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + if _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: " KubeCon EU ", Project: "tlf", Objective: "traffic", + // Padded RegistrationURL: validation trims its own copy, but buildUTMURL + // reads in.RegistrationURL directly — CreateCampaign must trim it in place + // so the creative click URL is built from the clean base, not " https...". + RegistrationURL: " https://x.example.org/e ", GeoTargets: []string{"US"}, + Budget: 10, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }); err != nil { + t.Fatalf("CreateCampaign error: %v", err) + } + + // Drain each capture ONCE (get() consumes the buffered value). + adsetBody := adsetCap.get() + creativeBody := creativeCap.get() + adBody := adCap.get() + + // Ad-set name uses the trimmed event name (no leading/trailing spaces around + // the " - Traffic" join). + if got, _ := adsetBody["name"].(string); got != "KubeCon EU - Traffic" { + t.Errorf("ad set name = %q, want %q", got, "KubeCon EU - Traffic") + } + // Creative name uses the trimmed event name. + if got, _ := creativeBody["name"].(string); got != "KubeCon EU - Variant 1" { + t.Errorf("creative name = %q, want %q", got, "KubeCon EU - Variant 1") + } + // Ad name uses the trimmed event name. + if got, _ := adBody["name"].(string); got != "KubeCon EU - Ad 1" { + t.Errorf("ad name = %q, want %q", got, "KubeCon EU - Ad 1") + } + // UTM term is derived from the trimmed name: no leading/trailing dash. + oss, _ := creativeBody["object_story_spec"].(map[string]any) + linkData, _ := oss["link_data"].(map[string]any) + link, _ := linkData["link"].(string) + if !strings.Contains(link, "utm_term=kubecon-eu") { + t.Errorf("creative link = %q, want clean utm_term=kubecon-eu", link) + } + // The padded RegistrationURL was trimmed in place: the creative link must start + // with the clean base, never a leading space or an unparseable " https". + if !strings.HasPrefix(link, "https://x.example.org/e") { + t.Errorf("creative link = %q, want it to start with the trimmed base URL", link) + } + if strings.Contains(link, "utm_term=-kubecon") || strings.Contains(link, "kubecon-eu-&") || strings.Contains(link, "kubecon-eu-#") { + t.Errorf("creative link = %q, want no leading/trailing dash in utm_term", link) + } +} + +// TestCreateCampaignAdSetFailureReturnsPartialResult verifies that when the ad +// set POST fails AFTER the campaign was already created, CreateCampaign returns +// a non-nil partial CampaignResult carrying the orphaned campaign's ID (so a +// caller can reconcile/clean up without parsing the error string) alongside the +// error. +func TestCreateCampaignAdSetFailureReturnsPartialResult(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/act_777") && strings.Contains(r.URL.RawQuery, "account_status"): + _, _ = io.WriteString(w, `{"name":"LF Core","account_status":1,"currency":"USD"}`) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/campaigns"): + _, _ = io.WriteString(w, `{"id":"camp_orphan"}`) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/adsets"): + // Ad set creation fails after the campaign already exists. + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"error":{"message":"bad targeting","type":"OAuthException","code":100}}`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c := NewClient( + Credentials{AccessToken: "tok-abc"}, + AccountConfig{AccountID: "act_777", PageID: "987654321", CurrencyOffset: 100}, + WithBaseURL(srv.URL), + WithClock(fixedMetaClock()), + ) + + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "KubeCon", + Project: "tlf", + RegistrationURL: "https://events.example.org/kubecon", + Objective: "traffic", + GeoTargets: []string{"US"}, + Budget: 500, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "Join us", Headline: "KubeCon 2026"}}, + }) + if err == nil { + t.Fatal("expected an error when ad set creation fails") + } + if res == nil { + t.Fatal("expected a non-nil partial result carrying the orphaned campaign ID, got nil") + } + if res.CampaignID != "camp_orphan" { + t.Errorf("partial result CampaignID = %q, want camp_orphan", res.CampaignID) + } + if res.AdSetID != "" { + t.Errorf("partial result AdSetID = %q, want empty (ad set was never created)", res.AdSetID) + } + if res.AdCount != 0 { + t.Errorf("partial result AdCount = %d, want 0", res.AdCount) + } +} + +// TestCreateCampaignNoIDReturnsPartial verifies a 2xx campaign create with no id +// returns a partial result carrying the campaign name (reconcilable by name), not +// a bare (nil, err). +func TestCreateCampaignNoIDReturnsPartial(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + _, _ = io.WriteString(w, `{"name":"x","currency":"USD"}`) + case strings.HasSuffix(r.URL.Path, "/campaigns"): + _, _ = io.WriteString(w, `{}`) // 2xx, no id + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", Objective: "traffic", + RegistrationURL: "https://x.example.org/e", GeoTargets: []string{"US"}, + Budget: 10, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil { + t.Fatal("expected an error for a campaign with no id") + } + if res == nil || res.CampaignName == "" { + t.Fatalf("expected a partial result with the campaign name, got %+v", res) + } + if res.CampaignID != "" { + t.Errorf("CampaignID = %q, want empty", res.CampaignID) + } +} + +// TestCreateCampaignAmbiguousCampaignCreateReturnsPartial verifies that a 5xx on +// the campaign POST (an AMBIGUOUS outcome — Meta may have committed the create) +// yields a NON-NIL partial result carrying the campaign name and an UNCONFIRMED +// step, rather than (nil, err) that would discard the reconcilable name. +func TestCreateCampaignAmbiguousCampaignCreateReturnsPartial(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + _, _ = io.WriteString(w, `{"name":"x","currency":"USD"}`) + case strings.HasSuffix(r.URL.Path, "/campaigns"): + // 5xx: an ambiguous failure that may have committed the create. + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"error":{"message":"internal","type":"OAuthException","code":1}}`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "KubeCon", Project: "tlf", Objective: "traffic", + RegistrationURL: "https://x.example.org/e", GeoTargets: []string{"US"}, + Budget: 10, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil { + t.Fatal("expected an error for an ambiguous 5xx campaign create") + } + if res == nil || res.CampaignName == "" { + t.Fatalf("expected a non-nil partial result carrying the campaign name, got %+v", res) + } + if res.CampaignID != "" { + t.Errorf("CampaignID = %q, want empty (id was never read)", res.CampaignID) + } + if !anyStepContains(res.Steps, "UNCONFIRMED") { + t.Errorf("expected an UNCONFIRMED step, got %v", res.Steps) + } +} + +// TestCreateCampaignAmbiguousTransportReturnsPartial verifies that a transport +// timeout on the campaign POST (also AMBIGUOUS) yields a partial result with the +// campaign name and an UNCONFIRMED step. +func TestCreateCampaignAmbiguousTransportReturnsPartial(t *testing.T) { + rt := roundTripFunc(func(req *http.Request) (*http.Response, error) { + if strings.HasSuffix(req.URL.Path, "/campaigns") { + // A mid-flight timeout (not a pre-send dial error) is ambiguous. + return nil, &url.Error{ + Op: "Post", + URL: req.URL.String(), + Err: fmt.Errorf("net/http: request canceled (Client.Timeout exceeded while awaiting headers): %w", context.DeadlineExceeded), + } + } + body := `{"name":"x","currency":"USD"}` + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + }) + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, + WithBaseURL("http://meta.test"), WithHTTPClient(&http.Client{Transport: rt}), WithClock(fixedMetaClock())) + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "KubeCon", Project: "tlf", Objective: "traffic", + RegistrationURL: "https://x.example.org/e", GeoTargets: []string{"US"}, + Budget: 10, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil { + t.Fatal("expected an error for an ambiguous transport timeout on campaign create") + } + if res == nil || res.CampaignName == "" { + t.Fatalf("expected a non-nil partial result carrying the campaign name, got %+v", res) + } + if !anyStepContains(res.Steps, "UNCONFIRMED") { + t.Errorf("expected an UNCONFIRMED step, got %v", res.Steps) + } +} + +// TestCreateCampaign4xxCampaignCreateReturnsNoPartial verifies that a clear 4xx +// on the campaign POST (nothing was created) still returns (nil, err) with no +// partial result — the non-ambiguous path is unchanged. +func TestCreateCampaign4xxCampaignCreateReturnsNoPartial(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + _, _ = io.WriteString(w, `{"name":"x","currency":"USD"}`) + case strings.HasSuffix(r.URL.Path, "/campaigns"): + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"error":{"message":"bad name","type":"OAuthException","code":100}}`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", Objective: "traffic", + RegistrationURL: "https://x.example.org/e", GeoTargets: []string{"US"}, + Budget: 10, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil { + t.Fatal("expected an error for a 4xx campaign create") + } + if res != nil { + t.Errorf("expected NO partial result for a definite 4xx (nothing created), got %+v", res) + } +} + +// TestDisplayMetaUTMURLStripsSecretsAndFragment verifies displayMetaUTMURL drops +// a caller ?token=... query, any fragment, and any userinfo — keeping only the +// generated utm_* params. The secret is composed at RUNTIME so a literal never +// appears in the test source (secretlint/gitleaks). +func TestDisplayMetaUTMURLStripsSecretsAndFragment(t *testing.T) { + secret := "s3cr" + "et-" + "abc123" // composed at runtime, not a literal + in := CampaignInput{ + EventName: "KubeCon", + RegistrationURL: "https://events.example.org/register?token=" + secret + "&ref=x#section-tickets", + } + got := displayMetaUTMURL(in, 0) + if strings.Contains(got, secret) { + t.Errorf("display URL %q leaked the secret token", got) + } + if strings.Contains(got, "ref=x") { + t.Errorf("display URL %q kept a caller query param; only utm_* should survive", got) + } + if strings.Contains(got, "#") || strings.Contains(got, "section-tickets") { + t.Errorf("display URL %q kept the fragment", got) + } + if !strings.Contains(got, "utm_source=meta") || !strings.Contains(got, "utm_content=variant-1") { + t.Errorf("display URL %q missing the generated utm_* params", got) + } + // The REAL click URL (buildUTMURL) must still carry the caller's full query. + real := buildUTMURL(in, 0) + if !strings.Contains(real, secret) { + t.Errorf("real click URL %q must preserve the caller's token; it is the ad destination", real) + } + + // Userinfo is stripped even when present (displayMetaUTMURL is defensive; the + // URL is composed at runtime so no literal user:pass@ appears in source). + inUser := CampaignInput{EventName: "E", RegistrationURL: urlWithUserinfo("https", "u", "p", "events.example.org/register?token="+secret)} + gotUser := displayMetaUTMURL(inUser, 0) + if strings.Contains(gotUser, "u:p@") || strings.Contains(gotUser, secret) { + t.Errorf("display URL %q leaked userinfo or secret", gotUser) + } +} + +// TestCreateCampaignSuccessStepsHideSecret verifies a fully-successful +// CreateCampaign records the SANITIZED display URL in Steps, so a caller +// ?token=... secret never reaches the (persisted/logged) Steps. +func TestCreateCampaignSuccessStepsHideSecret(t *testing.T) { + secret := "s3cr" + "et-" + "xyz789" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + _, _ = io.WriteString(w, `{"name":"x","currency":"USD"}`) + case strings.HasSuffix(r.URL.Path, "/campaigns"): + _, _ = io.WriteString(w, `{"id":"camp_1"}`) + case strings.HasSuffix(r.URL.Path, "/adsets"): + _, _ = io.WriteString(w, `{"id":"adset_1"}`) + case strings.HasSuffix(r.URL.Path, "/adcreatives"): + _, _ = io.WriteString(w, `{"id":"cr_1"}`) + case strings.HasSuffix(r.URL.Path, "/ads"): + _, _ = io.WriteString(w, `{"id":"ad_1"}`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "KubeCon", Project: "tlf", Objective: "traffic", + RegistrationURL: "https://events.example.org/register?token=" + secret, + GeoTargets: []string{"US"}, + Budget: 10, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err != nil { + t.Fatalf("expected success, got %v", err) + } + if res.AdCount != 1 { + t.Fatalf("ad count = %d, want 1", res.AdCount) + } + for _, s := range res.Steps { + if strings.Contains(s, secret) { + t.Errorf("Steps leaked the caller secret: %q", s) + } + } +} + +// TestCreateCampaignAllGeosInvalidNamesThem verifies the all-invalid-geo error +// names the dropped codes (the discarded "Geo targets dropped" step otherwise +// hides them). +func TestCreateCampaignAllGeosInvalidNamesThem(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + t.Errorf("no POST should happen when all geos are invalid: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"name":"x","currency":"USD"}`) + })) + defer srv.Close() + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", Objective: "traffic", + RegistrationURL: "https://x.example.org/e", GeoTargets: []string{"IR", "ZZ"}, + Budget: 10, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil || !strings.Contains(err.Error(), "no usable geo targets") { + t.Fatalf("err = %v, want 'no usable geo targets'", err) + } + if !strings.Contains(err.Error(), "IR") || !strings.Contains(err.Error(), "ZZ") { + t.Errorf("error should name the dropped geos IR and ZZ, got: %v", err) + } +} + +// TestCreateCampaignWhitespaceOnlyGeosDefaultToUS verifies that geo targets +// consisting solely of blank entries are treated like no geos (a legitimate US +// default), NOT as an explicit request that errors with an empty "(dropped: )". +func TestCreateCampaignWhitespaceOnlyGeosDefaultToUS(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + _, _ = io.WriteString(w, `{"name":"x","currency":"USD"}`) + case strings.HasSuffix(r.URL.Path, "/campaigns"): + _, _ = io.WriteString(w, `{"id":"camp_1"}`) + case strings.HasSuffix(r.URL.Path, "/adsets"): + _, _ = io.WriteString(w, `{"id":"adset_1"}`) + case strings.HasSuffix(r.URL.Path, "/adcreatives"): + _, _ = io.WriteString(w, `{"id":"creative_1"}`) + case strings.HasSuffix(r.URL.Path, "/ads"): + _, _ = io.WriteString(w, `{"id":"ad_1"}`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", Objective: "traffic", + RegistrationURL: "https://x.example.org/e", GeoTargets: []string{" ", ""}, + Budget: 10, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err != nil { + t.Fatalf("whitespace-only geos should default to US, got err: %v", err) + } + if res == nil || res.CampaignID == "" { + t.Fatalf("expected a created campaign, got %+v", res) + } + for _, s := range res.Steps { + if strings.Contains(s, "dropped") { + t.Errorf("no 'dropped' step expected for blank-only geos, got: %q", s) + } + } +} + +// TestCreateCampaignPartialVariantErrorsWithIndex verifies a variant missing +// primary text or headline is rejected by NAME (1-based index) rather than +// silently dropped, which would renumber the surviving variants. +func TestCreateCampaignPartialVariantErrorsWithIndex(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + t.Errorf("no POST should happen when a variant is invalid: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"name":"x","currency":"USD"}`) + })) + defer srv.Close() + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", Objective: "traffic", + RegistrationURL: "https://x.example.org/e", GeoTargets: []string{"US"}, + Budget: 10, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{ + {PrimaryText: "p", Headline: "h"}, + {PrimaryText: "p2", Headline: ""}, // second variant missing headline + }, + }) + if err == nil || !strings.Contains(err.Error(), "variant 2") { + t.Fatalf("err = %v, want an error naming 'variant 2'", err) + } +} + +func TestCreateCampaignLifetimeBudget(t *testing.T) { + adsetCap := newBodyCapture() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.RawQuery, "account_status"): + _, _ = io.WriteString(w, `{"name":"LF Core","account_status":1}`) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/campaigns"): + _, _ = io.WriteString(w, `{"id":"camp_1"}`) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/adsets"): + adsetCap.set(decodeBody(t, r)) + _, _ = io.WriteString(w, `{"id":"adset_1"}`) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/adcreatives"): + _, _ = io.WriteString(w, `{"id":"creative_1"}`) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/ads"): + _, _ = io.WriteString(w, `{"id":"ad_1"}`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c := NewClient( + Credentials{AccessToken: "tok"}, + AccountConfig{AccountID: "act_777", PageID: "987654321", CurrencyOffset: 100}, + WithBaseURL(srv.URL), + WithClock(fixedMetaClock()), + ) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "KubeCon", + Project: "tlf", + RegistrationURL: "https://events.example.org/kubecon", + Objective: "traffic", + GeoTargets: []string{"US"}, + Budget: 500, + LifetimeBudget: true, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "Join us", Headline: "KubeCon 2026"}}, + }) + if err != nil { + t.Fatalf("CreateCampaign error: %v", err) + } + adsetBody := adsetCap.get() + if adsetBody["lifetime_budget"] != float64(50000) { + t.Errorf("lifetime_budget = %v, want 50000", adsetBody["lifetime_budget"]) + } + if _, ok := adsetBody["daily_budget"]; ok { + t.Errorf("daily_budget should be absent when LifetimeBudget is set, got %v", adsetBody["daily_budget"]) + } +} + +// TestCreateCampaignCurrencyOffset verifies budget conversion honors the ad +// account's minor-unit offset instead of a hardcoded ×100: a zero-decimal +// currency (JPY, offset 1) must NOT be multiplied by 100, and an explicit offset +// of 100 scales an account-currency amount to minor units. An UNSET (zero) offset +// is fetched from the account preflight instead (see +// TestCreateCampaignUsesPreflightCurrencyOffset), and rejected before mutation +// when the preflight cannot supply it (see +// TestCreateCampaignRejectsUnsetOffsetWhenPreflightOmitsIt); a NEGATIVE offset is +// rejected (see TestCreateCampaignRejectsNegativeCurrencyOffset). +func TestCreateCampaignCurrencyOffset(t *testing.T) { + newSrv := func(cap *bodyCapture) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + _, _ = io.WriteString(w, `{"name":"x"}`) + case strings.HasSuffix(r.URL.Path, "/campaigns"): + _, _ = io.WriteString(w, `{"id":"camp_1"}`) + case strings.HasSuffix(r.URL.Path, "/adsets"): + cap.set(decodeBody(t, r)) + _, _ = io.WriteString(w, `{"id":"adset_1"}`) + case strings.HasSuffix(r.URL.Path, "/adcreatives"): + _, _ = io.WriteString(w, `{"id":"creative_1"}`) + case strings.HasSuffix(r.URL.Path, "/ads"): + _, _ = io.WriteString(w, `{"id":"ad_1"}`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + } + input := func(budget float64) CampaignInput { + return CampaignInput{ + EventName: "E", + Project: "tlf", + Objective: "traffic", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: budget, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + } + } + + // JPY account: the budget is denominated in the ACCOUNT's currency (¥, no FX + // done by the client). With offset 1, a ¥5000 budget stays 5000 minor units, + // NOT 500000. A hardcoded ×100 would over-send this account 100×. + t.Run("jpy offset 1 does not multiply by 100", func(t *testing.T) { + cap := newBodyCapture() + srv := newSrv(cap) + defer srv.Close() + c := NewClient( + Credentials{AccessToken: "t"}, + AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 1}, + WithBaseURL(srv.URL), WithClock(fixedMetaClock()), + ) + if _, err := c.CreateCampaign(context.Background(), input(5000)); err != nil { + t.Fatalf("CreateCampaign error: %v", err) + } + if got := cap.get()["daily_budget"]; got != float64(5000) { + t.Errorf("daily_budget = %v, want 5000 (offset 1, no ×100)", got) + } + }) + + // Explicit offset 100: a 500 account-currency budget (e.g. $500 for a USD + // account) becomes 50000 minor units. + t.Run("explicit offset 100 scales x100 to account-currency amount", func(t *testing.T) { + cap := newBodyCapture() + srv := newSrv(cap) + defer srv.Close() + c := NewClient( + Credentials{AccessToken: "t"}, + AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, + WithBaseURL(srv.URL), WithClock(fixedMetaClock()), + ) + if _, err := c.CreateCampaign(context.Background(), input(500)); err != nil { + t.Fatalf("CreateCampaign error: %v", err) + } + if got := cap.get()["daily_budget"]; got != float64(50000) { + t.Errorf("daily_budget = %v, want 50000 (offset 100)", got) + } + }) +} + +// TestCreateCampaignRejectsNegativeCurrencyOffset verifies a NEGATIVE offset is +// rejected before any mutating call (it's malformed). An unset (zero) offset is +// resolved from the account preflight — see +// TestCreateCampaignUsesPreflightCurrencyOffset. +func TestCreateCampaignRejectsNegativeCurrencyOffset(t *testing.T) { + srv := noPostServer(t) + defer srv.Close() + c := NewClient(Credentials{AccessToken: "t"}, + AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: -1}, + WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, Budget: 10, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil || !strings.Contains(err.Error(), "must not be negative") { + t.Fatalf("err = %v, want it to reject a negative CurrencyOffset", err) + } +} + +// TestCreateCampaignRejectsUnsetOffsetWhenPreflightOmitsIt verifies that when +// CurrencyOffset is unset (0) AND the account preflight succeeds but does NOT +// return a usable currency code, CreateCampaign fails BEFORE any mutating call +// rather than guessing 100. Defaulting to 100 would silently encode a zero-decimal +// -currency (JPY/KRW/CLP) budget 100× too high, and a warning step after resource +// creation cannot prevent that budget from being activated. noPostServer returns +// {"name":"x"} — no currency field — so the offset can't be derived. +func TestCreateCampaignRejectsUnsetOffsetWhenPreflightOmitsIt(t *testing.T) { + srv := noPostServer(t) + defer srv.Close() + + // CurrencyOffset omitted (0); preflight body carries no currency code. + c := NewClient(Credentials{AccessToken: "t"}, + AccountConfig{AccountID: "act_1", PageID: "100"}, + WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, Budget: 5, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil || !strings.Contains(err.Error(), "unsupported or missing currency code") { + t.Fatalf("err = %v, want it to reject an undeterminable offset before mutation", err) + } +} + +// TestCreateCampaignRejectsUnsetOffsetWhenPreflightFails verifies that when +// CurrencyOffset is unset (0) AND the account preflight FAILS, CreateCampaign +// fails BEFORE any mutating call (no POST) rather than guessing 100 — the offset +// cannot be determined, so encoding a budget would be unsafe. +func TestCreateCampaignRejectsUnsetOffsetWhenPreflightFails(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + t.Errorf("unexpected POST to %s: offset resolution should fail first", r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + return + } + // Preflight GET fails. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"error":{"message":"account lookup failed"}}`) + })) + defer srv.Close() + + c := NewClient(Credentials{AccessToken: "t"}, + AccountConfig{AccountID: "act_1", PageID: "100"}, + WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, Budget: 5, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil || !strings.Contains(err.Error(), "could not determine the account currency") { + t.Fatalf("err = %v, want it to reject an undeterminable offset (preflight failed) before mutation", err) + } +} + +// TestCreateCampaignUsesPreflightCurrencyOffset verifies that when CurrencyOffset +// is unset (0), the offset DERIVED from the ISO currency code returned by the +// account preflight is used to encode the budget — and, crucially, a zero-decimal +// currency (JPY, offset 1) does NOT get multiplied by 100. With JPY, a ¥5000 +// budget stays 5000 minor units; with USD it scales ×100. +func TestCreateCampaignUsesPreflightCurrencyOffset(t *testing.T) { + cases := []struct { + name string + currency string + budget float64 + wantMinor float64 + }{ + {"jpy preflight code derives offset 1, no ×100", "JPY", 5000, 5000}, + {"usd preflight code derives offset 100, scales ×100", "USD", 500, 50000}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + adsetCap := newBodyCapture() + currency := tc.currency + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + // Preflight returns the account ISO currency code (NOT a + // currency_offset field — the AdAccount node does not expose one). + _, _ = io.WriteString(w, `{"name":"x","account_status":1,"currency":"`+currency+`"}`) + case strings.HasSuffix(r.URL.Path, "/campaigns"): + _, _ = io.WriteString(w, `{"id":"camp_1"}`) + case strings.HasSuffix(r.URL.Path, "/adsets"): + adsetCap.set(decodeBody(t, r)) + _, _ = io.WriteString(w, `{"id":"adset_1"}`) + case strings.HasSuffix(r.URL.Path, "/adcreatives"): + _, _ = io.WriteString(w, `{"id":"creative_1"}`) + case strings.HasSuffix(r.URL.Path, "/ads"): + _, _ = io.WriteString(w, `{"id":"ad_1"}`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + // CurrencyOffset intentionally omitted (0): must be derived from the + // preflight currency code. + c := NewClient(Credentials{AccessToken: "t"}, + AccountConfig{AccountID: "act_1", PageID: "100"}, + WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + if _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", Objective: "traffic", + RegistrationURL: "https://x.example.org/e", GeoTargets: []string{"US"}, + Budget: tc.budget, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }); err != nil { + t.Fatalf("CreateCampaign error: %v", err) + } + if got := adsetCap.get()["daily_budget"]; got != tc.wantMinor { + t.Errorf("daily_budget = %v, want %v (preflight currency %s)", got, tc.wantMinor, tc.currency) + } + }) + } +} + +// TestCreateCampaignExplicitOffsetMustMatchPreflightCurrency verifies the +// override-consistency rule: when the preflight returns a recognized currency, an +// explicit AccountConfig.CurrencyOffset that CONFLICTS with that currency's true +// offset is REJECTED before any mutation (a stale override would mis-scale the +// budget), while an override that AGREES with the preflight currency is accepted. +func TestCreateCampaignExplicitOffsetMustMatchPreflightCurrency(t *testing.T) { + newSrv := func(currency string, adsetCap *bodyCapture, postCount *int32) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + _, _ = io.WriteString(w, `{"name":"x","currency":"`+currency+`"}`) + case strings.HasSuffix(r.URL.Path, "/campaigns"): + atomic.AddInt32(postCount, 1) + _, _ = io.WriteString(w, `{"id":"camp_1"}`) + case strings.HasSuffix(r.URL.Path, "/adsets"): + atomic.AddInt32(postCount, 1) + adsetCap.set(decodeBody(t, r)) + _, _ = io.WriteString(w, `{"id":"adset_1"}`) + case strings.HasSuffix(r.URL.Path, "/adcreatives"): + _, _ = io.WriteString(w, `{"id":"creative_1"}`) + case strings.HasSuffix(r.URL.Path, "/ads"): + _, _ = io.WriteString(w, `{"id":"ad_1"}`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + } + input := CampaignInput{ + EventName: "E", Project: "tlf", Objective: "traffic", + RegistrationURL: "https://x.example.org/e", GeoTargets: []string{"US"}, + Budget: 5000, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + } + + // Conflicting override (1) vs a USD account (true offset 100): rejected before + // any POST. + var postCount int32 + srv := newSrv("USD", newBodyCapture(), &postCount) + c := NewClient(Credentials{AccessToken: "t"}, + AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 1}, + WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), input) + if err == nil || !strings.Contains(err.Error(), "conflicts with the account's currency") { + t.Errorf("conflicting override: err = %v, want a currency-conflict rejection", err) + } + if n := atomic.LoadInt32(&postCount); n != 0 { + t.Errorf("conflicting override made %d POSTs, want 0 (reject before mutation)", n) + } + srv.Close() + + // Agreeing override (100) vs a USD account: accepted, budget scaled ×100. + adsetCap := newBodyCapture() + var postCount2 int32 + srv2 := newSrv("USD", adsetCap, &postCount2) + defer srv2.Close() + c2 := NewClient(Credentials{AccessToken: "t"}, + AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, + WithBaseURL(srv2.URL), WithClock(fixedMetaClock())) + if _, err := c2.CreateCampaign(context.Background(), input); err != nil { + t.Fatalf("agreeing override: CreateCampaign error: %v", err) + } + if got := adsetCap.get()["daily_budget"]; got != float64(500000) { + t.Errorf("daily_budget = %v, want 500000 (5000 × offset 100)", got) + } +} + +// TestCreateCampaignRejectsEmptyProjectBeforeAnyPost verifies that an empty or +// whitespace-only Project is rejected during pre-flight validation, before any +// mutating call. The campaign name's Project segment must be the caller-supplied +// canonical LFX slug; silently substituting a placeholder (e.g. "tlf") could +// mis-attribute a non-Linux-Foundation campaign to the wrong project. +func TestCreateCampaignRejectsEmptyProjectBeforeAnyPost(t *testing.T) { + base := func() CampaignInput { + return CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + } + } + for _, project := range []string{"", " "} { + srv := noPostServer(t) + c := NewClient(Credentials{AccessToken: "t"}, + AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, + WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + in := base() + in.Project = project + _, err := c.CreateCampaign(context.Background(), in) + srv.Close() + if err == nil || !strings.Contains(err.Error(), "project is required") { + t.Fatalf("project %q: err = %v, want it to mention Project is required", project, err) + } + } +} + +// TestCreateCampaignRejectsEmptyEventNameBeforeAnyPost verifies that an empty or +// whitespace-only EventName is rejected during pre-flight validation, before any +// mutating call. EventName is the base-name segment of every generated name and +// feeds attribution; a blank value would otherwise create paid resources with an +// empty base-name segment (e.g. " - Traffic") and break attribution. +func TestCreateCampaignRejectsEmptyEventNameBeforeAnyPost(t *testing.T) { + base := func() CampaignInput { + return CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + } + } + for _, eventName := range []string{"", " ", "\t\n"} { + srv := noPostServer(t) + c := NewClient(Credentials{AccessToken: "t"}, + AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, + WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + in := base() + in.EventName = eventName + _, err := c.CreateCampaign(context.Background(), in) + srv.Close() + if err == nil || !strings.Contains(err.Error(), "event name is required") { + t.Fatalf("event name %q: err = %v, want it to mention event name is required", eventName, err) + } + } +} + +func TestCreateCampaignSkipsRegulatedGeos(t *testing.T) { + adsetCap := newBodyCapture() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + _, _ = io.WriteString(w, `{"name":"x"}`) + case strings.HasSuffix(r.URL.Path, "/campaigns"): + _, _ = io.WriteString(w, `{"id":"c1"}`) + case strings.HasSuffix(r.URL.Path, "/adsets"): + adsetCap.set(decodeBody(t, r)) + _, _ = io.WriteString(w, `{"id":"a1"}`) + case strings.HasSuffix(r.URL.Path, "/adcreatives"): + _, _ = io.WriteString(w, `{"id":"cr1"}`) + case strings.HasSuffix(r.URL.Path, "/ads"): + _, _ = io.WriteString(w, `{"id":"ad1"}`) + } + })) + defer srv.Close() + + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US", "SG", "KR"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err != nil { + t.Fatalf("error: %v", err) + } + adsetBody := adsetCap.get() + geo := adsetBody["targeting"].(map[string]any)["geo_locations"].(map[string]any)["countries"].([]any) + if len(geo) != 1 || geo[0] != "US" { + t.Errorf("geo countries = %v, want [US]", geo) + } + if !anyStepContains(res.Steps, "Geo targets skipped") { + t.Errorf("expected a skipped-geo step, got %v", res.Steps) + } +} + +// TestCreateCampaignReportsDroppedIneligibleGeos verifies that when eligible and +// ineligible/sanctioned geos are mixed (US + IR), the dropped ineligible code is +// surfaced in a step — a caller must not silently believe a sanctioned country is +// being targeted. +func TestCreateCampaignReportsDroppedIneligibleGeos(t *testing.T) { + adsetCap := newBodyCapture() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + _, _ = io.WriteString(w, `{"name":"x","currency":"USD"}`) + case strings.HasSuffix(r.URL.Path, "/campaigns"): + _, _ = io.WriteString(w, `{"id":"c1"}`) + case strings.HasSuffix(r.URL.Path, "/adsets"): + adsetCap.set(decodeBody(t, r)) + _, _ = io.WriteString(w, `{"id":"a1"}`) + case strings.HasSuffix(r.URL.Path, "/adcreatives"): + _, _ = io.WriteString(w, `{"id":"cr1"}`) + case strings.HasSuffix(r.URL.Path, "/ads"): + _, _ = io.WriteString(w, `{"id":"ad1"}`) + } + })) + defer srv.Close() + + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US", "IR"}, // IR is sanctioned/ineligible + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err != nil { + t.Fatalf("error: %v", err) + } + geo := adsetCap.get()["targeting"].(map[string]any)["geo_locations"].(map[string]any)["countries"].([]any) + if len(geo) != 1 || geo[0] != "US" { + t.Errorf("geo countries = %v, want [US] (IR dropped)", geo) + } + dropStep := false + for _, s := range res.Steps { + if strings.Contains(s, "Geo targets dropped") && strings.Contains(s, "IR") { + dropStep = true + } + } + if !dropStep { + t.Errorf("expected a step naming the dropped ineligible geo IR, got %v", res.Steps) + } +} + +func TestCreateCampaignAllGeosRegulated(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"name":"x"}`) + })) + defer srv.Close() + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"SG", "KR"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil || !strings.Contains(err.Error(), "supply at least one eligible") { + t.Fatalf("expected regulated-geo error, got %v", err) + } +} + +// --------------------------------------------------------------------------- +// Graph API error body -> Go error mapping +// --------------------------------------------------------------------------- + +func TestGraphAPIErrorMapping(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + _, _ = io.WriteString(w, `{"name":"x"}`) + case strings.HasSuffix(r.URL.Path, "/campaigns"): + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"error":{"message":"Invalid parameter","type":"OAuthException","code":100,"fbtrace_id":"XYZ"}}`) + } + })) + defer srv.Close() + + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil { + t.Fatalf("expected an error") + } + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("error type = %T, want *APIError", err) + } + if apiErr.StatusCode != http.StatusBadRequest { + t.Errorf("status = %d, want 400", apiErr.StatusCode) + } + if apiErr.Message != "Invalid parameter" { + t.Errorf("message = %q, want 'Invalid parameter'", apiErr.Message) + } + // The Graph envelope's diagnostic fields must be preserved so callers can + // distinguish invalid-params vs auth failures and quote Meta's trace id. + if apiErr.Type != "OAuthException" { + t.Errorf("type = %q, want 'OAuthException'", apiErr.Type) + } + if apiErr.Code != 100 { + t.Errorf("code = %d, want 100", apiErr.Code) + } + if apiErr.FBTraceID != "XYZ" { + t.Errorf("fbtrace_id = %q, want 'XYZ'", apiErr.FBTraceID) + } + // ...and they must appear in the error string (fbtrace_id is critical for + // Meta support tickets). + msg := apiErr.Error() + for _, want := range []string{"OAuthException", "code: 100", "fbtrace_id: XYZ"} { + if !strings.Contains(msg, want) { + t.Errorf("error string %q missing %q", msg, want) + } + } +} + +// TestNonGraphErrorBodySurfaces verifies that a non-2xx response whose body is +// NOT a Graph error envelope still surfaces the raw body in the error, rather +// than pointing at nonexistent server logs. A 5xx on the campaign create is +// AMBIGUOUS, so CreateCampaign wraps the *APIError in an UNCONFIRMED partial +// result; the underlying *APIError (and its raw body snippet) must still be +// reachable via errors.As. +func TestNonGraphErrorBodySurfaces(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"name":"x"}`) + case strings.HasSuffix(r.URL.Path, "/campaigns"): + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusBadGateway) + _, _ = io.WriteString(w, "502 Bad Gateway from upstream proxy") + } + })) + defer srv.Close() + + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil { + t.Fatalf("expected an error") + } + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error type = %T, want an unwrappable *APIError", err) + } + if !strings.Contains(apiErr.Message, "502 Bad Gateway from upstream proxy") { + t.Errorf("APIError.Message = %q, want the raw body snippet", apiErr.Message) + } + if !strings.Contains(err.Error(), "502 Bad Gateway from upstream proxy") { + t.Errorf("error string = %q, want the raw body snippet", err.Error()) + } + if strings.Contains(err.Error(), "server logs") { + t.Errorf("error string %q must not reference server logs", err.Error()) + } +} + +// --------------------------------------------------------------------------- +// Non-fatal failure paths (per-variant ad failure; account verification) +// --------------------------------------------------------------------------- + +// TestCreateCampaignPerVariantFailureIsNonFatal verifies that when the FIRST +// variant's creative call fails, the campaign still succeeds, a later variant is +// still created, AdCount counts only the successes, and the failure is recorded +// as a step. +func TestCreateCampaignPerVariantFailureIsNonFatal(t *testing.T) { + var creativeCalls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + _, _ = io.WriteString(w, `{"name":"x"}`) + case strings.HasSuffix(r.URL.Path, "/campaigns"): + _, _ = io.WriteString(w, `{"id":"camp_1"}`) + case strings.HasSuffix(r.URL.Path, "/adsets"): + _, _ = io.WriteString(w, `{"id":"adset_1"}`) + case strings.HasSuffix(r.URL.Path, "/adcreatives"): + // Fail the first creative; succeed on all subsequent ones. + if atomic.AddInt32(&creativeCalls, 1) == 1 { + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"error":{"message":"bad creative"}}`) + return + } + _, _ = io.WriteString(w, `{"id":"creative_ok"}`) + case strings.HasSuffix(r.URL.Path, "/ads"): + _, _ = io.WriteString(w, `{"id":"ad_ok"}`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{ + {PrimaryText: "p1", Headline: "h1"}, + {PrimaryText: "p2", Headline: "h2"}, + }, + }) + if err != nil { + t.Fatalf("CreateCampaign should not fail when one variant fails: %v", err) + } + if res.CampaignID != "camp_1" { + t.Errorf("campaign id = %q, want camp_1", res.CampaignID) + } + if res.AdCount != 1 { + t.Errorf("ad count = %d, want 1 (only the second variant succeeds)", res.AdCount) + } + if !anyStepContains(res.Steps, "Ad 1 failed") { + t.Errorf("expected an 'Ad 1 failed' step, got %v", res.Steps) + } + if !anyStepContains(res.Steps, "Ad 2 created") { + t.Errorf("expected an 'Ad 2 created' step, got %v", res.Steps) + } +} + +// TestCreateCampaignContextCancelDuringAdsIsFatal verifies that a cancelled +// CALLER context observed while creating a variant ad is FATAL: CreateCampaign +// must return an error rather than reporting a "successful" result after its +// context died. The decision keys off the caller ctx (ctx.Err()), not errors.Is +// on the returned error, so this test cancels the CALLER ctx directly. +func TestCreateCampaignContextCancelDuringAdsIsFatal(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + // A RoundTripper that succeeds for the account GET, campaign, and ad-set calls + // but, on the first /adcreatives call, cancels the caller ctx and returns the + // context.Canceled error that c.httpClient.Do surfaces mid-flight. Deterministic + // and non-blocking (no server goroutine to drain). + rt := roundTripFunc(func(req *http.Request) (*http.Response, error) { + if strings.HasSuffix(req.URL.Path, "/adcreatives") { + cancel() + return nil, fmt.Errorf("Post %q: %w", req.URL.String(), context.Canceled) + } + body := `{"id":"x"}` + switch { + case req.Method == http.MethodGet: + body = `{"name":"x"}` + case strings.HasSuffix(req.URL.Path, "/campaigns"): + body = `{"id":"camp_1"}` + case strings.HasSuffix(req.URL.Path, "/adsets"): + body = `{"id":"adset_1"}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + }) + defer cancel() + + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, + WithBaseURL("http://meta.test"), WithHTTPClient(&http.Client{Transport: rt}), WithClock(fixedMetaClock())) + res, err := c.CreateCampaign(ctx, CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil { + t.Fatalf("expected error after context cancellation, got success: %+v", res) + } + // A caller-cancel during ad creation is still fatal (error returned), but the + // campaign + ad set already exist, so a partial result must carry their IDs for + // cleanup/reconcile rather than being discarded. + if res == nil { + t.Fatal("expected a non-nil partial result carrying the created campaign/ad set IDs, got nil") + } + if res.CampaignID != "camp_1" { + t.Errorf("partial result CampaignID = %q, want camp_1", res.CampaignID) + } + if res.AdSetID != "adset_1" { + t.Errorf("partial result AdSetID = %q, want adset_1", res.AdSetID) + } + if !errors.Is(err, context.Canceled) { + t.Errorf("err = %v, want it to wrap context.Canceled", err) + } +} + +// TestCreateCampaignContextCancelAfterCreativeSurfacesOrphan verifies that when +// the caller ctx is cancelled DURING the /ads call — after the adcreative was +// already created — the fatal error names the orphaned creative id, so the +// known paid-adjacent resource isn't silently lost (the non-fatal path already +// reports it; the fatal ctx-cancel path must too). +func TestCreateCampaignContextCancelAfterCreativeSurfacesOrphan(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + // Succeed through the adcreative (returning a real id), then cancel the caller + // ctx and fail the /ads call — mirroring http.Client.Do surfacing the cancel. + rt := roundTripFunc(func(req *http.Request) (*http.Response, error) { + if strings.HasSuffix(req.URL.Path, "/ads") { + cancel() + return nil, fmt.Errorf("Post %q: %w", req.URL.String(), context.Canceled) + } + body := `{"id":"x"}` + switch { + case req.Method == http.MethodGet: + body = `{"name":"x"}` + case strings.HasSuffix(req.URL.Path, "/campaigns"): + body = `{"id":"camp_1"}` + case strings.HasSuffix(req.URL.Path, "/adsets"): + body = `{"id":"adset_1"}` + case strings.HasSuffix(req.URL.Path, "/adcreatives"): + body = `{"id":"creative_orphan_9"}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + }) + defer cancel() + + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, + WithBaseURL("http://meta.test"), WithHTTPClient(&http.Client{Transport: rt}), WithClock(fixedMetaClock())) + res, err := c.CreateCampaign(ctx, CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil { + t.Fatalf("expected error after context cancellation, got success: %+v", res) + } + // Fatal (error returned), but campaign + ad set already exist, so a partial + // result must carry their IDs. The orphaned creative id has no CampaignResult + // field, so it stays surfaced in the error string (asserted below). + if res == nil { + t.Fatal("expected a non-nil partial result carrying the created campaign/ad set IDs, got nil") + } + if res.CampaignID != "camp_1" { + t.Errorf("partial result CampaignID = %q, want camp_1", res.CampaignID) + } + if res.AdSetID != "adset_1" { + t.Errorf("partial result AdSetID = %q, want adset_1", res.AdSetID) + } + if !errors.Is(err, context.Canceled) { + t.Errorf("err = %v, want it to wrap context.Canceled", err) + } + if !strings.Contains(err.Error(), "creative_orphan_9") { + t.Errorf("err = %v, want it to name the orphaned creative id creative_orphan_9", err) + } +} + +// TestCreateCampaignPerCreativeTimeoutIsNonFatal verifies that a per-creative +// request failing with a DeadlineExceeded-like error while the CALLER context is +// still live stays NON-fatal: the campaign still returns and the failure is +// recorded as a warning step. This is the client's own http.Client.Timeout case — +// it must NOT abort the whole campaign (contrast with the caller-cancel test). +// The timeout is an AMBIGUOUS outcome (the creative MAY have been created), so +// the step is worded UNCONFIRMED rather than a definite "failed / create +// manually" that could duplicate the creative. +func TestCreateCampaignPerCreativeTimeoutIsNonFatal(t *testing.T) { + // The caller ctx is never cancelled. The first /adcreatives call fails with a + // url error wrapping context.DeadlineExceeded — exactly what http.Client.Timeout + // surfaces — but ctx.Err() stays nil, so it must be treated as an ordinary + // per-creative failure. + rt := roundTripFunc(func(req *http.Request) (*http.Response, error) { + if strings.HasSuffix(req.URL.Path, "/adcreatives") { + return nil, &url.Error{ + Op: "Post", + URL: req.URL.String(), + Err: fmt.Errorf("net/http: request canceled (Client.Timeout exceeded while awaiting headers): %w", context.DeadlineExceeded), + } + } + body := `{"id":"x"}` + switch { + case req.Method == http.MethodGet: + body = `{"name":"x"}` + case strings.HasSuffix(req.URL.Path, "/campaigns"): + body = `{"id":"camp_1"}` + case strings.HasSuffix(req.URL.Path, "/adsets"): + body = `{"id":"adset_1"}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + }) + + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, + WithBaseURL("http://meta.test"), WithHTTPClient(&http.Client{Transport: rt}), WithClock(fixedMetaClock())) + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err != nil { + t.Fatalf("per-creative timeout with a live caller ctx must be non-fatal: %v", err) + } + if res == nil { + t.Fatalf("expected a campaign result, got nil") + } + if res.CampaignID != "camp_1" { + t.Errorf("campaign id = %q, want camp_1", res.CampaignID) + } + // The creative failed, so no ad was created, but the campaign still returns. + if res.AdCount != 0 { + t.Errorf("ad count = %d, want 0 (the only creative timed out)", res.AdCount) + } + if !anyStepContains(res.Steps, "Ad/creative creation outcome UNCONFIRMED for variant 1") { + t.Errorf("expected an UNCONFIRMED warning step for the timed-out creative, got %v", res.Steps) + } +} + +// TestCreateCampaignAccountVerificationFailureIsNonFatal verifies that a failing +// account-verification GET does not abort creation: the campaign is still created +// and a warning step is recorded. +func TestCreateCampaignAccountVerificationFailureIsNonFatal(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + // Account verification fails. + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"error":{"message":"account lookup failed"}}`) + case strings.HasSuffix(r.URL.Path, "/campaigns"): + _, _ = io.WriteString(w, `{"id":"camp_1"}`) + case strings.HasSuffix(r.URL.Path, "/adsets"): + _, _ = io.WriteString(w, `{"id":"adset_1"}`) + case strings.HasSuffix(r.URL.Path, "/adcreatives"): + _, _ = io.WriteString(w, `{"id":"creative_1"}`) + case strings.HasSuffix(r.URL.Path, "/ads"): + _, _ = io.WriteString(w, `{"id":"ad_1"}`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", Label: "LF Core", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err != nil { + t.Fatalf("account verification failure must be non-fatal: %v", err) + } + if res.CampaignID != "camp_1" { + t.Errorf("campaign id = %q, want camp_1", res.CampaignID) + } + if res.AdCount != 1 { + t.Errorf("ad count = %d, want 1", res.AdCount) + } + if !anyStepContains(res.Steps, "Account preflight warning") { + t.Errorf("expected an 'Account preflight warning' step, got %v", res.Steps) + } +} + +// TestCreateCampaignNormalizesObjective verifies that a padded / mixed-case +// Objective is trimmed and lowercased so it resolves like the canonical value +// instead of failing the objectiveParams lookup as "unknown", and that a +// whitespace-only Objective defaults to traffic. +func TestCreateCampaignNormalizesObjective(t *testing.T) { + cases := []struct { + name string + objective string + wantCampaign string + wantOptGoal string + }{ + {"padded lowercase", " traffic ", "OUTCOME_TRAFFIC", "LINK_CLICKS"}, + {"mixed case", "AwArEnEsS", "OUTCOME_AWARENESS", "REACH"}, + {"whitespace only defaults to traffic", " ", "OUTCOME_TRAFFIC", "LINK_CLICKS"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + campaignCap := newBodyCapture() + adsetCap := newBodyCapture() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + _, _ = io.WriteString(w, `{"name":"x","currency":"USD"}`) + case strings.HasSuffix(r.URL.Path, "/campaigns"): + campaignCap.set(decodeBody(t, r)) + _, _ = io.WriteString(w, `{"id":"camp_1"}`) + case strings.HasSuffix(r.URL.Path, "/adsets"): + adsetCap.set(decodeBody(t, r)) + _, _ = io.WriteString(w, `{"id":"adset_1"}`) + case strings.HasSuffix(r.URL.Path, "/adcreatives"): + _, _ = io.WriteString(w, `{"id":"creative_1"}`) + case strings.HasSuffix(r.URL.Path, "/ads"): + _, _ = io.WriteString(w, `{"id":"ad_1"}`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, + WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", Objective: tc.objective, + RegistrationURL: "https://x.example.org/e", GeoTargets: []string{"US"}, + Budget: 10, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err != nil { + t.Fatalf("objective %q should normalize and succeed, got err = %v", tc.objective, err) + } + if got := campaignCap.get()["objective"]; got != tc.wantCampaign { + t.Errorf("campaign objective = %v, want %v", got, tc.wantCampaign) + } + if got := adsetCap.get()["optimization_goal"]; got != tc.wantOptGoal { + t.Errorf("optimization_goal = %v, want %v", got, tc.wantOptGoal) + } + }) + } +} + +// TestCreateCampaignRejectsInactiveAccountBeforeAnyPost verifies that a successful +// preflight reporting a known-inactive account_status (e.g. 2 = disabled) fails +// BEFORE any mutating call, rather than creating a paid campaign that Meta would +// reject at a later step. account_status is fetched during the preflight, so a +// successful GET alone must not be treated as "verified/active". +func TestCreateCampaignRejectsInactiveAccountBeforeAnyPost(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + t.Errorf("unexpected POST to %s: an inactive account must fail before mutation", r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"name":"x","account_status":2,"currency":"USD"}`) + })) + defer srv.Close() + + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, + WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, Budget: 10, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil || !strings.Contains(err.Error(), "not active") { + t.Fatalf("err = %v, want inactive-account rejection before mutation", err) + } +} + +// TestCreateCampaignAcceptsAnyActiveAccountStatus verifies account_status 201 +// (ANY_ACTIVE — a Meta aggregate, not a per-account inactive state) is NOT +// treated as inactive, so the campaign proceeds. +func TestCreateCampaignAcceptsAnyActiveAccountStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + _, _ = io.WriteString(w, `{"name":"x","account_status":201,"currency":"USD"}`) + case strings.HasSuffix(r.URL.Path, "/campaigns"): + _, _ = io.WriteString(w, `{"id":"c1"}`) + case strings.HasSuffix(r.URL.Path, "/adsets"): + _, _ = io.WriteString(w, `{"id":"a1"}`) + case strings.HasSuffix(r.URL.Path, "/adcreatives"): + _, _ = io.WriteString(w, `{"id":"cr1"}`) + case strings.HasSuffix(r.URL.Path, "/ads"): + _, _ = io.WriteString(w, `{"id":"ad1"}`) + } + })) + defer srv.Close() + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + if _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, Budget: 10, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }); err != nil { + t.Errorf("account_status 201 (ANY_ACTIVE) must be accepted, got error: %v", err) + } +} + +// TestCreateCampaignRejectsOverlongCreativeNameBeforeAnyPost verifies an +// EventName long enough to push the composed creative name (" - +// Variant N") past Meta's 255-char cap is rejected before any POST — it must not +// create the campaign + ad set and then fail at every creative. +func TestCreateCampaignRejectsOverlongCreativeNameBeforeAnyPost(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + t.Errorf("no POST should happen for an over-long creative name: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"name":"x","currency":"USD"}`) + })) + defer srv.Close() + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: strings.Repeat("E", 260), Project: "tlf", RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, Budget: 10, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil || !strings.Contains(err.Error(), "ad-creative name") { + t.Fatalf("err = %v, want ad-creative-name length rejection before mutation", err) + } +} + +// TestValidateRegistrationURLRejectsMalformedQuery verifies a registration URL +// whose query can't be cleanly parsed (so u.Query() would silently drop a param) +// is rejected up front. +func TestValidateRegistrationURLRejectsMalformedQuery(t *testing.T) { + if err := validateRegistrationURL("https://x.example.org/e?a=%zz"); err == nil { + t.Error("malformed-query registration URL should be rejected") + } + if err := validateRegistrationURL("https://x.example.org/e?a=b&c=d"); err != nil { + t.Errorf("well-formed query should pass, got %v", err) + } +} + +// --------------------------------------------------------------------------- +// Input validation errors +// --------------------------------------------------------------------------- + +func TestCreateCampaignValidation(t *testing.T) { + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL("http://unused.invalid"), WithClock(fixedMetaClock())) + base := CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + } + + tests := []struct { + name string + mutate func(*CampaignInput) + want string + }{ + {"no variants", func(in *CampaignInput) { in.Variants = nil }, "at least one ad variant"}, + {"empty variants", func(in *CampaignInput) { in.Variants = []AdVariant{{PrimaryText: " ", Headline: ""}} }, "non-empty primary text"}, + {"bad url", func(in *CampaignInput) { in.RegistrationURL = "http://x.example" }, "must use HTTPS"}, + {"bad budget", func(in *CampaignInput) { in.Budget = 0 }, "positive number"}, + {"sub-cent budget rounds to zero", func(in *CampaignInput) { in.Budget = 0.001 }, "budget too small"}, + {"bad start date", func(in *CampaignInput) { in.StartDate = "2026/08/01" }, "invalid start date"}, + {"impossible calendar date", func(in *CampaignInput) { in.StartDate = "2026-13-40" }, "invalid start date"}, + {"end before start", func(in *CampaignInput) { in.EndDate = "2026-07-01" }, "must be after start date"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + in := base + tc.mutate(&in) + _, err := c.CreateCampaign(context.Background(), in) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("err = %v, want containing %q", err, tc.want) + } + }) + } +} + +// noPostServer returns an httptest server that fails the test if it ever +// receives a POST (a mutating call). GETs return a benign body so account +// verification succeeds. +func noPostServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + t.Errorf("unexpected POST (mutating call) to %s: input validation should have failed first", r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"name":"x"}`) + })) +} + +func TestCreateCampaignRejectsSubCentBudgetBeforeAnyPost(t *testing.T) { + srv := noPostServer(t) + defer srv.Close() + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 0.001, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil || !strings.Contains(err.Error(), "budget too small") { + t.Fatalf("err = %v, want 'budget too small'", err) + } +} + +// TestCreateCampaignRejectsOverLimitCopyBeforeAnyPost verifies that a variant +// whose copy exceeds Meta's per-field character limits is rejected during +// pre-flight validation, before any mutating call — so over-limit copy fails +// fast rather than after a paid campaign/ad-set already exists (the creative +// call is non-fatal and would otherwise leave an orphaned paid campaign). +func TestCreateCampaignRejectsOverLimitCopyBeforeAnyPost(t *testing.T) { + base := func() CampaignInput { + return CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + } + } + cases := []struct { + name string + mutate func(in *CampaignInput) + wantSub string + }{ + {"over-limit primary text", func(in *CampaignInput) { + in.Variants[0].PrimaryText = strings.Repeat("a", maxPrimaryTextChars+1) + }, "primary text"}, + {"over-limit headline", func(in *CampaignInput) { + in.Variants[0].Headline = strings.Repeat("h", maxHeadlineChars+1) + }, "headline"}, + {"over-limit description", func(in *CampaignInput) { + in.Variants[0].Description = strings.Repeat("d", maxDescriptionChars+1) + }, "description"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := noPostServer(t) + defer srv.Close() + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + in := base() + tc.mutate(&in) + _, err := c.CreateCampaign(context.Background(), in) + if err == nil || !strings.Contains(err.Error(), tc.wantSub) { + t.Fatalf("err = %v, want it to mention %q", err, tc.wantSub) + } + }) + } +} + +// TestCreateCampaignAtLimitCopyAllowed verifies that copy exactly at the limit +// (and multi-byte runes counted by rune, not byte) passes validation. +func TestCreateCampaignAtLimitCopyAllowed(t *testing.T) { + // posts is written by the httptest handler goroutine and read by the test + // goroutine below, so it must be accessed atomically to stay race-free under + // `go test -race`. + var posts int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost { + atomic.AddInt32(&posts, 1) + _, _ = io.WriteString(w, `{"id":"x"}`) + return + } + _, _ = io.WriteString(w, `{"name":"x"}`) + })) + defer srv.Close() + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + // Use multi-byte runes to prove the check counts runes, not bytes: a headline + // of maxHeadlineChars 'é' runes is 2*maxHeadlineChars bytes but still valid. + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{ + PrimaryText: strings.Repeat("a", maxPrimaryTextChars), + Headline: strings.Repeat("é", maxHeadlineChars), + Description: strings.Repeat("d", maxDescriptionChars), + }}, + }) + if err != nil { + t.Fatalf("at-limit copy should be accepted, got err = %v", err) + } + if atomic.LoadInt32(&posts) == 0 { + t.Errorf("expected mutating calls to proceed for at-limit copy") + } +} + +func TestCreateCampaignAllDisabledPlacementsMakesZeroPosts(t *testing.T) { + srv := noPostServer(t) + defer srv.Close() + f := false + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Placements: Placement{FacebookFeed: &f, InstagramFeed: &f}, + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil || !strings.Contains(err.Error(), "at least one placement") { + t.Fatalf("err = %v, want 'at least one placement'", err) + } +} + +func TestCreateCampaignRequiresPageIDBeforeAnyPost(t *testing.T) { + srv := noPostServer(t) + defer srv.Close() + // PageID intentionally left empty. + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil || !strings.Contains(err.Error(), "PageID is required") { + t.Fatalf("err = %v, want 'PageID is required'", err) + } +} + +func TestCreateCampaignRequiresAccountIDBeforeAnyPost(t *testing.T) { + srv := noPostServer(t) + defer srv.Close() + // AccountID intentionally left empty; an empty ID would build "//campaigns". + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil || !strings.Contains(err.Error(), "AccountID is required") { + t.Fatalf("err = %v, want 'AccountID is required'", err) + } +} + +func TestCreateCampaignImpossibleDateMakesZeroPosts(t *testing.T) { + srv := noPostServer(t) + defer srv.Close() + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 10, + StartDate: "2026-13-40", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil || !strings.Contains(err.Error(), "invalid start date") { + t.Fatalf("err = %v, want 'invalid start date'", err) + } +} + +// TestCreateCampaignRejectsPortOnlyURLBeforeAnyPost verifies a port-only / +// empty-hostname destination URL is rejected at preflight, before any mutating +// call — so no campaign or ad set is created for an unreachable destination. +func TestCreateCampaignRejectsPortOnlyURLBeforeAnyPost(t *testing.T) { + srv := noPostServer(t) + defer srv.Close() + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://:443", + GeoTargets: []string{"US"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil || !strings.Contains(err.Error(), "not a valid URL") { + t.Fatalf("err = %v, want 'not a valid URL'", err) + } +} + +// TestCreateCampaignAdSetFailureReportsOrphanCampaignID verifies that when the +// ad-set call fails AFTER the campaign was created, the returned error carries +// the created (PAUSED) campaign id so the caller can identify the orphan. +func TestCreateCampaignAdSetFailureReportsOrphanCampaignID(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + _, _ = io.WriteString(w, `{"name":"x"}`) + case strings.HasSuffix(r.URL.Path, "/campaigns"): + _, _ = io.WriteString(w, `{"id":"camp_orphan"}`) + case strings.HasSuffix(r.URL.Path, "/adsets"): + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"error":{"message":"bad ad set"}}`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil { + t.Fatalf("expected an error when the ad set fails") + } + if !strings.Contains(err.Error(), "camp_orphan") { + t.Errorf("error = %q, want it to mention the orphaned campaign id camp_orphan", err.Error()) + } + if !strings.Contains(err.Error(), "PAUSED") { + t.Errorf("error = %q, want it to note the campaign is PAUSED", err.Error()) + } +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// decodeBody decodes a JSON request body inside an httptest handler goroutine. +// It uses t.Errorf (not t.Fatalf): FailNow/Fatalf must only be called from the +// test goroutine, so a malformed payload records a failure and returns an empty +// map rather than trying to abort from the wrong goroutine. +func decodeBody(t *testing.T, r *http.Request) map[string]any { + t.Helper() + var m map[string]any + if err := json.NewDecoder(r.Body).Decode(&m); err != nil { + t.Errorf("decode body: %v", err) + return map[string]any{} + } + return m +} + +// bodyCapture provides a race-free handoff of a request body decoded inside an +// httptest handler goroutine to the test goroutine. The handler calls set(); the +// test calls get() after CreateCampaign returns. The buffered channel creates the +// happens-before edge that -race requires (the send in the handler happens-before +// the receive in the test), and buffering keeps the handler from blocking if the +// body is captured more than once. +type bodyCapture struct { + ch chan map[string]any +} + +func newBodyCapture() *bodyCapture { + // Buffer generously so a handler never blocks even if the endpoint is hit + // more than once; get() drains to the most recent value. + return &bodyCapture{ch: make(chan map[string]any, 16)} +} + +func (b *bodyCapture) set(m map[string]any) { b.ch <- m } + +// get returns the most recently captured body, or nil if nothing was captured. +// It must be called from the test goroutine after the request(s) have completed. +func (b *bodyCapture) get() map[string]any { + var last map[string]any + for { + select { + case m := <-b.ch: + last = m + default: + return last + } + } +} + +func contains(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} + +func anyStepContains(steps []string, sub string) bool { + for _, s := range steps { + if strings.Contains(s, sub) { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// 429 rate-limit retry/backoff +// --------------------------------------------------------------------------- + +// TestDoRequestRetriesOn429 verifies that a 429 followed by a 200 is retried and +// ultimately succeeds. A short Retry-After keeps the test fast. +func TestDoRequestRetriesOn429(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if atomic.AddInt32(&calls, 1) == 1 { + w.Header().Set("Retry-After", "0") // 0 -> falls back to base backoff + w.WriteHeader(http.StatusTooManyRequests) + _, _ = io.WriteString(w, `{"error":{"message":"rate limited"}}`) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"123"}`) + })) + defer srv.Close() + + // Shrink the base backoff so the fallback wait doesn't slow the test. + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1"}, + WithBaseURL(srv.URL), withRetryBaseDelay(time.Millisecond)) + var out createResponse + if err := c.doRequest(context.Background(), http.MethodPost, "/x", map[string]any{"k": "v"}, &out); err != nil { + t.Fatalf("doRequest: %v", err) + } + if out.ID != "123" { + t.Errorf("id = %q, want 123", out.ID) + } + if got := atomic.LoadInt32(&calls); got != 2 { + t.Errorf("server calls = %d, want 2 (one 429 + one success)", got) + } +} + +// TestDoRequestAbortsOnOverCapRetryAfter verifies that a server-declared +// Retry-After exceeding maxRetryWait ABORTS immediately (does not clamp and retry +// early while Meta is still throttling), issuing exactly one request. +func TestDoRequestAbortsOnOverCapRetryAfter(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Header().Set("Retry-After", "600") // 10 min, well over maxRetryWait + w.WriteHeader(http.StatusTooManyRequests) + _, _ = io.WriteString(w, `{"error":{"message":"rate limited"}}`) + })) + defer srv.Close() + + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1"}, + WithBaseURL(srv.URL), withRetryBaseDelay(time.Millisecond)) + var out createResponse + err := c.doRequest(context.Background(), http.MethodPost, "/x", map[string]any{"k": "v"}, &out) + if err == nil { + t.Fatal("expected a rate-limit abort error on an over-cap Retry-After") + } + if got := atomic.LoadInt32(&calls); got != 1 { + t.Errorf("server calls = %d, want 1 (abort, not clamp+retry)", got) + } +} + +// TestDoRequestExhaustsRetries verifies that persistent 429s return an error +// after retryMax attempts rather than looping forever. +func TestDoRequestExhaustsRetries(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusTooManyRequests) + })) + defer srv.Close() + + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1"}, + WithBaseURL(srv.URL), withRetryBaseDelay(time.Millisecond)) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + err := c.doRequest(ctx, http.MethodGet, "/x", nil, nil) + if err == nil { + t.Fatalf("expected an error after exhausting retries") + } + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("error type = %T, want *APIError", err) + } + if apiErr.StatusCode != http.StatusTooManyRequests { + t.Errorf("status = %d, want 429", apiErr.StatusCode) + } + // 1 initial + retryMax retries = retryMax+1 total server hits. + if got := atomic.LoadInt32(&calls); got != int32(retryMax+1) { + t.Errorf("server calls = %d, want %d", got, retryMax+1) + } +} + +// TestParseRetryAfter covers the header parsing paths: delay-seconds, HTTP-date, +// and absent/invalid headers. +func TestParseRetryAfter(t *testing.T) { + fixed := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "a"}, + WithClock(func() time.Time { return fixed })) + + tests := []struct { + name string + header string + want time.Duration + }{ + {"delay seconds", "5", 5 * time.Second}, + {"zero -> none", "0", 0}, + {"negative -> none", "-3", 0}, + {"http-date future", fixed.Add(10 * time.Second).UTC().Format(http.TimeFormat), 10 * time.Second}, + {"http-date past -> none", fixed.Add(-10 * time.Second).UTC().Format(http.TimeFormat), 0}, + {"absent -> none", "", 0}, + {"garbage -> none", "soon", 0}, + // A huge delay-seconds value must be CLAMPED to just over maxRetryWait, not + // multiplied (which overflows time.Duration NEGATIVE and would make the + // caller retry far too early). 10_000_000_000s is well past the ~9.2e9-second + // wrap threshold. + {"huge delay clamps just over max", "10000000000", maxRetryWait + time.Second}, + // Exactly at the ceiling is allowed through as-is (not spuriously clamped). + {"delay exactly at max wait", strconv.FormatInt(int64(maxRetryWait/time.Second), 10), maxRetryWait}, + // A far-future HTTP-date is clamped the same way rather than returning an + // enormous positive duration. + {"far-future http-date clamps just over max", fixed.Add(365 * 24 * time.Hour).UTC().Format(http.TimeFormat), maxRetryWait + time.Second}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := &http.Response{Header: http.Header{}} + if tt.header != "" { + resp.Header.Set("Retry-After", tt.header) + } + if got := c.parseRetryAfter(resp); got != tt.want { + t.Errorf("parseRetryAfter(%q) = %v, want %v", tt.header, got, tt.want) + } + }) + } +} + +// TestDoRequestRetryHonorsContextCancel verifies that a cancelled context during +// 429 backoff aborts promptly rather than sleeping out the full delay. +func TestDoRequestRetryHonorsContextCancel(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "30") // long enough that cancel must win + w.WriteHeader(http.StatusTooManyRequests) + })) + defer srv.Close() + + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "a"}, WithBaseURL(srv.URL)) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + start := time.Now() + err := c.doRequest(ctx, http.MethodGet, "/x", nil, nil) + if err == nil { + t.Fatalf("expected a context error") + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Errorf("doRequest blocked %v; should have aborted on cancel", elapsed) + } +} + +// TestCreateCampaignSupportsLeadsObjective verifies the leads objective creates an +// interim website-traffic campaign: OUTCOME_TRAFFIC optimizing for LINK_CLICKS to +// the registration URL, with no promoted object and no pixel/lead form required. +func TestCreateCampaignSupportsLeadsObjective(t *testing.T) { + campaignCap := newBodyCapture() + adsetCap := newBodyCapture() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + _, _ = io.WriteString(w, `{"name":"x"}`) + case strings.HasSuffix(r.URL.Path, "/campaigns"): + campaignCap.set(decodeBody(t, r)) + _, _ = io.WriteString(w, `{"id":"camp_1"}`) + case strings.HasSuffix(r.URL.Path, "/adsets"): + adsetCap.set(decodeBody(t, r)) + _, _ = io.WriteString(w, `{"id":"adset_1"}`) + case strings.HasSuffix(r.URL.Path, "/adcreatives"): + _, _ = io.WriteString(w, `{"id":"creative_1"}`) + case strings.HasSuffix(r.URL.Path, "/ads"): + _, _ = io.WriteString(w, `{"id":"ad_1"}`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + Objective: "leads", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err != nil { + t.Fatalf("leads objective must be supported: %v", err) + } + if res.AdCount != 1 { + t.Errorf("ad count = %d, want 1", res.AdCount) + } + campaignBody := campaignCap.get() + adsetBody := adsetCap.get() + if campaignBody["objective"] != "OUTCOME_TRAFFIC" { + t.Errorf("campaign objective = %v, want OUTCOME_TRAFFIC", campaignBody["objective"]) + } + if adsetBody["optimization_goal"] != "LINK_CLICKS" { + t.Errorf("optimization_goal = %v, want LINK_CLICKS", adsetBody["optimization_goal"]) + } + // Website-leads needs no promoted object (no lead form / no pixel). + if _, ok := adsetBody["promoted_object"]; ok { + t.Errorf("leads ad set unexpectedly carried a promoted_object: %v", adsetBody["promoted_object"]) + } +} + +// TestValidateGeoTargetsExcludesNonTargetableTerritories verifies that ISO codes +// for uninhabited / non-targetable territories (AQ/BV/HM/TF/GS/UM) are dropped even +// though they are assigned ISO 3166-1 codes — they are not Meta ad-geolocation +// countries, so admitting them would create a campaign that then fails at the +// ad-set step. +func TestValidateGeoTargetsExcludesNonTargetableTerritories(t *testing.T) { + got := validateGeoTargets([]string{"US", "AQ", "BV", "HM", "TF", "GS", "UM", "DE"}) + for _, bad := range []string{"AQ", "BV", "HM", "TF", "GS", "UM"} { + if contains(got, bad) { + t.Errorf("non-targetable territory %s leaked into %v", bad, got) + } + } + if !contains(got, "US") || !contains(got, "DE") { + t.Errorf("valid countries dropped: %v", got) + } +} + +// TestValidateGeoTargetsRejectsBogusISO verifies that a well-shaped but +// non-existent code (XX) is dropped and does not reach Meta. +func TestValidateGeoTargetsRejectsBogusISO(t *testing.T) { + got := validateGeoTargets([]string{"XX", "ZZ"}) + // All inputs invalid -> defaults to US, and the bogus codes are absent. + if len(got) != 1 || got[0] != "US" { + t.Errorf("validateGeoTargets(XX,ZZ) = %v, want [US] (bogus codes dropped)", got) + } + if contains(got, "XX") || contains(got, "ZZ") { + t.Errorf("bogus codes leaked into %v", got) + } + // A mix keeps the real one, drops the bogus one. + got2 := validateGeoTargets([]string{"DE", "XX"}) + if len(got2) != 1 || got2[0] != "DE" { + t.Errorf("validateGeoTargets(DE,XX) = %v, want [DE]", got2) + } +} + +// TestDoRequestRetriesOnGraphThrottleCode verifies a 400 with a Graph rate-limit +// envelope code is retried like a 429 and ultimately succeeds — covering both a +// classic app-level code (4) and the Marketing API account/business-use-case +// throttling code (80004), which Meta also reports over HTTP 400. +func TestDoRequestRetriesOnGraphThrottleCode(t *testing.T) { + for _, code := range []int{4, 80004} { + t.Run(fmt.Sprintf("code %d", code), func(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if atomic.AddInt32(&calls, 1) == 1 { + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, fmt.Sprintf(`{"error":{"message":"rate limited","code":%d}}`, code)) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"123"}`) + })) + defer srv.Close() + + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1"}, + WithBaseURL(srv.URL), withRetryBaseDelay(time.Millisecond)) + var out createResponse + if err := c.doRequest(context.Background(), http.MethodPost, "/x", map[string]any{"k": "v"}, &out); err != nil { + t.Fatalf("doRequest: %v", err) + } + if out.ID != "123" { + t.Errorf("id = %q, want 123", out.ID) + } + if got := atomic.LoadInt32(&calls); got != 2 { + t.Errorf("server calls = %d, want 2 (one throttled 400 + one success)", got) + } + }) + } +} + +// TestCreateCampaignRejectsPastStartDate verifies a start date before today is +// rejected before any mutating call. +func TestCreateCampaignRejectsPastStartDate(t *testing.T) { + srv := noPostServer(t) + defer srv.Close() + // Pin the clock so "past" is deterministic. + now := func() time.Time { return time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC) } + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, + WithBaseURL(srv.URL), WithClock(now)) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 10, + StartDate: "2026-08-01", // before the pinned "today" of 2026-08-15 + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil || !strings.Contains(err.Error(), "past") { + t.Fatalf("err = %v, want past-start-date rejection", err) + } +} + +// TestCreateCampaignRejectsHugeBudget verifies an overflow-scale budget is +// rejected before any mutating call. There is no fixed major-unit cap anymore, so +// the offset-aware overflow guard is the one that must catch it: at offset 100 a +// 1e18 budget scales to 1e20, well past int64, and must be rejected. +func TestCreateCampaignRejectsHugeBudget(t *testing.T) { + srv := noPostServer(t) + defer srv.Close() + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 1e18, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil || !strings.Contains(err.Error(), "exceeds the representable minor-unit range") { + t.Fatalf("err = %v, want offset-aware overflow rejection", err) + } +} + +// TestCreateCampaignAcceptsLargeLowValueCurrencyBudget verifies that removing the +// fixed major-unit cap lets a valid budget in a low-value, zero-decimal currency +// (VND, offset 1) through: 100,000,001 VND is only a few thousand USD-equivalent +// but exceeded the old 100M major-unit cap. With offset 1 it stays 100,000,001 +// minor units — well inside int64 — so it must be ACCEPTED. +func TestCreateCampaignAcceptsLargeLowValueCurrencyBudget(t *testing.T) { + adsetCap := newBodyCapture() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + // VND account: zero-decimal, offset 1. + _, _ = io.WriteString(w, `{"name":"x","currency":"VND"}`) + case strings.HasSuffix(r.URL.Path, "/campaigns"): + _, _ = io.WriteString(w, `{"id":"camp_1"}`) + case strings.HasSuffix(r.URL.Path, "/adsets"): + adsetCap.set(decodeBody(t, r)) + _, _ = io.WriteString(w, `{"id":"adset_1"}`) + case strings.HasSuffix(r.URL.Path, "/adcreatives"): + _, _ = io.WriteString(w, `{"id":"creative_1"}`) + case strings.HasSuffix(r.URL.Path, "/ads"): + _, _ = io.WriteString(w, `{"id":"ad_1"}`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + // CurrencyOffset omitted (0): derived from the VND preflight code (offset 1). + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100"}, WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + Objective: "traffic", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 100_000_001, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err != nil { + t.Fatalf("a large but sane VND budget must be accepted, got err = %v", err) + } + if got := adsetCap.get()["daily_budget"]; got != float64(100_000_001) { + t.Errorf("daily_budget = %v, want 100000001 (VND offset 1, no ×100)", got) + } +} + +// TestCreateCampaignRejectsOffsetOverflowBeforeAnyPost verifies that a bogus +// large currency offset (supplied here as an explicit AccountConfig.CurrencyOffset +// override) that would push the scaled minor-unit value past int64 is rejected +// BEFORE any mutating call, rather than converting an out-of-range float to a +// wrapped int64. Note the DERIVED offset can never trigger this (it is at most +// 100), so an explicit override is the only path that can supply an overflow-scale +// offset — hence the guard is exercised via the explicit field. +func TestCreateCampaignRejectsOffsetOverflowBeforeAnyPost(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + t.Errorf("unexpected POST to %s: overflow guard should fail first", r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + // Preflight returns an UNRECOGNIZED currency so the override-consistency + // check is skipped and the explicit (absurd) offset is trusted — letting the + // overflow guard be the thing under test. + _, _ = io.WriteString(w, `{"name":"x","currency":"ZZZ"}`) + })) + defer srv.Close() + + // Explicit absurd offset that overflows int64 when scaled by the budget. + c := NewClient(Credentials{AccessToken: "t"}, + AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 1000000000000000000}, + WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, Budget: 1000, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil || !strings.Contains(err.Error(), "exceeds the representable minor-unit range") { + t.Fatalf("err = %v, want it to reject an offset-scaled overflow before mutation", err) + } +} + +// TestValidateRegistrationURLUppercaseScheme verifies an uppercase HTTPS scheme is +// accepted: Go's url.Parse normalizes the scheme to lowercase per RFC 3986, so the +// exact "https" comparison already matches "HTTPS://...". +func TestValidateRegistrationURLUppercaseScheme(t *testing.T) { + for _, raw := range []string{"HTTPS://events.example.org/register", "HttpS://events.example.org/x"} { + if err := validateRegistrationURL(raw); err != nil { + t.Errorf("validateRegistrationURL(%q) = %v, want nil (scheme is case-insensitive)", raw, err) + } + } +} + +// TestDoRequestReadsLargeSuccessBody verifies a success body larger than the +// old 64KiB drain cap is fully read (not truncated) and decoded. +func TestDoRequestReadsLargeSuccessBody(t *testing.T) { + // Build a >64KiB JSON success body with a padded field plus the id. + pad := strings.Repeat("x", 100<<10) // 100 KiB + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"pad":"`+pad+`","id":"123"}`) + })) + defer srv.Close() + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1"}, WithBaseURL(srv.URL)) + var out createResponse + if err := c.doRequest(context.Background(), http.MethodGet, "/x", nil, &out); err != nil { + t.Fatalf("doRequest: %v", err) + } + if out.ID != "123" { + t.Errorf("id = %q, want 123 (body must not be truncated before the id field)", out.ID) + } +} + +// TestDoRequestPropagatesBodyReadError verifies a truncated response (declared +// Content-Length larger than the body sent) is reported as an error, not a +// false success, even if the partial body would parse. +func TestDoRequestPropagatesBodyReadError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Advertise more bytes than we actually write, then hijack-close so the + // client sees an unexpected EOF mid-body. + w.Header().Set("Content-Length", "1000") + _, _ = io.WriteString(w, `{"id":"123"}`) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + hj, ok := w.(http.Hijacker) + if !ok { + return + } + conn, _, err := hj.Hijack() + if err == nil { + _ = conn.Close() + } + })) + defer srv.Close() + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1"}, + WithBaseURL(srv.URL), withRetryBaseDelay(time.Millisecond)) + var out createResponse + err := c.doRequest(context.Background(), http.MethodGet, "/x", nil, &out) + if err == nil { + t.Fatal("expected a read error, got nil (a truncated body must not be a success)") + } +} + +// TestWithHTTPClientNilIsIgnored verifies a nil client doesn't clobber the +// default (which would panic on the next request). +func TestWithHTTPClientNilIsIgnored(t *testing.T) { + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1"}, WithHTTPClient(nil)) + if c.httpClient == nil { + t.Fatal("WithHTTPClient(nil) nil-ed the default http client") + } +} + +// TestNewClientTrimsCredentials verifies whitespace in AccountID/PageID/token is +// trimmed at construction so it can't produce a malformed request URL. +func TestNewClientTrimsCredentials(t *testing.T) { + c := NewClient(Credentials{AccessToken: " tok "}, AccountConfig{AccountID: " act_1 ", PageID: " p "}) + if c.creds.AccessToken != "tok" || c.account.AccountID != "act_1" || c.account.PageID != "p" { + t.Errorf("credentials not trimmed: token=%q account=%q page=%q", c.creds.AccessToken, c.account.AccountID, c.account.PageID) + } +} + +// TestAdSetStartTimeTodayUsesBuffer verifies that a campaign starting today gets +// an ad-set start_time of now+buffer (not 00:00 UTC, which would be in the past), +// while a future start date uses start-of-day. +func TestAdSetStartTimeTodayUsesBuffer(t *testing.T) { + now := time.Date(2026, 8, 15, 14, 30, 0, 0, time.UTC) + + // Start today: must be after now (buffered), not 00:00. + today := time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC) + got := adSetStartTime(today, now) + parsed, err := time.Parse("2006-01-02T15:04:05-0700", got) + if err != nil { + t.Fatalf("unparseable start_time %q: %v", got, err) + } + if !parsed.After(now) { + t.Errorf("today start_time = %q, want after now (%v)", got, now) + } + + // Future date: start-of-day. + future := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + if got := adSetStartTime(future, now); got != "2026-09-01T00:00:00+0000" { + t.Errorf("future start_time = %q, want 2026-09-01T00:00:00+0000", got) + } +} + +// TestValidateGeoTargetsExcludesSanctioned verifies Meta-ineligible countries +// (comprehensively sanctioned CU/IR/KP, plus RU and SY excluded on Meta ads- +// eligibility grounds) are dropped even though they're valid ISO codes. +func TestValidateGeoTargetsExcludesSanctioned(t *testing.T) { + got := validateGeoTargets([]string{"US", "IR", "KP", "CU", "SY", "RU", "DE"}) + for _, bad := range []string{"IR", "KP", "CU", "SY", "RU"} { + if contains(got, bad) { + t.Errorf("ineligible country %s leaked into %v", bad, got) + } + } + if !contains(got, "US") || !contains(got, "DE") { + t.Errorf("valid countries dropped: %v", got) + } +} + +// TestCreateCampaignRejectsAllSanctionedGeos verifies that when every supplied +// geo is invalid/sanctioned, CreateCampaign errors instead of silently +// falling back to US. +func TestCreateCampaignRejectsAllSanctionedGeos(t *testing.T) { + srv := noPostServer(t) + defer srv.Close() + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, + WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"IR", "KP"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil || !strings.Contains(err.Error(), "no usable geo targets") { + t.Fatalf("err = %v, want all-sanctioned-geos rejection (no silent US fallback)", err) + } +} + +// TestCreateCampaignRejectsRussiaOnlyGeo verifies that a Russia-only target is +// rejected at preflight (no mutating HTTP call) rather than passing preflight and +// failing at the ad-set step after the campaign already exists. RU is Meta- +// ineligible per Meta's ads policy, so it must be handled identically to the +// comprehensively-sanctioned geos (no silent fallback to US). +func TestCreateCampaignRejectsRussiaOnlyGeo(t *testing.T) { + srv := noPostServer(t) + defer srv.Close() + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, + WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"RU"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil || !strings.Contains(err.Error(), "no usable geo targets") { + t.Fatalf("err = %v, want Russia-only rejection at preflight (no silent US fallback)", err) + } +} + +// TestCreateCampaignAdFailureSurfacesOrphanCreative verifies that when the +// creative is created but the subsequent /ads call fails (non-fatally), the +// created creative's id is surfaced in the failure step rather than discarded, +// so the orphaned creative is visible for cleanup/reuse. +func TestCreateCampaignAdFailureSurfacesOrphanCreative(t *testing.T) { + rt := roundTripFunc(func(req *http.Request) (*http.Response, error) { + // /ads fails with a plain (non-context) error → non-fatal per-variant path. + if strings.HasSuffix(req.URL.Path, "/ads") { + return &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"bad ad"}}`)), + Request: req, + }, nil + } + body := `{"id":"x"}` + switch { + case req.Method == http.MethodGet: + body = `{"name":"x"}` + case strings.HasSuffix(req.URL.Path, "/campaigns"): + body = `{"id":"camp_1"}` + case strings.HasSuffix(req.URL.Path, "/adsets"): + body = `{"id":"adset_1"}` + case strings.HasSuffix(req.URL.Path, "/adcreatives"): + body = `{"id":"creative_777"}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + }) + + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, + WithBaseURL("http://meta.test"), WithHTTPClient(&http.Client{Transport: rt}), WithClock(fixedMetaClock())) + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", + Project: "tlf", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, + Budget: 10, + StartDate: "2026-08-01", + EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + // Ad failure is non-fatal: campaign still returns. + if err != nil { + t.Fatalf("ad failure should be non-fatal, got err: %v", err) + } + if res == nil { + t.Fatal("expected a non-nil result on non-fatal ad failure") + } + var found bool + for _, s := range res.Steps { + if strings.Contains(s, "orphaned creative: creative_777") { + found = true + } + } + if !found { + t.Errorf("expected a step surfacing the orphaned creative id, got steps: %v", res.Steps) + } +} + +// TestTruncate verifies the rune-aware truncate clips at max runes with an +// ellipsis (multi-byte safe) without converting the whole string to []rune. +func TestTruncate(t *testing.T) { + cases := []struct { + in string + max int + want string + }{ + {"hello", 3, "hel…"}, + {"hello", 5, "hello"}, + {"hello", 10, "hello"}, + {"héllo", 2, "hé…"}, + {"日本語テスト", 3, "日本語…"}, + {"", 3, ""}, + } + for _, c := range cases { + if got := truncate(c.in, c.max); got != c.want { + t.Errorf("truncate(%q,%d) = %q, want %q", c.in, c.max, got, c.want) + } + } +} + +// TestCurrencyOffsetForAuthoritativeMap verifies the supported-currency map is the +// source of truth: known two-decimal and zero-decimal codes resolve to their +// factor, while any code NOT in the map (blank, or a well-formed-but-unknown code +// like "ZZZ"/"XYZ") returns ok=false so the caller fails closed rather than +// guessing 100. +func TestCurrencyOffsetForAuthoritativeMap(t *testing.T) { + known := map[string]int64{ + "USD": 100, "usd": 100, " EUR ": 100, "GBP": 100, "BRL": 100, "AED": 100, + // A sampling of the broader Meta-supported two-decimal set. + "ARS": 100, "NGN": 100, "PKR": 100, "EGP": 100, + // Zero-decimal (offset 1) per Meta's Marketing API currency table — + // including IDR/HUF/COP/CRC/TWD, which have minor units in general ISO + // usage but are billed in whole units by Meta. + "JPY": 1, "KRW": 1, "CLP": 1, "VND": 1, "XOF": 1, + "IDR": 1, "HUF": 1, "COP": 1, "CRC": 1, "TWD": 1, + } + for code, want := range known { + got, ok := currencyOffsetFor(code) + if !ok || got != want { + t.Errorf("currencyOffsetFor(%q) = (%d,%v), want (%d,true)", code, got, ok, want) + } + } + for _, code := range []string{"", " ", "ZZZ", "XYZ", "US", "US$", "123"} { + if got, ok := currencyOffsetFor(code); ok { + t.Errorf("currencyOffsetFor(%q) = (%d,true), want ok=false (not in supported-currency map)", code, got) + } + } +} + +// TestCreateCampaignRejectsUnknownCurrencyBeforeAnyPost verifies that when the +// preflight returns a well-formed but UNSUPPORTED currency code (ZZZ) and no +// explicit CurrencyOffset override is set, CreateCampaign fails BEFORE any mutating +// call (0 POSTs) rather than treating the unknown code as a two-decimal default and +// risking a 100× over-encoded budget for a new/zero-decimal currency. +func TestCreateCampaignRejectsUnknownCurrencyBeforeAnyPost(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + t.Errorf("unexpected POST to %s: an unknown currency must fail before mutation", r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"name":"x","currency":"ZZZ"}`) + })) + defer srv.Close() + + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100"}, + WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, Budget: 5, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil || !strings.Contains(err.Error(), "unsupported or missing currency code") { + t.Fatalf("err = %v, want it to reject the unknown ZZZ currency before mutation", err) + } +} + +// TestCreateCampaignRejectsUserinfoURLBeforeAnyPost verifies a RegistrationURL that +// embeds userinfo (basic-auth user[:password]@host) is rejected at preflight, +// before any mutating call — so a password can't be forwarded as the creative click +// URL or echoed in the success step. +func TestCreateCampaignRejectsUserinfoURLBeforeAnyPost(t *testing.T) { + for _, raw := range []string{ + urlWithUserinfo("https", "user", "pass", "events.example.org/register"), + "https://user@events.example.org/register", + } { + srv := noPostServer(t) + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100", CurrencyOffset: 100}, + WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", RegistrationURL: raw, + GeoTargets: []string{"US"}, Budget: 10, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + srv.Close() + if err == nil || !strings.Contains(err.Error(), "embedded credentials") { + t.Fatalf("url %q: err = %v, want embedded-credentials rejection", raw, err) + } + } +} + +// TestValidateRegistrationURLRejectsUserinfo verifies the helper rejects userinfo +// directly (unit-level). +func TestValidateRegistrationURLRejectsUserinfo(t *testing.T) { + for _, raw := range []string{ + urlWithUserinfo("https", "user", "pass", "events.example.org/x"), + "https://user@events.example.org/x", + } { + if err := validateRegistrationURL(raw); err == nil || !strings.Contains(err.Error(), "embedded credentials") { + t.Errorf("validateRegistrationURL(%q) = %v, want embedded-credentials rejection", raw, err) + } + } +} + +// TestCreateCampaignRejectsMalformedAccountIDBeforeAnyPost verifies a non-empty but +// malformed AccountID (wrong shape, or containing path delimiters / traversal) is +// rejected before any mutating call, so it can't redirect a Graph request. +func TestCreateCampaignRejectsMalformedAccountIDBeforeAnyPost(t *testing.T) { + for _, id := range []string{"12345", "act_", "act_12/34", "act_..", "act_12?x", "act_12#y", "acct_123"} { + srv := noPostServer(t) + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: id, PageID: "100", CurrencyOffset: 100}, + WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, Budget: 10, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + srv.Close() + if err == nil || !strings.Contains(err.Error(), "malformed") { + t.Fatalf("AccountID %q: err = %v, want malformed-AccountID rejection", id, err) + } + } +} + +// TestCreateCampaignRejectsMalformedPageIDBeforeAnyPost verifies a non-empty but +// non-numeric PageID is rejected before any mutating call, so a bad Page id can't +// create a campaign+ad set that then orphans when the creative fails. +func TestCreateCampaignRejectsMalformedPageIDBeforeAnyPost(t *testing.T) { + for _, id := range []string{"PAGE99", "12a", "12/34", ".."} { + srv := noPostServer(t) + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: id, CurrencyOffset: 100}, + WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, Budget: 10, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + srv.Close() + if err == nil || !strings.Contains(err.Error(), "PageID") || !strings.Contains(err.Error(), "malformed") { + t.Fatalf("PageID %q: err = %v, want malformed-PageID rejection", id, err) + } + } +} + +// TestCreateCampaignRejectsMalformedPixelIDBeforeAnyPost verifies a non-empty but +// non-numeric PixelID (for a conversions objective that requires it) is rejected +// before any mutating call, so a bad pixel id can't create a campaign that then +// orphans when the promoted object is rejected at ad-set time. +func TestCreateCampaignRejectsMalformedPixelIDBeforeAnyPost(t *testing.T) { + srv := noPostServer(t) + defer srv.Close() + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "12345", CurrencyOffset: 100}, + WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", Objective: "conversions", PixelID: "PIX9", + RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, Budget: 10, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil || !strings.Contains(err.Error(), "pixelID") || !strings.Contains(err.Error(), "malformed") { + t.Fatalf("err = %v, want malformed-pixelID rejection before mutation", err) + } +} + +// TestCreateCampaignPreflightErrorUnwrapsToAPIError verifies that when the account +// preflight fails with a Graph 4xx AND CurrencyOffset is unset (so the failure is +// fatal at offset resolution), the returned error still unwraps to *APIError via +// errors.As — i.e. the preflight error is wrapped with %w, not flattened with %s. +func TestCreateCampaignPreflightErrorUnwrapsToAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + t.Errorf("unexpected POST to %s: offset resolution should fail first", r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"error":{"message":"Invalid account","type":"OAuthException","code":100,"fbtrace_id":"ABC"}}`) + })) + defer srv.Close() + + // CurrencyOffset unset (0): the preflight failure becomes fatal at offset + // resolution, and that returned error must carry the *APIError chain. + c := NewClient(Credentials{AccessToken: "t"}, AccountConfig{AccountID: "act_1", PageID: "100"}, + WithBaseURL(srv.URL), WithClock(fixedMetaClock())) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", RegistrationURL: "https://x.example.org/e", + GeoTargets: []string{"US"}, Budget: 10, StartDate: "2026-08-01", EndDate: "2026-08-31", + Variants: []AdVariant{{PrimaryText: "p", Headline: "h"}}, + }) + if err == nil { + t.Fatalf("expected an error when the preflight fails") + } + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("err = %v (%T), want it to unwrap to *APIError via errors.As", err, err) + } + if apiErr.StatusCode != http.StatusBadRequest { + t.Errorf("unwrapped APIError.StatusCode = %d, want 400", apiErr.StatusCode) + } + if apiErr.FBTraceID != "ABC" { + t.Errorf("unwrapped APIError.FBTraceID = %q, want ABC", apiErr.FBTraceID) + } +} + +// TestBuildPlacementTargetingRejectsMessengerInbox verifies that enabling the +// Messenger Inbox placement (removed from Meta Ads in Nov 2025) is rejected, +// rather than producing a v25.0 ad-set request that fails after the campaign +// already exists. +func TestBuildPlacementTargetingRejectsMessengerInbox(t *testing.T) { + on := true + _, err := buildPlacementTargeting(Placement{MessengerInbox: &on}) + if err == nil { + t.Fatal("expected an error enabling MessengerInbox, got nil") + } + if !strings.Contains(err.Error(), "messengerInbox") { + t.Errorf("error = %v, want it to name messengerInbox", err) + } +} diff --git a/internal/platform/meta/countries.go b/internal/platform/meta/countries.go new file mode 100644 index 00000000..a9bf7e80 --- /dev/null +++ b/internal/platform/meta/countries.go @@ -0,0 +1,42 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package meta + +// iso3166Alpha2 is the set of assigned ISO 3166-1 alpha-2 country codes, used to +// reject well-shaped but non-existent codes before they reach Meta. It lives in +// its own file to keep the large static table out of the core client logic. +var iso3166Alpha2 = map[string]bool{ + "AD": true, "AE": true, "AF": true, "AG": true, "AI": true, "AL": true, "AM": true, "AO": true, + "AQ": true, "AR": true, "AS": true, "AT": true, "AU": true, "AW": true, "AX": true, "AZ": true, + "BA": true, "BB": true, "BD": true, "BE": true, "BF": true, "BG": true, "BH": true, "BI": true, + "BJ": true, "BL": true, "BM": true, "BN": true, "BO": true, "BQ": true, "BR": true, "BS": true, + "BT": true, "BV": true, "BW": true, "BY": true, "BZ": true, "CA": true, "CC": true, "CD": true, + "CF": true, "CG": true, "CH": true, "CI": true, "CK": true, "CL": true, "CM": true, "CN": true, + "CO": true, "CR": true, "CU": true, "CV": true, "CW": true, "CX": true, "CY": true, "CZ": true, + "DE": true, "DJ": true, "DK": true, "DM": true, "DO": true, "DZ": true, "EC": true, "EE": true, + "EG": true, "EH": true, "ER": true, "ES": true, "ET": true, "FI": true, "FJ": true, "FK": true, + "FM": true, "FO": true, "FR": true, "GA": true, "GB": true, "GD": true, "GE": true, "GF": true, + "GG": true, "GH": true, "GI": true, "GL": true, "GM": true, "GN": true, "GP": true, "GQ": true, + "GR": true, "GS": true, "GT": true, "GU": true, "GW": true, "GY": true, "HK": true, "HM": true, + "HN": true, "HR": true, "HT": true, "HU": true, "ID": true, "IE": true, "IL": true, "IM": true, + "IN": true, "IO": true, "IQ": true, "IR": true, "IS": true, "IT": true, "JE": true, "JM": true, + "JO": true, "JP": true, "KE": true, "KG": true, "KH": true, "KI": true, "KM": true, "KN": true, + "KP": true, "KR": true, "KW": true, "KY": true, "KZ": true, "LA": true, "LB": true, "LC": true, + "LI": true, "LK": true, "LR": true, "LS": true, "LT": true, "LU": true, "LV": true, "LY": true, + "MA": true, "MC": true, "MD": true, "ME": true, "MF": true, "MG": true, "MH": true, "MK": true, + "ML": true, "MM": true, "MN": true, "MO": true, "MP": true, "MQ": true, "MR": true, "MS": true, + "MT": true, "MU": true, "MV": true, "MW": true, "MX": true, "MY": true, "MZ": true, "NA": true, + "NC": true, "NE": true, "NF": true, "NG": true, "NI": true, "NL": true, "NO": true, "NP": true, + "NR": true, "NU": true, "NZ": true, "OM": true, "PA": true, "PE": true, "PF": true, "PG": true, + "PH": true, "PK": true, "PL": true, "PM": true, "PN": true, "PR": true, "PS": true, "PT": true, + "PW": true, "PY": true, "QA": true, "RE": true, "RO": true, "RS": true, "RU": true, "RW": true, + "SA": true, "SB": true, "SC": true, "SD": true, "SE": true, "SG": true, "SH": true, "SI": true, + "SJ": true, "SK": true, "SL": true, "SM": true, "SN": true, "SO": true, "SR": true, "SS": true, + "ST": true, "SV": true, "SX": true, "SY": true, "SZ": true, "TC": true, "TD": true, "TF": true, + "TG": true, "TH": true, "TJ": true, "TK": true, "TL": true, "TM": true, "TN": true, "TO": true, + "TR": true, "TT": true, "TV": true, "TW": true, "TZ": true, "UA": true, "UG": true, "UM": true, + "US": true, "UY": true, "UZ": true, "VA": true, "VC": true, "VE": true, "VG": true, "VI": true, + "VN": true, "VU": true, "WF": true, "WS": true, "YE": true, "YT": true, "ZA": true, "ZM": true, + "ZW": true, +}