From 0068c0b530d9e4bc83891a180cfee51c82ec7d67 Mon Sep 17 00:00:00 2001 From: robelsust Date: Sun, 25 Oct 2026 15:18:43 +0600 Subject: [PATCH] Abilities API: Allow abilities to belong to multiple categories. Extends WP_Ability and the Abilities API registry to support multiple categories per ability while preserving backwards compatibility: - Added $categories property, get_categories(), and has_category() to WP_Ability. - In WP_Ability::prepare_properties() and WP_Abilities_Registry::register(), accept categories and category, normalize to a unique list of valid slugs, validate every category exists via wp_has_ability_category(), and retain the first category as the primary category for get_category(). - Updated wp_get_abilities() to check $ability->has_category( $category ) and added $categories filter argument. - Updated WP_REST_Abilities_V1_List_Controller to expose categories in item response and schema, and support categories filter argument in collection parameters. - Added comprehensive unit tests covering getters, has_category, validation, registry checks, filtering, and REST endpoint responses. Props therssoftware, raftaar1191. Fixes #66367. --- src/wp-includes/abilities-api.php | 20 ++- .../class-wp-abilities-registry.php | 90 +++++++++---- .../abilities-api/class-wp-ability.php | 81 +++++++++++- ...s-wp-rest-abilities-v1-list-controller.php | 25 ++++ .../phpunit/tests/abilities-api/wpAbility.php | 121 ++++++++++++++++++ .../tests/abilities-api/wpGetAbilities.php | 73 +++++++++++ .../tests/abilities-api/wpRegisterAbility.php | 73 +++++++++++ .../wpRestAbilitiesV1ListController.php | 116 ++++++++++++++++- tests/qunit/fixtures/wp-api-generated.js | 8 ++ 9 files changed, 570 insertions(+), 37 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index 764261d3313a4..9386c3a754bbb 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -280,6 +280,7 @@ * @since 6.9.0 * @since 7.1.0 Added the `public` meta argument. * @since 7.2.0 The `category` argument is now optional and defaults to `uncategorized`. + * @since 7.2.0 Added the `categories` argument. * @since 7.2.0 Added the `required_capabilities` argument. * * @see WP_Abilities_Registry::register() @@ -297,9 +298,11 @@ * @type string $label Required. The human-readable label for the ability. * @type string $description Required. A detailed description of what the ability does * and when it should be used. - * @type string $category Optional. The ability category slug this ability belongs to. + * @type string $category Optional. The primary ability category slug this ability belongs to. * Defaults to `uncategorized`. Custom categories must be registered * via `wp_register_ability_category()` before registering the ability. + * @type list $categories Optional. List of ability category slugs this ability belongs to. + * Each custom category must be registered via `wp_register_ability_category()`. * @type callable $execute_callback Required. A callback function to execute when the ability is invoked. * Receives optional mixed input data and must return either a result * value (any type) or a `WP_Error` object on failure. @@ -544,6 +547,7 @@ function wp_get_ability( string $name ): ?WP_Ability { * @since 6.9.0 * @since 7.1.0 Added the `$args` parameter for filtering support. * @since 7.2.0 Added the `has_required_capabilities` argument. + * @since 7.2.0 Added the `categories` argument and updated `category` to match any assigned category. * * @see WP_Abilities_Registry::get_all_registered() * @@ -551,8 +555,10 @@ function wp_get_ability( string $name ): ?WP_Ability { * Optional. Arguments to filter the returned abilities. Default empty array. * Omitted filters do not restrict the results. * - * @type string $category Optional. Filter by category slug. Only abilities whose category - * exactly matches the given slug are included. + * @type string $category Optional. Filter by category slug. Only abilities that belong to + * the given category are included. + * @type list $categories Optional. Filter by category slugs. Only abilities that belong to + * at least one of the given categories are included. * @type string $namespace Optional. Filter by ability namespace prefix. Pass the namespace * without a trailing slash, e.g. `'woocommerce'` matches * `'woocommerce/create-order'`. @@ -585,6 +591,7 @@ function wp_get_abilities( array $args = array() ): array { $abilities = $registry->get_all_registered(); $category = isset( $args['category'] ) && is_string( $args['category'] ) ? $args['category'] : ''; + $categories = isset( $args['categories'] ) && is_array( $args['categories'] ) ? array_filter( $args['categories'], 'is_string' ) : array(); $namespace = isset( $args['namespace'] ) && is_string( $args['namespace'] ) ? rtrim( $args['namespace'], '/' ) . '/' : ''; $meta = isset( $args['meta'] ) && is_array( $args['meta'] ) ? $args['meta'] : array(); $has_required_capabilities = ! empty( $args['has_required_capabilities'] ); @@ -595,7 +602,12 @@ function wp_get_abilities( array $args = array() ): array { foreach ( $abilities as $name => $ability ) { // Step 1a: Filter by category. - if ( '' !== $category && $ability->get_category() !== $category ) { + if ( '' !== $category && ! $ability->has_category( $category ) ) { + continue; + } + + // Step 1a2: Filter by categories. + if ( ! empty( $categories ) && empty( array_intersect( $categories, $ability->get_categories() ) ) ) { continue; } diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index fe41f8e0f64a7..e675c2c17aaa5 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -42,6 +42,7 @@ final class WP_Abilities_Registry { * @since 6.9.0 * @since 7.1.0 Added the `public` meta argument. * @since 7.2.0 The `category` argument is now optional and defaults to `uncategorized`. + * @since 7.2.0 Added the `categories` argument and support for multiple categories. * @since 7.2.0 Added the `required_capabilities` argument. * * @see wp_register_ability() @@ -54,8 +55,9 @@ final class WP_Abilities_Registry { * * @type string $label The human-readable label for the ability. * @type string $description A detailed description of what the ability does. - * @type string $category Optional. The ability category slug this ability belongs to. + * @type string $category Optional. The primary ability category slug this ability belongs to. * Defaults to `uncategorized`. + * @type list $categories Optional. The list of ability category slugs this ability belongs to. * @type callable $execute_callback A callback function to execute when the ability is invoked. * Receives optional mixed input and returns mixed result or WP_Error. * @type callable $permission_callback A callback function to check permissions before execution. @@ -120,6 +122,7 @@ public function register( string $name, array $args ): ?WP_Ability { * @since 6.9.0 * @since 7.1.0 Added the `public` meta argument. * @since 7.2.0 The `category` argument is now optional and defaults to `uncategorized`. + * @since 7.2.0 Added the `categories` argument. * @since 7.2.0 Added the `required_capabilities` argument. * * @param array $args { @@ -127,8 +130,9 @@ public function register( string $name, array $args ): ?WP_Ability { * * @type string $label The human-readable label for the ability. * @type string $description A detailed description of what the ability does. - * @type string $category Optional. The ability category slug this ability belongs to. + * @type string $category Optional. The primary ability category slug this ability belongs to. * Defaults to `uncategorized`. + * @type list $categories Optional. The list of ability category slugs this ability belongs to. * @type callable $execute_callback A callback function to execute when the ability is invoked. * Receives optional mixed input and returns mixed result or WP_Error. * @type callable $permission_callback A callback function to check permissions before execution. @@ -156,31 +160,71 @@ public function register( string $name, array $args ): ?WP_Ability { */ $args = apply_filters( 'wp_register_ability_args', $args, $name ); - if ( ! array_key_exists( 'category', $args ) ) { - $args['category'] = 'uncategorized'; + if ( array_key_exists( 'category', $args ) ) { + if ( ! is_string( $args['category'] ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Ability category must be a string.' ), + '6.9.0' + ); + return null; + } } - if ( ! is_string( $args['category'] ) ) { - _doing_it_wrong( - __METHOD__, - __( 'Ability category must be a string.' ), - '6.9.0' - ); - return null; + if ( array_key_exists( 'categories', $args ) ) { + if ( ! is_array( $args['categories'] ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Ability categories must be an array of strings.' ), + '7.2.0' + ); + return null; + } + + foreach ( $args['categories'] as $cat ) { + if ( ! is_string( $cat ) || '' === trim( $cat ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Ability categories must contain only non-empty strings.' ), + '7.2.0' + ); + return null; + } + } } - if ( ! wp_has_ability_category( $args['category'] ) ) { - _doing_it_wrong( - __METHOD__, - sprintf( - /* translators: %1$s: ability category slug, %2$s: ability name */ - __( 'Ability category "%1$s" is not registered. Please register the ability category before assigning it to ability "%2$s".' ), - esc_html( $args['category'] ), - esc_html( $name ) - ), - '6.9.0' - ); - return null; + if ( isset( $args['category'] ) && isset( $args['categories'] ) ) { + $categories = array_merge( array( $args['category'] ), $args['categories'] ); + } elseif ( isset( $args['category'] ) ) { + $categories = array( $args['category'] ); + } elseif ( isset( $args['categories'] ) ) { + $categories = $args['categories']; + } else { + $categories = array( 'uncategorized' ); + } + + if ( empty( $categories ) ) { + $categories = array( 'uncategorized' ); + } + + $categories = array_values( array_unique( $categories ) ); + $args['categories'] = $categories; + $args['category'] = $categories[0]; + + foreach ( $categories as $cat ) { + if ( ! wp_has_ability_category( $cat ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %1$s: ability category slug, %2$s: ability name */ + __( 'Ability category "%1$s" is not registered. Please register the ability category before assigning it to ability "%2$s".' ), + esc_html( $cat ), + esc_html( $name ) + ), + '6.9.0' + ); + return null; + } } // The class is only used to instantiate the ability, and is not a property of the ability itself. diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 8b007de1707fd..65c51764836d5 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -84,13 +84,21 @@ class WP_Ability { protected $description; /** - * The ability category. + * The primary ability category. * * @since 6.9.0 * @var string */ protected $category; + /** + * The ability categories. + * + * @since 7.2.0 + * @var list + */ + protected $categories = array(); + /** * The optional ability input schema. * @@ -158,7 +166,8 @@ class WP_Ability { * * @type string $label The human-readable label for the ability. * @type string $description A detailed description of what the ability does. - * @type string $category The ability category slug this ability belongs to. + * @type string $category Optional. The primary ability category slug this ability belongs to. + * @type list $categories Optional. The list of ability category slugs this ability belongs to. * @type callable $execute_callback A callback function to execute when the ability is invoked. * Receives optional mixed input and returns mixed result or WP_Error. * @type callable $permission_callback A callback function to check permissions before execution. @@ -233,7 +242,8 @@ public function __construct( string $name, array $args ) { * * @type string $label The human-readable label for the ability. * @type string $description A detailed description of what the ability does. - * @type string $category The ability category slug this ability belongs to. + * @type string $category Optional. The primary ability category slug this ability belongs to. + * @type list $categories Optional. The list of ability category slugs this ability belongs to. * @type callable $execute_callback A callback function to execute when the ability is invoked. * Receives optional mixed input and returns mixed result or WP_Error. * @type callable $permission_callback A callback function to check permissions before execution. @@ -270,7 +280,8 @@ public function __construct( string $name, array $args ) { * * @type string $label The human-readable label for the ability. * @type string $description A detailed description of what the ability does. - * @type string $category The ability category slug this ability belongs to. + * @type string $category The primary ability category slug this ability belongs to. + * @type list $categories The list of ability category slugs this ability belongs to. * @type callable $execute_callback A callback function to execute when the ability is invoked. * Receives optional mixed input and returns mixed result or WP_Error. * @type callable $permission_callback A callback function to check permissions before execution. @@ -316,12 +327,44 @@ protected function prepare_properties( array $args ): array { ); } - if ( empty( $args['category'] ) || ! is_string( $args['category'] ) ) { + $categories = array(); + + if ( isset( $args['category'] ) ) { + if ( ! is_string( $args['category'] ) || '' === trim( $args['category'] ) ) { + throw new InvalidArgumentException( + __( 'The ability properties must contain a `category` string.' ) + ); + } + $categories[] = trim( $args['category'] ); + } + + if ( isset( $args['categories'] ) ) { + if ( ! is_array( $args['categories'] ) || empty( $args['categories'] ) ) { + throw new InvalidArgumentException( + __( 'The ability properties `categories` must be a non-empty array of strings.' ) + ); + } + + foreach ( $args['categories'] as $cat ) { + if ( ! is_string( $cat ) || '' === trim( $cat ) ) { + throw new InvalidArgumentException( + __( 'The ability properties `categories` must contain only non-empty strings.' ) + ); + } + $categories[] = trim( $cat ); + } + } + + if ( empty( $categories ) ) { throw new InvalidArgumentException( __( 'The ability properties must contain a `category` string.' ) ); } + $categories = array_values( array_unique( $categories ) ); + $args['categories'] = $categories; + $args['category'] = $categories[0]; + // If we are not overriding `ability_class` parameter during instantiation, then we need to validate the execute_callback. if ( get_class( $this ) === self::class && ( empty( $args['execute_callback'] ) || ! is_callable( $args['execute_callback'] ) ) ) { throw new InvalidArgumentException( @@ -451,16 +494,40 @@ public function get_description(): string { } /** - * Retrieves the ability category for the ability. + * Retrieves the primary ability category for the ability. * * @since 6.9.0 * - * @return string The ability category for the ability. + * @return string The primary ability category for the ability. */ public function get_category(): string { return $this->category; } + /** + * Retrieves all ability categories for the ability. + * + * @since 7.2.0 + * + * @return list The ability category slugs for the ability. + */ + public function get_categories(): array { + return $this->categories; + } + + /** + * Checks whether the ability belongs to a specific category. + * + * @since 7.2.0 + * + * @param string $category The ability category slug to check. + * @return bool True if the ability belongs to the category, false otherwise. + */ + public function has_category( string $category ): bool { + return in_array( $category, $this->categories, true ); + } + + /** * Retrieves the input schema for the ability. * diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index da2bb71fc51fd..af07ad3b687fc 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -94,6 +94,10 @@ public function get_items( $request ) { $query_args['category'] = $request['category']; } + if ( ! empty( $request['categories'] ) ) { + $query_args['categories'] = $request['categories']; + } + if ( ! empty( $request['namespace'] ) ) { $query_args['namespace'] = $request['namespace']; } @@ -212,6 +216,7 @@ public function prepare_item_for_response( $ability, $request ) { 'label' => $ability->get_label(), 'description' => $ability->get_description(), 'category' => $ability->get_category(), + 'categories' => $ability->get_categories(), 'required_capabilities' => $ability->get_required_capabilities(), 'input_schema' => wp_prepare_json_schema_for_client( $ability->get_input_schema() ), 'output_schema' => wp_prepare_json_schema_for_client( $ability->get_output_schema() ), @@ -251,6 +256,7 @@ public function prepare_item_for_response( $ability, $request ) { * @since 6.9.0 * @since 7.1.0 Added the `meta.public` property. * @since 7.2.0 Added the `required_capabilities` property. + * @since 7.2.0 Added the `categories` property. * * @return array Item schema data. */ @@ -284,6 +290,15 @@ public function get_item_schema(): array { 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, ), + 'categories' => array( + 'description' => __( 'Ability categories this ability belongs to.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), 'required_capabilities' => array( 'description' => __( 'Capabilities the current user must have to run the ability.' ), 'type' => 'array', @@ -348,6 +363,7 @@ public function get_item_schema(): array { * @since 6.9.0 * @since 7.1.0 Added the `namespace` and `meta` parameters and the `rest_abilities_collection_params` filter. * @since 7.2.0 Added the `has_required_capabilities` parameter. + * @since 7.2.0 Added the `categories` parameter. * * @return array Collection parameters. */ @@ -373,6 +389,15 @@ public function get_collection_params(): array { 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ), + 'categories' => array( + 'description' => __( 'Limit results to abilities belonging to any of the specified ability categories.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + 'sanitize_callback' => 'wp_parse_slug_list', + 'validate_callback' => 'rest_validate_request_arg', + ), 'namespace' => array( 'description' => __( 'Limit results to abilities in a specific namespace.' ), 'type' => 'string', diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php index e446c3faf22db..99c3743831070 100644 --- a/tests/phpunit/tests/abilities-api/wpAbility.php +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -2440,4 +2440,125 @@ public function test_check_permissions_custom_class_with_required_capabilities_o $this->assertTrue( $ability->check_permissions() ); } + + /** + * Tests that providing a single category string sets both get_category and get_categories. + * + * @ticket 66367 + * + * @covers WP_Ability::get_category + * @covers WP_Ability::get_categories + * @covers WP_Ability::has_category + */ + public function test_single_category_sets_get_category_and_get_categories() { + $args = self::$test_ability_properties; + $args['category'] = 'math'; + unset( $args['categories'] ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertSame( 'math', $ability->get_category() ); + $this->assertSame( array( 'math' ), $ability->get_categories() ); + $this->assertTrue( $ability->has_category( 'math' ) ); + $this->assertFalse( $ability->has_category( 'system' ) ); + } + + /** + * Tests that providing a categories array sets both get_category and get_categories. + * + * @ticket 66367 + * + * @covers WP_Ability::get_category + * @covers WP_Ability::get_categories + * @covers WP_Ability::has_category + */ + public function test_categories_array_sets_get_category_and_get_categories() { + $args = self::$test_ability_properties; + $args['categories'] = array( 'math', 'system' ); + unset( $args['category'] ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertSame( 'math', $ability->get_category() ); + $this->assertSame( array( 'math', 'system' ), $ability->get_categories() ); + $this->assertTrue( $ability->has_category( 'math' ) ); + $this->assertTrue( $ability->has_category( 'system' ) ); + $this->assertFalse( $ability->has_category( 'general' ) ); + } + + /** + * Tests that providing both category and categories merges them with category as primary. + * + * @ticket 66367 + * + * @covers WP_Ability::get_category + * @covers WP_Ability::get_categories + * @covers WP_Ability::has_category + */ + public function test_both_category_and_categories_merges_with_category_as_primary() { + $args = self::$test_ability_properties; + $args['category'] = 'primary'; + $args['categories'] = array( 'secondary', 'primary' ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertSame( 'primary', $ability->get_category() ); + $this->assertSame( array( 'primary', 'secondary' ), $ability->get_categories() ); + $this->assertTrue( $ability->has_category( 'primary' ) ); + $this->assertTrue( $ability->has_category( 'secondary' ) ); + } + + /** + * Tests that providing empty categories array throws InvalidArgumentException. + * + * @ticket 66367 + * + * @covers WP_Ability::prepare_properties + */ + public function test_empty_categories_throws_exception() { + $args = self::$test_ability_properties; + $args['categories'] = array(); + unset( $args['category'] ); + + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'The ability properties `categories` must be a non-empty array of strings.' ); + + new WP_Ability( self::$test_ability_name, $args ); + } + + /** + * Tests that categories array with non-string items throws InvalidArgumentException. + * + * @ticket 66367 + * + * @covers WP_Ability::prepare_properties + */ + public function test_invalid_categories_item_throws_exception() { + $args = self::$test_ability_properties; + $args['categories'] = array( 'valid', 123 ); + unset( $args['category'] ); + + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'The ability properties `categories` must contain only non-empty strings.' ); + + new WP_Ability( self::$test_ability_name, $args ); + } + + /** + * Tests that categories array with empty string throws InvalidArgumentException. + * + * @ticket 66367 + * + * @covers WP_Ability::prepare_properties + */ + public function test_empty_string_in_categories_throws_exception() { + $args = self::$test_ability_properties; + $args['categories'] = array( 'valid', ' ' ); + unset( $args['category'] ); + + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'The ability properties `categories` must contain only non-empty strings.' ); + + new WP_Ability( self::$test_ability_name, $args ); + } } diff --git a/tests/phpunit/tests/abilities-api/wpGetAbilities.php b/tests/phpunit/tests/abilities-api/wpGetAbilities.php index 3618a4070bc34..6e9bcacc124fc 100644 --- a/tests/phpunit/tests/abilities-api/wpGetAbilities.php +++ b/tests/phpunit/tests/abilities-api/wpGetAbilities.php @@ -185,6 +185,79 @@ public function test_filter_by_nonexistent_category_returns_empty(): void { $this->assertSame( array(), $result ); } + /** + * Tests filtering by single category matches an ability where that category is secondary. + * + * @ticket 66367 + */ + public function test_filter_by_single_category_matches_secondary_category(): void { + $this->simulate_wp_abilities_init(); + + $this->register_test_ability( 'test/multi-cat', array( 'categories' => array( 'math', 'text' ) ) ); + $this->register_test_ability( 'test/math-only', array( 'category' => 'math' ) ); + $this->register_test_ability( 'test/media-only', array( 'category' => 'media' ) ); + + $result = wp_get_abilities( array( 'category' => 'text' ) ); + $names = array_map( + static function ( WP_Ability $a ) { + return $a->get_name(); + }, + $result + ); + + $this->assertContains( 'test/multi-cat', $names ); + $this->assertNotContains( 'test/math-only', $names ); + $this->assertNotContains( 'test/media-only', $names ); + } + + /** + * Tests filtering by categories array matches abilities belonging to any of the categories. + * + * @ticket 66367 + */ + public function test_filter_by_categories_matches_any_category(): void { + $this->simulate_wp_abilities_init(); + + $this->register_test_ability( 'test/math-add', array( 'category' => 'math' ) ); + $this->register_test_ability( 'test/text-upper', array( 'category' => 'text' ) ); + $this->register_test_ability( 'test/media-crop', array( 'category' => 'media' ) ); + + $result = wp_get_abilities( array( 'categories' => array( 'math', 'media' ) ) ); + $names = array_map( + static function ( WP_Ability $a ) { + return $a->get_name(); + }, + $result + ); + + $this->assertContains( 'test/math-add', $names ); + $this->assertContains( 'test/media-crop', $names ); + $this->assertNotContains( 'test/text-upper', $names ); + } + + /** + * Tests filtering by categories array when ability has multiple categories. + * + * @ticket 66367 + */ + public function test_filter_by_categories_with_multiple_categories_on_ability(): void { + $this->simulate_wp_abilities_init(); + + $this->register_test_ability( 'test/multi-cat', array( 'categories' => array( 'math', 'text' ) ) ); + $this->register_test_ability( 'test/media-only', array( 'category' => 'media' ) ); + + $result = wp_get_abilities( array( 'categories' => array( 'text' ) ) ); + $names = array_map( + static function ( WP_Ability $a ) { + return $a->get_name(); + }, + $result + ); + + $this->assertContains( 'test/multi-cat', $names ); + $this->assertNotContains( 'test/media-only', $names ); + } + // ------------------------------------------------------------------------- // Namespace filter // ------------------------------------------------------------------------- diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index a3316c5756454..988d9ae8fabb9 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -705,4 +705,77 @@ public function test_get_all_registered_abilities() { // Keep assertEquals() because the objects are intentionally compared by value. $this->assertEquals( $expected, $result ); } + + /** + * Tests registering an ability with multiple categories. + * + * @ticket 66367 + */ + public function test_register_ability_with_multiple_categories(): void { + $this->simulate_doing_wp_abilities_init_action(); + + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_categories_init'; + wp_register_ability_category( + 'system', + array( + 'label' => 'System', + 'description' => 'System category.', + ) + ); + array_pop( $wp_current_filter ); + + $args = self::$test_ability_args; + $args['categories'] = array( 'math', 'system' ); + unset( $args['category'] ); + + $ability = wp_register_ability( 'test/multi-cat', $args ); + + wp_unregister_ability_category( 'system' ); + wp_unregister_ability( 'test/multi-cat' ); + + $this->assertInstanceOf( WP_Ability::class, $ability ); + $this->assertSame( 'math', $ability->get_category() ); + $this->assertSame( array( 'math', 'system' ), $ability->get_categories() ); + $this->assertTrue( $ability->has_category( 'math' ) ); + $this->assertTrue( $ability->has_category( 'system' ) ); + } + + /** + * Tests registering an ability with an unregistered category in categories fails. + * + * @ticket 66367 + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_ability_with_unregistered_category_in_categories_fails(): void { + $this->simulate_doing_wp_abilities_init_action(); + + $args = self::$test_ability_args; + $args['categories'] = array( 'math', 'nonexistent-category' ); + unset( $args['category'] ); + + $result = wp_register_ability( 'test/invalid-cat', $args ); + + $this->assertNull( $result ); + } + + /** + * Tests registering an ability with invalid categories type fails. + * + * @ticket 66367 + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_ability_with_invalid_categories_type_fails(): void { + $this->simulate_doing_wp_abilities_init_action(); + + $args = self::$test_ability_args; + $args['categories'] = 'not-an-array'; + unset( $args['category'] ); + + $result = wp_register_ability( 'test/invalid-cat-type', $args ); + + $this->assertNull( $result ); + } } diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index feb442fd1e462..043f3a7b85bc9 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -330,11 +330,12 @@ public function test_get_item(): void { $this->assertSame( 200, $response->get_status() ); $data = $response->get_data(); - $this->assertCount( 8, $data, 'Response should contain all fields.' ); + $this->assertCount( 9, $data, 'Response should contain all fields.' ); $this->assertSame( 'test/calculator', $data['name'] ); $this->assertSame( 'Calculator', $data['label'] ); $this->assertSame( 'Performs basic calculations', $data['description'] ); $this->assertSame( 'math', $data['category'] ); + $this->assertSame( array( 'math' ), $data['categories'] ); $this->assertSame( array(), $data['required_capabilities'] ); $this->assertArrayHasKey( 'input_schema', $data ); $this->assertArrayHasKey( 'output_schema', $data ); @@ -377,10 +378,11 @@ public function test_get_item_with_embed_context(): void { $this->assertSame( 200, $response->get_status() ); $data = $response->get_data(); - $this->assertCount( 3, $data, 'Response should only contain the fields for embed context.' ); + $this->assertCount( 4, $data, 'Response should only contain the fields for embed context.' ); $this->assertSame( 'test/calculator', $data['name'] ); $this->assertSame( 'Calculator', $data['label'] ); $this->assertSame( 'math', $data['category'] ); + $this->assertSame( array( 'math' ), $data['categories'] ); } /** @@ -669,7 +671,7 @@ public function test_get_schema(): void { $properties = $schema['properties']; // Assert the count of properties to catch when new keys are added - $this->assertCount( 8, $properties, 'Schema should have exactly 8 properties. If this fails, update this test to include the new property.' ); + $this->assertCount( 9, $properties, 'Schema should have exactly 9 properties. If this fails, update this test to include the new property.' ); // Check all expected properties exist $this->assertArrayHasKey( 'name', $properties ); @@ -679,6 +681,7 @@ public function test_get_schema(): void { $this->assertArrayHasKey( 'output_schema', $properties ); $this->assertArrayHasKey( 'meta', $properties ); $this->assertArrayHasKey( 'category', $properties ); + $this->assertArrayHasKey( 'categories', $properties ); $this->assertArrayHasKey( 'required_capabilities', $properties ); } @@ -698,6 +701,25 @@ public function test_get_schema_meta_declares_public(): void { $this->assertSame( 'boolean', $meta_properties['public']['type'] ); } + /** + * Test that the item schema declares the `categories` property. + * + * @ticket 66367 + */ + public function test_get_schema_declares_categories(): void { + $request = new WP_REST_Request( 'OPTIONS', '/wp-abilities/v1/abilities' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $properties = $data['schema']['properties']; + + $this->assertArrayHasKey( 'categories', $properties ); + $this->assertSame( 'array', $properties['categories']['type'] ); + $this->assertSame( array( 'type' => 'string' ), $properties['categories']['items'] ); + $this->assertSame( array( 'view', 'edit', 'embed' ), $properties['categories']['context'] ); + $this->assertTrue( $properties['categories']['readonly'] ); + } + /** * Test ability name with valid special characters. * @@ -874,6 +896,94 @@ public function test_filter_by_nonexistent_category(): void { $this->assertEmpty( $data, 'Should return empty array for non-existent category' ); } + /** + * Test filtering abilities by categories. + * + * @ticket 66367 + */ + public function test_filter_by_categories(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( 'categories', array( 'math', 'system' ) ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertIsArray( $data ); + + $ability_names = wp_list_pluck( $data, 'name' ); + $this->assertContains( 'test/calculator', $ability_names ); + $this->assertContains( 'test/system-info', $ability_names ); + } + + /** + * Test filtering abilities by comma-separated categories string. + * + * @ticket 66367 + */ + public function test_filter_by_categories_comma_separated(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( 'categories', 'math,system' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertIsArray( $data ); + + $ability_names = wp_list_pluck( $data, 'name' ); + $this->assertContains( 'test/calculator', $ability_names ); + $this->assertContains( 'test/system-info', $ability_names ); + } + + /** + * Test filtering ability assigned to multiple categories. + * + * @ticket 66367 + */ + public function test_filter_ability_with_multiple_categories(): void { + $this->register_test_ability( + 'test/multi-category-ability', + array( + 'label' => 'Multi Category Ability', + 'description' => 'Ability belonging to multiple categories.', + 'categories' => array( 'general', 'math' ), + 'execute_callback' => '__return_true', + 'permission_callback' => '__return_true', + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + // Filter by secondary category. + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( 'category', 'math' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $ability_names = wp_list_pluck( $response->get_data(), 'name' ); + $this->assertContains( 'test/multi-category-ability', $ability_names ); + + // Filter by primary category. + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( 'category', 'general' ); + $request->set_param( 'per_page', 100 ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $ability_names = wp_list_pluck( $response->get_data(), 'name' ); + $this->assertContains( 'test/multi-category-ability', $ability_names ); + + // Check response item structure. + $item_request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/multi-category-ability' ); + $item_response = $this->server->dispatch( $item_request ); + $this->assertSame( 200, $item_response->get_status() ); + $item_data = $item_response->get_data(); + $this->assertSame( 'general', $item_data['category'] ); + $this->assertSame( array( 'general', 'math' ), $item_data['categories'] ); + + wp_unregister_ability( 'test/multi-category-ability' ); + } + /** * Test filtering abilities by namespace. * diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index d3ba47bef2fb6..34f0661b9c2ec 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12749,6 +12749,14 @@ mockedApiResponse.Schema = { "type": "string", "required": false }, + "categories": { + "description": "Limit results to abilities belonging to any of the specified ability categories.", + "type": "array", + "items": { + "type": "string" + }, + "required": false + }, "namespace": { "description": "Limit results to abilities in a specific namespace.", "type": "string",