diff --git a/docs/knowledge/code/index.md b/docs/knowledge/code/index.md index a668dbb0..641efbe9 100644 --- a/docs/knowledge/code/index.md +++ b/docs/knowledge/code/index.md @@ -5,6 +5,7 @@ * [internal/infrastructure/config](internal-infrastructure-config.md) - Application configuration from CLI flags and env vars, including PG* composition into a PostgreSQL DSN. * [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/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. * [pkg/log](pkg-log.md) - Package log provides structured logging utilities for context-aware logging. diff --git a/docs/knowledge/code/internal-platform-twitter.md b/docs/knowledge/code/internal-platform-twitter.md new file mode 100644 index 00000000..80222e8a --- /dev/null +++ b/docs/knowledge/code/internal-platform-twitter.md @@ -0,0 +1,52 @@ +--- +type: "Go Package" +title: "internal/platform/twitter" +description: "X (Twitter) Ads v12 client: OAuth 1.0a signing and the campaign -> line_item -> promoted_tweet creation flow." +resource: "internal/platform/twitter" +tags: + - platform-client + - twitter + - x-ads + - oauth1 + - go-package +timestamp: "2026-07-13T19:18:36Z" +--- + +# internal/platform/twitter + +Package twitter is the X (Twitter) Ads API v12 platform client. It implements +OAuth 1.0a (HMAC-SHA1) request signing and drives the +campaign -> line_item -> promoted_tweet creation flow. Credentials and account +configuration are injected via `NewClient`; the package never reads environment +variables or touches the database. + +`CreateCampaign` is only PARTIALLY idempotent: it reuses existing campaigns and +line items by name (paged cursor lookups via `findByName`) before creating new +ones, and a lookup that fails transiently propagates an error so the caller +aborts rather than creating a duplicate. The promoted-tweet association, however, +is always re-POSTed on a repeat call. A recognizable duplicate response +(`DUPLICATE_PROMOTABLE_ENTITY`) is NOT treated as idempotent success: X returns +that code even when the tweet is already promoted by a DIFFERENT line item, so it +is surfaced as a warning (on `PromotedTweetWarning` and in the step log) to be +verified manually rather than assumed to attach to this line item. A +lost/malformed first response likewise produces a warning. True cross-call +idempotency (idempotency keys) is explicitly deferred and tracked in LFXV2-2665. Only the campaign and line item are created with +`entity_status=PAUSED`; the promoted-tweet endpoint does not accept +`entity_status`, so the API creates that association `ACTIVE`. It cannot serve, +though, because the parent line item is paused — delivery is gated by the paused +line item, not by the association's own status. + +Per the X Ads v12 contract, create endpoints take their parameters as URL query +parameters (not a JSON body); the client folds those params into the OAuth +signature base string. Flight dates (`start_time`/`end_time`, ISO8601 UTC) are +sent only on the line-item create, where they are required; the campaign +endpoint does not accept them in v12, so the campaign create omits them. Dates +are validated for both shape and real-calendar validity (`time.Parse`) before +any mutating call. The client paces sequential writes within a SINGLE dispatch +toward the 1-req/sec limit; it does NOT enforce the account-wide write limit +across concurrent dispatches or replicas (that needs cross-replica coordination, +tracked in LFXV2-2665), so operators must not rely on this stateless client for +cross-dispatch rate limiting. When the account limit is hit anyway, 429s are +retried with backoff bounded by `Retry-After` / `X-Rate-Limit-Reset`. + +See [internal/platform/twitter](../../../internal/platform/twitter). diff --git a/docs/knowledge/log.md b/docs/knowledge/log.md index 8240362a..9dc95362 100644 --- a/docs/knowledge/log.md +++ b/docs/knowledge/log.md @@ -2,6 +2,15 @@ ## 2026-07-10 +**Update** — Addressed Copilot review on the X/Twitter Ads client (PR #19): +create calls now send params as URL query parameters (not a JSON body) per the +X Ads v12 contract, use `entity_status=PAUSED`, and line items carry the +required `start_time`/`end_time` with `bid_strategy` (not `bid_type`); dates are +strictly parsed to reject impossible calendar values; name lookups propagate +errors instead of masking them as not-found. Added the +`internal/platform/twitter` code concept and index entry. + + **Update** — Dropped the Goa CLI path allowlist; twitter-api-secret FP is fingerprint-only in `.gitleaksignore`. Clarified `.grype.yaml` rationale (Engine fixes exist; Go module path not yet upgradeable via migrate/dktest). diff --git a/internal/platform/twitter/client.go b/internal/platform/twitter/client.go new file mode 100644 index 00000000..9b0f0e57 --- /dev/null +++ b/internal/platform/twitter/client.go @@ -0,0 +1,1306 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Package twitter is a Go port of the X (Twitter) Ads platform client. It +// implements OAuth 1.0a (HMAC-SHA1) request signing and the +// campaign -> line_item -> promoted_tweet creation flow against the X Ads API. +// +// Credentials and account configuration are injected via NewClient; this +// package never reads environment variables or touches the database. In +// production the credentials come from a decrypted stored connection. +package twitter + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha1" //nolint:gosec // OAuth 1.0a mandates HMAC-SHA1; not used for security hashing. + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +// --------------------------------------------------------------------------- +// Constants (mirror twitter.constants.ts + shared constants) +// --------------------------------------------------------------------------- + +const ( + // DefaultBaseURL is the X Ads API origin. Mirrors TWITTER_ADS_BASE_URL. + DefaultBaseURL = "https://ads-api.x.com" + // DefaultAPIVersion mirrors TWITTER_ADS_API_VERSION. + DefaultAPIVersion = "12" + // AdsManagerURL mirrors TWITTER_ADS_MANAGER_URL. + AdsManagerURL = "https://ads.x.com" + + // requestTimeout mirrors TWITTER_REQUEST_TIMEOUT_MS. + requestTimeout = 30 * time.Second + // writeDelay mirrors TWITTER_API_WRITE_DELAY_MS (1 write req/sec). + writeDelay = 1 * time.Second + // maxBudgetUsd caps the budget well below the int64 micro-unit overflow + // threshold (int64 max / 1e6 ≈ 9.2e12) so the ×1e6 conversion in + // toMicroCurrency can never wrap to a negative value. Mirrors the reddit + // client's redditMaxBudgetUSD. + maxBudgetUsd = 1_000_000_000.0 + // maxEventNameLen bounds the event name folded into campaign / line-item + // names, guarding against unbounded input producing oversized API payloads. + maxEventNameLen = 200 + // maxProjectLen bounds the project name folded into the campaign name. Like + // EventName, Project is caller-supplied and otherwise unbounded, so it is + // trimmed and length-capped before composition. + maxProjectLen = 200 + // maxEntityNameLen is X's hard limit on a campaign / line-item entity name. + // The composed name (event + project + fixed template) can exceed this even + // when EventName and Project are individually within bounds, so the FINAL + // composed names are validated against this rune limit before any create call. + maxEntityNameLen = 255 + // retryMax mirrors TWITTER_API_RETRY_MAX. + retryMax = 3 + // maxRetryWait caps how long a single 429 backoff will sleep. X rate-limit + // windows can be far longer than a request is willing to wait; if the + // server-declared reset exceeds this cap we abort with the rate-limit error + // instead of sleeping pointlessly (and a hostile huge reset can't hang us). + maxRetryWait = 90 * time.Second + // maxResponseBody bounds how much of any response body is read into memory, + // guarding against a hostile/oversized reply while comfortably exceeding any + // normal X Ads response or error envelope. + maxResponseBody = 1 << 20 // 1 MiB +) + +// --------------------------------------------------------------------------- +// Injected configuration +// --------------------------------------------------------------------------- + +// Credentials holds the OAuth 1.0a user-context credentials required for all +// X Ads API write operations. These are injected, never read from the +// environment. +type Credentials struct { + ConsumerKey string + ConsumerSecret string + AccessToken string + AccessTokenSecret string +} + +// AccountConfig identifies the ads account and its funding instrument. +type AccountConfig struct { + AccountID string + FundingInstrumentID string +} + +// Client is an X Ads API client. It is safe for sequential use; the X Ads API +// enforces a 1 write-request-per-second limit which this client honors. +type Client struct { + creds Credentials + account AccountConfig + + baseURL string + apiVersion string + httpClient *http.Client + + // nonceFn and timeFn are injectable for deterministic testing of the + // OAuth signature. Production code uses the crypto/rand + wall-clock + // defaults installed by NewClient. + nonceFn func() string + timeFn func() time.Time + + // writeDelay paces sequential write requests within a single dispatch + // (Twitter allows ~1 write/sec). Injectable so tests can set it to 0 rather + // than incurring real per-request sleeps; defaults to the writeDelay const. + writeDelay time.Duration +} + +// Option customizes a Client at construction time. +type Option func(*Client) + +// WithBaseURL overrides the API base URL (default DefaultBaseURL). Trailing +// slashes are trimmed so accountURL never produces a double-slash path (e.g. +// "https://ads-api.x.com/" + "/12/..." -> "//12/..."), which would be signed +// and sent verbatim and could break signature verification if the server +// normalizes the path differently than the client. +func WithBaseURL(u string) Option { + return func(c *Client) { c.baseURL = strings.TrimRight(u, "/") } +} + +// WithAPIVersion overrides the API version segment (default DefaultAPIVersion). +func WithAPIVersion(v string) Option { return func(c *Client) { c.apiVersion = v } } + +// WithHTTPClient overrides the underlying *http.Client (default has a 30s +// timeout). A nil client is ignored so the option can't produce an unusable +// Client whose httpClient.Do would panic. +func WithHTTPClient(h *http.Client) Option { + return func(c *Client) { + if h != nil { + c.httpClient = h + } + } +} + +// WithWriteDelay overrides the inter-write pacing delay. A zero (or negative) +// value disables the pacing sleep entirely — useful in tests to avoid real +// per-request sleeps. +func WithWriteDelay(d time.Duration) Option { return func(c *Client) { c.writeDelay = d } } + +// NewClient constructs a Client from injected credentials and account config. +func NewClient(creds Credentials, account AccountConfig, opts ...Option) *Client { + // Normalize the account identifiers once, on the way in, so every method uses + // the cleaned values. A stored connection can persist a padded id (" acc1 "); + // left untrimmed it would validate non-empty yet corrupt the account path and + // the funding_instrument_id param (a space-containing path/param guarantees an + // API rejection). Trimming here keeps the trimmed value the one that is BOTH + // validated non-empty AND sent in every request path/param. + account.AccountID = strings.TrimSpace(account.AccountID) + account.FundingInstrumentID = strings.TrimSpace(account.FundingInstrumentID) + c := &Client{ + creds: creds, + account: account, + baseURL: DefaultBaseURL, + apiVersion: DefaultAPIVersion, + httpClient: &http.Client{Timeout: requestTimeout}, + nonceFn: defaultNonce, + timeFn: time.Now, + writeDelay: writeDelay, + } + for _, o := range opts { + o(c) + } + return c +} + +// --------------------------------------------------------------------------- +// OAuth 1.0a — HMAC-SHA1 signing +// --------------------------------------------------------------------------- + +// percentEncode implements RFC 3986 percent-encoding as required by OAuth 1.0a. +// It mirrors the TS percentEncode (encodeURIComponent + escaping !'()*). +func percentEncode(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '-' || c == '.' || c == '_' || c == '~' { + b.WriteByte(c) + } else { + fmt.Fprintf(&b, "%%%02X", c) + } + } + return b.String() +} + +// generateOAuthSignature computes the HMAC-SHA1 base64 signature over the +// OAuth 1.0a signature base string. Mirrors generateOAuthSignature in the TS. +// oauthParam is a single (name, value) pair fed into the OAuth 1.0a signature +// base string. It exists so multi-valued query parameters (e.g. a=1&a=2) can be +// represented — a map[string]string would silently collapse them to one value +// and produce an invalid signature (RFC 5849 §3.4.1.3.2 requires EVERY value be +// included). +type oauthParam struct{ name, value string } + +func generateOAuthSignature(method, u string, params map[string]string, extraPairs []oauthParam, consumerSecret, tokenSecret string) string { + // OAuth 1.0a (RFC 5849 §3.4.1.3.2) normalizes parameters by their + // PERCENT-ENCODED name, breaking ties on the percent-encoded value — not by + // the raw key. Sorting raw keys is wrong: e.g. "c@" encodes to "c%40" and + // must sort BEFORE "c2" because '%' (0x25) < '2' (0x32), yet raw '@' (0x40) + // sorts AFTER '2'. Encode first, then sort by (name, value) as a TUPLE. + // + // Sorting the joined "name=value" string is ALSO wrong: it misorders when one + // encoded name is a prefix of another. Names "a" and "a1" must order a < a1, + // but "a1=" sorts BEFORE "a=" on the joined form because '1' (0x31) < + // '=' (0x3D). Compare names first, then values as a tiebreak. + type encodedPair struct{ name, value string } + pairs := make([]encodedPair, 0, len(params)+len(extraPairs)) + for k, v := range params { + pairs = append(pairs, encodedPair{percentEncode(k), percentEncode(v)}) + } + // extraPairs carries multi-valued query params (and any param whose key also + // appears in params, e.g. a repeated query key); all values must be signed. + for _, p := range extraPairs { + pairs = append(pairs, encodedPair{percentEncode(p.name), percentEncode(p.value)}) + } + sort.Slice(pairs, func(i, j int) bool { + if pairs[i].name != pairs[j].name { + return pairs[i].name < pairs[j].name + } + return pairs[i].value < pairs[j].value + }) + parts := make([]string, 0, len(pairs)) + for _, p := range pairs { + parts = append(parts, p.name+"="+p.value) + } + paramString := strings.Join(parts, "&") + + baseString := strings.ToUpper(method) + "&" + percentEncode(u) + "&" + percentEncode(paramString) + signingKey := percentEncode(consumerSecret) + "&" + percentEncode(tokenSecret) + + mac := hmac.New(sha1.New, []byte(signingKey)) + mac.Write([]byte(baseString)) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} + +// buildOAuthHeader builds the "Authorization: OAuth ..." header for a request. +// Query parameters present on rawURL are folded into the OAuth 1.0a signature +// base string (X Ads create calls carry their params on the query string). +// bodyParams is retained for callers that sign extra form params; callers here +// pass nil since no request carries a body. +func (c *Client) buildOAuthHeader(method, rawURL string, bodyParams map[string]string) (string, error) { + oauthParams := map[string]string{ + "oauth_consumer_key": c.creds.ConsumerKey, + "oauth_nonce": c.nonceFn(), + "oauth_signature_method": "HMAC-SHA1", + "oauth_timestamp": strconv.FormatInt(c.timeFn().Unix(), 10), + "oauth_token": c.creds.AccessToken, + "oauth_version": "1.0", + } + + // allParams = oauthParams + bodyParams + query params, used only for signing. + allParams := make(map[string]string, len(oauthParams)+len(bodyParams)) + for k, v := range oauthParams { + allParams[k] = v + } + for k, v := range bodyParams { + allParams[k] = v + } + + parsed, err := url.Parse(rawURL) + if err != nil { + return "", fmt.Errorf("parse url %q: %w", rawURL, err) + } + // Every query (name, value) pair must be folded into the signature base + // string — including repeated keys (a=1&a=2). Collapsing to a single value + // per key would silently sign the wrong request. Collected as a slice so + // duplicate values survive to generateOAuthSignature's (name, value) sort. + var queryPairs []oauthParam + for k, vs := range parsed.Query() { + for _, v := range vs { + queryPairs = append(queryPairs, oauthParam{name: k, value: v}) + } + } + + // Base URL for signing excludes the query string (origin + path) and MUST be + // normalized per RFC 5849 §3.4.1.2: the scheme and host are lowercased and a + // port equal to the scheme's default (80 for http, 443 for https) is omitted. + // WithBaseURL accepts any valid URL, so an input like "HTTPS://ADS-API.X.COM:443" + // would otherwise be signed verbatim and X would reject the signature. Only the + // base STRING is normalized here; the actual request still targets parsed as-is. + signingURL := normalizeSigningURL(parsed) + oauthParams["oauth_signature"] = generateOAuthSignature(method, signingURL, allParams, queryPairs, c.creds.ConsumerSecret, c.creds.AccessTokenSecret) + + keys := make([]string, 0, len(oauthParams)) + for k := range oauthParams { + keys = append(keys, k) + } + sort.Strings(keys) + + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, percentEncode(k)+"=\""+percentEncode(oauthParams[k])+"\"") + } + return "OAuth " + strings.Join(parts, ", "), nil +} + +// normalizeSigningURL returns the RFC 5849 §3.4.1.2 normalized origin+path used +// in the OAuth 1.0a signature base string: scheme and host lowercased, and the +// port dropped when it is the scheme's default (http:80 / https:443). A +// non-default port is preserved. The query string is excluded (its params are +// signed separately). This is applied ONLY to the value fed into the base +// string; the request itself still goes to the un-normalized URL. +func normalizeSigningURL(u *url.URL) string { + scheme := strings.ToLower(u.Scheme) + host := strings.ToLower(u.Hostname()) + // Re-bracket an IPv6 literal: Hostname() strips the brackets, but a URI + // authority requires them (e.g. "[::1]"), otherwise the host and any ":port" + // below would be ambiguous and the base-string authority wouldn't match the + // request URI. + if strings.Contains(host, ":") { + host = "[" + host + "]" + } + port := u.Port() + // Omit the port when it matches the scheme default; keep it otherwise. + if port != "" && (scheme != "http" || port != "80") && (scheme != "https" || port != "443") { + host = host + ":" + port + } + // Use EscapedPath(), not the decoded u.Path: the request is sent with the + // escaped path, so signing the decoded form (e.g. "/proxy/twitter" for a base + // path "/proxy%2Ftwitter") would produce a signature the verifier — which + // reconstructs the escaped request URI — rejects. + return scheme + "://" + host + u.EscapedPath() +} + +func defaultNonce() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + // crypto/rand should not fail; fall back to a timestamp-derived value. + return strconv.FormatInt(time.Now().UnixNano(), 16) + } + return hex.EncodeToString(b) +} + +// --------------------------------------------------------------------------- +// HTTP helper +// --------------------------------------------------------------------------- + +// apiResponse is the loose envelope returned by X Ads endpoints. +type apiResponse struct { + Data json.RawMessage `json:"data"` + // NextCursor is set on cursor-paginated list endpoints (campaigns, + // line_items). Empty when there are no further pages. + NextCursor string `json:"next_cursor"` +} + +// maxListPages caps how many pages a name-lookup will page through, a safety +// bound against an unexpectedly huge account. The find-by-name callers request +// count=1000 (the X Ads v12 list max page size), so 25 pages cover up to +// 25 × 1000 = 25,000 records — comfortably beyond the ~8,000 active campaigns +// an X account can hold. Hitting the cap with a cursor still outstanding is +// treated as inconclusive (an error), never as "not found". +const maxListPages = 25 + +func (c *Client) accountURL() string { + return fmt.Sprintf("%s/%s/accounts/%s", c.baseURL, c.apiVersion, c.account.AccountID) +} + +// request performs an account-scoped X Ads API GET/list request with OAuth1 +// signing and 429 exponential-backoff retry. Any parameters must be encoded +// into path as a query string. Mirrors twitterRequest in the TS for reads. +func (c *Client) request(ctx context.Context, method, path string) (*apiResponse, error) { + return c.doRequest(ctx, method, path, nil) +} + +// createRequest performs an X Ads API create (POST) call. Per the X Ads v12 +// contract, create endpoints (campaigns, line_items, promoted_tweets) accept +// their parameters as URL query parameters, not a JSON body. The params are +// appended to the request URL and also folded into the OAuth signature base +// string (OAuth 1.0a signs query params), and the request is sent with no +// body. Callers own the 1-req/sec write delay. +func (c *Client) createRequest(ctx context.Context, path string, params map[string]string) (*apiResponse, error) { + return c.doRequest(ctx, http.MethodPost, path, params) +} + +// doRequest is the shared HTTP path with OAuth1 signing and 429 +// exponential-backoff retry. queryParams, when non-nil, are appended to the +// request URL (create calls pass their params here); the request carries no +// body in either mode. +func (c *Client) doRequest(ctx context.Context, method, path string, queryParams map[string]string) (*apiResponse, error) { + // An empty path targets the account root itself (accountURL) — used by + // verifyAccount's GET — so don't append a bare "/" that would change the URL. + reqURL := c.accountURL() + if p := strings.TrimPrefix(path, "/"); p != "" { + reqURL += "/" + p + } + + if len(queryParams) > 0 { + vals := url.Values{} + for k, v := range queryParams { + vals.Set(k, v) + } + sep := "?" + if strings.Contains(reqURL, "?") { + sep = "&" + } + reqURL += sep + vals.Encode() + } + + for attempt := 0; attempt <= retryMax; attempt++ { + authHeader, err := c.buildOAuthHeader(method, reqURL, nil) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, method, reqURL, http.NoBody) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Authorization", authHeader) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("x ads api %s %s: %w", method, path, err) + } + + if resp.StatusCode == http.StatusTooManyRequests { + // If this was the last attempt, don't sleep+retry: the loop would + // exit and the 429 would otherwise fall through to the generic + // non-2xx return below. Surface the intended exhausted-rate-limit + // error instead. + if attempt >= retryMax { + _ = resp.Body.Close() + return nil, fmt.Errorf("x ads api %s %s -> exhausted %d retries after 429s", method, path, retryMax) + } + waitDur := c.parseRetryAfter(resp) + _ = resp.Body.Close() + if waitDur > 0 { + // The server declared a reset time (Retry-After delay or + // X-Rate-Limit-Reset epoch). Honor it rather than clamping to a + // small value and burning every retry while still limited. If the + // wait exceeds our cap, sleeping would consume a retry without any + // chance of the window clearing, so abort with the rate-limit error. + if waitDur > maxRetryWait { + return nil, fmt.Errorf("x ads api %s %s -> 429: rate-limit reset in %s exceeds max wait %s; aborting", method, path, waitDur.Round(time.Second), maxRetryWait) + } + } else { + // No server-declared reset: fall back to computed exponential + // backoff, clamped to maxRetryWait to match the header path above. + // (Bounded in practice today since attempt <= retryMax, but clamp + // defensively so the two 429 paths stay consistent.) + waitDur = writeDelay * time.Duration(1< maxRetryWait { + waitDur = maxRetryWait + } + } + if err := sleepCtx(ctx, waitDur); err != nil { + return nil, err + } + continue + } + + respBody, readErr := readAll(resp) + _ = resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + if readErr != nil { + return nil, fmt.Errorf("x ads api %s %s -> %d: %s (body read error: %v)", method, path, resp.StatusCode, truncate(respBody, 400), readErr) + } + return nil, fmt.Errorf("x ads api %s %s -> %d: %s", method, path, resp.StatusCode, truncate(respBody, 400)) + } + if readErr != nil { + // A 2xx with a body we couldn't fully/ cleanly read: don't decode a + // partial body into a misleading result — surface the I/O failure. + return nil, fmt.Errorf("x ads api %s %s: %w", method, path, readErr) + } + + var out apiResponse + if len(respBody) > 0 { + if err := json.Unmarshal(respBody, &out); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + } + return &out, nil + } + + return nil, fmt.Errorf("x ads api %s %s -> exhausted %d retries after 429s", method, path, retryMax) +} + +// parseRetryAfter returns how long to wait before retrying a 429, or 0 if no +// usable header is present. Header precedence mirrors the official X Ads SDK: +// X-Account-Rate-Limit-Reset (account-scoped limits) is checked first, then +// X-Rate-Limit-Reset (endpoint-scoped), then Retry-After. Both *-Rate-Limit-Reset +// headers carry a Unix epoch timestamp (the X Ads API commonly returns only these +// on a 429), so they must be converted to a duration-until-reset rather than +// treated as a delay. Retry-After is either a delay in seconds or an HTTP-date. +// Never returns a negative duration. +func (c *Client) parseRetryAfter(resp *http.Response) time.Duration { + // Account-scoped rate limits take precedence: an account-scoped 429 stays + // limited across every endpoint until this reset, so honoring the shorter + // endpoint header (or falling back to exponential backoff) would burn retries + // while still limited. + if d := c.resetHeaderDelay(resp.Header.Get("X-Account-Rate-Limit-Reset")); d > 0 { + return d + } + if d := c.resetHeaderDelay(resp.Header.Get("X-Rate-Limit-Reset")); d > 0 { + return d + } + if v := strings.TrimSpace(resp.Header.Get("Retry-After")); v != "" { + // Delay-seconds form. ParseInt (not Atoi) into an int64 so a large numeric + // value is captured rather than overflowing the platform int that Atoi + // returns (which on a 32-bit int would surface as an error and silently + // drop a real, if outsized, reset). Mirrors reddit's parseRetryAfter. + if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 { + // Even a validly-parsed int64 seconds value can overflow when scaled to + // nanoseconds: time.Duration(n)*time.Second wraps NEGATIVE for n beyond + // ~9.2e9, which would slip past the caller's `> maxRetryWait` abort and + // trigger an immediate retry before the declared reset. Guard the + // conversion: 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 over-cap abort fire — never perform the wrapping + // multiply. A value EXACTLY at the cap is allowed through and returned + // as-is (via the multiply below), so it isn't spuriously aborted. + 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.timeFn()); d > 0 { + return d + } + } + } + return 0 +} + +// resetHeaderDelay interprets a *-Rate-Limit-Reset header value (a Unix epoch +// timestamp) as a duration-until-reset relative to the injectable clock. Returns +// 0 for an empty/unparseable value or a reset that has already passed. +func (c *Client) resetHeaderDelay(v string) time.Duration { + v = strings.TrimSpace(v) + if v == "" { + return 0 + } + epoch, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return 0 + } + if d := time.Unix(epoch, 0).Sub(c.timeFn()); d > 0 { + return d + } + return 0 +} + +// pace waits c.writeDelay between sequential write requests within a single +// dispatch, honoring context cancellation. A non-positive writeDelay disables +// the sleep (used by tests). +func (c *Client) pace(ctx context.Context) error { + if c.writeDelay <= 0 { + return nil + } + return sleepCtx(ctx, c.writeDelay) +} + +// sleepCtx waits for d, honoring context cancellation. +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 + } +} + +// readAll reads up to maxResponseBody bytes (plus one, so truncation is +// detectable) from the response, surfacing both read and truncation errors +// rather than silently discarding them. io.ReadAll can return bytes together +// with an error, so a discarded error can hide a partial/corrupt body and turn +// a transport failure into a misleading JSON decode error downstream. +func readAll(resp *http.Response) ([]byte, error) { + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody+1)) + if err != nil { + return body, fmt.Errorf("read response body: %w", err) + } + if int64(len(body)) > maxResponseBody { + return body[:maxResponseBody], fmt.Errorf("response body exceeds %d bytes", maxResponseBody) + } + return body, nil +} + +func truncate(b []byte, n int) string { + if len(b) <= n { + return string(b) + } + return string(b[:n]) +} + +// --------------------------------------------------------------------------- +// Conversion + formatting helpers +// --------------------------------------------------------------------------- + +// toMicroCurrency converts USD to micro-currency (x 1,000,000), rounded. +func toMicroCurrency(usd float64) int64 { + return int64(math.Round(usd * 1_000_000)) +} + +// fromMicroCurrency converts micro-currency back to USD (/ 1,000,000). +func fromMicroCurrency(micro int64) float64 { + return float64(micro) / 1_000_000 +} + +// toIso8601Utc formats a YYYY-MM-DD date string as an ISO8601 UTC timestamp +// at midnight. Mirrors toIso8601Utc in the TS. +func toIso8601Utc(dateStr string) string { + return dateStr + "T00:00:00Z" +} + +// --------------------------------------------------------------------------- +// Campaign lookup (idempotency) +// --------------------------------------------------------------------------- + +type campaignElement struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// findCampaignByName returns the id of a campaign matching name, or "" if no +// such campaign exists. A non-nil error signals a transient/unexpected lookup +// failure (failed GET or undecodable response) — the caller must abort rather +// than treat it as "not found" and create a duplicate. +func (c *Client) findCampaignByName(ctx context.Context, name string) (string, error) { + // q= is X Ads' server-side name filter: it narrows the list to entities + // whose name matches, so a lookup is O(matches) instead of scanning the whole + // account (which could exceed the page cap on a large account and fail + // name-based idempotency). q does substring/prefix matching, so findByName + // still enforces the EXACT-name comparison locally. count=1000 (max page size, + // independent of cursor) keeps any residual paging cheap. + return c.findByName(ctx, "campaigns?with_deleted=false&count=1000&q="+url.QueryEscape(name), name) +} + +// findLineItemByName returns the id of a line item matching name within a +// campaign, or "" if none exists. A non-nil error signals a lookup failure the +// caller must not swallow (see findCampaignByName). +func (c *Client) findLineItemByName(ctx context.Context, campaignID, name string) (string, error) { + // The X Ads list endpoint filters line items with campaign_ids (plural); + // campaign_id (singular) is the CREATE parameter. Using the singular key here + // would leave the lookup unscoped and could reuse a same-named line item from + // another campaign. + // q= is the server-side name filter (see findCampaignByName); it makes + // the lookup O(matches), not O(account), while findByName still enforces the + // exact-name match locally. count=1000 is the max page size. + return c.findByName(ctx, "line_items?campaign_ids="+url.QueryEscape(campaignID)+"&with_deleted=false&count=1000&q="+url.QueryEscape(name), name) +} + +// findByName pages through a cursor-paginated X Ads list endpoint (campaigns / +// line_items) looking for an element whose name matches exactly. It returns +// (id, nil) on a match, ("", nil) for a genuine not-found (the pages were read +// successfully but held no match), and ("", err) when a page GET or decode +// fails — so a transient error is never conflated with "not found" and the +// caller can abort instead of creating a duplicate. A name match whose element +// carries no usable id is likewise returned as ("", err), not ("", nil), so the +// caller does not follow with a create and duplicate an existing element. It +// follows next_cursor so a match beyond the first page is still found, bounded +// by maxListPages. +func (c *Client) findByName(ctx context.Context, path, name string) (string, error) { + sep := "&" + if !strings.Contains(path, "?") { + sep = "?" + } + cursor := "" + for page := 0; page < maxListPages; page++ { + p := path + if cursor != "" { + p = path + sep + "cursor=" + url.QueryEscape(cursor) + } + resp, err := c.request(ctx, http.MethodGet, p) + if err != nil { + return "", fmt.Errorf("lookup %q: %w", name, err) + } + if resp == nil { + return "", fmt.Errorf("lookup %q: empty response", name) + } + var items []campaignElement + if err := json.Unmarshal(resp.Data, &items); err != nil { + return "", fmt.Errorf("lookup %q: decode list: %w", name, err) + } + for _, it := range items { + if it.Name == name { + // A match with no usable id cannot be reused. Returning ("", nil) + // here would read as "not found" and drive the caller into a create + // POST, risking a duplicate of an element that already exists. + // Surface it as a lookup error so the caller aborts instead. + if it.ID == "" { + return "", fmt.Errorf("lookup %q: matching element has no id; aborting to avoid creating a duplicate", name) + } + return it.ID, nil + } + } + if resp.NextCursor == "" { + return "", nil + } + cursor = resp.NextCursor + } + // Hit the page cap with a cursor still outstanding: we can't be sure the name + // doesn't exist further on, so return an error rather than "not found" (which + // would let the caller create a duplicate). + return "", fmt.Errorf("lookup %q: exceeded %d pages with more results remaining; aborting to avoid creating a duplicate", name, maxListPages) +} + +// --------------------------------------------------------------------------- +// Campaign name + UTM builders +// --------------------------------------------------------------------------- + +func buildTwitterCampaignName(in CampaignInput) string { + event := strings.ReplaceAll(in.EventName, "|", "-") + project := boundProject(in.Project) + project = strings.ReplaceAll(project, "|", "-") + return fmt.Sprintf("Events | %s | Global | Awareness | Prospecting | Promoted Post | %s | MoFU", event, project) +} + +// boundProject trims the caller-supplied project name and caps its rune length. +// The data pipeline parses the campaign name for attribution and joins on the +// caller-supplied canonical project slug; see docs/api-catalog.md. CreateCampaign +// rejects an empty Project up front, so this always receives a non-empty value — +// it does not substitute a default (which would misattribute the campaign). +// Project is otherwise unbounded, so bounding it here keeps the composed campaign +// name from ballooning. +func boundProject(project string) string { + project = strings.TrimSpace(project) + if r := []rune(project); len(r) > maxProjectLen { + project = string(r[:maxProjectLen]) + } + return project +} + +// validateEntityName enforces X's 255-rune entity-name limit on a FINAL composed +// campaign / line-item name. Even with EventName and Project individually bounded, +// the composed name (event + project + fixed template) can exceed 255, so it is +// checked here before any create call. kind is "campaign" or "line item". +func validateEntityName(kind, name string) error { + if n := len([]rune(name)); n > maxEntityNameLen { + return fmt.Errorf("invalid %s name: composed name is %d characters, exceeds X's %d-character limit", kind, n, maxEntityNameLen) + } + return nil +} + +var spaceRe = regexp.MustCompile(`\s+`) + +// validateRegistrationURL ensures a user-supplied registration URL is an +// absolute http/https URL with a real host, before any mutating call. In the +// manual-tweet workflow (TweetID omitted) this URL is the only ad destination +// (it feeds the UTM/destination via buildTwitterUtmURL), and url.Parse alone is +// far too permissive: url.Parse("") succeeds (yielding a query-only +// "?utm_source=..." string), relative URLs are accepted, and "https://:443/x" +// parses with an empty Hostname(). Mirrors validateRegistrationURL in the +// reddit/linkedin clients: TrimSpace, require IsAbs()+Hostname()!="", scheme +// http/https. +func validateRegistrationURL(raw string) error { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return fmt.Errorf("registration URL is required") + } + u, err := url.Parse(trimmed) + if err != nil { + return fmt.Errorf("registration URL %q is not a valid URL: %w", raw, err) + } + // Require a real host: url.Parse accepts "https://:443/path" (Host=":443") + // where Hostname() is empty, so check Hostname() not just Host. + if !u.IsAbs() || u.Hostname() == "" { + return fmt.Errorf("registration URL %q must be absolute (include scheme and host)", raw) + } + switch strings.ToLower(u.Scheme) { + case "http", "https": + return nil + default: + return fmt.Errorf("registration URL %q must use an http or https scheme, got %q", raw, u.Scheme) + } +} + +func buildTwitterUtmURL(in CampaignInput) string { + slug := in.EventSlug + if slug == "" { + slug = spaceRe.ReplaceAllString(strings.ToLower(in.EventName), "-") + } + campaign := in.HSToken + if campaign == "" { + campaign = slug + } + + raw := strings.TrimSpace(in.RegistrationURL) + u, err := url.Parse(raw) + if err != nil { + // Unparseable URL: return it unchanged rather than corrupting it. + return raw + } + // Merge UTM params into the URL's existing query and re-render, so the query + // lands before any fragment (a naive string append would put it inside the + // fragment, e.g. https://x/reg#a?utm_...). + q := u.Query() + q.Set("utm_source", "twitter") + q.Set("utm_medium", "paid-social") + q.Set("utm_campaign", campaign) + q.Set("utm_term", spaceRe.ReplaceAllString(strings.ToLower(in.EventName), "-")) + q.Set("utm_content", "promoted-tweet") + u.RawQuery = q.Encode() + return u.String() +} + +// --------------------------------------------------------------------------- +// Public API — campaign creation +// --------------------------------------------------------------------------- + +// CampaignInput carries the fields required to create an X Ads campaign. +// Mirrors the TS TwitterCampaignCreateRequest. +type CampaignInput struct { + EventName string + EventSlug string + Project string + BudgetUsd float64 + StartDate string // YYYY-MM-DD + EndDate string // YYYY-MM-DD + TweetID string + RegistrationURL string + HSToken string +} + +// CampaignResult is the outcome of a campaign creation attempt, including a +// step-by-step log. Mirrors the TS TwitterCampaignCreateResult. +type CampaignResult struct { + Platform string + CampaignName string + CampaignID string + LineItemName string + LineItemID string + PromotedTweetID string + // PromotedTweetWarning is non-empty when the promoted-tweet association could + // not be confirmed (POST failed, or returned a malformed/empty response). The + // campaign and line item may still have been created, so the overall call is + // not fatal, but consumers MUST NOT treat a result with this set as an + // unqualified success — the promoted tweet may need to be added manually. + PromotedTweetWarning string + TwitterURL string + Steps []string +} + +var dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) + +// accountIDRe restricts an X Ads account_id / funding_instrument_id to a safe +// charset. These ids interpolate directly into the account-scoped request path +// (accountURL) and into query params, so a value containing a path/query/ +// fragment delimiter ('/', '?', '#') or whitespace/control chars could redirect +// a campaign/list POST to a DIFFERENT account-scoped path (path injection) or +// corrupt the funding param. Real X Ads ids are alphanumeric handles (e.g. +// "18ce54d4x5t"), so restrict to letters and digits — the tightest charset that +// still accepts every real id — and validate up front, before any mutating call. +var accountIDRe = regexp.MustCompile(`^[A-Za-z0-9]+$`) + +// tweetIDRe matches an X Tweet id: a positive decimal snowflake of 1–19 digits +// with no leading zero. A malformed value ("not-a-tweet", "0", or an +// arbitrarily long decimal that can't be a real snowflake) would otherwise reach +// the promoted_tweets POST and be rejected AFTER the campaign and line item are +// already created, leaving a partial/orphaned campaign — so the format is +// validated up front, before any mutating call. (Snowflakes are positive int64s, +// so at most 19 digits; "0" and leading-zero forms are not valid ids.) +var tweetIDRe = regexp.MustCompile(`^[1-9][0-9]{0,18}$`) + +// validateDate enforces both the YYYY-MM-DD shape and that the value is a real +// calendar date. The regex alone accepts impossible dates like "2026-99-99", +// which would be forwarded as a bogus ISO8601 timestamp to the X Ads API; a +// strict time.Parse (which rejects out-of-range months/days) closes that gap +// before any mutating call. label is "start" or "end" for the error message. +func validateDate(label, date string) error { + if !dateRe.MatchString(date) { + return fmt.Errorf("invalid %s date format: %s — expected YYYY-MM-DD", label, date) + } + if _, err := time.Parse("2006-01-02", date); err != nil { + return fmt.Errorf("invalid %s date: %s is not a real calendar date", label, date) + } + return nil +} + +// CreateCampaign runs the campaign -> line_item -> promoted_tweet creation +// flow, reusing existing entities by name for idempotency. It mirrors +// executeTwitterCampaignCreation in the TS. The campaign and line item are +// created PAUSED (entity_status=PAUSED); the promoted-tweet association is +// created ACTIVE by the API (the endpoint does not accept entity_status), but +// the paused line item gates delivery so nothing serves until it is enabled. +func (c *Client) CreateCampaign(ctx context.Context, in CampaignInput) (*CampaignResult, error) { + steps := []string{} + + // Validate EventName before any mutating call: an empty/whitespace value + // would produce identical generic campaign & line-item names for every such + // request, letting the find-by-name lookup silently reuse an unrelated + // campaign. Trim and normalize it up front so every downstream builder sees + // the cleaned value. + in.EventName = strings.TrimSpace(in.EventName) + if in.EventName == "" { + return nil, fmt.Errorf("invalid event name: must not be empty") + } + if utf8.RuneCountInString(in.EventName) > maxEventNameLen { + return nil, fmt.Errorf("invalid event name: exceeds %d characters", maxEventNameLen) + } + + // Validate Project before any mutating call. The Project segment of the + // composed campaign name is the attribution key the data pipeline joins on, so + // it must be the authenticated caller-supplied canonical slug. Defaulting an + // omitted Project to a hardcoded slug (e.g. "tlf") would misattribute a + // non-TLF campaign, so reject an empty/whitespace value outright. Trim and + // store the cleaned value so every downstream builder sees the same input. + in.Project = strings.TrimSpace(in.Project) + if in.Project == "" { + return nil, fmt.Errorf("invalid project: must not be empty") + } + + // Validate budget. Reject NaN/Inf/non-positive, reject values above the + // int64 micro-unit overflow cap, and reject anything that rounds to zero (or + // negative) micro-units — such a value passes a naive >0 check but would send + // a zero/negative daily_budget_amount_local_micro. + if math.IsNaN(in.BudgetUsd) || math.IsInf(in.BudgetUsd, 0) || in.BudgetUsd <= 0 { + return nil, fmt.Errorf("invalid budget: must be a positive number") + } + if in.BudgetUsd > maxBudgetUsd { + return nil, fmt.Errorf("invalid budget: must be at most %v", maxBudgetUsd) + } + if toMicroCurrency(in.BudgetUsd) <= 0 { + return nil, fmt.Errorf("invalid budget: %g rounds to zero micro-units", in.BudgetUsd) + } + if err := validateDate("start", in.StartDate); err != nil { + return nil, err + } + if err := validateDate("end", in.EndDate); err != nil { + return nil, err + } + if in.EndDate <= in.StartDate { + return nil, fmt.Errorf("end date %s must be after start date %s", in.EndDate, in.StartDate) + } + + // Validate the registration URL up front, before any mutating call. In the + // manual-tweet workflow (TweetID omitted) it is the only ad destination and is + // fed into buildTwitterUtmURL, which would otherwise accept an empty/relative/ + // non-http value and emit a corrupt destination. Require a valid absolute + // http/https URL with a real host always, mirroring the reddit/linkedin clients. + if err := validateRegistrationURL(in.RegistrationURL); err != nil { + return nil, err + } + + // Validate the tweet id FORMAT up front, before any mutating call. A blank + // TweetID is optional (it skips the promoted-tweet step below), so only a + // supplied value is checked. X Tweet ids are decimal snowflake ids; a + // non-numeric value ("not-a-tweet") would otherwise reach the promoted_tweets + // POST and be rejected only AFTER the campaign and line item are created, + // leaving a partial campaign. Trim and store the cleaned value so the same + // value is validated here and sent in Step 4. + in.TweetID = strings.TrimSpace(in.TweetID) + if in.TweetID != "" { + if !tweetIDRe.MatchString(in.TweetID) { + return nil, fmt.Errorf("invalid tweet id: %q must be a numeric X Tweet id", in.TweetID) + } + // The 1–19 digit shape still admits values above the max positive int64 + // snowflake (e.g. 9999999999999999999); parse to reject those before any + // mutating call rather than letting X reject tweet_ids after the campaign + // and line item exist. + if _, perr := strconv.ParseInt(in.TweetID, 10, 64); perr != nil { + return nil, fmt.Errorf("invalid tweet id: %q is out of range for an X Tweet id", in.TweetID) + } + } + + // Validate required account config before any mutating call. account_id and + // funding_instrument_id are both required by the X Ads campaign-create + // contract, but a stored connection may persist them as empty (or + // whitespace-only) strings (the connection contract permits + // funding_instrument_id to be omitted). NewClient already trimmed both, so a + // padded " acc1 " is now stored — and thus validated AND sent — as "acc1"; + // checking the stored (trimmed) value here means a whitespace-only input is + // rejected outright rather than corrupting the account path / funding param. + if c.account.AccountID == "" { + return nil, fmt.Errorf("invalid account config: account_id must not be empty") + } + if !accountIDRe.MatchString(c.account.AccountID) { + // A non-empty check is not enough: account_id is interpolated into the + // account-scoped request path (accountURL), so a value with '/', '?', '#', + // or whitespace/control chars could redirect this POST to a different + // account path. Reject anything outside the safe alphanumeric charset. + return nil, fmt.Errorf("invalid account config: account_id %q must contain only letters and digits", c.account.AccountID) + } + if c.account.FundingInstrumentID == "" { + return nil, fmt.Errorf("invalid account config: funding_instrument_id must not be empty") + } + if !accountIDRe.MatchString(c.account.FundingInstrumentID) { + return nil, fmt.Errorf("invalid account config: funding_instrument_id %q must contain only letters and digits", c.account.FundingInstrumentID) + } + + // Compose and validate the entity names before ANY network call: even with + // EventName and Project individually bounded, the composed campaign / line-item + // names can exceed X's 255-rune entity-name limit, so reject an oversized name + // up front rather than after a wasted account-verify / lookup round trip. + campaignName := buildTwitterCampaignName(in) + if err := validateEntityName("campaign", campaignName); err != nil { + return nil, err + } + lineItemName := fmt.Sprintf("Events | %s | Promoted Tweets | AUTO", strings.ReplaceAll(in.EventName, "|", "-")) + if err := validateEntityName("line item", lineItemName); err != nil { + return nil, err + } + + // Step 1: verify account (non-fatal). + c.verifyAccount(ctx, &steps) + + // Step 2: create campaign (PAUSED), reusing by name. + campaignID, err := c.findCampaignByName(ctx, campaignName) + if err != nil { + return nil, err + } + // Track whether the campaign was created by THIS call or reused from a prior + // one, so downstream partial-failure messages don't claim "created" for a + // resource this call merely found. + campaignReused := campaignID != "" + if campaignID != "" { + // Find-or-create is idempotent by name, but a reused campaign may have been + // created with a DIFFERENT budget/config than THIS request carries (e.g. a + // re-dispatch with a corrected BudgetUsd). We deliberately do NOT update the + // campaign here — that is a separate PUT endpoint and an authoritative + // reconcile is the orchestrator's job (LFXV2-2665). Surface the divergence as + // a warning step (mirroring the promoted-tweet warning pattern) so an operator + // can see the existing config was NOT changed to match this request. + steps = append(steps, fmt.Sprintf("Reusing existing campaign: %s", campaignID)) + steps = append(steps, fmt.Sprintf("Warning: reused existing campaign %s by name; its budget/config were NOT updated to match this request ($%.2f/day) — verify/reconcile in X Ads Manager", campaignID, in.BudgetUsd)) + } else { + // X Ads v12 create endpoints take parameters as URL query params (not a + // JSON body), and use entity_status=PAUSED (not paused=true). Note: the + // campaign endpoint does NOT accept start_time/end_time in v12 — flight + // dates belong on the line item (sent below); including them here gets the + // campaign create rejected. + campaignParams := map[string]string{ + "name": campaignName, + "funding_instrument_id": c.account.FundingInstrumentID, + "daily_budget_amount_local_micro": strconv.FormatInt(toMicroCurrency(in.BudgetUsd), 10), + "entity_status": "PAUSED", + } + // These inter-request sleeps pace THIS dispatch's own sequential writes + // (campaign -> line item -> promoted tweet) to stay under X's per-second + // write rate. They do NOT enforce X's account-wide write limit across + // concurrent or replicated dispatches: this service dispatches jobs async + // (possibly across replicas), and separately-constructed clients in + // different goroutines/processes can wake and POST at the same instant. + // Correct account-wide limiting needs shared cross-replica coordination + // (a distributed limiter or the orchestrator serializing per account), + // which is out of scope for this stateless per-request client and is + // tracked by LFXV2-2665 (durable dispatch). If the account limit is hit + // anyway, the 429 exponential-backoff retry in doRequest is the backstop. + if err := c.pace(ctx); err != nil { + return nil, err + } + resp, err := c.createRequest(ctx, "campaigns", campaignParams) + if err != nil { + return nil, err + } + campaignID = extractID(resp) + if campaignID == "" { + return nil, fmt.Errorf("x campaign creation succeeded but returned no campaign ID") + } + steps = append(steps, fmt.Sprintf("Campaign created: %s (PAUSED, $%.2f/day)", campaignID, in.BudgetUsd)) + } + + // partialResult builds a *CampaignResult carrying the already-created (PAUSED) + // campaign — and, once known, the line item — plus the steps completed so far. + // It is returned ALONGSIDE the error at every downstream failure point after the + // campaign POST already succeeded, so an orphaned paid resource is identifiable + // for cleanup/reconcile and a caller retry can reconcile it instead of blindly + // creating a duplicate. This only makes the orphan IDENTIFIABLE — it does not + // resume creation. True retry-safe idempotency (not re-creating the campaign / + // line item on retry) needs provider idempotency keys / the orchestrator claim, + // tracked in LFXV2-2665. Mirrors the meta/reddit clients' partial-result helper. + // lineItemID is captured by reference so the returned result includes it once + // Step 3 has created it. + var lineItemID string + var lineItemReused bool + partialResult := func() *CampaignResult { + return &CampaignResult{ + Platform: "twitter-ads", + CampaignName: campaignName, + CampaignID: campaignID, + LineItemName: lineItemName, + LineItemID: lineItemID, + TwitterURL: AdsManagerURL, + Steps: steps, + } + } + // campaignStatus / lineItemStatus describe, in partial-failure error messages, + // whether the resource that already exists was CREATED by this call or REUSED + // (found by name). Wording a reused resource as "created" is misleading during + // cleanup/reconcile, so the message reflects the actual provenance. + campaignStatus := func() string { + if campaignReused { + return fmt.Sprintf("campaign %s reused, PRE-EXISTING", campaignID) + } + return fmt.Sprintf("campaign %s created, PAUSED", campaignID) + } + lineItemStatus := func() string { + if lineItemReused { + return fmt.Sprintf("line item %s reused, PRE-EXISTING", lineItemID) + } + return fmt.Sprintf("line item %s created, PAUSED", lineItemID) + } + + // Step 3: create line item (ad group), reusing by name. + lineItemID, err = c.findLineItemByName(ctx, campaignID, lineItemName) + if err != nil { + return partialResult(), fmt.Errorf("x line item lookup failed (%s): %w", campaignStatus(), err) + } + if lineItemID != "" { + lineItemReused = true + // A same-name line item is reused without re-checking its entity_status or + // flight dates. If it was previously ENABLED, the promoted-tweet POST below + // attaches an ACTIVE association to a line item that could be serving — the + // PAUSED/flight gating this request expects is NOT re-applied. We do NOT PATCH + // the line item to PAUSED here (separate endpoint; authoritative reconcile is + // the orchestrator's job, LFXV2-2665). Surface a warning step so an operator + // knows delivery may not be gated as expected. + steps = append(steps, fmt.Sprintf("Reusing existing line item: %s", lineItemID)) + steps = append(steps, fmt.Sprintf("Warning: reused existing line item %s by name; its entity_status/flight dates were NOT reset to the requested PAUSED/%s–%s — it may already be ENABLED and serving; verify in X Ads Manager", lineItemID, in.StartDate, in.EndDate)) + } else { + // X Ads v12 line_items: params go on the query string; start_time and + // end_time are REQUIRED; bid_strategy=AUTO selects automatic bidding + // (the field is bid_strategy in v12, not bid_type); entity_status + // replaces the removed paused flag. + lineItemParams := map[string]string{ + "campaign_id": campaignID, + "name": lineItemName, + "product_type": "PROMOTED_TWEETS", + "placements": "ALL_ON_TWITTER", + "objective": "WEBSITE_CLICKS", + "bid_strategy": "AUTO", + "start_time": toIso8601Utc(in.StartDate), + "end_time": toIso8601Utc(in.EndDate), + "entity_status": "PAUSED", + } + if err := c.pace(ctx); err != nil { + return partialResult(), fmt.Errorf("x line item creation aborted (%s): %w", campaignStatus(), err) + } + resp, err := c.createRequest(ctx, "line_items", lineItemParams) + if err != nil { + return partialResult(), fmt.Errorf("x line item creation failed (%s): %w", campaignStatus(), err) + } + lineItemID = extractID(resp) + if lineItemID == "" { + return partialResult(), fmt.Errorf("x line item creation succeeded but returned no line item ID (%s)", campaignStatus()) + } + steps = append(steps, fmt.Sprintf("Line item created: %s (PAUSED, ALL_ON_TWITTER, AUTO bid)", lineItemID)) + } + + // Step 4: create promoted tweet if a tweet ID was provided. in.TweetID was + // already trimmed AND format-validated (numeric) in the up-front validation + // block, so a whitespace-only value (" ") is treated as absent, a padded + // value (" 123 ") is sent as "123", and a non-numeric value never reaches + // here — it fails before the campaign + line item are created. + tweetID := in.TweetID + var promotedTweetID string + var promotedTweetWarning string + if tweetID != "" { + if err := c.pace(ctx); err != nil { + // The campaign AND line item are already created (both PAUSED). Returning + // a nil result would discard both IDs, preventing cleanup/reconciliation + // and letting a caller retry create a duplicate. Return a partial result + // carrying both IDs (and the steps so far) alongside the wrapped error. + return partialResult(), fmt.Errorf("x promoted tweet creation aborted (%s / %s): %w", campaignStatus(), lineItemStatus(), err) + } + // The promoted_tweets endpoint does not accept entity_status; the API + // creates the association ACTIVE. Delivery is still gated by the PAUSED + // line item above, so we intentionally send only the association params. + // This POST is always re-issued on a repeated CreateCampaign (unlike the + // find-or-create campaign/line-item steps), so a lost first response can + // make the retry hit a duplicate — handled below. + resp, err := c.createRequest(ctx, "promoted_tweets", map[string]string{ + "line_item_id": lineItemID, + "tweet_ids": tweetID, + }) + switch { + case err != nil && isDuplicatePromotedTweetErr(err): + // X reports the tweet is already promoted (DUPLICATE_PROMOTABLE_ENTITY). + // This is NOT proof the tweet is attached to THIS line item — X returns + // the same code when the tweet is promoted by a DIFFERENT line item — so + // we do NOT treat it as idempotent success. Surface a warning (step + + // result field) so the association is verified manually. Non-fatal: the + // campaign and line item still return. + promotedTweetWarning = fmt.Sprintf("promoted-tweet association for tweet %s may already exist (X returned DUPLICATE_PROMOTABLE_ENTITY), possibly on a different line item — verify manually in X Ads Manager", tweetID) + steps = append(steps, fmt.Sprintf("Promoted tweet reported as duplicate for line item %s (tweet: %s) — the association may already exist (possibly on a different line item); verify manually in X Ads Manager", lineItemID, tweetID)) + case err != nil: + // A real POST failure. Do NOT report unqualified success: record a + // warning both in the step log and on the result so the caller can see + // the promoted tweet may not have been created/associated. + promotedTweetWarning = fmt.Sprintf("promoted-tweet POST failed for tweet %s: %s", tweetID, err.Error()) + steps = append(steps, fmt.Sprintf("Promoted tweet creation failed: %s — add manually in X Ads Manager", err.Error())) + default: + promotedTweetID = extractPromotedTweetID(resp) + if promotedTweetID != "" { + steps = append(steps, fmt.Sprintf("Promoted tweet created: %s (tweet: %s; created ACTIVE by the API but held from serving by the PAUSED line item)", promotedTweetID, tweetID)) + } else { + // A 2xx response missing data.id is a malformed success: don't + // silently treat it as done. Surface a warning (step + result field) + // so the gap is visible without making the whole flow fatal. + promotedTweetWarning = fmt.Sprintf("promoted-tweet POST returned no ID (malformed response) for tweet %s", tweetID) + steps = append(steps, fmt.Sprintf("Promoted tweet creation returned no promoted-tweet ID (malformed response, tweet: %s) — add it manually in X Ads Manager", tweetID)) + } + } + } else { + utmURL := buildTwitterUtmURL(in) + steps = append(steps, "No tweet ID provided — post a tweet manually, then add it as a promoted tweet in X Ads Manager") + steps = append(steps, fmt.Sprintf("Destination URL with UTM: %s", utmURL)) + } + + return &CampaignResult{ + Platform: "twitter-ads", + CampaignName: campaignName, + CampaignID: campaignID, + LineItemName: lineItemName, + LineItemID: lineItemID, + PromotedTweetID: promotedTweetID, + PromotedTweetWarning: promotedTweetWarning, + TwitterURL: AdsManagerURL, + Steps: steps, + }, nil +} + +// verifyAccount performs a best-effort account lookup, appending a step. It goes +// through doRequest (an empty path targets the account root) so it gets the SAME +// OAuth1 signing and 429 rate-limit retry/backoff as every other call — unlike +// the earlier version, which fired httpClient.Do directly and thus skipped the +// shared retry path. All failures remain non-fatal (mirrors the TS Step 1 +// try/catch): doRequest surfaces a non-2xx status as an error, which is recorded +// here as a warning step and NOT propagated, so verification never aborts +// CreateCampaign. +func (c *Client) verifyAccount(ctx context.Context, steps *[]string) { + resp, err := c.request(ctx, http.MethodGet, "") + if err != nil { + *steps = append(*steps, fmt.Sprintf("Account verification warning: %s", err.Error())) + return + } + name := c.account.AccountID + if resp != nil && len(resp.Data) > 0 { + var obj struct { + Name string `json:"name"` + } + if err := json.Unmarshal(resp.Data, &obj); err == nil && obj.Name != "" { + name = obj.Name + } + } + *steps = append(*steps, fmt.Sprintf("Account verified: %s", name)) +} + +// extractID reads data.id from a response envelope. +func extractID(resp *apiResponse) string { + if resp == nil || len(resp.Data) == 0 { + return "" + } + var obj struct { + ID string `json:"id"` + } + if err := json.Unmarshal(resp.Data, &obj); err == nil { + return obj.ID + } + return "" +} + +// isDuplicatePromotedTweetErr reports whether err from a promoted_tweets POST is +// X's DUPLICATE_PROMOTABLE_ENTITY rejection. Because doRequest surfaces non-2xx +// bodies as the error string, we match X's recognizable error code. A match does +// NOT prove this tweet is attached to THIS line item: X returns this code when +// the tweet is already promoted by a DIFFERENT line item, so it cannot be treated +// as idempotent success — callers surface it as a warning to verify manually +// rather than as an unqualified success. NOTE: true cross-call idempotency +// (idempotency keys sent to X) is tracked in LFXV2-2665. +func isDuplicatePromotedTweetErr(err error) bool { + if err == nil { + return false + } + // Match only X's specific DUPLICATE_PROMOTABLE_ENTITY error code. Broad + // substring matches on "already promoted"/"already associated" widened the net + // to messages that don't actually prove a duplicate, so drop them: the code is + // the recognizable, unambiguous signal. + s := strings.ToLower(err.Error()) + return strings.Contains(s, "duplicate_promotable_entity") +} + +// extractPromotedTweetID reads the promoted tweet id, which the X Ads API +// returns as an array (data[0].id) or occasionally a single object. +func extractPromotedTweetID(resp *apiResponse) string { + if resp == nil || len(resp.Data) == 0 { + return "" + } + var arr []struct { + ID string `json:"id"` + } + if err := json.Unmarshal(resp.Data, &arr); err == nil { + if len(arr) > 0 { + return arr[0].ID + } + return "" + } + return extractID(resp) +} diff --git a/internal/platform/twitter/client_test.go b/internal/platform/twitter/client_test.go new file mode 100644 index 00000000..dfdea21a --- /dev/null +++ b/internal/platform/twitter/client_test.go @@ -0,0 +1,2738 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package twitter + +import ( + "context" + "crypto/hmac" + "crypto/sha1" //nolint:gosec // OAuth 1.0a mandates HMAC-SHA1; test mirrors production signing. + "encoding/base64" + "encoding/json" + "io" + "math" + "net/http" + "net/http/httptest" + "net/url" + "sort" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + "unicode/utf8" +) + +// staticTime is a fixed clock used to make OAuth signing deterministic in tests. +func staticTime() time.Time { return time.Unix(1600000000, 0).UTC() } + +// TestGenerateOAuthSignature verifies HMAC-SHA1 signing against a fixed input. +// This is the highest-value test: the signature must be byte-for-byte correct +// or every X Ads API call fails. Inputs are modeled on the RFC 5849 OAuth 1.0a +// worked example (single-valued form of its parameter set). The golden digest +// below is the HMAC-SHA1/base64 over the canonical signature base string these +// exact inputs produce; any conformant OAuth 1.0a implementation reproduces it. +func TestGenerateOAuthSignature(t *testing.T) { + method := "POST" + baseURL := "http://example.com/request" + params := map[string]string{ + "b5": "=%3D", + "a3": "a", + "c@": "", + "a2": "r b", + "oauth_consumer_key": "9djdj82h48djs9d2", + "oauth_token": "kkk9d7dh3k39sjv7", + "oauth_signature_method": "HMAC-SHA1", + "oauth_timestamp": "137131201", + "oauth_nonce": "7d8f3e4a", + "c2": "", + } + consumerSecret := "j49sk3j29djd" + tokenSecret := "dh893hdasih9" + + got := generateOAuthSignature(method, baseURL, params, nil, consumerSecret, tokenSecret) + + // Golden digest for the RFC 5849 §3.4.1.3.2 normalization: parameters sorted + // by their PERCENT-ENCODED name (so "c@"->"c%40" precedes "c2"), then by + // encoded value on ties. The normalized base-string param portion is + // "a2=r%20b&a3=a&b5=%3D%253D&c%40=&c2=&oauth_...". Any conformant OAuth 1.0a + // implementation reproduces this digest. + const want = "AYgdIfljDYmBX3Ce9owrBekam04=" + if got != want { + t.Fatalf("signature mismatch:\n got=%q\nwant=%q", got, want) + } +} + +// TestOAuthSignatureParamOrdering proves parameters are normalized by their +// PERCENT-ENCODED name, not the raw key: "c@" encodes to "c%40" and must sort +// BEFORE "c2" (because '%'=0x25 < '2'=0x32), even though raw '@'=0x40 sorts +// after '2'. Two signatures whose only difference is the param ordering rule +// would diverge; here we assert the value is stable and matches an independent +// encode-then-sort computation over the same params. +func TestOAuthSignatureParamOrdering(t *testing.T) { + // Sanity-check the byte-ordering claim itself. + if percentEncode("c@") >= percentEncode("c2") { + t.Fatalf("expected percentEncode(c@)=%q to sort before percentEncode(c2)=%q", + percentEncode("c@"), percentEncode("c2")) + } + + params := map[string]string{ + "c@": "1", + "c2": "2", + } + // Reference: encode each pair, sort the encoded pairs, join with '&'. This is + // exactly the normalization generateOAuthSignature must perform. + wantParamString := "c%40=1&c2=2" + if strings.Compare("c%40=1", "c2=2") >= 0 { + t.Fatalf("test premise wrong: %q should sort before %q", "c%40=1", "c2=2") + } + + // If the implementation sorted by raw key, "c2" would precede "c@" and the + // signature would differ. Recompute the expected signature with a known-good + // local reference and compare. + got := generateOAuthSignature("POST", "https://ads-api.x.com/12/accounts/acc1", params, nil, "cs", "ts") + want := referenceSignature("POST", "https://ads-api.x.com/12/accounts/acc1", wantParamString, "cs", "ts") + if got != want { + t.Fatalf("signature not built from percent-encoded-name ordering:\n got=%q\nwant=%q", got, want) + } +} + +// TestOAuthSignaturePrefixNameOrdering proves parameters are sorted by (encoded +// name, encoded value) as a TUPLE, not by the joined "name=value" string. When +// one encoded name is a prefix of another the two rules diverge: RFC 5849 +// §3.4.1.3.2 orders by name first, so "a" < "a1" and the normalized string is +// "a=&a1=". Sorting the joined form instead compares "a=" against +// "a1=" and, at index 1, '=' (0x3D) loses to '1' (0x31) — so "a1=" would +// sort FIRST, producing the WRONG "a1=&a=". This test asserts the correct +// tuple ordering and would fail under the old joined-string sort. +func TestOAuthSignaturePrefixNameOrdering(t *testing.T) { + // Prove the two sort rules genuinely disagree for these inputs, so the test + // is meaningful: joined-string sort puts "a1=..." before "a=...". + joined := []string{"a=va", "a1=v1"} + if joined[1] >= joined[0] { + t.Fatalf("test premise wrong: joined-string sort should misorder %q before %q", joined[1], joined[0]) + } + + params := map[string]string{ + "a": "va", + "a1": "v1", + } + // RFC-correct normalization: by name first (a < a1), giving "a=va&a1=v1". + wantParamString := "a=va&a1=v1" + + got := generateOAuthSignature("POST", "https://ads-api.x.com/12/accounts/acc1", params, nil, "cs", "ts") + want := referenceSignature("POST", "https://ads-api.x.com/12/accounts/acc1", wantParamString, "cs", "ts") + if got != want { + t.Fatalf("prefix-name params not tuple-sorted (name then value):\n got=%q\nwant=%q", got, want) + } + + // Guard against the specific wrong answer the joined-string sort would give, + // so a regression is caught even if referenceSignature ever changed shape. + wrong := referenceSignature("POST", "https://ads-api.x.com/12/accounts/acc1", "a1=v1&a=va", "cs", "ts") + if got == wrong { + t.Fatalf("signature matches the joined-string (misordered) normalization: %q", got) + } +} + +// referenceSignature is an independent HMAC-SHA1/base64 over the OAuth 1.0a +// base string, given an already-normalized param string, used to pin ordering. +func referenceSignature(method, u, paramString, consumerSecret, tokenSecret string) string { + base := strings.ToUpper(method) + "&" + percentEncode(u) + "&" + percentEncode(paramString) + key := percentEncode(consumerSecret) + "&" + percentEncode(tokenSecret) + mac := hmac.New(sha1.New, []byte(key)) + mac.Write([]byte(base)) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} + +// TestBuildOAuthHeaderDeterministic verifies the full Authorization header with +// injected nonce + timestamp, so the whole signing path is assertable. +func TestBuildOAuthHeaderDeterministic(t *testing.T) { + c := NewClient( + Credentials{ + ConsumerKey: "ck", + ConsumerSecret: "cs", + AccessToken: "at", + AccessTokenSecret: "ats", + }, + AccountConfig{AccountID: "acc1"}, + ) + c.nonceFn = func() string { return "fixednonce" } + c.timeFn = staticTime + + hdr, err := c.buildOAuthHeader("GET", "https://ads-api.x.com/12/accounts/acc1", nil) + if err != nil { + t.Fatalf("buildOAuthHeader: %v", err) + } + if !strings.HasPrefix(hdr, "OAuth ") { + t.Fatalf("header missing OAuth prefix: %q", hdr) + } + for _, want := range []string{ + `oauth_consumer_key="ck"`, + `oauth_nonce="fixednonce"`, + `oauth_signature_method="HMAC-SHA1"`, + `oauth_timestamp="1600000000"`, + `oauth_token="at"`, + `oauth_version="1.0"`, + `oauth_signature="`, + } { + if !strings.Contains(hdr, want) { + t.Errorf("header missing %q\nfull: %s", want, hdr) + } + } +} + +// TestBuildOAuthHeaderSignsQueryParams verifies that query-string parameters on +// the request URL are folded into the OAuth 1.0a signature base string. This is +// the critical create-POST signing path: X carries create params on the query +// string, and if the query-param signing loop were removed the Authorization +// header would still be well-formed but the signature would be computed over the +// oauth params ALONE — and X would reject every create. We recompute an +// independent reference signature over method + base-URL(no query) + the sorted, +// percent-encoded union of oauth params AND query params (RFC 5849 §3.4.1), and +// assert equality; mutating the query-param loop must break this test. +func TestBuildOAuthHeaderSignsQueryParams(t *testing.T) { + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1"}, + ) + c.nonceFn = func() string { return "fixednonce" } + c.timeFn = staticTime + + // A create-style URL: base + path + query params (name, funding, entity_status). + baseURL := "https://ads-api.x.com/12/accounts/acc1/campaigns" + rawURL := baseURL + "?name=KubeCon+EU&funding_instrument_id=fi1&entity_status=PAUSED" + + hdr, err := c.buildOAuthHeader("POST", rawURL, nil) + if err != nil { + t.Fatalf("buildOAuthHeader: %v", err) + } + gotSig := extractOAuthSignature(t, hdr) + + // Independent reference: the full signed param set is the deterministic oauth + // params PLUS the query params, normalized (encode name+value, sort, join). + allParams := map[string]string{ + "oauth_consumer_key": "ck", + "oauth_nonce": "fixednonce", + "oauth_signature_method": "HMAC-SHA1", + "oauth_timestamp": strconv.FormatInt(staticTime().Unix(), 10), + "oauth_token": "at", + "oauth_version": "1.0", + "name": "KubeCon EU", + "funding_instrument_id": "fi1", + "entity_status": "PAUSED", + } + parts := make([]string, 0, len(allParams)) + for k, v := range allParams { + parts = append(parts, percentEncode(k)+"="+percentEncode(v)) + } + sort.Strings(parts) + wantSig := referenceSignature("POST", baseURL, strings.Join(parts, "&"), "cs", "ats") + + if gotSig != wantSig { + t.Fatalf("query params not folded into signature:\n got=%q\nwant=%q", gotSig, wantSig) + } + + // Guard against a false positive: the signature over the oauth params ALONE + // (the query params dropped) must differ from wantSig — otherwise this test + // couldn't distinguish a working signing loop from a removed one. + oauthOnly := map[string]string{ + "oauth_consumer_key": "ck", + "oauth_nonce": "fixednonce", + "oauth_signature_method": "HMAC-SHA1", + "oauth_timestamp": strconv.FormatInt(staticTime().Unix(), 10), + "oauth_token": "at", + "oauth_version": "1.0", + } + oparts := make([]string, 0, len(oauthOnly)) + for k, v := range oauthOnly { + oparts = append(oparts, percentEncode(k)+"="+percentEncode(v)) + } + sort.Strings(oparts) + oauthOnlySig := referenceSignature("POST", baseURL, strings.Join(oparts, "&"), "cs", "ats") + if wantSig == oauthOnlySig { + t.Fatal("test cannot detect a dropped query-param signing loop: oauth-only signature equals full signature") + } + if gotSig == oauthOnlySig { + t.Fatalf("signature was computed over oauth params alone; query params not signed: %q", gotSig) + } +} + +// TestBuildOAuthHeaderMultiValuedQuery verifies that a repeated query parameter +// (a=1&a=2) has BOTH values folded into the signature base string per RFC 5849 +// §3.4.1.3.2. Collapsing to a single value per key would silently sign the wrong +// request. +func TestBuildOAuthHeaderMultiValuedQuery(t *testing.T) { + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1"}, + ) + c.nonceFn = func() string { return "fixednonce" } + c.timeFn = staticTime + + baseURL := "https://ads-api.x.com/12/accounts/acc1/campaigns" + rawURL := baseURL + "?a=1&a=2&b=x" + + hdr, err := c.buildOAuthHeader("POST", rawURL, nil) + if err != nil { + t.Fatalf("buildOAuthHeader: %v", err) + } + gotSig := extractOAuthSignature(t, hdr) + + // Reference: both values of "a" must appear. Build the sorted param string by + // (encoded name, encoded value) — matching the signing loop's tuple sort. + type pair struct{ n, v string } + pairs := []pair{ + {"oauth_consumer_key", "ck"}, + {"oauth_nonce", "fixednonce"}, + {"oauth_signature_method", "HMAC-SHA1"}, + {"oauth_timestamp", strconv.FormatInt(staticTime().Unix(), 10)}, + {"oauth_token", "at"}, + {"oauth_version", "1.0"}, + {"a", "1"}, + {"a", "2"}, + {"b", "x"}, + } + parts := make([]string, 0, len(pairs)) + for _, p := range pairs { + parts = append(parts, percentEncode(p.n)+"="+percentEncode(p.v)) + } + sort.Strings(parts) + wantSig := referenceSignature("POST", baseURL, strings.Join(parts, "&"), "cs", "ats") + if gotSig != wantSig { + t.Fatalf("multi-valued query param not fully signed:\n got=%q\nwant=%q", gotSig, wantSig) + } + + // Guard: dropping the second value of "a" must yield a DIFFERENT signature, + // so this test actually detects a single-value collapse. + dropped := make([]string, 0, len(pairs)-1) + for _, p := range pairs { + if p.n == "a" && p.v == "2" { + continue + } + dropped = append(dropped, percentEncode(p.n)+"="+percentEncode(p.v)) + } + sort.Strings(dropped) + collapsedSig := referenceSignature("POST", baseURL, strings.Join(dropped, "&"), "cs", "ats") + if wantSig == collapsedSig { + t.Fatal("test cannot detect a collapsed multi-value: full and single-value signatures match") + } + if gotSig == collapsedSig { + t.Fatalf("only the first value of a repeated query key was signed: %q", gotSig) + } +} + +// TestWithBaseURLTrimsTrailingSlash verifies a trailing slash on the base URL +// does not produce a double-slash account path (which would be signed and sent +// verbatim and could break signature verification). +func TestWithBaseURLTrimsTrailingSlash(t *testing.T) { + for _, in := range []string{"https://ads-api.x.com/", "https://ads-api.x.com///"} { + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1"}, + WithBaseURL(in), + WithAPIVersion("12"), + ) + got := c.accountURL() + want := "https://ads-api.x.com/12/accounts/acc1" + if got != want { + t.Errorf("WithBaseURL(%q): accountURL = %q, want %q", in, got, want) + } + } +} + +// extractOAuthSignature pulls the oauth_signature value out of an +// "OAuth k=\"v\", ..." Authorization header and percent-decodes it. +func extractOAuthSignature(t *testing.T, hdr string) string { + t.Helper() + const key = `oauth_signature="` + i := strings.Index(hdr, key) + if i < 0 { + t.Fatalf("no oauth_signature in header: %q", hdr) + } + rest := hdr[i+len(key):] + j := strings.Index(rest, `"`) + if j < 0 { + t.Fatalf("unterminated oauth_signature in header: %q", hdr) + } + dec, err := url.QueryUnescape(rest[:j]) + if err != nil { + t.Fatalf("decode signature: %v", err) + } + return dec +} + +func TestPercentEncode(t *testing.T) { + cases := map[string]string{ + "a b": "a%20b", + "r b": "r%20b", + "=%3D": "%3D%253D", + "AZaz09-._~": "AZaz09-._~", + "!'()*": "%21%27%28%29%2A", + "a3": "a3", + "c@": "c%40", + } + for in, want := range cases { + if got := percentEncode(in); got != want { + t.Errorf("percentEncode(%q) = %q, want %q", in, got, want) + } + } +} + +func TestMicroCurrencyRoundTrip(t *testing.T) { + cases := []struct { + usd float64 + micro int64 + }{ + {1, 1_000_000}, + {0.01, 10_000}, + {100.50, 100_500_000}, + {1234.56, 1_234_560_000}, + } + for _, tc := range cases { + if got := toMicroCurrency(tc.usd); got != tc.micro { + t.Errorf("toMicroCurrency(%v) = %d, want %d", tc.usd, got, tc.micro) + } + if got := fromMicroCurrency(tc.micro); got != tc.usd { + t.Errorf("fromMicroCurrency(%d) = %v, want %v", tc.micro, got, tc.usd) + } + } + + // Rounding: 0.1 * 1_000_000 must round cleanly to 100000. + if got := toMicroCurrency(0.1); got != 100_000 { + t.Errorf("toMicroCurrency(0.1) = %d, want 100000", got) + } +} + +func TestToIso8601Utc(t *testing.T) { + if got := toIso8601Utc("2026-07-09"); got != "2026-07-09T00:00:00Z" { + t.Errorf("toIso8601Utc = %q", got) + } +} + +func TestBuildTwitterCampaignName(t *testing.T) { + got := buildTwitterCampaignName(CampaignInput{EventName: "KubeCon | EU", Project: "CNCF"}) + want := "Events | KubeCon - EU | Global | Awareness | Prospecting | Promoted Post | CNCF | MoFU" + if got != want { + t.Errorf("campaign name = %q, want %q", got, want) + } +} + +func TestBuildTwitterUtmURL(t *testing.T) { + got := buildTwitterUtmURL(CampaignInput{ + EventName: "Open Source Summit", + RegistrationURL: "https://events.lf.org/oss/", + }) + if !strings.HasPrefix(got, "https://events.lf.org/oss/?") { + t.Errorf("bad base: %q", got) + } + for _, want := range []string{ + "utm_source=twitter", + "utm_medium=paid-social", + "utm_campaign=open-source-summit", + "utm_term=open-source-summit", + "utm_content=promoted-tweet", + } { + if !strings.Contains(got, want) { + t.Errorf("utm url missing %q: %s", want, got) + } + } + + // Existing query params are preserved (merged into the query). + got2 := buildTwitterUtmURL(CampaignInput{ + EventName: "Event", + RegistrationURL: "https://x.com/reg?ref=1", + }) + if !strings.Contains(got2, "ref=1") || !strings.Contains(got2, "utm_source=twitter") { + t.Errorf("existing query param not preserved alongside utm: %s", got2) + } + + // A URL fragment must stay at the END (query before #, not inside it). + got3 := buildTwitterUtmURL(CampaignInput{ + EventName: "Event", + RegistrationURL: "https://x.com/reg#details", + }) + if !strings.HasSuffix(got3, "#details") || strings.Contains(got3, "#details?") { + t.Errorf("fragment not preserved at end: %s", got3) + } +} + +// TestRetryOn429 verifies the client retries after a 429 then succeeds. +func TestRetryOn429(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", "1") + w.WriteHeader(http.StatusTooManyRequests) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":{"id":"cmp123"}}`)) + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1"}, + WithBaseURL(srv.URL), + WithAPIVersion("12"), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + resp, err := c.createRequest(context.Background(), "campaigns", map[string]string{"name": "x"}) + if err != nil { + t.Fatalf("request: %v", err) + } + if got := atomic.LoadInt32(&calls); got != 2 { + t.Errorf("expected 2 calls (1 retry), got %d", got) + } + if id := extractID(resp); id != "cmp123" { + t.Errorf("extractID = %q, want cmp123", id) + } +} + +// TestParseRetryAfter covers all three headers: Retry-After is a delay in +// seconds (or an HTTP-date); the *-Rate-Limit-Reset headers are Unix epochs that +// must be converted to time-until-reset (not treated as a raw delay, which would +// always saturate the cap). Header precedence mirrors the X Ads SDK: +// X-Account-Rate-Limit-Reset first, then X-Rate-Limit-Reset, then Retry-After. +func TestParseRetryAfter(t *testing.T) { + c := NewClient(Credentials{}, AccountConfig{}) + c.timeFn = staticTime // now = epoch 1600000000 + + mk := func(h map[string]string) *http.Response { + r := &http.Response{Header: http.Header{}} + for k, v := range h { + r.Header.Set(k, v) + } + return r + } + + // Retry-After: 5 -> 5s. + if got := c.parseRetryAfter(mk(map[string]string{"Retry-After": "5"})); got != 5*time.Second { + t.Errorf("Retry-After=5: got %v, want 5s", got) + } + // X-Rate-Limit-Reset 30s in the future -> ~30s, NOT a decades-long duration. + reset := strconv.FormatInt(staticTime().Unix()+30, 10) + if got := c.parseRetryAfter(mk(map[string]string{"X-Rate-Limit-Reset": reset})); got != 30*time.Second { + t.Errorf("X-Rate-Limit-Reset(+30s): got %v, want 30s", got) + } + // A reset already in the past -> 0 (fall back to backoff), never negative. + past := strconv.FormatInt(staticTime().Unix()-10, 10) + if got := c.parseRetryAfter(mk(map[string]string{"X-Rate-Limit-Reset": past})); got != 0 { + t.Errorf("X-Rate-Limit-Reset(past): got %v, want 0", got) + } + // X-Account-Rate-Limit-Reset (account-scoped) 45s in the future -> ~45s. + acct := strconv.FormatInt(staticTime().Unix()+45, 10) + if got := c.parseRetryAfter(mk(map[string]string{"X-Account-Rate-Limit-Reset": acct})); got != 45*time.Second { + t.Errorf("X-Account-Rate-Limit-Reset(+45s): got %v, want 45s", got) + } + // Precedence: X-Account-Rate-Limit-Reset must win over X-Rate-Limit-Reset and + // Retry-After, mirroring the X Ads SDK. Account reset (+45s) is checked first + // even though the endpoint reset (+30s) and Retry-After (5s) are shorter. + if got := c.parseRetryAfter(mk(map[string]string{ + "X-Account-Rate-Limit-Reset": acct, + "X-Rate-Limit-Reset": reset, + "Retry-After": "5", + })); got != 45*time.Second { + t.Errorf("account header precedence: got %v, want 45s", got) + } + // A past account reset falls through to the next header (X-Rate-Limit-Reset). + acctPast := strconv.FormatInt(staticTime().Unix()-10, 10) + if got := c.parseRetryAfter(mk(map[string]string{ + "X-Account-Rate-Limit-Reset": acctPast, + "X-Rate-Limit-Reset": reset, + })); got != 30*time.Second { + t.Errorf("past account reset should fall through to endpoint reset: got %v, want 30s", got) + } + // Retry-After as an HTTP-date 20s in the future -> ~20s. + httpDate := staticTime().Add(20 * time.Second).UTC().Format(http.TimeFormat) + if got := c.parseRetryAfter(mk(map[string]string{"Retry-After": httpDate})); got != 20*time.Second { + t.Errorf("Retry-After(HTTP-date +20s): got %v, want 20s", got) + } + // No headers -> 0. + if got := c.parseRetryAfter(mk(nil)); got != 0 { + t.Errorf("no headers: got %v, want 0", got) + } +} + +// TestRetryExhausted verifies persistent 429s exhaust retries and error out. +func TestRetryExhausted(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", "1") + w.WriteHeader(http.StatusTooManyRequests) + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + _, err := c.request(context.Background(), http.MethodGet, "campaigns") + if err == nil { + t.Fatal("expected error after exhausted retries") + } + // retryMax=3 -> attempts 0..3 that all 429; last attempt (==retryMax) does + // not sleep/retry, so total server hits = retryMax+1 = 4. + if got := atomic.LoadInt32(&calls); got != retryMax+1 { + t.Errorf("expected %d calls, got %d", retryMax+1, got) + } + // A persistent 429 across every attempt must surface the intended + // exhausted-rate-limit error, not the generic non-2xx path. The message + // must name the exhausted retries and their count. + if !strings.Contains(err.Error(), "exhausted") { + t.Errorf("expected exhausted-rate-limit error, got: %v", err) + } + if !strings.Contains(err.Error(), strconv.Itoa(retryMax)) { + t.Errorf("expected error to name %d retries, got: %v", retryMax, err) + } +} + +// TestContextCancellationDuringRetry verifies ctx cancellation aborts the +// backoff sleep rather than blocking. +func TestContextCancellationDuringRetry(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + // Serve a 429 with a long Retry-After so the client enters the backoff + // sleep, and cancel the context as the first request lands — so cancellation + // is observed inside sleepCtx (not at the initial httpClient.Do). + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "30") + w.WriteHeader(http.StatusTooManyRequests) + cancel() + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + start := time.Now() + _, err := c.request(ctx, http.MethodGet, "campaigns") + if err == nil { + t.Fatal("expected context cancellation error") + } + // The 30s Retry-After sleep must have been aborted by cancellation, not slept. + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Errorf("expected fast return via cancelled backoff, took %v", elapsed) + } +} + +// TestRequestSetsAuthHeader verifies each request carries an OAuth header. +func TestRequestSetsAuthHeader(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":{"id":"1"}}`)) + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + if _, err := c.request(context.Background(), http.MethodGet, "campaigns"); err != nil { + t.Fatalf("request: %v", err) + } + if !strings.HasPrefix(gotAuth, "OAuth ") { + t.Errorf("missing OAuth authorization header: %q", gotAuth) + } +} + +// TestCreateCampaignValidation covers the input validation guards. +func TestCreateCampaignValidation(t *testing.T) { + c := NewClient(Credentials{}, AccountConfig{}) + // Budget/date/calendar cases carry a valid Project so they reach the + // budget/date guards under test — Project is validated BEFORE budget/dates, so + // omitting it would short-circuit on "invalid project" and never exercise + // these branches. The event-name cases deliberately omit Project because + // EventName is validated FIRST (they must fail on event name, not project). + cases := []CampaignInput{ + {EventName: "E", Project: "tlf", BudgetUsd: 0, StartDate: "2026-01-01", EndDate: "2026-01-02"}, + {EventName: "E", Project: "tlf", BudgetUsd: 100, StartDate: "bad", EndDate: "2026-01-02"}, + {EventName: "E", Project: "tlf", BudgetUsd: 100, StartDate: "2026-01-01", EndDate: "bad"}, + {EventName: "E", Project: "tlf", BudgetUsd: 100, StartDate: "2026-01-02", EndDate: "2026-01-01"}, + // Well-shaped but impossible calendar dates must be rejected before any + // mutating call (the regex alone would let these through). + {EventName: "E", Project: "tlf", BudgetUsd: 100, StartDate: "2026-99-99", EndDate: "2026-12-31"}, + {EventName: "E", Project: "tlf", BudgetUsd: 100, StartDate: "2026-01-01", EndDate: "2026-99-99"}, + {EventName: "E", Project: "tlf", BudgetUsd: 100, StartDate: "2026-02-30", EndDate: "2026-12-31"}, + // Empty / whitespace-only event name (validated before project). + {EventName: "", BudgetUsd: 100, StartDate: "2026-01-01", EndDate: "2026-01-02"}, + {EventName: " ", BudgetUsd: 100, StartDate: "2026-01-01", EndDate: "2026-01-02"}, + // Over-length event name. + {EventName: strings.Repeat("x", maxEventNameLen+1), BudgetUsd: 100, StartDate: "2026-01-01", EndDate: "2026-01-02"}, + // Empty project (validated after event name, before budget/dates). + {EventName: "E", Project: "", BudgetUsd: 100, StartDate: "2026-01-01", EndDate: "2026-01-02"}, + {EventName: "E", Project: " ", BudgetUsd: 100, StartDate: "2026-01-01", EndDate: "2026-01-02"}, + // Budget above the int64 micro-unit overflow cap. + {EventName: "E", Project: "tlf", BudgetUsd: maxBudgetUsd + 1, StartDate: "2026-01-01", EndDate: "2026-01-02"}, + // Positive-but-rounds-to-zero micro-units (< half a micro-unit). + {EventName: "E", Project: "tlf", BudgetUsd: 1e-9, StartDate: "2026-01-01", EndDate: "2026-01-02"}, + // NaN / Inf budgets. + {EventName: "E", Project: "tlf", BudgetUsd: math.NaN(), StartDate: "2026-01-01", EndDate: "2026-01-02"}, + {EventName: "E", Project: "tlf", BudgetUsd: math.Inf(1), StartDate: "2026-01-01", EndDate: "2026-01-02"}, + } + for i, in := range cases { + if _, err := c.CreateCampaign(context.Background(), in); err == nil { + t.Errorf("case %d: expected validation error", i) + } + } +} + +// TestCreateCampaignBudgetErrorMessages verifies each budget-rejection branch +// returns a distinct, actionable message. In particular, an over-cap budget is +// positive, so it must NOT be reported as "must be a positive number" — the +// caller needs to know the actual limit. +func TestCreateCampaignBudgetErrorMessages(t *testing.T) { + c := NewClient(Credentials{}, AccountConfig{}) + base := CampaignInput{EventName: "E", Project: "tlf", StartDate: "2026-01-01", EndDate: "2026-01-02"} + + cases := []struct { + name string + budget float64 + want string + }{ + {"over cap", maxBudgetUsd + 1, "must be at most"}, + {"zero", 0, "must be a positive number"}, + {"negative", -5, "must be a positive number"}, + {"rounds to zero", 1e-9, "rounds to zero"}, + } + for _, tc := range cases { + in := base + in.BudgetUsd = tc.budget + _, err := c.CreateCampaign(context.Background(), in) + if err == nil { + t.Fatalf("%s: expected error", tc.name) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s: error = %q, want substring %q", tc.name, err.Error(), tc.want) + } + } + // Over-cap must not be mislabeled as non-positive. + in := base + in.BudgetUsd = maxBudgetUsd + 1 + if _, err := c.CreateCampaign(context.Background(), in); err == nil || + strings.Contains(err.Error(), "must be a positive number") { + t.Errorf("over-cap budget should not report 'must be a positive number', got %v", err) + } +} + +// TestCreateCampaignRejectsOversizedComposedName verifies a composed campaign +// name exceeding X's 255-rune entity-name limit is rejected before any network +// call. A 200-char event (the per-field max) with a short project composes to +// ~286 chars — within the per-field bounds but over the entity-name limit. +func TestCreateCampaignRejectsOversizedComposedName(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + // Sanity-check the premise: 200-char event + short project overflows 255. + name := buildTwitterCampaignName(CampaignInput{EventName: strings.Repeat("x", maxEventNameLen), Project: "CNCF"}) + if got := len([]rune(name)); got <= maxEntityNameLen { + t.Fatalf("test premise wrong: composed name is %d runes, expected > %d", got, maxEntityNameLen) + } + + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: strings.Repeat("x", maxEventNameLen), // valid per-field + Project: "CNCF", + BudgetUsd: 500, + StartDate: "2026-03-01", + EndDate: "2026-03-10", + RegistrationURL: "https://events.lf.org/reg", + }) + if err == nil { + t.Fatal("expected error for composed name exceeding entity-name limit") + } + if !strings.Contains(err.Error(), strconv.Itoa(maxEntityNameLen)) { + t.Errorf("error should mention the %d-char limit: %v", maxEntityNameLen, err) + } + if got := atomic.LoadInt32(&calls); got != 0 { + t.Errorf("expected no network call before name validation, got %d", got) + } +} + +// TestCreateCampaignRejectsEmptyProject verifies that an empty/whitespace +// Project is rejected in the up-front validation, before any mutating call. The +// Project segment is the attribution key the pipeline joins on, so defaulting an +// omitted value would misattribute a non-TLF campaign; the caller must supply the +// canonical slug. +func TestCreateCampaignRejectsEmptyProject(t *testing.T) { + for _, project := range []string{"", " "} { + var posts int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + posts++ + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "KubeCon EU", + Project: project, + BudgetUsd: 500, + StartDate: "2026-03-01", + EndDate: "2026-03-10", + RegistrationURL: "https://events.lf.org/reg", + }) + if err == nil { + t.Errorf("project %q: expected error for empty project", project) + } else if !strings.Contains(err.Error(), "project") { + t.Errorf("project %q: expected project error, got: %v", project, err) + } + if posts != 0 { + t.Errorf("project %q: expected no POST before project validation, got %d", project, posts) + } + srv.Close() + } +} + +// TestCreateCampaignEventNameRuneLimit verifies the EventName length guard +// counts runes, not UTF-8 bytes. A multi-byte name that is under the 200-rune +// limit but well over 200 bytes must pass the length guard (byte-counting would +// wrongly reject it), while a name over 200 runes must be rejected. +func TestCreateCampaignEventNameRuneLimit(t *testing.T) { + // 150 CJK runes = 450 bytes: under 200 runes but far over 200 bytes. The + // composed campaign name (with a short project) stays under 255 runes, so the + // whole create flow succeeds. + multiByteName := strings.Repeat("世", 150) + if utf8.RuneCountInString(multiByteName) > maxEventNameLen { + t.Fatalf("test premise wrong: %d runes exceeds %d", utf8.RuneCountInString(multiByteName), maxEventNameLen) + } + if len(multiByteName) <= maxEventNameLen { + t.Fatalf("test premise wrong: %d bytes should exceed %d to exercise the byte-vs-rune bug", len(multiByteName), maxEventNameLen) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/accounts/acc1"): + _, _ = w.Write([]byte(`{"data":{"name":"LF Events"}}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "line_items"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":{"id":"cmp1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "line_items"): + _, _ = w.Write([]byte(`{"data":{"id":"li1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "promoted_tweets"): + _, _ = w.Write([]byte(`{"data":[{"id":"pt1"}]}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + if _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: multiByteName, + Project: "CNCF", + BudgetUsd: 500, + StartDate: "2026-03-01", + EndDate: "2026-03-10", + TweetID: "1234567890", + RegistrationURL: "https://events.lf.org/kubecon", + }); err != nil { + t.Fatalf("multi-byte name under rune limit should be accepted, got: %v", err) + } + + // A name over 200 runes must be rejected by the length guard. + tooLong := strings.Repeat("世", maxEventNameLen+1) + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: tooLong, + BudgetUsd: 500, + StartDate: "2026-03-01", + EndDate: "2026-03-10", + }) + if err == nil { + t.Fatal("event name over the rune limit should be rejected") + } + if !strings.Contains(err.Error(), "event name") { + t.Errorf("expected event-name length error, got: %v", err) + } +} + +// TestValidateEntityName covers the 255-rune boundary directly. +func TestValidateEntityName(t *testing.T) { + if err := validateEntityName("campaign", strings.Repeat("x", maxEntityNameLen)); err != nil { + t.Errorf("name at limit should pass: %v", err) + } + if err := validateEntityName("campaign", strings.Repeat("x", maxEntityNameLen+1)); err == nil { + t.Error("name one over limit should fail") + } + // Rune-aware: multi-byte characters count as one rune each, not per-byte. + if err := validateEntityName("campaign", strings.Repeat("é", maxEntityNameLen)); err != nil { + t.Errorf("multi-byte name at rune limit should pass: %v", err) + } +} + +// TestCreateCampaignRejectsEmptyAccountConfig verifies required account config +// (account_id, funding_instrument_id) is guarded non-empty before any mutating +// call, so an empty stored connection value fails fast client-side instead of at +// the X API. A missing funding_instrument_id must never reach a create POST. +func TestCreateCampaignRejectsEmptyAccountConfig(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer srv.Close() + + base := CampaignInput{ + EventName: "KubeCon EU", Project: "CNCF", BudgetUsd: 500, + StartDate: "2026-03-01", EndDate: "2026-03-10", + RegistrationURL: "https://events.lf.org/reg", + } + cases := []struct { + name string + acct AccountConfig + }{ + {"empty funding instrument", AccountConfig{AccountID: "acc1", FundingInstrumentID: ""}}, + {"whitespace funding instrument", AccountConfig{AccountID: "acc1", FundingInstrumentID: " "}}, + {"empty account id", AccountConfig{AccountID: "", FundingInstrumentID: "fi1"}}, + } + for _, tc := range cases { + atomic.StoreInt32(&calls, 0) + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + tc.acct, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + if _, err := c.CreateCampaign(context.Background(), base); err == nil { + t.Errorf("%s: expected error, got nil", tc.name) + } + if got := atomic.LoadInt32(&calls); got != 0 { + t.Errorf("%s: expected no network call before config guard, got %d", tc.name, got) + } + } +} + +// TestCreateCampaignRejectsUnsafeAccountID verifies that an account_id or +// funding_instrument_id containing path/query/fragment delimiters or whitespace +// is rejected up front, with zero network calls — a non-empty value with '/', +// '?', '#', or a space must not reach a mutating POST (path injection). A valid +// alphanumeric id still flows past the guard. +func TestCreateCampaignRejectsUnsafeAccountID(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer srv.Close() + + base := CampaignInput{ + EventName: "KubeCon EU", Project: "CNCF", BudgetUsd: 500, + StartDate: "2026-03-01", EndDate: "2026-03-10", + RegistrationURL: "https://events.lf.org/reg", + } + cases := []struct { + name string + acct AccountConfig + }{ + {"account id with slash", AccountConfig{AccountID: "18ce/54", FundingInstrumentID: "fi1"}}, + {"account id with question mark", AccountConfig{AccountID: "acc?x", FundingInstrumentID: "fi1"}}, + {"account id with hash", AccountConfig{AccountID: "acc#x", FundingInstrumentID: "fi1"}}, + {"account id with space", AccountConfig{AccountID: "a b", FundingInstrumentID: "fi1"}}, + {"funding id with slash", AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi/1"}}, + {"funding id with space", AccountConfig{AccountID: "acc1", FundingInstrumentID: "f i"}}, + } + for _, tc := range cases { + atomic.StoreInt32(&calls, 0) + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + tc.acct, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + if _, err := c.CreateCampaign(context.Background(), base); err == nil { + t.Errorf("%s: expected error, got nil", tc.name) + } + if got := atomic.LoadInt32(&calls); got != 0 { + t.Errorf("%s: expected no network call before account-id guard, got %d", tc.name, got) + } + } + + // A valid alphanumeric id must still flow past the guard (reaches network). + okSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/accounts/18ce54d4x5t"): + _, _ = w.Write([]byte(`{"data":{"name":"LF Events"}}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "line_items"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":{"id":"cmp1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "line_items"): + _, _ = w.Write([]byte(`{"data":{"id":"li1"}}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer okSrv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "18ce54d4x5t", FundingInstrumentID: "fi1"}, + WithBaseURL(okSrv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + if _, err := c.CreateCampaign(context.Background(), base); err != nil { + t.Errorf("valid alphanumeric account id should flow past the guard, got error: %v", err) + } +} + +// TestBudgetRoundToZeroBoundary verifies the round-to-zero guard: a budget just +// below half a micro-unit rounds to 0 and is rejected, while the smallest value +// that rounds to 1 micro-unit is accepted by the conversion. +func TestBudgetRoundToZeroBoundary(t *testing.T) { + // 0.49e-6 USD -> 0.49 micro -> rounds to 0. + if got := toMicroCurrency(0.49e-6); got != 0 { + t.Fatalf("toMicroCurrency(0.49e-6) = %d, want 0", got) + } + // 0.5e-6 USD -> 0.5 micro -> rounds to 1 (still positive, accepted). + if got := toMicroCurrency(0.5e-6); got <= 0 { + t.Fatalf("toMicroCurrency(0.5e-6) = %d, want > 0", got) + } +} + +// TestCreateCampaignFlow exercises the full create flow against a fake server: +// account verify -> lookup (empty) -> create campaign -> create line item -> +// create promoted tweet. +func TestCreateCampaignFlow(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/accounts/acc1"): + _, _ = w.Write([]byte(`{"data":{"name":"LF Events"}}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "line_items"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":{"id":"cmp1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "line_items"): + _, _ = w.Write([]byte(`{"data":{"id":"li1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "promoted_tweets"): + _, _ = w.Write([]byte(`{"data":[{"id":"pt1"}]}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi1"}, + WithBaseURL(srv.URL), + // WithWriteDelay(0) disables the inter-write pacing sleep so this + // end-to-end flow doesn't incur the real ~1s-per-write delay. + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "KubeCon EU", + Project: "CNCF", + BudgetUsd: 500, + StartDate: "2026-03-01", + EndDate: "2026-03-10", + TweetID: "1234567890", + RegistrationURL: "https://events.lf.org/kubecon", + }) + if err != nil { + t.Fatalf("CreateCampaign: %v", err) + } + if res.CampaignID != "cmp1" || res.LineItemID != "li1" || res.PromotedTweetID != "pt1" { + t.Errorf("unexpected ids: %+v", res) + } + if res.Platform != "twitter-ads" || res.TwitterURL != AdsManagerURL { + t.Errorf("unexpected metadata: %+v", res) + } + if len(res.Steps) == 0 { + t.Error("expected step log entries") + } +} + +// TestCreateCampaignIdempotent verifies existing entities are reused by name. +func TestCreateCampaignIdempotent(t *testing.T) { + campaignName := buildTwitterCampaignName(CampaignInput{EventName: "KubeCon EU", Project: "CNCF"}) + lineItemName := "Events | KubeCon EU | Promoted Tweets | AUTO" + + var postCampaign, postLineItem int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/accounts/acc1"): + _, _ = w.Write([]byte(`{"data":{"name":"LF Events"}}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "campaigns"): + b, _ := json.Marshal(map[string]any{"data": []map[string]string{{"id": "existingCmp", "name": campaignName}}}) + _, _ = w.Write(b) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "line_items"): + b, _ := json.Marshal(map[string]any{"data": []map[string]string{{"id": "existingLi", "name": lineItemName}}}) + _, _ = w.Write(b) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "campaigns"): + postCampaign++ + _, _ = w.Write([]byte(`{"data":{"id":"new"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "line_items"): + postLineItem++ + _, _ = w.Write([]byte(`{"data":{"id":"new"}}`)) + default: + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[{"id":"pt1"}]}`)) + } + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "KubeCon EU", Project: "CNCF", BudgetUsd: 500, + StartDate: "2026-03-01", EndDate: "2026-03-10", + RegistrationURL: "https://events.lf.org/reg", + }) + if err != nil { + t.Fatalf("CreateCampaign: %v", err) + } + if res.CampaignID != "existingCmp" || res.LineItemID != "existingLi" { + t.Errorf("expected reuse, got %+v", res) + } + if postCampaign != 0 || postLineItem != 0 { + t.Errorf("expected no create POSTs, got campaign=%d lineItem=%d", postCampaign, postLineItem) + } +} + +// TestFindByNamePagination verifies name lookups follow next_cursor so a match +// on a deep page (page 3 here) is found (idempotency must not break past page 1), +// and that every list request carries count=1000 — the X Ads v12 max page size — +// so the lookup covers a realistic large account within the maxListPages cap. +func TestFindByNamePagination(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + // Every list request must request the max page size AND the server-side + // name filter so the lookup is O(matches), not O(account). + if got := r.URL.Query().Get("count"); got != "1000" { + t.Errorf("list request must carry count=1000, got count=%q (query %v)", got, r.URL.Query()) + } + if got := r.URL.Query().Get("q"); got != "target" { + t.Errorf("list request must carry the q name filter (q=target), got q=%q", got) + } + switch r.URL.Query().Get("cursor") { + case "": + // page 1: no match, hand back a cursor. + _, _ = w.Write([]byte(`{"data":[{"id":"c1","name":"other"}],"next_cursor":"CURSOR2"}`)) + case "CURSOR2": + // page 2: still no match, another cursor. + _, _ = w.Write([]byte(`{"data":[{"id":"c2","name":"still-other"}],"next_cursor":"CURSOR3"}`)) + default: + // page 3: the match, no further cursor. + _, _ = w.Write([]byte(`{"data":[{"id":"c3","name":"target"}]}`)) + } + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + id, err := c.findCampaignByName(context.Background(), "target") + if err != nil { + t.Fatalf("findCampaignByName: %v", err) + } + if id != "c3" { + t.Errorf("findCampaignByName across pages = %q, want c3", id) + } + if got := atomic.LoadInt32(&calls); got != 3 { + t.Errorf("expected 3 pages fetched, got %d", got) + } +} + +// TestFindByNameLineItemListSendsCount verifies the line-item lookup path also +// requests the max page size (count=1000), alongside its campaign_ids scope. +func TestFindByNameLineItemListSendsCount(t *testing.T) { + var sawCount, sawCampaignIDs, sawQ bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("count") == "1000" { + sawCount = true + } + if q.Get("campaign_ids") != "" { + sawCampaignIDs = true + } + if q.Get("q") == "target" { + sawQ = true + } + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + if _, err := c.findLineItemByName(context.Background(), "camp1", "target"); err != nil { + t.Fatalf("findLineItemByName: %v", err) + } + if !sawCount { + t.Error("line-item lookup must send count=1000") + } + if !sawCampaignIDs { + t.Error("line-item lookup must send campaign_ids scope") + } + if !sawQ { + t.Error("line-item lookup must send the q name filter") + } +} + +// TestFindByNameInconclusiveCapIsError verifies that when the page cap is +// reached with a next_cursor still outstanding (the name was never seen but more +// results remain), findByName returns an ERROR — never ("", nil). Treating an +// exhausted-but-inconclusive walk as "not found" would let the caller create a +// duplicate of an element that may exist further on. This behavior must be +// preserved by the count/page-size change. +func TestFindByNameInconclusiveCapIsError(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + // Never match, and ALWAYS return a next_cursor so the walk can never + // conclude "not found" — it must hit the maxListPages cap. + _, _ = w.Write([]byte(`{"data":[{"id":"x","name":"never-matches"}],"next_cursor":"MORE"}`)) + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + id, err := c.findCampaignByName(context.Background(), "target") + if err == nil { + t.Fatalf("expected an inconclusive-cap error, got id=%q, nil error", id) + } + if id != "" { + t.Errorf("expected empty id on inconclusive cap, got %q", id) + } + if got := atomic.LoadInt32(&calls); got != maxListPages { + t.Errorf("expected the walk to fetch exactly maxListPages=%d pages, got %d", maxListPages, got) + } +} + +// TestFindByNameMatchWithoutIDErrors verifies that a list element matching the +// name but carrying no usable id is surfaced as a lookup ERROR, not ("", nil). +// Returning "not found" would drive CreateCampaign into a create POST and risk +// duplicating an element that already exists. The test also asserts that no +// create POST is issued when the campaign lookup hits an id-less match. +func TestFindByNameMatchWithoutIDErrors(t *testing.T) { + // The campaign lookup in CreateCampaign searches for this composed name, so + // the id-less element the server returns must carry the same name to be a + // genuine (id-less) match on the campaign lookup path. + in := CampaignInput{ + EventName: "KubeCon EU", Project: "CNCF", BudgetUsd: 500, + StartDate: "2026-03-01", EndDate: "2026-03-10", TweetID: "123", + RegistrationURL: "https://events.lf.org/reg", + } + campaignName := buildTwitterCampaignName(in) + idlessBody, err := json.Marshal(map[string]any{ + "data": []map[string]any{{"name": campaignName}}, // matches by name, no id + }) + if err != nil { + t.Fatalf("marshal id-less body: %v", err) + } + + var createPosts int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/accounts/acc1"): + _, _ = w.Write([]byte(`{"data":{"name":"LF Events"}}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "campaigns"): + // Name matches the composed campaign name but the element has no id. + _, _ = w.Write(idlessBody) + case r.Method == http.MethodPost: + createPosts++ + _, _ = w.Write([]byte(`{"data":{"id":"should-not-happen"}}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + // Direct lookup: a name match with no id is an error, not ("", nil). + id, err := c.findCampaignByName(context.Background(), campaignName) + if err == nil { + t.Fatalf("expected error for id-less match, got id=%q, nil error", id) + } + if id != "" { + t.Errorf("expected empty id on error, got %q", id) + } + + // End-to-end: CreateCampaign must abort and NOT issue a create POST. + _, err = c.CreateCampaign(context.Background(), in) + if err == nil { + t.Fatal("CreateCampaign should abort when the campaign lookup returns an id-less match") + } + if createPosts != 0 { + t.Errorf("expected no create POST on id-less match, got %d", createPosts) + } +} + +// TestValidateDateStrict verifies validateDate rejects well-shaped but +// impossible calendar dates (e.g. 2026-99-99) that the shape regex accepts. +func TestValidateDateStrict(t *testing.T) { + valid := []string{"2026-01-01", "2026-12-31", "2024-02-29"} // 2024 is a leap year + for _, d := range valid { + if err := validateDate("start", d); err != nil { + t.Errorf("validateDate(%q) unexpected error: %v", d, err) + } + } + invalid := []string{"2026-99-99", "2026-13-01", "2026-02-30", "2026-00-10", "bad", "2026-1-1"} + for _, d := range invalid { + if err := validateDate("start", d); err == nil { + t.Errorf("validateDate(%q) expected error, got nil", d) + } + } +} + +// TestCreateSendsQueryParams verifies X Ads create calls carry their params as +// URL query parameters (not a JSON body), use entity_status=PAUSED (not +// paused=true) on the campaign and line item, that the campaign create does NOT +// send the unsupported start_time/end_time flight dates, that the line-item +// create includes the required start_time/end_time, and that the promoted-tweet +// create does not send entity_status (the API creates it ACTIVE). +func TestCreateSendsQueryParams(t *testing.T) { + var campaignQuery, lineItemQuery, promotedQuery, lineItemGetQuery url.Values + var campaignBodyLen, lineItemBodyLen int64 + var campaignCT, lineItemCT string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/accounts/acc1"): + _, _ = w.Write([]byte(`{"data":{"name":"LF Events"}}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "line_items"): + lineItemGetQuery = r.URL.Query() + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "campaigns"): + campaignQuery = r.URL.Query() + campaignBodyLen = r.ContentLength + campaignCT = r.Header.Get("Content-Type") + _, _ = w.Write([]byte(`{"data":{"id":"cmp1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "line_items"): + lineItemQuery = r.URL.Query() + lineItemBodyLen = r.ContentLength + lineItemCT = r.Header.Get("Content-Type") + _, _ = w.Write([]byte(`{"data":{"id":"li1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "promoted_tweets"): + promotedQuery = r.URL.Query() + _, _ = w.Write([]byte(`{"data":[{"id":"pt1"}]}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + if _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "KubeCon EU", Project: "CNCF", BudgetUsd: 500, + StartDate: "2026-03-01", EndDate: "2026-03-10", TweetID: "111", + RegistrationURL: "https://events.lf.org/reg", + }); err != nil { + t.Fatalf("CreateCampaign: %v", err) + } + + // The line-item lookup must scope by campaign_ids (plural list filter), not + // the singular create param campaign_id, or it runs unscoped and could reuse + // a same-named line item from another campaign. + if lineItemGetQuery.Get("campaign_ids") == "" { + t.Errorf("line-item lookup must send campaign_ids (plural): %v", lineItemGetQuery) + } + if lineItemGetQuery.Has("campaign_id") { + t.Errorf("line-item lookup should not use singular campaign_id: %v", lineItemGetQuery) + } + + // Campaign create: params on the query string, entity_status=PAUSED, no JSON body. + if campaignQuery.Get("name") == "" { + t.Errorf("campaign create missing name query param: %v", campaignQuery) + } + if campaignQuery.Get("funding_instrument_id") != "fi1" { + t.Errorf("campaign create funding_instrument_id = %q, want fi1", campaignQuery.Get("funding_instrument_id")) + } + if campaignQuery.Get("entity_status") != "PAUSED" { + t.Errorf("campaign create entity_status = %q, want PAUSED", campaignQuery.Get("entity_status")) + } + if campaignQuery.Has("paused") { + t.Errorf("campaign create should not send deprecated paused param: %v", campaignQuery) + } + // X Ads v12 rejects start_time/end_time on the campaign endpoint; flight + // dates belong on the line item, so the campaign create must not send them. + if campaignQuery.Has("start_time") { + t.Errorf("campaign create must not send start_time (unsupported in v12): %v", campaignQuery) + } + if campaignQuery.Has("end_time") { + t.Errorf("campaign create must not send end_time (unsupported in v12): %v", campaignQuery) + } + if campaignBodyLen > 0 { + t.Errorf("campaign create should carry no body, got ContentLength=%d", campaignBodyLen) + } + if strings.Contains(campaignCT, "application/json") { + t.Errorf("campaign create should not set JSON content-type, got %q", campaignCT) + } + + // Line-item create: required start_time/end_time present, entity_status=PAUSED, + // bid_strategy (not bid_type), params on the query string. + if lineItemQuery.Get("start_time") != "2026-03-01T00:00:00Z" { + t.Errorf("line item start_time = %q, want 2026-03-01T00:00:00Z", lineItemQuery.Get("start_time")) + } + if lineItemQuery.Get("end_time") != "2026-03-10T00:00:00Z" { + t.Errorf("line item end_time = %q, want 2026-03-10T00:00:00Z", lineItemQuery.Get("end_time")) + } + if lineItemQuery.Get("entity_status") != "PAUSED" { + t.Errorf("line item entity_status = %q, want PAUSED", lineItemQuery.Get("entity_status")) + } + if lineItemQuery.Get("bid_strategy") != "AUTO" { + t.Errorf("line item bid_strategy = %q, want AUTO", lineItemQuery.Get("bid_strategy")) + } + if lineItemQuery.Has("bid_type") { + t.Errorf("line item should not send deprecated bid_type param: %v", lineItemQuery) + } + if lineItemBodyLen > 0 { + t.Errorf("line item create should carry no body, got ContentLength=%d", lineItemBodyLen) + } + if strings.Contains(lineItemCT, "application/json") { + t.Errorf("line item create should not set JSON content-type, got %q", lineItemCT) + } + + // Promoted-tweet create: assert the request actually hit the promoted_tweets + // path (promotedQuery non-nil) before checking the absence of entity_status — + // otherwise a misrouted request leaves promotedQuery nil and the absence-only + // check below would pass vacuously. + if promotedQuery == nil { + t.Fatal("promoted tweet create was never received on the promoted_tweets path") + } + // The endpoint does not accept entity_status; the API creates the association + // ACTIVE and delivery is gated by the PAUSED line item, so we must not send + // entity_status here. + if promotedQuery.Has("entity_status") { + t.Errorf("promoted tweet create should not send entity_status: %v", promotedQuery) + } +} + +// TestRetryResetExceedsCapAborts verifies that when the server declares a +// rate-limit reset longer than maxRetryWait, the client aborts immediately with +// the rate-limit error rather than sleeping (burning retries or hanging). +func TestRetryResetExceedsCapAborts(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + // Declare a reset far beyond the cap. + w.Header().Set("Retry-After", strconv.Itoa(int(maxRetryWait/time.Second)+3600)) + w.WriteHeader(http.StatusTooManyRequests) + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + start := time.Now() + _, err := c.request(context.Background(), http.MethodGet, "campaigns") + if err == nil { + t.Fatal("expected error when reset exceeds max wait") + } + // Must have aborted on the first 429 without sleeping or retrying. + if got := atomic.LoadInt32(&calls); got != 1 { + t.Errorf("expected 1 call (immediate abort), got %d", got) + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Errorf("expected immediate return, took %v", elapsed) + } + if !strings.Contains(err.Error(), "429") { + t.Errorf("unexpected error: %v", err) + } +} + +// TestPromotedTweetMissingIDWarns verifies a 2xx promoted-tweet response with no +// data.id is surfaced as a warning step (not silent success, not fatal): the +// flow returns nil error, PromotedTweetID is empty, and a step records the gap. +func TestPromotedTweetMissingIDWarns(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/accounts/acc1"): + _, _ = w.Write([]byte(`{"data":{"name":"LF Events"}}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "line_items"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":{"id":"cmp1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "line_items"): + _, _ = w.Write([]byte(`{"data":{"id":"li1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "promoted_tweets"): + // 2xx but the array is empty -> no id. + _, _ = w.Write([]byte(`{"data":[]}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "KubeCon EU", Project: "CNCF", BudgetUsd: 500, + StartDate: "2026-03-01", EndDate: "2026-03-10", TweetID: "123", + RegistrationURL: "https://events.lf.org/reg", + }) + if err != nil { + t.Fatalf("CreateCampaign should not be fatal on missing promoted-tweet id: %v", err) + } + if res.PromotedTweetID != "" { + t.Errorf("expected empty PromotedTweetID, got %q", res.PromotedTweetID) + } + if res.PromotedTweetWarning == "" { + t.Errorf("expected PromotedTweetWarning to be set for malformed promoted-tweet response") + } + var found bool + for _, s := range res.Steps { + if strings.Contains(s, "no promoted-tweet ID") { + found = true + break + } + } + if !found { + t.Errorf("expected a warning step for missing promoted-tweet id, steps: %v", res.Steps) + } +} + +// TestPromotedTweetPostErrorWarns verifies that a promoted-tweet POST that fails +// with a non-2xx (non-duplicate) error is NOT reported as a clean success: the +// campaign flow stays non-fatal, but PromotedTweetWarning is set and a warning +// step records the gap. +func TestPromotedTweetPostErrorWarns(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/accounts/acc1"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":{"name":"LF Events"}}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "campaigns"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "line_items"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "campaigns"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":{"id":"cmp1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "line_items"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":{"id":"li1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "promoted_tweets"): + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"errors":[{"code":"INVALID_PARAMETER","message":"bad tweet"}]}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "KubeCon EU", Project: "CNCF", BudgetUsd: 500, + StartDate: "2026-03-01", EndDate: "2026-03-10", TweetID: "123", + RegistrationURL: "https://events.lf.org/reg", + }) + if err != nil { + t.Fatalf("CreateCampaign should not be fatal on promoted-tweet POST failure: %v", err) + } + if res.PromotedTweetID != "" { + t.Errorf("expected empty PromotedTweetID on failed POST, got %q", res.PromotedTweetID) + } + if res.PromotedTweetWarning == "" { + t.Errorf("expected PromotedTweetWarning to be set on failed promoted-tweet POST (must not report clean success)") + } + var found bool + for _, s := range res.Steps { + if strings.Contains(s, "Promoted tweet creation failed") { + found = true + break + } + } + if !found { + t.Errorf("expected a warning step for failed promoted-tweet POST, steps: %v", res.Steps) + } +} + +// TestPromotedTweetDuplicateSurfacesWarning verifies that a +// DUPLICATE_PROMOTABLE_ENTITY response is surfaced as a WARNING (not an +// unqualified success): X can return that code when the tweet is promoted by a +// DIFFERENT line item, so it does not prove this tweet is attached to this line +// item. The flow stays non-fatal (campaign + line item still return), but +// PromotedTweetWarning is set and a step tells the caller to verify manually. +func TestPromotedTweetDuplicateSurfacesWarning(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/accounts/acc1"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":{"name":"LF Events"}}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "campaigns"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "line_items"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "campaigns"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":{"id":"cmp1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "line_items"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":{"id":"li1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "promoted_tweets"): + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"errors":[{"code":"DUPLICATE_PROMOTABLE_ENTITY","message":"tweet already promoted on this line item"}]}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "KubeCon EU", Project: "CNCF", BudgetUsd: 500, + StartDate: "2026-03-01", EndDate: "2026-03-10", TweetID: "123", + RegistrationURL: "https://events.lf.org/reg", + }) + if err != nil { + t.Fatalf("CreateCampaign should not be fatal on duplicate promoted-tweet: %v", err) + } + // Non-fatal, but a duplicate is NOT an unqualified success: the warning must be + // set so consumers do not treat the result as a confirmed association. + if res.PromotedTweetWarning == "" { + t.Error("expected PromotedTweetWarning to be set for a DUPLICATE_PROMOTABLE_ENTITY response, got empty") + } + // The campaign and line item should still have been created/returned. + if res.CampaignID != "cmp1" || res.LineItemID != "li1" { + t.Errorf("expected campaign+line item to still return (cmp1/li1), got %q/%q", res.CampaignID, res.LineItemID) + } + var found bool + for _, s := range res.Steps { + if strings.Contains(s, "duplicate") && strings.Contains(s, "verify manually") { + found = true + break + } + } + if !found { + t.Errorf("expected a duplicate/verify-manually warning step for duplicate promoted-tweet, steps: %v", res.Steps) + } +} + +// TestCreateCampaignLookupErrorAborts verifies that a transient 500 during the +// campaign name lookup aborts the flow with an error and does NOT proceed to a +// create POST — so a failed lookup is never treated as "not found" (which would +// create a duplicate). +func TestCreateCampaignLookupErrorAborts(t *testing.T) { + var postCampaign int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/accounts/acc1"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":{"name":"LF Events"}}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "campaigns"): + // Lookup fails hard. + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"errors":["boom"]}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "campaigns"): + postCampaign++ + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":{"id":"should-not-happen"}}`)) + default: + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[]}`)) + } + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "KubeCon EU", Project: "CNCF", BudgetUsd: 500, + StartDate: "2026-03-01", EndDate: "2026-03-10", + RegistrationURL: "https://events.lf.org/reg", + }) + if err == nil { + t.Fatal("expected error when campaign lookup fails, got nil") + } + if postCampaign != 0 { + t.Errorf("expected no campaign create POST after lookup failure, got %d", postCampaign) + } +} + +// TestWithHTTPClientNilIgnored verifies WithHTTPClient(nil) does not install a +// nil client (which would panic on the first httpClient.Do); the default client +// is retained so the option can't produce an unusable Client. +func TestWithHTTPClientNilIgnored(t *testing.T) { + c := NewClient(Credentials{}, AccountConfig{}, WithHTTPClient(nil)) + if c.httpClient == nil { + t.Fatal("WithHTTPClient(nil) left a nil httpClient; expected the default to be retained") + } +} + +// TestWithWriteDelayZeroDisablesPacing verifies a zero write delay makes pace a +// no-op so tests don't incur real per-request sleeps. +func TestWithWriteDelayZeroDisablesPacing(t *testing.T) { + c := NewClient(Credentials{}, AccountConfig{}, WithWriteDelay(0)) + start := time.Now() + if err := c.pace(context.Background()); err != nil { + t.Fatalf("pace: %v", err) + } + if elapsed := time.Since(start); elapsed > 100*time.Millisecond { + t.Errorf("pace slept %v with zero writeDelay; expected a no-op", elapsed) + } +} + +// TestTweetIDWhitespaceNotPromoted verifies a whitespace-only TweetID is treated +// as "not supplied" (no promoted-tweet POST) rather than sent verbatim and +// guaranteeing a rejected POST after the campaign + line item already exist. +func TestTweetIDWhitespaceNotPromoted(t *testing.T) { + var promotedHit int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/accounts/acc1"): + _, _ = w.Write([]byte(`{"data":{"name":"LF"}}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "line_items"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":{"id":"cmp1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "line_items"): + _, _ = w.Write([]byte(`{"data":{"id":"li1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "promoted_tweets"): + atomic.AddInt32(&promotedHit, 1) + _, _ = w.Write([]byte(`{"data":[{"id":"pt1"}]}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + if _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", BudgetUsd: 500, + StartDate: "2026-03-01", EndDate: "2026-03-10", TweetID: " ", + RegistrationURL: "https://events.lf.org/reg", + }); err != nil { + t.Fatalf("CreateCampaign: %v", err) + } + if got := atomic.LoadInt32(&promotedHit); got != 0 { + t.Errorf("promoted_tweets hit %d times for a whitespace-only TweetID, want 0", got) + } +} + +// TestAccountConfigTrimmedInRequests verifies NewClient trims AccountID and +// FundingInstrumentID so the TRIMMED value is what is both validated non-empty +// AND used in every request path/param. A padded " acc1 "/" fi1 " must produce +// an account path containing "acc1" (no spaces) and a funding_instrument_id +// param of "fi1", while a whitespace-only value is still rejected as empty. +func TestAccountConfigTrimmedInRequests(t *testing.T) { + // NewClient must store the trimmed values. + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: " acc1 ", FundingInstrumentID: " fi1 "}, + ) + if c.account.AccountID != "acc1" { + t.Errorf("AccountID not trimmed on construction: %q", c.account.AccountID) + } + if c.account.FundingInstrumentID != "fi1" { + t.Errorf("FundingInstrumentID not trimmed on construction: %q", c.account.FundingInstrumentID) + } + // The account URL (used for every request path) must carry the trimmed id, so + // the path has no embedded spaces. + if got := c.accountURL(); !strings.HasSuffix(got, "/accounts/acc1") { + t.Errorf("account URL should end in /accounts/acc1 (trimmed), got %q", got) + } + + // End-to-end: the padded ids must reach the server as trimmed values — the + // campaign create funding_instrument_id param and the account path. + var acctPathSeen string + var fundingParamSeen string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/accounts/") && strings.HasSuffix(r.URL.Path, "acc1"): + acctPathSeen = r.URL.Path + _, _ = w.Write([]byte(`{"data":{"name":"LF"}}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "line_items"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "campaigns"): + fundingParamSeen = r.URL.Query().Get("funding_instrument_id") + _, _ = w.Write([]byte(`{"data":{"id":"cmp1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "line_items"): + _, _ = w.Write([]byte(`{"data":{"id":"li1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "promoted_tweets"): + _, _ = w.Write([]byte(`{"data":[{"id":"pt1"}]}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c2 := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: " acc1 ", FundingInstrumentID: " fi1 "}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c2.nonceFn = func() string { return "n" } + c2.timeFn = staticTime + + if _, err := c2.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", BudgetUsd: 500, + StartDate: "2026-03-01", EndDate: "2026-03-10", + RegistrationURL: "https://events.lf.org/reg", + }); err != nil { + t.Fatalf("CreateCampaign with padded account config: %v", err) + } + if strings.Contains(acctPathSeen, " ") || strings.Contains(acctPathSeen, "%20") || !strings.HasSuffix(acctPathSeen, "/accounts/acc1") { + t.Errorf("account path should be trimmed (/accounts/acc1, no spaces), got %q", acctPathSeen) + } + if fundingParamSeen != "fi1" { + t.Errorf("funding_instrument_id param should be trimmed to fi1, got %q", fundingParamSeen) + } + + // A whitespace-only id must still be rejected as empty before any network call. + whitespaceCases := []AccountConfig{ + {AccountID: " ", FundingInstrumentID: "fi1"}, + {AccountID: "acc1", FundingInstrumentID: " "}, + } + for i, acct := range whitespaceCases { + var calls int32 + wsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + cw := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + acct, + WithBaseURL(wsrv.URL), + WithWriteDelay(0), + ) + cw.nonceFn = func() string { return "n" } + cw.timeFn = staticTime + // Project is supplied so CreateCampaign reaches the account-config guard + // (Project is validated first; without it this would fail on "invalid + // project" and never exercise the whitespace-account-id rejection). + _, err := cw.CreateCampaign(context.Background(), CampaignInput{ + EventName: "E", Project: "tlf", BudgetUsd: 500, StartDate: "2026-03-01", EndDate: "2026-03-10", + RegistrationURL: "https://events.lf.org/reg", + }) + if err == nil { + t.Errorf("case %d: whitespace-only account config should be rejected", i) + } + if got := atomic.LoadInt32(&calls); got != 0 { + t.Errorf("case %d: expected no network call before config guard, got %d", i, got) + } + wsrv.Close() + } +} + +// TestTweetIDFormatValidatedUpFront verifies a supplied but non-numeric TweetID +// is rejected in the up-front validation block, BEFORE any mutating call — so an +// invalid tweet id can never leave a partial/orphaned campaign (campaign + line +// item created, promoted_tweets POST rejected). A valid numeric id still flows +// through, and a blank id still skips the promoted-tweet step. +func TestTweetIDFormatValidatedUpFront(t *testing.T) { + newSrv := func(createPosts *int32) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/accounts/acc1"): + _, _ = w.Write([]byte(`{"data":{"name":"LF"}}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "line_items"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "campaigns"): + atomic.AddInt32(createPosts, 1) + _, _ = w.Write([]byte(`{"data":{"id":"cmp1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "line_items"): + atomic.AddInt32(createPosts, 1) + _, _ = w.Write([]byte(`{"data":{"id":"li1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "promoted_tweets"): + atomic.AddInt32(createPosts, 1) + _, _ = w.Write([]byte(`{"data":[{"id":"pt1"}]}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + } + mkClient := func(url string) *Client { + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi1"}, + WithBaseURL(url), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + return c + } + base := CampaignInput{ + EventName: "E", Project: "tlf", BudgetUsd: 500, + StartDate: "2026-03-01", EndDate: "2026-03-10", + RegistrationURL: "https://events.lf.org/reg", + } + + // Invalid (non-numeric) tweet ids must fail up front with ZERO create POSTs. + for _, bad := range []string{"not-a-tweet", "12x3", " 12x3 "} { + var createPosts int32 + srv := newSrv(&createPosts) + c := mkClient(srv.URL) + in := base + in.TweetID = bad + _, err := c.CreateCampaign(context.Background(), in) + if err == nil { + t.Errorf("TweetID %q should be rejected as non-numeric", bad) + } + if !strings.Contains(err.Error(), "tweet id") { + t.Errorf("TweetID %q: expected a tweet-id error, got %v", bad, err) + } + if got := atomic.LoadInt32(&createPosts); got != 0 { + t.Errorf("TweetID %q: expected 0 create POSTs (fail before any mutation), got %d", bad, got) + } + srv.Close() + } + + // A valid numeric tweet id still flows all the way through the promoted step. + { + var createPosts int32 + srv := newSrv(&createPosts) + c := mkClient(srv.URL) + in := base + in.TweetID = "1234567890" + res, err := c.CreateCampaign(context.Background(), in) + if err != nil { + t.Fatalf("valid numeric TweetID should flow: %v", err) + } + if res.PromotedTweetID != "pt1" { + t.Errorf("expected promoted tweet pt1, got %q", res.PromotedTweetID) + } + srv.Close() + } + + // A blank tweet id still skips the promoted step (no promoted_tweets POST), + // while the campaign + line item are still created. + { + var promotedPosts int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/accounts/acc1"): + _, _ = w.Write([]byte(`{"data":{"name":"LF"}}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "line_items"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":{"id":"cmp1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "line_items"): + _, _ = w.Write([]byte(`{"data":{"id":"li1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "promoted_tweets"): + atomic.AddInt32(&promotedPosts, 1) + _, _ = w.Write([]byte(`{"data":[{"id":"pt1"}]}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + c := mkClient(srv.URL) + in := base + in.TweetID = "" + if _, err := c.CreateCampaign(context.Background(), in); err != nil { + t.Fatalf("blank TweetID should still create campaign + line item: %v", err) + } + if got := atomic.LoadInt32(&promotedPosts); got != 0 { + t.Errorf("blank TweetID should skip promoted step, got %d POSTs", got) + } + } +} + +// TestTweetIDInt64OverflowRejected verifies a 19-digit value above the max int64 +// snowflake passes the regex shape but is rejected by CreateCampaign's parse +// check BEFORE any mutating call — so the flow never creates a partial campaign. +// The test drives CreateCampaign (not just strconv.ParseInt) against a server +// that fails on any create POST, so removing the production overflow check would +// make this test fail rather than silently pass. +func TestTweetIDInt64OverflowRejected(t *testing.T) { + const overflow = "9999999999999999999" // 19 digits (matches regex) but > math.MaxInt64. + // Precondition: value has the valid digit shape yet overflows int64. + if !tweetIDRe.MatchString(overflow) { + t.Fatal("precondition: value should match the digit-shape regex") + } + if _, err := strconv.ParseInt(overflow, 10, 64); err == nil { + t.Fatal("precondition: value should overflow int64") + } + + // Any create POST means the overflow check failed to reject up front. + var creates int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + creates++ + t.Errorf("unexpected create POST to %s for out-of-range tweet id", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + _, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "KubeCon EU", Project: "CNCF", BudgetUsd: 500, + StartDate: "2026-03-01", EndDate: "2026-03-10", TweetID: overflow, + RegistrationURL: "https://events.lf.org/reg", + }) + if err == nil { + t.Fatal("expected CreateCampaign to reject an out-of-int64-range tweet id, got nil error") + } + if creates != 0 { + t.Errorf("expected zero create POSTs for out-of-range tweet id, got %d", creates) + } +} + +// TestTweetIDRejectsNonSnowflake verifies the up-front TweetID format check +// rejects values that are numeric but cannot be real Tweet snowflakes ("0", +// leading-zero, or an over-19-digit decimal) so they fail before any mutation. +func TestTweetIDRejectsNonSnowflake(t *testing.T) { + for _, bad := range []string{"0", "0123", "01", "12345678901234567890" /* 20 digits */} { + if tweetIDRe.MatchString(bad) { + t.Errorf("tweetIDRe accepted %q, want reject (not a valid snowflake)", bad) + } + } + for _, ok := range []string{"1", "111", "1234567890", "1234567890123456789" /* 19 digits */} { + if !tweetIDRe.MatchString(ok) { + t.Errorf("tweetIDRe rejected %q, want accept", ok) + } + } +} + +// TestCreateCampaignValidatesRegistrationURL verifies an empty / relative / +// non-http RegistrationURL is rejected up front — before any network call — +// while a valid https URL flows past validation. +func TestCreateCampaignValidatesRegistrationURL(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/accounts/acc1"): + _, _ = w.Write([]byte(`{"data":{"name":"LF"}}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "line_items"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":{"id":"cmp1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "line_items"): + _, _ = w.Write([]byte(`{"data":{"id":"li1"}}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + mk := func() *Client { + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + return c + } + base := CampaignInput{ + EventName: "KubeCon EU", Project: "CNCF", BudgetUsd: 500, + StartDate: "2026-03-01", EndDate: "2026-03-10", + } + + // Invalid URLs must be rejected before any network call. + for _, bad := range []string{"", " ", "/relative/path", "events.lf.org/reg", "ftp://events.lf.org/reg"} { + atomic.StoreInt32(&calls, 0) + in := base + in.RegistrationURL = bad + if _, err := mk().CreateCampaign(context.Background(), in); err == nil { + t.Errorf("RegistrationURL %q should be rejected", bad) + } + if got := atomic.LoadInt32(&calls); got != 0 { + t.Errorf("RegistrationURL %q: expected 0 network calls before validation, got %d", bad, got) + } + } + + // A valid https URL flows past validation (reaches the network). + in := base + in.RegistrationURL = "https://events.lf.org/reg" + if _, err := mk().CreateCampaign(context.Background(), in); err != nil { + t.Fatalf("valid https RegistrationURL should flow: %v", err) + } + if got := atomic.LoadInt32(&calls); got == 0 { + t.Errorf("valid RegistrationURL should reach the network, got 0 calls") + } +} + +// TestVerifyAccountRetriesOn429 proves verifyAccount now goes through doRequest +// and therefore inherits the shared OAuth signing + 429 retry/backoff: a server +// that 429s once (with Retry-After) then 200s must still yield "Account +// verified: ", after a retry. The earlier version fired httpClient.Do +// directly and would have surfaced the 429 as a warning without retrying. +func TestVerifyAccountRetriesOn429(t *testing.T) { + var acctCalls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/accounts/acc1"): + if atomic.AddInt32(&acctCalls, 1) == 1 { + w.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusTooManyRequests) + return + } + _, _ = w.Write([]byte(`{"data":{"name":"LF Events"}}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + var steps []string + c.verifyAccount(context.Background(), &steps) + + if got := atomic.LoadInt32(&acctCalls); got != 2 { + t.Errorf("expected the account GET to be retried after a 429 (2 calls), got %d", got) + } + if len(steps) != 1 { + t.Fatalf("expected exactly one verification step, got %d: %v", len(steps), steps) + } + if steps[0] != "Account verified: LF Events" { + t.Errorf("expected verified step after retry, got %q", steps[0]) + } +} + +// TestVerifyAccountNonFatalOnError verifies a non-2xx account response (surfaced +// by doRequest as an error) is recorded as a warning step and NOT propagated — +// verification stays non-fatal. +func TestVerifyAccountNonFatalOnError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"errors":[{"code":"UNAUTHORIZED_ACCESS"}]}`)) + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + var steps []string + c.verifyAccount(context.Background(), &steps) + + if len(steps) != 1 { + t.Fatalf("expected one warning step, got %d: %v", len(steps), steps) + } + if !strings.HasPrefix(steps[0], "Account verification warning:") { + t.Errorf("expected a non-fatal warning step, got %q", steps[0]) + } +} + +// TestComputedBackoffClampedToMaxRetryWait verifies the no-Retry-After computed +// exponential backoff never exceeds maxRetryWait, matching the header path. It +// mirrors doRequest's fallback formula for attempt 0..retryMax. +func TestComputedBackoffClampedToMaxRetryWait(t *testing.T) { + for attempt := 0; attempt <= retryMax; attempt++ { + waitDur := writeDelay * time.Duration(1< maxRetryWait { + waitDur = maxRetryWait + } + if waitDur > maxRetryWait { + t.Errorf("attempt %d: computed backoff %v exceeds maxRetryWait %v", attempt, waitDur, maxRetryWait) + } + } +} + +// TestParseRetryAfterOverflowGuard covers the Retry-After seconds->Duration +// overflow guard (mirrors the reddit client): a validly-parsed but astronomically +// large Retry-After seconds value must NOT wrap negative (which would slip past +// the caller's `> maxRetryWait` abort and trigger an immediate retry before the +// declared reset). Instead it must return a POSITIVE duration strictly greater +// than maxRetryWait so the abort fires. A normal value passes through unchanged, +// and a value exactly at the cap is returned as-is (not spuriously over-capped). +func TestParseRetryAfterOverflowGuard(t *testing.T) { + c := NewClient(Credentials{}, AccountConfig{}) + c.timeFn = staticTime + + mk := func(v string) *http.Response { + r := &http.Response{Header: http.Header{}} + r.Header.Set("Retry-After", v) + return r + } + + // Huge values that would overflow time.Duration(n)*time.Second (int64 ns) and + // wrap to a small/negative value. Both must return a positive duration ABOVE + // maxRetryWait so the caller's over-cap abort fires — never a wrapped delay. + for _, huge := range []string{"9223372037", "99999999999"} { + got := c.parseRetryAfter(mk(huge)) + if got <= 0 { + t.Fatalf("huge Retry-After %q: got %v (non-positive); overflow not guarded", huge, got) + } + if got <= maxRetryWait { + t.Fatalf("huge Retry-After %q: got %v, want > maxRetryWait %v so the abort fires", huge, got, maxRetryWait) + } + } + + // A normal value still passes through unchanged. + if got := c.parseRetryAfter(mk("5")); got != 5*time.Second { + t.Errorf("Retry-After=5: got %v, want 5s", got) + } + + // A value EXACTLY at the cap must NOT be treated as over-cap (STRICTLY-greater + // comparison), so it is returned as-is rather than the over-cap sentinel. + capSecs := strconv.FormatInt(int64(maxRetryWait/time.Second), 10) + if got := c.parseRetryAfter(mk(capSecs)); got != maxRetryWait { + t.Errorf("Retry-After at cap (%s): got %v, want %v (returned as-is, not over-cap)", capSecs, got, maxRetryWait) + } +} + +// TestPartialResultAfterLineItemCreated verifies FINDING 2: once the campaign and +// line item are created, a fatal downstream failure (here the caller-supplied ctx +// is cancelled right after the line_items POST completes, so the pace() before the +// promoted step returns ctx.Err()) must NOT discard the created IDs. CreateCampaign +// returns an error AND a non-nil *CampaignResult carrying both CampaignID and +// LineItemID so the orphaned paid resources are identifiable for reconcile. Mirrors +// the meta/reddit partial-result contract. The cancel is driven by the server +// handler (fired once the line item is created) rather than a timed sleep, so the +// test is deterministic. +func TestPartialResultAfterLineItemCreated(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + // Server: account + find return empty; campaign and line item create OK. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/accounts/acc1"): + _, _ = w.Write([]byte(`{"data":{"name":"LF"}}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "line_items"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":{"id":"cmp1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "line_items"): + _, _ = w.Write([]byte(`{"data":{"id":"li1"}}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + // Cancellation is driven off the CLIENT side, not a timed sleep: a RoundTripper + // wrapper detects the line_items create response and wraps its Body so that + // Body.Close() fires cancel(). doRequest reads the full body and THEN closes it + // before returning, so by the time cancel() runs the line-item create has already + // succeeded (LineItemID captured). The very next pace() — before the promoted + // step, with a large WithWriteDelay so it actually blocks on ctx.Done() — then + // deterministically observes the cancellation and returns a fatal ctx error, + // exercising the post-line-item partial path with zero timing dependence. + transport := &cancelOnLineItemCloseTransport{cancel: cancel} + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi1"}, + WithBaseURL(srv.URL), + WithHTTPClient(&http.Client{Transport: transport}), + // A real write delay so the post-line-item pace() blocks in its select on + // ctx.Done(); the client-side cancel above lands before this pace runs. + WithWriteDelay(100*time.Millisecond), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + res, err := c.CreateCampaign(ctx, CampaignInput{ + EventName: "KubeCon EU", Project: "CNCF", BudgetUsd: 500, + StartDate: "2026-03-01", EndDate: "2026-03-10", TweetID: "123", + RegistrationURL: "https://events.lf.org/reg", + }) + if err == nil { + t.Fatal("expected a fatal error once the ctx was cancelled during the flow") + } + if res == nil { + t.Fatal("expected a non-nil partial result carrying the created IDs, got nil") + } + if res.CampaignID != "cmp1" { + t.Errorf("partial result must carry CampaignID cmp1, got %q", res.CampaignID) + } + if res.LineItemID != "li1" { + t.Errorf("partial result must carry LineItemID li1 (created before the failure), got %q", res.LineItemID) + } +} + +// cancelOnLineItemCloseTransport is an http.RoundTripper that forwards every +// request to the default transport, but for the line_items create (POST) response +// it wraps the response Body so that Body.Close() invokes cancel(). doRequest +// fully reads then closes each response body before returning, so this fires the +// cancellation deterministically AFTER the line item has been created and its ID +// captured, and BEFORE the next pace() — no sleep, no timing race. +type cancelOnLineItemCloseTransport struct { + cancel context.CancelFunc +} + +func (t *cancelOnLineItemCloseTransport) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := http.DefaultTransport.RoundTrip(req) + if err != nil { + return resp, err + } + if req.Method == http.MethodPost && strings.HasSuffix(req.URL.Path, "line_items") { + resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: t.cancel} + } + return resp, nil +} + +// cancelOnCloseBody calls cancel() exactly once, when the wrapped body is closed. +type cancelOnCloseBody struct { + io.ReadCloser + cancel context.CancelFunc + canceled bool +} + +func (b *cancelOnCloseBody) Close() error { + err := b.ReadCloser.Close() + if !b.canceled { + b.canceled = true + b.cancel() + } + return err +} + +// TestPartialResultAfterCampaignCreated verifies that a failure AFTER campaign +// creation but BEFORE the line item is created returns an error AND a non-nil +// result carrying the campaignID (with LineItemID still empty). Here the line_items +// create POST hard-fails, so the campaign is orphaned; its id must be surfaced. +func TestPartialResultAfterCampaignCreated(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/accounts/acc1"): + _, _ = w.Write([]byte(`{"data":{"name":"LF"}}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "line_items"): + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":{"id":"cmp1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "line_items"): + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"errors":[{"code":"BOOM","message":"line item rejected"}]}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "KubeCon EU", Project: "CNCF", BudgetUsd: 500, + StartDate: "2026-03-01", EndDate: "2026-03-10", TweetID: "123", + RegistrationURL: "https://events.lf.org/reg", + }) + if err == nil { + t.Fatal("expected a fatal error when line item creation fails") + } + if res == nil { + t.Fatal("expected a non-nil partial result carrying the campaign ID, got nil") + } + if res.CampaignID != "cmp1" { + t.Errorf("partial result must carry CampaignID cmp1, got %q", res.CampaignID) + } + if res.LineItemID != "" { + t.Errorf("LineItemID must be empty (line item was never created), got %q", res.LineItemID) + } + if !strings.Contains(err.Error(), "cmp1") { + t.Errorf("error should name the orphaned campaign id cmp1, got %v", err) + } +} + +// TestNormalizeSigningURL is a direct unit test of the RFC 5849 §3.4.1.2 signing +// URL normalization: scheme + host are lowercased, a port equal to the scheme's +// default (http:80 / https:443) is dropped, a non-default port is preserved, and +// the query string is excluded from the base-string URL. +func TestNormalizeSigningURL(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"https default port dropped, scheme+host lowered", "HTTPS://Host:443/p", "https://host/p"}, + {"http default port dropped, scheme+host lowered", "http://Host:80/p", "http://host/p"}, + {"non-default port preserved", "https://host:8080/p", "https://host:8080/p"}, + {"query excluded from base string", "https://Host:443/p?a=1&b=2", "https://host/p"}, + {"http non-default port preserved", "HTTP://HOST:8080/x/y", "http://host:8080/x/y"}, + {"escaped path preserved (not decoded)", "https://host/proxy%2Ftwitter/campaigns", "https://host/proxy%2Ftwitter/campaigns"}, + {"IPv6 literal re-bracketed with port", "http://[::1]:8080/p", "http://[::1]:8080/p"}, + {"IPv6 literal re-bracketed, default port dropped", "https://[2001:db8::1]:443/p", "https://[2001:db8::1]/p"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + u, err := url.Parse(tc.in) + if err != nil { + t.Fatalf("url.Parse(%q): %v", tc.in, err) + } + if got := normalizeSigningURL(u); got != tc.want { + t.Errorf("normalizeSigningURL(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// TestNormalizeSigningURLAppliedInSignature proves the normalization is actually +// wired into the OAuth base string: two clients that differ ONLY in how their base +// URL is written — one "HTTPS://ADS-API.X.COM:443" (upper-cased scheme+host, the +// explicit https default port) and one "https://ads-api.x.com" (already canonical) +// — must, with an identical injected nonce/time, produce the SAME oauth_signature +// for a request to the same account-scoped path. If normalization were dropped, the +// verbatim upper-cased/port-bearing base string would sign to a different value and +// X would reject it; equality here is the guard. +func TestNormalizeSigningURLAppliedInSignature(t *testing.T) { + creds := Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"} + acct := AccountConfig{AccountID: "acc1"} + + newSigner := func(base string) *Client { + c := NewClient(creds, acct, WithBaseURL(base)) + c.nonceFn = func() string { return "fixednonce" } + c.timeFn = staticTime + return c + } + + raw := newSigner("HTTPS://ADS-API.X.COM:443") + canonical := newSigner("https://ads-api.x.com") + + // Each client builds its own account-scoped request URL (differs textually in + // scheme case / explicit :443) for the same logical path; the query string is + // present to confirm it is excluded from the signing base yet still signed as a + // param on both sides identically. + pathSuffix := "/campaigns?count=1000&q=KubeCon+EU" + rawURL := raw.accountURL() + pathSuffix + canonicalURL := canonical.accountURL() + pathSuffix + + if rawURL == canonicalURL { + t.Fatalf("test precondition: the two base URLs must yield textually different request URLs, both were %q", rawURL) + } + + rawHdr, err := raw.buildOAuthHeader("GET", rawURL, nil) + if err != nil { + t.Fatalf("buildOAuthHeader (raw): %v", err) + } + canonicalHdr, err := canonical.buildOAuthHeader("GET", canonicalURL, nil) + if err != nil { + t.Fatalf("buildOAuthHeader (canonical): %v", err) + } + + rawSig := extractOAuthSignature(t, rawHdr) + canonicalSig := extractOAuthSignature(t, canonicalHdr) + if rawSig != canonicalSig { + t.Fatalf("normalization not applied to the signing base string: signatures differ\n raw base=%q sig=%q\ncanon base=%q sig=%q", rawURL, rawSig, canonicalURL, canonicalSig) + } +} + +// TestReuseExistingCampaignSurfacesWarning covers FINDING 2: when a campaign with +// the same name already exists it is reused (idempotent), the campaign create POST +// is skipped, AND a warning step is surfaced noting the existing budget/config was +// NOT updated to match this request (authoritative reconcile is the orchestrator's +// job, LFXV2-2665). Here the line item does NOT already exist, so ONLY the campaign +// reuse warning must appear — isolating the campaign branch. +func TestReuseExistingCampaignSurfacesWarning(t *testing.T) { + campaignName := buildTwitterCampaignName(CampaignInput{EventName: "KubeCon EU", Project: "CNCF"}) + + var postCampaign int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/accounts/acc1"): + _, _ = w.Write([]byte(`{"data":{"name":"LF Events"}}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "campaigns"): + b, _ := json.Marshal(map[string]any{"data": []map[string]string{{"id": "existingCmp", "name": campaignName}}}) + _, _ = w.Write(b) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "line_items"): + // No existing line item -> the line item is created fresh (no reuse warning). + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "campaigns"): + postCampaign++ + _, _ = w.Write([]byte(`{"data":{"id":"shouldNotHappen"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "line_items"): + _, _ = w.Write([]byte(`{"data":{"id":"li1"}}`)) + default: + _, _ = w.Write([]byte(`{"data":[{"id":"pt1"}]}`)) + } + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "KubeCon EU", Project: "CNCF", BudgetUsd: 750, + StartDate: "2026-03-01", EndDate: "2026-03-10", + RegistrationURL: "https://events.lf.org/reg", + }) + if err != nil { + t.Fatalf("CreateCampaign: %v", err) + } + if res.CampaignID != "existingCmp" { + t.Errorf("expected reused campaign existingCmp, got %q", res.CampaignID) + } + if postCampaign != 0 { + t.Errorf("campaign create POST must be skipped on reuse, got %d POST(s)", postCampaign) + } + if !stepsContain(res.Steps, "reused existing campaign existingCmp by name") || + !stepsContain(res.Steps, "NOT updated to match this request") { + t.Errorf("expected a campaign-reuse config-drift warning step, got steps: %v", res.Steps) + } +} + +// TestReuseExistingLineItemSurfacesWarning covers FINDING 3: when a line item with +// the same name already exists it is reused without re-checking its entity_status / +// flight dates, and a warning step must be surfaced noting the status/dates were NOT +// reset to the requested PAUSED/flight (it may already be ENABLED and serving); +// authoritative reconcile is the orchestrator's job (LFXV2-2665). Here the campaign +// is created fresh so ONLY the line-item reuse warning must appear. +func TestReuseExistingLineItemSurfacesWarning(t *testing.T) { + lineItemName := "Events | KubeCon EU | Promoted Tweets | AUTO" + + var postLineItem int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/accounts/acc1"): + _, _ = w.Write([]byte(`{"data":{"name":"LF Events"}}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "campaigns"): + // No existing campaign -> the campaign is created fresh (no reuse warning). + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "line_items"): + b, _ := json.Marshal(map[string]any{"data": []map[string]string{{"id": "existingLi", "name": lineItemName}}}) + _, _ = w.Write(b) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "campaigns"): + _, _ = w.Write([]byte(`{"data":{"id":"cmp1"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "line_items"): + postLineItem++ + _, _ = w.Write([]byte(`{"data":{"id":"shouldNotHappen"}}`)) + default: + _, _ = w.Write([]byte(`{"data":[{"id":"pt1"}]}`)) + } + })) + defer srv.Close() + + c := NewClient( + Credentials{ConsumerKey: "ck", ConsumerSecret: "cs", AccessToken: "at", AccessTokenSecret: "ats"}, + AccountConfig{AccountID: "acc1", FundingInstrumentID: "fi1"}, + WithBaseURL(srv.URL), + WithWriteDelay(0), + ) + c.nonceFn = func() string { return "n" } + c.timeFn = staticTime + + res, err := c.CreateCampaign(context.Background(), CampaignInput{ + EventName: "KubeCon EU", Project: "CNCF", BudgetUsd: 500, + StartDate: "2026-03-01", EndDate: "2026-03-10", + RegistrationURL: "https://events.lf.org/reg", + }) + if err != nil { + t.Fatalf("CreateCampaign: %v", err) + } + if res.LineItemID != "existingLi" { + t.Errorf("expected reused line item existingLi, got %q", res.LineItemID) + } + if postLineItem != 0 { + t.Errorf("line item create POST must be skipped on reuse, got %d POST(s)", postLineItem) + } + if !stepsContain(res.Steps, "reused existing line item existingLi by name") || + !stepsContain(res.Steps, "NOT reset to the requested PAUSED") { + t.Errorf("expected a line-item-reuse status/dates warning step, got steps: %v", res.Steps) + } +} + +// stepsContain reports whether any step in steps contains substr. +func stepsContain(steps []string, substr string) bool { + for _, s := range steps { + if strings.Contains(s, substr) { + return true + } + } + return false +}