From 6b88d37bb25c74a717dff5e565e5f42278a7298a Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Wed, 22 Oct 2025 19:14:48 +0200 Subject: [PATCH 01/11] Sync changes from WordPress core after 6.9 beta 1 --- includes/abilities-api.php | 226 +++++++--- .../class-wp-abilities-category-registry.php | 247 ----------- .../class-wp-abilities-registry.php | 191 +++++---- .../class-wp-ability-categories-registry.php | 254 +++++++++++ .../class-wp-ability-category.php | 123 +++--- includes/abilities-api/class-wp-ability.php | 380 ++++++++-------- includes/bootstrap.php | 16 +- .../rest-api/class-wp-rest-abilities-init.php | 19 +- ...st-abilities-v1-categories-controller.php} | 125 +++--- ...-wp-rest-abilities-v1-list-controller.php} | 126 +++--- ...s-wp-rest-abilities-v1-run-controller.php} | 155 +++---- .../abilities-api/wpAbilitiesRegistry.php | 145 ++++--- tests/unit/abilities-api/wpAbility.php | 162 ++++--- ...gory.php => wpAbilityCategoryRegistry.php} | 405 ++++++++---------- tests/unit/abilities-api/wpCoreAbilities.php | 12 +- .../unit/abilities-api/wpRegisterAbility.php | 321 +++++++++----- .../wpRegisterAbilityCategory.php | 365 ++++++++++++++++ tests/unit/rest-api/wpRestAbilitiesInit.php | 63 ++- ...wpRestAbilitiesV1CategoriesController.php} | 208 +++++---- ...hp => wpRestAbilitiesV1ListController.php} | 221 ++++++---- ...php => wpRestAbilitiesV1RunController.php} | 294 ++++++++++--- 21 files changed, 2550 insertions(+), 1508 deletions(-) delete mode 100644 includes/abilities-api/class-wp-abilities-category-registry.php create mode 100644 includes/abilities-api/class-wp-ability-categories-registry.php rename includes/rest-api/endpoints/{class-wp-rest-abilities-categories-controller.php => class-wp-rest-abilities-v1-categories-controller.php} (66%) rename includes/rest-api/endpoints/{class-wp-rest-abilities-list-controller.php => class-wp-rest-abilities-v1-list-controller.php} (73%) rename includes/rest-api/endpoints/{class-wp-rest-abilities-run-controller.php => class-wp-rest-abilities-v1-run-controller.php} (53%) rename tests/unit/abilities-api/{wpAbilityCategory.php => wpAbilityCategoryRegistry.php} (61%) create mode 100644 tests/unit/abilities-api/wpRegisterAbilityCategory.php rename tests/unit/rest-api/{wpRestAbilityCategoriesController.php => wpRestAbilitiesV1CategoriesController.php} (67%) rename tests/unit/rest-api/{wpRestAbilitiesListController.php => wpRestAbilitiesV1ListController.php} (78%) rename tests/unit/rest-api/{wpRestAbilitiesRunController.php => wpRestAbilitiesV1RunController.php} (77%) diff --git a/includes/abilities-api.php b/includes/abilities-api.php index 6672b0c6..9fb10c20 100644 --- a/includes/abilities-api.php +++ b/includes/abilities-api.php @@ -6,7 +6,7 @@ * * @package WordPress * @subpackage Abilities_API - * @since 0.1.0 + * @since 6.9.0 */ declare( strict_types = 1 ); @@ -14,39 +14,39 @@ /** * Registers a new ability using Abilities API. * - * Note: Do not use before the {@see 'abilities_api_init'} hook. + * Note: Should only be used on the {@see 'wp_abilities_api_init'} hook. * - * @since 0.1.0 + * @since 6.9.0 * * @see WP_Abilities_Registry::register() * - * @param string $name The name of the ability. The name must be a string containing a namespace - * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase - * alphanumeric characters, dashes and the forward slash. - * @param array $args An associative array of arguments for the ability. This should include - * `label`, `description`, `category`, `input_schema`, `output_schema`, `execute_callback`, - * `permission_callback`, `meta`, and `ability_class`. - * @return ?\WP_Ability An instance of registered ability on success, null on failure. - * - * @phpstan-param array{ - * label?: string, - * description?: string, - * category?: string, - * execute_callback?: callable( mixed $input= ): (mixed|\WP_Error), - * permission_callback?: callable( mixed $input= ): (bool|\WP_Error), - * input_schema?: array, - * output_schema?: array, - * meta?: array{ - * annotations?: array, - * show_in_rest?: bool, - * ..., - * }, - * ability_class?: class-string<\WP_Ability>, - * ... - * } $args + * @param string $name The name of the ability. The name must be a string containing a namespace + * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase + * alphanumeric characters, dashes and the forward slash. + * @param array $args { + * An associative array of arguments for the 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 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. + * Receives optional mixed input and returns bool or WP_Error. + * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { + * Optional. Additional metadata for the ability. + * + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * } + * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability. + * } + * @return WP_Ability|null An instance of registered ability on success, null on failure. */ function wp_register_ability( string $name, array $args ): ?WP_Ability { - if ( ! did_action( 'abilities_api_init' ) ) { + if ( ! did_action( 'wp_abilities_api_init' ) ) { _doing_it_wrong( __FUNCTION__, sprintf( @@ -55,116 +55,206 @@ function wp_register_ability( string $name, array $args ): ?WP_Ability { 'abilities_api_init', '' . esc_html( $name ) . '' ), - '0.1.0' + '6.9.0' ); return null; } - return WP_Abilities_Registry::get_instance()->register( $name, $args ); + $registry = WP_Abilities_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->register( $name, $args ); } /** - * Unregisters an ability using Abilities API. + * Unregisters an ability from the Abilities API. * - * @since 0.1.0 + * @since 6.9.0 * * @see WP_Abilities_Registry::unregister() * * @param string $name The name of the registered ability, with its namespace. - * @return ?\WP_Ability The unregistered ability instance on success, null on failure. + * @return WP_Ability|null The unregistered ability instance on success, null on failure. */ function wp_unregister_ability( string $name ): ?WP_Ability { - return WP_Abilities_Registry::get_instance()->unregister( $name ); + $registry = WP_Abilities_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->unregister( $name ); +} + +/** + * Checks if an ability is registered. + * + * @since 6.9.0 + * + * @see WP_Abilities_Registry::is_registered() + * + * @param string $name The name of the registered ability, with its namespace. + * @return bool True if the ability is registered, false otherwise. + */ +function wp_has_ability( string $name ): bool { + $registry = WP_Abilities_Registry::get_instance(); + if ( null === $registry ) { + return false; + } + + return $registry->is_registered( $name ); } /** * Retrieves a registered ability using Abilities API. * - * @since 0.1.0 + * @since 6.9.0 * * @see WP_Abilities_Registry::get_registered() * * @param string $name The name of the registered ability, with its namespace. - * @return ?\WP_Ability The registered ability instance, or null if it is not registered. + * @return WP_Ability|null The registered ability instance, or null if it is not registered. */ function wp_get_ability( string $name ): ?WP_Ability { - return WP_Abilities_Registry::get_instance()->get_registered( $name ); + $registry = WP_Abilities_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->get_registered( $name ); } /** * Retrieves all registered abilities using Abilities API. * - * @since 0.1.0 + * @since 6.9.0 * * @see WP_Abilities_Registry::get_all_registered() * - * @return \WP_Ability[] The array of registered abilities. + * @return WP_Ability[] The array of registered abilities. */ function wp_get_abilities(): array { - return WP_Abilities_Registry::get_instance()->get_all_registered(); + $registry = WP_Abilities_Registry::get_instance(); + if ( null === $registry ) { + return array(); + } + + return $registry->get_all_registered(); } /** * Registers a new ability category. * - * @since 0.3.0 + * @since 6.9.0 * - * @see WP_Abilities_Category_Registry::register() + * @see WP_Ability_Categories_Registry::register() * - * @param string $slug The unique slug for the category. Must contain only lowercase - * alphanumeric characters and dashes. - * @param array $args An associative array of arguments for the category. This should - * include `label`, `description`, and optionally `meta`. - * @return ?\WP_Ability_Category The registered category instance on success, null on failure. + * @param string $slug The unique slug for the ability category. Must contain only lowercase + * alphanumeric characters and dashes. + * @param array $args { + * An associative array of arguments for the ability category. * - * @phpstan-param array{ - * label: string, - * description: string, - * meta?: array, - * ... - * } $args + * @type string $label The human-readable label for the ability category. + * @type string $description A description of the ability category. + * @type array $meta Optional. Additional metadata for the ability category. + * } + * @return WP_Ability_Category|null The registered ability category instance on success, null on failure. */ function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_Category { - return WP_Abilities_Category_Registry::get_instance()->register( $slug, $args ); + if ( ! did_action( 'wp_abilities_api_categories_init' ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: abilities_api_categories_init, 2: ability category slug. */ + __( 'Ability categories must be registered on the %1$s action. The ability category %2$s was not registered.' ), + 'wp_abilities_api_categories_init', + '' . esc_html( $slug ) . '' + ), + '6.9.0' + ); + return null; + } + + $registry = WP_Ability_Categories_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->register( $slug, $args ); } /** * Unregisters an ability category. * - * @since 0.3.0 + * @since 6.9.0 * - * @see WP_Abilities_Category_Registry::unregister() + * @see WP_Ability_Categories_Registry::unregister() * - * @param string $slug The slug of the registered category. - * @return ?\WP_Ability_Category The unregistered category instance on success, null on failure. + * @param string $slug The slug of the registered ability category. + * @return WP_Ability_Category|null The unregistered ability category instance on success, null on failure. */ function wp_unregister_ability_category( string $slug ): ?WP_Ability_Category { - return WP_Abilities_Category_Registry::get_instance()->unregister( $slug ); + $registry = WP_Ability_Categories_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->unregister( $slug ); +} + +/** + * Checks if an ability category is registered. + * + * @since 6.9.0 + * + * @see WP_Ability_Categories_Registry::is_registered() + * + * @param string $slug The slug of the ability category. + * @return bool True if the ability category is registered, false otherwise. + */ +function wp_has_ability_category( string $slug ): bool { + $registry = WP_Ability_Categories_Registry::get_instance(); + if ( null === $registry ) { + return false; + } + + return $registry->is_registered( $slug ); } /** * Retrieves a registered ability category. * - * @since 0.3.0 + * @since 6.9.0 * - * @see WP_Abilities_Category_Registry::get_registered() + * @see WP_Ability_Categories_Registry::get_registered() * - * @param string $slug The slug of the registered category. - * @return ?\WP_Ability_Category The registered category instance, or null if it is not registered. + * @param string $slug The slug of the registered ability category. + * @return WP_Ability_Category|null The registered ability category instance, or null if it is not registered. */ function wp_get_ability_category( string $slug ): ?WP_Ability_Category { - return WP_Abilities_Category_Registry::get_instance()->get_registered( $slug ); + $registry = WP_Ability_Categories_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->get_registered( $slug ); } /** * Retrieves all registered ability categories. * - * @since 0.3.0 + * @since 6.9.0 * - * @see WP_Abilities_Category_Registry::get_all_registered() + * @see WP_Ability_Categories_Registry::get_all_registered() * - * @return \WP_Ability_Category[] The array of registered categories. + * @return WP_Ability_Category[] The array of registered ability categories. */ function wp_get_ability_categories(): array { - return WP_Abilities_Category_Registry::get_instance()->get_all_registered(); + $registry = WP_Ability_Categories_Registry::get_instance(); + if ( null === $registry ) { + return array(); + } + + return $registry->get_all_registered(); } diff --git a/includes/abilities-api/class-wp-abilities-category-registry.php b/includes/abilities-api/class-wp-abilities-category-registry.php deleted file mode 100644 index 045a1df6..00000000 --- a/includes/abilities-api/class-wp-abilities-category-registry.php +++ /dev/null @@ -1,247 +0,0 @@ - $args An associative array of arguments for the category. See wp_register_ability_category() for - * details. - * @return ?\WP_Ability_Category The registered category instance on success, null on failure. - * - * @phpstan-param array{ - * label: string, - * description: string, - * meta?: array, - * ... - * } $args - */ - public function register( string $slug, array $args ): ?WP_Ability_Category { - if ( ! doing_action( 'abilities_api_categories_init' ) ) { - _doing_it_wrong( - __METHOD__, - sprintf( - /* translators: 1: abilities_api_categories_init, 2: category slug. */ - esc_html__( 'Categories must be registered during the %1$s action. The category %2$s was not registered.' ), - 'abilities_api_categories_init', - '' . esc_html( $slug ) . '' - ), - '0.3.0' - ); - return null; - } - - if ( $this->is_registered( $slug ) ) { - _doing_it_wrong( - __METHOD__, - /* translators: %s: Category slug. */ - esc_html( sprintf( __( 'Category "%s" is already registered.' ), $slug ) ), - '0.3.0' - ); - return null; - } - - if ( ! preg_match( '/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug ) ) { - _doing_it_wrong( - __METHOD__, - esc_html__( 'Category slug must contain only lowercase alphanumeric characters and dashes.' ), - '0.3.0' - ); - return null; - } - - /** - * Filters the category arguments before they are validated and used to instantiate the category. - * - * @since 0.3.0 - * - * @param array $args The arguments used to instantiate the category. - * @param string $slug The slug of the category. - */ - $args = apply_filters( 'register_ability_category_args', $args, $slug ); - - try { - // WP_Ability_Category::prepare_properties() will throw an exception if the properties are invalid. - $category = new WP_Ability_Category( $slug, $args ); - } catch ( \InvalidArgumentException $e ) { - _doing_it_wrong( - __METHOD__, - esc_html( $e->getMessage() ), - '0.3.0' - ); - return null; - } - - $this->registered_categories[ $slug ] = $category; - return $category; - } - - /** - * Unregisters a category. - * - * Do not use this method directly. Instead, use the `wp_unregister_ability_category()` function. - * - * @since 0.3.0 - * - * @see wp_unregister_ability_category() - * - * @param string $slug The slug of the registered category. - * @return ?\WP_Ability_Category The unregistered category instance on success, null on failure. - */ - public function unregister( string $slug ): ?WP_Ability_Category { - if ( ! $this->is_registered( $slug ) ) { - _doing_it_wrong( - __METHOD__, - /* translators: %s: Ability category slug. */ - sprintf( esc_html__( 'Ability category "%s" not found.' ), esc_attr( $slug ) ), - '0.3.0' - ); - return null; - } - - $unregistered_category = $this->registered_categories[ $slug ]; - unset( $this->registered_categories[ $slug ] ); - - return $unregistered_category; - } - - /** - * Retrieves the list of all registered categories. - * - * Do not use this method directly. Instead, use the `wp_get_ability_categories()` function. - * - * @since 0.3.0 - * - * @see wp_get_ability_categories() - * - * @return array The array of registered categories. - */ - public function get_all_registered(): array { - return $this->registered_categories; - } - - /** - * Checks if a category is registered. - * - * @since 0.3.0 - * - * @param string $slug The slug of the category. - * @return bool True if the category is registered, false otherwise. - */ - public function is_registered( string $slug ): bool { - return isset( $this->registered_categories[ $slug ] ); - } - - /** - * Retrieves a registered category. - * - * Do not use this method directly. Instead, use the `wp_get_ability_category()` function. - * - * @since 0.3.0 - * - * @see wp_get_ability_category() - * - * @param string $slug The slug of the registered category. - * @return ?\WP_Ability_Category The registered category instance, or null if it is not registered. - */ - public function get_registered( string $slug ): ?WP_Ability_Category { - if ( ! $this->is_registered( $slug ) ) { - _doing_it_wrong( - __METHOD__, - /* translators: %s: Ability category slug. */ - sprintf( esc_html__( 'Ability category "%s" not found.' ), esc_attr( $slug ) ), - '0.3.0' - ); - return null; - } - return $this->registered_categories[ $slug ]; - } - - /** - * Utility method to retrieve the main instance of the registry class. - * - * The instance will be created if it does not exist yet. - * - * @since 0.3.0 - * - * @return \WP_Abilities_Category_Registry The main registry instance. - */ - public static function get_instance(): self { - if ( null === self::$instance ) { - self::$instance = new self(); - - /** - * Fires when preparing ability categories registry. - * - * Categories should be registered on this action to ensure they're available when needed. - * - * @since 0.3.0 - * - * @param \WP_Abilities_Category_Registry $instance Categories registry object. - */ - do_action( 'abilities_api_categories_init', self::$instance ); - } - - return self::$instance; - } - - /** - * Wakeup magic method. - * - * @since 0.3.0 - * @throws \LogicException If the registry is unserialized. This is a security hardening measure to prevent unserialization of the registry. - */ - public function __wakeup(): void { - throw new \LogicException( self::class . ' must not be unserialized.' ); - } - - /** - * Serialization magic method. - * - * @since 0.3.0 - * @throws \LogicException If the registry is serialized. This is a security hardening measure to prevent serialization of the registry. - */ - public function __sleep(): array { - throw new \LogicException( self::class . ' must not be serialized.' ); - } -} diff --git a/includes/abilities-api/class-wp-abilities-registry.php b/includes/abilities-api/class-wp-abilities-registry.php index d5f36999..e3086d71 100644 --- a/includes/abilities-api/class-wp-abilities-registry.php +++ b/includes/abilities-api/class-wp-abilities-registry.php @@ -6,7 +6,7 @@ * * @package WordPress * @subpackage Abilities API - * @since 0.1.0 + * @since 6.9.0 */ declare( strict_types = 1 ); @@ -14,23 +14,23 @@ /** * Manages the registration and lookup of abilities. * - * @since 0.1.0 + * @since 6.9.0 * @access private */ final class WP_Abilities_Registry { /** * The singleton instance of the registry. * - * @since 0.1.0 - * @var ?self + * @since 6.9.0 + * @var self|null */ private static $instance = null; /** * Holds the registered abilities. * - * @since 0.1.0 - * @var \WP_Ability[] + * @since 6.9.0 + * @var WP_Ability[] */ private $registered_abilities = array(); @@ -39,42 +39,43 @@ final class WP_Abilities_Registry { * * Do not use this method directly. Instead, use the `wp_register_ability()` function. * - * @since 0.1.0 + * @since 6.9.0 * * @see wp_register_ability() * - * @param string $name The name of the ability. The name must be a string containing a namespace - * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase - * alphanumeric characters, dashes and the forward slash. - * @param array $args An associative array of arguments for the ability. See wp_register_ability() for - * details. - * @return ?\WP_Ability The registered ability instance on success, null on failure. + * @param string $name The name of the ability. The name must be a string containing a namespace + * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase + * alphanumeric characters, dashes and the forward slash. + * @param array $args { + * An associative array of arguments for the ability. * - * @phpstan-param array{ - * label?: string, - * description?: string, - * category?: string, - * execute_callback?: callable( mixed $input= ): (mixed|\WP_Error), - * permission_callback?: callable( mixed $input= ): (bool|\WP_Error), - * input_schema?: array, - * output_schema?: array, - * meta?: array{ - * annotations?: array, - * show_in_rest?: bool, - * ... - * }, - * ability_class?: class-string<\WP_Ability>, - * ... - * } $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 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. + * Receives optional mixed input and returns bool or WP_Error. + * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { + * Optional. Additional metadata for the ability. + * + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * } + * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability. + * } + * @return WP_Ability|null The registered ability instance on success, null on failure. */ public function register( string $name, array $args ): ?WP_Ability { if ( ! preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $name ) ) { _doing_it_wrong( __METHOD__, - esc_html__( + __( 'Ability name must be a string containing a namespace prefix, i.e. "my-plugin/my-ability". It can only contain lowercase alphanumeric characters, dashes and the forward slash.' ), - '0.1.0' + '6.9.0' ); return null; } @@ -83,8 +84,8 @@ public function register( string $name, array $args ): ?WP_Ability { _doing_it_wrong( __METHOD__, /* translators: %s: Ability name. */ - esc_html( sprintf( __( 'Ability "%s" is already registered.' ), $name ) ), - '0.1.0' + sprintf( __( 'Ability "%s" is already registered.' ), esc_html( $name ) ), + '6.9.0' ); return null; } @@ -92,26 +93,45 @@ public function register( string $name, array $args ): ?WP_Ability { /** * Filters the ability arguments before they are validated and used to instantiate the ability. * - * @since 0.2.0 + * @since 6.9.0 + * + * @param array $args { + * An associative array of arguments for the 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 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. + * Receives optional mixed input and returns bool or WP_Error. + * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { + * Optional. Additional metadata for the ability. * - * @param array $args The arguments used to instantiate the ability. - * @param string $name The name of the ability, with its namespace. + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * } + * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability. + * } + * @param string $name The name of the ability, with its namespace. */ - $args = apply_filters( 'register_ability_args', $args, $name ); + $args = apply_filters( 'wp_register_ability_args', $args, $name ); - // Validate category exists if provided (will be validated as required in WP_Ability). + // Validate ability category exists if provided (will be validated as required in WP_Ability). if ( isset( $args['category'] ) ) { - $category_registry = WP_Abilities_Category_Registry::get_instance(); + $category_registry = WP_Ability_Categories_Registry::get_instance(); if ( ! $category_registry->is_registered( $args['category'] ) ) { _doing_it_wrong( __METHOD__, sprintf( /* translators: %1$s: ability category slug, %2$s: ability name */ - esc_html__( 'Ability category "%1$s" is not registered. Please register the category before assigning it to ability "%2$s".' ), - esc_attr( $args['category'] ), - esc_attr( $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 ) ), - '0.3.0' + '6.9.0' ); return null; } @@ -121,24 +141,24 @@ public function register( string $name, array $args ): ?WP_Ability { if ( isset( $args['ability_class'] ) && ! is_a( $args['ability_class'], WP_Ability::class, true ) ) { _doing_it_wrong( __METHOD__, - esc_html__( 'The ability args should provide a valid `ability_class` that extends WP_Ability.' ), - '0.1.0' + __( 'The ability args should provide a valid `ability_class` that extends WP_Ability.' ), + '6.9.0' ); return null; } - /** @var class-string<\WP_Ability> */ + /** @var class-string */ $ability_class = $args['ability_class'] ?? WP_Ability::class; unset( $args['ability_class'] ); try { // WP_Ability::prepare_properties() will throw an exception if the properties are invalid. $ability = new $ability_class( $name, $args ); - } catch ( \InvalidArgumentException $e ) { + } catch ( InvalidArgumentException $e ) { _doing_it_wrong( __METHOD__, - esc_html( $e->getMessage() ), - '0.1.0' + $e->getMessage(), + '6.9.0' ); return null; } @@ -152,20 +172,20 @@ public function register( string $name, array $args ): ?WP_Ability { * * Do not use this method directly. Instead, use the `wp_unregister_ability()` function. * - * @since 0.1.0 + * @since 6.9.0 * * @see wp_unregister_ability() * * @param string $name The name of the registered ability, with its namespace. - * @return ?\WP_Ability The unregistered ability instance on success, null on failure. + * @return WP_Ability|null The unregistered ability instance on success, null on failure. */ public function unregister( string $name ): ?WP_Ability { if ( ! $this->is_registered( $name ) ) { _doing_it_wrong( __METHOD__, /* translators: %s: Ability name. */ - sprintf( esc_html__( 'Ability "%s" not found.' ), esc_attr( $name ) ), - '0.1.0' + sprintf( __( 'Ability "%s" not found.' ), esc_html( $name ) ), + '6.9.0' ); return null; } @@ -181,11 +201,11 @@ public function unregister( string $name ): ?WP_Ability { * * Do not use this method directly. Instead, use the `wp_get_abilities()` function. * - * @since 0.1.0 + * @since 6.9.0 * * @see wp_get_abilities() * - * @return \WP_Ability[] The array of registered abilities. + * @return WP_Ability[] The array of registered abilities. */ public function get_all_registered(): array { return $this->registered_abilities; @@ -194,7 +214,11 @@ public function get_all_registered(): array { /** * Checks if an ability is registered. * - * @since 0.1.0 + * Do not use this method directly. Instead, use the `wp_has_ability()` function. + * + * @since 6.9.0 + * + * @see wp_has_ability() * * @param string $name The name of the registered ability, with its namespace. * @return bool True if the ability is registered, false otherwise. @@ -208,12 +232,12 @@ public function is_registered( string $name ): bool { * * Do not use this method directly. Instead, use the `wp_get_ability()` function. * - * @since 0.1.0 + * @since 6.9.0 * * @see wp_get_ability() * * @param string $name The name of the registered ability, with its namespace. - * @return ?\WP_Ability The registered ability instance, or null if it is not registered. + * @return ?WP_Ability The registered ability instance, or null if it is not registered. */ public function get_registered( string $name ): ?WP_Ability { if ( ! $this->is_registered( $name ) ) { @@ -221,7 +245,7 @@ public function get_registered( string $name ): ?WP_Ability { __METHOD__, /* translators: %s: Ability name. */ sprintf( esc_html__( 'Ability "%s" not found.' ), esc_attr( $name ) ), - '0.1.0' + '6.9.0' ); return null; } @@ -233,17 +257,28 @@ public function get_registered( string $name ): ?WP_Ability { * * The instance will be created if it does not exist yet. * - * @since 0.1.0 + * @since 6.9.0 * - * @return \WP_Abilities_Registry The main registry instance. + * @return WP_Abilities_Registry|null The main registry instance, or null when `init` action has not fired. */ - public static function get_instance(): self { + public static function get_instance(): ?self { + if ( ! did_action( 'init' ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + __( 'Ability API should not be initialized before the init action has fired' ) + ), + '6.9.0' + ); + return null; + } + if ( null === self::$instance ) { self::$instance = new self(); - // Ensure category registry is initialized first to allow categories to be registered + // Ensure ability category registry is initialized first to allow categories to be registered // before abilities that depend on them. - WP_Abilities_Category_Registry::get_instance(); + WP_Ability_Categories_Registry::get_instance(); /** * Fires when preparing abilities registry. @@ -251,11 +286,11 @@ public static function get_instance(): self { * Abilities should be created and register their hooks on this action rather * than another action to ensure they're only loaded when needed. * - * @since 0.1.0 + * @since 6.9.0 * - * @param \WP_Abilities_Registry $instance Abilities registry object. + * @param WP_Abilities_Registry $instance Abilities registry object. */ - do_action( 'abilities_api_init', self::$instance ); + do_action( 'wp_abilities_api_init', self::$instance ); } return self::$instance; @@ -264,14 +299,22 @@ public static function get_instance(): self { /** * Wakeup magic method. * - * @since 0.1.0 - * @throws \UnexpectedValueException If any of the registered abilities is not an instance of WP_Ability. + * @since 6.9.0 + * @throws LogicException If the registry object is unserialized. + * This is a security hardening measure to prevent unserialization of the registry. */ public function __wakeup(): void { - foreach ( $this->registered_abilities as $ability ) { - if ( ! $ability instanceof WP_Ability ) { - throw new \UnexpectedValueException(); - } - } + throw new LogicException( __CLASS__ . ' should never be unserialized.' ); + } + + /** + * Sleep magic method. + * + * @since 6.9.0 + * @throws LogicException If the registry object is serialized. + * This is a security hardening measure to prevent serialization of the registry. + */ + public function __sleep(): array { + throw new LogicException( __CLASS__ . ' should never be serialized' ); } } diff --git a/includes/abilities-api/class-wp-ability-categories-registry.php b/includes/abilities-api/class-wp-ability-categories-registry.php new file mode 100644 index 00000000..6a372c6d --- /dev/null +++ b/includes/abilities-api/class-wp-ability-categories-registry.php @@ -0,0 +1,254 @@ + $args { + * An associative array of arguments for the ability category. + * + * @type string $label The human-readable label for the ability category. + * @type string $description A description of the ability category. + * @type array $meta Optional. Additional metadata for the ability category. + * } + * @return WP_Ability_Category|null The registered ability category instance on success, null on failure. + */ + public function register( string $slug, array $args ): ?WP_Ability_Category { + if ( $this->is_registered( $slug ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Ability category slug. */ + sprintf( __( 'Ability category "%s" is already registered.' ), esc_html( $slug ) ), + '6.9.0' + ); + return null; + } + + if ( ! preg_match( '/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Ability category slug must contain only lowercase alphanumeric characters and dashes.' ), + '6.9.0' + ); + return null; + } + + /** + * Filters the ability category arguments before they are validated and used to instantiate the ability category. + * + * @since 6.9.0 + * + * @param array $args { + * The arguments used to instantiate the ability category. + * + * @type string $label The human-readable label for the ability category. + * @type string $description A description of the ability category. + * @type array $meta Optional. Additional metadata for the ability category. + * } + * @param string $slug The slug of the ability category. + */ + $args = apply_filters( 'wp_register_ability_category_args', $args, $slug ); + + try { + // WP_Ability_Category::prepare_properties() will throw an exception if the properties are invalid. + $category = new WP_Ability_Category( $slug, $args ); + } catch ( InvalidArgumentException $e ) { + _doing_it_wrong( + __METHOD__, + $e->getMessage(), + '6.9.0' + ); + return null; + } + + $this->registered_categories[ $slug ] = $category; + return $category; + } + + /** + * Unregisters an ability category. + * + * Do not use this method directly. Instead, use the `wp_unregister_ability_category()` function. + * + * @since 6.9.0 + * + * @see wp_unregister_ability_category() + * + * @param string $slug The slug of the registered ability category. + * @return WP_Ability_Category|null The unregistered ability category instance on success, null on failure. + */ + public function unregister( string $slug ): ?WP_Ability_Category { + if ( ! $this->is_registered( $slug ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Ability category slug. */ + sprintf( __( 'Ability category "%s" not found.' ), esc_html( $slug ) ), + '6.9.0' + ); + return null; + } + + $unregistered_category = $this->registered_categories[ $slug ]; + unset( $this->registered_categories[ $slug ] ); + + return $unregistered_category; + } + + /** + * Retrieves the list of all registered ability categories. + * + * Do not use this method directly. Instead, use the `wp_get_ability_categories()` function. + * + * @since 6.9.0 + * + * @see wp_get_ability_categories() + * + * @return array The array of registered ability categories. + */ + public function get_all_registered(): array { + return $this->registered_categories; + } + + /** + * Checks if an ability category is registered. + * + * Do not use this method directly. Instead, use the `wp_has_ability_category()` function. + * + * @since 6.9.0 + * + * @see wp_has_ability_category() + * + * @param string $slug The slug of the ability category. + * @return bool True if the ability category is registered, false otherwise. + */ + public function is_registered( string $slug ): bool { + return isset( $this->registered_categories[ $slug ] ); + } + + /** + * Retrieves a registered ability category. + * + * Do not use this method directly. Instead, use the `wp_get_ability_category()` function. + * + * @since 6.9.0 + * + * @see wp_get_ability_category() + * + * @param string $slug The slug of the registered ability category. + * @return WP_Ability_Category|null The registered ability category instance, or null if it is not registered. + */ + public function get_registered( string $slug ): ?WP_Ability_Category { + if ( ! $this->is_registered( $slug ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Ability category slug. */ + sprintf( __( 'Ability category "%s" not found.' ), esc_html( $slug ) ), + '6.9.0' + ); + return null; + } + return $this->registered_categories[ $slug ]; + } + + /** + * Utility method to retrieve the main instance of the registry class. + * + * The instance will be created if it does not exist yet. + * + * @since 6.9.0 + * + * @return WP_Ability_Categories_Registry|null The main registry instance, or null when `init` action has not fired. + */ + public static function get_instance(): ?self { + if ( ! did_action( 'init' ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + __( 'Ability API should not be initialized before the init action has fired' ) + ), + '6.9.0' + ); + return null; + } + + if ( null === self::$instance ) { + self::$instance = new self(); + + /** + * Fires when preparing ability categories registry. + * + * Ability categories should be registered on this action to ensure they're available when needed. + * + * @since 6.9.0 + * + * @param WP_Ability_Categories_Registry $instance Ability categories registry object. + */ + do_action( 'wp_abilities_api_categories_init', self::$instance ); + } + + return self::$instance; + } + + /** + * Wakeup magic method. + * + * @since 6.9.0 + * @throws LogicException If the registry object is unserialized. + * This is a security hardening measure to prevent unserialization of the registry. + */ + public function __wakeup(): void { + throw new LogicException( __CLASS__ . ' should never be unserialized.' ); + } + + /** + * Sleep magic method. + * + * @since 6.9.0 + * @throws LogicException If the registry object is serialized. + * This is a security hardening measure to prevent serialization of the registry. + */ + public function __sleep(): array { + throw new LogicException( __CLASS__ . ' should never be serialized' ); + } +} diff --git a/includes/abilities-api/class-wp-ability-category.php b/includes/abilities-api/class-wp-ability-category.php index 734b436f..df6c8970 100644 --- a/includes/abilities-api/class-wp-ability-category.php +++ b/includes/abilities-api/class-wp-ability-category.php @@ -6,7 +6,7 @@ * * @package WordPress * @subpackage Abilities API - * @since 0.3.0 + * @since 6.9.0 */ declare( strict_types = 1 ); @@ -14,41 +14,41 @@ /** * Encapsulates the properties and methods related to a specific ability category. * - * @since 0.3.0 + * @since 6.9.0 * - * @see WP_Abilities_Category_Registry + * @see WP_Ability_Categories_Registry */ final class WP_Ability_Category { /** - * The unique slug for the category. + * The unique slug for the ability category. * - * @since 0.3.0 + * @since 6.9.0 * @var string */ protected $slug; /** - * The human-readable category label. + * The human-readable ability category label. * - * @since 0.3.0 + * @since 6.9.0 * @var string */ protected $label; /** - * The detailed category description. + * The detailed ability category description. * - * @since 0.3.0 + * @since 6.9.0 * @var string */ protected $description; /** - * The optional category metadata. + * The optional ability category metadata. * - * @since 0.3.0 - * @var array + * @since 6.9.0 + * @var array */ protected $meta = array(); @@ -59,17 +59,23 @@ final class WP_Ability_Category { * * @access private * - * @since 0.3.0 + * @since 6.9.0 * * @see wp_register_ability_category() * - * @param string $slug The unique slug for the category. - * @param array $args An associative array of arguments for the category. + * @param string $slug The unique slug for the ability category. + * @param array $args { + * An associative array of arguments for the ability category. + * + * @type string $label The human-readable label for the ability category. + * @type string $description A description of the ability category. + * @type array $meta Optional. Additional metadata for the ability category. + * } */ public function __construct( string $slug, array $args ) { if ( empty( $slug ) ) { - throw new \InvalidArgumentException( - esc_html__( 'The category slug cannot be empty.' ) + throw new InvalidArgumentException( + esc_html__( 'The ability category slug cannot be empty.' ) ); } @@ -83,12 +89,12 @@ public function __construct( string $slug, array $args ) { __METHOD__, sprintf( /* translators: %s: Property name. */ - esc_html__( 'Property "%1$s" is not a valid property for category "%2$s". Please check the %3$s class for allowed properties.' ), + __( 'Property "%1$s" is not a valid property for ability category "%2$s". Please check the %3$s class for allowed properties.' ), '' . esc_html( $property_name ) . '', '' . esc_html( $this->slug ) . '', - '' . esc_html( self::class ) . '' + '' . __CLASS__ . '' ), - '0.3.0' + '6.9.0' ); continue; } @@ -98,39 +104,44 @@ public function __construct( string $slug, array $args ) { } /** - * Prepares and validates the properties used to instantiate the category. + * Prepares and validates the properties used to instantiate the ability category. * - * @since 0.3.0 + * @since 6.9.0 * - * @param array $args An associative array of arguments used to instantiate the class. - * @return array The validated and prepared properties. - * @throws \InvalidArgumentException if an argument is invalid. + * @param array $args $args { + * An associative array of arguments used to instantiate the ability category class. + * + * @type string $label The human-readable label for the ability category. + * @type string $description A description of the ability category. + * @type array $meta Optional. Additional metadata for the ability category. + * } + * @return array $args { + * An associative array with validated and prepared ability category properties. * - * @phpstan-return array{ - * label: string, - * description: string, - * meta?: array, - * ..., + * @type string $label The human-readable label for the ability category. + * @type string $description A description of the ability category. + * @type array $meta Optional. Additional metadata for the ability category. * } + * @throws InvalidArgumentException if an argument is invalid. */ protected function prepare_properties( array $args ): array { // Required args must be present and of the correct type. if ( empty( $args['label'] ) || ! is_string( $args['label'] ) ) { - throw new \InvalidArgumentException( - esc_html__( 'The category properties must contain a `label` string.' ) + throw new InvalidArgumentException( + __( 'The ability category properties must contain a `label` string.' ) ); } if ( empty( $args['description'] ) || ! is_string( $args['description'] ) ) { - throw new \InvalidArgumentException( - esc_html__( 'The category properties must contain a `description` string.' ) + throw new InvalidArgumentException( + __( 'The ability category properties must contain a `description` string.' ) ); } // Optional args only need to be of the correct type if they are present. if ( isset( $args['meta'] ) && ! is_array( $args['meta'] ) ) { - throw new \InvalidArgumentException( - esc_html__( 'The category properties should provide a valid `meta` array.' ) + throw new InvalidArgumentException( + __( 'The ability category properties should provide a valid `meta` array.' ) ); } @@ -138,44 +149,44 @@ protected function prepare_properties( array $args ): array { } /** - * Retrieves the slug of the category. + * Retrieves the slug of the ability category. * - * @since 0.3.0 + * @since 6.9.0 * - * @return string The category slug. + * @return string The ability category slug. */ public function get_slug(): string { return $this->slug; } /** - * Retrieves the human-readable label for the category. + * Retrieves the human-readable label for the ability category. * - * @since 0.3.0 + * @since 6.9.0 * - * @return string The human-readable category label. + * @return string The human-readable ability category label. */ public function get_label(): string { return $this->label; } /** - * Retrieves the detailed description for the category. + * Retrieves the detailed description for the ability category. * - * @since 0.3.0 + * @since 6.9.0 * - * @return string The detailed description for the category. + * @return string The detailed description for the ability category. */ public function get_description(): string { return $this->description; } /** - * Retrieves the metadata for the category. + * Retrieves the metadata for the ability category. * - * @since 0.3.0 + * @since 6.9.0 * - * @return array The metadata for the category. + * @return array The metadata for the ability category. */ public function get_meta(): array { return $this->meta; @@ -184,20 +195,22 @@ public function get_meta(): array { /** * Wakeup magic method. * - * @since 0.3.0 - * @throws \LogicException If the category is unserialized. This is a security hardening measure to prevent unserialization of the category. + * @since 6.9.0 + * @throws LogicException If the ability category object is unserialized. + * This is a security hardening measure to prevent unserialization of the ability category. */ public function __wakeup(): void { - throw new \LogicException( self::class . ' must not be unserialized.' ); + throw new LogicException( __CLASS__ . ' should never be unserialized.' ); } /** - * Serialization magic method. + * Sleep magic method. * - * @since 0.3.0 - * @throws \LogicException If the category is serialized. This is a security hardening measure to prevent serialization of the category. + * @since 6.9.0 + * @throws LogicException If the ability category object is serialized. + * This is a security hardening measure to prevent serialization of the ability category. */ public function __sleep(): array { - throw new \LogicException( self::class . ' must not be serialized.' ); + throw new LogicException( __CLASS__ . ' should never be serialized' ); } } diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index 84181db5..71b2ea25 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -6,7 +6,7 @@ * * @package WordPress * @subpackage Abilities API - * @since 0.1.0 + * @since 6.9.0 */ declare( strict_types = 1 ); @@ -14,7 +14,7 @@ /** * Encapsulates the properties and methods related to a specific ability in the registry. * - * @since 0.1.0 + * @since 6.9.0 * * @see WP_Abilities_Registry */ @@ -23,7 +23,7 @@ class WP_Ability { /** * The default value for the `show_in_rest` meta. * - * @since 0.3.0 + * @since 6.9.0 * @var bool */ protected const DEFAULT_SHOW_IN_REST = false; @@ -32,31 +32,29 @@ class WP_Ability { * The default ability annotations. * They are not guaranteed to provide a faithful description of ability behavior. * - * @since 0.3.0 - * @var array + * @since 6.9.0 + * @var array */ protected static $default_annotations = array( - // Instructions on how to use the ability. - 'instructions' => '', // If true, the ability does not modify its environment. - 'readonly' => false, + 'readonly' => null, /* * If true, the ability may perform destructive updates to its environment. * If false, the ability performs only additive updates. */ - 'destructive' => true, + 'destructive' => null, /* * If true, calling the ability repeatedly with the same arguments will have no additional effect * on its environment. */ - 'idempotent' => false, + 'idempotent' => null, ); /** * The name of the ability, with its namespace. * Example: `my-plugin/my-ability`. * - * @since 0.1.0 + * @since 6.9.0 * @var string */ protected $name; @@ -64,7 +62,7 @@ class WP_Ability { /** * The human-readable ability label. * - * @since 0.1.0 + * @since 6.9.0 * @var string */ protected $label; @@ -72,59 +70,59 @@ class WP_Ability { /** * The detailed ability description. * - * @since 0.1.0 + * @since 6.9.0 * @var string */ protected $description; + /** + * The ability category. + * + * @since 6.9.0 + * @var string + */ + protected $category; + /** * The optional ability input schema. * - * @since 0.1.0 - * @var array + * @since 6.9.0 + * @var array */ protected $input_schema = array(); /** * The optional ability output schema. * - * @since 0.1.0 - * @var array + * @since 6.9.0 + * @var array */ protected $output_schema = array(); /** * The ability execute callback. * - * @since 0.1.0 - * @var callable( mixed $input= ): (mixed|\WP_Error) + * @since 6.9.0 + * @var callable( mixed $input= ): (mixed|WP_Error) */ protected $execute_callback; /** * The optional ability permission callback. * - * @since 0.1.0 - * @var callable( mixed $input= ): (bool|\WP_Error) + * @since 6.9.0 + * @var callable( mixed $input= ): (bool|WP_Error) */ protected $permission_callback; /** * The optional ability metadata. * - * @since 0.1.0 - * @var array + * @since 6.9.0 + * @var array */ protected $meta; - /** - * The ability category (required). - * - * @since 0.3.0 - * @var string - */ - protected $category; - /** * Constructor. * @@ -132,14 +130,30 @@ class WP_Ability { * * @access private * - * @since 0.1.0 + * @since 6.9.0 * * @see wp_register_ability() * - * @param string $name The name of the ability, with its namespace. - * @param array $args An associative array of arguments for the ability. This should include: - * `label`, `description`, `category`, `input_schema`, `output_schema`, - * `execute_callback`, `permission_callback` and `meta` + * @param string $name The name of the ability, with its namespace. + * @param array $args { + * An associative array of arguments for the 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 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. + * Receives optional mixed input and returns bool or WP_Error. + * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { + * Optional. Additional metadata for the ability. + * + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * } + * } */ public function __construct( string $name, array $args ) { $this->name = $name; @@ -152,12 +166,12 @@ public function __construct( string $name, array $args ) { __METHOD__, sprintf( /* translators: %s: Property name. */ - esc_html__( 'Property "%1$s" is not a valid property for ability "%2$s". Please check the %3$s class for allowed properties.' ), + __( 'Property "%1$s" is not a valid property for ability "%2$s". Please check the %3$s class for allowed properties.' ), '' . esc_html( $property_name ) . '', '' . esc_html( $this->name ) . '', - '' . esc_html( self::class ) . '' + '' . self::class . '' ), - '0.1.0' + '6.9.0' ); continue; } @@ -169,93 +183,113 @@ public function __construct( string $name, array $args ) { /** * Prepares and validates the properties used to instantiate the ability. * - * Errors are thrown as exceptions instead of \WP_Errors to allow for simpler handling and overloading. They are then + * Errors are thrown as exceptions instead of WP_Errors to allow for simpler handling and overloading. They are then * caught and converted to a WP_Error when by WP_Abilities_Registry::register(). * - * @since 0.2.0 + * @since 6.9.0 * * @see WP_Abilities_Registry::register() * - * @param array $args An associative array of arguments used to instantiate the class. - * @return array The validated and prepared properties. - * @throws \InvalidArgumentException if an argument is invalid. - * - * @phpstan-return array{ - * label: string, - * description: string, - * category: string, - * execute_callback: callable( mixed $input= ): (mixed|\WP_Error), - * permission_callback: callable( mixed $input= ): (bool|\WP_Error), - * input_schema?: array, - * output_schema?: array, - * meta?: array{ - * annotations?: array, - * show_in_rest?: bool, - * ... - * }, - * ..., - * } $args + * @param array $args { + * An associative array of arguments used to instantiate the ability class. + * + * @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 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. + * Receives optional mixed input and returns bool or WP_Error. + * @type array $input_schema Optional. JSON Schema definition for the ability's input. Required if ability accepts an input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { + * Optional. Additional metadata for the ability. + * + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * } + * } + * @return array { + * An associative array of arguments with validated and prepared properties for the ability class. + * + * @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 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. + * Receives optional mixed input and returns bool or WP_Error. + * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { + * Additional metadata for the ability. + * + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Whether to expose this ability in the REST API. Default false. + * } + * } + * @throws InvalidArgumentException if an argument is invalid. */ protected function prepare_properties( array $args ): array { // Required args must be present and of the correct type. if ( empty( $args['label'] ) || ! is_string( $args['label'] ) ) { - throw new \InvalidArgumentException( - esc_html__( 'The ability properties must contain a `label` string.' ) + throw new InvalidArgumentException( + __( 'The ability properties must contain a `label` string.' ) ); } if ( empty( $args['description'] ) || ! is_string( $args['description'] ) ) { - throw new \InvalidArgumentException( - esc_html__( 'The ability properties must contain a `description` string.' ) + throw new InvalidArgumentException( + __( 'The ability properties must contain a `description` string.' ) ); } if ( empty( $args['category'] ) || ! is_string( $args['category'] ) ) { - throw new \InvalidArgumentException( - esc_html__( 'The ability properties must contain a `category` string.' ) + throw new InvalidArgumentException( + __( 'The ability properties must contain a `category` string.' ) ); } if ( empty( $args['execute_callback'] ) || ! is_callable( $args['execute_callback'] ) ) { - throw new \InvalidArgumentException( - esc_html__( 'The ability properties must contain a valid `execute_callback` function.' ) + throw new InvalidArgumentException( + __( 'The ability properties must contain a valid `execute_callback` function.' ) ); } if ( empty( $args['permission_callback'] ) || ! is_callable( $args['permission_callback'] ) ) { - throw new \InvalidArgumentException( - esc_html__( 'The ability properties must provide a valid `permission_callback` function.' ) + throw new InvalidArgumentException( + __( 'The ability properties must provide a valid `permission_callback` function.' ) ); } // Optional args only need to be of the correct type if they are present. if ( isset( $args['input_schema'] ) && ! is_array( $args['input_schema'] ) ) { - throw new \InvalidArgumentException( - esc_html__( 'The ability properties should provide a valid `input_schema` definition.' ) + throw new InvalidArgumentException( + __( 'The ability properties should provide a valid `input_schema` definition.' ) ); } if ( isset( $args['output_schema'] ) && ! is_array( $args['output_schema'] ) ) { - throw new \InvalidArgumentException( - esc_html__( 'The ability properties should provide a valid `output_schema` definition.' ) + throw new InvalidArgumentException( + __( 'The ability properties should provide a valid `output_schema` definition.' ) ); } if ( isset( $args['meta'] ) && ! is_array( $args['meta'] ) ) { - throw new \InvalidArgumentException( - esc_html__( 'The ability properties should provide a valid `meta` array.' ) + throw new InvalidArgumentException( + __( 'The ability properties should provide a valid `meta` array.' ) ); } if ( isset( $args['meta']['annotations'] ) && ! is_array( $args['meta']['annotations'] ) ) { - throw new \InvalidArgumentException( - esc_html__( 'The ability meta should provide a valid `annotations` array.' ) + throw new InvalidArgumentException( + __( 'The ability meta should provide a valid `annotations` array.' ) ); } if ( isset( $args['meta']['show_in_rest'] ) && ! is_bool( $args['meta']['show_in_rest'] ) ) { - throw new \InvalidArgumentException( - esc_html__( 'The ability meta should provide a valid `show_in_rest` boolean.' ) + throw new InvalidArgumentException( + __( 'The ability meta should provide a valid `show_in_rest` boolean.' ) ); } @@ -279,7 +313,7 @@ protected function prepare_properties( array $args ): array { * Retrieves the name of the ability, with its namespace. * Example: `my-plugin/my-ability`. * - * @since 0.1.0 + * @since 6.9.0 * * @return string The ability name, with its namespace. */ @@ -290,7 +324,7 @@ public function get_name(): string { /** * Retrieves the human-readable label for the ability. * - * @since 0.1.0 + * @since 6.9.0 * * @return string The human-readable ability label. */ @@ -301,7 +335,7 @@ public function get_label(): string { /** * Retrieves the detailed description for the ability. * - * @since 0.1.0 + * @since 6.9.0 * * @return string The detailed description for the ability. */ @@ -310,43 +344,33 @@ public function get_description(): string { } /** - * Retrieves the input schema for the ability. + * Retrieves the ability category for the ability. * - * @since 0.1.0 + * @since 6.9.0 * - * @return array The input schema for the ability. + * @return string The ability category for the ability. */ - public function get_input_schema(): array { - return $this->input_schema; + public function get_category(): string { + return $this->category; } /** - * Applies the defined input default when no input is provided. + * Retrieves the input schema for the ability. * - * @since 0.4.0 + * @since 6.9.0 * - * @param mixed $input Optional. The raw input provided for the ability. Default `null`. - * @return mixed The input with the schema default applied when available. + * @return array The input schema for the ability. */ - public function normalize_input( $input = null ) { - if ( null !== $input ) { - return $input; - } - - $input_schema = $this->get_input_schema(); - if ( ! empty( $input_schema ) && array_key_exists( 'default', $input_schema ) ) { - return $input_schema['default']; - } - - return null; + public function get_input_schema(): array { + return $this->input_schema; } /** * Retrieves the output schema for the ability. * - * @since 0.1.0 + * @since 6.9.0 * - * @return array The output schema for the ability. + * @return array The output schema for the ability. */ public function get_output_schema(): array { return $this->output_schema; @@ -355,29 +379,18 @@ public function get_output_schema(): array { /** * Retrieves the metadata for the ability. * - * @since 0.1.0 + * @since 6.9.0 * - * @return array The metadata for the ability. + * @return array The metadata for the ability. */ public function get_meta(): array { return $this->meta; } - /** - * Retrieves the category for the ability. - * - * @since 0.3.0 - * - * @return string The category for the ability. - */ - public function get_category(): string { - return $this->category; - } - /** * Retrieves a specific metadata item for the ability. * - * @since 0.3.0 + * @since 6.9.0 * * @param string $key The metadata key to retrieve. * @param mixed $default_value Optional. The default value to return if the metadata item is not found. Default `null`. @@ -387,22 +400,47 @@ public function get_meta_item( string $key, $default_value = null ) { return array_key_exists( $key, $this->meta ) ? $this->meta[ $key ] : $default_value; } + /** + * Normalizes the input for the ability, applying the default value from the input schema when needed. + * + * When no input is provided and the input schema is defined with a top-level `default` key, this method returns + * the value of that key. If the input schema does not define a `default`, or if the input schema is empty, + * this method returns null. If input is provided, it is returned as-is. + * + * @since 6.9.0 + * + * @param mixed $input Optional. The raw input provided for the ability. Default `null`. + * @return mixed The same input, or the default from schema, or `null` if default not set. + */ + public function normalize_input( $input = null ) { + if ( null !== $input ) { + return $input; + } + + $input_schema = $this->get_input_schema(); + if ( ! empty( $input_schema ) && array_key_exists( 'default', $input_schema ) ) { + return $input_schema['default']; + } + + return null; + } + /** * Validates input data against the input schema. * - * @since 0.1.0 + * @since 6.9.0 * * @param mixed $input Optional. The input data to validate. Default `null`. - * @return true|\WP_Error Returns true if valid or the WP_Error object if validation fails. + * @return true|WP_Error Returns true if valid or the WP_Error object if validation fails. */ - protected function validate_input( $input = null ) { + public function validate_input( $input = null ) { $input_schema = $this->get_input_schema(); if ( empty( $input_schema ) ) { if ( null === $input ) { return true; } - return new \WP_Error( + return new WP_Error( 'ability_missing_input_schema', sprintf( /* translators: %s ability name. */ @@ -414,7 +452,7 @@ protected function validate_input( $input = null ) { $valid_input = rest_validate_value_from_schema( $input, $input_schema, 'input' ); if ( is_wp_error( $valid_input ) ) { - return new \WP_Error( + return new WP_Error( 'ability_invalid_input', sprintf( /* translators: %1$s ability name, %2$s error message. */ @@ -431,7 +469,7 @@ protected function validate_input( $input = null ) { /** * Invokes a callable, ensuring the input is passed through only if the input schema is defined. * - * @since 0.3.0 + * @since 6.9.0 * * @param callable $callback The callable to invoke. * @param mixed $input Optional. The input data for the ability. Default `null`. @@ -449,52 +487,31 @@ protected function invoke_callback( callable $callback, $input = null ) { /** * Checks whether the ability has the necessary permissions. * - * The input is validated against the input schema before it is passed to to permission callback. + * Please note that input is not automatically validated against the input schema. + * Use `validate_input()` method to validate input before calling this method if needed. * - * @since 0.2.0 + * @since 6.9.0 * - * @param mixed $input Optional. The input data for permission checking. Default `null`. - * @return bool|\WP_Error Whether the ability has the necessary permission. + * @see validate_input() + * + * @param mixed $input Optional. The valid input data for permission checking. Default `null`. + * @return bool|WP_Error Whether the ability has the necessary permission. */ public function check_permissions( $input = null ) { - $input = $this->normalize_input( $input ); - $is_valid = $this->validate_input( $input ); - if ( is_wp_error( $is_valid ) ) { - return $is_valid; - } - return $this->invoke_callback( $this->permission_callback, $input ); } - /** - * Checks whether the ability has the necessary permissions (deprecated). - * - * The input is validated against the input schema before it is passed to to permission callback. - * - * @deprecated 0.2.0 Use check_permissions() instead. - * @see WP_Ability::check_permissions() - * - * @since 0.1.0 - * - * @param mixed $input Optional. The input data for permission checking. Default `null`. - * @return bool|\WP_Error Whether the ability has the necessary permission. - */ - public function has_permission( $input = null ) { - _deprecated_function( __METHOD__, '0.2.0', 'WP_Ability::check_permissions()' ); - return $this->check_permissions( $input ); - } - /** * Executes the ability callback. * - * @since 0.1.0 + * @since 6.9.0 * * @param mixed $input Optional. The input data for the ability. Default `null`. - * @return mixed|\WP_Error The result of the ability execution, or WP_Error on failure. + * @return mixed|WP_Error The result of the ability execution, or WP_Error on failure. */ protected function do_execute( $input = null ) { if ( ! is_callable( $this->execute_callback ) ) { - return new \WP_Error( + return new WP_Error( 'ability_invalid_execute_callback', /* translators: %s ability name. */ sprintf( __( 'Ability "%s" does not have a valid execute callback.' ), $this->name ) @@ -507,10 +524,10 @@ protected function do_execute( $input = null ) { /** * Validates output data against the output schema. * - * @since 0.1.0 + * @since 6.9.0 * * @param mixed $output The output data to validate. - * @return true|\WP_Error Returns true if valid, or a WP_Error object if validation fails. + * @return true|WP_Error Returns true if valid, or a WP_Error object if validation fails. */ protected function validate_output( $output ) { $output_schema = $this->get_output_schema(); @@ -520,7 +537,7 @@ protected function validate_output( $output ) { $valid_output = rest_validate_value_from_schema( $output, $output_schema, 'output' ); if ( is_wp_error( $valid_output ) ) { - return new \WP_Error( + return new WP_Error( 'ability_invalid_output', sprintf( /* translators: %1$s ability name, %2$s error message. */ @@ -538,28 +555,30 @@ protected function validate_output( $output ) { * Executes the ability after input validation and running a permission check. * Before returning the return value, it also validates the output. * - * @since 0.1.0 + * @since 6.9.0 * * @param mixed $input Optional. The input data for the ability. Default `null`. - * @return mixed|\WP_Error The result of the ability execution, or WP_Error on failure. + * @return mixed|WP_Error The result of the ability execution, or WP_Error on failure. */ public function execute( $input = null ) { - $input = $this->normalize_input( $input ); + $input = $this->normalize_input( $input ); + $is_valid = $this->validate_input( $input ); + if ( is_wp_error( $is_valid ) ) { + return $is_valid; + } + $has_permissions = $this->check_permissions( $input ); if ( true !== $has_permissions ) { if ( is_wp_error( $has_permissions ) ) { - if ( 'ability_invalid_input' === $has_permissions->get_error_code() ) { - return $has_permissions; - } // Don't leak the permission check error to someone without the correct perms. _doing_it_wrong( __METHOD__, esc_html( $has_permissions->get_error_message() ), - '0.1.0' + '6.9.0' ); } - return new \WP_Error( + return new WP_Error( 'ability_invalid_permissions', /* translators: %s ability name. */ sprintf( __( 'Ability "%s" does not have necessary permission.' ), $this->name ) @@ -567,14 +586,14 @@ public function execute( $input = null ) { } /** - * Fires before an ability gets executed. + * Fires before an ability gets executed, after input validation and permissions check. * - * @since 0.2.0 + * @since 6.9.0 * * @param string $ability_name The name of the ability. * @param mixed $input The input data for the ability. */ - do_action( 'before_execute_ability', $this->name, $input ); + do_action( 'wp_before_execute_ability', $this->name, $input ); $result = $this->do_execute( $input ); if ( is_wp_error( $result ) ) { @@ -589,13 +608,13 @@ public function execute( $input = null ) { /** * Fires immediately after an ability finished executing. * - * @since 0.2.0 + * @since 6.9.0 * * @param string $ability_name The name of the ability. * @param mixed $input The input data for the ability. * @param mixed $result The result of the ability execution. */ - do_action( 'after_execute_ability', $this->name, $input, $result ); + do_action( 'wp_after_execute_ability', $this->name, $input, $result ); return $result; } @@ -603,9 +622,22 @@ public function execute( $input = null ) { /** * Wakeup magic method. * - * @since 0.1.0 + * @since 6.9.0 + * @throws LogicException If the ability object is unserialized. + * This is a security hardening measure to prevent unserialization of the ability. */ public function __wakeup(): void { - throw new \LogicException( self::class . ' should never be unserialized.' ); + throw new LogicException( __CLASS__ . ' should never be unserialized.' ); + } + + /** + * Sleep magic method. + * + * @since 6.9.0 + * @throws LogicException If the ability object is serialized. + * This is a security hardening measure to prevent serialization of the ability. + */ + public function __sleep(): array { + throw new LogicException( __CLASS__ . ' should never be serialized' ); } } diff --git a/includes/bootstrap.php b/includes/bootstrap.php index ecafd7a2..8a6fe25e 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -23,18 +23,18 @@ } // Load core classes if they are not already defined (for non-Composer installs or direct includes). +if ( ! class_exists( 'WP_Ability_Category' ) ) { + require_once __DIR__ . '/abilities-api/class-wp-ability-category.php'; +} +if ( ! class_exists( 'WP_Ability_Categories_Registry' ) ) { + require_once __DIR__ . '/abilities-api/class-wp-ability-categories-registry.php'; +} if ( ! class_exists( 'WP_Ability' ) ) { require_once __DIR__ . '/abilities-api/class-wp-ability.php'; } if ( ! class_exists( 'WP_Abilities_Registry' ) ) { require_once __DIR__ . '/abilities-api/class-wp-abilities-registry.php'; } -if ( ! class_exists( 'WP_Ability_Category' ) ) { - require_once __DIR__ . '/abilities-api/class-wp-ability-category.php'; -} -if ( ! class_exists( 'WP_Abilities_Category_Registry' ) ) { - require_once __DIR__ . '/abilities-api/class-wp-abilities-category-registry.php'; -} // Ensure procedural functions are available, too. if ( ! function_exists( 'wp_register_ability' ) ) { @@ -50,8 +50,8 @@ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Plugin-specific hook for feature plugin context. if ( ! ( defined( 'WP_RUN_CORE_TESTS' ) || defined( 'WP_TESTS_CONFIG_FILE_PATH' ) || ( function_exists( 'getenv' ) && false !== getenv( 'WP_PHPUNIT__DIR' ) ) ) || apply_filters( 'abilities_api_register_core_abilities', false ) ) { if ( function_exists( 'add_action' ) ) { - add_action( 'abilities_api_categories_init', array( 'WP_Core_Abilities', 'register_category' ) ); - add_action( 'abilities_api_init', array( 'WP_Core_Abilities', 'register' ) ); + add_action( 'wp_abilities_api_categories_init', array( 'WP_Core_Abilities', 'register_category' ) ); + add_action( 'wp_abilities_api_init', array( 'WP_Core_Abilities', 'register' ) ); } } diff --git a/includes/rest-api/class-wp-rest-abilities-init.php b/includes/rest-api/class-wp-rest-abilities-init.php index 0b3798b4..6dd49144 100644 --- a/includes/rest-api/class-wp-rest-abilities-init.php +++ b/includes/rest-api/class-wp-rest-abilities-init.php @@ -22,17 +22,22 @@ class WP_REST_Abilities_Init { * @since 0.1.0 */ public static function register_routes(): void { - require_once __DIR__ . '/endpoints/class-wp-rest-abilities-run-controller.php'; - require_once __DIR__ . '/endpoints/class-wp-rest-abilities-list-controller.php'; - require_once __DIR__ . '/endpoints/class-wp-rest-abilities-categories-controller.php'; - - $categories_controller = new WP_REST_Abilities_Categories_Controller(); + if ( ! class_exists( 'WP_REST_Abilities_V1_Categories_Controller' ) ) { + require_once __DIR__ . '/endpoints/class-wp-rest-abilities-v1-categories-controller.php'; + } + $categories_controller = new WP_REST_Abilities_V1_Categories_Controller(); $categories_controller->register_routes(); - $run_controller = new WP_REST_Abilities_Run_Controller(); + if ( ! class_exists( 'WP_REST_Abilities_V1_Run_Controller' ) ) { + require_once __DIR__ . '/endpoints/class-wp-rest-abilities-v1-run-controller.php'; + } + $run_controller = new WP_REST_Abilities_V1_Run_Controller(); $run_controller->register_routes(); - $list_controller = new WP_REST_Abilities_List_Controller(); + if ( ! class_exists( 'WP_REST_Abilities_V1_List_Controller' ) ) { + require_once __DIR__ . '/endpoints/class-wp-rest-abilities-v1-list-controller.php'; + } + $list_controller = new WP_REST_Abilities_V1_List_Controller(); $list_controller->register_routes(); } } diff --git a/includes/rest-api/endpoints/class-wp-rest-abilities-categories-controller.php b/includes/rest-api/endpoints/class-wp-rest-abilities-v1-categories-controller.php similarity index 66% rename from includes/rest-api/endpoints/class-wp-rest-abilities-categories-controller.php rename to includes/rest-api/endpoints/class-wp-rest-abilities-v1-categories-controller.php index c7933117..2156109e 100644 --- a/includes/rest-api/endpoints/class-wp-rest-abilities-categories-controller.php +++ b/includes/rest-api/endpoints/class-wp-rest-abilities-v1-categories-controller.php @@ -1,10 +1,10 @@ WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), - 'permission_callback' => array( $this, 'get_permissions_check' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), @@ -70,7 +62,7 @@ public function register_routes(): void { array( 'args' => array( 'slug' => array( - 'description' => __( 'Unique identifier for the category.' ), + 'description' => __( 'Unique identifier for the ability category.' ), 'type' => 'string', 'pattern' => '^[a-z0-9]+(?:-[a-z0-9]+)*$', ), @@ -78,7 +70,7 @@ public function register_routes(): void { array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item' ), - 'permission_callback' => array( $this, 'get_permissions_check' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) @@ -88,24 +80,23 @@ public function register_routes(): void { /** * Retrieves all ability categories. * - * @since n.e.x.t + * @since 6.9.0 * - * @param \WP_REST_Request> $request Full details about the request. - * @return \WP_REST_Response Response object on success. + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response Response object on success. */ public function get_items( $request ) { $categories = wp_get_ability_categories(); - $params = $request->get_params(); - $page = $params['page'] ?? 1; - $per_page = $params['per_page'] ?? self::DEFAULT_PER_PAGE; + $page = $request['page']; + $per_page = $request['per_page']; $offset = ( $page - 1 ) * $per_page; $total_categories = count( $categories ); - $max_pages = ceil( $total_categories / $per_page ); + $max_pages = (int) ceil( $total_categories / $per_page ); if ( $request->get_method() === 'HEAD' ) { - $response = new \WP_REST_Response( array() ); + $response = new WP_REST_Response( array() ); } else { $categories = array_slice( $categories, $offset, $per_page ); @@ -122,7 +113,10 @@ public function get_items( $request ) { $response->header( 'X-WP-TotalPages', (string) $max_pages ); $query_params = $request->get_query_params(); - $base = add_query_arg( urlencode_deep( $query_params ), rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) ); + $base = add_query_arg( + urlencode_deep( $query_params ), + rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) + ); if ( $page > 1 ) { $prev_page = $page - 1; @@ -142,16 +136,16 @@ public function get_items( $request ) { /** * Retrieves a specific ability category. * - * @since n.e.x.t + * @since 6.9.0 * - * @param \WP_REST_Request> $request Full details about the request. - * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function get_item( $request ) { - $category = wp_get_ability_category( $request->get_param( 'slug' ) ); + $category = wp_get_ability_category( $request['slug'] ); if ( ! $category ) { - return new \WP_Error( - 'rest_category_not_found', + return new WP_Error( + 'rest_ability_category_not_found', __( 'Ability category not found.' ), array( 'status' => 404 ) ); @@ -164,23 +158,35 @@ public function get_item( $request ) { /** * Checks if a given request has access to read ability categories. * - * @since n.e.x.t + * @since 6.9.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return bool True if the request has read access. + */ + public function get_items_permissions_check( $request ) { + return current_user_can( 'read' ); + } + + /** + * Checks if a given request has access to read an ability category. + * + * @since 6.9.0 * - * @param \WP_REST_Request> $request Full details about the request. + * @param WP_REST_Request $request Full details about the request. * @return bool True if the request has read access. */ - public function get_permissions_check( $request ) { + public function get_item_permissions_check( $request ) { return current_user_can( 'read' ); } /** - * Prepares a category for response. + * Prepares an ability category for response. * - * @since n.e.x.t + * @since 6.9.0 * - * @param \WP_Ability_Category $category The category object. - * @param \WP_REST_Request> $request Request object. - * @return \WP_REST_Response Response object. + * @param WP_Ability_Category $category The ability category object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. */ public function prepare_item_for_response( $category, $request ) { $data = array( @@ -190,7 +196,7 @@ public function prepare_item_for_response( $category, $request ) { 'meta' => $category->get_meta(), ); - $context = $request->get_param( 'context' ) ?? 'view'; + $context = $request['context'] ?? 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); @@ -217,9 +223,9 @@ public function prepare_item_for_response( $category, $request ) { } /** - * Retrieves the category's schema, conforming to JSON Schema. + * Retrieves the ability category's schema, conforming to JSON Schema. * - * @since n.e.x.t + * @since 6.9.0 * * @return array Item schema data. */ @@ -230,7 +236,7 @@ public function get_item_schema(): array { 'type' => 'object', 'properties' => array( 'slug' => array( - 'description' => __( 'Unique identifier for the category.' ), + 'description' => __( 'Unique identifier for the ability category.' ), 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, @@ -254,7 +260,6 @@ public function get_item_schema(): array { 'readonly' => true, ), ), - 'required' => array( 'slug', 'label', 'description', 'meta' ), ); return $this->add_additional_fields_schema( $schema ); @@ -263,7 +268,7 @@ public function get_item_schema(): array { /** * Retrieves the query params for collections. * - * @since n.e.x.t + * @since 6.9.0 * * @return array Collection parameters. */ @@ -271,21 +276,17 @@ public function get_collection_params(): array { return array( 'context' => $this->get_context_param( array( 'default' => 'view' ) ), 'page' => array( - 'description' => __( 'Current page of the collection.' ), - 'type' => 'integer', - 'default' => 1, - 'sanitize_callback' => 'absint', - 'validate_callback' => 'rest_validate_request_arg', - 'minimum' => 1, + 'description' => __( 'Current page of the collection.' ), + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, ), 'per_page' => array( - 'description' => __( 'Maximum number of items to be returned in result set.' ), - 'type' => 'integer', - 'default' => self::DEFAULT_PER_PAGE, - 'minimum' => 1, - 'maximum' => 100, - 'sanitize_callback' => 'absint', - 'validate_callback' => 'rest_validate_request_arg', + 'description' => __( 'Maximum number of items to be returned in result set.' ), + 'type' => 'integer', + 'default' => 50, + 'minimum' => 1, + 'maximum' => 100, ), ); } diff --git a/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php b/includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php similarity index 73% rename from includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php rename to includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index 54e393f4..4f59d908 100644 --- a/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php +++ b/includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage Abilities_API - * @since 0.1.0 + * @since 6.9.0 */ declare( strict_types = 1 ); @@ -12,32 +12,24 @@ /** * Core controller used to access abilities via the REST API. * - * @since 0.1.0 + * @since 6.9.0 * * @see WP_REST_Controller */ -class WP_REST_Abilities_List_Controller extends WP_REST_Controller { - - /** - * Default number of items per page for pagination. - * - * @since 0.1.0 - * @var int - */ - public const DEFAULT_PER_PAGE = 50; +class WP_REST_Abilities_V1_List_Controller extends WP_REST_Controller { /** * REST API namespace. * - * @since 0.1.0 + * @since 6.9.0 * @var string */ - protected $namespace = 'wp/v2'; + protected $namespace = 'wp-abilities/v1'; /** * REST API base route. * - * @since 0.1.0 + * @since 6.9.0 * @var string */ protected $rest_base = 'abilities'; @@ -45,7 +37,7 @@ class WP_REST_Abilities_List_Controller extends WP_REST_Controller { /** * Registers the routes for abilities. * - * @since 0.1.0 + * @since 6.9.0 * * @see register_rest_route() */ @@ -57,7 +49,7 @@ public function register_routes(): void { array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), - 'permission_callback' => array( $this, 'get_permissions_check' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), @@ -78,7 +70,7 @@ public function register_routes(): void { array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item' ), - 'permission_callback' => array( $this, 'get_permissions_check' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) @@ -88,10 +80,10 @@ public function register_routes(): void { /** * Retrieves all abilities. * - * @since 0.1.0 + * @since 6.9.0 * - * @param \WP_REST_Request> $request Full details about the request. - * @return \WP_REST_Response Response object on success. + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response Response object on success. */ public function get_items( $request ) { $abilities = array_filter( @@ -101,8 +93,8 @@ static function ( $ability ) { } ); - // Filter by category if specified. - $category = $request->get_param( 'category' ); + // Filter by ability category if specified. + $category = $request['category']; if ( ! empty( $category ) ) { $abilities = array_filter( $abilities, @@ -114,17 +106,15 @@ static function ( $ability ) use ( $category ) { $abilities = array_values( $abilities ); } - // Handle pagination with explicit defaults. - $params = $request->get_params(); - $page = $params['page'] ?? 1; - $per_page = $params['per_page'] ?? self::DEFAULT_PER_PAGE; + $page = $request['page']; + $per_page = $request['per_page']; $offset = ( $page - 1 ) * $per_page; $total_abilities = count( $abilities ); - $max_pages = ceil( $total_abilities / $per_page ); + $max_pages = (int) ceil( $total_abilities / $per_page ); if ( $request->get_method() === 'HEAD' ) { - $response = new \WP_REST_Response( array() ); + $response = new WP_REST_Response( array() ); } else { $abilities = array_slice( $abilities, $offset, $per_page ); @@ -161,15 +151,15 @@ static function ( $ability ) use ( $category ) { /** * Retrieves a specific ability. * - * @since 0.1.0 + * @since 6.9.0 * - * @param \WP_REST_Request> $request Full details about the request. - * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function get_item( $request ) { - $ability = wp_get_ability( $request->get_param( 'name' ) ); + $ability = wp_get_ability( $request['name'] ); if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) { - return new \WP_Error( + return new WP_Error( 'rest_ability_not_found', __( 'Ability not found.' ), array( 'status' => 404 ) @@ -181,25 +171,37 @@ public function get_item( $request ) { } /** - * Checks if a given request has access to read abilities. + * Checks if a given request has access to read ability items. * - * @since 0.1.0 + * @since 6.9.0 * - * @param \WP_REST_Request> $request Full details about the request. + * @param WP_REST_Request $request Full details about the request. * @return bool True if the request has read access. */ - public function get_permissions_check( $request ) { + public function get_items_permissions_check( $request ) { + return current_user_can( 'read' ); + } + + /** + * Checks if a given request has access to read an ability item. + * + * @since 6.9.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return bool True if the request has read access. + */ + public function get_item_permissions_check( $request ) { return current_user_can( 'read' ); } /** * Prepares an ability for response. * - * @since 0.1.0 + * @since 6.9.0 * - * @param \WP_Ability $ability The ability object. - * @param \WP_REST_Request> $request Request object. - * @return \WP_REST_Response Response object. + * @param WP_Ability $ability The ability object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. */ public function prepare_item_for_response( $ability, $request ) { $data = array( @@ -212,7 +214,7 @@ public function prepare_item_for_response( $ability, $request ) { 'meta' => $ability->get_meta(), ); - $context = $request->get_param( 'context' ) ?? 'view'; + $context = $request['context'] ?? 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); @@ -229,7 +231,7 @@ public function prepare_item_for_response( $ability, $request ) { ), ); - $links['run'] = array( + $links['wp:action-run'] = array( 'href' => rest_url( sprintf( '%s/%s/%s/run', $this->namespace, $this->rest_base, $ability->get_name() ) ), ); @@ -242,7 +244,7 @@ public function prepare_item_for_response( $ability, $request ) { /** * Retrieves the ability's schema, conforming to JSON Schema. * - * @since 0.1.0 + * @since 6.9.0 * * @return array Item schema data. */ @@ -271,7 +273,7 @@ public function get_item_schema(): array { 'readonly' => true, ), 'category' => array( - 'description' => __( 'Category this ability belongs to.' ), + 'description' => __( 'Ability category this ability belongs to.' ), 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, @@ -291,11 +293,17 @@ public function get_item_schema(): array { 'meta' => array( 'description' => __( 'Meta information about the ability.' ), 'type' => 'object', + 'properties' => array( + 'annotations' => array( + 'description' => __( 'Annotations for the ability.' ), + 'type' => array( 'boolean', 'null' ), + 'default' => null, + ), + ), 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), - 'required' => array( 'name', 'label', 'meta', 'description', 'category', 'input_schema', 'output_schema' ), ); return $this->add_additional_fields_schema( $schema ); @@ -304,7 +312,7 @@ public function get_item_schema(): array { /** * Retrieves the query params for collections. * - * @since 0.1.0 + * @since 6.9.0 * * @return array Collection parameters. */ @@ -312,24 +320,20 @@ public function get_collection_params(): array { return array( 'context' => $this->get_context_param( array( 'default' => 'view' ) ), 'page' => array( - 'description' => __( 'Current page of the collection.' ), - 'type' => 'integer', - 'default' => 1, - 'sanitize_callback' => 'absint', - 'validate_callback' => 'rest_validate_request_arg', - 'minimum' => 1, + 'description' => __( 'Current page of the collection.' ), + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, ), 'per_page' => array( - 'description' => __( 'Maximum number of items to be returned in result set.' ), - 'type' => 'integer', - 'default' => self::DEFAULT_PER_PAGE, - 'minimum' => 1, - 'maximum' => 100, - 'sanitize_callback' => 'absint', - 'validate_callback' => 'rest_validate_request_arg', + 'description' => __( 'Maximum number of items to be returned in result set.' ), + 'type' => 'integer', + 'default' => 50, + 'minimum' => 1, + 'maximum' => 100, ), 'category' => array( - 'description' => __( 'Limit results to abilities in specific category.' ), + 'description' => __( 'Limit results to abilities in specific ability category.' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', diff --git a/includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php b/includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php similarity index 53% rename from includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php rename to includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php index a1c663a0..a58888be 100644 --- a/includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php +++ b/includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage Abilities_API - * @since 0.1.0 + * @since 6.9.0 */ declare( strict_types = 1 ); @@ -12,24 +12,24 @@ /** * Core controller used to execute abilities via the REST API. * - * @since 0.1.0 + * @since 6.9.0 * * @see WP_REST_Controller */ -class WP_REST_Abilities_Run_Controller extends WP_REST_Controller { +class WP_REST_Abilities_V1_Run_Controller extends WP_REST_Controller { /** * REST API namespace. * - * @since 0.1.0 + * @since 6.9.0 * @var string */ - protected $namespace = 'wp/v2'; + protected $namespace = 'wp-abilities/v1'; /** * REST API base route. * - * @since 0.1.0 + * @since 6.9.0 * @var string */ protected $rest_base = 'abilities'; @@ -37,7 +37,7 @@ class WP_REST_Abilities_Run_Controller extends WP_REST_Controller { /** * Registers the routes for ability execution. * - * @since 0.1.0 + * @since 6.9.0 * * @see register_rest_route() */ @@ -62,8 +62,8 @@ public function register_routes(): void { // This was the same issue that we ended up seeing with the Feature API. array( 'methods' => WP_REST_Server::ALLMETHODS, - 'callback' => array( $this, 'run_ability_with_method_check' ), - 'permission_callback' => array( $this, 'run_ability_permissions_check' ), + 'callback' => array( $this, 'execute_ability' ), + 'permission_callback' => array( $this, 'check_ability_permissions' ), 'args' => $this->get_run_args(), ), 'schema' => array( $this, 'get_run_schema' ), @@ -72,99 +72,107 @@ public function register_routes(): void { } /** - * Executes an ability with HTTP method validation. + * Executes an ability. * - * @since 0.1.0 + * @since 6.9.0 * - * @param \WP_REST_Request> $request Full details about the request. - * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ - public function run_ability_with_method_check( $request ) { - $ability = wp_get_ability( $request->get_param( 'name' ) ); - + public function execute_ability( $request ) { + $ability = wp_get_ability( $request['name'] ); if ( ! $ability ) { - return new \WP_Error( + return new WP_Error( 'rest_ability_not_found', __( 'Ability not found.' ), array( 'status' => 404 ) ); } - // Check if the HTTP method matches the ability annotations. - $annotations = $ability->get_meta_item( 'annotations' ); - $is_readonly = ! empty( $annotations['readonly'] ); - $method = $request->get_method(); - - if ( $is_readonly && 'GET' !== $method ) { - return new \WP_Error( - 'rest_ability_invalid_method', - __( 'Read-only abilities require GET method.' ), - array( 'status' => 405 ) - ); - } - - if ( ! $is_readonly && 'POST' !== $method ) { - return new \WP_Error( - 'rest_ability_invalid_method', - __( 'Abilities that perform updates require POST method.' ), - array( 'status' => 405 ) - ); + $input = $this->get_input_from_request( $request ); + $result = $ability->execute( $input ); + if ( is_wp_error( $result ) ) { + return $result; } - return $this->run_ability( $request ); + return rest_ensure_response( $result ); } /** - * Executes an ability. + * Validates if the HTTP method matches the expected method for the ability based on its annotations. * - * @since 0.1.0 + * @since 6.9.0 * - * @param \WP_REST_Request> $request Full details about the request. - * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + * @param string $request_method The HTTP method of the request. + * @param array $annotations The ability annotations. + * @return true|WP_Error True on success, or WP_Error object on failure. */ - public function run_ability( $request ) { - $ability = wp_get_ability( $request->get_param( 'name' ) ); - if ( ! $ability ) { - return new \WP_Error( - 'rest_ability_not_found', - __( 'Ability not found.' ), - array( 'status' => 404 ) - ); + public function validate_request_method( string $request_method, array $annotations ) { + $expected_method = 'POST'; + if ( ! empty( $annotations['readonly'] ) ) { + $expected_method = 'GET'; + } elseif ( ! empty( $annotations['destructive'] ) && ! empty( $annotations['idempotent'] ) ) { + $expected_method = 'DELETE'; } - $input = $ability->normalize_input( $this->get_input_from_request( $request ) ); - $result = $ability->execute( $input ); - if ( is_wp_error( $result ) ) { - if ( 'ability_invalid_input' === $result->get_error_code() ) { - $result->add_data( array( 'status' => 400 ) ); - } - return $result; + if ( $expected_method === $request_method ) { + return true; } - return rest_ensure_response( $result ); + $error_message = __( 'Abilities that perform updates require POST method.' ); + if ( 'GET' === $expected_method ) { + $error_message = __( 'Read-only abilities require GET method.' ); + } elseif ( 'DELETE' === $expected_method ) { + $error_message = __( 'Abilities that perform destructive actions require DELETE method.' ); + } + return new WP_Error( + 'rest_ability_invalid_method', + $error_message, + array( 'status' => 405 ) + ); } /** * Checks if a given request has permission to execute a specific ability. * - * @since 0.1.0 + * @since 6.9.0 * - * @param \WP_REST_Request> $request Full details about the request. - * @return true|\WP_Error True if the request has execution permission, WP_Error object otherwise. + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has execution permission, WP_Error object otherwise. */ - public function run_ability_permissions_check( $request ) { - $ability = wp_get_ability( $request->get_param( 'name' ) ); + public function check_ability_permissions( $request ) { + $ability = wp_get_ability( $request['name'] ); if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) { - return new \WP_Error( + return new WP_Error( 'rest_ability_not_found', __( 'Ability not found.' ), array( 'status' => 404 ) ); } - $input = $ability->normalize_input( $this->get_input_from_request( $request ) ); - if ( ! $ability->check_permissions( $input ) ) { - return new \WP_Error( + $is_valid = $this->validate_request_method( + $request->get_method(), + $ability->get_meta_item( 'annotations' ) + ); + if ( is_wp_error( $is_valid ) ) { + return $is_valid; + } + + $input = $this->get_input_from_request( $request ); + $input = $ability->normalize_input( $input ); + $is_valid = $ability->validate_input( $input ); + if ( is_wp_error( $is_valid ) ) { + $is_valid->add_data( array( 'status' => 400 ) ); + return $is_valid; + } + + $result = $ability->check_permissions( $input ); + if ( is_wp_error( $result ) ) { + $result->add_data( array( 'status' => rest_authorization_required_code() ) ); + return $result; + } + if ( ! $result ) { + return new WP_Error( 'rest_ability_cannot_execute', __( 'Sorry, you are not allowed to execute this ability.' ), array( 'status' => rest_authorization_required_code() ) @@ -177,14 +185,14 @@ public function run_ability_permissions_check( $request ) { /** * Extracts input parameters from the request. * - * @since 0.1.0 + * @since 6.9.0 * - * @param \WP_REST_Request> $request The request object. + * @param WP_REST_Request $request The request object. * @return mixed|null The input parameters. */ private function get_input_from_request( $request ) { - if ( 'GET' === $request->get_method() ) { - // For GET requests, look for 'input' query parameter. + if ( in_array( $request->get_method(), array( 'GET', 'DELETE' ), true ) ) { + // For GET and DELETE requests, look for 'input' query parameter. $query_params = $request->get_query_params(); return $query_params['input'] ?? null; } @@ -197,7 +205,7 @@ private function get_input_from_request( $request ) { /** * Retrieves the arguments for ability execution endpoint. * - * @since 0.1.0 + * @since 6.9.0 * * @return array Arguments for the run endpoint. */ @@ -214,7 +222,7 @@ public function get_run_args(): array { /** * Retrieves the schema for ability execution endpoint. * - * @since 0.1.0 + * @since 6.9.0 * * @return array Schema for the run endpoint. */ @@ -226,7 +234,8 @@ public function get_run_schema(): array { 'properties' => array( 'result' => array( 'description' => __( 'The result of the ability execution.' ), - 'context' => array( 'view' ), + 'type' => array( 'integer', 'number', 'boolean', 'string', 'array', 'object', 'null' ), + 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), diff --git a/tests/unit/abilities-api/wpAbilitiesRegistry.php b/tests/unit/abilities-api/wpAbilitiesRegistry.php index 57000933..95fa29a2 100644 --- a/tests/unit/abilities-api/wpAbilitiesRegistry.php +++ b/tests/unit/abilities-api/wpAbilitiesRegistry.php @@ -15,7 +15,7 @@ class Tests_Abilities_API_WpAbilitiesRegistry extends WP_UnitTestCase { /** * Mock abilities registry. * - * @var \WP_Abilities_Registry + * @var WP_Abilities_Registry */ private $registry = null; @@ -27,26 +27,17 @@ public function set_up(): void { $this->registry = new WP_Abilities_Registry(); - remove_all_filters( 'register_ability_args' ); - - // Register category during the hook. - add_action( - 'abilities_api_categories_init', - function () { - if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( 'math' ) ) { - wp_register_ability_category( - 'math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations and calculations.', - ) - ); - } - } - ); + remove_all_filters( 'wp_register_ability_args' ); - // Fire the hook to allow category registration. - do_action( 'abilities_api_categories_init' ); + // Fire the init hook to allow test ability category registration. + do_action( 'wp_abilities_api_categories_init' ); + wp_register_ability_category( + 'math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations and calculations.', + ) + ); self::$test_ability_args = array( 'label' => 'Add numbers', @@ -91,13 +82,10 @@ function () { public function tear_down(): void { $this->registry = null; - remove_all_filters( 'register_ability_args' ); + remove_all_filters( 'wp_register_ability_args' ); - // Clean up registered categories. - $category_registry = WP_Abilities_Category_Registry::get_instance(); - if ( $category_registry->is_registered( 'math' ) ) { - wp_unregister_ability_category( 'math' ); - } + // Clean up registered test ability category. + wp_unregister_ability_category( 'math' ); parent::tear_down(); } @@ -105,6 +93,8 @@ public function tear_down(): void { /** * Should reject ability name without a namespace. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * * @expectedIncorrectUsage WP_Abilities_Registry::register @@ -117,6 +107,8 @@ public function test_register_invalid_name_without_namespace() { /** * Should reject ability name with invalid characters. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * * @expectedIncorrectUsage WP_Abilities_Registry::register @@ -129,6 +121,8 @@ public function test_register_invalid_characters_in_name() { /** * Should reject ability name with uppercase characters. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * * @expectedIncorrectUsage WP_Abilities_Registry::register @@ -141,6 +135,8 @@ public function test_register_invalid_uppercase_characters_in_name() { /** * Should reject ability registration without a label. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -157,6 +153,8 @@ public function test_register_invalid_missing_label() { /** * Should reject ability registration with invalid label type. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -172,6 +170,8 @@ public function test_register_invalid_label_type() { /** * Should reject ability registration without a description. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -188,6 +188,8 @@ public function test_register_invalid_missing_description() { /** * Should reject ability registration with invalid description type. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -200,9 +202,29 @@ public function test_register_invalid_description_type() { $this->assertNull( $result ); } + /** + * Tests registering an ability with non-existent category. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_ability_nonexistent_category(): void { + $args = array_merge( + self::$test_ability_args, + array( 'category' => 'nonexistent' ) + ); + + $result = $this->registry->register( self::$test_ability_name, $args ); + + $this->assertNull( $result, 'Should return null when category does not exist.' ); + } + /** * Should reject ability registration without an execute callback. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -219,6 +241,8 @@ public function test_register_invalid_missing_execute_callback() { /** * Should reject ability registration if the execute callback is not a callable. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -234,6 +258,8 @@ public function test_register_incorrect_execute_callback_type() { /** * Should reject ability registration without an execute callback. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -250,6 +276,8 @@ public function test_register_invalid_missing_permission_callback() { /** * Should reject ability registration if the permission callback is not a callable. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -265,6 +293,8 @@ public function test_register_incorrect_permission_callback_type() { /** * Should reject ability registration if the input schema is not an array. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -280,6 +310,8 @@ public function test_register_incorrect_input_schema_type() { /** * Should reject ability registration if the output schema is not an array. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -296,6 +328,8 @@ public function test_register_incorrect_output_schema_type() { /** * Should reject ability registration with invalid `annotations` type. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -311,6 +345,8 @@ public function test_register_invalid_annotations_type() { /** * Should reject ability registration with invalid meta type. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -326,6 +362,8 @@ public function test_register_invalid_meta_type() { /** * Should reject ability registration with invalid show in REST type. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -341,6 +379,8 @@ public function test_register_invalid_show_in_rest_type() { /** * Should reject registration for already registered ability. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * * @expectedIncorrectUsage WP_Abilities_Registry::register @@ -356,6 +396,8 @@ public function test_register_incorrect_already_registered_ability() { /** * Should successfully register a new ability. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register */ public function test_register_new_ability() { @@ -370,6 +412,8 @@ public function test_register_new_ability() { /** * Should return false for ability that's not registered. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::is_registered */ public function test_is_registered_for_unknown_ability() { @@ -380,6 +424,8 @@ public function test_is_registered_for_unknown_ability() { /** * Should return true if ability is registered. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Abilities_Registry::is_registered */ @@ -395,6 +441,8 @@ public function test_is_registered_for_known_ability() { /** * Should not find ability that's not registered. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::get_registered * * @expectedIncorrectUsage WP_Abilities_Registry::get_registered @@ -407,6 +455,8 @@ public function test_get_registered_rejects_unknown_ability_name() { /** * Should find registered ability by name. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Abilities_Registry::get_registered */ @@ -420,7 +470,9 @@ public function test_get_registered_for_known_ability() { } /** - * Unregistering should fail if a ability is not registered. + * Unregistering should fail if an ability is not registered. + * + * @ticket 64098 * * @covers WP_Abilities_Registry::unregister * @@ -434,6 +486,8 @@ public function test_unregister_not_registered_ability() { /** * Should unregister ability by name. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Abilities_Registry::unregister */ @@ -451,6 +505,8 @@ public function test_unregister_for_known_ability() { /** * Should retrieve all registered abilities. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Abilities_Registry::get_all_registered */ @@ -471,33 +527,17 @@ public function test_get_all_registered() { $this->assertSame( $ability_three_name, $result[ $ability_three_name ]->get_name() ); } - /** - * Direct instantiation of WP_Ability with invalid properties should throw an exception. - * - * @covers WP_Ability::__construct - * @covers WP_Ability::prepare_properties - */ - public function test_wp_ability_invalid_properties_throws_exception() { - $this->expectException( \InvalidArgumentException::class ); - new WP_Ability( - 'test/invalid', - array( - 'label' => '', - 'description' => '', - 'execute_callback' => null, - ) - ); - } - /** * Test register_ability_args filter modifies the args before ability instantiation. + * + * @ticket 64098 */ public function test_register_ability_args_filter_modifies_args() { $was_filter_callback_fired = false; // Define the filter. add_filter( - 'register_ability_args', + 'wp_register_ability_args', static function ( $args ) use ( &$was_filter_callback_fired ) { $args['label'] = 'Modified label'; $original_execute_callback = $args['execute_callback']; @@ -532,12 +572,14 @@ static function ( $args ) use ( &$was_filter_callback_fired ) { /** * Test register_ability_args filter can block ability registration by returning invalid args. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_ability_args_filter_blocks_registration() { // Define the filter. add_filter( - 'register_ability_args', + 'wp_register_ability_args', static function ( $args ) { // Remove the label to make the args invalid. unset( $args['label'] ); @@ -555,12 +597,15 @@ static function ( $args ) { /** * Test register_ability_args filter can block an invalid ability class from being used. + * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_ability_args_filter_blocks_invalid_ability_class() { // Define the filter. add_filter( - 'register_ability_args', + 'wp_register_ability_args', static function ( $args ) { // Set an invalid ability class. $args['ability_class'] = 'NonExistentClass'; @@ -577,10 +622,12 @@ static function ( $args ) { /** * Tests register_ability_args filter is only applied to the specific ability being registered. + * + * @ticket 64098 */ public function test_register_ability_args_filter_only_applies_to_specific_ability() { add_filter( - 'register_ability_args', + 'wp_register_ability_args', static function ( $args, $name ) { if ( self::$test_ability_name !== $name ) { // Do not modify args for other abilities. diff --git a/tests/unit/abilities-api/wpAbility.php b/tests/unit/abilities-api/wpAbility.php index 17c16e7b..910ae7da 100644 --- a/tests/unit/abilities-api/wpAbility.php +++ b/tests/unit/abilities-api/wpAbility.php @@ -18,25 +18,16 @@ class Tests_Abilities_API_WpAbility extends WP_UnitTestCase { public function set_up(): void { parent::set_up(); - // Register category during the hook. - add_action( - 'abilities_api_categories_init', - function () { - if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( 'math' ) ) { - wp_register_ability_category( - 'math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations and calculations.', - ) - ); - } - } + // Fire the init hook to allow test ability category registration. + do_action( 'wp_abilities_api_categories_init' ); + wp_register_ability_category( + 'math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations and calculations.', + ) ); - // Fire the hook to allow category registration. - do_action( 'abilities_api_categories_init' ); - self::$test_ability_properties = array( 'label' => 'Calculator', 'description' => 'Calculates the result of math operations.', @@ -65,17 +56,36 @@ function () { * Tear down after each test. */ public function tear_down(): void { - // Clean up registered categories. - $category_registry = WP_Abilities_Category_Registry::get_instance(); - if ( $category_registry->is_registered( 'math' ) ) { - wp_unregister_ability_category( 'math' ); - } + // Clean up registered test ability category. + wp_unregister_ability_category( 'math' ); parent::tear_down(); } + /** + * Direct instantiation of WP_Ability with invalid properties should throw an exception. + * + * @ticket 64098 + * + * @covers WP_Ability::__construct + * @covers WP_Ability::prepare_properties + */ + public function test_wp_ability_invalid_properties_throws_exception() { + $this->expectException( InvalidArgumentException::class ); + new WP_Ability( + 'test/invalid', + array( + 'label' => '', + 'description' => '', + 'execute_callback' => null, + ) + ); + } + /* * Tests that getting non-existing metadata item returns default value. + * + * @ticket 64098 */ public function test_meta_get_non_existing_item_returns_default() { $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); @@ -88,6 +98,8 @@ public function test_meta_get_non_existing_item_returns_default() { /** * Tests that getting non-existing metadata item with custom default returns that default. + * + * @ticket 64098 */ public function test_meta_get_non_existing_item_with_custom_default() { $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); @@ -101,16 +113,17 @@ public function test_meta_get_non_existing_item_with_custom_default() { /** * Tests getting all annotations when selective overrides are applied. + * + * @ticket 64098 */ public function test_get_merged_annotations_from_meta() { $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); - $this->assertEquals( + $this->assertSame( array_merge( self::$test_ability_properties['meta']['annotations'], array( - 'instructions' => '', - 'idempotent' => false, + 'idempotent' => null, ) ), $ability->get_meta_item( 'annotations' ) @@ -119,6 +132,8 @@ public function test_get_merged_annotations_from_meta() { /** * Tests getting default annotations when not provided. + * + * @ticket 64098 */ public function test_get_default_annotations_from_meta() { $args = self::$test_ability_properties; @@ -128,10 +143,9 @@ public function test_get_default_annotations_from_meta() { $this->assertSame( array( - 'instructions' => '', - 'readonly' => false, - 'destructive' => true, - 'idempotent' => false, + 'readonly' => null, + 'destructive' => null, + 'idempotent' => null, ), $ability->get_meta_item( 'annotations' ) ); @@ -139,13 +153,14 @@ public function test_get_default_annotations_from_meta() { /** * Tests getting all annotations when values overridden. + * + * @ticket 64098 */ public function test_get_overridden_annotations_from_meta() { $annotations = array( - 'instructions' => 'Enjoy responsibly.', - 'readonly' => true, - 'destructive' => false, - 'idempotent' => false, + 'readonly' => true, + 'destructive' => false, + 'idempotent' => false, ); $args = array_merge( self::$test_ability_properties, @@ -163,6 +178,8 @@ public function test_get_overridden_annotations_from_meta() { /** * Tests that invalid `annotations` value throws an exception. + * + * @ticket 64098 */ public function test_annotations_from_meta_throws_exception() { $args = array_merge( @@ -182,6 +199,8 @@ public function test_annotations_from_meta_throws_exception() { /** * Tests that `show_in_rest` metadata defaults to false when not provided. + * + * @ticket 64098 */ public function test_meta_show_in_rest_defaults_to_false() { $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); @@ -194,6 +213,8 @@ public function test_meta_show_in_rest_defaults_to_false() { /** * Tests that `show_in_rest` metadata can be set to true. + * + * @ticket 64098 */ public function test_meta_show_in_rest_can_be_set_to_true() { $args = array_merge( @@ -214,6 +235,8 @@ public function test_meta_show_in_rest_can_be_set_to_true() { /** * Tests that `show_in_rest` can be set to false. + * + * @ticket 64098 */ public function test_show_in_rest_can_be_set_to_false() { $args = array_merge( @@ -234,6 +257,8 @@ public function test_show_in_rest_can_be_set_to_false() { /** * Tests that invalid `show_in_rest` value throws an exception. + * + * @ticket 64098 */ public function test_show_in_rest_throws_exception() { $args = array_merge( @@ -253,6 +278,8 @@ public function test_show_in_rest_throws_exception() { /** * Data provider for testing the execution of the ability. + * + * @return array Data sets with different configurations. */ public function data_execute_input() { return array( @@ -366,7 +393,14 @@ static function ( array $input ): int { /** * Tests the execution of the ability. * + * @ticket 64098 + * * @dataProvider data_execute_input + * + * @param array $input_schema The input schema for the ability. + * @param callable $execute_callback The execute callback for the ability. + * @param mixed $input The input to pass to the execute method. + * @param mixed $result The expected result from the execute method. */ public function test_execute_input( $input_schema, $execute_callback, $input, $result ) { $args = array_merge( @@ -404,6 +438,8 @@ public function my_instance_execute_callback( string $input ): int { /** * Data provider for testing different types of execute callbacks. + * + * @return array Data sets with different execute callbacks. */ public function data_execute_callback() { return array( @@ -430,7 +466,11 @@ static function ( string $input ): int { /** * Tests the execution of the ability with different types of callbacks. * + * @ticket 64098 + * * @dataProvider data_execute_callback + * + * @param callable $execute_callback The execute callback to test. */ public function test_execute_with_different_callbacks( $execute_callback ) { $args = array_merge( @@ -452,6 +492,8 @@ public function test_execute_with_different_callbacks( $execute_callback ) { /** * Tests the execution of the ability with no input. + * + * @ticket 64098 */ public function test_execute_no_input() { $args = array_merge( @@ -470,6 +512,8 @@ public function test_execute_no_input() { /** * Tests that before_execute_ability action is fired with correct parameters. + * + * @ticket 64098 */ public function test_before_execute_ability_action() { $action_ability_name = null; @@ -494,12 +538,12 @@ public function test_before_execute_ability_action() { $action_input = $input; }; - add_action( 'before_execute_ability', $callback, 10, 2 ); + add_action( 'wp_before_execute_ability', $callback, 10, 2 ); $ability = new WP_Ability( self::$test_ability_name, $args ); $result = $ability->execute( 5 ); - remove_action( 'before_execute_ability', $callback ); + remove_action( 'wp_before_execute_ability', $callback ); $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' ); $this->assertSame( 5, $action_input, 'Action should receive correct input' ); @@ -508,6 +552,8 @@ public function test_before_execute_ability_action() { /** * Tests that before_execute_ability action is fired with null input when no input schema is defined. + * + * @ticket 64098 */ public function test_before_execute_ability_action_no_input() { $action_ability_name = null; @@ -527,12 +573,12 @@ public function test_before_execute_ability_action_no_input() { $action_input = $input; }; - add_action( 'before_execute_ability', $callback, 10, 2 ); + add_action( 'wp_before_execute_ability', $callback, 10, 2 ); $ability = new WP_Ability( self::$test_ability_name, $args ); $result = $ability->execute(); - remove_action( 'before_execute_ability', $callback ); + remove_action( 'wp_before_execute_ability', $callback ); $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' ); $this->assertNull( $action_input, 'Action should receive null input when no input provided' ); @@ -541,6 +587,8 @@ public function test_before_execute_ability_action_no_input() { /** * Tests that after_execute_ability action is fired with correct parameters. + * + * @ticket 64098 */ public function test_after_execute_ability_action() { $action_ability_name = null; @@ -567,12 +615,12 @@ public function test_after_execute_ability_action() { $action_result = $result; }; - add_action( 'after_execute_ability', $callback, 10, 3 ); + add_action( 'wp_after_execute_ability', $callback, 10, 3 ); $ability = new WP_Ability( self::$test_ability_name, $args ); $result = $ability->execute( 7 ); - remove_action( 'after_execute_ability', $callback ); + remove_action( 'wp_after_execute_ability', $callback ); $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' ); $this->assertSame( 7, $action_input, 'Action should receive correct input' ); @@ -582,6 +630,8 @@ public function test_after_execute_ability_action() { /** * Tests that after_execute_ability action is fired with null input when no input schema is defined. + * + * @ticket 64098 */ public function test_after_execute_ability_action_no_input() { $action_ability_name = null; @@ -604,12 +654,12 @@ public function test_after_execute_ability_action_no_input() { $action_result = $result; }; - add_action( 'after_execute_ability', $callback, 10, 3 ); + add_action( 'wp_after_execute_ability', $callback, 10, 3 ); $ability = new WP_Ability( self::$test_ability_name, $args ); $result = $ability->execute(); - remove_action( 'after_execute_ability', $callback ); + remove_action( 'wp_after_execute_ability', $callback ); $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' ); $this->assertNull( $action_input, 'Action should receive null input when no input provided' ); @@ -619,6 +669,8 @@ public function test_after_execute_ability_action_no_input() { /** * Tests that neither action is fired when execution fails due to permission issues. + * + * @ticket 64098 */ public function test_actions_not_fired_on_permission_failure() { $before_action_fired = false; @@ -641,14 +693,14 @@ public function test_actions_not_fired_on_permission_failure() { $after_action_fired = true; }; - add_action( 'before_execute_ability', $before_callback ); - add_action( 'after_execute_ability', $after_callback ); + add_action( 'wp_before_execute_ability', $before_callback ); + add_action( 'wp_after_execute_ability', $after_callback ); $ability = new WP_Ability( self::$test_ability_name, $args ); $result = $ability->execute(); - remove_action( 'before_execute_ability', $before_callback ); - remove_action( 'after_execute_ability', $after_callback ); + remove_action( 'wp_before_execute_ability', $before_callback ); + remove_action( 'wp_after_execute_ability', $after_callback ); $this->assertFalse( $before_action_fired, 'before_execute_ability action should not be fired on permission failure' ); $this->assertFalse( $after_action_fired, 'after_execute_ability action should not be fired on permission failure' ); @@ -657,6 +709,8 @@ public function test_actions_not_fired_on_permission_failure() { /** * Tests that after_execute_ability action is not fired when execution callback returns WP_Error. + * + * @ticket 64098 */ public function test_after_action_not_fired_on_execution_error() { $before_action_fired = false; @@ -679,14 +733,14 @@ public function test_after_action_not_fired_on_execution_error() { $after_action_fired = true; }; - add_action( 'before_execute_ability', $before_callback ); - add_action( 'after_execute_ability', $after_callback ); + add_action( 'wp_before_execute_ability', $before_callback ); + add_action( 'wp_after_execute_ability', $after_callback ); $ability = new WP_Ability( self::$test_ability_name, $args ); $result = $ability->execute(); - remove_action( 'before_execute_ability', $before_callback ); - remove_action( 'after_execute_ability', $after_callback ); + remove_action( 'wp_before_execute_ability', $before_callback ); + remove_action( 'wp_after_execute_ability', $after_callback ); $this->assertTrue( $before_action_fired, 'before_execute_ability action should be fired even if execution fails' ); $this->assertFalse( $after_action_fired, 'after_execute_ability action should not be fired when execution returns WP_Error' ); @@ -695,6 +749,8 @@ public function test_after_action_not_fired_on_execution_error() { /** * Tests that after_execute_ability action is not fired when output validation fails. + * + * @ticket 64098 */ public function test_after_action_not_fired_on_output_validation_error() { $before_action_fired = false; @@ -722,14 +778,14 @@ public function test_after_action_not_fired_on_output_validation_error() { $after_action_fired = true; }; - add_action( 'before_execute_ability', $before_callback ); - add_action( 'after_execute_ability', $after_callback ); + add_action( 'wp_before_execute_ability', $before_callback ); + add_action( 'wp_after_execute_ability', $after_callback ); $ability = new WP_Ability( self::$test_ability_name, $args ); $result = $ability->execute(); - remove_action( 'before_execute_ability', $before_callback ); - remove_action( 'after_execute_ability', $after_callback ); + remove_action( 'wp_before_execute_ability', $before_callback ); + remove_action( 'wp_after_execute_ability', $after_callback ); $this->assertTrue( $before_action_fired, 'before_execute_ability action should be fired even if output validation fails' ); $this->assertFalse( $after_action_fired, 'after_execute_ability action should not be fired when output validation fails' ); diff --git a/tests/unit/abilities-api/wpAbilityCategory.php b/tests/unit/abilities-api/wpAbilityCategoryRegistry.php similarity index 61% rename from tests/unit/abilities-api/wpAbilityCategory.php rename to tests/unit/abilities-api/wpAbilityCategoryRegistry.php index c82056c4..c517ad66 100644 --- a/tests/unit/abilities-api/wpAbilityCategory.php +++ b/tests/unit/abilities-api/wpAbilityCategoryRegistry.php @@ -4,20 +4,16 @@ * Tests for the ability category functionality. * * @covers WP_Ability_Category - * @covers WP_Abilities_Category_Registry - * @covers wp_register_ability_category - * @covers wp_unregister_ability_category - * @covers wp_get_ability_category - * @covers wp_get_ability_categories + * @covers WP_Ability_Categories_Registry * * @group abilities-api */ -class Tests_Abilities_API_WpAbilityCategory extends WP_UnitTestCase { +class Tests_Abilities_API_WpAbilityCategoryRegistry extends WP_UnitTestCase { /** * Category registry instance. * - * @var \WP_Abilities_Category_Registry + * @var WP_Ability_Categories_Registry */ private $registry; @@ -34,7 +30,7 @@ class Tests_Abilities_API_WpAbilityCategory extends WP_UnitTestCase { public function set_up(): void { parent::set_up(); - $this->registry = WP_Abilities_Category_Registry::get_instance(); + $this->registry = new WP_Ability_Categories_Registry(); $this->doing_it_wrong_log = array(); add_action( 'doing_it_wrong_run', array( $this, 'record_doing_it_wrong' ), 10, 3 ); @@ -47,14 +43,7 @@ public function tear_down(): void { remove_action( 'doing_it_wrong_run', array( $this, 'record_doing_it_wrong' ) ); $this->doing_it_wrong_log = array(); - // Clean up all test categories. - $categories = $this->registry->get_all_registered(); - foreach ( $categories as $category ) { - if ( 0 !== strpos( $category->get_slug(), 'test-' ) ) { - continue; - } - $this->registry->unregister( $category->get_slug() ); - } + $this->registry = null; parent::tear_down(); } @@ -104,27 +93,13 @@ private function assertDoingItWrongTriggered( string $the_method, ?string $messa } } - /** - * Helper to register a category during the hook. - */ - private function register_category_during_hook( string $slug, array $args ): ?WP_Ability_Category { - $result = null; - $callback = static function () use ( $slug, $args, &$result ): void { - $result = wp_register_ability_category( $slug, $args ); - }; - - add_action( 'abilities_api_categories_init', $callback ); - do_action( 'abilities_api_categories_init', WP_Abilities_Category_Registry::get_instance() ); - remove_action( 'abilities_api_categories_init', $callback ); - - return $result; - } - /** * Test registering a valid category. + * + * @ticket 64098 */ public function test_register_valid_category(): void { - $result = $this->register_category_during_hook( + $result = $this->registry->register( 'test-math', array( 'label' => 'Math', @@ -141,11 +116,13 @@ public function test_register_valid_category(): void { /** * Test registering category with invalid slug format. * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_category_invalid_slug_format(): void { // Uppercase characters not allowed. - $result = $this->register_category_during_hook( + $result = $this->registry->register( 'Test-Math', array( 'label' => 'Math', @@ -154,16 +131,18 @@ public function test_register_category_invalid_slug_format(): void { ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'slug must contain only lowercase' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'slug must contain only lowercase' ); } /** * Test registering category with invalid slug - underscore. * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_category_invalid_slug_underscore(): void { - $result = $this->register_category_during_hook( + $result = $this->registry->register( 'test_math', array( 'label' => 'Math', @@ -172,16 +151,18 @@ public function test_register_category_invalid_slug_underscore(): void { ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'slug must contain only lowercase' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'slug must contain only lowercase' ); } /** * Test registering category without label. * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_category_missing_label(): void { - $result = $this->register_category_during_hook( + $result = $this->registry->register( 'test-math', array( 'description' => 'Mathematical operations.', @@ -189,16 +170,18 @@ public function test_register_category_missing_label(): void { ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); } /** * Test registering category without description. * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_category_missing_description(): void { - $result = $this->register_category_during_hook( + $result = $this->registry->register( 'test-math', array( 'label' => 'Math', @@ -206,24 +189,18 @@ public function test_register_category_missing_description(): void { ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); } /** - * Test registering category before abilities_api_categories_init hook. + * Test registering duplicate category. + * + * @ticket 64098 * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ - public function test_register_category_before_init_hook(): void { - global $wp_actions; - - // Store original count. - $original_count = isset( $wp_actions['abilities_api_categories_init'] ) ? $wp_actions['abilities_api_categories_init'] : 0; - - // Reset to simulate hook not fired. - unset( $wp_actions['abilities_api_categories_init'] ); - - $result = wp_register_ability_category( + public function test_register_duplicate_category(): void { + $result = $this->registry->register( 'test-math', array( 'label' => 'Math', @@ -231,53 +208,27 @@ public function test_register_category_before_init_hook(): void { ) ); - // Restore original count. - if ( $original_count > 0 ) { - $wp_actions['abilities_api_categories_init'] = $original_count; - } - - $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'abilities_api_categories_init' ); - } - - /** - * Test registering duplicate category. - * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register - */ - public function test_register_duplicate_category(): void { - $result = null; - $callback = static function () use ( &$result ): void { - wp_register_ability_category( - 'test-math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations.', - ) - ); - - $result = wp_register_ability_category( - 'test-math', - array( - 'label' => 'Math 2', - 'description' => 'Another math category.', - ) - ); - }; + $this->assertInstanceOf( WP_Ability_Category::class, $result ); - add_action( 'abilities_api_categories_init', $callback ); - do_action( 'abilities_api_categories_init', WP_Abilities_Category_Registry::get_instance() ); - remove_action( 'abilities_api_categories_init', $callback ); + $result = $this->registry->register( + 'test-math', + array( + 'label' => 'Math 2', + 'description' => 'Another math category.', + ) + ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'already registered' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'already registered' ); } /** * Test unregistering existing category. + * + * @ticket 64098 */ public function test_unregister_existing_category(): void { - $this->register_category_during_hook( + $this->registry->register( 'test-math', array( 'label' => 'Math', @@ -285,7 +236,7 @@ public function test_unregister_existing_category(): void { ) ); - $result = wp_unregister_ability_category( 'test-math' ); + $result = $this->registry->unregister( 'test-math' ); $this->assertInstanceOf( WP_Ability_Category::class, $result ); $this->assertFalse( $this->registry->is_registered( 'test-math' ) ); @@ -294,20 +245,24 @@ public function test_unregister_existing_category(): void { /** * Test unregistering non-existent category. * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::unregister + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::unregister */ public function test_unregister_nonexistent_category(): void { - $result = wp_unregister_ability_category( 'test-nonexistent' ); + $result = $this->registry->unregister( 'test-nonexistent' ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::unregister' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::unregister' ); } /** * Test retrieving existing category. + * + * @ticket 64098 */ public function test_get_existing_category(): void { - $this->register_category_during_hook( + $this->registry->register( 'test-math', array( 'label' => 'Math', @@ -315,7 +270,7 @@ public function test_get_existing_category(): void { ) ); - $result = wp_get_ability_category( 'test-math' ); + $result = $this->registry->get_registered( 'test-math' ); $this->assertInstanceOf( WP_Ability_Category::class, $result ); $this->assertSame( 'test-math', $result->get_slug() ); @@ -324,20 +279,55 @@ public function test_get_existing_category(): void { /** * Test retrieving non-existent category. * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::get_registered + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_registered */ public function test_get_nonexistent_category(): void { - $result = wp_get_ability_category( 'test-nonexistent' ); + $result = $this->registry->get_registered( 'test-nonexistent' ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::get_registered' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::get_registered' ); + } + + /** + * Tests checking if an ability category is registered. + * + * @ticket 64098 + */ + public function test_has_registered_ability_category(): void { + $category_slug = 'test-math'; + $this->registry->register( + $category_slug, + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $result = $this->registry->is_registered( $category_slug ); + + $this->assertTrue( $result ); + } + + /** + * Tests checking if a non-existent ability category is registered. + * + * @ticket 64098 + */ + public function test_has_registered_nonexistent_ability_category(): void { + $result = $this->registry->is_registered( 'test/non-existent' ); + + $this->assertFalse( $result ); } /** * Test retrieving all registered categories. + * + * @ticket 64098 */ public function test_get_all_categories(): void { - $this->register_category_during_hook( + $this->registry->register( 'test-math', array( 'label' => 'Math', @@ -345,7 +335,7 @@ public function test_get_all_categories(): void { ) ); - $this->register_category_during_hook( + $this->registry->register( 'test-system', array( 'label' => 'System', @@ -353,7 +343,7 @@ public function test_get_all_categories(): void { ) ); - $categories = wp_get_ability_categories(); + $categories = $this->registry->get_all_registered(); $this->assertIsArray( $categories ); $this->assertCount( 2, $categories ); @@ -363,11 +353,13 @@ public function test_get_all_categories(): void { /** * Test category is_registered method. + * + * @ticket 64098 */ public function test_category_is_registered(): void { $this->assertFalse( $this->registry->is_registered( 'test-math' ) ); - $this->register_category_during_hook( + $this->registry->register( 'test-math', array( 'label' => 'Math', @@ -378,92 +370,13 @@ public function test_category_is_registered(): void { $this->assertTrue( $this->registry->is_registered( 'test-math' ) ); } - /** - * Test ability can only be registered with existing category. - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_ability_requires_existing_category(): void { - do_action( 'abilities_api_init' ); - - // Ensure category doesn't exist - test should fail if it does. - $this->assertFalse( - WP_Abilities_Category_Registry::get_instance()->is_registered( 'test-nonexistent' ), - 'The test-nonexistent category should not be registered - test isolation may be broken' - ); - - // Try to register ability with non-existent category. - $result = wp_register_ability( - 'test/calculator', - array( - 'label' => 'Calculator', - 'description' => 'Performs calculations.', - 'category' => 'test-nonexistent', - 'execute_callback' => static function () { - return 42; - }, - 'permission_callback' => '__return_true', - ) - ); - - $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Registry::register', 'not registered' ); - } - - /** - * Test ability can be registered with valid category. - */ - public function test_ability_with_valid_category(): void { - $category_callback = static function (): void { - wp_register_ability_category( - 'test-math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations.', - ) - ); - }; - - add_action( 'abilities_api_categories_init', $category_callback ); - do_action( 'abilities_api_categories_init', WP_Abilities_Category_Registry::get_instance() ); - remove_action( 'abilities_api_categories_init', $category_callback ); - do_action( 'abilities_api_init' ); - - $result = wp_register_ability( - 'test/calculator', - array( - 'label' => 'Calculator', - 'description' => 'Performs calculations.', - 'category' => 'test-math', - 'execute_callback' => static function () { - return 42; - }, - 'permission_callback' => '__return_true', - ) - ); - - $this->assertInstanceOf( WP_Ability::class, $result ); - $this->assertSame( 'test-math', $result->get_category() ); - - // Cleanup. - wp_unregister_ability( 'test/calculator' ); - } - - /** - * Test category registry singleton. - */ - public function test_category_registry_singleton(): void { - $instance1 = WP_Abilities_Category_Registry::get_instance(); - $instance2 = WP_Abilities_Category_Registry::get_instance(); - - $this->assertSame( $instance1, $instance2 ); - } - /** * Test category with special characters in label and description. + * + * @ticket 64098 */ public function test_category_with_special_characters(): void { - $result = $this->register_category_during_hook( + $result = $this->registry->register( 'test-special', array( 'label' => 'Math & Science ', @@ -477,11 +390,11 @@ public function test_category_with_special_characters(): void { } /** - * Data provider for valid category slugs. + * Data provider for valid ability category slugs. * - * @return array> + * @return array> Valid ability category slugs. */ - public function valid_slug_provider(): array { + public function data_valid_slug_provider(): array { return array( array( 'test-simple' ), array( 'test-multiple-words' ), @@ -494,10 +407,14 @@ public function valid_slug_provider(): array { /** * Test category slug validation with valid formats. * - * @dataProvider valid_slug_provider + * @ticket 64098 + * + * @dataProvider data_valid_slug_provider + * + * @param string $slug The category slug to test. */ public function test_category_slug_valid_formats( string $slug ): void { - $result = $this->register_category_during_hook( + $result = $this->registry->register( $slug, array( 'label' => 'Test', @@ -509,11 +426,11 @@ public function test_category_slug_valid_formats( string $slug ): void { } /** - * Data provider for invalid category slugs. + * Data provider for invalid ability category slugs. * - * @return array> + * @return array> Invalid ability category slugs. */ - public function invalid_slug_provider(): array { + public function data_invalid_slug_provider(): array { return array( array( 'Test-Uppercase' ), array( 'test_underscore' ), @@ -529,11 +446,15 @@ public function invalid_slug_provider(): array { /** * Test category slug validation with invalid formats. * - * @dataProvider invalid_slug_provider - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @ticket 64098 + * + * @dataProvider data_invalid_slug_provider + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register + * + * @param string $slug The category slug to test. */ public function test_category_slug_invalid_formats( string $slug ): void { - $result = $this->register_category_during_hook( + $result = $this->registry->register( $slug, array( 'label' => 'Test', @@ -542,16 +463,18 @@ public function test_category_slug_invalid_formats( string $slug ): void { ); $this->assertNull( $result, "Slug '{$slug}' should be invalid" ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); } /** * Test registering category with non-string label. * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_category_constructor_non_string_label(): void { - $result = $this->register_category_during_hook( + $result = $this->registry->register( 'test-invalid', array( 'label' => 123, // Integer instead of string @@ -560,16 +483,18 @@ public function test_category_constructor_non_string_label(): void { ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); } /** * Test registering category with empty label. * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_category_constructor_empty_label(): void { - $result = $this->register_category_during_hook( + $result = $this->registry->register( 'test-invalid', array( 'label' => '', @@ -578,16 +503,18 @@ public function test_category_constructor_empty_label(): void { ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); } /** * Test registering category with non-string description. * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_category_constructor_non_string_description(): void { - $result = $this->register_category_during_hook( + $result = $this->registry->register( 'test-invalid', array( 'label' => 'Valid Label', @@ -596,16 +523,18 @@ public function test_category_constructor_non_string_description(): void { ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); } /** * Test registering category with empty description. * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_category_constructor_empty_description(): void { - $result = $this->register_category_during_hook( + $result = $this->registry->register( 'test-invalid', array( 'label' => 'Valid Label', @@ -614,15 +543,17 @@ public function test_category_constructor_empty_description(): void { ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); } /** * Test register_ability_category_args filter. + * + * @ticket 64098 */ public function test_register_category_args_filter(): void { add_filter( - 'register_ability_category_args', + 'wp_register_ability_category_args', static function ( $args, $slug ) { if ( 'test-filtered' === $slug ) { $args['label'] = 'Filtered Label'; @@ -634,7 +565,7 @@ static function ( $args, $slug ) { 2 ); - $result = $this->register_category_during_hook( + $result = $this->registry->register( 'test-filtered', array( 'label' => 'Original Label', @@ -649,9 +580,11 @@ static function ( $args, $slug ) { /** * Test that WP_Ability_Category cannot be unserialized. + * + * @ticket 64098 */ public function test_category_wakeup_throws_exception(): void { - $category = $this->register_category_during_hook( + $category = $this->registry->register( 'test-serialize', array( 'label' => 'Test', @@ -659,13 +592,15 @@ public function test_category_wakeup_throws_exception(): void { ) ); - $this->expectException( \LogicException::class ); + $this->expectException( LogicException::class ); $serialized = serialize( $category ); unserialize( $serialized ); } /** * Test registering a category with valid meta. + * + * @ticket 64098 */ public function test_register_category_with_valid_meta(): void { $meta = array( @@ -674,7 +609,7 @@ public function test_register_category_with_valid_meta(): void { 'custom' => array( 'key' => 'value' ), ); - $result = $this->register_category_during_hook( + $result = $this->registry->register( 'test-meta', array( 'label' => 'Math', @@ -690,9 +625,11 @@ public function test_register_category_with_valid_meta(): void { /** * Test registering a category with empty meta array. + * + * @ticket 64098 */ public function test_register_category_with_empty_meta(): void { - $result = $this->register_category_during_hook( + $result = $this->registry->register( 'test-empty-meta', array( 'label' => 'Math', @@ -707,9 +644,11 @@ public function test_register_category_with_empty_meta(): void { /** * Test registering a category without meta returns empty array. + * + * @ticket 64098 */ public function test_register_category_without_meta_returns_empty_array(): void { - $result = $this->register_category_during_hook( + $result = $this->registry->register( 'test-no-meta', array( 'label' => 'Math', @@ -724,10 +663,12 @@ public function test_register_category_without_meta_returns_empty_array(): void /** * Test registering a category with invalid meta (non-array). * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_category_with_invalid_meta(): void { - $result = $this->register_category_during_hook( + $result = $this->registry->register( 'test-invalid-meta', array( 'label' => 'Math', @@ -737,16 +678,18 @@ public function test_register_category_with_invalid_meta(): void { ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'valid `meta` array' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'valid `meta` array' ); } /** * Test registering a category with unknown property triggers _doing_it_wrong. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Ability_Category::__construct */ public function test_register_category_with_unknown_property(): void { - $result = $this->register_category_during_hook( + $result = $this->registry->register( 'test-unknown-property', array( 'label' => 'Math', @@ -760,4 +703,16 @@ public function test_register_category_with_unknown_property(): void { // But _doing_it_wrong should be triggered. $this->assertDoingItWrongTriggered( 'WP_Ability_Category::__construct', 'not a valid property' ); } + + /** + * Test category registry singleton. + * + * @ticket 64098 + */ + public function test_category_registry_singleton(): void { + $instance1 = WP_Ability_Categories_Registry::get_instance(); + $instance2 = WP_Ability_Categories_Registry::get_instance(); + + $this->assertSame( $instance1, $instance2 ); + } } diff --git a/tests/unit/abilities-api/wpCoreAbilities.php b/tests/unit/abilities-api/wpCoreAbilities.php index 16203a77..e4da23e7 100644 --- a/tests/unit/abilities-api/wpCoreAbilities.php +++ b/tests/unit/abilities-api/wpCoreAbilities.php @@ -26,7 +26,7 @@ public function set_up(): void { } // Unregister categories if they exist. - $category_registry = WP_Abilities_Category_Registry::get_instance(); + $category_registry = WP_Ability_Categories_Registry::get_instance(); if ( $category_registry->is_registered( 'site' ) ) { wp_unregister_ability_category( 'site' ); } @@ -38,13 +38,13 @@ public function set_up(): void { $callback = static function (): void { WP_Core_Abilities::register_category(); }; - add_action( 'abilities_api_categories_init', $callback ); - do_action( 'abilities_api_categories_init', WP_Abilities_Category_Registry::get_instance() ); - remove_action( 'abilities_api_categories_init', $callback ); + add_action( 'wp_abilities_api_categories_init', $callback ); + do_action( 'wp_abilities_api_categories_init', WP_Ability_Categories_Registry::get_instance() ); + remove_action( 'wp_abilities_api_categories_init', $callback ); // Fire the init action if it hasn't been fired yet. - if ( ! did_action( 'abilities_api_init' ) ) { - do_action( 'abilities_api_init' ); + if ( ! did_action( 'wp_abilities_api_init' ) ) { + do_action( 'wp_abilities_api_init' ); } // Register core abilities for testing. diff --git a/tests/unit/abilities-api/wpRegisterAbility.php b/tests/unit/abilities-api/wpRegisterAbility.php index 04a39a21..21b108f5 100644 --- a/tests/unit/abilities-api/wpRegisterAbility.php +++ b/tests/unit/abilities-api/wpRegisterAbility.php @@ -15,6 +15,7 @@ protected function do_execute( $input = null ) { * @covers wp_register_ability * @covers wp_unregister_ability * @covers wp_get_ability + * @covers wp_has_ability * @covers wp_get_all_abilities * * @group abilities-api @@ -30,25 +31,16 @@ class Test_Abilities_API_WpRegisterAbility extends WP_UnitTestCase { public function set_up(): void { parent::set_up(); - // Register category during the hook. - add_action( - 'abilities_api_categories_init', - function () { - if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( 'math' ) ) { - wp_register_ability_category( - 'math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations and calculations.', - ) - ); - } - } + // Fire the init hook to allow test ability category registration. + do_action( 'wp_abilities_api_categories_init' ); + wp_register_ability_category( + 'math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations and calculations.', + ) ); - // Fire the hook to allow category registration. - do_action( 'abilities_api_categories_init' ); - self::$test_ability_args = array( 'label' => 'Add numbers', 'description' => 'Calculates the result of adding two numbers.', @@ -102,11 +94,8 @@ public function tear_down(): void { wp_unregister_ability( $ability->get_name() ); } - // Clean up registered categories. - $category_registry = WP_Abilities_Category_Registry::get_instance(); - if ( $category_registry->is_registered( 'math' ) ) { - wp_unregister_ability_category( 'math' ); - } + // Clean up registered test ability category. + wp_unregister_ability_category( 'math' ); parent::tear_down(); } @@ -114,10 +103,12 @@ public function tear_down(): void { /** * Tests registering an ability with invalid name. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_ability_invalid_name(): void { - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); $result = wp_register_ability( 'invalid_name', array() ); @@ -125,24 +116,54 @@ public function test_register_ability_invalid_name(): void { } /** - * Tests registering an ability when `abilities_api_init` hook is not fired. + * Tests registering an ability when `abilities_api_init` action has not fired. + * + * @ticket 64098 * * @expectedIncorrectUsage wp_register_ability */ - public function test_register_ability_no_abilities_api_init_hook(): void { + public function test_register_ability_no_abilities_api_init_action(): void { global $wp_actions; - // Store the original action count - $original_count = isset( $wp_actions['abilities_api_init'] ) ? $wp_actions['abilities_api_init'] : 0; + // Store the original action count. + $original_count = isset( $wp_actions['wp_abilities_api_init'] ) ? $wp_actions['wp_abilities_api_init'] : 0; - // Reset the action count to simulate it not being fired - unset( $wp_actions['abilities_api_init'] ); + // Reset the action count to simulate it not being fired. + unset( $wp_actions['wp_abilities_api_init'] ); $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); - // Restore the original action count + // Restore the original action count. if ( $original_count > 0 ) { - $wp_actions['abilities_api_init'] = $original_count; + $wp_actions['wp_abilities_api_init'] = $original_count; + } + + $this->assertNull( $result ); + } + + /** + * Tests registering an ability when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_instance + */ + public function test_register_ability_no_init_action(): void { + global $wp_actions; + + do_action( 'wp_abilities_api_init' ); + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; } $this->assertNull( $result ); @@ -150,17 +171,18 @@ public function test_register_ability_no_abilities_api_init_hook(): void { /** * Tests registering a valid ability. + * + * @ticket 64098 */ public function test_register_valid_ability(): void { - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); $expected_annotations = array_merge( self::$test_ability_args['meta']['annotations'], array( - 'instructions' => '', - 'idempotent' => false, + 'idempotent' => false, ) ); $expected_meta = array_merge( @@ -199,9 +221,11 @@ public function test_register_valid_ability(): void { /** * Tests executing an ability with no permissions. + * + * @ticket 64098 */ public function test_register_ability_no_permissions(): void { - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); self::$test_ability_args['permission_callback'] = static function (): bool { return false; @@ -232,9 +256,11 @@ public function test_register_ability_no_permissions(): void { /** * Tests registering an ability with a custom ability class. + * + * @ticket 64098 */ public function test_register_ability_custom_ability_class(): void { - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); $result = wp_register_ability( self::$test_ability_name, @@ -272,9 +298,11 @@ public function test_register_ability_custom_ability_class(): void { /** * Tests executing an ability with input not matching schema. + * + * @ticket 64098 */ public function test_execute_ability_no_input_schema_match(): void { - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); @@ -299,9 +327,11 @@ public function test_execute_ability_no_input_schema_match(): void { /** * Tests executing an ability with output not matching schema. + * + * @ticket 64098 */ public function test_execute_ability_no_output_schema_match(): void { - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); self::$test_ability_args['execute_callback'] = static function (): bool { return true; @@ -327,14 +357,16 @@ public function test_execute_ability_no_output_schema_match(): void { } /** - * Tests permission callback receiving input not matching schema. + * Tests input validation failing due to schema mismatch. + * + * @ticket 64098 */ - public function test_permission_callback_no_input_schema_match(): void { - do_action( 'abilities_api_init' ); + public function test_validate_input_no_input_schema_match(): void { + do_action( 'wp_abilities_api_init' ); $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); - $actual = $result->check_permissions( + $actual = $result->validate_input( array( 'a' => 2, 'b' => 3, @@ -344,7 +376,7 @@ public function test_permission_callback_no_input_schema_match(): void { $this->assertWPError( $actual, - 'Permission check should fail due to input not matching schema.' + 'Input validation should fail due to input not matching schema.' ); $this->assertSame( 'ability_invalid_input', $actual->get_error_code() ); $this->assertSame( @@ -353,32 +385,13 @@ public function test_permission_callback_no_input_schema_match(): void { ); } - /** - * Tests that deprecated has_permission() method still works. - * - * @expectedDeprecated WP_Ability::has_permission - */ - public function test_has_permission_deprecated_coverage(): void { - do_action( 'abilities_api_init' ); - - $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); - - // Test that deprecated method still works - $this->assertTrue( - $result->has_permission( - array( - 'a' => 2, - 'b' => 3, - ) - ) - ); - } - /** * Tests permission callback receiving input for contextual permission checks. + * + * @ticket 64098 */ public function test_permission_callback_receives_input(): void { - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); $received_input = null; self::$test_ability_args['permission_callback'] = static function ( array $input ) use ( &$received_input ): bool { @@ -424,11 +437,39 @@ public function test_permission_callback_receives_input(): void { ); } + /** + * Tests unregistering an ability when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_instance + */ + public function test_unregister_ability_no_init_action(): void { + global $wp_actions; + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_unregister_ability( self::$test_ability_name ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertNull( $result ); + } + /** * Tests unregistering existing ability. + * + * @ticket 64098 */ public function test_unregister_existing_ability() { - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); wp_register_ability( self::$test_ability_name, self::$test_ability_args ); @@ -441,26 +482,56 @@ public function test_unregister_existing_ability() { } /** - * Tests retrieving existing ability. + * Tests retrieving an ability when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_instance */ - public function test_get_existing_ability() { + public function test_get_ability_no_init_action(): void { + global $wp_actions; + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_get_ability( self::$test_ability_name ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertNull( $result ); + } + + /** + * Tests retrieving existing ability registered with the `wp_abilities_api_init` callback. + * + * @ticket 64098 + */ + public function test_get_existing_ability_using_callback() { $name = self::$test_ability_name; $args = self::$test_ability_args; $callback = static function ( $instance ) use ( $name, $args ) { wp_register_ability( $name, $args ); }; - add_action( 'abilities_api_init', $callback ); + add_action( 'wp_abilities_api_init', $callback ); // Reset the Registry, to ensure it's empty before the test. $registry_reflection = new ReflectionClass( WP_Abilities_Registry::class ); $instance_prop = $registry_reflection->getProperty( 'instance' ); - $instance_prop->setAccessible( true ); + if ( PHP_VERSION_ID < 80100 ) { + $instance_prop->setAccessible( true ); + } $instance_prop->setValue( null, null ); $result = wp_get_ability( $name ); - remove_action( 'abilities_api_init', $callback ); + remove_action( 'wp_abilities_api_init', $callback ); $this->assertEquals( new WP_Ability( $name, $args ), @@ -469,11 +540,93 @@ public function test_get_existing_ability() { ); } + /** + * Tests checking if an ability is registered when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_instance + */ + public function test_has_ability_no_init_action(): void { + global $wp_actions; + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_has_ability( self::$test_ability_name ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertFalse( $result ); + } + + /** + * Tests checking if an ability is registered. + * + * @ticket 64098 + */ + public function test_has_registered_ability() { + do_action( 'wp_abilities_api_init' ); + + wp_register_ability( self::$test_ability_name, self::$test_ability_args ); + + $result = wp_has_ability( self::$test_ability_name ); + + $this->assertTrue( $result ); + } + + /** + * Tests checking if a non-existent ability is registered. + * + * @ticket 64098 + */ + public function test_has_registered_nonexistent_ability() { + do_action( 'wp_abilities_api_init' ); + + $result = wp_has_ability( 'test/non-existent' ); + + $this->assertFalse( $result ); + } + + /** + * Tests retrieving all registered abilities when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_instance + */ + public function test_get_abilities_no_init_action(): void { + global $wp_actions; + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_get_abilities(); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertSame( array(), $result ); + } + /** * Tests retrieving all registered abilities. + * + * @ticket 64098 */ public function test_get_all_registered_abilities() { - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); $ability_one_name = 'test/ability-one'; $ability_one_args = self::$test_ability_args; @@ -496,28 +649,4 @@ public function test_get_all_registered_abilities() { $result = wp_get_abilities(); $this->assertEquals( $expected, $result ); } - - /** - * Tests registering an ability with non-existent category. - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_ability_nonexistent_category(): void { - do_action( 'abilities_api_init' ); - - // Ensure category doesn't exist - test should fail if it does. - $this->assertFalse( - WP_Abilities_Category_Registry::get_instance()->is_registered( 'nonexistent' ), - 'The nonexistent category should not be registered - test isolation may be broken' - ); - - $args = array_merge( - self::$test_ability_args, - array( 'category' => 'nonexistent' ) - ); - - $result = wp_register_ability( self::$test_ability_name, $args ); - - $this->assertNull( $result, 'Should return null when category does not exist' ); - } } diff --git a/tests/unit/abilities-api/wpRegisterAbilityCategory.php b/tests/unit/abilities-api/wpRegisterAbilityCategory.php new file mode 100644 index 00000000..ede55f98 --- /dev/null +++ b/tests/unit/abilities-api/wpRegisterAbilityCategory.php @@ -0,0 +1,365 @@ + 'Math', + 'description' => 'Mathematical operations.', + ); + } + + /** + * Tear down after each test. + */ + public function tear_down(): void { + // Clean up any test ability categories registered during tests. + foreach ( wp_get_ability_categories() as $ability_category ) { + if ( ! str_starts_with( $ability_category->get_slug(), 'test-' ) ) { + continue; + } + + wp_unregister_ability_category( $ability_category->get_slug() ); + } + + parent::tear_down(); + } + + /** + * Test registering ability category before `abilities_api_categories_init` hook. + * + * @ticket 64098 + * + * @expectedIncorrectUsage wp_register_ability_category + */ + public function test_register_category_before_init_hook(): void { + $result = wp_register_ability_category( + self::$test_ability_category_name, + self::$test_ability_category_args + ); + + $this->assertNull( $result ); + } + + /** + * Tests registering an ability category when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance + */ + public function test_register_ability_category_no_init_action(): void { + global $wp_actions; + + do_action( 'wp_abilities_api_categories_init' ); + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_register_ability_category( + self::$test_ability_category_name, + self::$test_ability_category_args + ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertNull( $result ); + } + + /** + * Test registering a valid ability category. + * + * @ticket 64098 + */ + public function test_register_valid_category(): void { + do_action( 'wp_abilities_api_categories_init' ); + + $result = wp_register_ability_category( + self::$test_ability_category_name, + self::$test_ability_category_args + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( self::$test_ability_category_name, $result->get_slug() ); + $this->assertSame( 'Math', $result->get_label() ); + $this->assertSame( 'Mathematical operations.', $result->get_description() ); + } + + /** + * Tests unregistering an ability category when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance + */ + public function test_unregister_ability_category_no_init_action(): void { + global $wp_actions; + + do_action( 'wp_abilities_api_categories_init' ); + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_unregister_ability_category( self::$test_ability_category_name ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertNull( $result ); + } + + /** + * Test unregistering non-existent ability category. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::unregister + */ + public function test_unregister_nonexistent_category(): void { + do_action( 'wp_abilities_api_categories_init' ); + + $result = wp_unregister_ability_category( 'test-nonexistent' ); + + $this->assertNull( $result ); + } + + /** + * Test unregistering existing ability category. + * + * @ticket 64098 + */ + public function test_unregister_existing_category(): void { + do_action( 'wp_abilities_api_categories_init' ); + + wp_register_ability_category( + self::$test_ability_category_name, + self::$test_ability_category_args + ); + + $result = wp_unregister_ability_category( self::$test_ability_category_name ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertFalse( wp_has_ability_category( self::$test_ability_category_name ) ); + } + + /** + * Tests checking if an ability category is registered when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance + */ + public function test_has_ability_category_no_init_action(): void { + global $wp_actions; + + do_action( 'wp_abilities_api_categories_init' ); + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_has_ability_category( self::$test_ability_category_name ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertFalse( $result ); + } + + /** + * Tests checking if a non-existent ability category is registered. + * + * @ticket 64098 + */ + public function test_has_registered_nonexistent_ability_category(): void { + do_action( 'wp_abilities_api_categories_init' ); + + $result = wp_has_ability_category( 'test/non-existent' ); + + $this->assertFalse( $result ); + } + + /** + * Tests checking if an ability category is registered. + * + * @ticket 64098 + */ + public function test_has_registered_ability_category(): void { + do_action( 'wp_abilities_api_categories_init' ); + + $category_slug = self::$test_ability_category_name; + + wp_register_ability_category( + $category_slug, + self::$test_ability_category_args + ); + + $result = wp_has_ability_category( $category_slug ); + + $this->assertTrue( $result ); + } + + /** + * Tests retrieving an ability category when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance + */ + public function test_get_ability_category_no_init_action(): void { + global $wp_actions; + + do_action( 'wp_abilities_api_categories_init' ); + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_get_ability_category( self::$test_ability_category_name ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertNull( $result ); + } + + /** + * Test retrieving non-existent ability category. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_registered + */ + public function test_get_nonexistent_category(): void { + do_action( 'wp_abilities_api_categories_init' ); + + $result = wp_get_ability_category( 'test-nonexistent' ); + + $this->assertNull( $result ); + } + + /** + * Test retrieving existing ability category registered with the `wp_abilities_api_categories_init` callback. + * + * @ticket 64098 + */ + public function test_get_existing_category_using_callback(): void { + $name = self::$test_ability_category_name; + $args = self::$test_ability_category_args; + $callback = static function ( $instance ) use ( $name, $args ) { + wp_register_ability_category( $name, $args ); + }; + + add_action( 'wp_abilities_api_categories_init', $callback ); + + // Reset the Registry, to ensure it's empty before the test. + $registry_reflection = new ReflectionClass( WP_Ability_Categories_Registry::class ); + $instance_prop = $registry_reflection->getProperty( 'instance' ); + if ( PHP_VERSION_ID < 80100 ) { + $instance_prop->setAccessible( true ); + } + $instance_prop->setValue( null, null ); + + $result = wp_get_ability_category( $name ); + + remove_action( 'wp_abilities_api_categories_init', $callback ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( self::$test_ability_category_name, $result->get_slug() ); + } + + /** + * Test retrieving all registered ability categories when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance + */ + public function test_get_ability_categories_no_init_action(): void { + global $wp_actions; + + do_action( 'wp_abilities_api_categories_init' ); + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_get_ability_categories( self::$test_ability_category_name ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertSame( array(), $result ); + } + + /** + * Test retrieving all registered ability categories. + * + * @ticket 64098 + */ + public function test_get_all_categories(): void { + do_action( 'wp_abilities_api_categories_init' ); + + wp_register_ability_category( + self::$test_ability_category_name, + self::$test_ability_category_args + ); + + wp_register_ability_category( + 'test-system', + array( + 'label' => 'System', + 'description' => 'System operations.', + ) + ); + + $categories = wp_get_ability_categories(); + + $this->assertIsArray( $categories ); + $this->assertCount( 2, $categories ); + $this->assertArrayHasKey( self::$test_ability_category_name, $categories ); + $this->assertArrayHasKey( 'test-system', $categories ); + } +} diff --git a/tests/unit/rest-api/wpRestAbilitiesInit.php b/tests/unit/rest-api/wpRestAbilitiesInit.php index b963cf0a..0e1b015f 100644 --- a/tests/unit/rest-api/wpRestAbilitiesInit.php +++ b/tests/unit/rest-api/wpRestAbilitiesInit.php @@ -42,18 +42,20 @@ public function tear_down(): void { public function test_routes_registered_on_rest_api_init(): void { // Routes should not exist before init $routes = $this->server->get_routes(); - $this->assertArrayNotHasKey( '/wp/v2/abilities', $routes ); - $this->assertArrayNotHasKey( '/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+)', $routes ); - $this->assertArrayNotHasKey( '/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run', $routes ); + $this->assertArrayNotHasKey( '/wp-abilities/v1/categories', $routes ); + $this->assertArrayNotHasKey( '/wp-abilities/v1/abilities', $routes ); + $this->assertArrayNotHasKey( '/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\\-\\/]+)', $routes ); + $this->assertArrayNotHasKey( '/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run', $routes ); // Trigger rest_api_init do_action( 'rest_api_init' ); // Routes should now be registered $routes = $this->server->get_routes(); - $this->assertArrayHasKey( '/wp/v2/abilities', $routes ); - $this->assertArrayHasKey( '/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+)', $routes ); - $this->assertArrayHasKey( '/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run', $routes ); + $this->assertArrayHasKey( '/wp-abilities/v1/categories', $routes ); + $this->assertArrayHasKey( '/wp-abilities/v1/abilities', $routes ); + $this->assertArrayHasKey( '/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\\-\\/]+)', $routes ); + $this->assertArrayHasKey( '/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run', $routes ); } /** @@ -65,17 +67,23 @@ public function test_correct_controllers_instantiated(): void { $routes = $this->server->get_routes(); + // Check categories controller + $this->assertArrayHasKey( '/wp-abilities/v1/categories', $routes ); + $categories_route = $routes['/wp-abilities/v1/categories'][0]; + $this->assertIsArray( $categories_route['callback'] ); + $this->assertInstanceOf( 'WP_REST_Abilities_V1_Categories_Controller', $categories_route['callback'][0] ); + // Check list controller - $this->assertArrayHasKey( '/wp/v2/abilities', $routes ); - $list_route = $routes['/wp/v2/abilities'][0]; + $this->assertArrayHasKey( '/wp-abilities/v1/abilities', $routes ); + $list_route = $routes['/wp-abilities/v1/abilities'][0]; $this->assertIsArray( $list_route['callback'] ); - $this->assertInstanceOf( 'WP_REST_Abilities_List_Controller', $list_route['callback'][0] ); + $this->assertInstanceOf( 'WP_REST_Abilities_V1_List_Controller', $list_route['callback'][0] ); // Check run controller - $this->assertArrayHasKey( '/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run', $routes ); - $run_route = $routes['/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run'][0]; + $this->assertArrayHasKey( '/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run', $routes ); + $run_route = $routes['/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run'][0]; $this->assertIsArray( $run_route['callback'] ); - $this->assertInstanceOf( 'WP_REST_Abilities_Run_Controller', $run_route['callback'][0] ); + $this->assertInstanceOf( 'WP_REST_Abilities_V1_Run_Controller', $run_route['callback'][0] ); } /** @@ -84,8 +92,9 @@ public function test_correct_controllers_instantiated(): void { public function test_required_files_loaded(): void { // Classes should be available after requiring the main plugin file $this->assertTrue( class_exists( 'WP_REST_Abilities_Init' ) ); - $this->assertTrue( class_exists( 'WP_REST_Abilities_List_Controller' ) ); - $this->assertTrue( class_exists( 'WP_REST_Abilities_Run_Controller' ) ); + $this->assertTrue( class_exists( 'WP_REST_Abilities_V1_Categories_Controller' ) ); + $this->assertTrue( class_exists( 'WP_REST_Abilities_V1_List_Controller' ) ); + $this->assertTrue( class_exists( 'WP_REST_Abilities_V1_Run_Controller' ) ); } /** @@ -96,8 +105,18 @@ public function test_routes_support_expected_methods(): void { $routes = $this->server->get_routes(); + // Categories endpoint should support GET + $categories_methods = $routes['/wp-abilities/v1/categories'][0]['methods']; + // Methods can be a string like 'GET' or an array of method constants + if ( is_string( $categories_methods ) ) { + $this->assertEquals( WP_REST_Server::READABLE, $categories_methods ); + } else { + // Just check it's set, don't check specific values + $this->assertNotEmpty( $categories_methods ); + } + // List endpoint should support GET - $list_methods = $routes['/wp/v2/abilities'][0]['methods']; + $list_methods = $routes['/wp-abilities/v1/abilities'][0]['methods']; // Methods can be a string like 'GET' or an array of method constants if ( is_string( $list_methods ) ) { $this->assertEquals( WP_REST_Server::READABLE, $list_methods ); @@ -107,7 +126,7 @@ public function test_routes_support_expected_methods(): void { } // Single ability endpoint should support GET - $single_methods = $routes['/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+)'][0]['methods']; + $single_methods = $routes['/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\\-\\/]+)'][0]['methods']; // Methods can be a string like 'GET' or an array of method constants if ( is_string( $single_methods ) ) { $this->assertEquals( WP_REST_Server::READABLE, $single_methods ); @@ -117,7 +136,7 @@ public function test_routes_support_expected_methods(): void { } // Run endpoint should support all methods (for type-based routing) - $run_route = $routes['/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run'][0]; + $run_route = $routes['/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run'][0]; // ALLMETHODS can be a string or array if ( is_string( $run_route['methods'] ) ) { $this->assertEquals( WP_REST_Server::ALLMETHODS, $run_route['methods'] ); @@ -136,14 +155,14 @@ public function test_namespace_and_base_configuration(): void { $namespaces = $this->server->get_namespaces(); $this->assertContains( 'wp/v2', $namespaces ); - // Verify abilities endpoints are under wp/v2 namespace + // Verify abilities endpoints are under /wp-abilities/v1 namespace $routes = $this->server->get_routes(); foreach ( array_keys( $routes ) as $route ) { - if ( strpos( $route, 'abilities' ) === false || $route === '/' ) { + if ( strpos( $route, 'abilities' ) === false ) { continue; } - $this->assertStringStartsWith( '/wp/v2/abilities', $route ); + $this->assertStringStartsWith( '/wp-abilities/v1', $route ); } } @@ -155,14 +174,14 @@ public function test_no_duplicate_routes_on_multiple_init(): void { do_action( 'rest_api_init' ); $routes_first = $this->server->get_routes(); - $abilities_route_count_first = count( $routes_first['/wp/v2/abilities'] ?? array() ); + $abilities_route_count_first = count( $routes_first['/wp-abilities/v1/abilities'] ?? array() ); // Second init (simulating multiple calls) // Note: WordPress doesn't prevent duplicate registration, so we expect 2x routes WP_REST_Abilities_Init::register_routes(); $routes_second = $this->server->get_routes(); - $abilities_route_count_second = count( $routes_second['/wp/v2/abilities'] ?? array() ); + $abilities_route_count_second = count( $routes_second['/wp-abilities/v1/abilities'] ?? array() ); // WordPress allows duplicate route registration $this->assertEquals( $abilities_route_count_first * 2, $abilities_route_count_second ); diff --git a/tests/unit/rest-api/wpRestAbilityCategoriesController.php b/tests/unit/rest-api/wpRestAbilitiesV1CategoriesController.php similarity index 67% rename from tests/unit/rest-api/wpRestAbilityCategoriesController.php rename to tests/unit/rest-api/wpRestAbilitiesV1CategoriesController.php index 7afa92f9..a6b7f233 100644 --- a/tests/unit/rest-api/wpRestAbilityCategoriesController.php +++ b/tests/unit/rest-api/wpRestAbilitiesV1CategoriesController.php @@ -1,18 +1,19 @@ register_test_ability_categories(); wp_set_current_user( self::$admin_user_id ); } @@ -74,19 +73,14 @@ public function set_up(): void { * Tear down after each test. */ public function tear_down(): void { - foreach ( array( 'data-retrieval', 'data-modification', 'communication', 'test-category-1', 'test-category-2', 'test-category-3' ) as $slug ) { - if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( $slug ) ) { + + // Clean up test ability categories. + foreach ( wp_get_ability_categories() as $ability_category ) { + if ( ! str_starts_with( $ability_category->get_slug(), 'test-' ) ) { continue; } - wp_unregister_ability_category( $slug ); - } - - for ( $i = 4; $i <= 60; $i++ ) { - $slug = "test-category-{$i}"; - if ( WP_Abilities_Category_Registry::get_instance()->is_registered( $slug ) ) { - wp_unregister_ability_category( $slug ); - } + wp_unregister_ability_category( $ability_category->get_slug() ); } global $wp_rest_server; @@ -96,11 +90,11 @@ public function tear_down(): void { } /** - * Register test categories for testing. + * Register test ability categories for testing. */ - public function register_test_categories(): void { + public function register_test_ability_categories(): void { wp_register_ability_category( - 'data-retrieval', + 'test-data-retrieval', array( 'label' => 'Data Retrieval', 'description' => 'Abilities that retrieve and return data from the WordPress site.', @@ -108,7 +102,7 @@ public function register_test_categories(): void { ); wp_register_ability_category( - 'data-modification', + 'test-data-modification', array( 'label' => 'Data Modification', 'description' => 'Abilities that modify data on the WordPress site.', @@ -116,7 +110,7 @@ public function register_test_categories(): void { ); wp_register_ability_category( - 'communication', + 'test-communication', array( 'label' => 'Communication', 'description' => 'Abilities that send messages or notifications.', @@ -126,7 +120,7 @@ public function register_test_categories(): void { ) ); - // Register multiple categories for pagination testing + // Register multiple ability categories for pagination testing for ( $i = 1; $i <= 60; $i++ ) { wp_register_ability_category( "test-category-{$i}", @@ -139,10 +133,12 @@ public function register_test_categories(): void { } /** - * Test listing all categories. + * Test listing all ability categories. + * + * @ticket 64098 */ public function test_get_items(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/categories' ); $response = $this->server->dispatch( $request ); $this->assertEquals( 200, $response->get_status() ); @@ -154,77 +150,108 @@ public function test_get_items(): void { $this->assertCount( 50, $data, 'First page should return exactly 50 items (default per_page)' ); $category_slugs = wp_list_pluck( $data, 'slug' ); - $this->assertContains( 'data-retrieval', $category_slugs ); - $this->assertContains( 'data-modification', $category_slugs ); - $this->assertContains( 'communication', $category_slugs ); + $this->assertContains( 'test-data-retrieval', $category_slugs ); + $this->assertContains( 'test-data-modification', $category_slugs ); + $this->assertContains( 'test-communication', $category_slugs ); } /** - * Test getting a specific category. + * Test getting a specific ability category. + * + * @ticket 64098 */ public function test_get_item(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories/data-retrieval' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/categories/test-data-retrieval' ); $response = $this->server->dispatch( $request ); $this->assertEquals( 200, $response->get_status() ); $data = $response->get_data(); - $this->assertEquals( 'data-retrieval', $data['slug'] ); + $this->assertEquals( 'test-data-retrieval', $data['slug'] ); $this->assertEquals( 'Data Retrieval', $data['label'] ); $this->assertEquals( 'Abilities that retrieve and return data from the WordPress site.', $data['description'] ); $this->assertArrayHasKey( 'meta', $data ); } /** - * Test getting a category with meta. + * Test getting an ability category with meta. + * + * @ticket 64098 */ public function test_get_item_with_meta(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories/communication' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/categories/test-communication' ); $response = $this->server->dispatch( $request ); $this->assertEquals( 200, $response->get_status() ); $data = $response->get_data(); - $this->assertEquals( 'communication', $data['slug'] ); + $this->assertEquals( 'test-communication', $data['slug'] ); $this->assertArrayHasKey( 'meta', $data ); $this->assertIsArray( $data['meta'] ); $this->assertEquals( 'high', $data['meta']['priority'] ); } /** - * Test getting a non-existent category returns 404. + * Test getting a specific ability category with only selected fields. + * + * @ticket 64098 + */ + public function test_get_item_with_selected_fields(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/categories/test-data-retrieval' ); + $request->set_param( '_fields', 'slug,label' ); + $response = $this->server->dispatch( $request ); + add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); + $response = apply_filters( 'rest_post_dispatch', $response, $this->server, $request ); + remove_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10 ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertCount( 2, $data, 'Response should only contain the requested fields.' ); + $this->assertEquals( 'test-data-retrieval', $data['slug'] ); + $this->assertEquals( 'Data Retrieval', $data['label'] ); + } + + /** + * Test getting a non-existent ability category returns 404. * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::get_registered + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_registered */ public function test_get_item_not_found(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories/non-existent' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/categories/non-existent' ); $response = $this->server->dispatch( $request ); $this->assertEquals( 404, $response->get_status() ); $data = $response->get_data(); - $this->assertEquals( 'rest_category_not_found', $data['code'] ); + $this->assertEquals( 'rest_ability_category_not_found', $data['code'] ); } /** - * Test permission check for listing categories. + * Test permission check for listing ability categories. + * + * @ticket 64098 */ public function test_get_items_permission_denied(): void { wp_set_current_user( 0 ); - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/categories' ); $response = $this->server->dispatch( $request ); $this->assertEquals( 401, $response->get_status() ); } /** - * Test permission check for single category. + * Test permission check for single ability category. + * + * @ticket 64098 */ public function test_get_item_permission_denied(): void { wp_set_current_user( 0 ); - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories/data-retrieval' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/categories/test-data-retrieval' ); $response = $this->server->dispatch( $request ); $this->assertEquals( 401, $response->get_status() ); @@ -232,9 +259,11 @@ public function test_get_item_permission_denied(): void { /** * Test pagination headers. + * + * @ticket 64098 */ public function test_pagination_headers(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/categories' ); $request->set_param( 'per_page', 10 ); $response = $this->server->dispatch( $request ); @@ -251,9 +280,11 @@ public function test_pagination_headers(): void { /** * Test HEAD method returns empty body with proper headers. + * + * @ticket 64098 */ public function test_head_request(): void { - $request = new WP_REST_Request( 'HEAD', '/wp/v2/abilities/categories' ); + $request = new WP_REST_Request( 'HEAD', '/wp-abilities/v1/categories' ); $response = $this->server->dispatch( $request ); $data = $response->get_data(); @@ -266,9 +297,11 @@ public function test_head_request(): void { /** * Test pagination links. + * + * @ticket 64098 */ public function test_pagination_links(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/categories' ); $request->set_param( 'per_page', 10 ); $request->set_param( 'page', 1 ); $response = $this->server->dispatch( $request ); @@ -302,9 +335,11 @@ public function test_pagination_links(): void { /** * Test collection parameters. + * + * @ticket 64098 */ public function test_collection_params(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/categories' ); $request->set_param( 'per_page', 5 ); $response = $this->server->dispatch( $request ); @@ -317,7 +352,7 @@ public function test_collection_params(): void { $data = $response->get_data(); $this->assertCount( 5, $data ); - $page1_request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories' ); + $page1_request = new WP_REST_Request( 'GET', '/wp-abilities/v1/categories' ); $page1_request->set_param( 'per_page', 5 ); $page1_request->set_param( 'page', 1 ); $page1_response = $this->server->dispatch( $page1_request ); @@ -328,10 +363,12 @@ public function test_collection_params(): void { } /** - * Test response links for individual categories. + * Test response links for individual ability categories. + * + * @ticket 64098 */ - public function test_category_response_links(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories/data-retrieval' ); + public function test_ability_category_response_links(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/categories/test-data-retrieval' ); $response = $this->server->dispatch( $request ); $links = $response->get_links(); @@ -340,20 +377,22 @@ public function test_category_response_links(): void { $this->assertArrayHasKey( 'abilities', $links ); $self_link = $links['self'][0]['href']; - $this->assertStringContainsString( '/wp/v2/abilities/categories/data-retrieval', $self_link ); + $this->assertStringContainsString( '/wp-abilities/v1/categories/test-data-retrieval', $self_link ); $collection_link = $links['collection'][0]['href']; - $this->assertStringContainsString( '/wp/v2/abilities/categories', $collection_link ); + $this->assertStringContainsString( '/wp-abilities/v1/categories', $collection_link ); $abilities_link = $links['abilities'][0]['href']; - $this->assertStringContainsString( '/wp/v2/abilities?category=data-retrieval', $abilities_link ); + $this->assertStringContainsString( '/wp-abilities/v1/abilities?category=test-data-retrieval', $abilities_link ); } /** * Test context parameter. + * + * @ticket 64098 */ public function test_context_parameter(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories/data-retrieval' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/categories/test-data-retrieval' ); $request->set_param( 'context', 'view' ); $response = $this->server->dispatch( $request ); @@ -370,9 +409,11 @@ public function test_context_parameter(): void { /** * Test schema retrieval. + * + * @ticket 64098 */ public function test_get_schema(): void { - $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/abilities/categories' ); + $request = new WP_REST_Request( 'OPTIONS', '/wp-abilities/v1/categories' ); $response = $this->server->dispatch( $request ); $data = $response->get_data(); @@ -395,29 +436,25 @@ public function test_get_schema(): void { $slug_property = $properties['slug']; $this->assertEquals( 'string', $slug_property['type'] ); $this->assertTrue( $slug_property['readonly'] ); - - $this->assertArrayHasKey( 'required', $schema ); - $this->assertContains( 'slug', $schema['required'] ); - $this->assertContains( 'label', $schema['required'] ); - $this->assertContains( 'description', $schema['required'] ); - $this->assertContains( 'meta', $schema['required'] ); } /** - * Test category slug with valid format. + * Test ability category slug with valid format. + * + * @ticket 64098 */ - public function test_category_slug_with_valid_format(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories/data-retrieval' ); + public function test_ability_category_slug_with_valid_format(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/categories/test-data-retrieval' ); $response = $this->server->dispatch( $request ); $this->assertEquals( 200, $response->get_status() ); } /** - * Data provider for invalid category slugs. + * Data provider for invalid ability category slugs. * * @return array */ - public function invalid_category_slugs_provider(): array { + public function data_invalid_ability_category_slugs_provider(): array { return array( 'Uppercase' => array( 'Data-Retrieval' ), '@ symbol' => array( 'data@retrieval' ), @@ -429,29 +466,19 @@ public function invalid_category_slugs_provider(): array { } /** - * Test category slugs with invalid format. + * Test ability category slugs with invalid format. * - * @dataProvider invalid_category_slugs_provider - * @param string $slug Invalid category slug to test. - */ - public function test_category_slug_with_invalid_format( string $slug ): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories/' . $slug ); - $response = $this->server->dispatch( $request ); - - $this->assertContains( $response->get_status(), array( 400, 404 ) ); - } - - /** - * Test category slug with forward slash (matched by abilities route). + * @ticket 64098 * - * @expectedIncorrectUsage WP_Abilities_Registry::get_registered + * @dataProvider data_invalid_ability_category_slugs_provider + * + * @param string $slug Invalid ability category slug to test. */ - public function test_category_slug_with_forward_slash(): void { - // Forward slashes cause the URL to be matched by the abilities route instead - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories/data/retrieval' ); + public function test_ability_category_slug_with_invalid_format( string $slug ): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/categories/' . $slug ); $response = $this->server->dispatch( $request ); - $this->assertEquals( 404, $response->get_status() ); + $this->assertContains( $response->get_status(), array( 400, 404 ) ); } /** @@ -459,7 +486,7 @@ public function test_category_slug_with_forward_slash(): void { * * @return array}> */ - public function invalid_pagination_params_provider(): array { + public function data_invalid_pagination_params_provider(): array { return array( 'Zero page' => array( array( 'page' => 0 ) ), 'Negative page' => array( array( 'page' => -1 ) ), @@ -474,11 +501,14 @@ public function invalid_pagination_params_provider(): array { /** * Test pagination parameters with invalid values. * - * @dataProvider invalid_pagination_params_provider + * @ticket 64098 + * + * @dataProvider data_invalid_pagination_params_provider + * * @param array $params Invalid pagination parameters. */ public function test_invalid_pagination_parameters( array $params ): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/categories' ); $request->set_query_params( $params ); $response = $this->server->dispatch( $request ); diff --git a/tests/unit/rest-api/wpRestAbilitiesListController.php b/tests/unit/rest-api/wpRestAbilitiesV1ListController.php similarity index 78% rename from tests/unit/rest-api/wpRestAbilitiesListController.php rename to tests/unit/rest-api/wpRestAbilitiesV1ListController.php index 24093c46..a6e5d92f 100644 --- a/tests/unit/rest-api/wpRestAbilitiesListController.php +++ b/tests/unit/rest-api/wpRestAbilitiesV1ListController.php @@ -3,16 +3,17 @@ /** * Tests for the REST list controller for abilities endpoint. * - * @covers WP_REST_Abilities_List_Controller + * @covers WP_REST_Abilities_V1_List_Controller + * * @group abilities-api * @group rest-api */ -class Tests_REST_API_WpRestAbilitiesListController extends WP_UnitTestCase { +class Tests_REST_API_WpRestAbilitiesV1ListController extends WP_UnitTestCase { /** * REST Server instance. * - * @var \WP_REST_Server + * @var WP_REST_Server */ protected $server; @@ -35,6 +36,22 @@ public static function set_up_before_class(): void { 'role' => 'subscriber', ) ); + + // Fire the init hook to allow test ability categories registration. + do_action( 'wp_abilities_api_categories_init' ); + self::register_test_categories(); + } + + /** + * Tear down after class. + */ + public static function tear_down_after_class(): void { + // Clean up registered test ability categories. + foreach ( array( 'math', 'system', 'general' ) as $slug ) { + wp_unregister_ability_category( $slug ); + } + + parent::tear_down_after_class(); } /** @@ -50,17 +67,8 @@ public function set_up(): void { do_action( 'rest_api_init' ); - // Register test categories during the hook - add_action( - 'abilities_api_categories_init', - array( $this, 'register_test_categories' ) - ); - do_action( 'abilities_api_categories_init' ); - - // Initialize abilities API - do_action( 'abilities_api_init' ); - - // Register test abilities + // Initialize Abilities API. + do_action( 'wp_abilities_api_init' ); $this->register_test_abilities(); // Set default user for tests @@ -71,7 +79,7 @@ public function set_up(): void { * Tear down after each test. */ public function tear_down(): void { - // Clean up test abilities + // Clean up test abilities. foreach ( wp_get_abilities() as $ability ) { if ( ! str_starts_with( $ability->get_name(), 'test/' ) ) { continue; @@ -80,15 +88,6 @@ public function tear_down(): void { wp_unregister_ability( $ability->get_name() ); } - // Clean up test categories - foreach ( array( 'math', 'system', 'general' ) as $slug ) { - if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( $slug ) ) { - continue; - } - - wp_unregister_ability_category( $slug ); - } - // Reset REST server global $wp_rest_server; $wp_rest_server = null; @@ -99,7 +98,7 @@ public function tear_down(): void { /** * Register test categories for testing. */ - public function register_test_categories(): void { + public static function register_test_categories(): void { wp_register_ability_category( 'math', array( @@ -256,9 +255,11 @@ private function register_test_abilities(): void { /** * Test listing all abilities. + * + * @ticket 64098 */ public function test_get_items(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); $response = $this->server->dispatch( $request ); $this->assertEquals( 200, $response->get_status() ); @@ -277,30 +278,79 @@ public function test_get_items(): void { /** * Test getting a specific ability. + * + * @ticket 64098 */ public function test_get_item(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/calculator' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' ); $response = $this->server->dispatch( $request ); $this->assertEquals( 200, $response->get_status() ); $data = $response->get_data(); + $this->assertCount( 7, $data, 'Response should contain all fields.' ); $this->assertEquals( 'test/calculator', $data['name'] ); $this->assertEquals( 'Calculator', $data['label'] ); $this->assertEquals( 'Performs basic calculations', $data['description'] ); + $this->assertEquals( 'math', $data['category'] ); $this->assertArrayHasKey( 'input_schema', $data ); $this->assertArrayHasKey( 'output_schema', $data ); $this->assertArrayHasKey( 'meta', $data ); $this->assertTrue( $data['meta']['show_in_rest'] ); } + /** + * Test getting a specific ability with only selected fields. + * + * @ticket 64098 + */ + public function test_get_item_with_selected_fields(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' ); + $request->set_param( '_fields', 'name,label' ); + $response = $this->server->dispatch( $request ); + add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); + $response = apply_filters( 'rest_post_dispatch', $response, $this->server, $request ); + remove_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10 ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertCount( 2, $data, 'Response should only contain the requested fields.' ); + $this->assertEquals( 'test/calculator', $data['name'] ); + $this->assertEquals( 'Calculator', $data['label'] ); + } + + /** + * Test getting a specific ability with embed context. + * + * @ticket 64098 + */ + public function test_get_item_with_embed_context(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' ); + $request->set_param( 'context', 'embed' ); + $response = $this->server->dispatch( $request ); + add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); + $response = apply_filters( 'rest_post_dispatch', $response, $this->server, $request ); + remove_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10 ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertCount( 3, $data, 'Response should only contain the fields for embed context.' ); + $this->assertEquals( 'test/calculator', $data['name'] ); + $this->assertEquals( 'Calculator', $data['label'] ); + $this->assertEquals( 'math', $data['category'] ); + } + /** * Test getting a non-existent ability returns 404. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Registry::get_registered */ public function test_get_item_not_found(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/non/existent' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/non/existent' ); $response = $this->server->dispatch( $request ); $this->assertEquals( 404, $response->get_status() ); @@ -311,9 +361,11 @@ public function test_get_item_not_found(): void { /** * Test getting an ability that does not show in REST returns 404. + * + * @ticket 64098 */ public function test_get_item_not_show_in_rest(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/not-show-in-rest' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/not-show-in-rest' ); $response = $this->server->dispatch( $request ); $this->assertEquals( 404, $response->get_status() ); @@ -324,12 +376,14 @@ public function test_get_item_not_show_in_rest(): void { /** * Test permission check for listing abilities. + * + * @ticket 64098 */ public function test_get_items_permission_denied(): void { // Test with non-logged-in user wp_set_current_user( 0 ); - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); $response = $this->server->dispatch( $request ); $this->assertEquals( 401, $response->get_status() ); @@ -337,9 +391,11 @@ public function test_get_items_permission_denied(): void { /** * Test pagination headers. + * + * @ticket 64098 */ public function test_pagination_headers(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); $request->set_param( 'per_page', 10 ); $response = $this->server->dispatch( $request ); @@ -356,9 +412,11 @@ public function test_pagination_headers(): void { /** * Test HEAD method returns empty body with proper headers. + * + * @ticket 64098 */ public function test_head_request(): void { - $request = new WP_REST_Request( 'HEAD', '/wp/v2/abilities' ); + $request = new WP_REST_Request( 'HEAD', '/wp-abilities/v1/abilities' ); $response = $this->server->dispatch( $request ); // Verify empty response body @@ -373,10 +431,12 @@ public function test_head_request(): void { /** * Test pagination links. + * + * @ticket 64098 */ public function test_pagination_links(): void { // Test first page (should have 'next' link header but no 'prev') - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); $request->set_param( 'per_page', 10 ); $request->set_param( 'page', 1 ); $response = $this->server->dispatch( $request ); @@ -413,10 +473,12 @@ public function test_pagination_links(): void { /** * Test collection parameters. + * + * @ticket 64098 */ public function test_collection_params(): void { // Test per_page parameter - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); $request->set_param( 'per_page', 5 ); $response = $this->server->dispatch( $request ); @@ -432,7 +494,7 @@ public function test_collection_params(): void { $this->assertCount( 5, $data ); // Verify we got different abilities on page 2 - $page1_request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $page1_request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); $page1_request->set_param( 'per_page', 5 ); $page1_request->set_param( 'page', 1 ); $page1_response = $this->server->dispatch( $page1_request ); @@ -444,32 +506,36 @@ public function test_collection_params(): void { /** * Test response links for individual abilities. + * + * @ticket 64098 */ public function test_ability_response_links(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/calculator' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' ); $response = $this->server->dispatch( $request ); $links = $response->get_links(); $this->assertArrayHasKey( 'self', $links ); $this->assertArrayHasKey( 'collection', $links ); - $this->assertArrayHasKey( 'run', $links ); + $this->assertArrayHasKey( 'wp:action-run', $links ); // Verify link URLs $self_link = $links['self'][0]['href']; - $this->assertStringContainsString( '/wp/v2/abilities/test/calculator', $self_link ); + $this->assertStringContainsString( '/wp-abilities/v1/abilities/test/calculator', $self_link ); $collection_link = $links['collection'][0]['href']; - $this->assertStringContainsString( '/wp/v2/abilities', $collection_link ); + $this->assertStringContainsString( '/wp-abilities/v1/abilities', $collection_link ); - $run_link = $links['run'][0]['href']; - $this->assertStringContainsString( '/wp/v2/abilities/test/calculator/run', $run_link ); + $run_link = $links['wp:action-run'][0]['href']; + $this->assertStringContainsString( '/wp-abilities/v1/abilities/test/calculator/run', $run_link ); } /** * Test context parameter. + * + * @ticket 64098 */ public function test_context_parameter(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/calculator' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' ); $request->set_param( 'context', 'view' ); $response = $this->server->dispatch( $request ); @@ -486,9 +552,11 @@ public function test_context_parameter(): void { /** * Test schema retrieval. + * + * @ticket 64098 */ public function test_get_schema(): void { - $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/abilities' ); + $request = new WP_REST_Request( 'OPTIONS', '/wp-abilities/v1/abilities' ); $response = $this->server->dispatch( $request ); $data = $response->get_data(); @@ -512,22 +580,15 @@ public function test_get_schema(): void { $this->assertArrayHasKey( 'output_schema', $properties ); $this->assertArrayHasKey( 'meta', $properties ); $this->assertArrayHasKey( 'category', $properties ); - - // Test category property details - $category_property = $properties['category']; - $this->assertEquals( 'string', $category_property['type'] ); - $this->assertTrue( $category_property['readonly'] ); - - // Check that category is in required fields - $this->assertArrayHasKey( 'required', $schema ); - $this->assertContains( 'category', $schema['required'] ); } /** * Test ability name with valid special characters. + * + * @ticket 64098 */ public function test_ability_name_with_valid_special_characters(): void { - // Register ability with hyphen (valid) + // Register ability with hyphen (valid). wp_register_ability( 'test-hyphen/ability', array( @@ -545,8 +606,11 @@ public function test_ability_name_with_valid_special_characters(): void { ); // Test valid special characters (hyphen, forward slash) - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test-hyphen/ability' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test-hyphen/ability' ); $response = $this->server->dispatch( $request ); + + wp_unregister_ability( 'test-hyphen/ability' ); + $this->assertEquals( 200, $response->get_status() ); } @@ -555,7 +619,7 @@ public function test_ability_name_with_valid_special_characters(): void { * * @return array */ - public function invalid_ability_names_provider(): array { + public function data_invalid_ability_names_provider(): array { return array( '@ symbol' => array( 'test@ability' ), 'space' => array( 'test ability' ), @@ -571,26 +635,31 @@ public function invalid_ability_names_provider(): array { /** * Test ability names with invalid special characters. * - * @dataProvider invalid_ability_names_provider + * @ticket 64098 + * + * @dataProvider data_invalid_ability_names_provider + * * @param string $name Invalid ability name to test. */ public function test_ability_name_with_invalid_special_characters( string $name ): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/' . $name ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/' . $name ); $response = $this->server->dispatch( $request ); // Should return 404 as the regex pattern won't match $this->assertEquals( 404, $response->get_status() ); } - /** * Test extremely long ability names. + * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Registry::get_registered */ public function test_extremely_long_ability_names(): void { // Create a very long but valid ability name $long_name = 'test/' . str_repeat( 'a', 1000 ); - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/' . $long_name ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/' . $long_name ); $response = $this->server->dispatch( $request ); // Should return 404 as ability doesn't exist @@ -602,7 +671,7 @@ public function test_extremely_long_ability_names(): void { * * @return array}> */ - public function invalid_pagination_params_provider(): array { + public function data_invalid_pagination_params_provider(): array { return array( 'Zero page' => array( array( 'page' => 0 ) ), 'Negative page' => array( array( 'page' => -1 ) ), @@ -617,11 +686,14 @@ public function invalid_pagination_params_provider(): array { /** * Test pagination parameters with invalid values. * - * @dataProvider invalid_pagination_params_provider + * @ticket 64098 + * + * @dataProvider data_invalid_pagination_params_provider + * * @param array $params Invalid pagination parameters. */ public function test_invalid_pagination_parameters( array $params ): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); $request->set_query_params( $params ); $response = $this->server->dispatch( $request ); @@ -640,9 +712,11 @@ public function test_invalid_pagination_parameters( array $params ): void { /** * Test filtering abilities by category. + * + * @ticket 64098 */ public function test_filter_by_category(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); $request->set_param( 'category', 'math' ); $response = $this->server->dispatch( $request ); @@ -664,15 +738,17 @@ public function test_filter_by_category(): void { /** * Test filtering by non-existent category returns empty results. + * + * @ticket 64098 */ public function test_filter_by_nonexistent_category(): void { // Ensure category doesn't exist - test should fail if it does. $this->assertFalse( - WP_Abilities_Category_Registry::get_instance()->is_registered( 'nonexistent' ), + wp_has_ability_category( 'nonexistent' ), 'The nonexistent category should not be registered - test isolation may be broken' ); - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); $request->set_param( 'category', 'nonexistent' ); $response = $this->server->dispatch( $request ); @@ -682,19 +758,4 @@ public function test_filter_by_nonexistent_category(): void { $this->assertIsArray( $data ); $this->assertEmpty( $data, 'Should return empty array for non-existent category' ); } - - /** - * Test that category field is present in response. - */ - public function test_category_field_in_response(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/calculator' ); - $response = $this->server->dispatch( $request ); - - $this->assertEquals( 200, $response->get_status() ); - - $data = $response->get_data(); - $this->assertArrayHasKey( 'category', $data ); - $this->assertEquals( 'math', $data['category'] ); - $this->assertIsString( $data['category'], 'Category should be a string' ); - } } diff --git a/tests/unit/rest-api/wpRestAbilitiesRunController.php b/tests/unit/rest-api/wpRestAbilitiesV1RunController.php similarity index 77% rename from tests/unit/rest-api/wpRestAbilitiesRunController.php rename to tests/unit/rest-api/wpRestAbilitiesV1RunController.php index 92693cc6..40372fe5 100644 --- a/tests/unit/rest-api/wpRestAbilitiesRunController.php +++ b/tests/unit/rest-api/wpRestAbilitiesV1RunController.php @@ -3,16 +3,17 @@ /** * Tests for the REST run controller for abilities endpoint. * - * @covers WP_REST_Abilities_Run_Controller + * @covers WP_REST_Abilities_V1_Run_Controller + * * @group abilities-api * @group rest-api */ -class Tests_REST_API_WpRestAbilitiesRunController extends WP_UnitTestCase { +class Tests_REST_API_WpRestAbilitiesV1RunController extends WP_UnitTestCase { /** * REST Server instance. * - * @var \WP_REST_Server + * @var WP_REST_Server */ protected $server; @@ -47,6 +48,22 @@ public static function set_up_before_class(): void { 'role' => 'subscriber', ) ); + + // Fire the init hook to allow test ability categories registration. + do_action( 'wp_abilities_api_categories_init' ); + self::register_test_categories(); + } + + /** + * Tear down after class. + */ + public static function tear_down_after_class(): void { + // Clean up registered test ability categories. + foreach ( array( 'math', 'system', 'general' ) as $slug ) { + wp_unregister_ability_category( $slug ); + } + + parent::tear_down_after_class(); } /** @@ -61,15 +78,8 @@ public function set_up(): void { do_action( 'rest_api_init' ); - // Register test categories during the hook - add_action( - 'abilities_api_categories_init', - array( $this, 'register_test_categories' ) - ); - do_action( 'abilities_api_categories_init' ); - - do_action( 'abilities_api_init' ); - + // Initialize Abilities API. + do_action( 'wp_abilities_api_init' ); $this->register_test_abilities(); // Set default user for tests @@ -80,6 +90,7 @@ public function set_up(): void { * Tear down after each test. */ public function tear_down(): void { + // Clean up test abilities. foreach ( wp_get_abilities() as $ability ) { if ( ! str_starts_with( $ability->get_name(), 'test/' ) ) { continue; @@ -88,14 +99,6 @@ public function tear_down(): void { wp_unregister_ability( $ability->get_name() ); } - // Clean up registered categories. - $category_registry = WP_Abilities_Category_Registry::get_instance(); - foreach ( array( 'math', 'system', 'general' ) as $category ) { - if ( $category_registry->is_registered( $category ) ) { - wp_unregister_ability_category( $category ); - } - } - global $wp_rest_server; $wp_rest_server = null; @@ -105,7 +108,7 @@ public function tear_down(): void { /** * Register test categories for testing. */ - public function register_test_categories(): void { + public static function register_test_categories(): void { wp_register_ability_category( 'math', array( @@ -199,7 +202,7 @@ private function register_test_abilities(): void { $user_id = $input['user_id'] ?? get_current_user_id(); $user = get_user_by( 'id', $user_id ); if ( ! $user ) { - return new \WP_Error( 'user_not_found', 'User not found' ); + return new WP_Error( 'user_not_found', 'User not found' ); } return array( 'id' => $user->ID, @@ -218,6 +221,47 @@ private function register_test_abilities(): void { ) ); + // Destructive ability (DELETE method). + wp_register_ability( + 'test/delete-user', + array( + 'label' => 'Delete User', + 'description' => 'Deletes a user', + 'category' => 'system', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'user_id' => array( + 'type' => 'integer', + 'default' => 0, + ), + ), + ), + 'output_schema' => array( + 'type' => 'string', + 'required' => true, + ), + 'execute_callback' => static function ( array $input ) { + $user_id = $input['user_id'] ?? get_current_user_id(); + $user = get_user_by( 'id', $user_id ); + if ( ! $user ) { + return new WP_Error( 'user_not_found', 'User not found' ); + } + return 'User successfully deleted!'; + }, + 'permission_callback' => static function () { + return is_user_logged_in(); + }, + 'meta' => array( + 'annotations' => array( + 'destructive' => true, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + // Ability with contextual permissions wp_register_ability( 'test/restricted', @@ -288,7 +332,7 @@ private function register_test_abilities(): void { 'description' => 'Returns error', 'category' => 'general', 'execute_callback' => static function () { - return new \WP_Error( 'test_error', 'This is a test error' ); + return new WP_Error( 'test_error', 'This is a test error' ); }, 'permission_callback' => '__return_true', 'meta' => array( @@ -347,9 +391,11 @@ private function register_test_abilities(): void { /** * Test executing a regular ability with POST. + * + * @ticket 64098 */ public function test_execute_regular_ability_post(): void { - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/calculator/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( wp_json_encode( @@ -370,9 +416,11 @@ public function test_execute_regular_ability_post(): void { /** * Test executing a read-only ability with GET. + * + * @ticket 64098 */ public function test_execute_readonly_ability_get(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/user-info/run' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/user-info/run' ); $request->set_query_params( array( 'input' => array( @@ -388,8 +436,31 @@ public function test_execute_readonly_ability_get(): void { $this->assertEquals( self::$user_id, $data['id'] ); } + /** + * Test executing a destructive ability with GET. + * + * @ticket 64098 + */ + public function test_execute_destructive_ability_delete(): void { + $request = new WP_REST_Request( 'DELETE', '/wp-abilities/v1/abilities/test/delete-user/run' ); + $request->set_query_params( + array( + 'input' => array( + 'user_id' => self::$user_id, + ), + ) + ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 'User successfully deleted!', $response->get_data() ); + } + /** * Test HTTP method validation for regular abilities. + * + * @ticket 64098 */ public function test_regular_ability_requires_post(): void { wp_register_ability( @@ -408,7 +479,7 @@ public function test_regular_ability_requires_post(): void { ) ); - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/open-tool/run' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/open-tool/run' ); $response = $this->server->dispatch( $request ); $this->assertSame( 405, $response->get_status() ); @@ -419,10 +490,12 @@ public function test_regular_ability_requires_post(): void { /** * Test HTTP method validation for read-only abilities. + * + * @ticket 64098 */ public function test_readonly_ability_requires_get(): void { // Try POST on a read-only ability (should fail). - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/user-info/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/user-info/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( wp_json_encode( array( 'user_id' => 1 ) ) ); @@ -434,14 +507,34 @@ public function test_readonly_ability_requires_get(): void { $this->assertSame( 'Read-only abilities require GET method.', $data['message'] ); } + /** + * Test HTTP method validation for destructive abilities. + * + * @ticket 64098 + */ + public function test_destructive_ability_requires_delete(): void { + // Try POST on a destructive ability (should fail). + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/delete-user/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'user_id' => 1 ) ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 405, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( 'rest_ability_invalid_method', $data['code'] ); + $this->assertSame( 'Abilities that perform destructive actions require DELETE method.', $data['message'] ); + } /** * Test output validation against schema. * Note: When output validation fails in WP_Ability::execute(), it returns null, * which causes the REST controller to return 'ability_invalid_output'. + * + * @ticket 64098 */ public function test_output_validation(): void { - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/invalid-output/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/invalid-output/run' ); $request->set_header( 'Content-Type', 'application/json' ); $response = $this->server->dispatch( $request ); @@ -457,11 +550,13 @@ public function test_output_validation(): void { /** * Test permission check for execution. + * + * @ticket 64098 */ public function test_execution_permission_denied(): void { wp_set_current_user( self::$no_permission_user_id ); - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/calculator/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( wp_json_encode( @@ -484,9 +579,11 @@ public function test_execution_permission_denied(): void { /** * Test contextual permission check. + * + * @ticket 64098 */ public function test_contextual_permission_check(): void { - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/restricted/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/restricted/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( wp_json_encode( @@ -520,9 +617,11 @@ public function test_contextual_permission_check(): void { /** * Test handling an ability that does not show in REST. + * + * @ticket 64098 */ public function test_do_not_show_in_rest(): void { - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/not-show-in-rest/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/not-show-in-rest/run' ); $request->set_header( 'Content-Type', 'application/json' ); $response = $this->server->dispatch( $request ); @@ -535,9 +634,11 @@ public function test_do_not_show_in_rest(): void { /** * Test handling of null is a valid return value. + * + * @ticket 64098 */ public function test_null_return_handling(): void { - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/null-return/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/null-return/run' ); $request->set_header( 'Content-Type', 'application/json' ); $response = $this->server->dispatch( $request ); @@ -549,9 +650,11 @@ public function test_null_return_handling(): void { /** * Test handling of WP_Error return from ability. + * + * @ticket 64098 */ public function test_wp_error_return_handling(): void { - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/error-return/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/error-return/run' ); $request->set_header( 'Content-Type', 'application/json' ); $response = $this->server->dispatch( $request ); @@ -565,10 +668,12 @@ public function test_wp_error_return_handling(): void { /** * Test non-existent ability returns 404. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Registry::get_registered */ public function test_execute_non_existent_ability(): void { - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/non/existent/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/non/existent/run' ); $request->set_header( 'Content-Type', 'application/json' ); $response = $this->server->dispatch( $request ); @@ -580,9 +685,11 @@ public function test_execute_non_existent_ability(): void { /** * Test schema retrieval for run endpoint. + * + * @ticket 64098 */ public function test_run_endpoint_schema(): void { - $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/abilities/test/calculator/run' ); + $request = new WP_REST_Request( 'OPTIONS', '/wp-abilities/v1/abilities/test/calculator/run' ); $response = $this->server->dispatch( $request ); $data = $response->get_data(); @@ -597,9 +704,11 @@ public function test_run_endpoint_schema(): void { /** * Test that invalid JSON in POST body is handled correctly. + * + * @ticket 64098 */ public function test_invalid_json_in_post_body(): void { - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/calculator/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' ); $request->set_header( 'Content-Type', 'application/json' ); // Set raw body with invalid JSON $request->set_body( '{"input": {invalid json}' ); @@ -612,9 +721,11 @@ public function test_invalid_json_in_post_body(): void { /** * Test GET request with complex nested input array. + * + * @ticket 64098 */ public function test_get_request_with_nested_input_array(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/query-params/run' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/query-params/run' ); $request->set_query_params( array( 'input' => array( @@ -638,9 +749,11 @@ public function test_get_request_with_nested_input_array(): void { /** * Test GET request with non-array input parameter. + * + * @ticket 64098 */ public function test_get_request_with_non_array_input(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/query-params/run' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/query-params/run' ); $request->set_query_params( array( 'input' => 'not-an-array', // String instead of array @@ -654,9 +767,11 @@ public function test_get_request_with_non_array_input(): void { /** * Test POST request with non-array input in JSON body. + * + * @ticket 64098 */ public function test_post_request_with_non_array_input(): void { - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/calculator/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( wp_json_encode( @@ -673,6 +788,8 @@ public function test_post_request_with_non_array_input(): void { /** * Test ability with invalid output that fails validation. + * + * @ticket 64098 */ public function test_output_validation_failure_returns_error(): void { // Register ability with strict output schema. @@ -703,7 +820,7 @@ public function test_output_validation_failure_returns_error(): void { ) ); - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/strict-output/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/strict-output/run' ); $request->set_header( 'Content-Type', 'application/json' ); $response = $this->server->dispatch( $request ); @@ -720,6 +837,8 @@ public function test_output_validation_failure_returns_error(): void { /** * Test ability with invalid input that fails validation. + * + * @ticket 64098 */ public function test_input_validation_failure_returns_error(): void { // Register ability with strict input schema. @@ -748,7 +867,7 @@ public function test_input_validation_failure_returns_error(): void { ) ); - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/strict-input/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/strict-input/run' ); $request->set_header( 'Content-Type', 'application/json' ); // Missing required field $request->set_body( wp_json_encode( array( 'input' => array( 'other_field' => 'value' ) ) ) ); @@ -767,6 +886,8 @@ public function test_input_validation_failure_returns_error(): void { /** * Test ability without annotations defaults to POST method. + * + * @ticket 64098 */ public function test_ability_without_annotations_defaults_to_post_method(): void { // Register ability without annotations. @@ -787,12 +908,12 @@ public function test_ability_without_annotations_defaults_to_post_method(): void ); // Should require POST (default behavior). - $get_request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/no-annotations/run' ); + $get_request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/no-annotations/run' ); $get_response = $this->server->dispatch( $get_request ); $this->assertEquals( 405, $get_response->get_status() ); // Should work with POST. - $post_request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/no-annotations/run' ); + $post_request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/no-annotations/run' ); $post_request->set_header( 'Content-Type', 'application/json' ); $post_response = $this->server->dispatch( $post_request ); @@ -800,10 +921,11 @@ public function test_ability_without_annotations_defaults_to_post_method(): void } /** - * Test edge case with empty input for both GET and POST methods. + * Test edge case with empty input for GET method. + * + * @ticket 64098 */ - public function test_empty_input_handling(): void { - // Registers abilities for empty input testing. + public function test_empty_input_handling_get_method(): void { wp_register_ability( 'test/read-only-empty', array( @@ -823,6 +945,55 @@ public function test_empty_input_handling(): void { ) ); + // Tests GET with no input parameter. + $get_request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/read-only-empty/run' ); + $get_response = $this->server->dispatch( $get_request ); + $this->assertEquals( 200, $get_response->get_status() ); + $this->assertTrue( $get_response->get_data()['input_was_empty'] ); + } + + /** + * Test edge case with empty input for GET method, and normalized input using schema. + * + * @ticket 64098 + */ + public function test_empty_input_handling_get_method_with_normalized_input(): void { + wp_register_ability( + 'test/read-only-empty-array', + array( + 'label' => 'Read-only Empty Array', + 'description' => 'Read-only with inferred empty array input from schema.', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'array', + 'default' => array(), + ), + 'execute_callback' => static function ( $input ) { + return is_array( $input ) && empty( $input ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + + // Tests GET with no input parameter. + $get_request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/read-only-empty-array/run' ); + $get_response = $this->server->dispatch( $get_request ); + $this->assertEquals( 200, $get_response->get_status() ); + $this->assertTrue( $get_response->get_data() ); + } + + /** + * Test edge case with empty input for POST method. + * + * @ticket 64098 + */ + public function test_empty_input_handling_post_method(): void { wp_register_ability( 'test/regular-empty', array( @@ -839,14 +1010,8 @@ public function test_empty_input_handling(): void { ) ); - // Tests GET with no input parameter. - $get_request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/read-only-empty/run' ); - $get_response = $this->server->dispatch( $get_request ); - $this->assertEquals( 200, $get_response->get_status() ); - $this->assertTrue( $get_response->get_data()['input_was_empty'] ); - // Tests POST with no body. - $post_request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/regular-empty/run' ); + $post_request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/regular-empty/run' ); $post_request->set_header( 'Content-Type', 'application/json' ); $post_request->set_body( '{}' ); // Empty JSON object @@ -876,11 +1041,14 @@ public function data_malformed_json_provider(): array { /** * Test malformed JSON in POST body. * + * @ticket 64098 + * * @dataProvider data_malformed_json_provider + * * @param string $json Malformed JSON to test. */ public function test_malformed_json_post_body( string $json ): void { - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/calculator/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( $json ); @@ -890,9 +1058,10 @@ public function test_malformed_json_post_body( string $json ): void { $this->assertEquals( 400, $response->get_status() ); } - /** * Test input with various PHP types as strings. + * + * @ticket 64098 */ public function test_php_type_strings_in_input(): void { // Register ability that accepts any input @@ -927,7 +1096,7 @@ public function test_php_type_strings_in_input(): void { 'negative' => -1, ); - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/echo/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/echo/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( wp_json_encode( array( 'input' => $inputs ) ) ); @@ -940,6 +1109,8 @@ public function test_php_type_strings_in_input(): void { /** * Test input with mixed encoding. + * + * @ticket 64098 */ public function test_mixed_encoding_in_input(): void { // Register ability that accepts any input @@ -972,7 +1143,7 @@ public function test_mixed_encoding_in_input(): void { 'quotes' => "It's \"quoted\"", ); - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/echo-encoding/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/echo-encoding/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( wp_json_encode( array( 'input' => $input ) ) ); @@ -1004,7 +1175,10 @@ public function data_invalid_http_methods_provider(): array { /** * Test request with invalid HTTP methods. * + * @ticket 64098 + * * @dataProvider data_invalid_http_methods_provider + * * @param string $method HTTP method to test. */ public function test_invalid_http_methods( string $method ): void { @@ -1025,7 +1199,7 @@ public function test_invalid_http_methods( string $method ): void { ) ); - $request = new WP_REST_Request( $method, '/wp/v2/abilities/test/method-test/run' ); + $request = new WP_REST_Request( $method, '/wp-abilities/v1/abilities/test/method-test/run' ); $response = $this->server->dispatch( $request ); // Regular abilities should only accept POST, so these should return 405. @@ -1037,9 +1211,11 @@ public function test_invalid_http_methods( string $method ): void { /** * Test OPTIONS method handling. + * + * @ticket 64098 */ public function test_options_method_handling(): void { - $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/abilities/test/calculator/run' ); + $request = new WP_REST_Request( 'OPTIONS', '/wp-abilities/v1/abilities/test/calculator/run' ); $response = $this->server->dispatch( $request ); // OPTIONS requests return 200 with allowed methods $this->assertEquals( 200, $response->get_status() ); From 0c7f6215800890578a14c5f6e0497ccda365dea5 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Wed, 22 Oct 2025 19:33:26 +0200 Subject: [PATCH 02/11] Use `wp_has_ability_category` helper function --- includes/abilities-api/class-wp-abilities-registry.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/includes/abilities-api/class-wp-abilities-registry.php b/includes/abilities-api/class-wp-abilities-registry.php index e3086d71..770e553a 100644 --- a/includes/abilities-api/class-wp-abilities-registry.php +++ b/includes/abilities-api/class-wp-abilities-registry.php @@ -121,8 +121,7 @@ public function register( string $name, array $args ): ?WP_Ability { // Validate ability category exists if provided (will be validated as required in WP_Ability). if ( isset( $args['category'] ) ) { - $category_registry = WP_Ability_Categories_Registry::get_instance(); - if ( ! $category_registry->is_registered( $args['category'] ) ) { + if ( ! wp_has_ability_category( $args['category'] ) ) { _doing_it_wrong( __METHOD__, sprintf( From 76f1fad51fb14aa4f8512d7bea2384732c3fe837 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 23 Oct 2025 09:10:28 +0200 Subject: [PATCH 03/11] Disable or fix issues reported by PHPCS --- includes/abilities-api.php | 2 +- .../class-wp-abilities-registry.php | 2 +- includes/abilities-api/class-wp-ability.php | 8 ++++---- ...class-wp-rest-abilities-v1-run-controller.php | 2 +- phpcs.xml.dist | 16 ++-------------- 5 files changed, 9 insertions(+), 21 deletions(-) diff --git a/includes/abilities-api.php b/includes/abilities-api.php index 9fb10c20..37c0c1e4 100644 --- a/includes/abilities-api.php +++ b/includes/abilities-api.php @@ -38,7 +38,7 @@ * @type array $meta { * Optional. Additional metadata for the ability. * - * @type array $annotations Optional. Annotation metadata for the ability. + * @type array $annotations Optional. Annotation metadata for the ability. * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. * } * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability. diff --git a/includes/abilities-api/class-wp-abilities-registry.php b/includes/abilities-api/class-wp-abilities-registry.php index 770e553a..848847d8 100644 --- a/includes/abilities-api/class-wp-abilities-registry.php +++ b/includes/abilities-api/class-wp-abilities-registry.php @@ -61,7 +61,7 @@ final class WP_Abilities_Registry { * @type array $meta { * Optional. Additional metadata for the ability. * - * @type array $annotations Optional. Annotation metadata for the ability. + * @type array $annotations Optional. Annotation metadata for the ability. * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. * } * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability. diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index 71b2ea25..e51ef5ef 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -33,7 +33,7 @@ class WP_Ability { * They are not guaranteed to provide a faithful description of ability behavior. * * @since 6.9.0 - * @var array + * @var array */ protected static $default_annotations = array( // If true, the ability does not modify its environment. @@ -150,7 +150,7 @@ class WP_Ability { * @type array $meta { * Optional. Additional metadata for the ability. * - * @type array $annotations Optional. Annotation metadata for the ability. + * @type array $annotations Optional. Annotation metadata for the ability. * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. * } * } @@ -205,7 +205,7 @@ public function __construct( string $name, array $args ) { * @type array $meta { * Optional. Additional metadata for the ability. * - * @type array $annotations Optional. Annotation metadata for the ability. + * @type array $annotations Optional. Annotation metadata for the ability. * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. * } * } @@ -224,7 +224,7 @@ public function __construct( string $name, array $args ) { * @type array $meta { * Additional metadata for the ability. * - * @type array $annotations Optional. Annotation metadata for the ability. + * @type array $annotations Optional. Annotation metadata for the ability. * @type bool $show_in_rest Whether to expose this ability in the REST API. Default false. * } * } diff --git a/includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php b/includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php index a58888be..36766a21 100644 --- a/includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php +++ b/includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php @@ -104,7 +104,7 @@ public function execute_ability( $request ) { * @since 6.9.0 * * @param string $request_method The HTTP method of the request. - * @param array $annotations The ability annotations. + * @param array $annotations The ability annotations. * @return true|WP_Error True on success, or WP_Error object on failure. */ public function validate_request_method( string $request_method, array $annotations ) { diff --git a/phpcs.xml.dist b/phpcs.xml.dist index d4d027cb..67ef76e2 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -60,6 +60,7 @@ + @@ -114,6 +115,7 @@ + @@ -138,20 +140,6 @@ - - - - - - - - - - - - - - From c03bbc7a9a99d46c5e7e24800e1dba9f6ee55a17 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 23 Oct 2025 09:55:59 +0200 Subject: [PATCH 04/11] Ignore `missingType.generics` error in PHPStan --- phpstan.neon.dist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 780d7cea..2c5fb2c0 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -22,6 +22,8 @@ parameters: paths: - abilities-api.php - includes/ + ignoreErrors: + - identifier: missingType.generics excludePaths: analyse: - tests/ From 550d0ef921feec0573b4ab352711b4189ee70744 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 23 Oct 2025 11:47:40 +0200 Subject: [PATCH 05/11] Try to fix the failing unit test --- tests/unit/rest-api/wpRestAbilitiesInit.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/rest-api/wpRestAbilitiesInit.php b/tests/unit/rest-api/wpRestAbilitiesInit.php index 0e1b015f..a5cb9d6f 100644 --- a/tests/unit/rest-api/wpRestAbilitiesInit.php +++ b/tests/unit/rest-api/wpRestAbilitiesInit.php @@ -176,6 +176,8 @@ public function test_no_duplicate_routes_on_multiple_init(): void { $routes_first = $this->server->get_routes(); $abilities_route_count_first = count( $routes_first['/wp-abilities/v1/abilities'] ?? array() ); + $this->assertEquals( 1, $abilities_route_count_first ); + // Second init (simulating multiple calls) // Note: WordPress doesn't prevent duplicate registration, so we expect 2x routes WP_REST_Abilities_Init::register_routes(); @@ -183,7 +185,6 @@ public function test_no_duplicate_routes_on_multiple_init(): void { $routes_second = $this->server->get_routes(); $abilities_route_count_second = count( $routes_second['/wp-abilities/v1/abilities'] ?? array() ); - // WordPress allows duplicate route registration - $this->assertEquals( $abilities_route_count_first * 2, $abilities_route_count_second ); + $this->assertEquals( 2, $abilities_route_count_second ); } } From 5392975ce7ae3a348791e8ed93a4cd0f0b31a64b Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 23 Oct 2025 18:29:57 +0200 Subject: [PATCH 06/11] Improve the REST API routes registration --- includes/bootstrap.php | 2 +- .../rest-api/class-wp-rest-abilities-init.php | 35 ++++++++++++++----- tests/unit/rest-api/wpRestAbilitiesInit.php | 35 +++++++++++-------- 3 files changed, 47 insertions(+), 25 deletions(-) diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 8a6fe25e..ab4f97fe 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -61,7 +61,7 @@ // Initialize REST API routes when WordPress is available. if ( function_exists( 'add_action' ) ) { - add_action( 'rest_api_init', array( 'WP_REST_Abilities_Init', 'register_routes' ) ); + add_action( 'rest_api_init', array( 'WP_REST_Abilities_Init', 'register_routes' ), 11 ); } } diff --git a/includes/rest-api/class-wp-rest-abilities-init.php b/includes/rest-api/class-wp-rest-abilities-init.php index 6dd49144..be25ca95 100644 --- a/includes/rest-api/class-wp-rest-abilities-init.php +++ b/includes/rest-api/class-wp-rest-abilities-init.php @@ -20,19 +20,36 @@ class WP_REST_Abilities_Init { * Registers the REST API routes for abilities. * * @since 0.1.0 + * + * @param WP_REST_Server|null $rest_server Optional. The REST server to register routes with. Default null, which + * will use the main REST server instance. */ - public static function register_routes(): void { - if ( ! class_exists( 'WP_REST_Abilities_V1_Categories_Controller' ) ) { - require_once __DIR__ . '/endpoints/class-wp-rest-abilities-v1-categories-controller.php'; + public static function register_routes( $rest_server = null ): void { + if ( ! $rest_server instanceof WP_REST_Server ) { + $rest_server = rest_get_server(); + } + + $routes = $rest_server->get_routes(); + + if ( ! isset( $routes['/wp-abilities/v1/categories'] ) ) { + if ( ! class_exists( 'WP_REST_Abilities_V1_Categories_Controller' ) ) { + require_once __DIR__ . '/endpoints/class-wp-rest-abilities-v1-categories-controller.php'; + } + $categories_controller = new WP_REST_Abilities_V1_Categories_Controller(); + $categories_controller->register_routes(); + } + + if ( ! isset( $routes['/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run'] ) ) { + if ( ! class_exists( 'WP_REST_Abilities_V1_Run_Controller' ) ) { + require_once __DIR__ . '/endpoints/class-wp-rest-abilities-v1-run-controller.php'; + } + $run_controller = new WP_REST_Abilities_V1_Run_Controller(); + $run_controller->register_routes(); } - $categories_controller = new WP_REST_Abilities_V1_Categories_Controller(); - $categories_controller->register_routes(); - if ( ! class_exists( 'WP_REST_Abilities_V1_Run_Controller' ) ) { - require_once __DIR__ . '/endpoints/class-wp-rest-abilities-v1-run-controller.php'; + if ( isset( $routes['/wp-abilities/v1/abilities'] ) ) { + return; } - $run_controller = new WP_REST_Abilities_V1_Run_Controller(); - $run_controller->register_routes(); if ( ! class_exists( 'WP_REST_Abilities_V1_List_Controller' ) ) { require_once __DIR__ . '/endpoints/class-wp-rest-abilities-v1-list-controller.php'; diff --git a/tests/unit/rest-api/wpRestAbilitiesInit.php b/tests/unit/rest-api/wpRestAbilitiesInit.php index a5cb9d6f..6232678e 100644 --- a/tests/unit/rest-api/wpRestAbilitiesInit.php +++ b/tests/unit/rest-api/wpRestAbilitiesInit.php @@ -48,7 +48,7 @@ public function test_routes_registered_on_rest_api_init(): void { $this->assertArrayNotHasKey( '/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run', $routes ); // Trigger rest_api_init - do_action( 'rest_api_init' ); + do_action( 'rest_api_init', $this->server ); // Routes should now be registered $routes = $this->server->get_routes(); @@ -63,7 +63,7 @@ public function test_routes_registered_on_rest_api_init(): void { */ public function test_correct_controllers_instantiated(): void { // Trigger rest_api_init - do_action( 'rest_api_init' ); + do_action( 'rest_api_init', $this->server ); $routes = $this->server->get_routes(); @@ -101,7 +101,7 @@ public function test_required_files_loaded(): void { * Test that routes support expected HTTP methods. */ public function test_routes_support_expected_methods(): void { - do_action( 'rest_api_init' ); + do_action( 'rest_api_init', $this->server ); $routes = $this->server->get_routes(); @@ -150,7 +150,7 @@ public function test_routes_support_expected_methods(): void { * Test namespace and base configuration. */ public function test_namespace_and_base_configuration(): void { - do_action( 'rest_api_init' ); + do_action( 'rest_api_init', $this->server ); $namespaces = $this->server->get_namespaces(); $this->assertContains( 'wp/v2', $namespaces ); @@ -170,21 +170,26 @@ public function test_namespace_and_base_configuration(): void { * Test that multiple calls to register_routes don't duplicate routes. */ public function test_no_duplicate_routes_on_multiple_init(): void { - // First init - do_action( 'rest_api_init' ); + // First init. + do_action( 'rest_api_init', $this->server ); - $routes_first = $this->server->get_routes(); - $abilities_route_count_first = count( $routes_first['/wp-abilities/v1/abilities'] ?? array() ); + $routes_init = $this->server->get_routes(); - $this->assertEquals( 1, $abilities_route_count_first ); + // This number depends on how many routes are registered initially. + $initial_count = count( $routes_init['/wp-abilities/v1/abilities'] ?? array() ); - // Second init (simulating multiple calls) - // Note: WordPress doesn't prevent duplicate registration, so we expect 2x routes - WP_REST_Abilities_Init::register_routes(); + $this->assertGreaterThanOrEqual( 1, $initial_count ); + $this->assertCount( $initial_count, $routes_init['/wp-abilities/v1/categories'] ?? array() ); + $this->assertCount( $initial_count, $routes_init['/wp-abilities/v1/abilities'] ?? array() ); + $this->assertCount( $initial_count , $routes_init['/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\-\/]+?)/run'] ?? array() ); - $routes_second = $this->server->get_routes(); - $abilities_route_count_second = count( $routes_second['/wp-abilities/v1/abilities'] ?? array() ); + // Second init (simulating multiple calls). + WP_REST_Abilities_Init::register_routes( $this->server ); - $this->assertEquals( 2, $abilities_route_count_second ); + $routes_second_init = $this->server->get_routes(); + + $this->assertCount( $initial_count, $routes_second_init['/wp-abilities/v1/categories'] ?? array() ); + $this->assertCount( $initial_count, $routes_second_init['/wp-abilities/v1/abilities'] ?? array() ); + $this->assertCount( $initial_count, $routes_second_init['/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\-\/]+?)/run'] ?? array() ); } } From 0a511e04d0fe031fb2eba0a840eb3595707b2cb3 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 23 Oct 2025 19:21:42 +0200 Subject: [PATCH 07/11] Update client to use new REST API urls --- packages/client/src/__tests__/api.test.ts | 8 ++++---- packages/client/src/api.ts | 2 +- packages/client/src/store/__tests__/reducer.test.ts | 8 ++++---- packages/client/src/store/index.ts | 4 ++-- packages/client/src/types.ts | 7 +++---- 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/client/src/__tests__/api.test.ts b/packages/client/src/__tests__/api.test.ts index 5f761d0d..979673a6 100644 --- a/packages/client/src/__tests__/api.test.ts +++ b/packages/client/src/__tests__/api.test.ts @@ -228,7 +228,7 @@ describe( 'API functions', () => { 'test/server-ability' ); expect( apiFetch ).toHaveBeenCalledWith( { - path: '/wp/v2/abilities/test/server-ability/run', + path: '/wp-abilities/v1/abilities/test/server-ability/run', method: 'POST', data: { input }, } ); @@ -337,7 +337,7 @@ describe( 'API functions', () => { const result = await executeAbility( 'test/read-only', input ); expect( apiFetch ).toHaveBeenCalledWith( { - path: '/wp/v2/abilities/test/read-only/run?input%5Bid%5D=123&input%5Bformat%5D=json', + path: '/wp-abilities/v1/abilities/test/read-only/run?input%5Bid%5D=123&input%5Bformat%5D=json', method: 'GET', } ); expect( result ).toEqual( mockResponse ); @@ -369,7 +369,7 @@ describe( 'API functions', () => { const result = await executeAbility( 'test/read-only', {} ); expect( apiFetch ).toHaveBeenCalledWith( { - path: '/wp/v2/abilities/test/read-only/run?', + path: '/wp-abilities/v1/abilities/test/read-only/run?', method: 'GET', } ); expect( result ).toEqual( mockResponse ); @@ -469,7 +469,7 @@ describe( 'API functions', () => { } ); expect( apiFetch ).toHaveBeenCalledWith( { - path: '/wp/v2/abilities/test/ability/run', + path: '/wp-abilities/v1/abilities/test/ability/run', method: 'POST', data: { input: { data: 'test' } }, } ); diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index d509ce81..9938b97c 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -275,7 +275,7 @@ async function executeServerAbility( ): Promise< AbilityOutput > { const method = !! ability.meta?.annotations?.readonly ? 'GET' : 'POST'; - let path = `/wp/v2/abilities/${ ability.name }/run`; + let path = `/wp-abilities/v1/abilities/${ ability.name }/run`; const options: { method: string; data?: { input: AbilityInput }; diff --git a/packages/client/src/store/__tests__/reducer.test.ts b/packages/client/src/store/__tests__/reducer.test.ts index 3b7de3f3..73b1e233 100644 --- a/packages/client/src/store/__tests__/reducer.test.ts +++ b/packages/client/src/store/__tests__/reducer.test.ts @@ -67,8 +67,8 @@ describe( 'Store Reducer', () => { label: 'Test Ability', description: 'Test ability with links', _links: { - self: { href: '/wp/v2/abilities/test/ability' }, - collection: { href: '/wp/v2/abilities' }, + self: { href: '/wp-abilities/v1/abilities/test/ability' }, + collection: { href: '/wp-abilities/v1/abilities' }, }, }, ]; @@ -444,10 +444,10 @@ describe( 'Store Reducer', () => { description: 'Test category with links', _links: { self: { - href: '/wp/v2/abilities/categories/data-retrieval', + href: '/wp-abilities/v1/categories/data-retrieval', }, collection: { - href: '/wp/v2/abilities/categories', + href: '/wp-abilities/v1/categories', }, }, }, diff --git a/packages/client/src/store/index.ts b/packages/client/src/store/index.ts index 136b54c1..422670e7 100644 --- a/packages/client/src/store/index.ts +++ b/packages/client/src/store/index.ts @@ -36,7 +36,7 @@ dispatch( coreStore ).addEntities( [ name: ENTITY_NAME, kind: ENTITY_KIND, key: 'name', - baseURL: '/wp/v2/abilities', + baseURL: '/wp-abilities/v1/abilities', baseURLParams: { context: 'edit' }, plural: 'abilities', label: __( 'Abilities' ), @@ -46,7 +46,7 @@ dispatch( coreStore ).addEntities( [ name: ENTITY_NAME_CATEGORIES, kind: ENTITY_KIND, key: 'slug', - baseURL: '/wp/v2/abilities/categories', + baseURL: '/wp-abilities/v1/categories', baseURLParams: { context: 'edit' }, plural: 'ability-categories', label: __( 'Ability Categories' ), diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 934204cb..f645d331 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -82,10 +82,9 @@ export interface Ability { */ meta?: { annotations?: { - instructions?: string; - readonly?: boolean; - destructive?: boolean; - idempotent?: boolean; + readonly?: boolean | null; + destructive?: boolean | null; + idempotent?: boolean | null; }; [ key: string ]: any; }; From b70eec3bf3bcb133e1ddb77ba951517598b56b40 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 23 Oct 2025 20:52:31 +0200 Subject: [PATCH 08/11] Add handling for destructive idempotent abilities to the client --- packages/client/src/__tests__/api.test.ts | 42 +++++++++++++++++++++++ packages/client/src/api.ts | 14 ++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/client/src/__tests__/api.test.ts b/packages/client/src/__tests__/api.test.ts index 979673a6..9671e513 100644 --- a/packages/client/src/__tests__/api.test.ts +++ b/packages/client/src/__tests__/api.test.ts @@ -375,6 +375,48 @@ describe( 'API functions', () => { expect( result ).toEqual( mockResponse ); } ); + it( 'should execute a destructive idempotent ability via DELETE', async () => { + const mockAbility: Ability = { + name: 'test/destructive', + label: 'Destructive Ability', + description: 'Test destructive idempotent ability.', + category: 'test-category', + input_schema: { + type: 'object', + properties: { + id: { type: 'string' }, + format: { type: 'string' }, + }, + }, + output_schema: { type: 'string' }, + meta: { + annotations: { + destructive: true, + idempotent: true, + }, + }, + }; + + const mockGetAbility = jest.fn().mockResolvedValue( mockAbility ); + ( resolveSelect as jest.Mock ).mockReturnValue( { + getAbility: mockGetAbility, + } ); + + const mockResponse = 'Item deleted successfully.'; + ( apiFetch as unknown as jest.Mock ).mockResolvedValue( + mockResponse + ); + + const input = { id: '123', format: 'json' }; + const result = await executeAbility( 'test/destructive', input ); + + expect( apiFetch ).toHaveBeenCalledWith( { + path: '/wp-abilities/v1/abilities/test/destructive/run?input%5Bid%5D=123&input%5Bformat%5D=json', + method: 'DELETE', + } ); + expect( result ).toEqual( mockResponse ); + } ); + it( 'should handle errors in client ability execution', async () => { const consoleErrorSpy = jest .spyOn( console, 'error' ) diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index 9938b97c..4c771108 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -273,7 +273,15 @@ async function executeServerAbility( ability: Ability, input: AbilityInput ): Promise< AbilityOutput > { - const method = !! ability.meta?.annotations?.readonly ? 'GET' : 'POST'; + let method = 'POST'; + if ( !! ability.meta?.annotations?.readonly ) { + method = 'GET'; + } else if ( + !! ability.meta?.annotations?.destructive && + !! ability.meta?.annotations?.idempotent + ) { + method = 'DELETE'; + } let path = `/wp-abilities/v1/abilities/${ ability.name }/run`; const options: { @@ -283,8 +291,8 @@ async function executeServerAbility( method, }; - if ( method === 'GET' && input !== null ) { - // For GET requests, pass the input directly + if ( [ 'GET', 'DELETE' ].includes( method ) && input !== null ) { + // For GET and DELETE requests, pass the input directly. path = addQueryArgs( path, { input } ); } else if ( method === 'POST' && input !== null ) { options.data = { input }; From aa88f6a7597a062b7545d1cc436dd448e234ce2d Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Mon, 27 Oct 2025 10:36:08 +0100 Subject: [PATCH 09/11] Bring the latest documentation updates added in WP Core --- includes/abilities-api.php | 439 +++++++++++++++++++++++++++++++++---- 1 file changed, 393 insertions(+), 46 deletions(-) diff --git a/includes/abilities-api.php b/includes/abilities-api.php index 37c0c1e4..4de55873 100644 --- a/includes/abilities-api.php +++ b/includes/abilities-api.php @@ -1,8 +1,84 @@ __( 'Export Users', 'my-plugin' ), + * 'description' => __( 'Exports user data to CSV format.', 'my-plugin' ), + * 'category' => 'data-export', + * 'execute_callback' => 'my_plugin_export_users', + * 'permission_callback' => function(): bool { + * return current_user_can( 'export' ); + * }, + * 'input_schema' => array( + * 'type' => 'string', + * 'enum' => array( 'subscriber', 'contributor', 'author', 'editor', 'administrator' ), + * 'description' => __( 'Limits the export to users with this role.', 'my-plugin' ), + * 'required' => false, + * ), + * 'output_schema' => array( + * 'type' => 'string', + * 'description' => __( 'User data in CSV format.', 'my-plugin' ), + * 'required' => true, + * ), + * 'meta' => array( + * 'show_in_rest' => true, + * ), + * ) + * ); + * } + * add_action( 'wp_abilities_api_init', 'my_plugin_register_abilities' ); + * + * Once registered, abilities can be checked, retrieved, and managed: + * + * // Checks if an ability is registered, and prints its label. + * if ( wp_has_ability( 'my-plugin/export-users' ) ) { + * $ability = wp_get_ability( 'my-plugin/export-users' ); + * + * echo $ability->get_label(); + * } + * + * // Gets all registered abilities. + * $all_abilities = wp_get_abilities(); + * + * // Unregisters when no longer needed. + * wp_unregister_ability( 'my-plugin/export-users' ); + * + * ## Best Practices + * + * - Always register abilities on the `wp_abilities_api_init` hook. + * - Use namespaced ability names to prevent conflicts. + * - Implement robust permission checks in permission callbacks. + * - Provide an `input_schema` to ensure data integrity and document expected inputs. + * - Define an `output_schema` to describe return values and validate responses. + * - Return `WP_Error` objects for failures rather than throwing exceptions. + * - Use internationalization functions for all user-facing strings. * * @package WordPress * @subpackage Abilities_API @@ -12,47 +88,194 @@ declare( strict_types = 1 ); /** - * Registers a new ability using Abilities API. + * Registers a new ability using the Abilities API. It requires three steps: + * + * 1. Hook into the `wp_abilities_api_init` action. + * 2. Call `wp_register_ability()` with a namespaced name and configuration. + * 3. Provide execute and permission callbacks. + * + * Example: + * + * function my_plugin_register_abilities(): void { + * wp_register_ability( + * 'my-plugin/analyze-text', + * array( + * 'label' => __( 'Analyze Text', 'my-plugin' ), + * 'description' => __( 'Performs sentiment analysis on provided text.', 'my-plugin' ), + * 'category' => 'text-processing', + * 'input_schema' => array( + * 'type' => 'string', + * 'description' => __( 'The text to be analyzed.', 'my-plugin' ), + * 'minLength' => 10, + * 'required' => true, + * ), + * 'output_schema' => array( + * 'type' => 'string', + * 'enum' => array( 'positive', 'negative', 'neutral' ), + * 'description' => __( 'The sentiment result: positive, negative, or neutral.', 'my-plugin' ), + * 'required' => true, + * ), + * 'execute_callback' => 'my_plugin_analyze_text', + * 'permission_callback' => 'my_plugin_can_analyze_text', + * 'meta' => array( + * 'annotations' => array( + * 'readonly' => true, + * ), + * 'show_in_rest' => true, + * ), + * ) + * ); + * } + * add_action( 'wp_abilities_api_init', 'my_plugin_register_abilities' ); + * + * ### Naming Conventions + * + * Ability names must follow these rules: + * + * - Include a namespace prefix (e.g., `my-plugin/my-ability`). + * - Use only lowercase alphanumeric characters, dashes, and forward slashes. + * - Use descriptive, action-oriented names (e.g., `process-payment`, `generate-report`). * - * Note: Should only be used on the {@see 'wp_abilities_api_init'} hook. + * ### Categories + * + * Abilities must be organized into categories. Ability categories provide better + * discoverability and must be registered before the abilities that reference them: + * + * function my_plugin_register_categories(): void { + * wp_register_ability_category( + * 'text-processing', + * array( + * 'label' => __( 'Text Processing', 'my-plugin' ), + * 'description' => __( 'Abilities for analyzing and transforming text.', 'my-plugin' ), + * ) + * ); + * } + * add_action( 'wp_abilities_api_categories_init', 'my_plugin_register_categories' ); + * + * ### Input and Output Schemas + * + * Schemas define the expected structure, type, and constraints for ability inputs + * and outputs using JSON Schema syntax. They serve two critical purposes: automatic + * validation of data passed to and returned from abilities, and self-documenting + * API contracts for developers. + * + * WordPress implements a validator based on a subset of the JSON Schema Version 4 + * specification (https://json-schema.org/specification-links.html#draft-4). + * For details on supported JSON Schema properties and syntax, see the + * related WordPress REST API Schema documentation: + * https://developer.wordpress.org/rest-api/extending-the-rest-api/schema/#json-schema-basics + * + * Defining schemas is mandatory when there is a value to pass or return. + * They ensure data integrity, improve developer experience, and enable + * better documentation: + * + * 'input_schema' => array( + * 'type' => 'string', + * 'description' => __( 'The text to be analyzed.', 'my-plugin' ), + * 'minLength' => 10, + * 'required' => true, + * ), + * 'output_schema' => array( + * 'type' => 'string', + * 'enum' => array( 'positive', 'negative', 'neutral' ), + * 'description' => __( 'The sentiment result: positive, negative, or neutral.', 'my-plugin' ), + * 'required' => true, + * ), + * + * ### Callbacks + * + * #### Execute Callback + * + * The execute callback performs the ability's core functionality. It receives + * optional input data and returns either a result or `WP_Error` on failure. + * + * function my_plugin_analyze_text( string $input ): string|WP_Error { + * $score = My_Plugin::perform_sentiment_analysis( $input ); + * if ( is_wp_error( $score ) ) { + * return $score; + * } + * return My_Plugin::interpret_sentiment_score( $score ); + * } + * + * #### Permission Callback + * + * The permission callback determines whether the ability can be executed. + * It receives the same input as the execute callback and must return a + * boolean or `WP_Error`. Common use cases include checking user capabilities, + * validating API keys, or verifying system state: + * + * function my_plugin_can_analyze_text( string $input ): bool|WP_Error { + * return current_user_can( 'edit_posts' ); + * } + * + * ### REST API Integration + * + * Abilities can be exposed through the REST API by setting `show_in_rest` + * to `true` in the meta configuration: + * + * 'meta' => array( + * 'show_in_rest' => true, + * ), + * + * This allows abilities to be invoked via HTTP requests to the WordPress REST API. * * @since 6.9.0 * * @see WP_Abilities_Registry::register() + * @see wp_register_ability_category() + * @see wp_unregister_ability() * - * @param string $name The name of the ability. The name must be a string containing a namespace - * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase - * alphanumeric characters, dashes and the forward slash. + * @param string $name The name of the ability. Must be a namespaced string containing + * a prefix, e.g., `my-plugin/my-ability`. Can only contain lowercase + * alphanumeric characters, dashes, and forward slashes. * @param array $args { - * An associative array of arguments for the 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 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. - * Receives optional mixed input and returns bool or WP_Error. - * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * An associative array of arguments for configuring the ability. + * + * @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 Required. The ability category slug this ability belongs to. + * The ability category must be registered via `wp_register_ability_category()` + * before registering the ability. + * @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. + * @type callable $permission_callback Required. A callback function to check permissions before execution. + * Receives optional mixed input data (same as `execute_callback`) and + * must return `true`/`false` for simple checks, or `WP_Error` for + * detailed error responses. + * @type array $input_schema Optional. JSON Schema definition for validating the ability's input. + * Must be a valid JSON Schema object defining the structure and + * constraints for input data. Used for automatic validation and + * API documentation. * @type array $output_schema Optional. JSON Schema definition for the ability's output. - * @type array $meta { + * Describes the structure of successful return values from + * `execute_callback`. Used for documentation and validation. + * @type array $meta { * Optional. Additional metadata for the ability. * - * @type array $annotations Optional. Annotation metadata for the ability. - * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * @type array $annotations Optional. Annotation metadata for the ability. Provides + * additional semantic information about the ability's + * characteristics and behavior. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. + * When true, the ability can be invoked via HTTP requests. + * Default false. * } - * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability. + * @type string $ability_class Optional. Fully-qualified custom class name to instantiate + * instead of the default `WP_Ability` class. The custom class + * must extend `WP_Ability`. Useful for advanced customization + * of ability behavior. * } - * @return WP_Ability|null An instance of registered ability on success, null on failure. + * @return WP_Ability|null The registered ability instance on success, `null` on failure. */ function wp_register_ability( string $name, array $args ): ?WP_Ability { if ( ! did_action( 'wp_abilities_api_init' ) ) { _doing_it_wrong( __FUNCTION__, sprintf( - /* translators: 1: abilities_api_init, 2: string value of the ability name. */ + /* translators: 1: wp_abilities_api_init, 2: string value of the ability name. */ esc_html__( 'Abilities must be registered on the %1$s action. The ability %2$s was not registered.' ), - 'abilities_api_init', + 'wp_abilities_api_init', '' . esc_html( $name ) . '' ), '6.9.0' @@ -71,12 +294,25 @@ function wp_register_ability( string $name, array $args ): ?WP_Ability { /** * Unregisters an ability from the Abilities API. * + * Removes a previously registered ability from the global registry. Use this to + * disable abilities provided by other plugins or when an ability is no longer needed. + * + * Can be called at any time after the ability has been registered. + * + * Example: + * + * if ( wp_has_ability( 'other-plugin/some-ability' ) ) { + * wp_unregister_ability( 'other-plugin/some-ability' ); + * } + * * @since 6.9.0 * * @see WP_Abilities_Registry::unregister() + * @see wp_register_ability() * - * @param string $name The name of the registered ability, with its namespace. - * @return WP_Ability|null The unregistered ability instance on success, null on failure. + * @param string $name The name of the ability to unregister, including namespace prefix + * (e.g., 'my-plugin/my-ability'). + * @return WP_Ability|null The unregistered ability instance on success, `null` on failure. */ function wp_unregister_ability( string $name ): ?WP_Ability { $registry = WP_Abilities_Registry::get_instance(); @@ -90,12 +326,26 @@ function wp_unregister_ability( string $name ): ?WP_Ability { /** * Checks if an ability is registered. * + * Use this for conditional logic and feature detection before attempting to + * retrieve or use an ability. + * + * Example: + * + * // Displays different UI based on available abilities. + * if ( wp_has_ability( 'premium-plugin/advanced-export' ) ) { + * echo 'Export with Premium Features'; + * } else { + * echo 'Basic Export'; + * } + * * @since 6.9.0 * * @see WP_Abilities_Registry::is_registered() + * @see wp_get_ability() * - * @param string $name The name of the registered ability, with its namespace. - * @return bool True if the ability is registered, false otherwise. + * @param string $name The name of the ability to check, including namespace prefix + * (e.g., 'my-plugin/my-ability'). + * @return bool `true` if the ability is registered, `false` otherwise. */ function wp_has_ability( string $name ): bool { $registry = WP_Abilities_Registry::get_instance(); @@ -107,14 +357,27 @@ function wp_has_ability( string $name ): bool { } /** - * Retrieves a registered ability using Abilities API. + * Retrieves a registered ability. + * + * Returns the ability instance for inspection or use. The instance provides access + * to the ability's configuration, metadata, and execution methods. + * + * Example: + * + * // Prints information about a registered ability. + * $ability = wp_get_ability( 'my-plugin/export-data' ); + * if ( $ability ) { + * echo $ability->get_label() . ': ' . $ability->get_description(); + * } * * @since 6.9.0 * * @see WP_Abilities_Registry::get_registered() + * @see wp_has_ability() * - * @param string $name The name of the registered ability, with its namespace. - * @return WP_Ability|null The registered ability instance, or null if it is not registered. + * @param string $name The name of the ability, including namespace prefix + * (e.g., 'my-plugin/my-ability'). + * @return WP_Ability|null The registered ability instance, or `null` if not registered. */ function wp_get_ability( string $name ): ?WP_Ability { $registry = WP_Abilities_Registry::get_instance(); @@ -126,13 +389,25 @@ function wp_get_ability( string $name ): ?WP_Ability { } /** - * Retrieves all registered abilities using Abilities API. + * Retrieves all registered abilities. + * + * Returns an array of all ability instances currently registered in the system. + * Use this for discovery, debugging, or building administrative interfaces. + * + * Example: + * + * // Prints information about all available abilities. + * $abilities = wp_get_abilities(); + * foreach ( $abilities as $ability ) { + * echo $ability->get_label() . ': ' . $ability->get_description() . "\n"; + * } * * @since 6.9.0 * * @see WP_Abilities_Registry::get_all_registered() * - * @return WP_Ability[] The array of registered abilities. + * @return WP_Ability[] An array of registered WP_Ability instances. Returns an empty + * array if no abilities are registered or if the registry is unavailable. */ function wp_get_abilities(): array { $registry = WP_Abilities_Registry::get_instance(); @@ -146,27 +421,48 @@ function wp_get_abilities(): array { /** * Registers a new ability category. * + * Ability categories provide a way to organize and group related abilities for better + * discoverability and management. Ability categories must be registered before abilities + * that reference them. + * + * Ability categories must be registered on the `wp_abilities_api_categories_init` action hook. + * + * Example: + * + * function my_plugin_register_categories() { + * wp_register_ability_category( + * 'content-management', + * array( + * 'label' => __( 'Content Management', 'my-plugin' ), + * 'description' => __( 'Abilities for managing and organizing content.', 'my-plugin' ), + * ) + * ); + * } + * add_action( 'wp_abilities_api_categories_init', 'my_plugin_register_categories' ); + * * @since 6.9.0 * * @see WP_Ability_Categories_Registry::register() + * @see wp_register_ability() + * @see wp_unregister_ability_category() * * @param string $slug The unique slug for the ability category. Must contain only lowercase - * alphanumeric characters and dashes. + * alphanumeric characters and dashes (e.g., 'data-export'). * @param array $args { * An associative array of arguments for the ability category. * - * @type string $label The human-readable label for the ability category. - * @type string $description A description of the ability category. + * @type string $label Required. The human-readable label for the ability category. + * @type string $description Required. A description of what abilities in this category do. * @type array $meta Optional. Additional metadata for the ability category. * } - * @return WP_Ability_Category|null The registered ability category instance on success, null on failure. + * @return WP_Ability_Category|null The registered ability category instance on success, `null` on failure. */ function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_Category { if ( ! did_action( 'wp_abilities_api_categories_init' ) ) { _doing_it_wrong( - __METHOD__, + __FUNCTION__, sprintf( - /* translators: 1: abilities_api_categories_init, 2: ability category slug. */ + /* translators: 1: wp_abilities_api_categories_init, 2: ability category slug. */ __( 'Ability categories must be registered on the %1$s action. The ability category %2$s was not registered.' ), 'wp_abilities_api_categories_init', '' . esc_html( $slug ) . '' @@ -187,12 +483,24 @@ function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_ /** * Unregisters an ability category. * + * Removes a previously registered ability category from the global registry. Use this to + * disable ability categories that are no longer needed. + * + * Can be called at any time after the ability category has been registered. + * + * Example: + * + * if ( wp_has_ability_category( 'deprecated-category' ) ) { + * wp_unregister_ability_category( 'deprecated-category' ); + * } + * * @since 6.9.0 * * @see WP_Ability_Categories_Registry::unregister() + * @see wp_register_ability_category() * - * @param string $slug The slug of the registered ability category. - * @return WP_Ability_Category|null The unregistered ability category instance on success, null on failure. + * @param string $slug The slug of the ability category to unregister. + * @return WP_Ability_Category|null The unregistered ability category instance on success, `null` on failure. */ function wp_unregister_ability_category( string $slug ): ?WP_Ability_Category { $registry = WP_Ability_Categories_Registry::get_instance(); @@ -206,12 +514,25 @@ function wp_unregister_ability_category( string $slug ): ?WP_Ability_Category { /** * Checks if an ability category is registered. * + * Use this for conditional logic and feature detection before attempting to + * retrieve or use an ability category. + * + * Example: + * + * // Displays different UI based on available ability categories. + * if ( wp_has_ability_category( 'premium-features' ) ) { + * echo 'Premium Features Available'; + * } else { + * echo 'Standard Features'; + * } + * * @since 6.9.0 * * @see WP_Ability_Categories_Registry::is_registered() + * @see wp_get_ability_category() * - * @param string $slug The slug of the ability category. - * @return bool True if the ability category is registered, false otherwise. + * @param string $slug The slug of the ability category to check. + * @return bool `true` if the ability category is registered, `false` otherwise. */ function wp_has_ability_category( string $slug ): bool { $registry = WP_Ability_Categories_Registry::get_instance(); @@ -225,12 +546,25 @@ function wp_has_ability_category( string $slug ): bool { /** * Retrieves a registered ability category. * + * Returns the ability category instance for inspection or use. The instance provides access + * to the ability category's configuration and metadata. + * + * Example: + * + * // Prints information about a registered ability category. + * $ability_category = wp_get_ability_category( 'content-management' ); + * if ( $ability_category ) { + * echo $ability_category->get_label() . ': ' . $ability_category->get_description(); + * } + * * @since 6.9.0 * * @see WP_Ability_Categories_Registry::get_registered() + * @see wp_has_ability_category() + * @see wp_get_ability_categories() * - * @param string $slug The slug of the registered ability category. - * @return WP_Ability_Category|null The registered ability category instance, or null if it is not registered. + * @param string $slug The slug of the ability category. + * @return WP_Ability_Category|null The ability category instance, or `null` if not registered. */ function wp_get_ability_category( string $slug ): ?WP_Ability_Category { $registry = WP_Ability_Categories_Registry::get_instance(); @@ -244,11 +578,24 @@ function wp_get_ability_category( string $slug ): ?WP_Ability_Category { /** * Retrieves all registered ability categories. * + * Returns an array of all ability category instances currently registered in the system. + * Use this for discovery, debugging, or building administrative interfaces. + * + * Example: + * + * // Prints information about all available ability categories. + * $ability_categories = wp_get_ability_categories(); + * foreach ( $ability_categories as $ability_category ) { + * echo $ability_category->get_label() . ': ' . $ability_category->get_description() . "\n"; + * } + * * @since 6.9.0 * * @see WP_Ability_Categories_Registry::get_all_registered() + * @see wp_get_ability_category() * - * @return WP_Ability_Category[] The array of registered ability categories. + * @return WP_Ability_Category[] An array of registered ability category instances. Returns an empty array + * if no ability categories are registered or if the registry is unavailable. */ function wp_get_ability_categories(): array { $registry = WP_Ability_Categories_Registry::get_instance(); From 3f31cb5e4eec4b2cdd9e3c6313b128a99e7bd023 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Mon, 27 Oct 2025 12:45:59 +0100 Subject: [PATCH 10/11] Address feedback from review --- includes/abilities-api.php | 2 +- .../class-wp-abilities-registry.php | 12 +++++----- .../class-wp-ability-categories-registry.php | 6 +++-- .../class-wp-ability-category.php | 4 ++-- includes/abilities-api/class-wp-ability.php | 22 +++++++++---------- 5 files changed, 25 insertions(+), 21 deletions(-) diff --git a/includes/abilities-api.php b/includes/abilities-api.php index 4de55873..05e4bb38 100644 --- a/includes/abilities-api.php +++ b/includes/abilities-api.php @@ -274,7 +274,7 @@ function wp_register_ability( string $name, array $args ): ?WP_Ability { __FUNCTION__, sprintf( /* translators: 1: wp_abilities_api_init, 2: string value of the ability name. */ - esc_html__( 'Abilities must be registered on the %1$s action. The ability %2$s was not registered.' ), + __( 'Abilities must be registered on the %1$s action. The ability %2$s was not registered.' ), 'wp_abilities_api_init', '' . esc_html( $name ) . '' ), diff --git a/includes/abilities-api/class-wp-abilities-registry.php b/includes/abilities-api/class-wp-abilities-registry.php index 848847d8..398d28c7 100644 --- a/includes/abilities-api/class-wp-abilities-registry.php +++ b/includes/abilities-api/class-wp-abilities-registry.php @@ -61,7 +61,7 @@ final class WP_Abilities_Registry { * @type array $meta { * Optional. Additional metadata for the ability. * - * @type array $annotations Optional. Annotation metadata for the ability. + * @type array $annotations Optional. Annotation metadata for the ability. * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. * } * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability. @@ -236,14 +236,14 @@ public function is_registered( string $name ): bool { * @see wp_get_ability() * * @param string $name The name of the registered ability, with its namespace. - * @return ?WP_Ability The registered ability instance, or null if it is not registered. + * @return WP_Ability|null The registered ability instance, or null if it is not registered. */ public function get_registered( string $name ): ?WP_Ability { if ( ! $this->is_registered( $name ) ) { _doing_it_wrong( __METHOD__, /* translators: %s: Ability name. */ - sprintf( esc_html__( 'Ability "%s" not found.' ), esc_attr( $name ) ), + sprintf( __( 'Ability "%s" not found.' ), esc_html( $name ) ), '6.9.0' ); return null; @@ -265,7 +265,9 @@ public static function get_instance(): ?self { _doing_it_wrong( __METHOD__, sprintf( - __( 'Ability API should not be initialized before the init action has fired' ) + // translators: %s: init action. + __( 'Ability API should not be initialized before the %s action has fired.' ), + 'init' ), '6.9.0' ); @@ -314,6 +316,6 @@ public function __wakeup(): void { * This is a security hardening measure to prevent serialization of the registry. */ public function __sleep(): array { - throw new LogicException( __CLASS__ . ' should never be serialized' ); + throw new LogicException( __CLASS__ . ' should never be serialized.' ); } } diff --git a/includes/abilities-api/class-wp-ability-categories-registry.php b/includes/abilities-api/class-wp-ability-categories-registry.php index 6a372c6d..00e6ad8e 100644 --- a/includes/abilities-api/class-wp-ability-categories-registry.php +++ b/includes/abilities-api/class-wp-ability-categories-registry.php @@ -205,7 +205,9 @@ public static function get_instance(): ?self { _doing_it_wrong( __METHOD__, sprintf( - __( 'Ability API should not be initialized before the init action has fired' ) + // translators: %s: init action. + __( 'Ability API should not be initialized before the %s action has fired' ), + 'init' ), '6.9.0' ); @@ -249,6 +251,6 @@ public function __wakeup(): void { * This is a security hardening measure to prevent serialization of the registry. */ public function __sleep(): array { - throw new LogicException( __CLASS__ . ' should never be serialized' ); + throw new LogicException( __CLASS__ . ' should never be serialized.' ); } } diff --git a/includes/abilities-api/class-wp-ability-category.php b/includes/abilities-api/class-wp-ability-category.php index df6c8970..957ce5ef 100644 --- a/includes/abilities-api/class-wp-ability-category.php +++ b/includes/abilities-api/class-wp-ability-category.php @@ -75,7 +75,7 @@ final class WP_Ability_Category { public function __construct( string $slug, array $args ) { if ( empty( $slug ) ) { throw new InvalidArgumentException( - esc_html__( 'The ability category slug cannot be empty.' ) + __( 'The ability category slug cannot be empty.' ) ); } @@ -211,6 +211,6 @@ public function __wakeup(): void { * This is a security hardening measure to prevent serialization of the ability category. */ public function __sleep(): array { - throw new LogicException( __CLASS__ . ' should never be serialized' ); + throw new LogicException( __CLASS__ . ' should never be serialized.' ); } } diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index e51ef5ef..4aa9f4c6 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -33,7 +33,7 @@ class WP_Ability { * They are not guaranteed to provide a faithful description of ability behavior. * * @since 6.9.0 - * @var array + * @var array */ protected static $default_annotations = array( // If true, the ability does not modify its environment. @@ -150,7 +150,7 @@ class WP_Ability { * @type array $meta { * Optional. Additional metadata for the ability. * - * @type array $annotations Optional. Annotation metadata for the ability. + * @type array $annotations Optional. Annotation metadata for the ability. * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. * } * } @@ -169,7 +169,7 @@ public function __construct( string $name, array $args ) { __( 'Property "%1$s" is not a valid property for ability "%2$s". Please check the %3$s class for allowed properties.' ), '' . esc_html( $property_name ) . '', '' . esc_html( $this->name ) . '', - '' . self::class . '' + '' . __CLASS__ . '' ), '6.9.0' ); @@ -205,7 +205,7 @@ public function __construct( string $name, array $args ) { * @type array $meta { * Optional. Additional metadata for the ability. * - * @type array $annotations Optional. Annotation metadata for the ability. + * @type array $annotations Optional. Annotation metadata for the ability. * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. * } * } @@ -224,7 +224,7 @@ public function __construct( string $name, array $args ) { * @type array $meta { * Additional metadata for the ability. * - * @type array $annotations Optional. Annotation metadata for the ability. + * @type array $annotations Optional. Annotation metadata for the ability. * @type bool $show_in_rest Whether to expose this ability in the REST API. Default false. * } * } @@ -445,7 +445,7 @@ public function validate_input( $input = null ) { sprintf( /* translators: %s ability name. */ __( 'Ability "%s" does not define an input schema required to validate the provided input.' ), - $this->name + esc_html( $this->name ) ) ); } @@ -457,7 +457,7 @@ public function validate_input( $input = null ) { sprintf( /* translators: %1$s ability name, %2$s error message. */ __( 'Ability "%1$s" has invalid input. Reason: %2$s' ), - $this->name, + esc_html( $this->name ), $valid_input->get_error_message() ) ); @@ -514,7 +514,7 @@ protected function do_execute( $input = null ) { return new WP_Error( 'ability_invalid_execute_callback', /* translators: %s ability name. */ - sprintf( __( 'Ability "%s" does not have a valid execute callback.' ), $this->name ) + sprintf( __( 'Ability "%s" does not have a valid execute callback.' ), esc_html( $this->name ) ) ); } @@ -542,7 +542,7 @@ protected function validate_output( $output ) { sprintf( /* translators: %1$s ability name, %2$s error message. */ __( 'Ability "%1$s" has invalid output. Reason: %2$s' ), - $this->name, + esc_html( $this->name ), $valid_output->get_error_message() ) ); @@ -581,7 +581,7 @@ public function execute( $input = null ) { return new WP_Error( 'ability_invalid_permissions', /* translators: %s ability name. */ - sprintf( __( 'Ability "%s" does not have necessary permission.' ), $this->name ) + sprintf( __( 'Ability "%s" does not have necessary permission.' ), esc_html( $this->name ) ) ); } @@ -638,6 +638,6 @@ public function __wakeup(): void { * This is a security hardening measure to prevent serialization of the ability. */ public function __sleep(): array { - throw new LogicException( __CLASS__ . ' should never be serialized' ); + throw new LogicException( __CLASS__ . ' should never be serialized.' ); } } From a0355d9feb7cedcf5eed8d519fe08ce11ac0f50a Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Mon, 27 Oct 2025 12:59:20 +0100 Subject: [PATCH 11/11] Fix reported error by PHPCS --- includes/abilities-api/class-wp-ability.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index 4aa9f4c6..b890d927 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -33,7 +33,7 @@ class WP_Ability { * They are not guaranteed to provide a faithful description of ability behavior. * * @since 6.9.0 - * @var array + * @var array */ protected static $default_annotations = array( // If true, the ability does not modify its environment.