Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/product-attributes-v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@gmc-cli/api": patch
"@gmc-cli/cli": patch
"@gmc-cli/preflight": patch
"@gmc-cli/migrate": patch
---

fix(products): use `productAttributes` for Merchant API v1 (was `attributes`)

Merchant API v1 renamed the product attributes field from `attributes` to
`productAttributes` (for both processed products and product inputs). gmc still
sent/read `attributes`, so against the live API `products list`/`get` showed blank
titles and prices, and `products insert` failed with
`400 INVALID_ARGUMENT: Unknown name "attributes"`. This also affected `feeds`,
`migrate products`, and `preflight --remote`.

Renames the field across `Product`/`ProductInput` and every read/write site (CLI
renderers, `toProductInput`, feeds, preflight rules, migrate transform). Fixes
`ItemLevelIssue` to the real v1 shape — `severity` / `reportingContext` /
`applicableCountries` (dropping the v1beta `servability` / `destination` /
`attribute`) — so `products get` shows issue details and disapproval counts
correctly. Adds a recorded-shape v1 contract test so a future field rename fails
CI instead of shipping silently.

Also corrects the `gmc issues` severity docs: the live `renderaccountissues`
value is `ERROR`, not the previously-documented `DISAPPROVED` / `DEMOTED` /
`NOT_IMPACTED`.
2 changes: 1 addition & 1 deletion docs/reference/feeds.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ feeds/
"offerId": "SKU1",
"contentLanguage": "en",
"feedLabel": "US",
"attributes": {
"productAttributes": {
"title": "Trail Runner",
"price": { "amountMicros": "49990000", "currencyCode": "USD" }
}
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ gmc issues account --json | jq '.issues[] | { title, severity: .impact.severity
## Output

Each issue prints `[SEVERITY] title`, the impact message, and a region/destination breakdown. Severity
is the Merchant API enum: `DISAPPROVED`, `DEMOTED`, or `NOT_IMPACTED`. The full how-to-fix detail is
is the value the API returns for the rendered issue, e.g. `ERROR` or `WARNING`. The full how-to-fix detail is
**HTML** — it is omitted from the table and available under `--json`. `--json` emits `{ "issues": [...] }`
— the raw `RenderedIssue`s, including `prerenderedContent` (the HTML) and `actions`.

Expand Down
22 changes: 11 additions & 11 deletions docs/reference/migrate.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,17 +105,17 @@ gmc preflight --dir feeds # validate the converted cat

### What it converts

The Merchant API keeps only _identity_ fields at the top level and nests everything descriptive under `attributes`, and both APIs share the product-spec attribute names — so the transform moves every field except the identity ones into `attributes`, converts prices to micros, and remaps the identity fields:

| Content API v2.1 | Merchant API | |
| ------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `price: {value:"49.99", currency:"USD"}` | `attributes.price: {amountMicros:"49990000", currencyCode:"USD"}` | value × 1,000,000 (BigInt, half-up at 6 dp); also `salePrice`, nested `shipping[].price`, … |
| `availability: "in stock"` | `attributes.availability: "in_stock"` | enum spaces → underscores |
| `targetCountry: "US"` | `feedLabel: "US"` | the key remap (an explicit `feedLabel` wins; `--feed-label` overrides) |
| `id: "online:en:US:SKU1"` | `offerId`/`contentLanguage`/`feedLabel` | parsed to backfill missing identity, then dropped |
| `title`, `description`, `link`, `customLabel0`, `shipping`, … | `attributes.*` | moved as-is (names match) |
| `customAttributes: [{name,value}]` | `customAttributes` | carried through |
| `id` / `kind` / `source` / `selfLink` | — | output-only → dropped |
The Merchant API keeps only _identity_ fields at the top level and nests everything descriptive under `productAttributes`, and both APIs share the product-spec attribute names — so the transform moves every field except the identity ones into `productAttributes`, converts prices to micros, and remaps the identity fields:

| Content API v2.1 | Merchant API | |
| ------------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| `price: {value:"49.99", currency:"USD"}` | `productAttributes.price: {amountMicros:"49990000", currencyCode:"USD"}` | value × 1,000,000 (BigInt, half-up at 6 dp); also `salePrice`, nested `shipping[].price`, … |
| `availability: "in stock"` | `productAttributes.availability: "in_stock"` | enum spaces → underscores |
| `targetCountry: "US"` | `feedLabel: "US"` | the key remap (an explicit `feedLabel` wins; `--feed-label` overrides) |
| `id: "online:en:US:SKU1"` | `offerId`/`contentLanguage`/`feedLabel` | parsed to backfill missing identity, then dropped |
| `title`, `description`, `link`, `customLabel0`, `shipping`, … | `productAttributes.*` | moved as-is (names match) |
| `customAttributes: [{name,value}]` | `customAttributes` | carried through |
| `id` / `kind` / `source` / `selfLink` | — | output-only → dropped |

Each run prints a **migration report** — products converted, identity remaps, dropped fields, and any warnings (e.g. a price whose value isn't a number, left for `preflight` to flag) — or the full report as `--json`.

Expand Down
4 changes: 2 additions & 2 deletions docs/reference/preflight.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ gmc preflight — scanned 2 product(s)

en~US~SKU2
✗ title — Missing title — every product must have a `title`.
→ Set attributes.title to the product's name as shown to shoppers.
→ Set productAttributes.title to the product's name as shown to shoppers.
✗ price — Missing price — every product must have a `price` with an amount.
→ Set attributes.price.amountMicros (and currencyCode), e.g. 49990000 / "USD".
→ Set productAttributes.price.amountMicros (and currencyCode), e.g. 49990000 / "USD".

2 errors across 1 product(s).
Failed.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/products.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ cat product.json | gmc products insert --data-source 11223344
"offerId": "SKU1",
"contentLanguage": "en",
"feedLabel": "US",
"attributes": {
"productAttributes": {
"title": "Trail Runner",
"link": "https://shop.com/p/run01",
"price": { "amountMicros": "49990000", "currencyCode": "USD" },
Expand Down
3 changes: 2 additions & 1 deletion packages/api/src/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ export interface IssueBreakdown {
/** How an issue affects the account or product. */
export interface IssueImpact {
message?: string;
/** Severity enum: `NOT_IMPACTED` | `DEMOTED` | `DISAPPROVED` | `SEVERITY_UNSPECIFIED`. */
/** Severity the API returns for a rendered issue, e.g. `ERROR` or `WARNING` (confirmed
* live against `renderaccountissues`; not the v1beta RPC enum). Rendered verbatim. */
severity?: string;
breakdowns?: IssueBreakdown[];
}
Expand Down
20 changes: 11 additions & 9 deletions packages/api/src/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,20 +49,22 @@ export interface ProductInput {
feedLabel?: string;
/** True for products sold exclusively in physical stores (Merchant API v1 replaced `channel` with this). */
legacyLocal?: boolean;
attributes?: ProductAttributes;
productAttributes?: ProductAttributes;
customAttributes?: CustomAttribute[];
}

/** A single item-level issue from product processing. */
/** A single item-level issue from product processing (Merchant API v1). */
export interface ItemLevelIssue {
code?: string;
servability?: string;
/** e.g. `ERROR`, `SUGGESTION`. v1 field (v1beta used `servability`). */
severity?: string;
resolution?: string;
attribute?: string;
destination?: string;
/** Destination/program the issue applies to (e.g. `SHOPPING_ADS`). v1 renamed `destination`. */
reportingContext?: string;
description?: string;
detail?: string;
documentation?: string;
applicableCountries?: string[];
}

/** Processing status for a product. */
Expand All @@ -81,7 +83,7 @@ export interface Product {
feedLabel?: string;
legacyLocal?: boolean;
dataSource?: string;
attributes?: ProductAttributes;
productAttributes?: ProductAttributes;
customAttributes?: CustomAttribute[];
productStatus?: ProductStatus;
}
Expand Down Expand Up @@ -121,16 +123,16 @@ export function productKey(input: ProductInput): string {
* Map a processed Product to a push-ready ProductInput. Intentional allowlist:
* output-only data (`name`, `productStatus`, `dataSource`, …) can never leak into
* a file that will later be pushed, at the cost of dropping edge writable fields
* (e.g. `versionNumber`). `attributes`/`customAttributes` are kept by reference —
* the caller must not mutate the result.
* (e.g. `versionNumber`). `productAttributes`/`customAttributes` are kept by
* reference — the caller must not mutate the result.
*/
export function toProductInput(product: Product): ProductInput {
const input: ProductInput = {};
if (product.offerId !== undefined) input.offerId = product.offerId;
if (product.contentLanguage !== undefined) input.contentLanguage = product.contentLanguage;
if (product.feedLabel !== undefined) input.feedLabel = product.feedLabel;
if (product.legacyLocal !== undefined) input.legacyLocal = product.legacyLocal;
if (product.attributes !== undefined) input.attributes = product.attributes;
if (product.productAttributes !== undefined) input.productAttributes = product.productAttributes;
if (product.customAttributes !== undefined) input.customAttributes = product.customAttributes;
return input;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/api/tests/issues.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe("IssuesService", () => {
method = String(init.method);
return jsonResponse(200, {
renderedIssues: [
{ title: "Misrepresentation", impact: { severity: "DISAPPROVED", message: "why" } },
{ title: "Misrepresentation", impact: { severity: "ERROR", message: "why" } },
],
});
}) as unknown as typeof fetch;
Expand Down
57 changes: 55 additions & 2 deletions packages/api/tests/products.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,59 @@ describe("ProductsService", () => {
);
});

// Contract test: a recorded-shape Merchant API v1 product response. Locks the v1
// field names (`productAttributes`, itemLevelIssue `severity`/`reportingContext`/
// `applicableCountries`) at compile time (the typed reads below) and runtime — the
// exact bug class that shipped in 1.0.10 (`attributes`→`productAttributes`) and that
// fully-mocked tests missed. Structure mirrors a real v1 response; content is synthetic.
it("parses the real Merchant API v1 product shape (productAttributes + item-level issues)", async () => {
const v1Response = {
products: [
{
name: "accounts/123/products/en~GB~SKU1",
offerId: "SKU1",
contentLanguage: "en",
feedLabel: "GB",
dataSource: "accounts/123/dataSources/456",
base64EncodedName: "ZXhhbXBsZQ==",
productAttributes: {
title: "Sample Product",
description: "A sample product.",
link: "https://example.com/p/sku1",
availability: "in_stock",
price: { amountMicros: "19990000", currencyCode: "GBP" },
},
productStatus: {
itemLevelIssues: [
{
code: "policy_enforcement_account_disapproval",
severity: "DISAPPROVED",
resolution: "merchant_action",
reportingContext: "SHOPPING_ADS",
description: "Your products are not showing to customers",
detail: "Fix policy issues.",
documentation: "https://support.google.com/merchants/answer/12153802",
applicableCountries: ["GB"],
},
],
creationDate: "2026-06-01T00:00:00Z",
lastUpdateDate: "2026-06-13T00:00:00Z",
},
},
],
};
const fetchImpl = (async () => jsonResponse(200, v1Response)) as unknown as typeof fetch;

const [p] = await service(fetchImpl).listProducts();

expect(p?.productAttributes?.title).toBe("Sample Product");
expect(p?.productAttributes?.price?.amountMicros).toBe("19990000");
const issue = p?.productStatus?.itemLevelIssues?.[0];
expect(issue?.severity).toBe("DISAPPROVED");
expect(issue?.reportingContext).toBe("SHOPPING_ADS");
expect(issue?.applicableCountries).toEqual(["GB"]);
});

it("listProducts follows pagination and passes pageSize", async () => {
const pages = [
{ products: [{ name: "p1" }, { name: "p2" }], nextPageToken: "p2tok" },
Expand Down Expand Up @@ -136,7 +189,7 @@ describe("toProductInput", () => {
feedLabel: "US",
legacyLocal: true,
dataSource: "accounts/123/dataSources/55",
attributes: { title: "Shoe", price: { amountMicros: "9990000", currencyCode: "USD" } },
productAttributes: { title: "Shoe", price: { amountMicros: "9990000", currencyCode: "USD" } },
customAttributes: [{ name: "x", value: "y" }],
productStatus: { itemLevelIssues: [{ code: "image_link" }] },
});
Expand All @@ -146,7 +199,7 @@ describe("toProductInput", () => {
contentLanguage: "en",
feedLabel: "US",
legacyLocal: true,
attributes: { title: "Shoe", price: { amountMicros: "9990000", currencyCode: "USD" } },
productAttributes: { title: "Shoe", price: { amountMicros: "9990000", currencyCode: "USD" } },
customAttributes: [{ name: "x", value: "y" }],
});
// Output-only fields must not survive into a push-ready input.
Expand Down
10 changes: 5 additions & 5 deletions packages/cli/src/commands/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function offerIdOf(product: Product): string {
function issueSummary(product: Product): string {
const issues = product.productStatus?.itemLevelIssues ?? [];
if (issues.length === 0) return "no issues";
const disapproved = issues.filter((i) => i.servability === "disapproved").length;
const disapproved = issues.filter((i) => i.severity === "DISAPPROVED").length;
return disapproved > 0
? `${disapproved} disapproved / ${issues.length} issue(s)`
: `${issues.length} issue(s)`;
Expand All @@ -32,8 +32,8 @@ function renderProducts(products: Product[]): void {
}
const rows = products.map((p) => ({
id: offerIdOf(p),
title: p.attributes?.title ?? "—",
avail: p.attributes?.availability ?? "—",
title: p.productAttributes?.title ?? "—",
avail: p.productAttributes?.availability ?? "—",
issues: issueSummary(p),
}));
const idWidth = Math.max(...rows.map((r) => r.id.length));
Expand All @@ -44,15 +44,15 @@ function renderProducts(products: Product[]): void {
}

function renderProduct(product: Product): void {
const a = product.attributes ?? {};
const a = product.productAttributes ?? {};
line("Offer ID", offerIdOf(product));
if (a.title) line("Title", a.title);
if (a.link) line("Link", a.link);
if (a.price?.amountMicros) line("Price", formatPrice(a.price));
if (a.availability) line("Availability", a.availability);
line("Status", issueSummary(product));
for (const issue of product.productStatus?.itemLevelIssues ?? []) {
const sev = issue.servability ? `${issue.servability}: ` : "";
const sev = issue.severity ? `${issue.severity}: ` : "";
process.stdout.write(` - ${sev}${issue.description ?? issue.code ?? "issue"}\n`);
}
}
Expand Down
Loading