Commit 548f6edbae4 for woocommerce

commit 548f6edbae499d5cd1032e63212ddc3749a6b66a
Author: Christopher Hertel <mail@christopher-hertel.de>
Date:   Tue Nov 3 18:48:15 2026 +0100

    Return terms, images and shipping fields from the products-query ability (#70412)

    * Return terms, images and shipping fields from the products-query ability

    products-query returned no categories, tags or brands and could not
    filter by them, and it left out the description, images and the
    shipping and tax fields.

    Each product now carries categories, tags and brands, the descriptions,
    images, weight, dimensions, shipping and tax fields, backorders,
    low_stock_amount, on_sale, shipping_required, shipping_class_id and
    parent_id, named as in the wc/v3 products response. New category, tag
    and brand filters take term IDs, match child terms and combine with
    extension filters with AND.

    product-create and product-update format the product as reloaded after
    the save, so a product created without categories shows the default
    category it was saved with.

    Term names come back decoded in products-query and product-terms-query,
    and product-terms-query encodes search the way WordPress stores term
    names, so a decoded name still finds its term.

    * Drop parent_id and return plain image titles

    The abilities refuse variations, so parent_id was always 0. It ships
    with variation support instead.

    get_the_title() runs the the_title filters, so "Bob's mug" came back as
    "Bob&#8217;s mug" while term names come back decoded. Both abilities now
    return the stored title.

diff --git a/plugins/woocommerce/changelog/products-query-catalog-fields b/plugins/woocommerce/changelog/products-query-catalog-fields
new file mode 100644
index 00000000000..d2ddf5ad34d
--- /dev/null
+++ b/plugins/woocommerce/changelog/products-query-catalog-fields
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Show categories, tags, brands, images, shipping and tax details in the products query ability, and filter products by category, tag or brand.
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
index 70adad37292..b734816355e 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
@@ -101,6 +101,10 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
 		}
 		$args['number'] = $per_page;
 		$args['offset'] = $input['offset'] ?? ( $page - 1 ) * $per_page;
+		if ( isset( $args['search'] ) && is_string( $args['search'] ) ) {
+			// Term names are stored with &, < and > encoded, so the search has to match that.
+			$args['search'] = _wp_specialchars( $args['search'] );
+		}
 		if ( ! empty( $input['product'] ) ) {
 			$args['object_ids'] = array( $input['product'] );
 		}
@@ -143,7 +147,7 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
 	private static function format_term( \WP_Term $term ): array {
 		$output = array(
 			'id'          => $term->term_id,
-			'name'        => $term->name,
+			'name'        => html_entity_decode( $term->name, ENT_QUOTES | ENT_HTML5 ),
 			'slug'        => $term->slug,
 			'description' => $term->description,
 			'count'       => $term->count,
@@ -160,7 +164,7 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
 				$output['image'] = array(
 					'id'   => $image->ID,
 					'src'  => $image_url,
-					'name' => get_the_title( $image ),
+					'name' => $image->post_title,
 					'alt'  => get_post_meta( $image->ID, '_wp_attachment_image_alt', true ),
 				);
 				foreach ( array(
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
index 67e6fffa67d..21d0587b568 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
@@ -22,6 +22,15 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {

 	use ProductAbilityTrait;

+	/**
+	 * Taxonomies of the term filters, keyed by input property.
+	 */
+	private const TERM_FILTERS = array(
+		'category' => 'product_cat',
+		'tag'      => 'product_tag',
+		'brand'    => 'product_brand',
+	);
+
 	/**
 	 * Get the ability name.
 	 *
@@ -141,12 +150,27 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
 			return $extension_args;
 		}

+		foreach ( self::TERM_FILTERS as $field => $taxonomy ) {
+			if ( ! empty( $input[ $field ] ) && is_array( $input[ $field ] ) ) {
+				$extension_args['tax_query'][] = array(
+					'taxonomy' => $taxonomy,
+					'field'    => 'term_id',
+					'terms'    => array_map( 'absint', $input[ $field ] ),
+				);
+			}
+		}
+
 		$args[ AbilitiesLoader::EXTENSION_QUERY_VAR ] = $extension_args;

 		$results  = wc_get_products( $args );
 		$products = is_object( $results ) && isset( $results->products ) ? $results->products : array();
 		$pages    = is_object( $results ) && isset( $results->max_num_pages ) ? (int) $results->max_num_pages : ( count( $products ) > 0 ? 1 : 0 );

+		$image_ids = array_unique( array_merge( array(), ...array_map( array( __CLASS__, 'get_product_image_ids' ), $products ) ) );
+		if ( ! empty( $image_ids ) ) {
+			_prime_post_caches( $image_ids );
+		}
+
 		return array(
 			'products'    => array_map(
 				static function ( $product ) {
@@ -209,6 +233,9 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
 					'type' => 'string',
 					'enum' => array_keys( wc_get_product_stock_status_options() ),
 				),
+				'category'     => self::get_term_filter_input_schema( __( 'Only products in any of these product category IDs, including their child categories.', 'woocommerce' ) ),
+				'tag'          => self::get_term_filter_input_schema( __( 'Only products with any of these product tag IDs.', 'woocommerce' ) ),
+				'brand'        => self::get_term_filter_input_schema( __( 'Only products of any of these brand IDs, including their child brands.', 'woocommerce' ) ),
 				'page'         => array(
 					'type'    => 'integer',
 					'default' => 1,
@@ -226,4 +253,21 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
 		);
 		return ExtensibleAbilities::add_query_filters_schema( $schema, 'product' );
 	}
+
+	/**
+	 * Schema of a filter that takes term IDs.
+	 *
+	 * @param string $description Filter description.
+	 * @return array
+	 */
+	private static function get_term_filter_input_schema( string $description ): array {
+		return array(
+			'type'        => 'array',
+			'description' => $description,
+			'items'       => array(
+				'type'    => 'integer',
+				'minimum' => 1,
+			),
+		);
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
index 11368de472e..88a5d13e467 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
@@ -9,6 +9,7 @@ namespace Automattic\WooCommerce\Internal\Abilities\Domain\Traits;

 use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\Enums\ProductTaxStatus;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Abilities\ExtensibleAbilities;

@@ -759,29 +760,49 @@ trait ProductAbilityTrait {
 	 * @return array
 	 */
 	protected static function format_product_for_response( \WC_Product $product ): array {
-		$stock_quantity = $product->get_stock_quantity();
-		$permalink      = $product->get_permalink();
+		$stock_quantity   = $product->get_stock_quantity();
+		$low_stock_amount = $product->get_low_stock_amount();
+		$permalink        = $product->get_permalink();

 		$data = array(
-			'id'               => $product->get_id(),
-			'name'             => $product->get_name(),
-			'slug'             => $product->get_slug(),
-			'type'             => $product->get_type(),
-			'status'           => $product->get_status(),
-			'sku'              => $product->get_sku(),
-			'currency'         => get_woocommerce_currency(),
-			'currency_symbol'  => html_entity_decode(
+			'id'                => $product->get_id(),
+			'name'              => $product->get_name(),
+			'slug'              => $product->get_slug(),
+			'type'              => $product->get_type(),
+			'status'            => $product->get_status(),
+			'sku'               => $product->get_sku(),
+			'currency'          => get_woocommerce_currency(),
+			'currency_symbol'   => html_entity_decode(
 				get_woocommerce_currency_symbol(),
 				ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
 			),
-			'price'            => $product->get_price(),
-			'regular_price'    => $product->get_regular_price(),
-			'sale_price'       => $product->get_sale_price(),
-			'stock_status'     => $product->get_stock_status(),
-			'manage_stock'     => (bool) $product->get_manage_stock(),
-			'virtual'          => (bool) $product->get_virtual(),
-			'downloadable'     => (bool) $product->get_downloadable(),
-			'grouped_products' => $product instanceof \WC_Product_Grouped ? array_map( 'absint', $product->get_children( 'edit' ) ) : array(),
+			'price'             => $product->get_price(),
+			'regular_price'     => $product->get_regular_price(),
+			'sale_price'        => $product->get_sale_price(),
+			'on_sale'           => (bool) $product->is_on_sale(),
+			'stock_status'      => $product->get_stock_status(),
+			'manage_stock'      => (bool) $product->get_manage_stock(),
+			'virtual'           => (bool) $product->get_virtual(),
+			'downloadable'      => (bool) $product->get_downloadable(),
+			'grouped_products'  => $product instanceof \WC_Product_Grouped ? array_map( 'absint', $product->get_children( 'edit' ) ) : array(),
+			'categories'        => self::format_product_terms_for_response( $product->get_category_ids( 'edit' ), 'product_cat' ),
+			'tags'              => self::format_product_terms_for_response( $product->get_tag_ids( 'edit' ), 'product_tag' ),
+			'brands'            => self::format_product_terms_for_response( $product->get_brand_ids( 'edit' ), 'product_brand' ),
+			'description'       => $product->get_description(),
+			'short_description' => $product->get_short_description(),
+			'images'            => self::format_product_images_for_response( $product ),
+			'weight'            => $product->get_weight(),
+			'dimensions'        => array(
+				'length' => $product->get_length(),
+				'width'  => $product->get_width(),
+				'height' => $product->get_height(),
+			),
+			'shipping_required' => (bool) $product->needs_shipping(),
+			'shipping_class'    => $product->get_shipping_class(),
+			'shipping_class_id' => $product->get_shipping_class_id(),
+			'tax_status'        => $product->get_tax_status(),
+			'tax_class'         => $product->get_tax_class(),
+			'backorders'        => $product->get_backorders(),
 		);

 		if ( false !== $permalink ) {
@@ -790,6 +811,9 @@ trait ProductAbilityTrait {
 		if ( null !== $stock_quantity ) {
 			$data['stock_quantity'] = wc_stock_amount( $stock_quantity );
 		}
+		if ( is_numeric( $low_stock_amount ) ) {
+			$data['low_stock_amount'] = wc_stock_amount( (float) $low_stock_amount );
+		}
 		if ( $product instanceof \WC_Product_External ) {
 			$external_url = $product->get_product_url();
 			$button_text  = $product->get_button_text();
@@ -813,6 +837,74 @@ trait ProductAbilityTrait {
 		return ExtensibleAbilities::add_fields_to_object( $data, 'product', $product );
 	}

+	/**
+	 * Terms of a product in one taxonomy. Loading a product caches its terms, so this adds no query per product.
+	 *
+	 * @param array<int, int|string> $term_ids Term IDs of the product.
+	 * @param string                 $taxonomy Taxonomy, such as `product_cat`.
+	 * @return array<int, array{id: int, name: string, slug: string}>
+	 */
+	private static function format_product_terms_for_response( array $term_ids, string $taxonomy ): array {
+		if ( ! taxonomy_exists( $taxonomy ) ) {
+			return array();
+		}
+
+		$terms = array();
+
+		foreach ( $term_ids as $term_id ) {
+			$term = get_term( (int) $term_id, $taxonomy );
+
+			if ( $term instanceof \WP_Term ) {
+				$terms[] = array(
+					'id'   => $term->term_id,
+					'name' => html_entity_decode( $term->name, ENT_QUOTES | ENT_HTML5 ),
+					'slug' => $term->slug,
+				);
+			}
+		}
+
+		return $terms;
+	}
+
+	/**
+	 * Featured image first, then the gallery. Unlike the REST API, a product without images gets no placeholder.
+	 *
+	 * @param \WC_Product $product Product object.
+	 * @return array<int, array{id: int, src: string, name: string, alt: string}>
+	 */
+	private static function format_product_images_for_response( \WC_Product $product ): array {
+		$images = array();
+
+		foreach ( self::get_product_image_ids( $product ) as $attachment_id ) {
+			$attachment = wp_get_attachment_image_src( $attachment_id, 'full' );
+
+			if ( ! is_array( $attachment ) ) {
+				continue;
+			}
+
+			$images[] = array(
+				'id'   => $attachment_id,
+				'src'  => (string) $attachment[0],
+				'name' => get_post_field( 'post_title', $attachment_id ),
+				'alt'  => (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ),
+			);
+		}
+
+		return $images;
+	}
+
+	/**
+	 * Attachment IDs of a product's featured image and gallery, without duplicates.
+	 *
+	 * @param \WC_Product $product Product object.
+	 * @return array<int, int>
+	 */
+	protected static function get_product_image_ids( \WC_Product $product ): array {
+		$image_ids = array_merge( array( (int) $product->get_image_id() ), array_map( 'intval', $product->get_gallery_image_ids() ) );
+
+		return array_values( array_unique( array_filter( $image_ids ) ) );
+	}
+
 	/**
 	 * Get the schema for a single product in a response.
 	 *
@@ -858,6 +950,10 @@ trait ProductAbilityTrait {
 						'type'        => 'string',
 						'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
 					),
+					'on_sale'           => array(
+						'type'        => 'boolean',
+						'description' => __( 'Whether the sale price applies now, taking the sale dates into account.', 'woocommerce' ),
+					),
 					'stock_status'      => array(
 						'type' => 'string',
 						'enum' => array_keys( wc_get_product_stock_status_options() ),
@@ -883,6 +979,75 @@ trait ProductAbilityTrait {
 						'description' => __( 'Product IDs included as children of a grouped product.', 'woocommerce' ),
 						'items'       => array( 'type' => 'integer' ),
 					),
+					'categories'        => self::get_product_terms_output_schema(),
+					'tags'              => self::get_product_terms_output_schema(),
+					'brands'            => self::get_product_terms_output_schema(),
+					'description'       => array( 'type' => 'string' ),
+					'short_description' => array( 'type' => 'string' ),
+					'images'            => array(
+						'type'        => 'array',
+						'description' => __( 'Featured image first, then the gallery.', 'woocommerce' ),
+						'items'       => array(
+							'type'                 => 'object',
+							'properties'           => array(
+								'id'   => array( 'type' => 'integer' ),
+								'src'  => array(
+									'type'   => 'string',
+									'format' => 'uri',
+								),
+								'name' => array( 'type' => 'string' ),
+								'alt'  => array( 'type' => 'string' ),
+							),
+							'additionalProperties' => false,
+						),
+					),
+					'weight'            => array(
+						'type'        => 'string',
+						'description' => sprintf(
+							/* translators: %s: weight unit, such as kg. */
+							__( 'Decimal string in %s, or empty when not set.', 'woocommerce' ),
+							get_option( 'woocommerce_weight_unit' )
+						),
+					),
+					'dimensions'        => array(
+						'type'                 => 'object',
+						'description'          => sprintf(
+							/* translators: %s: dimension unit, such as cm. */
+							__( 'Decimal strings in %s, each empty when not set.', 'woocommerce' ),
+							get_option( 'woocommerce_dimension_unit' )
+						),
+						'properties'           => array(
+							'length' => array( 'type' => 'string' ),
+							'width'  => array( 'type' => 'string' ),
+							'height' => array( 'type' => 'string' ),
+						),
+						'additionalProperties' => false,
+					),
+					'shipping_required' => array( 'type' => 'boolean' ),
+					'shipping_class'    => array(
+						'type'        => 'string',
+						'description' => __( 'Shipping class slug, or empty when the product has none.', 'woocommerce' ),
+					),
+					'shipping_class_id' => array(
+						'type'        => 'integer',
+						'description' => __( 'Shipping class term ID, or 0 when the product has none.', 'woocommerce' ),
+					),
+					'tax_status'        => array(
+						'type' => 'string',
+						'enum' => array( ProductTaxStatus::TAXABLE, ProductTaxStatus::SHIPPING, ProductTaxStatus::NONE ),
+					),
+					'tax_class'         => array(
+						'type'        => 'string',
+						'description' => __( 'Tax class slug, or empty for the standard rate.', 'woocommerce' ),
+					),
+					'backorders'        => array(
+						'type' => 'string',
+						'enum' => array_keys( wc_get_product_backorder_options() ),
+					),
+					'low_stock_amount'  => array(
+						'type'        => self::get_product_stock_quantity_schema_type(),
+						'description' => __( 'Low stock threshold of the product. Omitted when the store-wide threshold applies.', 'woocommerce' ),
+					),
 					'date_created'      => array(
 						'type'        => 'string',
 						'format'      => 'date-time',
@@ -910,6 +1075,26 @@ trait ProductAbilityTrait {
 		);
 	}

+	/**
+	 * Schema of a product's terms in one taxonomy.
+	 *
+	 * @return array
+	 */
+	private static function get_product_terms_output_schema(): array {
+		return array(
+			'type'  => 'array',
+			'items' => array(
+				'type'                 => 'object',
+				'properties'           => array(
+					'id'   => array( 'type' => 'integer' ),
+					'name' => array( 'type' => 'string' ),
+					'slug' => array( 'type' => 'string' ),
+				),
+				'additionalProperties' => false,
+			),
+		);
+	}
+
 	/**
 	 * Get the schema type for product stock quantities.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index ea3978baa0d..496e0df13f9 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -1158,6 +1158,20 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
 	}

+	/**
+	 * @testdox Should return the default category that saving assigns to a product created without categories.
+	 */
+	public function test_product_create_returns_the_default_category(): void {
+		$default = wp_insert_term( 'Uncategorized test', 'product_cat' )['term_id'];
+		update_option( 'default_product_cat', $default );
+
+		$result = wp_get_ability( 'woocommerce/product-create' )->execute( array( 'name' => 'Default category product' ) );
+
+		$this->assertIsArray( $result );
+		$this->created_product_ids[] = $result['product']['id'];
+		$this->assertSame( array( $default ), array_column( $result['product']['categories'], 'id' ) );
+	}
+
 	/**
 	 * @testdox Should reject auto-draft as a product mutation status.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php
index a6d11c3ac99..3bca1673393 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php
@@ -9,6 +9,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Abilities\Domain;

 use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductTermsQuery;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductsQuery;
 use Automattic\WooCommerce\Abilities\ExtensibleAbilities;

 /**
@@ -87,6 +88,86 @@ class ProductTermsQueryTest extends \WC_Unit_Test_Case {
 		$this->assertArrayNotHasKey( 'image', $result['terms'][0] );
 	}

+	/**
+	 * @testdox Should return term names with HTML entities decoded.
+	 */
+	public function test_decodes_term_names(): void {
+		$id = self::factory()->term->create(
+			array(
+				'taxonomy' => 'product_cat',
+				'name'     => 'Tools & Hardware',
+			)
+		);
+
+		$ability = new \WP_Ability( ProductTermsQuery::get_name(), ProductTermsQuery::get_registration_args() );
+		$result  = $ability->execute(
+			array(
+				'taxonomy' => 'product_cat',
+				'include'  => array( $id ),
+			)
+		);
+
+		$this->assertIsArray( $result );
+		$this->assertSame( 'Tools & Hardware', $result['terms'][0]['name'] );
+	}
+
+	/**
+	 * @testdox Should find a term whose name holds an entity when searching for "$search".
+	 * @testWith ["Tools & Hardware"]
+	 *           ["Tools &amp; Hardware"]
+	 *           ["& Hard"]
+	 *           ["Hardware"]
+	 * @param string $search Search input.
+	 */
+	public function test_search_matches_names_with_entities( string $search ): void {
+		$id = self::factory()->term->create(
+			array(
+				'taxonomy' => 'product_cat',
+				'name'     => 'Tools & Hardware',
+			)
+		);
+
+		$this->assertSame( array( $id ), array_column( $this->search_categories( $search ), 'id' ) );
+	}
+
+	/**
+	 * @testdox Should not encode an already encoded search twice.
+	 */
+	public function test_search_does_not_double_encode(): void {
+		self::factory()->term->create(
+			array(
+				'taxonomy' => 'product_cat',
+				'name'     => 'Tools & Hardware',
+			)
+		);
+
+		$this->assertSame( array(), $this->search_categories( 'Tools &amp;amp; Hardware' ) );
+	}
+
+	/**
+	 * @testdox Should return the same term name as products-query.
+	 */
+	public function test_term_name_matches_products_query(): void {
+		$id      = self::factory()->term->create(
+			array(
+				'taxonomy' => 'product_cat',
+				'name'     => 'Tools & Hardware',
+			)
+		);
+		$product = \WC_Helper_Product::create_simple_product();
+		$product->set_category_ids( array( $id ) );
+		$product->save();
+
+		$products_query = new \WP_Ability( ProductsQuery::get_name(), ProductsQuery::get_registration_args() );
+		$products       = $products_query->execute( array( 'id' => $product->get_id() ) );
+
+		$this->assertIsArray( $products );
+		$this->assertSame(
+			$products['products'][0]['categories'][0]['name'],
+			$this->search_categories( 'Hardware' )[0]['name']
+		);
+	}
+
 	/**
 	 * @testdox Should filter, order and paginate product terms without leaking other taxonomies.
 	 */
@@ -377,6 +458,33 @@ class ProductTermsQueryTest extends \WC_Unit_Test_Case {
 		$this->assertArrayNotHasKey( 'image', $result['terms'][0] );
 	}

+	/**
+	 * @testdox Should return the image title as stored, without texturized quotes and dashes.
+	 */
+	public function test_returns_plain_image_name(): void {
+		$id       = self::factory()->term->create( array( 'taxonomy' => 'product_cat' ) );
+		$image_id = self::factory()->post->create(
+			array(
+				'post_type'      => 'attachment',
+				'post_title'     => 'Bob\'s mug -- "x"',
+				'post_mime_type' => 'image/jpeg',
+				'guid'           => 'https://example.test/mug.jpg',
+			)
+		);
+		update_term_meta( $id, 'thumbnail_id', $image_id );
+
+		$ability = new \WP_Ability( ProductTermsQuery::get_name(), ProductTermsQuery::get_registration_args() );
+		$result  = $ability->execute(
+			array(
+				'taxonomy' => 'product_cat',
+				'include'  => array( $id ),
+			)
+		);
+
+		$this->assertIsArray( $result );
+		$this->assertSame( 'Bob\'s mug -- "x"', $result['terms'][0]['image']['name'] );
+	}
+
 	/**
 	 * @testdox Should enforce read permissions and validate taxonomy and pagination through the ability.
 	 */
@@ -467,4 +575,23 @@ class ProductTermsQueryTest extends \WC_Unit_Test_Case {
 		$this->assertSame( $baseline, ProductTermsQuery::execute( $input ) );
 		$this->assertSame( $schema, ProductTermsQuery::get_registration_args()['output_schema'] );
 	}
+
+	/**
+	 * Product categories the ability returns for a search.
+	 *
+	 * @param string $search Search input.
+	 * @return array
+	 */
+	private function search_categories( string $search ): array {
+		$ability = new \WP_Ability( ProductTermsQuery::get_name(), ProductTermsQuery::get_registration_args() );
+		$result  = $ability->execute(
+			array(
+				'taxonomy' => 'product_cat',
+				'search'   => $search,
+			)
+		);
+		$this->assertIsArray( $result );
+
+		return $result['terms'];
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductsQueryTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductsQueryTest.php
new file mode 100644
index 00000000000..071422c6ae4
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductsQueryTest.php
@@ -0,0 +1,262 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Enums\ProductTaxStatus;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductsQuery;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the products-query ability.
+ */
+class ProductsQueryTest extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox Should return the categories, tags and brands of each product with decoded names.
+	 */
+	public function test_returns_product_terms(): void {
+		$category = wp_insert_term( 'Hats & Caps', 'product_cat' );
+		$tag      = wp_insert_term( 'Summer', 'product_tag' );
+		$brand    = wp_insert_term( 'Acme', 'product_brand' );
+		$product  = WC_Helper_Product::create_simple_product();
+		$product->set_category_ids( array( $category['term_id'] ) );
+		$product->set_tag_ids( array( $tag['term_id'] ) );
+		$product->set_brand_ids( array( $brand['term_id'] ) );
+		$product->save();
+
+		$item = $this->query_single( $product->get_id() );
+
+		$this->assertSame(
+			array(
+				array(
+					'id'   => $category['term_id'],
+					'name' => 'Hats & Caps',
+					'slug' => 'hats-caps',
+				),
+			),
+			$item['categories'],
+			'Term names should be decoded.'
+		);
+		$this->assertSame( 'summer', $item['tags'][0]['slug'] );
+		$this->assertSame( 'Acme', $item['brands'][0]['name'] );
+	}
+
+	/**
+	 * @testdox Should return only products that match a category, tag or brand filter.
+	 *
+	 * @testWith ["category", "product_cat", "set_category_ids"]
+	 *           ["tag", "product_tag", "set_tag_ids"]
+	 *           ["brand", "product_brand", "set_brand_ids"]
+	 *
+	 * @param string $filter   Input filter.
+	 * @param string $taxonomy Taxonomy.
+	 * @param string $setter   Product setter.
+	 */
+	public function test_filters_by_term( string $filter, string $taxonomy, string $setter ): void {
+		$term    = wp_insert_term( 'Matching ' . $filter, $taxonomy );
+		$matched = WC_Helper_Product::create_simple_product();
+		$matched->$setter( array( $term['term_id'] ) );
+		$matched->save();
+		WC_Helper_Product::create_simple_product();
+
+		$result = ProductsQuery::execute( array( $filter => array( $term['term_id'] ) ) );
+
+		$this->assertIsArray( $result );
+		$this->assertSame( array( $matched->get_id() ), array_column( $result['products'], 'id' ) );
+	}
+
+	/**
+	 * @testdox Should match products in a child term when filtering by its parent category or brand.
+	 *
+	 * @testWith ["category", "product_cat", "set_category_ids"]
+	 *           ["brand", "product_brand", "set_brand_ids"]
+	 *
+	 * @param string $filter   Input filter.
+	 * @param string $taxonomy Hierarchical taxonomy.
+	 * @param string $setter   Product setter.
+	 */
+	public function test_filters_include_child_terms( string $filter, string $taxonomy, string $setter ): void {
+		$parent  = wp_insert_term( 'Parent ' . $filter, $taxonomy );
+		$child   = wp_insert_term( 'Child ' . $filter, $taxonomy, array( 'parent' => $parent['term_id'] ) );
+		$matched = WC_Helper_Product::create_simple_product();
+		$matched->$setter( array( $child['term_id'] ) );
+		$matched->save();
+		WC_Helper_Product::create_simple_product();
+
+		$result = ProductsQuery::execute( array( $filter => array( $parent['term_id'] ) ) );
+
+		$this->assertIsArray( $result );
+		$this->assertSame( array( $matched->get_id() ), array_column( $result['products'], 'id' ) );
+	}
+
+	/**
+	 * @testdox Should return the description, shipping, tax and stock fields of a product.
+	 */
+	public function test_returns_catalog_fields(): void {
+		$shipping_class = wp_insert_term( 'Bulky', 'product_shipping_class' );
+		$product        = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'description'       => '<p>Long</p>',
+				'short_description' => 'Short',
+				'weight'            => '1.5',
+				'length'            => '10',
+				'width'             => '20',
+				'height'            => '',
+				'shipping_class_id' => $shipping_class['term_id'],
+				'tax_status'        => ProductTaxStatus::SHIPPING,
+				'tax_class'         => 'reduced-rate',
+				'manage_stock'      => true,
+				'backorders'        => 'notify',
+				'low_stock_amount'  => 3,
+			)
+		);
+
+		$item = $this->query_single( $product->get_id() );
+
+		$this->assertSame( '<p>Long</p>', $item['description'] );
+		$this->assertSame( 'Short', $item['short_description'] );
+		$this->assertSame( '1.5', $item['weight'] );
+		$this->assertSame(
+			array(
+				'length' => '10',
+				'width'  => '20',
+				'height' => '',
+			),
+			$item['dimensions']
+		);
+		$this->assertTrue( $item['shipping_required'] );
+		$this->assertSame( 'bulky', $item['shipping_class'] );
+		$this->assertSame( $shipping_class['term_id'], $item['shipping_class_id'] );
+		$this->assertSame( ProductTaxStatus::SHIPPING, $item['tax_status'] );
+		$this->assertSame( 'reduced-rate', $item['tax_class'] );
+		$this->assertSame( 'notify', $item['backorders'] );
+		$this->assertSame( 3, $item['low_stock_amount'] );
+	}
+
+	/**
+	 * @testdox Should report a product as on sale only while its sale dates include today.
+	 *
+	 * @testWith ["-1 day", true]
+	 *           ["+1 day", false]
+	 *
+	 * @param string $sale_start Relative start date of the sale.
+	 * @param bool   $expected   Expected on_sale value.
+	 */
+	public function test_on_sale_follows_sale_dates( string $sale_start, bool $expected ): void {
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'regular_price'     => '10',
+				'sale_price'        => '5',
+				'date_on_sale_from' => gmdate( 'Y-m-d', strtotime( $sale_start ) ),
+				'date_on_sale_to'   => gmdate( 'Y-m-d', strtotime( '+1 week' ) ),
+			)
+		);
+
+		$item = $this->query_single( $product->get_id() );
+
+		$this->assertSame( '5', $item['sale_price'], 'The sale price is set either way.' );
+		$this->assertSame( $expected, $item['on_sale'] );
+	}
+
+	/**
+	 * @testdox Should return the featured image first and then the gallery, without duplicates.
+	 */
+	public function test_returns_images_in_order(): void {
+		$featured = $this->create_image( 'featured.jpg', 'Featured', 'Front view' );
+		$gallery  = $this->create_image( 'gallery.jpg', 'Gallery', '' );
+		$product  = WC_Helper_Product::create_simple_product();
+		$product->set_image_id( $featured );
+		$product->set_gallery_image_ids( array( $gallery, $featured ) );
+		$product->save();
+
+		$item = $this->query_single( $product->get_id() );
+
+		$this->assertSame( array( $featured, $gallery ), array_column( $item['images'], 'id' ) );
+		$this->assertSame( 'Featured', $item['images'][0]['name'] );
+		$this->assertSame( 'Front view', $item['images'][0]['alt'] );
+		$this->assertStringEndsWith( 'featured.jpg', $item['images'][0]['src'] );
+	}
+
+	/**
+	 * @testdox Should return the image title as stored, without texturized quotes and dashes.
+	 */
+	public function test_returns_plain_image_names(): void {
+		$image   = $this->create_image( 'mug.jpg', 'Bob\'s mug -- "x"', '' );
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_image_id( $image );
+		$product->save();
+
+		$item = $this->query_single( $product->get_id() );
+
+		$this->assertSame( 'Bob\'s mug -- "x"', $item['images'][0]['name'] );
+	}
+
+	/**
+	 * @testdox Should return no images and omit the low stock amount for a product without them.
+	 */
+	public function test_returns_empty_values_for_unset_fields(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$item = $this->query_single( $product->get_id() );
+
+		$this->assertSame( array(), $item['images'], 'A product without images should get no placeholder.' );
+		$this->assertArrayNotHasKey( 'low_stock_amount', $item );
+	}
+
+	/**
+	 * @testdox Should match the output schema when listing products.
+	 */
+	public function test_list_output_matches_schema(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_image_id( $this->create_image( 'schema.jpg', 'Schema', '' ) );
+		$product->save();
+
+		$result = ProductsQuery::execute( array() );
+		$schema = ProductsQuery::get_registration_args()['output_schema'];
+
+		$this->assertTrue( rest_validate_value_from_schema( $result, $schema, 'products' ) );
+	}
+
+	/**
+	 * Query one product by ID and check the result against the output schema.
+	 *
+	 * @param int $product_id Product ID.
+	 * @return array
+	 */
+	private function query_single( int $product_id ): array {
+		$result = ProductsQuery::execute( array( 'id' => $product_id ) );
+		$schema = ProductsQuery::get_registration_args()['output_schema'];
+
+		$this->assertIsArray( $result );
+		$this->assertTrue( rest_validate_value_from_schema( $result, $schema, 'products' ), 'The result should match the output schema.' );
+
+		return $result['products'][0];
+	}
+
+	/**
+	 * Create an image attachment.
+	 *
+	 * @param string $file  File name.
+	 * @param string $title Attachment title.
+	 * @param string $alt   Alt text.
+	 * @return int
+	 */
+	private function create_image( string $file, string $title, string $alt ): int {
+		$attachment_id = self::factory()->attachment->create_object(
+			array(
+				'file'           => $file,
+				'post_title'     => $title,
+				'post_mime_type' => 'image/jpeg',
+			)
+		);
+		if ( '' !== $alt ) {
+			update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt );
+		}
+
+		return $attachment_id;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index 00ff82a6983..e345a49e6f2 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -367,6 +367,71 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		$this->assertSame( array( $external->get_id() ), array_column( $output['products'], 'id' ) );
 	}

+	/**
+	 * @testdox Should combine Core's term filters with an extension's OR tax_query with AND.
+	 */
+	public function test_term_filters_combine_with_extension_or_tax_query(): void {
+		$gifts  = wp_insert_term( 'Gifts', 'product_cat' )['term_id'];
+		$sale   = wp_insert_term( 'Sale', 'product_cat' )['term_id'];
+		$summer = wp_insert_term( 'Summer', 'product_tag' )['term_id'];
+		$this->add_test_action(
+			'woocommerce_extensible_abilities_init',
+			static function () {
+				ExtensibleAbilities::register_query_filter(
+					array(
+						'resource'  => 'product',
+						'namespace' => 'test-ext',
+						'filter'    => 'gifts_or_sale',
+						'schema'    => array( 'type' => 'boolean' ),
+						'callback'  => static function () {
+							return array(
+								'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
+									array(
+										'relation' => 'OR',
+										array(
+											'taxonomy' => 'product_cat',
+											'field'    => 'slug',
+											'terms'    => 'gifts',
+										),
+										array(
+											'taxonomy' => 'product_cat',
+											'field'    => 'slug',
+											'terms'    => 'sale',
+										),
+									),
+								),
+							);
+						},
+					)
+				);
+			}
+		);
+		$matched = array();
+		foreach ( array( array( $gifts ), array( $sale ) ) as $categories ) {
+			$product = \WC_Helper_Product::create_simple_product();
+			$product->set_category_ids( $categories );
+			$product->set_tag_ids( array( $summer ) );
+			$product->save();
+			$matched[] = $product->get_id();
+		}
+		$category_only = \WC_Helper_Product::create_simple_product();
+		$category_only->set_category_ids( array( $gifts ) );
+		$category_only->save();
+		$tag_only = \WC_Helper_Product::create_simple_product();
+		$tag_only->set_tag_ids( array( $summer ) );
+		$tag_only->save();
+
+		$output = $this->run_ability(
+			'woocommerce/products-query',
+			array(
+				'tag'     => array( $summer ),
+				'filters' => array( 'extensions' => array( 'test-ext' => array( 'gifts_or_sale' => 'true' ) ) ),
+			)
+		);
+
+		$this->assertEqualsCanonicalizing( $matched, array_column( $output['products'], 'id' ) );
+	}
+
 	/**
 	 * @testdox Should not filter a product query that a hook runs inside the ability's query.
 	 */