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() ) );
}
/**