From 06f53d7937ee44011e1cf9d158e56ae5aef9fcd9 Mon Sep 17 00:00:00 2001 From: Yasser's studio Date: Sat, 13 Jun 2026 15:23:22 +0100 Subject: [PATCH] fix(products): use productAttributes for Merchant API v1 (was attributes) Merchant API v1 renamed the product attributes field from `attributes` to `productAttributes` (processed products and product inputs alike). gmc still sent/read `attributes`, so against the live API `products list`/`get` showed blank titles and `products insert` failed with `400 INVALID_ARGUMENT: Unknown name "attributes"`. This also affected `feeds`, `migrate products`, and `preflight --remote`. - Rename the field on Product/ProductInput and every read/write site (CLI renderers, toProductInput, feeds, preflight rules, migrate transform). - Fix ItemLevelIssue to the real v1 shape (severity / reportingContext / applicableCountries; drop v1beta servability / destination / attribute) and base the disapproval count on `severity === "DISAPPROVED"`. - Add a recorded-shape v1 contract test so a future field rename fails CI. - Correct the `gmc issues` severity docs (live value is ERROR). Promotions (its own attributes) and accounts (identity attributes) are unchanged. Verified against a live Merchant Center account: products list shows titles, insert succeeds. --- .changeset/product-attributes-v1.md | 27 ++++++++++ docs/reference/feeds.md | 2 +- docs/reference/issues.md | 2 +- docs/reference/migrate.md | 22 ++++---- docs/reference/preflight.md | 4 +- docs/reference/products.md | 2 +- packages/api/src/issues.ts | 3 +- packages/api/src/products.ts | 20 +++---- packages/api/tests/issues.test.ts | 2 +- packages/api/tests/products.test.ts | 57 +++++++++++++++++++- packages/cli/src/commands/products.ts | 10 ++-- packages/cli/tests/feeds.test.ts | 32 +++++++----- packages/cli/tests/issues.test.ts | 4 +- packages/cli/tests/migrate.test.ts | 4 +- packages/cli/tests/preflight.test.ts | 6 +-- packages/cli/tests/products.test.ts | 29 ++++++++-- packages/migrate/src/products.ts | 16 +++--- packages/migrate/tests/products.test.ts | 16 +++--- packages/preflight/src/rules/format.ts | 18 +++---- packages/preflight/src/rules/policy.ts | 10 ++-- packages/preflight/src/rules/required.ts | 31 +++++------ packages/preflight/src/types.ts | 4 +- packages/preflight/tests/engine.test.ts | 2 +- packages/preflight/tests/format.test.ts | 61 +++++++++++---------- packages/preflight/tests/policy.test.ts | 14 +++-- packages/preflight/tests/required.test.ts | 64 ++++++++++++++--------- 26 files changed, 300 insertions(+), 162 deletions(-) create mode 100644 .changeset/product-attributes-v1.md diff --git a/.changeset/product-attributes-v1.md b/.changeset/product-attributes-v1.md new file mode 100644 index 0000000..02f602b --- /dev/null +++ b/.changeset/product-attributes-v1.md @@ -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`. diff --git a/docs/reference/feeds.md b/docs/reference/feeds.md index 62e988c..f898098 100644 --- a/docs/reference/feeds.md +++ b/docs/reference/feeds.md @@ -31,7 +31,7 @@ feeds/ "offerId": "SKU1", "contentLanguage": "en", "feedLabel": "US", - "attributes": { + "productAttributes": { "title": "Trail Runner", "price": { "amountMicros": "49990000", "currencyCode": "USD" } } diff --git a/docs/reference/issues.md b/docs/reference/issues.md index bc785a8..d241a58 100644 --- a/docs/reference/issues.md +++ b/docs/reference/issues.md @@ -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`. diff --git a/docs/reference/migrate.md b/docs/reference/migrate.md index a2b5efc..94068c5 100644 --- a/docs/reference/migrate.md +++ b/docs/reference/migrate.md @@ -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`. diff --git a/docs/reference/preflight.md b/docs/reference/preflight.md index b1a71e1..ee4ae04 100644 --- a/docs/reference/preflight.md +++ b/docs/reference/preflight.md @@ -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. diff --git a/docs/reference/products.md b/docs/reference/products.md index 65a6206..ff3f377 100644 --- a/docs/reference/products.md +++ b/docs/reference/products.md @@ -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" }, diff --git a/packages/api/src/issues.ts b/packages/api/src/issues.ts index 54c7da4..9b0154d 100644 --- a/packages/api/src/issues.ts +++ b/packages/api/src/issues.ts @@ -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[]; } diff --git a/packages/api/src/products.ts b/packages/api/src/products.ts index e1dfcbe..54f5d85 100644 --- a/packages/api/src/products.ts +++ b/packages/api/src/products.ts @@ -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. */ @@ -81,7 +83,7 @@ export interface Product { feedLabel?: string; legacyLocal?: boolean; dataSource?: string; - attributes?: ProductAttributes; + productAttributes?: ProductAttributes; customAttributes?: CustomAttribute[]; productStatus?: ProductStatus; } @@ -121,8 +123,8 @@ 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 = {}; @@ -130,7 +132,7 @@ export function toProductInput(product: Product): ProductInput { 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; } diff --git a/packages/api/tests/issues.test.ts b/packages/api/tests/issues.test.ts index a6f8111..de1fd12 100644 --- a/packages/api/tests/issues.test.ts +++ b/packages/api/tests/issues.test.ts @@ -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; diff --git a/packages/api/tests/products.test.ts b/packages/api/tests/products.test.ts index 8f4f34b..37d108e 100644 --- a/packages/api/tests/products.test.ts +++ b/packages/api/tests/products.test.ts @@ -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" }, @@ -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" }] }, }); @@ -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. diff --git a/packages/cli/src/commands/products.ts b/packages/cli/src/commands/products.ts index 8e328fa..79c0f52 100644 --- a/packages/cli/src/commands/products.ts +++ b/packages/cli/src/commands/products.ts @@ -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)`; @@ -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)); @@ -44,7 +44,7 @@ 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); @@ -52,7 +52,7 @@ function renderProduct(product: Product): void { 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`); } } diff --git a/packages/cli/tests/feeds.test.ts b/packages/cli/tests/feeds.test.ts index 1aead80..58bc536 100644 --- a/packages/cli/tests/feeds.test.ts +++ b/packages/cli/tests/feeds.test.ts @@ -81,10 +81,14 @@ describe("gmc feeds pull", () => { { name: "accounts/123/products/en~US~SKU1", offerId: "SKU1", - attributes: { title: "A" }, + productAttributes: { title: "A" }, productStatus: { itemLevelIssues: [] }, }, - { name: "accounts/123/products/en~US~SKU2", offerId: "SKU2", attributes: { title: "B" } }, + { + name: "accounts/123/products/en~US~SKU2", + offerId: "SKU2", + productAttributes: { title: "B" }, + }, ]); await run(["feeds", "pull", "--dir", dir, "--json"]); @@ -99,7 +103,7 @@ describe("gmc feeds pull", () => { string, unknown >; - expect(f1).toEqual({ offerId: "SKU1", attributes: { title: "A" } }); + expect(f1).toEqual({ offerId: "SKU1", productAttributes: { title: "A" } }); expect("productStatus" in f1).toBe(false); expect("name" in f1).toBe(false); expect(process.exitCode).toBe(0); @@ -124,7 +128,7 @@ describe("gmc feeds pull", () => { it("sanitizes path-unsafe characters in the filename", async () => { listProducts.mockResolvedValue([ - { name: "accounts/123/products/en~US~A:B", offerId: "A:B", attributes: {} }, + { name: "accounts/123/products/en~US~A:B", offerId: "A:B", productAttributes: {} }, ]); await run(["feeds", "pull", "--dir", dir]); expect(readdirSync(dir)).toEqual(["en~US~A_B.json"]); @@ -143,8 +147,8 @@ describe("gmc feeds pull", () => { it("skips a colliding filename instead of overwriting", async () => { listProducts.mockResolvedValue([ - { name: "accounts/123/products/en~US~A:B", attributes: { title: "first" } }, - { name: "accounts/123/products/en~US~A_B", attributes: { title: "second" } }, + { name: "accounts/123/products/en~US~A:B", productAttributes: { title: "first" } }, + { name: "accounts/123/products/en~US~A_B", productAttributes: { title: "second" } }, ]); await run(["feeds", "pull", "--dir", dir, "--json"]); const out = JSON.parse(writes.join("")) as { pulled: number; skipped?: number }; @@ -152,9 +156,9 @@ describe("gmc feeds pull", () => { expect(out.skipped).toBe(1); expect(readdirSync(dir)).toEqual(["en~US~A_B.json"]); const written = JSON.parse(readFileSync(join(dir, "en~US~A_B.json"), "utf8")) as { - attributes: { title: string }; + productAttributes: { title: string }; }; - expect(written.attributes.title).toBe("first"); + expect(written.productAttributes.title).toBe("first"); expect(process.exitCode).toBe(0); }); @@ -234,11 +238,11 @@ describe("gmc feeds push", () => { it("inserts one product input per JSON file, under the given data source", async () => { writeFileSync( join(dir, "a.json"), - JSON.stringify({ offerId: "SKU1", attributes: { title: "A" } }), + JSON.stringify({ offerId: "SKU1", productAttributes: { title: "A" } }), ); writeFileSync( join(dir, "b.json"), - JSON.stringify({ offerId: "SKU2", attributes: { title: "B" } }), + JSON.stringify({ offerId: "SKU2", productAttributes: { title: "B" } }), ); await run(["feeds", "push", "--dir", dir, "--data-source", "55", "--json"]); @@ -253,7 +257,7 @@ describe("gmc feeds push", () => { expect("failed" in out).toBe(false); expect(insertProductInput).toHaveBeenCalledTimes(2); expect(insertProductInput).toHaveBeenCalledWith( - { offerId: "SKU1", attributes: { title: "A" } }, + { offerId: "SKU1", productAttributes: { title: "A" } }, "55", ); expect(process.exitCode).toBe(0); @@ -363,14 +367,14 @@ describe("gmc feeds diff", () => { offerId, contentLanguage: "en", feedLabel: "US", - attributes: { title }, + productAttributes: { title }, }); // The pulled-file equivalent of the product above (key fields + attributes). const file = (offerId: string, title: string) => ({ offerId, contentLanguage: "en", feedLabel: "US", - attributes: { title }, + productAttributes: { title }, }); beforeEach(() => { @@ -463,7 +467,7 @@ describe("gmc feeds diff", () => { writeFileSync( join(dir, "a.json"), JSON.stringify({ - attributes: { title: "A" }, + productAttributes: { title: "A" }, feedLabel: "US", contentLanguage: "en", offerId: "SKU1", diff --git a/packages/cli/tests/issues.test.ts b/packages/cli/tests/issues.test.ts index 081d3cc..d3affa5 100644 --- a/packages/cli/tests/issues.test.ts +++ b/packages/cli/tests/issues.test.ts @@ -77,7 +77,7 @@ describe("gmc issues", () => { { title: "Misrepresentation", impact: { - severity: "DISAPPROVED", + severity: "ERROR", message: "Account suspended for policy violation.", breakdowns: [ { regions: [{ code: "US", name: "United States" }], details: ["Shopping ads"] }, @@ -88,7 +88,7 @@ describe("gmc issues", () => { ]); await run(["issues", "account"]); expect(out()).toContain("1 issue(s)"); - expect(out()).toContain("[DISAPPROVED] Misrepresentation"); + expect(out()).toContain("[ERROR] Misrepresentation"); expect(out()).toContain("Account suspended"); expect(out()).toContain("United States — Shopping ads"); expect(out()).toContain("prerenderedContent"); diff --git a/packages/cli/tests/migrate.test.ts b/packages/cli/tests/migrate.test.ts index 02f67d3..cb027a0 100644 --- a/packages/cli/tests/migrate.test.ts +++ b/packages/cli/tests/migrate.test.ts @@ -250,7 +250,7 @@ describe("gmc migrate products", () => { expect(written).toMatchObject({ offerId: "SKU1", feedLabel: "US", - attributes: { + productAttributes: { price: { amountMicros: "49990000", currencyCode: "USD" }, availability: "in_stock", }, @@ -344,7 +344,7 @@ const pi = (offerId: string, feedLabel?: string) => ({ offerId, contentLanguage: "en", ...(feedLabel !== undefined ? { feedLabel } : {}), - attributes: { title: offerId }, + productAttributes: { title: offerId }, }); const source = (feedLabel: string) => ({ primaryProductDataSource: { feedLabel, contentLanguage: "en" }, diff --git a/packages/cli/tests/preflight.test.ts b/packages/cli/tests/preflight.test.ts index 8960773..69886eb 100644 --- a/packages/cli/tests/preflight.test.ts +++ b/packages/cli/tests/preflight.test.ts @@ -37,7 +37,7 @@ const GOOD = JSON.stringify({ offerId: "SKU1", contentLanguage: "en", feedLabel: "US", - attributes: { + productAttributes: { title: "Trail Runner", description: "A lightweight trail running shoe.", link: "https://example.com/trail-runner", @@ -54,7 +54,7 @@ const MISSING_TITLE = JSON.stringify({ offerId: "SKU2", contentLanguage: "en", feedLabel: "US", - attributes: { + productAttributes: { description: "A lightweight trail running shoe.", link: "https://example.com/trail-runner", imageLink: "https://example.com/trail-runner.jpg", @@ -155,7 +155,7 @@ describe("gmc preflight", () => { offerId: "SKU9", contentLanguage: "en", feedLabel: "US", - attributes: { price: { amountMicros: "1000", currencyCode: "USD" } }, // no title + productAttributes: { price: { amountMicros: "1000", currencyCode: "USD" } }, // no title }, ]); await run(["preflight", "--remote"]); diff --git a/packages/cli/tests/products.test.ts b/packages/cli/tests/products.test.ts index 961fc9e..ce8cdca 100644 --- a/packages/cli/tests/products.test.ts +++ b/packages/cli/tests/products.test.ts @@ -105,11 +105,34 @@ describe("gmc products", () => { expect(process.exitCode).toBe(0); }); + it("list (text) renders title, availability, and the disapproved/issue counts", async () => { + listProducts.mockResolvedValue([ + { + name: "accounts/123/products/en~US~SKU1", + offerId: "SKU1", + productAttributes: { title: "Trail Runner", availability: "in_stock" }, + productStatus: { + itemLevelIssues: [ + { code: "x", severity: "DISAPPROVED", reportingContext: "SHOPPING_ADS" }, + { code: "y", severity: "NOT_IMPACTED", reportingContext: "FREE_LISTINGS" }, + ], + }, + }, + ]); + + await run(["products", "list"]); + + const out = writes.join(""); + expect(out).toContain("Trail Runner"); + expect(out).toContain("[in_stock]"); + expect(out).toContain("1 disapproved / 2 issue(s)"); + }); + it("get fetches the product by id", async () => { getProduct.mockResolvedValue({ name: "accounts/123/products/online~en~US~SKU1", offerId: "SKU1", - attributes: { title: "Shoe" }, + productAttributes: { title: "Shoe" }, }); await run(["products", "get", "online~en~US~SKU1", "--json"]); @@ -126,7 +149,7 @@ describe("gmc products", () => { }); const file = tmpFile( "gmc-prod-insert.json", - JSON.stringify({ offerId: "SKU1", attributes: { title: "Shoe" } }), + JSON.stringify({ offerId: "SKU1", productAttributes: { title: "Shoe" } }), ); try { @@ -136,7 +159,7 @@ describe("gmc products", () => { } expect(insertProductInput).toHaveBeenCalledWith( - { offerId: "SKU1", attributes: { title: "Shoe" } }, + { offerId: "SKU1", productAttributes: { title: "Shoe" } }, "55", ); expect(process.exitCode).toBe(0); diff --git a/packages/migrate/src/products.ts b/packages/migrate/src/products.ts index 09268dc..477f20e 100644 --- a/packages/migrate/src/products.ts +++ b/packages/migrate/src/products.ts @@ -5,15 +5,15 @@ // writing the output dir, rendering the report); this engine only transforms. // // The Merchant API keeps only identity fields at the top level and nests everything -// descriptive under `attributes`, and the two APIs share product-spec attribute +// descriptive under `productAttributes`, and the two APIs share product-spec attribute // names — so the transform is data-driven: move every field except the identity -// ones into `attributes`, convert price-shaped values to micros, and remap the +// ones into `productAttributes`, convert price-shaped values to micros, and remap the // identity / `id` / `targetCountry` fields. The one real rename is // `targetCountry` → `feedLabel`. import { toMicros, type CustomAttribute, type Price, type ProductInput } from "@gmc-cli/api"; -// Identity / structural fields handled explicitly — never hoisted into attributes. +// Identity / structural fields handled explicitly — never hoisted into productAttributes. const IDENTITY_FIELDS = new Set([ "offerId", "channel", @@ -167,8 +167,8 @@ export function transformProduct(raw: unknown): ProductTransformResult { input.customAttributes = src["customAttributes"] as CustomAttribute[]; } - // Hoist every remaining field into attributes, converting prices to micros. - const attributes: Record = {}; + // Hoist every remaining field into productAttributes, converting prices to micros. + const productAttributes: Record = {}; for (const [key, val] of Object.entries(src)) { if (IDENTITY_FIELDS.has(key)) continue; if (DROP_FIELDS.has(key)) { @@ -187,12 +187,12 @@ export function transformProduct(raw: unknown): ProductTransformResult { value = norm; } } - attributes[key] = value; + productAttributes[key] = value; } - if (Object.keys(attributes).length > 0) { + if (Object.keys(productAttributes).length > 0) { // The Merchant API accepts more attributes than ProductAttributes models; the // typed view is intentionally partial (see @gmc-cli/api products.ts), so cast. - input.attributes = attributes as ProductInput["attributes"]; + input.productAttributes = productAttributes as ProductInput["productAttributes"]; } return { input, remapped, dropped, warnings }; diff --git a/packages/migrate/tests/products.test.ts b/packages/migrate/tests/products.test.ts index 9daa646..23bed64 100644 --- a/packages/migrate/tests/products.test.ts +++ b/packages/migrate/tests/products.test.ts @@ -51,12 +51,12 @@ describe("transformProduct", () => { feedLabel: "US", }); expect(input.legacyLocal).toBeUndefined(); - expect(input.attributes).toMatchObject({ + expect(input.productAttributes).toMatchObject({ title: "Shoe", price: { amountMicros: "49990000", currencyCode: "USD" }, }); // identity fields are NOT duplicated into attributes - expect((input.attributes as Record)["targetCountry"]).toBeUndefined(); + expect((input.productAttributes as Record)["targetCountry"]).toBeUndefined(); }); it("remaps targetCountry → feedLabel and reports it", () => { @@ -73,19 +73,21 @@ describe("transformProduct", () => { it("normalizes the availability enum (spaces → underscores)", () => { const r = ok({ offerId: "X", availability: "in stock" }); - expect((r.input.attributes as Record)["availability"]).toBe("in_stock"); + expect((r.input.productAttributes as Record)["availability"]).toBe("in_stock"); expect(r.remapped).toContain('availability "in stock" → "in_stock"'); }); it("leaves an already-valid availability untouched (no remap note)", () => { const r = ok({ offerId: "X", availability: "preorder" }); - expect((r.input.attributes as Record)["availability"]).toBe("preorder"); + expect((r.input.productAttributes as Record)["availability"]).toBe("preorder"); expect(r.remapped).toHaveLength(0); }); it("does not invent an invalid availability — leaves it for preflight to flag", () => { const r = ok({ offerId: "X", availability: "pre order" }); - expect((r.input.attributes as Record)["availability"]).toBe("pre order"); + expect((r.input.productAttributes as Record)["availability"]).toBe( + "pre order", + ); expect(r.remapped).toHaveLength(0); }); @@ -95,7 +97,7 @@ describe("transformProduct", () => { shipping: [{ country: "US", price: { value: "5.00", currency: "USD" } }], shippingWeight: { value: "1.2", unit: "kg" }, }); - const attrs = input.attributes as Record; + const attrs = input.productAttributes as Record; expect((attrs["shipping"] as { price: unknown }[])[0].price).toEqual({ amountMicros: "5000000", currencyCode: "USD", @@ -119,7 +121,7 @@ describe("transformProduct", () => { const r = ok({ offerId: "X", price: { value: "free", currency: "USD" } }); expect(r.warnings.some((w) => w.includes("price"))).toBe(true); // left as-is, not converted - expect((r.input.attributes as Record)["price"]).toEqual({ + expect((r.input.productAttributes as Record)["price"]).toEqual({ value: "free", currency: "USD", }); diff --git a/packages/preflight/src/rules/format.ts b/packages/preflight/src/rules/format.ts index 67c5a9d..8c58a1e 100644 --- a/packages/preflight/src/rules/format.ts +++ b/packages/preflight/src/rules/format.ts @@ -31,7 +31,7 @@ export const formatRules: Rule[] = [ title: "Link is a valid URL", defaultSeverity: "error", check(product) { - const link = text(product.attributes?.link); + const link = text(product.productAttributes?.link); if (link === undefined || isHttpUrl(link)) return []; return [ { @@ -48,7 +48,7 @@ export const formatRules: Rule[] = [ title: "Image link is a valid URL", defaultSeverity: "error", check(product) { - const image = text(product.attributes?.imageLink); + const image = text(product.productAttributes?.imageLink); if (image === undefined || isHttpUrl(image)) return []; return [ { @@ -65,7 +65,7 @@ export const formatRules: Rule[] = [ title: "Price amount is well-formed", defaultSeverity: "error", check(product) { - const parsed = parseMicros(product.attributes?.price?.amountMicros); + const parsed = parseMicros(product.productAttributes?.price?.amountMicros); if (parsed.kind !== "invalid") return []; return [ { @@ -83,7 +83,7 @@ export const formatRules: Rule[] = [ title: "Price currency is well-formed", defaultSeverity: "error", check(product) { - const price = product.attributes?.price; + const price = product.productAttributes?.price; // Only check the currency once the amount itself is well-formed: an absent price // is required.price's job, and a malformed amount is format.price-amount's — so a // broken price yields one finding, not a pile-on about a price being rebuilt anyway. @@ -117,7 +117,7 @@ export const formatRules: Rule[] = [ title: "Availability is a recognized value", defaultSeverity: "error", check(product) { - const value = text(product.attributes?.availability); + const value = text(product.productAttributes?.availability); if (value === undefined || AVAILABILITY.has(normalizeEnum(value))) return []; return [ { @@ -137,7 +137,7 @@ export const formatRules: Rule[] = [ title: "Condition is a recognized value", defaultSeverity: "error", check(product) { - const value = text(product.attributes?.condition); + const value = text(product.productAttributes?.condition); if (value === undefined || CONDITION.has(normalizeEnum(value))) return []; return [ { @@ -154,7 +154,7 @@ export const formatRules: Rule[] = [ title: "GTIN check digit is valid", defaultSeverity: "warning", check(product) { - const gtin = text(product.attributes?.gtin); + const gtin = text(product.productAttributes?.gtin); if (gtin === undefined || isValidGtin(gtin)) return []; return [ { @@ -172,7 +172,7 @@ export const formatRules: Rule[] = [ title: "Title within length limit", defaultSeverity: "warning", check(product) { - const title = text(product.attributes?.title); + const title = text(product.productAttributes?.title); if (title === undefined || title.length <= TITLE_MAX) return []; return [ { @@ -189,7 +189,7 @@ export const formatRules: Rule[] = [ title: "Description within length limit", defaultSeverity: "warning", check(product) { - const description = text(product.attributes?.description); + const description = text(product.productAttributes?.description); if (description === undefined || description.length <= DESCRIPTION_MAX) return []; return [ { diff --git a/packages/preflight/src/rules/policy.ts b/packages/preflight/src/rules/policy.ts index 992e9f3..6a5bd81 100644 --- a/packages/preflight/src/rules/policy.ts +++ b/packages/preflight/src/rules/policy.ts @@ -56,7 +56,7 @@ export const policyRules: Rule[] = [ title: "No promotional text in title", defaultSeverity: "error", check(product) { - const title = text(product.attributes?.title); + const title = text(product.productAttributes?.title); if (title === undefined) return []; const match = PROMO_PHRASE_RE.exec(title) ?? PROMO_PERCENT_RE.exec(title); if (!match) return []; @@ -76,7 +76,7 @@ export const policyRules: Rule[] = [ title: "Title isn't excessively capitalized", defaultSeverity: "warning", check(product) { - const title = text(product.attributes?.title); + const title = text(product.productAttributes?.title); if (title === undefined) return []; // Count Unicode letters / uppercase letters so non-Latin scripts (Cyrillic, Greek, // …) are judged too; caseless scripts (CJK) have 0 uppercase and never trip. @@ -99,7 +99,7 @@ export const policyRules: Rule[] = [ title: "No gimmicky symbols in title", defaultSeverity: "warning", check(product) { - const title = text(product.attributes?.title); + const title = text(product.productAttributes?.title); if (title === undefined) return []; if (!SYMBOL_RUN_RE.test(title) && !DECORATIVE_RE.test(title) && !EMOJI_RE.test(title)) { return []; @@ -120,7 +120,7 @@ export const policyRules: Rule[] = [ title: "No phone number in title", defaultSeverity: "warning", check(product) { - const title = text(product.attributes?.title); + const title = text(product.productAttributes?.title); if (title === undefined) return []; const match = PHONE_RE.exec(title); if (!match) return []; @@ -139,7 +139,7 @@ export const policyRules: Rule[] = [ title: "Landing page is served over https", defaultSeverity: "warning", check(product) { - const link = text(product.attributes?.link); + const link = text(product.productAttributes?.link); if (link === undefined) return []; let protocol: string; try { diff --git a/packages/preflight/src/rules/required.ts b/packages/preflight/src/rules/required.ts index 8dbbdf3..fac1b06 100644 --- a/packages/preflight/src/rules/required.ts +++ b/packages/preflight/src/rules/required.ts @@ -30,12 +30,12 @@ export const requiredRules: Rule[] = [ title: "Title present", defaultSeverity: "error", check(product) { - if (blank(product.attributes?.title)) { + if (blank(product.productAttributes?.title)) { return [ { attribute: "title", message: "Missing title — every product must have a `title`.", - suggestion: "Set attributes.title to the product's name as shown to shoppers.", + suggestion: "Set productAttributes.title to the product's name as shown to shoppers.", documentation: SPEC, }, ]; @@ -48,12 +48,13 @@ export const requiredRules: Rule[] = [ title: "Description present", defaultSeverity: "error", check(product) { - if (blank(product.attributes?.description)) { + if (blank(product.productAttributes?.description)) { return [ { attribute: "description", message: "Missing description — every product must have a `description`.", - suggestion: "Set attributes.description to the text shoppers see for the product.", + suggestion: + "Set productAttributes.description to the text shoppers see for the product.", documentation: SPEC, }, ]; @@ -66,12 +67,12 @@ export const requiredRules: Rule[] = [ title: "Link present", defaultSeverity: "error", check(product) { - if (blank(product.attributes?.link)) { + if (blank(product.productAttributes?.link)) { return [ { attribute: "link", message: "Missing link — every product must have a landing-page `link`.", - suggestion: "Set attributes.link to the product's landing page URL.", + suggestion: "Set productAttributes.link to the product's landing page URL.", documentation: SPEC, }, ]; @@ -84,12 +85,12 @@ export const requiredRules: Rule[] = [ title: "Image link present", defaultSeverity: "error", check(product) { - if (blank(product.attributes?.imageLink)) { + if (blank(product.productAttributes?.imageLink)) { return [ { attribute: "imageLink", message: "Missing image link — every product must have an `image_link`.", - suggestion: "Set attributes.imageLink to the main product image URL.", + suggestion: "Set productAttributes.imageLink to the main product image URL.", documentation: SPEC, }, ]; @@ -102,13 +103,13 @@ export const requiredRules: Rule[] = [ title: "Availability present", defaultSeverity: "error", check(product) { - if (blank(product.attributes?.availability)) { + if (blank(product.productAttributes?.availability)) { return [ { attribute: "availability", message: "Missing availability — every product must declare `availability`.", suggestion: - "Set attributes.availability to in_stock, out_of_stock, preorder, or backorder.", + "Set productAttributes.availability to in_stock, out_of_stock, preorder, or backorder.", documentation: SPEC, }, ]; @@ -121,7 +122,7 @@ export const requiredRules: Rule[] = [ title: "Price present", defaultSeverity: "error", check(product) { - const price = product.attributes?.price; + const price = product.productAttributes?.price; // Absent amount only — a present-but-malformed amount is format.price-amount's job. if (!price || parseMicros(price.amountMicros).kind === "absent") { return [ @@ -129,7 +130,7 @@ export const requiredRules: Rule[] = [ attribute: "price", message: "Missing price — every product must have a `price` with an amount.", suggestion: - 'Set attributes.price.amountMicros (and currencyCode), e.g. 49990000 / "USD".', + 'Set productAttributes.price.amountMicros (and currencyCode), e.g. 49990000 / "USD".', documentation: SPEC, }, ]; @@ -142,13 +143,13 @@ export const requiredRules: Rule[] = [ title: "Condition present", defaultSeverity: "warning", check(product) { - if (blank(product.attributes?.condition)) { + if (blank(product.productAttributes?.condition)) { return [ { attribute: "condition", message: "No condition set — recommended for all products and required for used/refurbished items.", - suggestion: 'Set attributes.condition to "new", "refurbished", or "used".', + suggestion: 'Set productAttributes.condition to "new", "refurbished", or "used".', documentation: SPEC, }, ]; @@ -161,7 +162,7 @@ export const requiredRules: Rule[] = [ title: "Has a product identifier", defaultSeverity: "warning", check(product) { - const a = product.attributes; + const a = product.productAttributes; if (blank(a?.gtin) && blank(a?.mpn) && blank(a?.brand)) { return [ { diff --git a/packages/preflight/src/types.ts b/packages/preflight/src/types.ts index e2776c4..3b5b729 100644 --- a/packages/preflight/src/types.ts +++ b/packages/preflight/src/types.ts @@ -16,8 +16,8 @@ export type RuleSetting = Severity | "off"; /** * One problem a rule found with a product, before the engine attaches the * product identity and effective severity. Shaped to echo the Merchant API's - * `ItemLevelIssue` (attribute / description / documentation) so preflight reads - * like a prediction of what the API would report. + * `ItemLevelIssue` (description, documentation, and the faulting attribute) so + * preflight reads like a prediction of what the API would report. */ export interface RuleViolation { /** The product attribute at fault (e.g. "title", "price.currencyCode"), if any. */ diff --git a/packages/preflight/tests/engine.test.ts b/packages/preflight/tests/engine.test.ts index 0fcc0c9..8e39ff5 100644 --- a/packages/preflight/tests/engine.test.ts +++ b/packages/preflight/tests/engine.test.ts @@ -106,7 +106,7 @@ describe("runPreflight", () => { it("runs the default registry and catches a missing title", () => { const report = runPreflight([ - { offerId: "x", attributes: { price: { amountMicros: "1", currencyCode: "USD" } } }, + { offerId: "x", productAttributes: { price: { amountMicros: "1", currencyCode: "USD" } } }, ]); const ids = report.findings.map((f) => f.ruleId); expect(ids).toContain("required.title"); diff --git a/packages/preflight/tests/format.test.ts b/packages/preflight/tests/format.test.ts index 16ad0a8..fbf2f4b 100644 --- a/packages/preflight/tests/format.test.ts +++ b/packages/preflight/tests/format.test.ts @@ -11,17 +11,23 @@ const check = (id: string, product: ProductInput) => rule(id).check(product, {}) describe("format.link-url / format.image-link-url", () => { it("passes valid http(s) URLs, flags malformed, ignores absent", () => { - expect(check("format.link-url", { attributes: {} })).toHaveLength(0); - expect(check("format.link-url", { attributes: { link: "https://x.com/p" } })).toHaveLength(0); - expect(check("format.link-url", { attributes: { link: "http://x.com" } })).toHaveLength(0); - expect(check("format.link-url", { attributes: { link: "ftp://x.com" } })).toHaveLength(1); - expect(check("format.link-url", { attributes: { link: "not a url" } })).toHaveLength(1); + expect(check("format.link-url", { productAttributes: {} })).toHaveLength(0); + expect( + check("format.link-url", { productAttributes: { link: "https://x.com/p" } }), + ).toHaveLength(0); + expect(check("format.link-url", { productAttributes: { link: "http://x.com" } })).toHaveLength( + 0, + ); + expect(check("format.link-url", { productAttributes: { link: "ftp://x.com" } })).toHaveLength( + 1, + ); + expect(check("format.link-url", { productAttributes: { link: "not a url" } })).toHaveLength(1); expect( - check("format.image-link-url", { attributes: { imageLink: "https://x.com/a.jpg" } }), + check("format.image-link-url", { productAttributes: { imageLink: "https://x.com/a.jpg" } }), ).toHaveLength(0); expect( - check("format.image-link-url", { attributes: { imageLink: "/relative.jpg" } }), + check("format.image-link-url", { productAttributes: { imageLink: "/relative.jpg" } }), ).toHaveLength(1); }); }); @@ -29,11 +35,11 @@ describe("format.link-url / format.image-link-url", () => { describe("format.price-amount", () => { const c = (amountMicros: unknown) => check("format.price-amount", { - attributes: { price: { amountMicros: amountMicros as string } }, + productAttributes: { price: { amountMicros: amountMicros as string } }, }); it("flags only a present-but-malformed amount", () => { - expect(check("format.price-amount", { attributes: {} })).toHaveLength(0); // absent + expect(check("format.price-amount", { productAttributes: {} })).toHaveLength(0); // absent expect(c("")).toHaveLength(0); // empty == absent expect(c("49990000")).toHaveLength(0); expect(c(49990000)).toHaveLength(0); // numeric tolerated @@ -44,10 +50,10 @@ describe("format.price-amount", () => { }); describe("format.price-currency", () => { - const c = (price: Price) => check("format.price-currency", { attributes: { price } }); + const c = (price: Price) => check("format.price-currency", { productAttributes: { price } }); it("only fires when an amount is present", () => { - expect(check("format.price-currency", { attributes: {} })).toHaveLength(0); + expect(check("format.price-currency", { productAttributes: {} })).toHaveLength(0); expect(c({ currencyCode: "USD" })).toHaveLength(0); // no amount → not our job }); @@ -67,10 +73,10 @@ describe("format.price-currency", () => { describe("format.availability-enum", () => { const c = (availability: string) => - check("format.availability-enum", { attributes: { availability } }); + check("format.availability-enum", { productAttributes: { availability } }); it("accepts canonical values and case/space variants, flags the rest", () => { - expect(check("format.availability-enum", { attributes: {} })).toHaveLength(0); + expect(check("format.availability-enum", { productAttributes: {} })).toHaveLength(0); expect(c("in_stock")).toHaveLength(0); expect(c("In Stock")).toHaveLength(0); // normalized expect(c("backorder")).toHaveLength(0); @@ -79,7 +85,8 @@ describe("format.availability-enum", () => { }); describe("format.condition-enum", () => { - const c = (condition: string) => check("format.condition-enum", { attributes: { condition } }); + const c = (condition: string) => + check("format.condition-enum", { productAttributes: { condition } }); it("accepts new/refurbished/used (any case), flags the rest", () => { expect(c("new")).toHaveLength(0); @@ -89,11 +96,11 @@ describe("format.condition-enum", () => { }); describe("format.gtin-checksum", () => { - const c = (gtin: string) => check("format.gtin-checksum", { attributes: { gtin } }); + const c = (gtin: string) => check("format.gtin-checksum", { productAttributes: { gtin } }); it("passes valid GTINs and flags bad check digits (as a warning)", () => { expect(rule("format.gtin-checksum").defaultSeverity).toBe("warning"); - expect(check("format.gtin-checksum", { attributes: {} })).toHaveLength(0); + expect(check("format.gtin-checksum", { productAttributes: {} })).toHaveLength(0); expect(c("4006381333931")).toHaveLength(0); // valid EAN-13 expect(c("036000291452")).toHaveLength(0); // valid UPC-A expect(c("4006381333930")).toHaveLength(1); // bad check digit @@ -105,11 +112,11 @@ describe("non-string values (hand-edited feeds) yield real findings, not crashes it("coerces a non-string attribute and flags it instead of throwing", () => { expect( check("format.availability-enum", { - attributes: { availability: 0 as unknown as string }, + productAttributes: { availability: 0 as unknown as string }, }), ).toHaveLength(1); // "0" is not a recognized value expect( - check("format.link-url", { attributes: { link: ["x"] as unknown as string } }), + check("format.link-url", { productAttributes: { link: ["x"] as unknown as string } }), ).toHaveLength(1); }); }); @@ -117,18 +124,18 @@ describe("non-string values (hand-edited feeds) yield real findings, not crashes describe("format length limits", () => { it("warns past the cap, passes within (and ignores absent)", () => { expect(rule("format.title-length").defaultSeverity).toBe("warning"); - expect(check("format.title-length", { attributes: {} })).toHaveLength(0); - expect(check("format.title-length", { attributes: { title: "x".repeat(150) } })).toHaveLength( - 0, - ); - expect(check("format.title-length", { attributes: { title: "x".repeat(151) } })).toHaveLength( - 1, - ); + expect(check("format.title-length", { productAttributes: {} })).toHaveLength(0); + expect( + check("format.title-length", { productAttributes: { title: "x".repeat(150) } }), + ).toHaveLength(0); + expect( + check("format.title-length", { productAttributes: { title: "x".repeat(151) } }), + ).toHaveLength(1); expect( - check("format.description-length", { attributes: { description: "x".repeat(5000) } }), + check("format.description-length", { productAttributes: { description: "x".repeat(5000) } }), ).toHaveLength(0); expect( - check("format.description-length", { attributes: { description: "x".repeat(5001) } }), + check("format.description-length", { productAttributes: { description: "x".repeat(5001) } }), ).toHaveLength(1); }); }); diff --git a/packages/preflight/tests/policy.test.ts b/packages/preflight/tests/policy.test.ts index d741649..d31a161 100644 --- a/packages/preflight/tests/policy.test.ts +++ b/packages/preflight/tests/policy.test.ts @@ -8,7 +8,7 @@ const rule = (id: string) => { return found; }; const check = (id: string, product: ProductInput) => rule(id).check(product, {}); -const title = (t: string): ProductInput => ({ attributes: { title: t } }); +const title = (t: string): ProductInput => ({ productAttributes: { title: t } }); describe("policy.promotional-title", () => { it("is an error and flags promotional phrases, not legit names", () => { @@ -72,9 +72,15 @@ describe("policy.phone-in-title", () => { describe("policy.link-https", () => { it("warns on http, passes https / absent / malformed", () => { expect(rule("policy.link-https").defaultSeverity).toBe("warning"); - expect(check("policy.link-https", { attributes: { link: "http://x.com/p" } })).toHaveLength(1); - expect(check("policy.link-https", { attributes: { link: "https://x.com/p" } })).toHaveLength(0); - expect(check("policy.link-https", { attributes: { link: "not a url" } })).toHaveLength(0); + expect( + check("policy.link-https", { productAttributes: { link: "http://x.com/p" } }), + ).toHaveLength(1); + expect( + check("policy.link-https", { productAttributes: { link: "https://x.com/p" } }), + ).toHaveLength(0); + expect(check("policy.link-https", { productAttributes: { link: "not a url" } })).toHaveLength( + 0, + ); expect(check("policy.link-https", {})).toHaveLength(0); }); }); diff --git a/packages/preflight/tests/required.test.ts b/packages/preflight/tests/required.test.ts index 8bb073d..135b889 100644 --- a/packages/preflight/tests/required.test.ts +++ b/packages/preflight/tests/required.test.ts @@ -20,42 +20,46 @@ describe("required.offer-id", () => { describe("required.title", () => { it("flags a missing or blank title", () => { expect(check("required.title", { offerId: "x" })).toHaveLength(1); - expect(check("required.title", { attributes: { title: "" } })).toHaveLength(1); - expect(check("required.title", { attributes: { title: "Shoe" } })).toHaveLength(0); + expect(check("required.title", { productAttributes: { title: "" } })).toHaveLength(1); + expect(check("required.title", { productAttributes: { title: "Shoe" } })).toHaveLength(0); }); }); describe("required.description", () => { it("flags a missing or blank description", () => { - expect(check("required.description", { attributes: {} })).toHaveLength(1); - expect(check("required.description", { attributes: { description: " " } })).toHaveLength(1); - expect(check("required.description", { attributes: { description: "A shoe." } })).toHaveLength( - 0, - ); + expect(check("required.description", { productAttributes: {} })).toHaveLength(1); + expect( + check("required.description", { productAttributes: { description: " " } }), + ).toHaveLength(1); + expect( + check("required.description", { productAttributes: { description: "A shoe." } }), + ).toHaveLength(0); }); }); describe("required.link", () => { it("flags a missing link", () => { - expect(check("required.link", { attributes: {} })).toHaveLength(1); - expect(check("required.link", { attributes: { link: "https://x.com/p" } })).toHaveLength(0); + expect(check("required.link", { productAttributes: {} })).toHaveLength(1); + expect(check("required.link", { productAttributes: { link: "https://x.com/p" } })).toHaveLength( + 0, + ); }); }); describe("required.image-link", () => { it("flags a missing image link", () => { - expect(check("required.image-link", { attributes: {} })).toHaveLength(1); + expect(check("required.image-link", { productAttributes: {} })).toHaveLength(1); expect( - check("required.image-link", { attributes: { imageLink: "https://x.com/a.jpg" } }), + check("required.image-link", { productAttributes: { imageLink: "https://x.com/a.jpg" } }), ).toHaveLength(0); }); }); describe("required.availability", () => { it("flags missing availability", () => { - expect(check("required.availability", { attributes: {} })).toHaveLength(1); + expect(check("required.availability", { productAttributes: {} })).toHaveLength(1); expect( - check("required.availability", { attributes: { availability: "in_stock" } }), + check("required.availability", { productAttributes: { availability: "in_stock" } }), ).toHaveLength(0); }); }); @@ -63,23 +67,23 @@ describe("required.availability", () => { describe("required.price", () => { it("flags a missing price or amount only", () => { expect(check("required.price", { offerId: "x" })).toHaveLength(1); - expect(check("required.price", { attributes: { price: {} } })).toHaveLength(1); + expect(check("required.price", { productAttributes: { price: {} } })).toHaveLength(1); expect( - check("required.price", { attributes: { price: { currencyCode: "USD" } } }), + check("required.price", { productAttributes: { price: { currencyCode: "USD" } } }), ).toHaveLength(1); expect( - check("required.price", { attributes: { price: { amountMicros: "1000" } } }), + check("required.price", { productAttributes: { price: { amountMicros: "1000" } } }), ).toHaveLength(0); }); it("does not flag a present-but-malformed amount (format.price-amount's job)", () => { - expect(check("required.price", { attributes: { price: { amountMicros: "-5" } } })).toHaveLength( - 0, - ); + expect( + check("required.price", { productAttributes: { price: { amountMicros: "-5" } } }), + ).toHaveLength(0); // A numeric amountMicros (hand-edited file) must not throw the defensive parse. expect( check("required.price", { - attributes: { price: { amountMicros: 1000 as unknown as string } }, + productAttributes: { price: { amountMicros: 1000 as unknown as string } }, }), ).toHaveLength(0); }); @@ -88,17 +92,25 @@ describe("required.price", () => { describe("required.condition", () => { it("warns (not errors) when missing, passes when present", () => { expect(rule("required.condition").defaultSeverity).toBe("warning"); - expect(check("required.condition", { attributes: {} })).toHaveLength(1); - expect(check("required.condition", { attributes: { condition: "new" } })).toHaveLength(0); + expect(check("required.condition", { productAttributes: {} })).toHaveLength(1); + expect(check("required.condition", { productAttributes: { condition: "new" } })).toHaveLength( + 0, + ); }); }); describe("required.identifier-exists", () => { it("warns only when gtin, mpn, and brand are all absent", () => { expect(rule("required.identifier-exists").defaultSeverity).toBe("warning"); - expect(check("required.identifier-exists", { attributes: {} })).toHaveLength(1); - expect(check("required.identifier-exists", { attributes: { brand: "Acme" } })).toHaveLength(0); - expect(check("required.identifier-exists", { attributes: { gtin: "x" } })).toHaveLength(0); - expect(check("required.identifier-exists", { attributes: { mpn: "x" } })).toHaveLength(0); + expect(check("required.identifier-exists", { productAttributes: {} })).toHaveLength(1); + expect( + check("required.identifier-exists", { productAttributes: { brand: "Acme" } }), + ).toHaveLength(0); + expect(check("required.identifier-exists", { productAttributes: { gtin: "x" } })).toHaveLength( + 0, + ); + expect(check("required.identifier-exists", { productAttributes: { mpn: "x" } })).toHaveLength( + 0, + ); }); });