Commit e770c2aa3ac for woocommerce
commit e770c2aa3ac7238853d15e58a2609c44fe28d1dd
Author: Sergey Mitroshin <sergeymitr@gmail.com>
Date: Thu Oct 15 15:43:59 2026 -0400
UCP: Add catalog search, lookup and product endpoints (#69577)
Adds the UCP (Universal Commerce Protocol) catalog endpoints to WooCommerce Core.
---------
Co-authored-by: Timur Gogolev <timur.gogolev@automattic.com>
diff --git a/plugins/woocommerce/changelog/add-ucp-catalog-routes b/plugins/woocommerce/changelog/add-ucp-catalog-routes
new file mode 100644
index 00000000000..59c6f8aba48
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-ucp-catalog-routes
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add opt-in UCP catalog search, lookup and product endpoints so AI shopping agents can browse the store.
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index 6dfb260dfe5..87fdc15f74f 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -642,6 +642,19 @@ class FeaturesController {
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'is_legacy' => false,
),
+ 'ucp' => array(
+ 'name' => __( 'Universal Commerce Protocol', 'woocommerce' ),
+ 'description' => __(
+ 'Expose the catalog to AI shopping agents through UCP endpoints.',
+ 'woocommerce'
+ ),
+ 'enabled_by_default' => false,
+ // Hidden: the flag is set by the integration that drives UCP, not by merchants.
+ 'disable_ui' => true,
+ 'is_experimental' => true,
+ 'skip_compatibility_checks' => true,
+ 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
+ ),
'destroy-empty-sessions' => array(
'name' => __( 'Clear Customer Sessions When Empty', 'woocommerce' ),
'description' => __(
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Mapping/UcpProductMapper.php b/plugins/woocommerce/src/Internal/ProductFeed/Mapping/UcpProductMapper.php
new file mode 100644
index 00000000000..4686fb24d64
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Mapping/UcpProductMapper.php
@@ -0,0 +1,937 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Mapping;
+
+use Automattic\WooCommerce\StoreApi\SchemaController;
+use Automattic\WooCommerce\StoreApi\Schemas\V1\ProductSchema;
+use Automattic\WooCommerce\StoreApi\Schemas\V1\Ucp\UcpProductSchema;
+use Automattic\WooCommerce\StoreApi\StoreApi;
+
+/**
+ * Maps WooCommerce products to the UCP catalog product shape.
+ *
+ * Field values are read from Store API product item responses, so tax display,
+ * password protection, description formatting and attribute labels follow the
+ * storefront. Instances carry per-response state (variant budget, truncation
+ * records), so a caller assembling one response reuses one instance and calls
+ * {@see self::begin_response()} before its first mapping call.
+ *
+ * @since 11.3.0
+ */
+class UcpProductMapper implements ProductShapeMapperInterface {
+ /**
+ * Maximum number of variants returned for a single product. Each variant is
+ * a product load; a request naming its variations never hits this cap.
+ */
+ const MAX_VARIANTS_PER_PRODUCT = 100;
+
+ /**
+ * Maximum number of variants built across one response. Not absolute:
+ * `variants` has `minItems: 1`, so the worst case is this budget plus one
+ * per product on the page.
+ */
+ const MAX_VARIANTS_PER_RESPONSE = 500;
+
+ /**
+ * Maximum number of media entries returned per product.
+ */
+ const MAX_MEDIA_PER_PRODUCT = 10;
+
+ /**
+ * Maximum number of categories or tags returned per product.
+ */
+ const MAX_TERMS_PER_PRODUCT = 25;
+
+ /**
+ * ISO 4217 currency code, or '' to resolve the store currency per call.
+ *
+ * @var string
+ */
+ private $currency;
+
+ /**
+ * Store API product schema the item responses are read from.
+ *
+ * @var ProductSchema|null
+ */
+ private $schema;
+
+ /**
+ * Products whose variant list was cut short, keyed by product id.
+ *
+ * @var array
+ */
+ private $truncations = array();
+
+ /**
+ * Variants still available to build in the response being assembled.
+ *
+ * @var int
+ */
+ private $variant_budget = self::MAX_VARIANTS_PER_RESPONSE;
+
+ /**
+ * Constructor.
+ *
+ * @param string $currency ISO 4217 currency code; '' uses the store currency.
+ * @param ProductSchema|null $schema Product schema; null resolves the UCP product schema.
+ */
+ public function __construct( string $currency = '', ?ProductSchema $schema = null ) {
+ $this->currency = $currency;
+ $this->schema = $schema;
+ }
+
+ /**
+ * Start a new response, clearing per-response state.
+ */
+ public function begin_response(): void {
+ $this->truncations = array();
+ $this->variant_budget = self::MAX_VARIANTS_PER_RESPONSE;
+ }
+
+ /**
+ * Take the truncations recorded while building the current response.
+ *
+ * Raw data, not messages: only the caller knows the JSONPath to point at.
+ *
+ * @return array Keyed by product id, each `{ title, returned, total }`.
+ */
+ public function take_truncations(): array {
+ $truncations = $this->truncations;
+ $this->truncations = array();
+
+ return $truncations;
+ }
+
+ /**
+ * Map a page of products, skipping those with no catalog variant.
+ *
+ * @param array $products WooCommerce products.
+ * @return array
+ */
+ public function map_products( array $products ): array {
+ $this->begin_response();
+
+ $mapped = array();
+
+ foreach ( $products as $product ) {
+ if ( ! $product instanceof \WC_Product ) {
+ continue;
+ }
+
+ if ( ! $this->has_catalog_variants( $product ) ) {
+ continue;
+ }
+
+ $mapped[] = $this->map_product( $product );
+ }
+
+ return $mapped;
+ }
+
+ /**
+ * Map a product to the UCP catalog product shape.
+ *
+ * @param \WC_Product $product The product to map.
+ * @param array $required_variation_ids Variation ids the request named directly, which the
+ * per-product variant cap must not drop.
+ * @param bool $only_required Build only the named variations, because the caller will
+ * discard everything else.
+ * @return array The mapped product data.
+ */
+ public function map_product( \WC_Product $product, array $required_variation_ids = array(), bool $only_required = false ): array {
+ return $this->map_item( $product, $this->item( $product ), $required_variation_ids, $only_required );
+ }
+
+ /**
+ * Map a product for `POST /catalog/lookup`.
+ *
+ * Every returned variant must be justified by a request identifier via the
+ * required `inputs` metadata. A product-level identifier resolves to all of
+ * the product's variants as `featured` — server-selected representatives —
+ * while an identifier naming a variant directly by id resolves
+ * only to that variant, as `exact`. Variants no identifier resolved to are
+ * dropped, so a request naming only variations gets only those variations
+ * back; several identifiers resolving to one variant all appear in its
+ * `inputs`.
+ *
+ * @param \WC_Product $product WooCommerce product.
+ * @param array $inputs Identifiers from the lookup request that resolved to this product,
+ * each `{ id, variation_id }` with the variation it named or 0 for
+ * the product itself.
+ * @return array
+ */
+ public function map_lookup_product( \WC_Product $product, array $inputs ): array {
+ // A variation named by id must survive the per-product cap.
+ $required = array();
+ $names_product = false;
+ foreach ( $inputs as $input ) {
+ if ( $input['variation_id'] > 0 ) {
+ $required[] = (int) $input['variation_id'];
+ continue;
+ }
+
+ // A product-level identifier needs the full variant list.
+ $names_product = true;
+ }
+
+ // With only variation-level identifiers, build only those variants: the
+ // loop below discards the rest anyway.
+ $payload = $this->map_product( $product, $required, ! $names_product );
+
+ if ( ! isset( $payload['variants'] ) || ! is_array( $payload['variants'] ) ) {
+ return $payload;
+ }
+
+ $variants = array();
+
+ foreach ( $payload['variants'] as $variant ) {
+ $correlations = array();
+
+ foreach ( $inputs as $input ) {
+ // The resolved variation decides the match: the raw id may carry
+ // leading zeros the resolver already normalized.
+ $names_variant = $input['variation_id'] > 0;
+
+ if ( $names_variant && (int) $input['variation_id'] !== (int) $variant['id'] ) {
+ continue;
+ }
+
+ $correlations[] = array(
+ 'id' => $input['id'],
+ 'match' => $names_variant ? 'exact' : 'featured',
+ );
+ }
+
+ if ( empty( $correlations ) ) {
+ continue;
+ }
+
+ $variant['inputs'] = $correlations;
+ $variants[] = $variant;
+ }
+
+ $payload['variants'] = $variants;
+
+ return $payload;
+ }
+
+ /**
+ * Map a product for `POST /catalog/product`.
+ *
+ * A selection narrows the variants to those matching every selected option.
+ * One matching no variation is relaxed from its last option until one does,
+ * and `selected` reports the options kept, as UCP requires. A request naming
+ * a variation returns it first, with its own options as the selection.
+ *
+ * @param \WC_Product $product WooCommerce product.
+ * @param array $selected Selected option values, each `{ name, label, id? }`.
+ * @param int $variation_id Variation the request named, or 0 when it named the product.
+ * @return array
+ */
+ public function map_detail_product( \WC_Product $product, array $selected = array(), int $variation_id = 0 ): array {
+ if ( ! $product instanceof \WC_Product_Variable || ( empty( $selected ) && $variation_id <= 0 ) ) {
+ return $this->map_product( $product );
+ }
+
+ $item = $this->item( $product );
+
+ // UCP: a variant id fully determines the selection state, so the
+ // request's `selected` is ignored rather than merged.
+ if ( $variation_id > 0 ) {
+ $selected = $this->variation_options_by_id( $item )[ $variation_id ] ?? array();
+ }
+
+ $matching = array();
+
+ while ( ! empty( $selected ) ) {
+ $matching = $this->match_selection( $item, $selected );
+
+ if ( ! empty( $matching ) ) {
+ break;
+ }
+
+ array_pop( $selected );
+ }
+
+ $required = $variation_id > 0 ? array( $variation_id ) : array();
+ $mapped = $this->map_item( $product, $item, $required, false, empty( $matching ) ? null : $matching );
+ $mapped['selected'] = $selected;
+
+ return $mapped;
+ }
+
+ /**
+ * Determine whether a product exposes at least one purchasable catalog variant.
+ *
+ * Single source of truth for catalog eligibility: a product without variants
+ * would violate the catalog `product` schema (`variants` requires
+ * `minItems: 1`), so it is excluded from listings and treated as not found on
+ * detail lookups. Simple products always qualify; a variable product needs at
+ * least one visible variation: enabled and, when the store hides out-of-stock
+ * items, in stock. That is the set the storefront and the Store API offer, read
+ * through the data store's cached children list.
+ *
+ * @param \WC_Product $product WooCommerce product.
+ * @return bool
+ */
+ public function has_catalog_variants( \WC_Product $product ): bool {
+ if ( ! $product instanceof \WC_Product_Variable ) {
+ return true;
+ }
+
+ return ! empty( $product->get_visible_children() );
+ }
+
+ /**
+ * Get the product schema, resolving the UCP one from the Store API container on first use.
+ *
+ * @return ProductSchema
+ */
+ private function schema(): ProductSchema {
+ if ( null === $this->schema ) {
+ $this->schema = StoreApi::container()->get( SchemaController::class )->get( UcpProductSchema::IDENTIFIER );
+ }
+
+ return $this->schema;
+ }
+
+ /**
+ * Build the Store API item response for a product or variation, as nested arrays.
+ *
+ * @param \WC_Product $product WooCommerce product.
+ * @return array
+ */
+ private function item( \WC_Product $product ): array {
+ // Nested values are stdClass; decode through JSON for uniform array access.
+ return (array) json_decode( (string) wp_json_encode( $this->schema()->get_item_response( $product ) ), true );
+ }
+
+ /**
+ * Map a product from its item response.
+ *
+ * @param \WC_Product $product The product to map.
+ * @param array $item Its Store API item response.
+ * @param array $required_variation_ids Variation ids the per-product cap must not drop.
+ * @param bool $only_required Build only the named variations.
+ * @param array|null $candidates Variation ids to build from, or null for every visible one.
+ * @return array
+ */
+ private function map_item( \WC_Product $product, array $item, array $required_variation_ids, bool $only_required, ?array $candidates = null ): array {
+ $currency = $this->get_currency();
+ $variants = $this->build_variants( $product, $item, $currency, $required_variation_ids, $only_required, $candidates );
+
+ $mapped = array(
+ 'id' => (string) $item['id'],
+ 'title' => (string) $item['name'],
+ 'description' => $this->build_description( $item ),
+ 'price_range' => $this->build_price_range( $item['prices'], $currency ),
+ 'variants' => $variants,
+ );
+
+ $handle = (string) $item['slug'];
+ if ( '' !== $handle ) {
+ $mapped['handle'] = $handle;
+ }
+
+ $url = $item['permalink'];
+ if ( is_string( $url ) && '' !== $url ) {
+ $mapped['url'] = $url;
+ }
+
+ $categories = $this->build_categories( (array) $item['categories'] );
+ if ( ! empty( $categories ) ) {
+ $mapped['categories'] = $categories;
+ }
+
+ $media = $this->build_media( (array) $item['images'] );
+ if ( ! empty( $media ) ) {
+ $mapped['media'] = $media;
+ }
+
+ $options = $this->build_options( $item );
+ if ( ! empty( $options ) ) {
+ $mapped['options'] = $options;
+ }
+
+ $list_price_range = $this->build_list_price_range( $variants );
+ if ( null !== $list_price_range ) {
+ $mapped['list_price_range'] = $list_price_range;
+ }
+
+ $rating = $this->build_rating( $item );
+ if ( null !== $rating ) {
+ $mapped['rating'] = $rating;
+ }
+
+ $tags = $this->build_tags( (array) $item['tags'] );
+ if ( ! empty( $tags ) ) {
+ $mapped['tags'] = $tags;
+ }
+
+ return $mapped;
+ }
+
+ /**
+ * Resolve the currency code used to label amounts.
+ *
+ * @return string
+ */
+ private function get_currency(): string {
+ return '' !== $this->currency ? $this->currency : (string) get_woocommerce_currency();
+ }
+
+ /**
+ * Build a `price` object from a Store API minor-unit amount.
+ *
+ * @param mixed $amount Amount in minor units, as the Store API formats it.
+ * @param string $currency ISO 4217 currency code.
+ * @return array
+ */
+ private function price( $amount, string $currency ): array {
+ return array(
+ 'amount' => (int) $amount,
+ 'currency' => $currency,
+ );
+ }
+
+ /**
+ * Build the description object, preferring the short description.
+ *
+ * @param array $item Store API item response.
+ * @return array
+ */
+ private function build_description( array $item ): array {
+ $html = trim( (string) $item['short_description'] );
+ if ( '' === $html ) {
+ $html = trim( (string) $item['description'] );
+ }
+
+ // Entities would reach the agent literally, so decode after stripping tags.
+ $plain = html_entity_decode( wp_strip_all_tags( $html ), ENT_QUOTES, get_bloginfo( 'charset' ) );
+
+ // Decoded ` ` is U+00A0. Agents match on this text, so normalize it to a
+ // plain space rather than leaving a character that breaks string comparison.
+ $plain = str_replace( "\xc2\xa0", ' ', $plain );
+
+ $description = array( 'plain' => trim( $plain ) );
+
+ if ( '' !== $html ) {
+ $description['html'] = $html;
+ }
+
+ return $description;
+ }
+
+ /**
+ * Build the price range from Store API prices.
+ *
+ * `price_range` is only set for variable products whose variations differ in
+ * price; everything else is a single price.
+ *
+ * @param array $prices Store API `prices` data.
+ * @param string $currency ISO 4217 currency code.
+ * @return array
+ */
+ private function build_price_range( $prices, string $currency ): array {
+ $range = $prices['price_range'] ?? null;
+
+ if ( is_array( $range ) ) {
+ return array(
+ 'min' => $this->price( $range['min_amount'], $currency ),
+ 'max' => $this->price( $range['max_amount'], $currency ),
+ );
+ }
+
+ return array(
+ 'min' => $this->price( $prices['price'], $currency ),
+ 'max' => $this->price( $prices['price'], $currency ),
+ );
+ }
+
+ /**
+ * Build the list price range from the variants built for the product.
+ *
+ * Spans every variant's regular price, but only when some variant is genuinely
+ * discounted; a variant carries `list_price` only in that case.
+ *
+ * @param array $variants Built variants.
+ * @return array|null
+ */
+ private function build_list_price_range( array $variants ) {
+ $has_discount = false;
+ $regular = array();
+
+ foreach ( $variants as $variant ) {
+ if ( isset( $variant['list_price'] ) ) {
+ $has_discount = true;
+ $regular[] = $variant['list_price']['amount'];
+ } else {
+ $regular[] = $variant['price']['amount'];
+ }
+ }
+
+ if ( ! $has_discount || empty( $regular ) ) {
+ return null;
+ }
+
+ $currency = $variants[0]['price']['currency'];
+
+ return array(
+ 'min' => $this->price( min( $regular ), $currency ),
+ 'max' => $this->price( max( $regular ), $currency ),
+ );
+ }
+
+ /**
+ * Build variants from product, bounded by the per-product cap.
+ *
+ * The loop stops at the cap rather than building everything and slicing, so
+ * variations past it are never loaded. Named variations go first, so the cap
+ * can only drop variants nobody asked for by id.
+ *
+ * @param \WC_Product $product WooCommerce product.
+ * @param array $item Its Store API item response.
+ * @param string $currency ISO 4217 currency code.
+ * @param array $required_variation_ids Variation ids the request named directly.
+ * @param bool $only_required Build nothing beyond $required_variation_ids.
+ * @param array|null $candidates Variation ids to build from, or null for every visible one.
+ * @return array
+ */
+ private function build_variants( \WC_Product $product, array $item, string $currency, array $required_variation_ids, bool $only_required, ?array $candidates ): array {
+ if ( ! $product instanceof \WC_Product_Variable ) {
+ return array( $this->build_simple_variant( $item, $currency ) );
+ }
+
+ $children = array_map( 'intval', null === $candidates ? $product->get_visible_children() : $candidates );
+ $required = array_values( array_intersect( $children, array_map( 'intval', $required_variation_ids ) ) );
+ $options = $this->variation_options_by_id( $item );
+
+ if ( $only_required && ! empty( $required ) ) {
+ // Bounded by the request's own id cap; nothing left out, nothing to
+ // disclose.
+ $variants = $this->build_variation_list( $required, $options, $currency );
+ $this->variant_budget -= count( $variants );
+
+ return $variants;
+ }
+
+ $required_first = array_merge( $required, array_values( array_diff( $children, $required ) ) );
+
+ // The floor keeps named variations and at least one variant: an empty
+ // `variants` array would violate the schema's `minItems: 1`.
+ $floor = max( 1, count( $required ) );
+ $cap = max( $floor, min( self::MAX_VARIANTS_PER_PRODUCT, $this->variant_budget ) );
+ $variants = $this->build_variation_list( $required_first, $options, $currency, $cap );
+
+ $this->variant_budget -= count( $variants );
+
+ $this->record_truncation( $item, count( $variants ), count( $children ) );
+
+ return $variants;
+ }
+
+ /**
+ * Hydrate and transform variations in order, stopping at an optional limit.
+ *
+ * The limit is applied while iterating rather than by slicing afterwards, so
+ * variations past it are never loaded.
+ *
+ * @param array $variation_ids Variation ids, in the order to build them.
+ * @param array $options Selected options keyed by variation id, from the parent item.
+ * @param string $currency ISO 4217 currency code.
+ * @param int|null $limit Maximum variants to build, or null for no limit.
+ * @return array
+ */
+ private function build_variation_list( array $variation_ids, array $options, string $currency, ?int $limit = null ): array {
+ $variants = array();
+
+ foreach ( $variation_ids as $variation_id ) {
+ if ( null !== $limit && count( $variants ) >= $limit ) {
+ break;
+ }
+
+ $variation = wc_get_product( $variation_id );
+ if ( ! $variation instanceof \WC_Product_Variation ) {
+ continue;
+ }
+
+ $variants[] = $this->build_variation_variant( $this->item( $variation ), $options[ (int) $variation_id ] ?? array(), $currency );
+ }
+
+ return $variants;
+ }
+
+ /**
+ * Note that a product's variant list came back short, for the caller to report.
+ *
+ * @param array $item Store API item response of the variable product.
+ * @param int $returned Number of variants actually built.
+ * @param int $total Number of variants there were to build.
+ */
+ private function record_truncation( array $item, int $returned, int $total ): void {
+ if ( $returned >= $total ) {
+ return;
+ }
+
+ $this->truncations[ (int) $item['id'] ] = array(
+ 'title' => (string) $item['name'],
+ 'returned' => $returned,
+ 'total' => $total,
+ );
+ }
+
+ /**
+ * Build a variant entry from a simple product's item response.
+ *
+ * @param array $item Store API item response.
+ * @param string $currency ISO 4217 currency code.
+ * @return array
+ */
+ private function build_simple_variant( array $item, string $currency ): array {
+ $variant = array(
+ 'id' => (string) $item['id'],
+ 'title' => (string) $item['name'],
+ 'description' => $this->build_description( $item ),
+ 'price' => $this->price( $item['prices']['price'], $currency ),
+ 'availability' => array( 'available' => $item['is_purchasable'] && $item['is_in_stock'] ),
+ );
+
+ $sku = (string) $item['sku'];
+ if ( '' !== $sku ) {
+ $variant['sku'] = $sku;
+ }
+
+ $list_price = $this->build_variant_list_price( $item['prices'], $currency );
+ if ( null !== $list_price ) {
+ $variant['list_price'] = $list_price;
+ }
+
+ return $variant;
+ }
+
+ /**
+ * Build a variant entry from a variation's item response.
+ *
+ * @param array $item Store API item response of the variation.
+ * @param array $options Selected options, each `{ name, label }`.
+ * @param string $currency ISO 4217 currency code.
+ * @return array
+ */
+ private function build_variation_variant( array $item, array $options, string $currency ): array {
+ $labels = array_column( $options, 'label' );
+
+ $variant = array(
+ 'id' => (string) $item['id'],
+ 'title' => empty( $labels ) ? (string) $item['name'] : implode( ' / ', $labels ),
+ 'description' => $this->build_description( $item ),
+ 'price' => $this->price( $item['prices']['price'], $currency ),
+ 'availability' => array( 'available' => $item['is_purchasable'] && $item['is_in_stock'] ),
+ );
+
+ $sku = (string) $item['sku'];
+ if ( '' !== $sku ) {
+ $variant['sku'] = $sku;
+ }
+
+ $list_price = $this->build_variant_list_price( $item['prices'], $currency );
+ if ( null !== $list_price ) {
+ $variant['list_price'] = $list_price;
+ }
+
+ if ( ! empty( $options ) ) {
+ $variant['options'] = $options;
+ }
+
+ $media = $this->build_media( array_slice( (array) $item['images'], 0, 1 ) );
+ if ( ! empty( $media ) ) {
+ $variant['media'] = $media;
+ }
+
+ return $variant;
+ }
+
+ /**
+ * Build a variant's list price when it is genuinely discounted.
+ *
+ * Compares against the effective price so expired or scheduled sales are excluded.
+ *
+ * @param array $prices Store API `prices` data.
+ * @param string $currency ISO 4217 currency code.
+ * @return array|null
+ */
+ private function build_variant_list_price( $prices, string $currency ) {
+ if ( (int) $prices['regular_price'] <= (int) $prices['price'] ) {
+ return null;
+ }
+
+ return $this->price( $prices['regular_price'], $currency );
+ }
+
+ /**
+ * Index term names by slug for each variation attribute of a product.
+ *
+ * @param array $item Store API item response of the variable product.
+ * @return array Attribute label => [ slug => term name ].
+ */
+ private function term_names( array $item ): array {
+ $names = array();
+
+ foreach ( (array) $item['attributes'] as $attribute ) {
+ if ( empty( $attribute['has_variations'] ) ) {
+ continue;
+ }
+
+ foreach ( (array) $attribute['terms'] as $term ) {
+ $names[ (string) $attribute['name'] ][ (string) $term['slug'] ] = (string) $term['name'];
+ }
+ }
+
+ return $names;
+ }
+
+ /**
+ * Build each visible variation's selected options from the parent item.
+ *
+ * An attribute left as "Any" on a variation has a null value and is omitted.
+ *
+ * @param array $item Store API item response of the variable product.
+ * @return array Variation id => list of `{ name, label }`.
+ */
+ private function variation_options_by_id( array $item ): array {
+ $names = $this->term_names( $item );
+ $options = array();
+
+ foreach ( (array) $item['variations'] as $variation ) {
+ $selected = array();
+
+ foreach ( (array) $variation['attributes'] as $attribute ) {
+ if ( null === $attribute['value'] || '' === (string) $attribute['value'] ) {
+ continue;
+ }
+
+ $name = (string) $attribute['name'];
+ $value = (string) $attribute['value'];
+ $selected[] = array(
+ 'name' => $name,
+ 'label' => $names[ $name ][ $value ] ?? $value,
+ );
+ }
+
+ $options[ (int) $variation['id'] ] = $selected;
+ }
+
+ return $options;
+ }
+
+ /**
+ * Build product options for variable products.
+ *
+ * Lists the values the visible variations actually use, in the attribute's
+ * own term order; an "Any" variation offers every term.
+ *
+ * @param array $item Store API item response.
+ * @return array
+ */
+ private function build_options( array $item ): array {
+ $variations = (array) $item['variations'];
+ if ( empty( $variations ) ) {
+ return array();
+ }
+
+ $used = array();
+ $any = array();
+ foreach ( $variations as $variation ) {
+ foreach ( (array) $variation['attributes'] as $attribute ) {
+ $name = (string) $attribute['name'];
+ if ( null === $attribute['value'] || '' === (string) $attribute['value'] ) {
+ $any[ $name ] = true;
+ continue;
+ }
+ $used[ $name ][ (string) $attribute['value'] ] = true;
+ }
+ }
+
+ $options = array();
+
+ foreach ( (array) $item['attributes'] as $attribute ) {
+ if ( empty( $attribute['has_variations'] ) ) {
+ continue;
+ }
+
+ $name = (string) $attribute['name'];
+ $values = array();
+
+ foreach ( (array) $attribute['terms'] as $term ) {
+ if ( isset( $any[ $name ] ) || isset( $used[ $name ][ (string) $term['slug'] ] ) ) {
+ $values[] = array( 'label' => (string) $term['name'] );
+ }
+ }
+
+ if ( ! empty( $values ) ) {
+ $options[] = array(
+ 'name' => $name,
+ 'values' => $values,
+ );
+ }
+ }
+
+ return $options;
+ }
+
+ /**
+ * Build media entries from Store API images.
+ *
+ * @param array $images Store API image objects.
+ * @return array
+ */
+ private function build_media( array $images ): array {
+ $media = array();
+
+ foreach ( array_slice( $images, 0, self::MAX_MEDIA_PER_PRODUCT ) as $image ) {
+ $url = $image['src'] ?? '';
+ if ( ! is_string( $url ) || '' === $url ) {
+ continue;
+ }
+
+ $entry = array(
+ 'type' => 'image',
+ 'url' => $url,
+ );
+
+ if ( is_string( $image['alt'] ) && '' !== $image['alt'] ) {
+ $entry['alt_text'] = $image['alt'];
+ }
+
+ $media[] = $entry;
+ }
+
+ return $media;
+ }
+
+ /**
+ * Build product categories from Store API terms.
+ *
+ * @param array $terms Store API category objects.
+ * @return array
+ */
+ private function build_categories( array $terms ): array {
+ $categories = array();
+
+ foreach ( array_slice( $terms, 0, self::MAX_TERMS_PER_PRODUCT ) as $term ) {
+ $categories[] = array(
+ 'id' => (string) $term['id'],
+ 'name' => (string) $term['name'],
+ 'value' => (string) $term['slug'],
+ );
+ }
+
+ return $categories;
+ }
+
+ /**
+ * Build product rating.
+ *
+ * @param array $item Store API item response.
+ * @return array|null
+ */
+ private function build_rating( array $item ) {
+ $average = (float) $item['average_rating'];
+ if ( $average <= 0 ) {
+ return null;
+ }
+
+ $rating = array(
+ 'value' => $average,
+ 'scale_min' => 1,
+ 'scale_max' => 5,
+ );
+
+ $count = (int) $item['review_count'];
+ if ( $count > 0 ) {
+ $rating['count'] = $count;
+ }
+
+ return $rating;
+ }
+
+ /**
+ * Build product tags from Store API terms.
+ *
+ * @param array $terms Store API tag objects.
+ * @return array
+ */
+ private function build_tags( array $terms ): array {
+ $tags = array();
+
+ foreach ( array_slice( $terms, 0, self::MAX_TERMS_PER_PRODUCT ) as $term ) {
+ $tags[] = (string) $term['name'];
+ }
+
+ return $tags;
+ }
+
+ /**
+ * Find the visible variations carrying every selected option value.
+ *
+ * Matches on the parent item's variation attributes, so no variation is loaded
+ * to be rejected. A label matches a term's name or its slug, case-insensitively,
+ * and a variation left as "Any" for an attribute accepts every label, as the
+ * storefront does.
+ *
+ * @param array $item Store API item response of the variable product.
+ * @param array $selected Selected option values, each `{ name, label }`.
+ * @return array Matching variation ids.
+ */
+ private function match_selection( array $item, array $selected ): array {
+ $selection = array();
+ foreach ( $selected as $option ) {
+ if ( isset( $option['name'], $option['label'] ) ) {
+ $selection[ strtolower( (string) $option['name'] ) ] = strtolower( (string) $option['label'] );
+ }
+ }
+
+ if ( empty( $selection ) ) {
+ return array();
+ }
+
+ $names = $this->term_names( $item );
+
+ // A name no variation attribute carries can never match; without this
+ // check the "Any" rule below would accept it on every variation.
+ $known = array_fill_keys( array_map( 'strtolower', array_keys( $names ) ), true );
+ if ( array_diff_key( $selection, $known ) ) {
+ return array();
+ }
+
+ $matching = array();
+
+ foreach ( (array) $item['variations'] as $variation ) {
+ $values = array();
+ foreach ( (array) $variation['attributes'] as $attribute ) {
+ if ( null === $attribute['value'] || '' === (string) $attribute['value'] ) {
+ continue;
+ }
+
+ $name = (string) $attribute['name'];
+ $value = (string) $attribute['value'];
+
+ $values[ strtolower( $name ) ] = array(
+ strtolower( $value ),
+ strtolower( $names[ $name ][ $value ] ?? $value ),
+ );
+ }
+
+ foreach ( $selection as $name => $label ) {
+ if ( isset( $values[ $name ] ) && ! in_array( $label, $values[ $name ], true ) ) {
+ continue 2;
+ }
+ }
+
+ $matching[] = (int) $variation['id'];
+ }
+
+ return $matching;
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCatalogRoute.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCatalogRoute.php
new file mode 100644
index 00000000000..211e8b58a21
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCatalogRoute.php
@@ -0,0 +1,282 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1\Ucp;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Mapping\UcpProductMapper;
+use Automattic\WooCommerce\StoreApi\Routes\V1\AbstractRoute;
+use Automattic\WooCommerce\StoreApi\Schemas\V1\Ucp\UcpProductSchema;
+use Automattic\WooCommerce\StoreApi\Utilities\UcpUtils;
+
+/**
+ * Shared behaviour of the UCP catalog routes.
+ */
+abstract class AbstractCatalogRoute extends AbstractRoute {
+ /**
+ * The routes schema.
+ *
+ * @var string
+ */
+ const SCHEMA_TYPE = 'ucp-catalog';
+
+ /**
+ * Product mapper, holding per-response state.
+ *
+ * @var UcpProductMapper|null
+ */
+ private $mapper;
+
+ /**
+ * The UCP capability this route serves, one of the UcpUtils::CAPABILITY_* constants.
+ *
+ * @return string
+ */
+ abstract protected function get_capability(): string;
+
+ /**
+ * Build the `ucp` envelope for a response from this route.
+ *
+ * @param string $status Response status, `success` or `error`.
+ * @return array
+ */
+ protected function ucp_metadata( string $status ): array {
+ return UcpUtils::response_metadata( $this->get_capability(), $status );
+ }
+
+ /**
+ * Get method arguments for this REST route.
+ *
+ * @return array An array of endpoints.
+ */
+ public function get_args() {
+ return array(
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'get_response' ),
+ 'permission_callback' => '__return_true',
+ 'args' => $this->get_request_args(),
+ ),
+ 'schema' => array( $this->schema, 'get_public_item_schema' ),
+ );
+ }
+
+ /**
+ * Request body arguments accepted by this route.
+ *
+ * @return array
+ */
+ protected function get_request_args(): array {
+ return array(
+ 'context' => array(
+ 'description' => __( 'Provisional buyer signals for relevance and localization.', 'woocommerce' ),
+ 'type' => 'object',
+ ),
+ );
+ }
+
+ /**
+ * Get the product mapper for the response being built.
+ *
+ * @return UcpProductMapper
+ */
+ protected function mapper(): UcpProductMapper {
+ if ( null === $this->mapper ) {
+ /**
+ * The controller returns the registered subclass; typed for static analysis.
+ *
+ * @var UcpProductSchema $schema
+ */
+ $schema = $this->schema_controller->get( UcpProductSchema::IDENTIFIER );
+ $this->mapper = new UcpProductMapper( $this->store_currency(), $schema );
+ }
+
+ return $this->mapper;
+ }
+
+ /**
+ * Get the store's configured currency code.
+ *
+ * @return string
+ */
+ protected function store_currency(): string {
+ return (string) get_woocommerce_currency();
+ }
+
+ /**
+ * Extract the recognized context fields from a request.
+ *
+ * @param \WP_REST_Request $request Request object.
+ *
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return array
+ */
+ protected function get_context( \WP_REST_Request $request ): array {
+ $raw = $request->get_param( 'context' );
+
+ if ( ! is_array( $raw ) ) {
+ return array();
+ }
+
+ $context = array();
+
+ foreach ( array( 'address_country', 'address_region', 'postal_code', 'intent', 'language', 'currency' ) as $key ) {
+ if ( isset( $raw[ $key ] ) && is_scalar( $raw[ $key ] ) && '' !== (string) $raw[ $key ] ) {
+ $context[ $key ] = (string) $raw[ $key ];
+ }
+ }
+
+ return $context;
+ }
+
+ /**
+ * Build a transport-level error response.
+ *
+ * @param string $code Error code.
+ * @param string $message Error message.
+ * @param string $severity Error severity.
+ * @param int $status HTTP status.
+ * @return \WP_REST_Response
+ */
+ protected function ucp_error( string $code, string $message, string $severity, int $status ): \WP_REST_Response {
+ return new \WP_REST_Response(
+ array(
+ 'ucp' => $this->ucp_metadata( 'error' ),
+ 'messages' => array(
+ array(
+ 'type' => 'error',
+ 'code' => $code,
+ 'content' => $message,
+ 'severity' => $severity,
+ 'path' => '$',
+ 'content_type' => 'plain',
+ ),
+ ),
+ ),
+ $status
+ );
+ }
+
+ /**
+ * Resolve a catalog product by product or variation ID.
+ *
+ * The lookup schema requires variant identifiers to be resolvable, so an
+ * identifier naming a variation resolves to its parent product — the variation
+ * is returned as one of that product's variants, not as a self-parented product
+ * of its own. Callers are told which variation was named so they can correlate
+ * it back to the request.
+ *
+ * @param string $identifier Product ID or variation ID.
+ * @return array|null `{ product, variation_id }`, where `variation_id` is 0 when the identifier
+ * named the product itself, or null when nothing catalog-visible matched.
+ */
+ protected function resolve_product( string $identifier ) {
+ $product_id = ctype_digit( $identifier ) ? (int) $identifier : 0;
+ $product = $product_id > 0 ? wc_get_product( $product_id ) : null;
+ $variation_id = 0;
+
+ if ( $product instanceof \WC_Product_Variation ) {
+ $variation_id = (int) $product->get_id();
+ $product = wc_get_product( $product->get_parent_id() );
+ }
+
+ // Catalog visibility as the storefront applies it: published, not hidden
+ // from the catalog, and in stock when the store hides out-of-stock items.
+ if ( ! $product instanceof \WC_Product || ! $product->is_visible() ) {
+ return null;
+ }
+
+ if ( ! $this->mapper()->has_catalog_variants( $product ) ) {
+ return null;
+ }
+
+ // A named variation the storefront would not offer — disabled, or out of
+ // stock while the store hides out-of-stock items — is a miss even when
+ // its parent is otherwise fine.
+ if ( $variation_id > 0 && ( ! $product instanceof \WC_Product_Variable || ! in_array( $variation_id, array_map( 'intval', $product->get_visible_children() ), true ) ) ) {
+ return null;
+ }
+
+ return array(
+ 'product' => $product,
+ 'variation_id' => $variation_id,
+ );
+ }
+
+ /**
+ * Build `messages` entries for products whose variant list was cut short.
+ *
+ * UCP has no variant pagination, so this message is the only disclosure of a
+ * shortened list — without it an agent reports the missing variants as
+ * nonexistent. `path` is the RFC 9535 JSONPath of the affected list.
+ *
+ * @param array $truncations Keyed by product id, from UcpProductMapper::take_truncations().
+ * @param array $products Mapped products, in response order.
+ * @param string $path_prefix JSONPath of the products container.
+ * @return array
+ */
+ protected function truncation_messages( array $truncations, array $products, string $path_prefix ): array {
+ if ( empty( $truncations ) ) {
+ return array();
+ }
+
+ $messages = array();
+
+ foreach ( $products as $index => $product ) {
+ $product_id = isset( $product['id'] ) ? (int) $product['id'] : 0;
+
+ if ( ! isset( $truncations[ $product_id ] ) ) {
+ continue;
+ }
+
+ $truncation = $truncations[ $product_id ];
+
+ $messages[] = array(
+ 'type' => 'info',
+ 'code' => 'variants_truncated',
+ 'content' => sprintf(
+ /* translators: 1: number of variants returned, 2: total published variants, 3: product title. */
+ __( 'Showing %1$d of %2$d variants for "%3$s". Request this product directly, or name a variant by id, to reach the others.', 'woocommerce' ),
+ $truncation['returned'],
+ $truncation['total'],
+ $truncation['title']
+ ),
+ 'path' => '$.product' === $path_prefix ? '$.product.variants' : sprintf( '%s[%d].variants', $path_prefix, $index ),
+ 'content_type' => 'plain',
+ );
+ }
+
+ return $messages;
+ }
+
+ /**
+ * Build a non-fatal warning when the requested context currency differs from the store currency.
+ *
+ * Catalog amounts are always expressed in the store's own currency; the store performs no FX
+ * conversion. When an agent requests a different currency we ignore it for labeling and surface
+ * an informational message so the difference is not silently dropped.
+ *
+ * @param array $context Request context.
+ * @param string $store_currency Store currency code used for labeling.
+ * @return array|null
+ */
+ protected function currency_warning( array $context, string $store_currency ) {
+ if ( ! isset( $context['currency'] ) || 0 === strcasecmp( $context['currency'], $store_currency ) ) {
+ return null;
+ }
+
+ return array(
+ 'type' => 'warning',
+ 'code' => 'currency_not_supported',
+ 'content' => sprintf(
+ /* translators: 1: requested currency code, 2: store currency code. */
+ __( 'This store only transacts in %2$s and does not convert currencies. Prices are returned in %2$s; the requested currency %1$s was ignored.', 'woocommerce' ),
+ $context['currency'],
+ $store_currency
+ ),
+ 'severity' => 'recoverable',
+ 'path' => '$.context.currency',
+ 'content_type' => 'plain',
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogLookup.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogLookup.php
new file mode 100644
index 00000000000..6fe919932bb
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogLookup.php
@@ -0,0 +1,182 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1\Ucp;
+
+use Automattic\WooCommerce\StoreApi\Utilities\UcpUtils;
+
+/**
+ * UCP `POST /catalog/lookup` route.
+ */
+class CatalogLookup extends AbstractCatalogRoute {
+ /**
+ * The route identifier.
+ *
+ * @var string
+ */
+ const IDENTIFIER = 'ucp-catalog-lookup';
+
+ /**
+ * Maximum number of `ids` honoured per lookup request.
+ *
+ * Each id costs a product load plus a full transform on an unauthenticated
+ * endpoint. Unlike category filters, an over-long list is rejected rather than
+ * truncated: silently dropping requested ids would report existing products as
+ * missing.
+ */
+ const MAX_LOOKUP_IDS = 50;
+
+ /**
+ * Get the path of this REST route.
+ *
+ * @return string
+ */
+ public function get_path() {
+ return self::get_path_regex();
+ }
+
+ /**
+ * Get the path of this rest route.
+ *
+ * @return string
+ */
+ public static function get_path_regex() {
+ return '/catalog/lookup';
+ }
+
+ /**
+ * The UCP capability this route serves.
+ *
+ * @return string
+ */
+ protected function get_capability(): string {
+ return UcpUtils::CAPABILITY_CATALOG_LOOKUP;
+ }
+
+ /**
+ * Request body arguments accepted by this route.
+ *
+ * `ids` is deliberately not marked required: a missing list answers with the
+ * UCP `VALIDATION_ERROR` envelope rather than `rest_missing_callback_param`.
+ *
+ * @return array
+ */
+ protected function get_request_args(): array {
+ return array_merge(
+ parent::get_request_args(),
+ array(
+ 'ids' => array(
+ 'description' => __( 'Product or variant identifiers to look up.', 'woocommerce' ),
+ 'type' => 'array',
+ 'items' => array( 'type' => array( 'string', 'integer' ) ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Handle the request.
+ *
+ * @param \WP_REST_Request $request Request object.
+ *
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return \WP_REST_Response
+ */
+ protected function get_route_post_response( \WP_REST_Request $request ) {
+ $raw_ids = $request->get_param( 'ids' );
+
+ if ( ! is_array( $raw_ids ) || empty( $raw_ids ) ) {
+ return $this->ucp_error( 'VALIDATION_ERROR', __( 'ids is required.', 'woocommerce' ), 'recoverable', 400 );
+ }
+
+ // Deduplicate before counting: repeating one id is not a reason to reject,
+ // and each distinct id is what actually costs a product load.
+ $ids = array_values( array_unique( array_map( 'strval', $raw_ids ) ) );
+
+ if ( count( $ids ) > self::MAX_LOOKUP_IDS ) {
+ // `request_too_large` is the code UCP conformance names for a request
+ // over a batch size limit, which is exactly what an over-long id list is.
+ return $this->ucp_error(
+ 'request_too_large',
+ sprintf(
+ /* translators: %d: maximum number of ids accepted per lookup request. */
+ __( 'ids accepts at most %d entries per request.', 'woocommerce' ),
+ self::MAX_LOOKUP_IDS
+ ),
+ 'recoverable',
+ 400
+ );
+ }
+
+ $messages = array();
+
+ $currency_warning = $this->currency_warning( $this->get_context( $request ), $this->store_currency() );
+ if ( null !== $currency_warning ) {
+ $messages[] = $currency_warning;
+ }
+
+ // Several identifiers can resolve to the same product (a product id and one
+ // of its variation ids, say), so group them and emit one entry per product
+ // carrying every identifier that resolved to it.
+ $groups = array();
+
+ foreach ( $ids as $id ) {
+ $resolved = $this->resolve_product( $id );
+
+ if ( null === $resolved ) {
+ $messages[] = array(
+ 'type' => 'info',
+ 'code' => 'not_found',
+ 'content' => sprintf(
+ /* translators: %s: product identifier. */
+ __( 'Product "%s" not found.', 'woocommerce' ),
+ $id
+ ),
+ 'path' => '$.ids',
+ 'content_type' => 'plain',
+ );
+ continue;
+ }
+
+ $product_id = (int) $resolved['product']->get_id();
+
+ if ( ! isset( $groups[ $product_id ] ) ) {
+ $groups[ $product_id ] = array(
+ 'product' => $resolved['product'],
+ 'inputs' => array(),
+ );
+ }
+
+ $groups[ $product_id ]['inputs'][] = array(
+ 'id' => $id,
+ 'variation_id' => $resolved['variation_id'],
+ );
+ }
+
+ // One response spans several mapping calls here, so the per-response state
+ // is started explicitly rather than by the mapper.
+ $this->mapper()->begin_response();
+
+ $products = array();
+ foreach ( $groups as $group ) {
+ $products[] = $this->mapper()->map_lookup_product( $group['product'], $group['inputs'] );
+ }
+
+ $messages = array_merge(
+ $messages,
+ $this->truncation_messages( $this->mapper()->take_truncations(), $products, '$.products' )
+ );
+
+ $response = array(
+ 'ucp' => $this->ucp_metadata( 'success' ),
+ 'products' => $products,
+ );
+
+ if ( ! empty( $messages ) ) {
+ $response['messages'] = $messages;
+ }
+
+ return new \WP_REST_Response( $response, 200 );
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogProduct.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogProduct.php
new file mode 100644
index 00000000000..5e34b8aa43b
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogProduct.php
@@ -0,0 +1,183 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1\Ucp;
+
+use Automattic\WooCommerce\StoreApi\Utilities\UcpUtils;
+
+/**
+ * UCP `POST /catalog/product` route.
+ */
+class CatalogProduct extends AbstractCatalogRoute {
+ /**
+ * The route identifier.
+ *
+ * @var string
+ */
+ const IDENTIFIER = 'ucp-catalog-product';
+
+ /**
+ * Maximum number of `selected` options honoured per product request.
+ *
+ * Every option is compared against every attribute of every variation.
+ */
+ const MAX_SELECTED_OPTIONS = 25;
+
+ /**
+ * Get the path of this REST route.
+ *
+ * @return string
+ */
+ public function get_path() {
+ return self::get_path_regex();
+ }
+
+ /**
+ * Get the path of this rest route.
+ *
+ * @return string
+ */
+ public static function get_path_regex() {
+ return '/catalog/product';
+ }
+
+ /**
+ * The UCP capability this route serves.
+ *
+ * @return string
+ */
+ protected function get_capability(): string {
+ return UcpUtils::CAPABILITY_CATALOG_LOOKUP;
+ }
+
+ /**
+ * Request body arguments accepted by this route.
+ *
+ * `id` is deliberately not marked required: a missing id answers with the UCP
+ * `VALIDATION_ERROR` envelope rather than `rest_missing_callback_param`.
+ *
+ * @return array
+ */
+ protected function get_request_args(): array {
+ return array_merge(
+ parent::get_request_args(),
+ array(
+ 'id' => array(
+ 'description' => __( 'Product or variant identifier.', 'woocommerce' ),
+ 'type' => array( 'string', 'integer' ),
+ ),
+ 'selected' => array(
+ 'description' => __( 'Option selections used to narrow the returned variants.', 'woocommerce' ),
+ 'type' => 'array',
+ ),
+ )
+ );
+ }
+
+ /**
+ * Handle the request.
+ *
+ * @param \WP_REST_Request $request Request object.
+ *
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return \WP_REST_Response
+ */
+ protected function get_route_post_response( \WP_REST_Request $request ) {
+ $id = trim( strval( $request->get_param( 'id' ) ) );
+
+ if ( '' === $id ) {
+ return $this->ucp_error( 'VALIDATION_ERROR', __( 'id is required.', 'woocommerce' ), 'recoverable', 400 );
+ }
+
+ $selected = (array) ( $request->get_param( 'selected' ) ?? array() );
+
+ // Checked before the product is loaded: an over-long selection is refused
+ // without paying for the lookup it would have been applied to.
+ if ( count( $selected ) > self::MAX_SELECTED_OPTIONS ) {
+ return $this->ucp_error(
+ 'request_too_large',
+ sprintf(
+ /* translators: %d: maximum number of selected options accepted per request. */
+ __( 'selected accepts at most %d options per request.', 'woocommerce' ),
+ self::MAX_SELECTED_OPTIONS
+ ),
+ 'recoverable',
+ 400
+ );
+ }
+
+ $resolved = $this->resolve_product( $id );
+
+ if ( null === $resolved ) {
+ // A missing product is reported in the envelope, not as a 404: UCP
+ // clients read `ucp.status` and `messages`, not the HTTP status.
+ return new \WP_REST_Response(
+ array(
+ 'ucp' => $this->ucp_metadata( 'error' ),
+ 'messages' => array(
+ array(
+ 'type' => 'error',
+ 'code' => 'not_found',
+ 'content' => __( 'Product not found.', 'woocommerce' ),
+ 'severity' => 'unrecoverable',
+ 'path' => '$.id',
+ 'content_type' => 'plain',
+ ),
+ ),
+ ),
+ 200
+ );
+ }
+
+ $this->mapper()->begin_response();
+ $product = $this->mapper()->map_detail_product( $resolved['product'], $this->normalize_selected( $selected ), (int) $resolved['variation_id'] );
+
+ $response = array(
+ 'ucp' => $this->ucp_metadata( 'success' ),
+ 'product' => $product,
+ );
+
+ $messages = $this->truncation_messages( $this->mapper()->take_truncations(), array( $product ), '$.product' );
+
+ $currency_warning = $this->currency_warning( $this->get_context( $request ), $this->store_currency() );
+ if ( null !== $currency_warning ) {
+ array_unshift( $messages, $currency_warning );
+ }
+
+ if ( ! empty( $messages ) ) {
+ $response['messages'] = $messages;
+ }
+
+ return new \WP_REST_Response( $response, 200 );
+ }
+
+ /**
+ * Normalize selected options from the request payload.
+ *
+ * @param array $selected Raw selected options.
+ * @return array
+ */
+ private function normalize_selected( array $selected ): array {
+ $normalized = array();
+
+ foreach ( $selected as $option ) {
+ if ( ! is_array( $option ) || ! isset( $option['name'], $option['label'] ) ) {
+ continue;
+ }
+
+ $entry = array(
+ 'name' => strval( $option['name'] ),
+ 'label' => strval( $option['label'] ),
+ );
+
+ if ( isset( $option['id'] ) ) {
+ $entry['id'] = strval( $option['id'] );
+ }
+
+ $normalized[] = $entry;
+ }
+
+ return $normalized;
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogSearch.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogSearch.php
new file mode 100644
index 00000000000..32dabbbbf02
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogSearch.php
@@ -0,0 +1,380 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1\Ucp;
+
+use Automattic\WooCommerce\Enums\CatalogVisibility;
+use Automattic\WooCommerce\StoreApi\Utilities\ProductQuery;
+use Automattic\WooCommerce\StoreApi\Utilities\UcpUtils;
+
+/**
+ * UCP `POST /catalog/search` route.
+ */
+class CatalogSearch extends AbstractCatalogRoute {
+ /**
+ * The route identifier.
+ *
+ * @var string
+ */
+ const IDENTIFIER = 'ucp-catalog-search';
+
+ /**
+ * Maximum number of `filters.categories` references honoured per request.
+ *
+ * Each reference costs a term lookup on an unauthenticated endpoint.
+ */
+ const MAX_CATEGORY_FILTERS = 25;
+
+ /**
+ * Maximum accepted length of the free-text `query`, in bytes.
+ *
+ * The term reaches a `LIKE` scan over product titles and SKUs, so its length
+ * is paid per row examined.
+ */
+ const MAX_QUERY_LENGTH = 256;
+
+ /**
+ * Get the path of this REST route.
+ *
+ * @return string
+ */
+ public function get_path() {
+ return self::get_path_regex();
+ }
+
+ /**
+ * Get the path of this rest route.
+ *
+ * @return string
+ */
+ public static function get_path_regex() {
+ return '/catalog/search';
+ }
+
+ /**
+ * The UCP capability this route serves.
+ *
+ * @return string
+ */
+ protected function get_capability(): string {
+ return UcpUtils::CAPABILITY_CATALOG_SEARCH;
+ }
+
+ /**
+ * Request body arguments accepted by this route.
+ *
+ * Bounds the UCP contract names its own error codes for (query length, price
+ * range) stay in the handler, so the response carries those codes rather than
+ * `rest_invalid_param`.
+ *
+ * @return array
+ */
+ protected function get_request_args(): array {
+ return array_merge(
+ parent::get_request_args(),
+ array(
+ 'query' => array(
+ 'description' => __( 'Free-text search query.', 'woocommerce' ),
+ 'type' => 'string',
+ ),
+ 'filters' => array(
+ 'description' => __( 'Filter criteria to narrow search results.', 'woocommerce' ),
+ 'type' => 'object',
+ ),
+ 'pagination' => array(
+ 'description' => __( 'Requested page size and cursor.', 'woocommerce' ),
+ 'type' => 'object',
+ ),
+ )
+ );
+ }
+
+ /**
+ * Handle the request.
+ *
+ * @param \WP_REST_Request $request Request object.
+ *
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return \WP_REST_Response
+ */
+ protected function get_route_post_response( \WP_REST_Request $request ) {
+ $query = trim( (string) $request->get_param( 'query' ) );
+ $filters = (array) ( $request->get_param( 'filters' ) ?? array() );
+ $pagination = (array) ( $request->get_param( 'pagination' ) ?? array() );
+ $context = $this->get_context( $request );
+
+ $invalid = $this->validate_search_input( $query, $filters );
+ if ( null !== $invalid ) {
+ return $invalid;
+ }
+
+ $limit = isset( $pagination['limit'] ) ? min( max( (int) $pagination['limit'], 1 ), 100 ) : 10;
+ $offset = $this->decode_cursor( isset( $pagination['cursor'] ) ? (string) $pagination['cursor'] : '' );
+
+ $category_slugs = null;
+
+ if ( ! empty( $filters['categories'] ) && is_array( $filters['categories'] ) ) {
+ $category_slugs = $this->resolve_category_slugs( $filters['categories'] );
+ }
+
+ // A category list naming no existing term can match nothing; querying
+ // without the filter would return the whole catalog instead.
+ if ( null !== $category_slugs && empty( $category_slugs ) ) {
+ $results = array(
+ 'objects' => array(),
+ 'total' => 0,
+ );
+ } else {
+ $results = $this->query_products( $this->store_api_params( $query, $filters, $category_slugs, $limit, $offset ) );
+ }
+
+ $mapped = $this->mapper()->map_products( $results['objects'] );
+
+ $pagination_result = array( 'has_next_page' => $offset + $limit < $results['total'] );
+
+ if ( $pagination_result['has_next_page'] ) {
+ $pagination_result['cursor'] = $this->encode_cursor( $offset + $limit );
+ }
+
+ $response = array(
+ 'ucp' => $this->ucp_metadata( 'success' ),
+ 'products' => $mapped,
+ 'pagination' => $pagination_result,
+ );
+
+ $messages = $this->truncation_messages( $this->mapper()->take_truncations(), $mapped, '$.products' );
+
+ $currency_warning = $this->currency_warning( $context, $this->store_currency() );
+ if ( null !== $currency_warning ) {
+ array_unshift( $messages, $currency_warning );
+ }
+
+ if ( ! empty( $messages ) ) {
+ $response['messages'] = $messages;
+ }
+
+ return new \WP_REST_Response( $response, 200 );
+ }
+
+ /**
+ * Validate the search query string and filters before any query runs.
+ *
+ * Values that would silently degrade to something meaningless — a price cast
+ * from a non-numeric string, a range whose minimum exceeds its maximum — are
+ * refused rather than executed, so a caller cannot spend database work on a
+ * request that was never going to match.
+ *
+ * @param string $query Free-text search term.
+ * @param array $filters Request filters.
+ * @return \WP_REST_Response|null Error response, or null when the input is acceptable.
+ */
+ private function validate_search_input( string $query, array $filters ) {
+ if ( strlen( $query ) > self::MAX_QUERY_LENGTH ) {
+ return $this->ucp_error(
+ 'VALIDATION_ERROR',
+ sprintf(
+ /* translators: %d: maximum accepted query length in characters. */
+ __( 'query exceeds the maximum length of %d characters.', 'woocommerce' ),
+ self::MAX_QUERY_LENGTH
+ ),
+ 'recoverable',
+ 400
+ );
+ }
+
+ $has_categories = ! empty( $filters['categories'] ) && is_array( $filters['categories'] );
+ $has_price = isset( $filters['price'] ) && is_array( $filters['price'] ) && ( isset( $filters['price']['min'] ) || isset( $filters['price']['max'] ) );
+
+ // UCP requires at least one recognized input; an unknown filter on its
+ // own would otherwise browse the whole catalog.
+ if ( '' === $query && ! $has_categories && ! $has_price ) {
+ return $this->ucp_error(
+ 'VALIDATION_ERROR',
+ __( 'A query or at least one supported filter (categories, price) is required.', 'woocommerce' ),
+ 'recoverable',
+ 400
+ );
+ }
+
+ if ( ! $has_price ) {
+ return null;
+ }
+
+ $bounds = array();
+
+ foreach ( array( 'min', 'max' ) as $bound ) {
+ if ( ! isset( $filters['price'][ $bound ] ) ) {
+ continue;
+ }
+
+ $value = $filters['price'][ $bound ];
+
+ if ( ! is_numeric( $value ) || (float) $value < 0 ) {
+ return $this->ucp_error(
+ 'VALIDATION_ERROR',
+ sprintf(
+ /* translators: %s: price filter bound name, `min` or `max`. */
+ __( 'filters.price.%s must be a non-negative amount in minor units.', 'woocommerce' ),
+ $bound
+ ),
+ 'recoverable',
+ 400
+ );
+ }
+
+ $bounds[ $bound ] = (int) $value;
+ }
+
+ if ( isset( $bounds['min'], $bounds['max'] ) && $bounds['min'] > $bounds['max'] ) {
+ return $this->ucp_error(
+ 'VALIDATION_ERROR',
+ __( 'filters.price.min must not exceed filters.price.max.', 'woocommerce' ),
+ 'recoverable',
+ 400
+ );
+ }
+
+ return null;
+ }
+
+ /**
+ * Translate the UCP search body into Store API product collection parameters.
+ *
+ * Price bounds pass through in minor units for ProductQuery to convert and tax-adjust.
+ * Like core's other price filters it matches on range overlap: a variable product
+ * qualifies when its min–max span intersects the bounds, even with no variation inside them.
+ *
+ * @param string $query Free-text search term.
+ * @param array $filters Request filters.
+ * @param array|null $category_slugs Resolved category slugs, or null when none were requested.
+ * @param int $limit Page size.
+ * @param int $offset Offset of the first result.
+ * @return array
+ */
+ private function store_api_params( string $query, array $filters, ?array $category_slugs, int $limit, int $offset ): array {
+ $params = array(
+ 'orderby' => 'date',
+ 'order' => 'desc',
+ 'per_page' => $limit,
+ 'offset' => $offset,
+ 'catalog_visibility' => $query ? CatalogVisibility::SEARCH : CatalogVisibility::CATALOG,
+ );
+
+ if ( '' !== $query ) {
+ $params['search'] = $query;
+ }
+
+ if ( ! empty( $category_slugs ) ) {
+ $params['category'] = $category_slugs;
+ }
+
+ if ( isset( $filters['price'] ) && is_array( $filters['price'] ) ) {
+ foreach ( array( 'min', 'max' ) as $bound ) {
+ if ( isset( $filters['price'][ $bound ] ) ) {
+ $params[ $bound . '_price' ] = (int) $filters['price'][ $bound ];
+ }
+ }
+ }
+
+ return $params;
+ }
+
+ /**
+ * Resolve category references to the term slugs ProductQuery filters on.
+ *
+ * ProductQuery types a category list from its first element, so a request
+ * mixing ids and slugs is normalized to slugs here. The list is deduplicated
+ * and capped: one request must not drive an unbounded number of term lookups
+ * on an unauthenticated endpoint.
+ *
+ * @param array $references Category term ids or slugs.
+ * @return array
+ */
+ private function resolve_category_slugs( array $references ): array {
+ $slugs = array();
+
+ foreach ( array_slice( array_unique( $references ), 0, self::MAX_CATEGORY_FILTERS ) as $reference ) {
+ $reference = (string) $reference;
+ $term = ctype_digit( $reference )
+ ? get_term_by( 'id', (int) $reference, 'product_cat' )
+ : get_term_by( 'slug', $reference, 'product_cat' );
+
+ if ( $term instanceof \WP_Term ) {
+ $slugs[] = $term->slug;
+ }
+ }
+
+ return $slugs;
+ }
+
+ /**
+ * Run the Store API product query, recounting when the cursor is past the end.
+ *
+ * `found_posts` is only computed when the page returned rows, and ProductQuery's
+ * own recount keys off `page` rather than `offset`, so an empty page at a non-zero
+ * offset is recounted here with a single-row query from the start.
+ *
+ * @param array $params Store API product collection parameters.
+ * @return array `{ objects, total }`.
+ */
+ private function query_products( array $params ): array {
+ $product_query = new ProductQuery();
+ $results = $product_query->get_objects( $this->store_api_request( $params ) );
+
+ if ( empty( $results['objects'] ) && ! empty( $params['offset'] ) ) {
+ $params['offset'] = 0;
+ $params['per_page'] = 1;
+ $results['total'] = $product_query->get_results( $this->store_api_request( $params ) )['total'];
+ }
+
+ return $results;
+ }
+
+ /**
+ * Wrap collection parameters in the request object ProductQuery reads.
+ *
+ * @param array $params Store API product collection parameters.
+ * @return \WP_REST_Request<array<string, mixed>>
+ */
+ private function store_api_request( array $params ): \WP_REST_Request {
+ /**
+ * Typed for static analysis; WP_REST_Request is generic in the stubs.
+ *
+ * @var \WP_REST_Request<array<string, mixed>> $request
+ */
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/products' );
+ $request->set_query_params( $params );
+
+ return $request;
+ }
+
+ /**
+ * Encode a pagination offset into an opaque cursor string.
+ *
+ * @param int $offset Numeric offset.
+ * @return string
+ */
+ private function encode_cursor( int $offset ): string {
+ return base64_encode( 'offset:' . $offset ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
+ }
+
+ /**
+ * Decode a cursor string back into a numeric offset.
+ *
+ * @param string $cursor Opaque cursor string.
+ * @return int
+ */
+ private function decode_cursor( string $cursor ): int {
+ if ( '' === $cursor ) {
+ return 0;
+ }
+
+ $decoded = base64_decode( $cursor, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
+ if ( false === $decoded || 0 !== strpos( $decoded, 'offset:' ) ) {
+ return 0;
+ }
+
+ return max( 0, (int) substr( $decoded, 7 ) );
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/RoutesController.php b/plugins/woocommerce/src/StoreApi/RoutesController.php
index 9f9eb40af1d..bd3dda1a49e 100644
--- a/plugins/woocommerce/src/StoreApi/RoutesController.php
+++ b/plugins/woocommerce/src/StoreApi/RoutesController.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\StoreApi;
use Automattic\WooCommerce\Internal\ShopperLists\ShopperListsController;
use Automattic\WooCommerce\StoreApi\Routes\V1\AbstractRoute;
+use Automattic\WooCommerce\StoreApi\Utilities\UcpUtils;
/**
* RoutesController class.
@@ -82,6 +83,12 @@ class RoutesController {
Routes\V1\ShopperListItems::IDENTIFIER => Routes\V1\ShopperListItems::class,
Routes\V1\ShopperListItemsByKey::IDENTIFIER => Routes\V1\ShopperListItemsByKey::class,
],
+ 'ucp' => [
+ // Universal Commerce Protocol endpoints, gated by UcpUtils::is_enabled().
+ Routes\V1\Ucp\CatalogSearch::IDENTIFIER => Routes\V1\Ucp\CatalogSearch::class,
+ Routes\V1\Ucp\CatalogLookup::IDENTIFIER => Routes\V1\Ucp\CatalogLookup::class,
+ Routes\V1\Ucp\CatalogProduct::IDENTIFIER => Routes\V1\Ucp\CatalogProduct::class,
+ ],
];
}
@@ -96,6 +103,10 @@ class RoutesController {
if ( wc_get_container()->get( ShopperListsController::class )->is_enabled() ) {
$this->register_routes( 'shopper_lists', self::$api_namespace . '/v1' );
}
+
+ if ( UcpUtils::is_enabled() ) {
+ $this->register_routes( 'ucp', 'wc/ucp/v1' );
+ }
}
/**
diff --git a/plugins/woocommerce/src/StoreApi/SchemaController.php b/plugins/woocommerce/src/StoreApi/SchemaController.php
index 8705a2f15c5..574d85a0a54 100644
--- a/plugins/woocommerce/src/StoreApi/SchemaController.php
+++ b/plugins/woocommerce/src/StoreApi/SchemaController.php
@@ -59,6 +59,8 @@ class SchemaController {
Schemas\V1\PatternsSchema::IDENTIFIER => Schemas\V1\PatternsSchema::class,
Schemas\V1\ShopperListSchema::IDENTIFIER => Schemas\V1\ShopperListSchema::class,
Schemas\V1\ShopperListItemSchema::IDENTIFIER => Schemas\V1\ShopperListItemSchema::class,
+ Schemas\V1\Ucp\UcpCatalogSchema::IDENTIFIER => Schemas\V1\Ucp\UcpCatalogSchema::class,
+ Schemas\V1\Ucp\UcpProductSchema::IDENTIFIER => Schemas\V1\Ucp\UcpProductSchema::class,
],
];
}
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpCatalogSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpCatalogSchema.php
new file mode 100644
index 00000000000..ebd3a734940
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpCatalogSchema.php
@@ -0,0 +1,38 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Schemas\V1\Ucp;
+
+use Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractSchema;
+
+/**
+ * Placeholder schema for the UCP catalog routes.
+ *
+ * The routes answer with the UCP envelope rather than a Store API resource, so
+ * they declare no properties; this exists because every Store API route is
+ * constructed with a schema.
+ */
+class UcpCatalogSchema extends AbstractSchema {
+ /**
+ * The schema item name.
+ *
+ * @var string
+ */
+ protected $title = 'ucp-catalog';
+
+ /**
+ * The schema item identifier.
+ *
+ * @var string
+ */
+ const IDENTIFIER = 'ucp-catalog';
+
+ /**
+ * Schema properties.
+ *
+ * @return array
+ */
+ public function get_properties() {
+ return array();
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpProductSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpProductSchema.php
new file mode 100644
index 00000000000..0c02e8088b2
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpProductSchema.php
@@ -0,0 +1,40 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Schemas\V1\Ucp;
+
+use Automattic\WooCommerce\StoreApi\Schemas\V1\ProductSchema;
+
+/**
+ * Product schema whose item responses carry raw strings, for the UCP catalog routes.
+ *
+ * Agents compare titles and SKUs literally, so the storefront HTML formatter
+ * (texturized quotes, `10x20` to `10×20`) would corrupt what they match on.
+ *
+ * @since 11.3.0
+ */
+class UcpProductSchema extends ProductSchema {
+ /**
+ * The schema item name.
+ *
+ * @var string
+ */
+ protected $title = 'ucp-product';
+
+ /**
+ * The schema item identifier.
+ *
+ * @var string
+ */
+ const IDENTIFIER = 'ucp-product';
+
+ /**
+ * Return content unformatted; the UCP mapper derives plain text itself.
+ *
+ * @param string|array $response Data to format.
+ * @return string|array
+ */
+ protected function prepare_html_response( $response ) {
+ return $response;
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php b/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php
new file mode 100644
index 00000000000..605715d9770
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php
@@ -0,0 +1,83 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Utilities;
+
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+
+/**
+ * Shared helpers for the UCP (Universal Commerce Protocol) routes.
+ */
+class UcpUtils {
+ /**
+ * UCP specification version these routes implement.
+ */
+ const VERSION = '2026-04-08';
+
+ /**
+ * Feature id gating the UCP routes, stored as `woocommerce_feature_ucp_enabled`.
+ */
+ const FEATURE_ID = 'ucp';
+
+ /**
+ * Capability served by `POST /catalog/search`.
+ */
+ const CAPABILITY_CATALOG_SEARCH = 'dev.ucp.shopping.catalog.search';
+
+ /**
+ * Capability served by `POST /catalog/lookup` and `POST /catalog/product`.
+ */
+ const CAPABILITY_CATALOG_LOOKUP = 'dev.ucp.shopping.catalog.lookup';
+
+ /**
+ * Whether the UCP routes are registered.
+ *
+ * @return bool
+ */
+ public static function is_enabled(): bool {
+ return FeaturesUtil::feature_is_enabled( self::FEATURE_ID );
+ }
+
+ /**
+ * Build the `ucp` envelope every UCP response carries.
+ *
+ * UCP requires `capabilities` to name only the capability relevant to the
+ * operation being answered, so each response declares exactly one.
+ *
+ * @param string $capability One of the CAPABILITY_* constants.
+ * @param string $status Response status, `success` or `error`.
+ * @return array
+ */
+ public static function response_metadata( string $capability, string $status ): array {
+ return array(
+ 'version' => self::VERSION,
+ 'status' => $status,
+ 'capabilities' => array(
+ $capability => array( self::capability_declaration( $capability ) ),
+ ),
+ );
+ }
+
+ /**
+ * Spec and schema references for a capability.
+ *
+ * @param string $capability One of the CAPABILITY_* constants.
+ * @return array
+ */
+ private static function capability_declaration( string $capability ): array {
+ $declarations = array(
+ self::CAPABILITY_CATALOG_SEARCH => array(
+ 'version' => self::VERSION,
+ 'spec' => 'https://ucp.dev/' . self::VERSION . '/specification/catalog/search',
+ 'schema' => 'https://ucp.dev/' . self::VERSION . '/schemas/shopping/catalog_search.json',
+ ),
+ self::CAPABILITY_CATALOG_LOOKUP => array(
+ 'version' => self::VERSION,
+ 'spec' => 'https://ucp.dev/' . self::VERSION . '/specification/catalog/lookup',
+ 'schema' => 'https://ucp.dev/' . self::VERSION . '/schemas/shopping/catalog_lookup.json',
+ ),
+ );
+
+ return $declarations[ $capability ] ?? array( 'version' => self::VERSION );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCatalog.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCatalog.php
new file mode 100644
index 00000000000..c1a3f2eb58e
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCatalog.php
@@ -0,0 +1,354 @@
+<?php
+/**
+ * UCP Catalog Route Tests.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes;
+
+/**
+ * Tests for the /wc/ucp/v1/catalog/* endpoints.
+ */
+class UcpCatalog extends ControllerTestCase {
+ /**
+ * Published product IDs shared by the class, oldest first.
+ *
+ * @var int[]
+ */
+ private static $product_ids = array();
+
+ /**
+ * SKU of the newest fixture product.
+ *
+ * @var string
+ */
+ private const SKU = 'ucp-catalog-sku';
+
+ /**
+ * Create immutable fixtures before route registration starts.
+ *
+ * @param \WP_UnitTest_Factory $factory WordPress unit test factory.
+ */
+ public static function wpSetUpBeforeClass( $factory ): void {
+ $products = self::create_class_fixture_products(
+ array(
+ array(
+ 'name' => 'UCP Older Product',
+ 'regular_price' => '5.00',
+ 'price' => '5.00',
+ ),
+ array(
+ 'name' => 'UCP Nebula Mug',
+ 'regular_price' => '19.99',
+ 'sale_price' => '14.50',
+ 'price' => '14.50',
+ 'sku' => self::SKU,
+ ),
+ )
+ );
+
+ self::$product_ids = array_map(
+ static function ( $product ) {
+ return $product->get_id();
+ },
+ $products
+ );
+ }
+
+ /**
+ * Delete class fixtures.
+ */
+ public static function wpTearDownAfterClass(): void {
+ self::delete_class_fixture_products( self::$product_ids );
+ }
+
+ /**
+ * Option behind the `ucp` feature.
+ */
+ private const FEATURE_OPTION = 'woocommerce_feature_ucp_enabled';
+
+ /**
+ * Enable the UCP routes before the REST server is built.
+ */
+ protected function setUp(): void {
+ update_option( self::FEATURE_OPTION, 'yes' );
+ parent::setUp();
+ }
+
+ /**
+ * Restore the default disabled state.
+ */
+ protected function tearDown(): void {
+ delete_option( self::FEATURE_OPTION );
+ parent::tearDown();
+ }
+
+ /**
+ * Dispatch a POST request with a JSON body.
+ *
+ * @param string $path Route path under /wc/ucp/v1.
+ * @param array $body Request body.
+ * @return \WP_REST_Response
+ */
+ private function post( string $path, array $body = array() ) {
+ $request = new \WP_REST_Request( 'POST', '/wc/ucp/v1' . $path );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body( wp_json_encode( $body ) );
+
+ return rest_get_server()->dispatch( $request );
+ }
+
+ /**
+ * The routes must not exist unless a site opts in.
+ */
+ public function test_routes_are_not_registered_by_default(): void {
+ delete_option( self::FEATURE_OPTION );
+ $this->initialize_store_api_server();
+
+ foreach ( array( '/catalog/search', '/catalog/lookup', '/catalog/product' ) as $path ) {
+ $this->assertSame( 404, $this->post( $path )->get_status(), $path . ' should not be registered' );
+ }
+ }
+
+ /**
+ * Search returns published products in the UCP shape, with amounts in minor units.
+ */
+ public function test_search_returns_published_products_in_minor_units(): void {
+ $response = $this->post( '/catalog/search', array( 'query' => 'Nebula Mug' ) );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( '2026-04-08', $data['ucp']['version'] );
+ $this->assertSame( 'success', $data['ucp']['status'] );
+ // The envelope names only the capability relevant to the operation answered.
+ $this->assertSame( array( 'dev.ucp.shopping.catalog.search' ), array_keys( $data['ucp']['capabilities'] ) );
+ $this->assertArrayHasKey( 'has_next_page', $data['pagination'] );
+
+ $product = $this->find_product( $data['products'], end( self::$product_ids ) );
+
+ $this->assertSame( 'UCP Nebula Mug', $product['title'] );
+ $this->assertSame( 1450, $product['price_range']['min']['amount'] );
+ $this->assertSame( get_woocommerce_currency(), $product['price_range']['min']['currency'] );
+ // The regular price survives as the strike-through comparison.
+ $this->assertSame( 1999, $product['list_price_range']['min']['amount'] );
+
+ $this->assertCount( 1, $product['variants'] );
+ $this->assertSame( self::SKU, $product['variants'][0]['sku'] );
+ $this->assertSame( 1450, $product['variants'][0]['price']['amount'] );
+ $this->assertTrue( $product['variants'][0]['availability']['available'] );
+ }
+
+ /**
+ * Price bounds are taken in minor units and applied against the stored price.
+ */
+ public function test_search_price_filter_takes_minor_units(): void {
+ list( $older, $newest ) = self::$product_ids;
+
+ // Both fixtures carry the "UCP" prefix, so the query scopes the assertions to them.
+ $above = $this->post(
+ '/catalog/search',
+ array(
+ 'query' => 'UCP',
+ 'filters' => array( 'price' => array( 'min' => 1000 ) ),
+ )
+ )->get_data();
+ $this->assertSame( array( (string) $newest ), wp_list_pluck( $above['products'], 'id' ) );
+
+ $below = $this->post(
+ '/catalog/search',
+ array(
+ 'query' => 'UCP',
+ 'filters' => array( 'price' => array( 'max' => 1000 ) ),
+ )
+ )->get_data();
+ $this->assertSame( array( (string) $older ), wp_list_pluck( $below['products'], 'id' ) );
+ }
+
+ /**
+ * Lookup resolves product IDs only; a SKU is reported as a miss like any other unknown id.
+ */
+ public function test_lookup_resolves_ids_and_reports_misses(): void {
+ $newest = end( self::$product_ids );
+ $response = $this->post( '/catalog/lookup', array( 'ids' => array( (string) $newest, self::SKU ) ) );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+
+ $this->assertCount( 1, $data['products'] );
+ $this->assertSame( (string) $newest, $data['products'][0]['id'] );
+ $this->assertSame( array( (string) $newest ), wp_list_pluck( $data['products'][0]['variants'][0]['inputs'], 'id' ) );
+
+ $this->assertSame( 'not_found', $data['messages'][0]['code'] );
+ $this->assertSame( '$.ids', $data['messages'][0]['path'] );
+ }
+
+ /**
+ * Lookup follows the storefront's hide-out-of-stock setting.
+ */
+ public function test_lookup_hides_out_of_stock_products_when_the_store_does(): void {
+ $product = \WC_Helper_Product::create_simple_product( true, array( 'stock_status' => 'outofstock' ) );
+ $body = array( 'ids' => array( (string) $product->get_id() ) );
+
+ $data = $this->post( '/catalog/lookup', $body )->get_data();
+ $this->assertFalse( $data['products'][0]['variants'][0]['availability']['available'] );
+
+ update_option( 'woocommerce_hide_out_of_stock_items', 'yes' );
+ $data = $this->post( '/catalog/lookup', $body )->get_data();
+ update_option( 'woocommerce_hide_out_of_stock_items', 'no' );
+
+ $this->assertSame( array(), $data['products'] );
+ $this->assertSame( 'not_found', $data['messages'][0]['code'] );
+ }
+
+ /**
+ * A category list resolving to no existing term matches nothing rather than everything.
+ */
+ public function test_search_unknown_category_returns_no_products(): void {
+ $newest = end( self::$product_ids );
+ $term = wp_insert_term( 'UCP Mugs', 'product_cat', array( 'slug' => 'ucp-mugs' ) );
+ wp_set_object_terms( $newest, (int) $term['term_id'], 'product_cat' );
+
+ $data = $this->post( '/catalog/search', array( 'filters' => array( 'categories' => array( 'ucp-mugs' ) ) ) )->get_data();
+ $this->assertSame( array( (string) $newest ), wp_list_pluck( $data['products'], 'id' ) );
+
+ $data = $this->post( '/catalog/search', array( 'filters' => array( 'categories' => array( 'does-not-exist' ) ) ) )->get_data();
+ $this->assertSame( array(), $data['products'] );
+ $this->assertFalse( $data['pagination']['has_next_page'] );
+ }
+
+ /**
+ * Search needs a query or a supported filter; an unrecognized filter alone does not count.
+ */
+ public function test_search_without_recognized_input_returns_ucp_validation_error(): void {
+ $response = $this->post( '/catalog/search', array( 'filters' => array( 'brand' => 'acme' ) ) );
+ $data = $response->get_data();
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'error', $data['ucp']['status'] );
+ $this->assertSame( 'VALIDATION_ERROR', $data['messages'][0]['code'] );
+ }
+
+ /**
+ * A missing `ids` list is a UCP validation error, not a WordPress one.
+ */
+ public function test_lookup_without_ids_returns_ucp_validation_error(): void {
+ $response = $this->post( '/catalog/lookup' );
+ $data = $response->get_data();
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'error', $data['ucp']['status'] );
+ $this->assertSame( 'VALIDATION_ERROR', $data['messages'][0]['code'] );
+ }
+
+ /**
+ * Product detail answers with `ucp.status`, and reports a miss as 200 + `not_found`.
+ */
+ public function test_product_detail_reports_status_in_the_envelope(): void {
+ $newest = end( self::$product_ids );
+ $response = $this->post( '/catalog/product', array( 'id' => (string) $newest ) );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'success', $data['ucp']['status'] );
+ $this->assertSame( array( 'dev.ucp.shopping.catalog.lookup' ), array_keys( $data['ucp']['capabilities'] ) );
+ $this->assertSame( (string) $newest, $data['product']['id'] );
+ $this->assertSame( 1450, $data['product']['variants'][0]['price']['amount'] );
+
+ $missing = $this->post( '/catalog/product', array( 'id' => '99999999' ) );
+ $data = $missing->get_data();
+
+ $this->assertSame( 200, $missing->get_status(), 'A missing product is reported in the envelope, not as a 404' );
+ $this->assertSame( 'error', $data['ucp']['status'] );
+ $this->assertSame( 'not_found', $data['messages'][0]['code'] );
+ $this->assertSame( 'unrecoverable', $data['messages'][0]['severity'] );
+ }
+
+ /**
+ * A variant id puts that variation first, takes its options as the selection,
+ * and overrides a conflicting request selection.
+ */
+ public function test_product_detail_by_variation_id_leads_with_that_variation(): void {
+ $children = \WC_Helper_Product::create_variation_product()->get_children();
+ // The helper's last variation: size huge, colour blue, number left as "Any".
+ $variation_id = (string) end( $children );
+
+ $data = $this->post(
+ '/catalog/product',
+ array(
+ 'id' => $variation_id,
+ 'selected' => array(
+ array(
+ 'name' => 'colour',
+ 'label' => 'red',
+ ),
+ ),
+ )
+ )->get_data();
+
+ $this->assertSame( $variation_id, $data['product']['variants'][0]['id'] );
+ $this->assertSame(
+ array(
+ array(
+ 'name' => 'size',
+ 'label' => 'huge',
+ ),
+ array(
+ 'name' => 'colour',
+ 'label' => 'blue',
+ ),
+ ),
+ $data['product']['selected']
+ );
+ // The huge/blue variation with number 2 matches the same selection; nothing else does.
+ $this->assertCount( 2, $data['product']['variants'] );
+ }
+
+ /**
+ * The opaque cursor walks forward through the result set.
+ */
+ public function test_search_cursor_advances_to_the_next_page(): void {
+ $first = $this->post(
+ '/catalog/search',
+ array(
+ 'query' => 'UCP',
+ 'pagination' => array( 'limit' => 1 ),
+ )
+ )->get_data();
+
+ $this->assertCount( 1, $first['products'] );
+ $this->assertTrue( $first['pagination']['has_next_page'] );
+ $this->assertNotEmpty( $first['pagination']['cursor'] );
+
+ $second = $this->post(
+ '/catalog/search',
+ array(
+ 'query' => 'UCP',
+ 'pagination' => array(
+ 'limit' => 1,
+ 'cursor' => $first['pagination']['cursor'],
+ ),
+ )
+ )->get_data();
+
+ $this->assertCount( 1, $second['products'] );
+ $this->assertNotSame( $first['products'][0]['id'], $second['products'][0]['id'] );
+ }
+
+ /**
+ * Find a mapped product by id.
+ *
+ * @param array $products Mapped products.
+ * @param int $product_id Product id to find.
+ * @return array
+ */
+ private function find_product( array $products, int $product_id ): array {
+ foreach ( $products as $product ) {
+ if ( (string) $product_id === $product['id'] ) {
+ return $product;
+ }
+ }
+
+ $this->fail( 'Product ' . $product_id . ' was not returned by the search' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Mapping/UcpProductMapperTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Mapping/UcpProductMapperTest.php
new file mode 100644
index 00000000000..9c64fce1939
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Mapping/UcpProductMapperTest.php
@@ -0,0 +1,325 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductFeed\Mapping;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Mapping\ProductShapeMapperInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Mapping\UcpProductMapper;
+use WC_Helper_Product;
+
+/**
+ * Tests for the UCP product shape mapper.
+ */
+class UcpProductMapperTest extends \WC_Unit_Test_Case {
+ /**
+ * @testdox The mapper should be consumable through the delivery-agnostic shape contract.
+ */
+ public function test_is_a_product_shape_mapper(): void {
+ $this->assertInstanceOf( ProductShapeMapperInterface::class, new UcpProductMapper() );
+ }
+
+ /**
+ * @testdox Mapping a simple product should express every amount as integer minor units.
+ */
+ public function test_maps_amounts_to_integer_minor_units(): void {
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'regular_price' => '19.99',
+ 'sale_price' => '14.50',
+ 'price' => '14.50',
+ )
+ );
+
+ $mapped = ( new UcpProductMapper( 'USD' ) )->map_product( $product );
+
+ $this->assertSame( 1450, $mapped['price_range']['min']['amount'] );
+ $this->assertSame( 'USD', $mapped['price_range']['min']['currency'] );
+ $this->assertSame( 1999, $mapped['list_price_range']['max']['amount'] );
+ $this->assertSame( 1450, $mapped['variants'][0]['price']['amount'] );
+ }
+
+ /**
+ * A variable product with no enabled variation cannot satisfy the catalog schema,
+ * which requires at least one variant, so it must be reported as ineligible.
+ *
+ * @testdox A variable product with no enabled variation should not be catalog-eligible.
+ */
+ public function test_variable_product_without_enabled_variations_is_ineligible(): void {
+ $mapper = new UcpProductMapper( 'USD' );
+ $variable = WC_Helper_Product::create_variation_product();
+
+ $this->assertTrue( $mapper->has_catalog_variants( $variable ) );
+
+ // Disabling a variation sets it private; saving through the CRUD layer
+ // invalidates the parent's cached children list.
+ foreach ( $variable->get_children() as $child_id ) {
+ $variation = wc_get_product( $child_id );
+ $variation->set_status( 'private' );
+ $variation->save();
+ }
+
+ $variable = wc_get_product( $variable->get_id() );
+
+ $this->assertFalse( $mapper->has_catalog_variants( $variable ) );
+ $this->assertSame( array(), $mapper->map_products( array( $variable ) ) );
+ }
+
+ /**
+ * A sale price outside its scheduled window is not the effective price, so
+ * advertising it as a list price would render a strikethrough equal to the price.
+ *
+ * @testdox A simple product whose sale window has passed should carry no list price.
+ */
+ public function test_expired_sale_yields_no_list_price(): void {
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'regular_price' => '72.22',
+ 'sale_price' => '45.00',
+ 'date_on_sale_from' => time() - 20 * DAY_IN_SECONDS,
+ 'date_on_sale_to' => time() - 10 * DAY_IN_SECONDS,
+ )
+ );
+
+ $mapped = ( new UcpProductMapper( 'USD' ) )->map_product( $product );
+
+ $this->assertSame( 7222, $mapped['price_range']['min']['amount'] );
+ $this->assertArrayNotHasKey( 'list_price_range', $mapped );
+ $this->assertArrayNotHasKey( 'list_price', $mapped['variants'][0] );
+ }
+
+ /**
+ * `get_variation_prices()` rewrites `sale_price` to the regular price when a sale
+ * is not active, so only the effective `price` distinguishes a real discount.
+ *
+ * @testdox A variable product should carry list prices only for variations on sale now.
+ */
+ public function test_variable_product_list_prices_follow_active_sales_only(): void {
+ $variable = new \WC_Product_Variable();
+ $variable->set_name( 'Variable Sale Windows' );
+ $variable->set_status( 'publish' );
+ $variable->set_attributes( array( WC_Helper_Product::create_product_attribute_object( 'size', array( 'small', 'medium', 'large' ) ) ) );
+ $parent_id = $variable->save();
+
+ // Active sale, sale that has expired, and sale that has not started.
+ $windows = array(
+ 'small' => array( '100', '80', null, null ),
+ 'medium' => array( '200', '150', time() - 20 * DAY_IN_SECONDS, time() - 10 * DAY_IN_SECONDS ),
+ 'large' => array( '300', '250', time() + 10 * DAY_IN_SECONDS, time() + 20 * DAY_IN_SECONDS ),
+ );
+
+ foreach ( $windows as $size => $window ) {
+ $variation = new \WC_Product_Variation();
+ $variation->set_parent_id( $parent_id );
+ $variation->set_attributes( array( 'pa_size' => $size ) );
+ $variation->set_regular_price( $window[0] );
+ $variation->set_sale_price( $window[1] );
+ $variation->set_date_on_sale_from( $window[2] );
+ $variation->set_date_on_sale_to( $window[3] );
+ $variation->set_status( 'publish' );
+ $variation->save();
+ }
+
+ \WC_Product_Variable::sync( $parent_id );
+ wc_delete_product_transients( $parent_id );
+
+ $mapped = ( new UcpProductMapper( 'USD' ) )->map_product( wc_get_product( $parent_id ) );
+
+ $this->assertSame( 8000, $mapped['price_range']['min']['amount'] );
+ $this->assertSame( 30000, $mapped['price_range']['max']['amount'] );
+
+ // The range spans every regular price, because one variation is genuinely discounted.
+ $this->assertSame( 10000, $mapped['list_price_range']['min']['amount'] );
+ $this->assertSame( 30000, $mapped['list_price_range']['max']['amount'] );
+
+ $list_prices = array();
+ foreach ( $mapped['variants'] as $variant ) {
+ $list_prices[ $variant['price']['amount'] ] = $variant['list_price']['amount'] ?? null;
+ }
+
+ $this->assertSame( 10000, $list_prices[8000] );
+ $this->assertNull( $list_prices[20000] );
+ $this->assertNull( $list_prices[30000] );
+ }
+
+ /**
+ * `description.plain` is declared plain text, so entities left in it would
+ * reach a shopper literally as ` ` or `&`.
+ *
+ * @testdox A plain description should be decoded and free of HTML entities.
+ */
+ public function test_description_decodes_html_entities(): void {
+ $product = WC_Helper_Product::create_simple_product( false );
+ $product->set_short_description( 'Rich & smooth — 100 % arabica <strong>bold</strong>' );
+ $product->save();
+
+ $mapped = ( new UcpProductMapper( 'USD' ) )->map_product( $product );
+
+ $this->assertSame( "Rich & smooth \u{2014} 100 % arabica bold", $mapped['description']['plain'] );
+ $this->assertStringNotContainsString( "\u{A0}", $mapped['description']['plain'] );
+ }
+
+ /**
+ * @testdox A description of only non-breaking spaces should map to an empty string.
+ */
+ public function test_description_of_entities_only_is_trimmed_to_empty(): void {
+ $product = WC_Helper_Product::create_simple_product( false );
+ $product->set_short_description( " \r\n\r\n " );
+ $product->save();
+
+ $mapped = ( new UcpProductMapper( 'USD' ) )->map_product( $product );
+
+ $this->assertSame( '', $mapped['description']['plain'] );
+ }
+
+ /**
+ * UCP requires `selected` to describe the variants returned, so an
+ * unmatched selection is relaxed from its last option rather than echoed.
+ *
+ * @testdox A selection matching no variation should be relaxed and reported as relaxed.
+ */
+ public function test_detail_selection_is_relaxed_until_a_variation_matches(): void {
+ $variable = new \WC_Product_Variable();
+ $variable->set_name( 'Relaxed Selection' );
+ $variable->set_status( 'publish' );
+ $variable->set_attributes( array( WC_Helper_Product::create_product_attribute_object( 'size', array( 'small', 'large' ) ) ) );
+ $parent_id = $variable->save();
+
+ foreach ( array( 'small', 'large' ) as $size ) {
+ $variation = new \WC_Product_Variation();
+ $variation->set_parent_id( $parent_id );
+ $variation->set_attributes( array( 'pa_size' => $size ) );
+ $variation->set_regular_price( '10' );
+ $variation->set_status( 'publish' );
+ $variation->save();
+ }
+
+ \WC_Product_Variable::sync( $parent_id );
+ $mapper = new UcpProductMapper( 'USD' );
+ $product = wc_get_product( $parent_id );
+
+ $size = array(
+ 'name' => 'size',
+ 'label' => 'large',
+ );
+ $color = array(
+ 'name' => 'color',
+ 'label' => 'red',
+ );
+
+ $mapped = $mapper->map_detail_product( $product, array( $size, $color ) );
+ $this->assertSame( array( $size ), $mapped['selected'] );
+ $this->assertCount( 1, $mapped['variants'] );
+
+ $mapped = $mapper->map_detail_product(
+ $product,
+ array(
+ array(
+ 'name' => 'size',
+ 'label' => 'medium',
+ ),
+ )
+ );
+ $this->assertSame( array(), $mapped['selected'] );
+ $this->assertCount( 2, $mapped['variants'] );
+ }
+
+ /**
+ * The storefront treats an "Any" variation attribute as a wildcard and
+ * `options` advertises every term for it, so a selection must match it too.
+ *
+ * @testdox A variation left as "Any" for an attribute should match every selection of it.
+ */
+ public function test_detail_selection_matches_any_variations(): void {
+ $variable = new \WC_Product_Variable();
+ $variable->set_name( 'Any Color' );
+ $variable->set_status( 'publish' );
+ $variable->set_attributes(
+ array(
+ WC_Helper_Product::create_product_attribute_object( 'size', array( 'small', 'large' ) ),
+ WC_Helper_Product::create_product_attribute_object( 'color', array( 'red', 'blue' ) ),
+ )
+ );
+ $parent_id = $variable->save();
+
+ foreach ( array( 'small', 'large' ) as $size ) {
+ $variation = new \WC_Product_Variation();
+ $variation->set_parent_id( $parent_id );
+ $variation->set_attributes(
+ array(
+ 'pa_size' => $size,
+ 'pa_color' => '',
+ )
+ );
+ $variation->set_regular_price( '10' );
+ $variation->set_status( 'publish' );
+ $variation->save();
+ }
+
+ \WC_Product_Variable::sync( $parent_id );
+
+ $selected = array(
+ array(
+ 'name' => 'color',
+ 'label' => 'red',
+ ),
+ array(
+ 'name' => 'size',
+ 'label' => 'large',
+ ),
+ );
+ $mapped = ( new UcpProductMapper( 'USD' ) )->map_detail_product( wc_get_product( $parent_id ), $selected );
+
+ $this->assertSame( $selected, $mapped['selected'] );
+ $this->assertCount( 1, $mapped['variants'] );
+ $this->assertCount( 2, $mapped['options'][1]['values'] );
+ }
+
+ /**
+ * Agents compare titles, SKUs and option labels literally, so the storefront
+ * HTML formatter must not reach them and both option lists must agree.
+ *
+ * @testdox Mapped strings should be raw and option labels should be term names on both lists.
+ */
+ public function test_strings_are_raw_and_option_labels_are_term_names(): void {
+ $variable = new \WC_Product_Variable();
+ $variable->set_name( 'Frame 10x20 "Classic"' );
+ $variable->set_status( 'publish' );
+ $variable->set_attributes( array( WC_Helper_Product::create_product_attribute_object( 'size', array( 'Extra Large' ) ) ) );
+ $parent_id = $variable->save();
+
+ $variation = new \WC_Product_Variation();
+ $variation->set_parent_id( $parent_id );
+ $variation->set_attributes( array( 'pa_size' => 'extra-large' ) );
+ $variation->set_regular_price( '10' );
+ $variation->set_sku( 'FRAME-10x20' );
+ $variation->set_status( 'publish' );
+ $variation->save();
+
+ \WC_Product_Variable::sync( $parent_id );
+
+ $mapped = ( new UcpProductMapper( 'USD' ) )->map_product( wc_get_product( $parent_id ) );
+
+ $this->assertSame( 'Frame 10x20 "Classic"', $mapped['title'] );
+ $this->assertSame( 'FRAME-10x20', $mapped['variants'][0]['sku'] );
+ $this->assertSame(
+ array(
+ array(
+ 'name' => 'size',
+ 'values' => array( array( 'label' => 'Extra Large' ) ),
+ ),
+ ),
+ $mapped['options']
+ );
+ $this->assertSame(
+ array(
+ array(
+ 'name' => 'size',
+ 'label' => 'Extra Large',
+ ),
+ ),
+ $mapped['variants'][0]['options']
+ );
+ }
+}