Commit d304b954fec for woocommerce

commit d304b954fecd6cc36ec33bdabc235a69c796423d
Author: Aarón Fas <yo@aaron.com.es>
Date:   Mon Nov 2 16:22:55 2026 +0100

    Add a Core query ability for product categories, tags and brands (#70397)

    * Add product term queries to Core abilities

    External clients need to discover categories, tags and brands without Woo AI.
    Provide one taxonomy-input query with include IDs, catalog filters, exact
    totals and pagination, and register it as a canonical Core ability.

    Preserve the common term data and category/brand hierarchy and display
    settings. Cover existing read data, product filtering, ordering, pagination,
    input validation and taxonomy read permissions with regression tests.

    * Preserve category and brand image data in term reads

    The existing term readers expose image details that external clients need.
    Return attachment metadata and prime attachment caches before formatting a
    page of terms so image reads do not add a query for each existing attachment.

    Omit unavailable images and include timezone offsets in image dates, using
    UTC for the GMT fields. Add regressions for image values, non-UTC site
    timezones and stale attachment references.

    * Expose extension fields on the product_term resource

    Let extensions add namespaced fields to category, tag and brand reads
    through the existing Core ability extension mechanism.

    Register product_term before fields are declared, extend the term schema,
    and pass each WP_Term to the field callbacks. Test all three taxonomies,
    multiple returned terms and unchanged base output/schema when the feature
    is disabled or no extension fields are registered.

    * Add changelog entry for product term reads

    Document that external clients can read product categories, tags and brands
    through WooCommerce Core abilities.

    * Keep term totals consistent with hierarchy filtering

    WordPress count queries skip the hierarchy rules that retain empty parents with non-empty descendants. Count matching term IDs for hide_empty queries on categories and brands so clients can reach every result page.

    Cover both taxonomies with an empty parent, two populated children, and a term assigned only to a hidden product, including one-item pagination.

    * Normalize hide_empty inputs from older Abilities API clients

    WordPress 7.0 can pass a valid boolean query parameter through as the string false. Parse hide_empty with wp_validate_boolean so that a false string does not remove empty terms.

    Test native and string forms of both boolean values without changing other abilities.

    * Reject empty term includes and describe filter semantics

    An empty include must not turn a lookup into a full catalog query. Document how include overrides exclude, how empty slugs behave, and why returned terms can have a zero catalog-visible count.

    * Omit term images whose attachment URL is unavailable

    An attachment without a usable URL must not invalidate the complete term response. Cover this case for categories and brands and validate complete RFC 3339 image dates.

    * Describe product term counts without transport details

    Explain the count in terms an MCP client can use. Keep the hide_empty caveat while removing the REST-specific wording; query behavior is unchanged.

diff --git a/plugins/woocommerce/changelog/add-product-terms-query b/plugins/woocommerce/changelog/add-product-terms-query
new file mode 100644
index 00000000000..92097208732
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-product-terms-query
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Allow external clients to read product categories, tags, and brands through WooCommerce abilities.
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index 6f55aa49523..5fac2d014b4 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -19,6 +19,7 @@ use Automattic\WooCommerce\Internal\Abilities\Domain\ProductCreate;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductDelete;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductUpdate;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductsQuery;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductTermsQuery;

 defined( 'ABSPATH' ) || exit;

@@ -46,6 +47,7 @@ class AbilitiesLoader {
 		OrderUpdateStatus::class,
 		PaymentGatewaysQuery::class,
 		ProductsQuery::class,
+		ProductTermsQuery::class,
 		ProductCreate::class,
 		ProductDelete::class,
 		ProductUpdate::class,
@@ -102,7 +104,7 @@ class AbilitiesLoader {
 	 * @since 11.3.0
 	 */
 	public static function register_core_resources(): void {
-		foreach ( array( 'product', 'order', 'order_item' ) as $resource_name ) {
+		foreach ( array( 'product', 'order', 'order_item', 'product_term' ) as $resource_name ) {
 			ExtensibleAbilities::register_resource( $resource_name );
 		}
 	}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
new file mode 100644
index 00000000000..0aa50f7e8f6
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
@@ -0,0 +1,317 @@
+<?php
+/**
+ * Product terms query ability definition.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Queries product categories, tags and brands.
+ */
+class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefinition {
+
+	/**
+	 * Get the ability name.
+	 *
+	 * @since 11.4.0
+	 * @return string
+	 */
+	public static function get_name(): string {
+		return 'woocommerce/product-terms-query';
+	}
+
+	/**
+	 * Get the ability registration arguments.
+	 *
+	 * @since 11.4.0
+	 * @return array
+	 */
+	public static function get_registration_args(): array {
+		$output_schema                        = self::get_collection_output_schema( 'terms', ExtensibleAbilities::add_fields_schema( self::get_term_schema(), 'product_term' ) );
+		$output_schema['properties']['total'] = array(
+			'type'        => 'integer',
+			'description' => __( 'Total number of matching terms across all pages.', 'woocommerce' ),
+		);
+
+		return array(
+			'label'               => __( 'Query product terms', 'woocommerce' ),
+			'description'         => __( 'Find product categories, tags or brands. Use include to retrieve specific term IDs.', 'woocommerce' ),
+			'category'            => 'woocommerce',
+			'input_schema'        => self::get_input_schema(),
+			'output_schema'       => $output_schema,
+			'execute_callback'    => array( __CLASS__, 'execute' ),
+			'permission_callback' => array( __CLASS__, 'can_query_terms' ),
+			'meta'                => array(
+				'show_in_rest' => true,
+				'mcp'          => array(
+					'public' => true,
+					'type'   => 'tool',
+				),
+				'annotations'  => array(
+					'readonly'    => true,
+					'idempotent'  => true,
+					'destructive' => false,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Check product term read access.
+	 *
+	 * @since 11.4.0
+	 * @param mixed $input Ability input.
+	 * @return bool
+	 */
+	public static function can_query_terms( $input = array() ): bool {
+		$taxonomy = $input['taxonomy'] ?? '';
+		return in_array( $taxonomy, array( 'product_cat', 'product_tag', 'product_brand' ), true )
+			&& taxonomy_exists( $taxonomy )
+			&& wc_rest_check_product_term_permissions( $taxonomy, 'read' );
+	}
+
+	/**
+	 * Query product terms.
+	 *
+	 * @since 11.4.0
+	 * @param array $input Ability input.
+	 * @return array|\WP_Error
+	 */
+	public static function execute( array $input ) {
+		$page               = (int) ( $input['page'] ?? 1 );
+		$per_page           = (int) ( $input['per_page'] ?? 10 );
+		$args               = array_merge(
+			array(
+				'hide_empty' => false,
+				'orderby'    => 'name',
+				'order'      => 'asc',
+			),
+			array_intersect_key( $input, array_flip( array( 'taxonomy', 'include', 'exclude', 'search', 'slug', 'parent', 'hide_empty', 'orderby', 'order' ) ) )
+		);
+		$args['hide_empty'] = wp_validate_boolean( $input['hide_empty'] ?? false );
+		$args['number']     = $per_page;
+		$args['offset']     = $input['offset'] ?? ( $page - 1 ) * $per_page;
+		if ( ! empty( $input['product'] ) ) {
+			$args['object_ids'] = array( $input['product'] );
+		}
+
+		$terms = get_terms( $args );
+		if ( is_wp_error( $terms ) ) {
+			return $terms;
+		}
+		$count_args = $args;
+		unset( $count_args['number'], $count_args['offset'] );
+		// WordPress count queries skip hierarchy-based hide_empty filtering.
+		$count_args['fields'] = $args['hide_empty'] && is_taxonomy_hierarchical( $args['taxonomy'] ) ? 'ids' : 'count';
+		$total                = get_terms( $count_args );
+		if ( is_wp_error( $total ) ) {
+			return $total;
+		}
+		$total = is_array( $total ) ? count( $total ) : (int) $total;
+
+		$image_ids = array_filter( array_map( static fn( $term ) => (int) get_term_meta( $term->term_id, 'thumbnail_id', true ), $terms ) );
+		if ( ! empty( $image_ids ) ) {
+			// Prime caches to reduce future queries.
+			_prime_post_caches( $image_ids );
+		}
+
+		return array(
+			'terms'       => array_map( array( __CLASS__, 'format_term' ), array_values( $terms ) ),
+			'total'       => (int) $total,
+			'total_pages' => (int) ceil( (int) $total / $per_page ),
+			'page'        => isset( $input['offset'] ) ? (int) floor( $args['offset'] / $per_page ) + 1 : $page,
+			'per_page'    => $per_page,
+		);
+	}
+
+	/**
+	 * Format a term with the fields of the WooCommerce term reads.
+	 *
+	 * @param \WP_Term $term Term to format.
+	 * @return array
+	 */
+	private static function format_term( \WP_Term $term ): array {
+		$output = array(
+			'id'          => $term->term_id,
+			'name'        => $term->name,
+			'slug'        => $term->slug,
+			'description' => $term->description,
+			'count'       => $term->count,
+		);
+		if ( 'product_tag' !== $term->taxonomy ) {
+			$output['parent']     = $term->parent;
+			$display              = get_term_meta( $term->term_id, 'display_type', true );
+			$output['display']    = $display ? $display : 'default';
+			$output['menu_order'] = (int) get_term_meta( $term->term_id, 'order', true );
+			$image_id             = (int) get_term_meta( $term->term_id, 'thumbnail_id', true );
+			$image                = $image_id ? get_post( $image_id ) : null;
+			$image_url            = $image ? wp_get_attachment_url( $image->ID ) : false;
+			if ( $image && 'attachment' === $image->post_type && $image_url ) {
+				$output['image'] = array(
+					'id'   => $image->ID,
+					'src'  => $image_url,
+					'name' => get_the_title( $image ),
+					'alt'  => get_post_meta( $image->ID, '_wp_attachment_image_alt', true ),
+				);
+				foreach ( array(
+					'date_created'  => 'date',
+					'date_modified' => 'modified',
+				) as $field => $source ) {
+					foreach ( array(
+						''     => 'local',
+						'_gmt' => 'gmt',
+					) as $suffix => $timezone ) {
+						$date = get_post_datetime( $image, $source, $timezone );
+						if ( $date ) {
+							if ( 'gmt' === $timezone ) {
+								$date = $date->setTimezone( new \DateTimeZone( 'UTC' ) );
+							}
+							$output['image'][ $field . $suffix ] = $date->format( DATE_RFC3339 );
+						}
+					}
+				}
+			}
+		}
+		return ExtensibleAbilities::add_fields_to_object( $output, 'product_term', $term );
+	}
+
+	/**
+	 * Get the input schema.
+	 *
+	 * @return array
+	 */
+	private static function get_input_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'taxonomy'   => array(
+					'description' => __( 'Product taxonomy to query.', 'woocommerce' ),
+					'type'        => 'string',
+					'enum'        => array( 'product_cat', 'product_tag', 'product_brand' ),
+				),
+				'include'    => array(
+					'description' => __( 'Limit results to these term IDs. Use one ID to retrieve a single term.', 'woocommerce' ),
+					'type'        => 'array',
+					'minItems'    => 1,
+					'items'       => array(
+						'type'    => 'integer',
+						'minimum' => 1,
+					),
+				),
+				'exclude'    => array(
+					'description' => __( 'Exclude these term IDs. Ignored when include is set.', 'woocommerce' ),
+					'type'        => 'array',
+					'items'       => array(
+						'type'    => 'integer',
+						'minimum' => 1,
+					),
+				),
+				'search'     => array( 'type' => 'string' ),
+				'slug'       => array(
+					'description' => __( 'Limit results to these slugs. An empty string or list applies no slug filter.', 'woocommerce' ),
+					'type'        => array( 'string', 'array' ),
+					'items'       => array( 'type' => 'string' ),
+				),
+				'parent'     => array(
+					'description' => __( 'Parent term ID for categories or brands. Use zero for top-level terms.', 'woocommerce' ),
+					'type'        => 'integer',
+					'minimum'     => 0,
+				),
+				'product'    => array(
+					'description' => __( 'Limit results to terms assigned to this product ID.', 'woocommerce' ),
+					'type'        => 'integer',
+					'minimum'     => 1,
+				),
+				'hide_empty' => array(
+					'type'    => 'boolean',
+					'default' => false,
+				),
+				'orderby'    => array(
+					'type'    => 'string',
+					'enum'    => array( 'id', 'include', 'name', 'slug', 'term_group', 'description', 'count' ),
+					'default' => 'name',
+				),
+				'order'      => array(
+					'type'    => 'string',
+					'enum'    => array( 'asc', 'desc' ),
+					'default' => 'asc',
+				),
+				'page'       => array(
+					'type'    => 'integer',
+					'minimum' => 1,
+					'default' => 1,
+				),
+				'per_page'   => array(
+					'type'    => 'integer',
+					'minimum' => 1,
+					'maximum' => 100,
+					'default' => 10,
+				),
+				'offset'     => array(
+					'type'    => 'integer',
+					'minimum' => 0,
+				),
+			),
+			'required'             => array( 'taxonomy' ),
+			'additionalProperties' => false,
+		);
+	}
+
+	/**
+	 * Get the term output schema, including taxonomy-specific fields.
+	 *
+	 * @return array
+	 */
+	private static function get_term_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'id'          => array( 'type' => 'integer' ),
+				'name'        => array( 'type' => 'string' ),
+				'slug'        => array( 'type' => 'string' ),
+				'description' => array( 'type' => 'string' ),
+				'count'       => array(
+					'type'        => 'integer',
+					'description' => __( 'Number of published, catalog-visible products. hide_empty uses stored counts and also retains parents with non-empty children, so a returned term can have count zero.', 'woocommerce' ),
+				),
+				'parent'      => array( 'type' => 'integer' ),
+				'display'     => array( 'type' => 'string' ),
+				'menu_order'  => array( 'type' => 'integer' ),
+				'image'       => array(
+					'type'                 => 'object',
+					'properties'           => array(
+						'id'                => array( 'type' => 'integer' ),
+						'src'               => array( 'type' => 'string' ),
+						'name'              => array( 'type' => 'string' ),
+						'alt'               => array( 'type' => 'string' ),
+						'date_created'      => array(
+							'type'   => 'string',
+							'format' => 'date-time',
+						),
+						'date_created_gmt'  => array(
+							'type'   => 'string',
+							'format' => 'date-time',
+						),
+						'date_modified'     => array(
+							'type'   => 'string',
+							'format' => 'date-time',
+						),
+						'date_modified_gmt' => array(
+							'type'   => 'string',
+							'format' => 'date-time',
+						),
+					),
+					'additionalProperties' => false,
+				),
+			),
+			'additionalProperties' => false,
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index 428a9422553..d70599d48a9 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -32,6 +32,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 	private static $administrator_id;

 	private const CANONICAL_ABILITY_IDS = array(
+		'woocommerce/product-terms-query',
 		'woocommerce/products-query',
 		'woocommerce/product-create',
 		'woocommerce/product-update',
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php
new file mode 100644
index 00000000000..bde2c4e8c7e
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php
@@ -0,0 +1,438 @@
+<?php
+/**
+ * Product term query ability tests.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductTermsQuery;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+
+/**
+ * Tests product term reads through the registered ability.
+ */
+class ProductTermsQueryTest extends \WC_Unit_Test_Case {
+
+	/**
+	 * Process state restored after each test.
+	 *
+	 * @var array
+	 */
+	private $saved_state = array();
+
+	/**
+	 * Set up ability registration.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		foreach ( array( 'resources', 'fields' ) as $name ) {
+			$property = new \ReflectionProperty( ExtensibleAbilities::class, $name );
+			$property->setAccessible( true );
+			$this->saved_state[ $name ] = $property->getValue();
+			$property->setValue( null, array() );
+		}
+		update_option( 'woocommerce_feature_extensible_abilities_enabled', 'no' );
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+	}
+
+	/**
+	 * Restore extension state.
+	 */
+	public function tearDown(): void {
+		foreach ( $this->saved_state as $name => $value ) {
+			$property = new \ReflectionProperty( ExtensibleAbilities::class, $name );
+			$property->setAccessible( true );
+			$property->setValue( null, $value );
+		}
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should preserve the data returned by each existing term read, using include for individual terms.
+	 * @testWith ["product_cat", "WC_REST_Product_Categories_Controller"]
+	 *           ["product_tag", "WC_REST_Product_Tags_Controller"]
+	 *           ["product_brand", "WC_REST_Product_Brands_Controller"]
+	 * @param string $taxonomy Taxonomy.
+	 * @param string $controller_class Existing read controller.
+	 */
+	public function test_output_parity( string $taxonomy, string $controller_class ): void {
+		$id = self::factory()->term->create(
+			array(
+				'taxonomy'    => $taxonomy,
+				'name'        => 'Summer',
+				'description' => 'Seasonal products',
+			)
+		);
+		update_term_meta( $id, 'display_type', 'both' );
+		update_term_meta( $id, 'order', 7 );
+		$controller = new $controller_class();
+		$expected   = $controller->prepare_item_for_response( get_term( $id, $taxonomy ), new \WP_REST_Request() )->get_data();
+		unset( $expected['image'] );
+
+		$ability = new \WP_Ability( ProductTermsQuery::get_name(), ProductTermsQuery::get_registration_args() );
+		$result  = $ability->execute(
+			array(
+				'taxonomy' => $taxonomy,
+				'include'  => array( $id ),
+			)
+		);
+		$this->assertIsArray( $result );
+		$this->assertCount( 1, $result['terms'] );
+		foreach ( $expected as $key => $value ) {
+			$this->assertSame( $value, $result['terms'][0][ $key ], $key );
+		}
+		$this->assertArrayNotHasKey( 'image', $result['terms'][0] );
+	}
+
+	/**
+	 * @testdox Should filter, order and paginate product terms without leaking other taxonomies.
+	 */
+	public function test_filters_and_pagination(): void {
+		$parent = self::factory()->term->create(
+			array(
+				'taxonomy' => 'product_cat',
+				'name'     => 'Parent',
+			)
+		);
+		$first  = self::factory()->term->create(
+			array(
+				'taxonomy' => 'product_cat',
+				'name'     => 'Match A',
+				'parent'   => $parent,
+			)
+		);
+		$second = self::factory()->term->create(
+			array(
+				'taxonomy' => 'product_cat',
+				'name'     => 'Match B',
+				'parent'   => $parent,
+			)
+		);
+		$tag    = self::factory()->term->create(
+			array(
+				'taxonomy' => 'product_tag',
+				'name'     => 'Match C',
+			)
+		);
+		$input  = array(
+			'taxonomy' => 'product_cat',
+			'search'   => 'Match',
+			'parent'   => $parent,
+			'per_page' => 1,
+			'page'     => 2,
+			'orderby'  => 'name',
+		);
+		$result = ProductTermsQuery::execute( $input );
+		$this->assertSame( $second, $result['terms'][0]['id'] );
+		$this->assertSame( 2, $result['total_pages'] );
+		$this->assertSame( 2, $result['total'] );
+		$this->assertSame( 2, $result['page'] );
+
+		$result = ProductTermsQuery::execute(
+			array(
+				'taxonomy' => 'product_cat',
+				'include'  => array( $second, $first, $tag ),
+				'orderby'  => 'include',
+			)
+		);
+		$this->assertSame( array( $second, $first ), array_column( $result['terms'], 'id' ) );
+		$result = ProductTermsQuery::execute(
+			array(
+				'taxonomy'   => 'product_cat',
+				'include'    => array( $first ),
+				'hide_empty' => true,
+			)
+		);
+		$this->assertCount( 0, $result['terms'] );
+	}
+
+	/**
+	 * @testdox Should count and paginate non-empty terms together with their empty parents.
+	 * @testWith ["product_cat"]
+	 *           ["product_brand"]
+	 * @param string $taxonomy Taxonomy.
+	 */
+	public function test_hide_empty_hierarchical_totals( string $taxonomy ): void {
+		$parent  = self::factory()->term->create(
+			array(
+				'taxonomy' => $taxonomy,
+				'name'     => 'Zulu parent',
+			)
+		);
+		$first   = self::factory()->term->create(
+			array(
+				'taxonomy' => $taxonomy,
+				'name'     => 'Alpha child',
+				'parent'   => $parent,
+			)
+		);
+		$second  = self::factory()->term->create(
+			array(
+				'taxonomy' => $taxonomy,
+				'name'     => 'Bravo child',
+				'parent'   => $parent,
+			)
+		);
+		$hidden  = self::factory()->term->create(
+			array(
+				'taxonomy' => $taxonomy,
+				'name'     => 'Charlie hidden',
+			)
+		);
+		$product = \WC_Helper_Product::create_simple_product();
+		wp_set_object_terms( $product->get_id(), array( $first, $second ), $taxonomy );
+		$hidden_product = \WC_Helper_Product::create_simple_product();
+		$hidden_product->set_catalog_visibility( 'hidden' );
+		$hidden_product->save();
+		wp_set_object_terms( $hidden_product->get_id(), array( $hidden ), $taxonomy );
+
+		$input  = array(
+			'taxonomy'   => $taxonomy,
+			'include'    => array( $parent, $first, $second, $hidden ),
+			'hide_empty' => true,
+		);
+		$result = ProductTermsQuery::execute( $input );
+		$this->assertCount( 4, $result['terms'] );
+		$this->assertSame( 4, $result['total'] );
+		$this->assertSame( 1, $result['total_pages'] );
+		$this->assertSame( 0, $result['terms'][2]['count'] );
+
+		$seen = array();
+		for ( $page = 1; $page <= 4; ++$page ) {
+			$result = ProductTermsQuery::execute(
+				array_merge(
+					$input,
+					array(
+						'page'     => $page,
+						'per_page' => 1,
+					)
+				)
+			);
+			$this->assertSame( 4, $result['total'] );
+			$this->assertSame( 4, $result['total_pages'] );
+			$this->assertCount( 1, $result['terms'] );
+			$seen[] = $result['terms'][0]['id'];
+		}
+		$this->assertSame( array( $first, $second, $hidden, $parent ), $seen );
+	}
+
+	/**
+	 * @testdox Should parse boolean strings from older Abilities REST controllers.
+	 * @testWith [false, 1]
+	 *           ["false", 1]
+	 *           [true, 0]
+	 *           ["true", 0]
+	 * @param bool|string $hide_empty Boolean input.
+	 * @param int         $expected_count Expected number of empty terms.
+	 */
+	public function test_hide_empty_boolean_input( $hide_empty, int $expected_count ): void {
+		$id     = self::factory()->term->create( array( 'taxonomy' => 'product_tag' ) );
+		$result = ProductTermsQuery::execute(
+			array(
+				'taxonomy'   => 'product_tag',
+				'include'    => array( $id ),
+				'hide_empty' => $hide_empty,
+			)
+		);
+		$this->assertCount( $expected_count, $result['terms'] );
+		$this->assertSame( $expected_count, $result['total'] );
+	}
+
+	/**
+	 * @testdox Should support product, slug, exclude and offset filters.
+	 */
+	public function test_remaining_filters(): void {
+		$first   = self::factory()->term->create(
+			array(
+				'taxonomy' => 'product_tag',
+				'name'     => 'Alpha',
+				'slug'     => 'alpha',
+			)
+		);
+		$second  = self::factory()->term->create(
+			array(
+				'taxonomy' => 'product_tag',
+				'name'     => 'Beta',
+				'slug'     => 'beta',
+			)
+		);
+		$product = \WC_Helper_Product::create_simple_product();
+		wp_set_object_terms( $product->get_id(), array( $first, $second ), 'product_tag' );
+		$result = ProductTermsQuery::execute(
+			array(
+				'taxonomy' => 'product_tag',
+				'product'  => $product->get_id(),
+				'slug'     => 'beta',
+			)
+		);
+		$this->assertSame( array( $second ), array_column( $result['terms'], 'id' ) );
+		$result = ProductTermsQuery::execute(
+			array(
+				'taxonomy' => 'product_tag',
+				'slug'     => array( 'alpha', 'beta' ),
+				'exclude'  => array( $first ),
+			)
+		);
+		$this->assertSame( array( $second ), array_column( $result['terms'], 'id' ) );
+		$result = ProductTermsQuery::execute(
+			array(
+				'taxonomy' => 'product_tag',
+				'include'  => array( $first, $second ),
+				'offset'   => 1,
+				'order'    => 'desc',
+			)
+		);
+		$this->assertSame( array( $first ), array_column( $result['terms'], 'id' ) );
+	}
+
+	/**
+	 * @testdox Should return image metadata with valid dates and omit missing images.
+	 * @testWith ["product_cat"]
+	 *           ["product_brand"]
+	 * @param string $taxonomy Taxonomy.
+	 */
+	public function test_image_metadata( string $taxonomy ): void {
+		update_option( 'timezone_string', 'Europe/Madrid' );
+		$id       = self::factory()->term->create( array( 'taxonomy' => $taxonomy ) );
+		$image_id = self::factory()->post->create(
+			array(
+				'post_type'      => 'attachment',
+				'post_title'     => 'Summer image',
+				'post_mime_type' => 'image/jpeg',
+				'guid'           => 'https://example.test/summer.jpg',
+			)
+		);
+		update_post_meta( $image_id, '_wp_attachment_image_alt', 'Summer products' );
+		update_term_meta( $id, 'thumbnail_id', $image_id );
+		$ability = new \WP_Ability( ProductTermsQuery::get_name(), ProductTermsQuery::get_registration_args() );
+		$result  = $ability->execute(
+			array(
+				'taxonomy' => $taxonomy,
+				'include'  => array( $id ),
+			)
+		);
+		$this->assertIsArray( $result );
+		$image = $result['terms'][0]['image'];
+		$this->assertStringEndsWith( '+00:00', $image['date_created_gmt'] );
+		$this->assertStringEndsWith( '+00:00', $image['date_modified_gmt'] );
+		$this->assertSame( $image_id, $image['id'] );
+		$this->assertSame( 'Summer image', $image['name'] );
+		$this->assertSame( 'Summer products', $image['alt'] );
+		$this->assertSame( wp_get_attachment_url( $image_id ), $image['src'] );
+		foreach ( array( 'date_created', 'date_created_gmt', 'date_modified', 'date_modified_gmt' ) as $field ) {
+			$this->assertInstanceOf( \DateTimeImmutable::class, \DateTimeImmutable::createFromFormat( DATE_RFC3339, $image[ $field ] ) );
+		}
+		add_filter( 'wp_get_attachment_url', '__return_false' );
+		$result = $ability->execute(
+			array(
+				'taxonomy' => $taxonomy,
+				'include'  => array( $id ),
+			)
+		);
+		remove_filter( 'wp_get_attachment_url', '__return_false' );
+		$this->assertIsArray( $result );
+		$this->assertArrayNotHasKey( 'image', $result['terms'][0] );
+		update_term_meta( $id, 'thumbnail_id', 99999999 );
+		$result = $ability->execute(
+			array(
+				'taxonomy' => $taxonomy,
+				'include'  => array( $id ),
+			)
+		);
+		$this->assertArrayNotHasKey( 'image', $result['terms'][0] );
+	}
+
+	/**
+	 * @testdox Should enforce read permissions and validate taxonomy and pagination through the ability.
+	 */
+	public function test_permissions_and_validation(): void {
+		$ability = new \WP_Ability( ProductTermsQuery::get_name(), ProductTermsQuery::get_registration_args() );
+		$this->assertWPError( $ability->execute( array( 'taxonomy' => 'category' ) ) );
+		$this->assertWPError(
+			$ability->execute(
+				array(
+					'taxonomy' => 'product_cat',
+					'per_page' => 0,
+				)
+			)
+		);
+		$this->assertWPError( $ability->execute( array() ) );
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'customer' ) ) );
+		$this->assertWPError( $ability->execute( array( 'taxonomy' => 'product_cat' ) ) );
+		wp_set_current_user( 0 );
+		$this->assertWPError( $ability->execute( array( 'taxonomy' => 'product_cat' ) ) );
+	}
+
+	/**
+	 * @testdox Should reject an empty include list instead of returning every term.
+	 */
+	public function test_empty_include_is_rejected(): void {
+		self::factory()->term->create( array( 'taxonomy' => 'product_tag' ) );
+		$ability = new \WP_Ability( ProductTermsQuery::get_name(), ProductTermsQuery::get_registration_args() );
+		$this->assertWPError(
+			$ability->execute(
+				array(
+					'taxonomy' => 'product_tag',
+					'include'  => array(),
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should expose extension schema and WP_Term fields only while the feature is enabled.
+	 * @testWith ["product_cat"]
+	 *           ["product_tag"]
+	 *           ["product_brand"]
+	 * @param string $taxonomy Taxonomy.
+	 */
+	public function test_extension_fields( string $taxonomy ): void {
+		$id       = self::factory()->term->create( array( 'taxonomy' => $taxonomy ) );
+		$other_id = self::factory()->term->create( array( 'taxonomy' => $taxonomy ) );
+		$input    = array(
+			'taxonomy' => $taxonomy,
+			'include'  => array( $id, $other_id ),
+		);
+		$baseline = ProductTermsQuery::execute( $input );
+		$schema   = ProductTermsQuery::get_registration_args()['output_schema'];
+		update_option( 'woocommerce_feature_extensible_abilities_enabled', 'yes' );
+		$this->assertSame( $baseline, ProductTermsQuery::execute( $input ) );
+		$this->assertSame( $schema, ProductTermsQuery::get_registration_args()['output_schema'] );
+		add_action( 'woocommerce_extensible_abilities_resources_init', array( AbilitiesLoader::class, 'register_core_resources' ), 0 );
+		do_action( 'woocommerce_extensible_abilities_resources_init' );
+		add_action(
+			'woocommerce_extensible_abilities_init',
+			static function () {
+				ExtensibleAbilities::register_field(
+					array(
+						'resource'     => 'product_term',
+						'namespace'    => 'test-ext',
+						'field'        => 'taxonomy',
+						'schema'       => array(
+							'type'  => 'string',
+							'title' => 'Taxonomy',
+						),
+						'get_callback' => static function ( \WP_Term $term ) {
+								return $term->taxonomy;
+						},
+					)
+				);
+			}
+		);
+		do_action( 'woocommerce_extensible_abilities_init' );
+		$ability = new \WP_Ability( ProductTermsQuery::get_name(), ProductTermsQuery::get_registration_args() );
+		$result  = $ability->execute( $input );
+		$this->assertIsArray( $result );
+		$this->assertCount( 2, $result['terms'] );
+		foreach ( $result['terms'] as $term ) {
+			$this->assertSame( $taxonomy, $term['extensions']['test-ext']['taxonomy'] );
+		}
+		$this->assertSame( 'string', $ability->get_output_schema()['properties']['terms']['items']['properties']['extensions']['properties']['test-ext']['properties']['taxonomy']['type'] );
+		update_option( 'woocommerce_feature_extensible_abilities_enabled', 'no' );
+		$this->assertSame( $baseline, ProductTermsQuery::execute( $input ) );
+		$this->assertSame( $schema, ProductTermsQuery::get_registration_args()['output_schema'] );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index f2de3cedffc..a8c2d903fac 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -985,7 +985,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {

 		$resources = new \ReflectionProperty( ExtensibleAbilities::class, 'resources' );
 		$resources->setAccessible( true );
-		$this->assertSame( array( 'product', 'order', 'order_item' ), array_keys( $resources->getValue() ) );
+		$this->assertSame( array( 'product', 'order', 'order_item', 'product_term' ), array_keys( $resources->getValue() ) );
 	}

 	/**