diff --git a/.changeset/conversions.md b/.changeset/conversions.md new file mode 100644 index 0000000..45ccc91 --- /dev/null +++ b/.changeset/conversions.md @@ -0,0 +1,22 @@ +--- +"@gmc-cli/api": patch +"@gmc-cli/cli": patch +--- + +feat(conversions): add `gmc conversions` (Merchant API conversions/v1) + +Adds the `conversions/v1` `accounts.conversionSources` sub-API — the 10th of 11 +Merchant API sub-APIs gmc covers. A conversion source links an account to a +conversion-measurement origin: a **Merchant Center destination** or a **Google +Analytics property link**. + +`gmc conversions list | get | create | update | delete | undelete`. `delete` +soft-archives a source; `undelete` restores it. `create` takes `--ga-property` +for a Google Analytics link or `--merchant-center --currency` for a Merchant +Center destination (or `--file` for the full body, e.g. nested +`attributionSettings`). `update` patches Merchant Center fields via a nested +`updateMask` so the rest of the destination is untouched. + +New `ConversionsService` in `@gmc-cli/api` plus the `conversions` rate-limit +bucket and OAuth-scope wiring. Field names and RPC paths verified against the +`conversions_v1` discovery document. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 14fe711..259a9c1 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -132,6 +132,7 @@ export default defineConfig({ { text: "gmc quota", link: "/reference/quota" }, { text: "gmc issues", link: "/reference/issues" }, { text: "gmc reports", link: "/reference/reports" }, + { text: "gmc conversions", link: "/reference/conversions" }, { text: "gmc preflight", link: "/reference/preflight" }, { text: "gmc migrate", link: "/reference/migrate" }, ], diff --git a/docs/reference/conversions.md b/docs/reference/conversions.md new file mode 100644 index 0000000..45cb948 --- /dev/null +++ b/docs/reference/conversions.md @@ -0,0 +1,76 @@ +# gmc conversions + +**Manage conversion sources** — where a Merchant Center account measures conversions +(`conversions/v1` `accounts.conversionSources`). A source is either a **Merchant Center +destination** or a **Google Analytics property link**. Full lifecycle: **list / get / create / +update / delete / undelete** (`delete` archives, `undelete` restores). Every subcommand operates on +the account resolved from `--account` / `GMC_ACCOUNT_ID` / your profile. + +```sh +gmc conversions create --ga-property 123456789 +gmc conversions create --merchant-center --currency USD --display-name "Store" +gmc conversions list +gmc conversions get +gmc conversions update --display-name "New name" +gmc conversions delete # archives (soft-delete) +gmc conversions undelete # restores +``` + +## Commands + +| Command | Description | +| --------------------------------------------------------- | ------------------------------------------------------- | +| `gmc conversions list` | List conversion sources for the account | +| `gmc conversions get ` | Fetch one source (id or resource name) | +| `gmc conversions create [flags]` | Create a source (its id is auto-generated) | +| `gmc conversions update [flags] [--update-mask ]` | Patch a source — only the fields you pass | +| `gmc conversions delete ` | Archive a source (soft-delete; restore with `undelete`) | +| `gmc conversions undelete ` | Restore a previously archived source | + +## Defining a source + +A source is **one type** — pass the flags for a Google Analytics link **or** a Merchant Center +destination (not both), or a full `--file` body. + +| Flag | Sets | +| ----------------------- | --------------------------------------------------------------------------- | +| `--ga-property ` | `googleAnalyticsLink.propertyId` — link a Google Analytics property | +| `--merchant-center` | Create a `merchantCenterDestination` source | +| `--currency ` | `merchantCenterDestination.currencyCode` (ISO 4217; **required** for MC) | +| `--display-name ` | `merchantCenterDestination.displayName` | +| `--file ` | Full `ConversionSource` JSON (else stdin); for nested `attributionSettings` | + +`--file` and the convenience flags are mutually exclusive. Output-only fields (`name`, `state`, +`controller`, `expireTime`) in a `--file` body are dropped, so a body saved from `get` re-applies. + +## Updating + +`update` patches only what you pass. The convenience flags target Merchant Center fields with a +**nested** `updateMask` (so the rest of the destination is untouched): + +| Flag | Patches | Mask | +| ----------------------- | ---------------------------------------- | ---------------------------------------- | +| `--display-name ` | `merchantCenterDestination.displayName` | `merchantCenterDestination.displayName` | +| `--currency ` | `merchantCenterDestination.currencyCode` | `merchantCenterDestination.currencyCode` | +| `--file ` | Replaces the named source object | its top-level keys (or `--update-mask`) | + +The Google Analytics `propertyId` is immutable, so there is no update flag for it. + +## Output + +`list` prints `id · state · type` (type is `Merchant Center "name" (CUR)` or `GA property `); +`get` adds `state` / `controller` / `expireTime` detail lines. `--json` emits the raw API result +(`{ "conversionSources": [...] }` for list, the resource for get/create/update/undelete, +`{ "deleted": "" }` for delete). + +::: tip Read-after-write +`create` returns the source immediately, but the Merchant API is eventually consistent: a `get` / +`update` / `delete` in the next instant may briefly return `404` until it propagates — usually a few +seconds, up to ~20s. If you script `create` followed by another call on the new id, allow a short +delay or retry. +::: + +## Exit codes + +`0` success · `2` usage (no account, no/both source types, `--merchant-center` without `--currency`, +`--file` with convenience flags, nothing-to-update) · `3` auth · `5` Merchant API. diff --git a/docs/reference/index.md b/docs/reference/index.md index c5d09f4..d88d40f 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -25,6 +25,7 @@ gmc [global options] [subcommand] [args] | [`gmc quota`](/reference/quota) | Inspect daily Merchant API call quota and usage (`list`) | | [`gmc issues`](/reference/issues) | Render account & product issues with resolution content (`account` / `product`) | | [`gmc reports`](/reference/reports) | Reports — `performance` / `competitive-visibility` / `price-competitiveness` / `check` (CI gate) / `query` (MCQL) | +| [`gmc conversions`](/reference/conversions) | Conversion sources — `list` / `get` / `create` / `update` / `delete` / `undelete` | | [`gmc feeds`](/reference/feeds) | Feeds as code (`pull` / `push` / `diff`) | | [`gmc preflight`](/reference/preflight) | Offline feed-compliance scanner — catch disapprovals before upload | | [`gmc migrate`](/reference/migrate) | Content API → Merchant API assistant (`scopes` / `products` / `feed-labels`) | diff --git a/packages/api/src/conversions.ts b/packages/api/src/conversions.ts new file mode 100644 index 0000000..bb7f9a9 --- /dev/null +++ b/packages/api/src/conversions.ts @@ -0,0 +1,153 @@ +// Typed Conversions service (Merchant API `conversions/v1`, +// `accounts.conversionSources`). A conversion source links a merchant account to a +// conversion-measurement origin — either a Merchant Center destination or a Google +// Analytics property. Full CRUD plus `undelete`: `delete` soft-archives a source and +// `undelete` restores it. `create` returns an auto-generated id (no client-supplied id), +// and `update` carries an `updateMask` query param, mirroring the `notifications` / +// `regions` write shape. Sources live on the new "conversions" rate-limit bucket. +// Wraps a MerchantClient scoped to one account. + +import type { MerchantClient } from "./client.js"; + +const CONVERSIONS_API = "conversions/v1"; + +// As elsewhere, these model only the fields the CLI reads/writes; the Merchant API +// accepts and returns more, and `client.get`/`request` round-trip the full JSON, so +// `--json` output and the bodies sent on create/update are never lossy. + +/** + * Attribution configuration shared by both source types. `attributionModel` is one of the + * `*_CLICK` / `*_DATA_DRIVEN` / `*_LINEAR` … enum values; the rest round-trip via `--file`. + */ +export interface AttributionSettings { + conversionType?: unknown[]; + attributionModel?: string; + attributionLookbackWindowDays?: number; +} + +/** A conversion source backed by a Merchant Center destination. */ +export interface MerchantCenterDestination { + /** Output-only: the Merchant Center destination id. */ + destination?: string; + /** Human-readable name shown in Merchant Center. */ + displayName?: string; + /** Required. Three-letter ISO 4217 currency code for reported conversion values. */ + currencyCode?: string; + /** Writable attribution configuration. */ + attributionSettings?: AttributionSettings; +} + +/** A conversion source backed by a Google Analytics property link. */ +export interface GoogleAnalyticsLink { + /** Output-only resource name of the linked GA property. */ + property?: string; + /** Required, immutable. The Google Analytics property id. */ + propertyId?: string; + /** Output-only attribution configuration (inherited from the GA property). */ + attributionSettings?: AttributionSettings; +} + +/** + * A conversion source (`accounts/{account}/conversionSources/{conversionSource}`). Exactly + * one of `merchantCenterDestination` / `googleAnalyticsLink` is set (a union — fixed at + * create, not switched on patch). `name`, `state`, `controller`, and `expireTime` are + * output-only. + */ +export interface ConversionSource { + /** Output-only resource name: `accounts/{account}/conversionSources/{id}`. */ + name?: string; + /** Output-only: `ACTIVE` / `ARCHIVED` / `PENDING` / `STATE_UNSPECIFIED`. */ + state?: string; + /** Output-only: who owns the source — `MERCHANT` / `YOUTUBE_AFFILIATES`. */ + controller?: string; + /** Output-only: when an archived source is permanently removed. */ + expireTime?: string; + merchantCenterDestination?: MerchantCenterDestination; + googleAnalyticsLink?: GoogleAnalyticsLink; +} + +/** The writable subset of a conversion source accepted on create / update. */ +export type ConversionSourceInput = Pick< + ConversionSource, + "merchantCenterDestination" | "googleAnalyticsLink" +>; + +/** One page of `conversionSources.list`. */ +interface ConversionSourcesListPage { + conversionSources?: ConversionSource[]; + nextPageToken?: string; +} + +/** + * Reduce a conversion-source id or full resource name to its bare id, mirroring + * {@link notificationSegment}, so callers can pass either a bare id or the `name` from `list`. + */ +export function conversionSourceSegment(idOrName: string): string { + return idOrName.replace(/^.*\/conversionSources\//, ""); +} + +/** Full create/list/get/update/delete/undelete access to Merchant API conversion sources. */ +export class ConversionsService { + constructor(private readonly client: MerchantClient) {} + + private get base(): string { + return `${CONVERSIONS_API}/${this.client.accountResource}/conversionSources`; + } + + private resource(idOrName: string): string { + return `${this.base}/${encodeURIComponent(conversionSourceSegment(idOrName))}`; + } + + /** List every conversion source for the account, following pagination. */ + async listConversionSources(): Promise { + const sources: ConversionSource[] = []; + for await (const s of this.client.paginate("conversions", this.base, { + select: (page) => (page as ConversionSourcesListPage).conversionSources ?? [], + })) { + sources.push(s); + } + return sources; + } + + /** Fetch a single conversion source by id (or full resource name). */ + getConversionSource(idOrName: string): Promise { + return this.client.get("conversions", this.resource(idOrName)); + } + + /** Create a conversion source. The id is auto-generated, so none is supplied. */ + createConversionSource(body: ConversionSourceInput): Promise { + return this.client.post("conversions", this.base, body); + } + + /** + * Patch a conversion source. The `updateMask` defaults to the input's own keys, so only + * what you pass is changed; pass `updateMask` to override (e.g. a nested + * `merchantCenterDestination.displayName`). Mirrors `notifications.update`. + */ + updateConversionSource( + idOrName: string, + body: ConversionSourceInput, + opts: { updateMask?: string } = {}, + ): Promise { + const updateMask = opts.updateMask ?? Object.keys(body).join(","); + return this.client.request("conversions", "PATCH", this.resource(idOrName), { + query: { updateMask }, + body, + }); + } + + /** Soft-delete (archive) a conversion source by id. Restorable with {@link undeleteConversionSource}. */ + async deleteConversionSource(idOrName: string): Promise { + await this.client.delete("conversions", this.resource(idOrName)); + } + + /** Re-enable a previously soft-deleted conversion source (`:undelete` colon-verb). */ + undeleteConversionSource(idOrName: string): Promise { + return this.client.request( + "conversions", + "POST", + `${this.resource(idOrName)}:undelete`, + { body: {} }, + ); + } +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index fcf164a..148c184 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -86,6 +86,15 @@ export type { RegisteredEvent, } from "./notifications.js"; +export { ConversionsService, conversionSourceSegment } from "./conversions.js"; +export type { + ConversionSource, + ConversionSourceInput, + MerchantCenterDestination, + GoogleAnalyticsLink, + AttributionSettings, +} from "./conversions.js"; + export { RegionsService, regionSegment } from "./regions.js"; export type { Region, diff --git a/packages/api/src/rate-limiter.ts b/packages/api/src/rate-limiter.ts index 3d69aea..84b980c 100644 --- a/packages/api/src/rate-limiter.ts +++ b/packages/api/src/rate-limiter.ts @@ -37,6 +37,7 @@ export const DEFAULT_RATE_LIMITS: RateLimitConfig = { notifications: { capacity: 30, refillPerSecond: 5 }, quota: { capacity: 30, refillPerSecond: 5 }, issueresolution: { capacity: 30, refillPerSecond: 5 }, + conversions: { capacity: 30, refillPerSecond: 5 }, }; class TokenBucket { diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index 463880c..4bf550b 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -11,7 +11,8 @@ export type SubApi = | "promotions" | "notifications" | "quota" - | "issueresolution"; + | "issueresolution" + | "conversions"; /** All sub-API keys, in a stable order. */ export const SUB_APIS: readonly SubApi[] = [ @@ -24,4 +25,5 @@ export const SUB_APIS: readonly SubApi[] = [ "notifications", "quota", "issueresolution", + "conversions", ]; diff --git a/packages/api/tests/conversions.test.ts b/packages/api/tests/conversions.test.ts new file mode 100644 index 0000000..f4b4850 --- /dev/null +++ b/packages/api/tests/conversions.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from "vitest"; +import { MerchantClient } from "../src/client.js"; +import { ConversionsService, conversionSourceSegment } from "../src/conversions.js"; +import type { ConversionSourceInput } from "../src/conversions.js"; +import type { Clock } from "../src/rate-limiter.js"; + +const auth = { + getAccessToken: async () => "tok", + getClientEmail: () => "e", + getProjectId: () => undefined, +}; +const instantClock: Clock = { now: () => 0, sleep: async () => {} }; + +function jsonResponse(status: number, body?: unknown): Response { + return new Response(body === undefined ? null : JSON.stringify(body), { status }); +} + +function capturing( + body: unknown, + status = 200, +): { + service: ConversionsService; + calls: { url: string; method?: string; body?: unknown }[]; +} { + const calls: { url: string; method?: string; body?: unknown }[] = []; + const fetchImpl = (async (u: string, init?: RequestInit) => { + calls.push({ + url: u, + method: init?.method, + body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined, + }); + return jsonResponse(status, body); + }) as unknown as typeof fetch; + const service = new ConversionsService( + new MerchantClient({ auth, accountId: "123", fetchImpl, clock: instantClock }), + ); + return { service, calls }; +} + +const BASE = "https://merchantapi.googleapis.com/conversions/v1/accounts/123/conversionSources"; + +describe("conversionSourceSegment", () => { + it("reduces a full resource name to its id", () => { + expect(conversionSourceSegment("accounts/123/conversionSources/abc")).toBe("abc"); + expect(conversionSourceSegment("abc")).toBe("abc"); + }); +}); + +describe("ConversionsService", () => { + it("lists sources, following pagination", async () => { + let call = 0; + const pages = [ + { + conversionSources: [{ name: "accounts/123/conversionSources/a" }], + nextPageToken: "t2", + }, + { conversionSources: [{ name: "accounts/123/conversionSources/b" }] }, + ]; + const urls: string[] = []; + const fetchImpl = (async (u: string) => { + urls.push(u); + return jsonResponse(200, pages[call++]); + }) as unknown as typeof fetch; + const service = new ConversionsService( + new MerchantClient({ auth, accountId: "123", fetchImpl, clock: instantClock }), + ); + const list = await service.listConversionSources(); + expect(list.map((s) => conversionSourceSegment(s.name ?? ""))).toEqual(["a", "b"]); + expect(urls[0]).toBe(BASE); + expect(urls[1]).toContain("pageToken=t2"); + }); + + it("gets a source, normalizing a full resource name", async () => { + const { service, calls } = capturing({ name: "accounts/123/conversionSources/abc" }); + await service.getConversionSource("accounts/123/conversionSources/abc"); + expect(calls[0]?.method).toBe("GET"); + expect(calls[0]?.url).toBe(`${BASE}/abc`); + }); + + it("creates a source by POSTing the body (no id in the path/query)", async () => { + const { service, calls } = capturing({ name: "accounts/123/conversionSources/new" }); + const input: ConversionSourceInput = { + merchantCenterDestination: { currencyCode: "USD", displayName: "My MC" }, + }; + await service.createConversionSource(input); + expect(calls[0]?.method).toBe("POST"); + expect(calls[0]?.url).toBe(BASE); + expect(calls[0]?.body).toEqual(input); + }); + + it("patches a source, defaulting updateMask to the input keys", async () => { + const { service, calls } = capturing({}); + await service.updateConversionSource("abc", { + merchantCenterDestination: { displayName: "Renamed" }, + }); + expect(calls[0]?.method).toBe("PATCH"); + expect(calls[0]?.url).toBe(`${BASE}/abc?updateMask=merchantCenterDestination`); + expect(calls[0]?.body).toEqual({ merchantCenterDestination: { displayName: "Renamed" } }); + }); + + it("patches with an explicit nested updateMask when provided", async () => { + const { service, calls } = capturing({}); + await service.updateConversionSource( + "abc", + { merchantCenterDestination: { displayName: "Renamed" } }, + { updateMask: "merchantCenterDestination.displayName" }, + ); + expect(calls[0]?.url).toBe(`${BASE}/abc?updateMask=merchantCenterDestination.displayName`); + }); + + it("deletes (archives) a source by id", async () => { + const { service, calls } = capturing(undefined, 204); + await service.deleteConversionSource("abc"); + expect(calls[0]?.method).toBe("DELETE"); + expect(calls[0]?.url).toBe(`${BASE}/abc`); + }); + + it("undeletes a source via the :undelete colon-verb with no body", async () => { + const { service, calls } = capturing({ name: "accounts/123/conversionSources/abc" }); + await service.undeleteConversionSource("abc"); + expect(calls[0]?.method).toBe("POST"); + expect(calls[0]?.url).toBe(`${BASE}/abc:undelete`); + expect(calls[0]?.body).toEqual({}); + }); +}); diff --git a/packages/api/tests/rate-limiter.test.ts b/packages/api/tests/rate-limiter.test.ts index cb90009..a13c7d6 100644 --- a/packages/api/tests/rate-limiter.test.ts +++ b/packages/api/tests/rate-limiter.test.ts @@ -20,9 +20,9 @@ function fakeClock(): Clock & { advance: (ms: number) => void; sleeps: number[] } describe("rate limiter buckets", () => { - it("DEFAULT_RATE_LIMITS has a bucket for all nine sub-APIs", () => { - expect(SUB_APIS).toHaveLength(9); - expect(SUB_APIS).toContain("notifications"); + it("DEFAULT_RATE_LIMITS has a bucket for all ten sub-APIs", () => { + expect(SUB_APIS).toHaveLength(10); + expect(SUB_APIS).toContain("conversions"); expect(Object.keys(DEFAULT_RATE_LIMITS).sort()).toEqual([...SUB_APIS].sort()); }); diff --git a/packages/auth/src/scopes.ts b/packages/auth/src/scopes.ts index b845d0c..89af625 100644 --- a/packages/auth/src/scopes.ts +++ b/packages/auth/src/scopes.ts @@ -22,7 +22,8 @@ export type SubApi = | "promotions" | "notifications" | "quota" - | "issueresolution"; + | "issueresolution" + | "conversions"; /** All sub-API keys, in a stable order. Mirrors `@gmc-cli/api` SUB_APIS. */ export const SUB_APIS: readonly SubApi[] = [ @@ -35,6 +36,7 @@ export const SUB_APIS: readonly SubApi[] = [ "notifications", "quota", "issueresolution", + "conversions", ]; // Per-sub-API scope map. Every entry currently resolves to the content scope; @@ -49,6 +51,7 @@ const SUB_API_SCOPES: Readonly> = { notifications: [MERCHANT_API_SCOPE], quota: [MERCHANT_API_SCOPE], issueresolution: [MERCHANT_API_SCOPE], + conversions: [MERCHANT_API_SCOPE], }; /** diff --git a/packages/cli/src/commands/conversions.ts b/packages/cli/src/commands/conversions.ts new file mode 100644 index 0000000..9ef813f --- /dev/null +++ b/packages/cli/src/commands/conversions.ts @@ -0,0 +1,306 @@ +import type { Command } from "commander"; +import { emitJson, reportError, UsageError } from "@gmc-cli/core"; +import { + ConversionsService, + conversionSourceSegment, + type ConversionSource, + type ConversionSourceInput, + type MerchantCenterDestination, +} from "@gmc-cli/api"; +import { contextFrom, wantsJson } from "../context.js"; +import { clientFor, resolveAccount, line, readJsonObject, pick } from "./_shared.js"; + +const CONVERSION_SOURCE_FIELDS = [ + "merchantCenterDestination", + "googleAnalyticsLink", +] as const satisfies readonly (keyof ConversionSourceInput)[]; + +interface ConversionWriteOpts { + gaProperty?: string; + merchantCenter?: boolean; + currency?: string; + displayName?: string; + file?: string; + updateMask?: string; +} + +/** True when any of the convenience (non-file) flags were passed. */ +function hasConvenienceFlags(opts: ConversionWriteOpts): boolean { + return ( + opts.gaProperty !== undefined || + opts.merchantCenter === true || + opts.currency !== undefined || + opts.displayName !== undefined + ); +} + +/** Read a conversion-source body from `--file`, keeping only the writable source union keys. */ +async function inputFromFile(file: string): Promise { + const input = pick( + await readJsonObject(file, "conversion source"), + CONVERSION_SOURCE_FIELDS, + ); + if (Object.keys(input).length === 0) { + throw new UsageError( + "The --file body has no conversion source fields.", + "Provide a merchantCenterDestination or googleAnalyticsLink object.", + ); + } + return input; +} + +/** + * Build the create body. Exactly one source type is required: a Google Analytics link + * (`--ga-property`) or a Merchant Center destination (`--merchant-center` + `--currency`), + * or a full `--file` body. `--file` and the convenience flags are mutually exclusive. + */ +async function buildCreateInput(opts: ConversionWriteOpts): Promise { + if (opts.file) { + if (hasConvenienceFlags(opts)) { + throw new UsageError( + "Pass either --file or the convenience flags, not both.", + "Use --file for the full body, or --ga-property / --merchant-center for the common cases.", + ); + } + return inputFromFile(opts.file); + } + + const wantsGa = opts.gaProperty !== undefined; + const wantsMc = + opts.merchantCenter === true || opts.currency !== undefined || opts.displayName !== undefined; + + if (wantsGa && wantsMc) { + throw new UsageError( + "A conversion source is one type.", + "Pass --ga-property for a Google Analytics link, or --merchant-center for a Merchant Center destination, not both.", + ); + } + if (wantsGa) { + return { googleAnalyticsLink: { propertyId: opts.gaProperty } }; + } + if (wantsMc) { + if (opts.currency === undefined) { + throw new UsageError( + "--currency is required for a Merchant Center destination.", + "Pass --currency , e.g. --currency USD.", + ); + } + const dest: Partial = { + currencyCode: opts.currency, + ...(opts.displayName !== undefined ? { displayName: opts.displayName } : {}), + }; + return { merchantCenterDestination: dest }; + } + throw new UsageError( + "A conversion source type is required.", + "Pass --ga-property , --merchant-center --currency , or --file.", + ); +} + +/** + * Build the update body and its `updateMask`. A `--file` replaces the named source object + * (mask = its top-level keys). The `--display-name` / `--currency` convenience flags patch + * just those Merchant Center fields via a nested mask (`merchantCenterDestination.`), + * so the rest of the destination is untouched. The GA `propertyId` is immutable (no flag). + */ +async function buildUpdateInput( + opts: ConversionWriteOpts, +): Promise<{ input: ConversionSourceInput; mask: string }> { + if (opts.file) { + if (hasConvenienceFlags(opts)) { + throw new UsageError( + "Pass either --file or the convenience flags, not both.", + "Use --file for the full body, or --display-name / --currency for a Merchant Center patch.", + ); + } + const input = await inputFromFile(opts.file); + return { input, mask: opts.updateMask ?? Object.keys(input).join(",") }; + } + + const dest: Partial = {}; + const maskFields: string[] = []; + if (opts.displayName !== undefined) { + dest.displayName = opts.displayName; + maskFields.push("merchantCenterDestination.displayName"); + } + if (opts.currency !== undefined) { + dest.currencyCode = opts.currency; + maskFields.push("merchantCenterDestination.currencyCode"); + } + if (maskFields.length === 0) { + throw new UsageError( + "Nothing to update.", + "Pass --display-name, --currency, or --file (with optional --update-mask).", + ); + } + return { + input: { merchantCenterDestination: dest }, + mask: opts.updateMask ?? maskFields.join(","), + }; +} + +/** The bare conversion-source id, preferring the resource `name` segment. */ +function conversionIdOf(source: ConversionSource): string { + return source.name ? conversionSourceSegment(source.name) : "—"; +} + +/** One-line source-type summary. */ +function typeSummary(source: ConversionSource): string { + if (source.googleAnalyticsLink) { + return `GA property ${source.googleAnalyticsLink.propertyId ?? "—"}`; + } + if (source.merchantCenterDestination) { + const d = source.merchantCenterDestination; + return `Merchant Center${d.displayName ? ` "${d.displayName}"` : ""}${d.currencyCode ? ` (${d.currencyCode})` : ""}`; + } + return "—"; +} + +function renderConversions(sources: ConversionSource[]): void { + if (sources.length === 0) { + process.stdout.write("No conversion sources for this account.\n"); + return; + } + const width = Math.max(...sources.map((s) => conversionIdOf(s).length)); + process.stdout.write(`${sources.length} conversion source(s):\n`); + for (const s of sources) { + process.stdout.write( + ` ${conversionIdOf(s).padEnd(width)} ${s.state ?? "—"} · ${typeSummary(s)}\n`, + ); + } +} + +function renderConversion(source: ConversionSource): void { + line("ID", conversionIdOf(source)); + if (source.state) line("State", source.state); + if (source.controller) line("Controller", source.controller); + line("Source", typeSummary(source)); + if (source.expireTime) line("Expires", source.expireTime); +} + +/** Register the `gmc conversions` command group (list / get / create / update / delete / undelete). */ +export function registerConversionsCommands(program: Command): void { + const conversions = program + .command("conversions") + .description( + "Manage conversion sources (Merchant Center destinations & Google Analytics links)", + ); + + conversions + .command("list") + .description("List conversion sources for the account") + .action(async () => { + const json = wantsJson(program); + try { + const ctx = contextFrom(program); + const account = resolveAccount(undefined, ctx); + const service = new ConversionsService(await clientFor(ctx, account)); + const list = await service.listConversionSources(); + if (ctx.json) emitJson({ conversionSources: list }); + else renderConversions(list); + } catch (err) { + reportError(err, { json }, "gmc conversions list"); + } + }); + + conversions + .command("get") + .argument("", "Conversion source id or resource name (from `conversions list`)") + .description("Fetch one conversion source") + .action(async (id: string) => { + const json = wantsJson(program); + try { + const ctx = contextFrom(program); + const account = resolveAccount(undefined, ctx); + const service = new ConversionsService(await clientFor(ctx, account)); + const result = await service.getConversionSource(id); + if (ctx.json) emitJson(result); + else renderConversion(result); + } catch (err) { + reportError(err, { json }, "gmc conversions get"); + } + }); + + conversions + .command("create") + .option("--ga-property ", "Create a Google Analytics link to this property id") + .option("--merchant-center", "Create a Merchant Center destination source") + .option("--currency ", "Destination currency (ISO 4217; required with --merchant-center)") + .option("--display-name ", "Destination display name") + .option("--file ", "Read the full ConversionSource JSON from this file (else stdin)") + .description("Create a conversion source (its id is auto-generated)") + .action(async (opts: ConversionWriteOpts) => { + const json = wantsJson(program); + try { + const ctx = contextFrom(program); + const account = resolveAccount(undefined, ctx); + const input = await buildCreateInput(opts); + const service = new ConversionsService(await clientFor(ctx, account)); + const result = await service.createConversionSource(input); + if (ctx.json) emitJson(result); + else process.stdout.write(`Created conversion source ${conversionIdOf(result)}.\n`); + } catch (err) { + reportError(err, { json }, "gmc conversions create"); + } + }); + + conversions + .command("update") + .argument("", "Conversion source id or resource name") + .option("--display-name ", "New Merchant Center destination display name") + .option("--currency ", "New Merchant Center destination currency (ISO 4217)") + .option("--file ", "Read the ConversionSource JSON body from this file") + .option("--update-mask ", "Explicit field mask (defaults to the fields you pass)") + .description("Patch a conversion source (only the fields you pass are changed)") + .action(async (id: string, opts: ConversionWriteOpts) => { + const json = wantsJson(program); + try { + const ctx = contextFrom(program); + const account = resolveAccount(undefined, ctx); + const { input, mask } = await buildUpdateInput(opts); + const service = new ConversionsService(await clientFor(ctx, account)); + const result = await service.updateConversionSource(id, input, { updateMask: mask }); + if (ctx.json) emitJson(result); + else process.stdout.write(`Updated conversion source ${conversionSourceSegment(id)}.\n`); + } catch (err) { + reportError(err, { json }, "gmc conversions update"); + } + }); + + conversions + .command("delete") + .argument("", "Conversion source id or resource name") + .description("Archive a conversion source (soft-delete; restore with `undelete`)") + .action(async (id: string) => { + const json = wantsJson(program); + try { + const ctx = contextFrom(program); + const account = resolveAccount(undefined, ctx); + const service = new ConversionsService(await clientFor(ctx, account)); + await service.deleteConversionSource(id); + const seg = conversionSourceSegment(id); + if (ctx.json) emitJson({ deleted: seg }); + else process.stdout.write(`Archived conversion source ${seg}.\n`); + } catch (err) { + reportError(err, { json }, "gmc conversions delete"); + } + }); + + conversions + .command("undelete") + .argument("", "Conversion source id or resource name") + .description("Restore a previously archived conversion source") + .action(async (id: string) => { + const json = wantsJson(program); + try { + const ctx = contextFrom(program); + const account = resolveAccount(undefined, ctx); + const service = new ConversionsService(await clientFor(ctx, account)); + const result = await service.undeleteConversionSource(id); + if (ctx.json) emitJson(result); + else process.stdout.write(`Restored conversion source ${conversionIdOf(result)}.\n`); + } catch (err) { + reportError(err, { json }, "gmc conversions undelete"); + } + }); +} diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 77094e8..22eb4c3 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -15,6 +15,7 @@ import { registerNotificationsCommands } from "./commands/notifications.js"; import { registerQuotaCommands } from "./commands/quota.js"; import { registerIssuesCommands } from "./commands/issues.js"; import { registerReportsCommands } from "./commands/reports.js"; +import { registerConversionsCommands } from "./commands/conversions.js"; /** * Build the root `gmc` command tree. @@ -58,6 +59,7 @@ export function createProgram(): Command { registerQuotaCommands(program); registerIssuesCommands(program); registerReportsCommands(program); + registerConversionsCommands(program); return program; } diff --git a/packages/cli/tests/conversions.test.ts b/packages/cli/tests/conversions.test.ts new file mode 100644 index 0000000..fe1be5b --- /dev/null +++ b/packages/cli/tests/conversions.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; + +const { + resolveAuth, + listConversionSources, + getConversionSource, + createConversionSource, + updateConversionSource, + deleteConversionSource, + undeleteConversionSource, +} = vi.hoisted(() => ({ + resolveAuth: vi.fn(), + listConversionSources: vi.fn(), + getConversionSource: vi.fn(), + createConversionSource: vi.fn(), + updateConversionSource: vi.fn(), + deleteConversionSource: vi.fn(), + undeleteConversionSource: vi.fn(), +})); + +vi.mock("@gmc-cli/auth", async (importActual) => { + const actual = await importActual(); + return { ...actual, resolveAuth }; +}); + +vi.mock("@gmc-cli/api", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + MerchantClient: class { + constructor(_o: unknown) {} + }, + ConversionsService: class { + listConversionSources = listConversionSources; + getConversionSource = getConversionSource; + createConversionSource = createConversionSource; + updateConversionSource = updateConversionSource; + deleteConversionSource = deleteConversionSource; + undeleteConversionSource = undeleteConversionSource; + }, + }; +}); + +import { createProgram } from "../src/program.js"; + +function run(args: string[]): Promise { + return createProgram().parseAsync(["node", "gmc", ...args]); +} + +describe("gmc conversions", () => { + let writes: string[]; + let dir: string; + let savedEnv: Record; + const ENV = ["GMC_CONFIG_DIR", "GMC_PROFILE", "GMC_ACCOUNT_ID"] as const; + + beforeEach(() => { + vi.clearAllMocks(); + process.exitCode = 0; + savedEnv = {}; + for (const key of ENV) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + process.env["GMC_CONFIG_DIR"] = join(tmpdir(), "gmc-conversions-noconfig"); + process.env["GMC_ACCOUNT_ID"] = "123"; + dir = mkdtempSync(join(tmpdir(), "gmc-conversions-")); + writes = []; + vi.spyOn(process.stdout, "write").mockImplementation((c: unknown) => { + writes.push(String(c)); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + resolveAuth.mockResolvedValue({ + getAccessToken: async () => "tok", + getClientEmail: () => "e", + getProjectId: () => undefined, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + rmSync(dir, { recursive: true, force: true }); + for (const key of ENV) { + const v = savedEnv[key]; + if (v === undefined) delete process.env[key]; + else process.env[key] = v; + } + }); + + const out = (): string => writes.join(""); + + it("lists sources with a state · type summary", async () => { + listConversionSources.mockResolvedValue([ + { + name: "accounts/123/conversionSources/abc", + state: "ACTIVE", + merchantCenterDestination: { displayName: "Store", currencyCode: "USD" }, + }, + ]); + await run(["conversions", "list"]); + expect(out()).toContain("1 conversion source(s)"); + expect(out()).toContain("abc"); + expect(out()).toContain("ACTIVE"); + expect(out()).toContain('Merchant Center "Store" (USD)'); + }); + + it("emits JSON for list under a conversionSources envelope", async () => { + listConversionSources.mockResolvedValue([{ name: "accounts/123/conversionSources/abc" }]); + await run(["-j", "conversions", "list"]); + expect(JSON.parse(out())).toEqual({ + conversionSources: [{ name: "accounts/123/conversionSources/abc" }], + }); + }); + + it("gets one source", async () => { + getConversionSource.mockResolvedValue({ + name: "accounts/123/conversionSources/abc", + state: "ACTIVE", + googleAnalyticsLink: { propertyId: "987" }, + }); + await run(["conversions", "get", "abc"]); + expect(getConversionSource).toHaveBeenCalledWith("abc"); + expect(out()).toContain("GA property 987"); + }); + + it("creates a Google Analytics link from --ga-property", async () => { + createConversionSource.mockResolvedValue({ name: "accounts/123/conversionSources/new" }); + await run(["conversions", "create", "--ga-property", "987"]); + expect(createConversionSource).toHaveBeenCalledWith({ + googleAnalyticsLink: { propertyId: "987" }, + }); + expect(out()).toContain("Created conversion source new"); + }); + + it("creates a Merchant Center destination from flags", async () => { + createConversionSource.mockResolvedValue({ name: "accounts/123/conversionSources/n2" }); + await run([ + "conversions", + "create", + "--merchant-center", + "--currency", + "USD", + "--display-name", + "Store", + ]); + expect(createConversionSource).toHaveBeenCalledWith({ + merchantCenterDestination: { currencyCode: "USD", displayName: "Store" }, + }); + }); + + it("rejects --merchant-center without --currency (exit 2)", async () => { + await run(["conversions", "create", "--merchant-center", "--display-name", "Store"]); + expect(createConversionSource).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(2); + }); + + it("rejects mixing --ga-property and --merchant-center (exit 2)", async () => { + await run([ + "conversions", + "create", + "--ga-property", + "987", + "--merchant-center", + "--currency", + "USD", + ]); + expect(createConversionSource).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(2); + }); + + it("rejects create with no source type (exit 2)", async () => { + await run(["conversions", "create"]); + expect(createConversionSource).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(2); + }); + + it("creates from --file, keeping only writable source keys", async () => { + const file = join(dir, "src.json"); + writeFileSync( + file, + JSON.stringify({ + name: "accounts/123/conversionSources/should-be-stripped", + state: "ACTIVE", + googleAnalyticsLink: { propertyId: "555" }, + }), + ); + createConversionSource.mockResolvedValue({ name: "accounts/123/conversionSources/n3" }); + await run(["conversions", "create", "--file", file]); + expect(createConversionSource).toHaveBeenCalledWith({ + googleAnalyticsLink: { propertyId: "555" }, + }); + }); + + it("rejects mixing --file and convenience flags (exit 2)", async () => { + const file = join(dir, "src.json"); + writeFileSync(file, JSON.stringify({ googleAnalyticsLink: { propertyId: "555" } })); + await run(["conversions", "create", "--file", file, "--ga-property", "987"]); + expect(createConversionSource).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(2); + }); + + it("updates a Merchant Center display name via a nested mask", async () => { + updateConversionSource.mockResolvedValue({ name: "accounts/123/conversionSources/abc" }); + await run(["conversions", "update", "abc", "--display-name", "Renamed"]); + expect(updateConversionSource).toHaveBeenCalledWith( + "abc", + { merchantCenterDestination: { displayName: "Renamed" } }, + { updateMask: "merchantCenterDestination.displayName" }, + ); + expect(out()).toContain("Updated conversion source abc"); + }); + + it("updates display name + currency together with a combined nested mask", async () => { + updateConversionSource.mockResolvedValue({}); + await run(["conversions", "update", "abc", "--display-name", "Renamed", "--currency", "EUR"]); + expect(updateConversionSource).toHaveBeenCalledWith( + "abc", + { merchantCenterDestination: { displayName: "Renamed", currencyCode: "EUR" } }, + { + updateMask: "merchantCenterDestination.displayName,merchantCenterDestination.currencyCode", + }, + ); + }); + + it("updates from --file, honoring an explicit --update-mask", async () => { + const file = join(dir, "patch.json"); + writeFileSync( + file, + JSON.stringify({ merchantCenterDestination: { currencyCode: "GBP", displayName: "X" } }), + ); + updateConversionSource.mockResolvedValue({}); + await run([ + "conversions", + "update", + "abc", + "--file", + file, + "--update-mask", + "merchantCenterDestination.currencyCode", + ]); + expect(updateConversionSource).toHaveBeenCalledWith( + "abc", + { merchantCenterDestination: { currencyCode: "GBP", displayName: "X" } }, + { updateMask: "merchantCenterDestination.currencyCode" }, + ); + }); + + it("rejects an update with no fields (exit 2)", async () => { + await run(["conversions", "update", "abc"]); + expect(updateConversionSource).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(2); + }); + + it("archives (deletes) a source and emits JSON", async () => { + deleteConversionSource.mockResolvedValue(undefined); + await run(["-j", "conversions", "delete", "accounts/123/conversionSources/abc"]); + expect(deleteConversionSource).toHaveBeenCalledWith("accounts/123/conversionSources/abc"); + expect(JSON.parse(out())).toEqual({ deleted: "abc" }); + }); + + it("undeletes (restores) a source", async () => { + undeleteConversionSource.mockResolvedValue({ name: "accounts/123/conversionSources/abc" }); + await run(["conversions", "undelete", "abc"]); + expect(undeleteConversionSource).toHaveBeenCalledWith("abc"); + expect(out()).toContain("Restored conversion source abc"); + }); +}); diff --git a/packages/migrate/tests/scopes.test.ts b/packages/migrate/tests/scopes.test.ts index de2181b..3d8f715 100644 --- a/packages/migrate/tests/scopes.test.ts +++ b/packages/migrate/tests/scopes.test.ts @@ -17,6 +17,7 @@ describe("auditScopes", () => { "notifications", "quota", "issueresolution", + "conversions", ]); for (const m of report.mapping) expect(m.scopes).toEqual([MERCHANT_API_SCOPE]); });