diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index f898ac0084f06..6672b0c6dbaa6 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -24,18 +24,23 @@ * 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`, `input_schema`, `output_schema`, `execute_callback`, + * `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, + * meta?: array{ + * annotations?: array, + * show_in_rest?: bool, + * ..., + * }, * ability_class?: class-string<\WP_Ability>, * ... * } $args @@ -98,3 +103,68 @@ function wp_get_ability( string $name ): ?WP_Ability { function wp_get_abilities(): array { return WP_Abilities_Registry::get_instance()->get_all_registered(); } + +/** + * Registers a new ability category. + * + * @since 0.3.0 + * + * @see WP_Abilities_Category_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. + * + * @phpstan-param array{ + * label: string, + * description: string, + * meta?: array, + * ... + * } $args + */ +function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_Category { + return WP_Abilities_Category_Registry::get_instance()->register( $slug, $args ); +} + +/** + * Unregisters an ability category. + * + * @since 0.3.0 + * + * @see WP_Abilities_Category_Registry::unregister() + * + * @param string $slug The slug of the registered category. + * @return ?\WP_Ability_Category The unregistered 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 ); +} + +/** + * Retrieves a registered ability category. + * + * @since 0.3.0 + * + * @see WP_Abilities_Category_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. + */ +function wp_get_ability_category( string $slug ): ?WP_Ability_Category { + return WP_Abilities_Category_Registry::get_instance()->get_registered( $slug ); +} + +/** + * Retrieves all registered ability categories. + * + * @since 0.3.0 + * + * @see WP_Abilities_Category_Registry::get_all_registered() + * + * @return \WP_Ability_Category[] The array of registered categories. + */ +function wp_get_ability_categories(): array { + return WP_Abilities_Category_Registry::get_instance()->get_all_registered(); +} diff --git a/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php new file mode 100644 index 0000000000000..045a1df64407e --- /dev/null +++ b/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php @@ -0,0 +1,247 @@ + $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/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index 3acf8a14ad6dd..d5f36999b384b 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -53,11 +53,16 @@ final class WP_Abilities_Registry { * @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, + * meta?: array{ + * annotations?: array, + * show_in_rest?: bool, + * ... + * }, * ability_class?: class-string<\WP_Ability>, * ... * } $args @@ -94,6 +99,24 @@ public function register( string $name, array $args ): ?WP_Ability { */ $args = apply_filters( 'register_ability_args', $args, $name ); + // Validate category exists if provided (will be validated as required in WP_Ability). + if ( isset( $args['category'] ) ) { + $category_registry = WP_Abilities_Category_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 ) + ), + '0.3.0' + ); + return null; + } + } + // The class is only used to instantiate the ability, and is not a property of the ability itself. if ( isset( $args['ability_class'] ) && ! is_a( $args['ability_class'], WP_Ability::class, true ) ) { _doing_it_wrong( @@ -218,6 +241,10 @@ public static function get_instance(): self { if ( null === self::$instance ) { self::$instance = new self(); + // Ensure category registry is initialized first to allow categories to be registered + // before abilities that depend on them. + WP_Abilities_Category_Registry::get_instance(); + /** * Fires when preparing abilities registry. * diff --git a/src/wp-includes/abilities-api/class-wp-ability-category.php b/src/wp-includes/abilities-api/class-wp-ability-category.php new file mode 100644 index 0000000000000..734b436fab66c --- /dev/null +++ b/src/wp-includes/abilities-api/class-wp-ability-category.php @@ -0,0 +1,203 @@ + + */ + protected $meta = array(); + + /** + * Constructor. + * + * Do not use this constructor directly. Instead, use the `wp_register_ability_category()` function. + * + * @access private + * + * @since 0.3.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. + */ + public function __construct( string $slug, array $args ) { + if ( empty( $slug ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The category slug cannot be empty.' ) + ); + } + + $this->slug = $slug; + + $properties = $this->prepare_properties( $args ); + + foreach ( $properties as $property_name => $property_value ) { + if ( ! property_exists( $this, $property_name ) ) { + _doing_it_wrong( + __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.' ), + '' . esc_html( $property_name ) . '', + '' . esc_html( $this->slug ) . '', + '' . esc_html( self::class ) . '' + ), + '0.3.0' + ); + continue; + } + + $this->$property_name = $property_value; + } + } + + /** + * Prepares and validates the properties used to instantiate the category. + * + * @since 0.3.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. + * + * @phpstan-return array{ + * label: string, + * description: string, + * meta?: array, + * ..., + * } + */ + 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.' ) + ); + } + + if ( empty( $args['description'] ) || ! is_string( $args['description'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The 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.' ) + ); + } + + return $args; + } + + /** + * Retrieves the slug of the category. + * + * @since 0.3.0 + * + * @return string The category slug. + */ + public function get_slug(): string { + return $this->slug; + } + + /** + * Retrieves the human-readable label for the category. + * + * @since 0.3.0 + * + * @return string The human-readable category label. + */ + public function get_label(): string { + return $this->label; + } + + /** + * Retrieves the detailed description for the category. + * + * @since 0.3.0 + * + * @return string The detailed description for the category. + */ + public function get_description(): string { + return $this->description; + } + + /** + * Retrieves the metadata for the category. + * + * @since 0.3.0 + * + * @return array The metadata for the category. + */ + public function get_meta(): array { + return $this->meta; + } + + /** + * 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. + */ + public function __wakeup(): void { + throw new \LogicException( self::class . ' must not be unserialized.' ); + } + + /** + * Serialization 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. + */ + public function __sleep(): array { + throw new \LogicException( self::class . ' must not be serialized.' ); + } +} diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 78ac5c3ea5e01..e31ce5ba0b315 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -20,6 +20,38 @@ */ class WP_Ability { + /** + * The default value for the `show_in_rest` meta. + * + * @since 0.3.0 + * @var bool + */ + protected const DEFAULT_SHOW_IN_REST = false; + + /** + * The default ability annotations. + * They are not guaranteed to provide a faithful description of ability behavior. + * + * @since 0.3.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, + /* + * If true, the ability may perform destructive updates to its environment. + * If false, the ability performs only additive updates. + */ + 'destructive' => true, + /* + * If true, calling the ability repeatedly with the same arguments will have no additional effect + * on its environment. + */ + 'idempotent' => false, + ); + /** * The name of the ability, with its namespace. * Example: `my-plugin/my-ability`. @@ -83,7 +115,15 @@ class WP_Ability { * @since 0.1.0 * @var array */ - protected $meta = array(); + protected $meta; + + /** + * The ability category (required). + * + * @since 0.3.0 + * @var string + */ + protected $category; /** * Constructor. @@ -97,9 +137,9 @@ class WP_Ability { * @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`, `input_schema`, `output_schema`, - * `execute_callback`, `permission_callback`, and `meta`. + * @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` */ public function __construct( string $name, array $args ) { $this->name = $name; @@ -143,11 +183,16 @@ public function __construct( string $name, array $args ) { * @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, + * meta?: array{ + * annotations?: array, + * show_in_rest?: bool, + * ... + * }, * ..., * } $args */ @@ -165,6 +210,12 @@ protected function prepare_properties( array $args ): array { ); } + if ( empty( $args['category'] ) || ! is_string( $args['category'] ) ) { + throw new \InvalidArgumentException( + esc_html__( '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.' ) @@ -196,6 +247,31 @@ protected function prepare_properties( array $args ): 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.' ) + ); + } + + 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.' ) + ); + } + + // Set defaults for optional meta. + $args['meta'] = wp_parse_args( + $args['meta'] ?? array(), + array( + 'annotations' => static::$default_annotations, + 'show_in_rest' => self::DEFAULT_SHOW_IN_REST, + ) + ); + $args['meta']['annotations'] = wp_parse_args( + $args['meta']['annotations'], + static::$default_annotations + ); + return $args; } @@ -266,6 +342,30 @@ 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 + * + * @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`. + * @return mixed The value of the metadata item, or the default value if not found. + */ + public function get_meta_item( string $key, $default_value = null ) { + return array_key_exists( $key, $this->meta ) ? $this->meta[ $key ] : $default_value; + } + /** * Validates input data against the input schema. * @@ -307,6 +407,24 @@ protected function validate_input( $input = null ) { return true; } + /** + * Invokes a callable, ensuring the input is passed through only if the input schema is defined. + * + * @since 0.3.0 + * + * @param callable $callback The callable to invoke. + * @param mixed $input Optional. The input data for the ability. Default `null`. + * @return mixed The result of the callable execution. + */ + protected function invoke_callback( callable $callback, $input = null ) { + $args = array(); + if ( ! empty( $this->get_input_schema() ) ) { + $args[] = $input; + } + + return $callback( ...$args ); + } + /** * Checks whether the ability has the necessary permissions. * @@ -323,11 +441,7 @@ public function check_permissions( $input = null ) { return $is_valid; } - if ( empty( $this->get_input_schema() ) ) { - return call_user_func( $this->permission_callback ); - } - - return call_user_func( $this->permission_callback, $input ); + return $this->invoke_callback( $this->permission_callback, $input ); } /** @@ -365,11 +479,7 @@ protected function do_execute( $input = null ) { ); } - if ( empty( $this->get_input_schema() ) ) { - return call_user_func( $this->execute_callback ); - } - - return call_user_func( $this->execute_callback, $input ); + return $this->invoke_callback( $this->execute_callback, $input ); } /** diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php index 1cd0f1cac0ce4..54e393f458e64 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php @@ -94,7 +94,25 @@ public function register_routes(): void { * @return \WP_REST_Response Response object on success. */ public function get_items( $request ) { - $abilities = wp_get_abilities(); + $abilities = array_filter( + wp_get_abilities(), + static function ( $ability ) { + return $ability->get_meta_item( 'show_in_rest' ); + } + ); + + // Filter by category if specified. + $category = $request->get_param( 'category' ); + if ( ! empty( $category ) ) { + $abilities = array_filter( + $abilities, + static function ( $ability ) use ( $category ) { + return $ability->get_category() === $category; + } + ); + // Reset array keys after filtering. + $abilities = array_values( $abilities ); + } // Handle pagination with explicit defaults. $params = $request->get_params(); @@ -105,7 +123,7 @@ public function get_items( $request ) { $total_abilities = count( $abilities ); $max_pages = ceil( $total_abilities / $per_page ); - if ( $request->is_method( 'HEAD' ) ) { + if ( $request->get_method() === 'HEAD' ) { $response = new \WP_REST_Response( array() ); } else { $abilities = array_slice( $abilities, $offset, $per_page ); @@ -150,8 +168,7 @@ public function get_items( $request ) { */ public function get_item( $request ) { $ability = wp_get_ability( $request->get_param( 'name' ) ); - - if ( ! $ability ) { + if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) { return new \WP_Error( 'rest_ability_not_found', __( 'Ability not found.' ), @@ -189,6 +206,7 @@ public function prepare_item_for_response( $ability, $request ) { 'name' => $ability->get_name(), 'label' => $ability->get_label(), 'description' => $ability->get_description(), + 'category' => $ability->get_category(), 'input_schema' => $ability->get_input_schema(), 'output_schema' => $ability->get_output_schema(), 'meta' => $ability->get_meta(), @@ -252,6 +270,12 @@ public function get_item_schema(): array { 'context' => array( 'view', 'edit' ), 'readonly' => true, ), + 'category' => array( + 'description' => __( 'Category this ability belongs to.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), 'input_schema' => array( 'description' => __( 'JSON Schema for the ability input.' ), 'type' => 'object', @@ -271,7 +295,7 @@ public function get_item_schema(): array { 'readonly' => true, ), ), - 'required' => array( 'name', 'label', 'description' ), + 'required' => array( 'name', 'label', 'meta', 'description', 'category', 'input_schema', 'output_schema' ), ); return $this->add_additional_fields_schema( $schema ); @@ -304,6 +328,12 @@ public function get_collection_params(): array { 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), + 'category' => array( + 'description' => __( 'Limit results to abilities in specific category.' ), + 'type' => 'string', + 'sanitize_callback' => 'sanitize_key', + 'validate_callback' => 'rest_validate_request_arg', + ), ); } } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php index a54003a5b8743..5fdbfea4a65e4 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php @@ -54,8 +54,8 @@ public function register_routes(): void { ), ), - // TODO: We register ALLMETHODS because at route registration time, we don't know - // which abilities exist or their types (resource vs tool). This is due to WordPress + // TODO: We register ALLMETHODS because at route registration time, we don't know which abilities + // exist or their annotations (`destructive`, `idempotent`, `readonly`). This is due to WordPress // load order - routes are registered early, before plugins have registered their abilities. // This approach works but could be improved with lazy route registration or a different // architecture that allows type-specific routes after abilities are registered. @@ -90,23 +90,23 @@ public function run_ability_with_method_check( $request ) { ); } - // Check if the HTTP method matches the ability type. - $meta = $ability->get_meta(); - $type = isset( $meta['type'] ) ? $meta['type'] : 'tool'; - $method = $request->get_method(); + // 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 ( 'resource' === $type && 'GET' !== $method ) { + if ( $is_readonly && 'GET' !== $method ) { return new \WP_Error( 'rest_ability_invalid_method', - __( 'Resource abilities require GET method.' ), + __( 'Read-only abilities require GET method.' ), array( 'status' => 405 ) ); } - if ( 'tool' === $type && 'POST' !== $method ) { + if ( ! $is_readonly && 'POST' !== $method ) { return new \WP_Error( 'rest_ability_invalid_method', - __( 'Tool abilities require POST method.' ), + __( 'Abilities that perform updates require POST method.' ), array( 'status' => 405 ) ); } @@ -154,7 +154,7 @@ public function run_ability( $request ) { */ public function run_ability_permissions_check( $request ) { $ability = wp_get_ability( $request->get_param( 'name' ) ); - if ( ! $ability ) { + if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) { return new \WP_Error( 'rest_ability_not_found', __( 'Ability not found.' ), diff --git a/src/wp-settings.php b/src/wp-settings.php index c263813464e37..781bdb88cc118 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -268,6 +268,8 @@ require ABSPATH . WPINC . '/html-api/class-wp-html-stack-event.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor-state.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor.php'; +require ABSPATH . WPINC . '/abilities-api/class-wp-ability-category.php'; +require ABSPATH . WPINC . '/abilities-api/class-wp-abilities-category-registry.php'; require ABSPATH . WPINC . '/abilities-api/class-wp-ability.php'; require ABSPATH . WPINC . '/abilities-api/class-wp-abilities-registry.php'; require ABSPATH . WPINC . '/abilities-api.php'; diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php index 94f46863e3394..5700093368723 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -29,9 +29,29 @@ public function set_up(): void { 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.', + ) + ); + } + } + ); + + // 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.', + 'category' => 'math', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -60,7 +80,7 @@ public function set_up(): void { return true; }, 'meta' => array( - 'category' => 'math', + 'foo' => 'bar', ), ); } @@ -73,6 +93,12 @@ public function tear_down(): void { remove_all_filters( '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' ); + } + parent::tear_down(); } @@ -266,6 +292,22 @@ public function test_register_incorrect_output_schema_type() { $this->assertNull( $result ); } + + /** + * Should reject ability registration with invalid `annotations` type. + * + * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_annotations_type() { + self::$test_ability_args['meta']['annotations'] = false; + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + $this->assertNull( $result ); + } + /** * Should reject ability registration with invalid meta type. * @@ -281,6 +323,21 @@ public function test_register_invalid_meta_type() { $this->assertNull( $result ); } + /** + * Should reject ability registration with invalid show in REST type. + * + * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_show_in_rest_type() { + self::$test_ability_args['meta']['show_in_rest'] = 5; + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + $this->assertNull( $result ); + } + /** * Should reject registration for already registered ability. * diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php index 961a40f8fcd90..17c16e7b60f1b 100644 --- a/tests/phpunit/tests/abilities-api/wpAbility.php +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -18,23 +18,239 @@ 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 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.', + 'category' => 'math', 'output_schema' => array( 'type' => 'number', 'description' => 'The result of performing a math operation.', 'required' => true, ), + 'execute_callback' => static function (): int { + return 0; + }, 'permission_callback' => static function (): bool { return true; }, 'meta' => array( - 'category' => 'math', + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + ), + ), + ); + } + + /** + * 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' ); + } + + parent::tear_down(); + } + + /* + * Tests that getting non-existing metadata item returns default value. + */ + public function test_meta_get_non_existing_item_returns_default() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertNull( + $ability->get_meta_item( 'non_existing' ), + 'Non-existing metadata item should return null.' + ); + } + + /** + * Tests that getting non-existing metadata item with custom default returns that default. + */ + public function test_meta_get_non_existing_item_with_custom_default() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertSame( + 'default_value', + $ability->get_meta_item( 'non_existing', 'default_value' ), + 'Non-existing metadata item should return custom default value.' + ); + } + + /** + * Tests getting all annotations when selective overrides are applied. + */ + public function test_get_merged_annotations_from_meta() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertEquals( + array_merge( + self::$test_ability_properties['meta']['annotations'], + array( + 'instructions' => '', + 'idempotent' => false, + ) + ), + $ability->get_meta_item( 'annotations' ) + ); + } + + /** + * Tests getting default annotations when not provided. + */ + public function test_get_default_annotations_from_meta() { + $args = self::$test_ability_properties; + unset( $args['meta']['annotations'] ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertSame( + array( + 'instructions' => '', + 'readonly' => false, + 'destructive' => true, + 'idempotent' => false, ), + $ability->get_meta_item( 'annotations' ) ); } + /** + * Tests getting all annotations when values overridden. + */ + public function test_get_overridden_annotations_from_meta() { + $annotations = array( + 'instructions' => 'Enjoy responsibly.', + 'readonly' => true, + 'destructive' => false, + 'idempotent' => false, + ); + $args = array_merge( + self::$test_ability_properties, + array( + 'meta' => array( + 'annotations' => $annotations, + ), + ) + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertSame( $annotations, $ability->get_meta_item( 'annotations' ) ); + } + + /** + * Tests that invalid `annotations` value throws an exception. + */ + public function test_annotations_from_meta_throws_exception() { + $args = array_merge( + self::$test_ability_properties, + array( + 'meta' => array( + 'annotations' => 5, + ), + ) + ); + + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'The ability meta should provide a valid `annotations` array.' ); + + new WP_Ability( self::$test_ability_name, $args ); + } + + /** + * Tests that `show_in_rest` metadata defaults to false when not provided. + */ + public function test_meta_show_in_rest_defaults_to_false() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertFalse( + $ability->get_meta_item( 'show_in_rest' ), + '`show_in_rest` metadata should default to false.' + ); + } + + /** + * Tests that `show_in_rest` metadata can be set to true. + */ + public function test_meta_show_in_rest_can_be_set_to_true() { + $args = array_merge( + self::$test_ability_properties, + array( + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertTrue( + $ability->get_meta_item( 'show_in_rest' ), + '`show_in_rest` metadata should be true.' + ); + } + + /** + * Tests that `show_in_rest` can be set to false. + */ + public function test_show_in_rest_can_be_set_to_false() { + $args = array_merge( + self::$test_ability_properties, + array( + 'meta' => array( + 'show_in_rest' => false, + ), + ) + ); + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertFalse( + $ability->get_meta_item( 'show_in_rest' ), + '`show_in_rest` metadata should be false.' + ); + } + + /** + * Tests that invalid `show_in_rest` value throws an exception. + */ + public function test_show_in_rest_throws_exception() { + $args = array_merge( + self::$test_ability_properties, + array( + 'meta' => array( + 'show_in_rest' => 5, + ), + ) + ); + + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'The ability meta should provide a valid `show_in_rest` boolean.' ); + + new WP_Ability( self::$test_ability_name, $args ); + } + /** * Data provider for testing the execution of the ability. */ @@ -166,6 +382,74 @@ public function test_execute_input( $input_schema, $execute_callback, $input, $r $this->assertSame( $result, $ability->execute( $input ) ); } + /** + * A static method to be used as a callback in tests. + * + * @param string $input An input string. + * @return int The length of the input string. + */ + public static function my_static_execute_callback( string $input ): int { + return strlen( $input ); + } + + /** + * An instance method to be used as a callback in tests. + * + * @param string $input An input string. + * @return int The length of the input string. + */ + public function my_instance_execute_callback( string $input ): int { + return strlen( $input ); + } + + /** + * Data provider for testing different types of execute callbacks. + */ + public function data_execute_callback() { + return array( + 'function name string' => array( + 'strlen', + ), + 'closure' => array( + static function ( string $input ): int { + return strlen( $input ); + }, + ), + 'static class method string' => array( + 'Tests_Abilities_API_WpAbility::my_static_execute_callback', + ), + 'static class method array' => array( + array( 'Tests_Abilities_API_WpAbility', 'my_static_execute_callback' ), + ), + 'object method' => array( + array( $this, 'my_instance_execute_callback' ), + ), + ); + } + + /** + * Tests the execution of the ability with different types of callbacks. + * + * @dataProvider data_execute_callback + */ + public function test_execute_with_different_callbacks( $execute_callback ) { + $args = array_merge( + self::$test_ability_properties, + array( + 'input_schema' => array( + 'type' => 'string', + 'description' => 'Test input string.', + 'required' => true, + ), + 'execute_callback' => $execute_callback, + ) + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertSame( 6, $ability->execute( 'hello!' ) ); + } + /** * Tests the execution of the ability with no input. */ @@ -346,9 +630,6 @@ public function test_actions_not_fired_on_permission_failure() { 'permission_callback' => static function (): bool { return false; }, - 'execute_callback' => static function (): int { - return 42; - }, ) ); diff --git a/tests/phpunit/tests/abilities-api/wpAbilityCategory.php b/tests/phpunit/tests/abilities-api/wpAbilityCategory.php new file mode 100644 index 0000000000000..c82056c430526 --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpAbilityCategory.php @@ -0,0 +1,763 @@ + + */ + private $doing_it_wrong_log = array(); + + /** + * Set up before each test. + */ + public function set_up(): void { + parent::set_up(); + + $this->registry = WP_Abilities_Category_Registry::get_instance(); + $this->doing_it_wrong_log = array(); + + add_action( 'doing_it_wrong_run', array( $this, 'record_doing_it_wrong' ), 10, 3 ); + } + + /** + * Tear down after each test. + */ + 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() ); + } + + parent::tear_down(); + } + + /** + * Records `_doing_it_wrong` calls for later assertions. + * + * @param string $the_method Function name flagged by `_doing_it_wrong`. + * @param string $message Message supplied to `_doing_it_wrong`. + * @param string $version Version string supplied to `_doing_it_wrong`. + */ + public function record_doing_it_wrong( string $the_method, string $message, string $version ): void { + $this->doing_it_wrong_log[] = array( + 'function' => $the_method, + 'message' => $message, + 'version' => $version, + ); + } + + /** + * Asserts that `_doing_it_wrong` was triggered for the expected function. + * + * @param string $the_method Function name expected to trigger `_doing_it_wrong`. + * @param string|null $message_contains Optional. String that should be contained in the error message. + */ + private function assertDoingItWrongTriggered( string $the_method, ?string $message_contains = null ): void { + foreach ( $this->doing_it_wrong_log as $entry ) { + if ( $the_method === $entry['function'] ) { + // If message check is specified, verify it contains the expected text. + if ( null !== $message_contains && false === strpos( $entry['message'], $message_contains ) ) { + continue; + } + return; + } + } + + if ( null !== $message_contains ) { + $this->fail( + sprintf( + 'Failed asserting that _doing_it_wrong() was triggered for %s with message containing "%s".', + $the_method, + $message_contains + ) + ); + } else { + $this->fail( sprintf( 'Failed asserting that _doing_it_wrong() was triggered for %s.', $the_method ) ); + } + } + + /** + * 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. + */ + public function test_register_valid_category(): void { + $result = $this->register_category_during_hook( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( 'test-math', $result->get_slug() ); + $this->assertSame( 'Math', $result->get_label() ); + $this->assertSame( 'Mathematical operations.', $result->get_description() ); + } + + /** + * Test registering category with invalid slug format. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_register_category_invalid_slug_format(): void { + // Uppercase characters not allowed. + $result = $this->register_category_during_hook( + 'Test-Math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'slug must contain only lowercase' ); + } + + /** + * Test registering category with invalid slug - underscore. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_register_category_invalid_slug_underscore(): void { + $result = $this->register_category_during_hook( + 'test_math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'slug must contain only lowercase' ); + } + + /** + * Test registering category without label. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_register_category_missing_label(): void { + $result = $this->register_category_during_hook( + 'test-math', + array( + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + } + + /** + * Test registering category without description. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_register_category_missing_description(): void { + $result = $this->register_category_during_hook( + 'test-math', + array( + 'label' => 'Math', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + } + + /** + * Test registering category before abilities_api_categories_init hook. + * + * @expectedIncorrectUsage WP_Abilities_Category_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( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + // 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.', + ) + ); + }; + + 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 ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'already registered' ); + } + + /** + * Test unregistering existing category. + */ + public function test_unregister_existing_category(): void { + $this->register_category_during_hook( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $result = wp_unregister_ability_category( 'test-math' ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertFalse( $this->registry->is_registered( 'test-math' ) ); + } + + /** + * Test unregistering non-existent category. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::unregister + */ + public function test_unregister_nonexistent_category(): void { + $result = wp_unregister_ability_category( 'test-nonexistent' ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::unregister' ); + } + + /** + * Test retrieving existing category. + */ + public function test_get_existing_category(): void { + $this->register_category_during_hook( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $result = wp_get_ability_category( 'test-math' ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( 'test-math', $result->get_slug() ); + } + + /** + * Test retrieving non-existent category. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::get_registered + */ + public function test_get_nonexistent_category(): void { + $result = wp_get_ability_category( 'test-nonexistent' ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::get_registered' ); + } + + /** + * Test retrieving all registered categories. + */ + public function test_get_all_categories(): void { + $this->register_category_during_hook( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $this->register_category_during_hook( + 'test-system', + array( + 'label' => 'System', + 'description' => 'System operations.', + ) + ); + + $categories = wp_get_ability_categories(); + + $this->assertIsArray( $categories ); + $this->assertCount( 2, $categories ); + $this->assertArrayHasKey( 'test-math', $categories ); + $this->assertArrayHasKey( 'test-system', $categories ); + } + + /** + * Test category is_registered method. + */ + public function test_category_is_registered(): void { + $this->assertFalse( $this->registry->is_registered( 'test-math' ) ); + + $this->register_category_during_hook( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $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. + */ + public function test_category_with_special_characters(): void { + $result = $this->register_category_during_hook( + 'test-special', + array( + 'label' => 'Math & Science ', + 'description' => 'Operations with "quotes" and \'apostrophes\'.', + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( 'Math & Science ', $result->get_label() ); + $this->assertSame( 'Operations with "quotes" and \'apostrophes\'.', $result->get_description() ); + } + + /** + * Data provider for valid category slugs. + * + * @return array> + */ + public function valid_slug_provider(): array { + return array( + array( 'test-simple' ), + array( 'test-multiple-words' ), + array( 'test-with-numbers-123' ), + array( 'test-a' ), + array( 'test-123' ), + ); + } + + /** + * Test category slug validation with valid formats. + * + * @dataProvider valid_slug_provider + */ + public function test_category_slug_valid_formats( string $slug ): void { + $result = $this->register_category_during_hook( + $slug, + array( + 'label' => 'Test', + 'description' => 'Test description.', + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result, "Slug '{$slug}' should be valid" ); + } + + /** + * Data provider for invalid category slugs. + * + * @return array> + */ + public function invalid_slug_provider(): array { + return array( + array( 'Test-Uppercase' ), + array( 'test_underscore' ), + array( 'test.dot' ), + array( 'test/slash' ), + array( 'test space' ), + array( '-test-start-dash' ), + array( 'test-end-dash-' ), + array( 'test--double-dash' ), + ); + } + + /** + * Test category slug validation with invalid formats. + * + * @dataProvider invalid_slug_provider + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_category_slug_invalid_formats( string $slug ): void { + $result = $this->register_category_during_hook( + $slug, + array( + 'label' => 'Test', + 'description' => 'Test description.', + ) + ); + + $this->assertNull( $result, "Slug '{$slug}' should be invalid" ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + } + + /** + * Test registering category with non-string label. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_category_constructor_non_string_label(): void { + $result = $this->register_category_during_hook( + 'test-invalid', + array( + 'label' => 123, // Integer instead of string + 'description' => 'Valid description.', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + } + + /** + * Test registering category with empty label. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_category_constructor_empty_label(): void { + $result = $this->register_category_during_hook( + 'test-invalid', + array( + 'label' => '', + 'description' => 'Valid description.', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + } + + /** + * Test registering category with non-string description. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_category_constructor_non_string_description(): void { + $result = $this->register_category_during_hook( + 'test-invalid', + array( + 'label' => 'Valid Label', + 'description' => array( 'invalid' ), // Array instead of string + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + } + + /** + * Test registering category with empty description. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_category_constructor_empty_description(): void { + $result = $this->register_category_during_hook( + 'test-invalid', + array( + 'label' => 'Valid Label', + 'description' => '', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + } + + /** + * Test register_ability_category_args filter. + */ + public function test_register_category_args_filter(): void { + add_filter( + 'register_ability_category_args', + static function ( $args, $slug ) { + if ( 'test-filtered' === $slug ) { + $args['label'] = 'Filtered Label'; + $args['description'] = 'Filtered Description'; + } + return $args; + }, + 10, + 2 + ); + + $result = $this->register_category_during_hook( + 'test-filtered', + array( + 'label' => 'Original Label', + 'description' => 'Original Description.', + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( 'Filtered Label', $result->get_label() ); + $this->assertSame( 'Filtered Description', $result->get_description() ); + } + + /** + * Test that WP_Ability_Category cannot be unserialized. + */ + public function test_category_wakeup_throws_exception(): void { + $category = $this->register_category_during_hook( + 'test-serialize', + array( + 'label' => 'Test', + 'description' => 'Test description.', + ) + ); + + $this->expectException( \LogicException::class ); + $serialized = serialize( $category ); + unserialize( $serialized ); + } + + /** + * Test registering a category with valid meta. + */ + public function test_register_category_with_valid_meta(): void { + $meta = array( + 'icon' => 'dashicons-calculator', + 'priority' => 10, + 'custom' => array( 'key' => 'value' ), + ); + + $result = $this->register_category_during_hook( + 'test-meta', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + 'meta' => $meta, + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( 'test-meta', $result->get_slug() ); + $this->assertSame( $meta, $result->get_meta() ); + } + + /** + * Test registering a category with empty meta array. + */ + public function test_register_category_with_empty_meta(): void { + $result = $this->register_category_during_hook( + 'test-empty-meta', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + 'meta' => array(), + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( array(), $result->get_meta() ); + } + + /** + * Test registering a category without meta returns empty array. + */ + public function test_register_category_without_meta_returns_empty_array(): void { + $result = $this->register_category_during_hook( + 'test-no-meta', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( array(), $result->get_meta() ); + } + + /** + * Test registering a category with invalid meta (non-array). + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_register_category_with_invalid_meta(): void { + $result = $this->register_category_during_hook( + 'test-invalid-meta', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + 'meta' => 'invalid-string', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'valid `meta` array' ); + } + + /** + * Test registering a category with unknown property triggers _doing_it_wrong. + * + * @expectedIncorrectUsage WP_Ability_Category::__construct + */ + public function test_register_category_with_unknown_property(): void { + $result = $this->register_category_during_hook( + 'test-unknown-property', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + 'unknown_property' => 'some value', + ) + ); + + // Category should still be created. + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + // But _doing_it_wrong should be triggered. + $this->assertDoingItWrongTriggered( 'WP_Ability_Category::__construct', 'not a valid property' ); + } +} diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index a292a11896c8a..04a39a210ef5f 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -30,9 +30,29 @@ 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 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.', + 'category' => 'math', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -61,7 +81,11 @@ public function set_up(): void { return true; }, 'meta' => array( - 'category' => 'math', + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + ), + 'show_in_rest' => true, ), ); } @@ -78,6 +102,12 @@ 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' ); + } + parent::tear_down(); } @@ -126,13 +156,28 @@ public function test_register_valid_ability(): void { $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, + ) + ); + $expected_meta = array_merge( + self::$test_ability_args['meta'], + array( + 'annotations' => $expected_annotations, + 'show_in_rest' => true, + ) + ); + $this->assertInstanceOf( WP_Ability::class, $result ); $this->assertSame( self::$test_ability_name, $result->get_name() ); $this->assertSame( self::$test_ability_args['label'], $result->get_label() ); $this->assertSame( self::$test_ability_args['description'], $result->get_description() ); $this->assertSame( self::$test_ability_args['input_schema'], $result->get_input_schema() ); $this->assertSame( self::$test_ability_args['output_schema'], $result->get_output_schema() ); - $this->assertSame( self::$test_ability_args['meta'], $result->get_meta() ); + $this->assertEquals( $expected_meta, $result->get_meta() ); $this->assertTrue( $result->check_permissions( array( @@ -451,4 +496,28 @@ 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/phpunit/tests/rest-api/wpRestAbilitiesListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php index 319e26e0e8499..24093c4655847 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php @@ -50,6 +50,13 @@ 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' ); @@ -73,6 +80,15 @@ 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; @@ -80,16 +96,46 @@ public function tear_down(): void { parent::tear_down(); } + /** + * Register test categories for testing. + */ + public function register_test_categories(): void { + wp_register_ability_category( + 'math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations and calculations.', + ) + ); + + wp_register_ability_category( + 'system', + array( + 'label' => 'System', + 'description' => 'System information and operations.', + ) + ); + + wp_register_ability_category( + 'general', + array( + 'label' => 'General', + 'description' => 'General purpose abilities.', + ) + ); + } + /** * Register test abilities for testing. */ private function register_test_abilities(): void { - // Register a tool ability + // Register a regular ability. wp_register_ability( 'test/calculator', array( 'label' => 'Calculator', 'description' => 'Performs basic calculations', + 'category' => 'math', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -122,18 +168,18 @@ private function register_test_abilities(): void { return current_user_can( 'read' ); }, 'meta' => array( - 'type' => 'tool', - 'category' => 'math', + 'show_in_rest' => true, ), ) ); - // Register a resource ability + // Register a read-only ability. wp_register_ability( 'test/system-info', array( 'label' => 'System Info', 'description' => 'Returns system information', + 'category' => 'system', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -165,12 +211,29 @@ private function register_test_abilities(): void { return current_user_can( 'read' ); }, 'meta' => array( - 'type' => 'resource', - 'category' => 'system', + 'annotations' => array( + 'readonly' => true, + ), + 'category' => 'system', + 'show_in_rest' => true, ), ) ); + // Ability that does not show in REST. + wp_register_ability( + 'test/not-show-in-rest', + array( + 'label' => 'Hidden from REST', + 'description' => 'It does not show in REST.', + 'category' => 'general', + 'execute_callback' => static function (): int { + return 0; + }, + 'permission_callback' => '__return_true', + ) + ); + // Register multiple abilities for pagination testing for ( $i = 1; $i <= 60; $i++ ) { wp_register_ability( @@ -178,10 +241,14 @@ private function register_test_abilities(): void { array( 'label' => "Test Ability {$i}", 'description' => "Test ability number {$i}", + 'category' => 'general', 'execute_callback' => static function () use ( $i ) { return "Result from ability {$i}"; }, 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), ) ); } @@ -205,6 +272,7 @@ public function test_get_items(): void { $ability_names = wp_list_pluck( $data, 'name' ); $this->assertContains( 'test/calculator', $ability_names ); $this->assertContains( 'test/system-info', $ability_names ); + $this->assertNotContains( 'test/not-show-in-rest', $ability_names ); } /** @@ -223,7 +291,7 @@ public function test_get_item(): void { $this->assertArrayHasKey( 'input_schema', $data ); $this->assertArrayHasKey( 'output_schema', $data ); $this->assertArrayHasKey( 'meta', $data ); - $this->assertEquals( 'tool', $data['meta']['type'] ); + $this->assertTrue( $data['meta']['show_in_rest'] ); } /** @@ -241,6 +309,19 @@ public function test_get_item_not_found(): void { $this->assertEquals( 'rest_ability_not_found', $data['code'] ); } + /** + * Test getting an ability that does not show in REST returns 404. + */ + public function test_get_item_not_show_in_rest(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/not-show-in-rest' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 404, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( 'rest_ability_not_found', $data['code'] ); + } + /** * Test permission check for listing abilities. */ @@ -268,7 +349,7 @@ public function test_pagination_headers(): void { $this->assertArrayHasKey( 'X-WP-Total', $headers ); $this->assertArrayHasKey( 'X-WP-TotalPages', $headers ); - $total_abilities = count( wp_get_abilities() ); + $total_abilities = count( wp_get_abilities() ) - 1; // Exclude the one that doesn't show in REST. $this->assertEquals( $total_abilities, (int) $headers['X-WP-Total'] ); $this->assertEquals( ceil( $total_abilities / 10 ), (int) $headers['X-WP-TotalPages'] ); } @@ -419,12 +500,27 @@ public function test_get_schema(): void { $this->assertArrayHasKey( 'properties', $schema ); $properties = $schema['properties']; + + // Assert the count of properties to catch when new keys are added + $this->assertCount( 7, $properties, 'Schema should have exactly 7 properties. If this fails, update this test to include the new property.' ); + + // Check all expected properties exist $this->assertArrayHasKey( 'name', $properties ); $this->assertArrayHasKey( 'label', $properties ); $this->assertArrayHasKey( 'description', $properties ); $this->assertArrayHasKey( 'input_schema', $properties ); $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'] ); } /** @@ -437,10 +533,14 @@ public function test_ability_name_with_valid_special_characters(): void { array( 'label' => 'Test Hyphen Ability', 'description' => 'Test ability with hyphen', + 'category' => 'general', 'execute_callback' => static function ( $input ) { return array( 'success' => true ); }, 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), ) ); @@ -537,4 +637,64 @@ public function test_invalid_pagination_parameters( array $params ): void { $data = $response->get_data(); $this->assertIsArray( $data ); } + + /** + * Test filtering abilities by category. + */ + public function test_filter_by_category(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request->set_param( 'category', 'math' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertIsArray( $data ); + + // Should only have math category abilities + foreach ( $data as $ability ) { + $this->assertEquals( 'math', $ability['category'], 'All abilities should be in math category' ); + } + + // Should at least contain the calculator + $ability_names = wp_list_pluck( $data, 'name' ); + $this->assertContains( 'test/calculator', $ability_names ); + $this->assertNotContains( 'test/system-info', $ability_names, 'System info should not be in math category' ); + } + + /** + * Test filtering by non-existent category returns empty results. + */ + 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' ), + 'The nonexistent category should not be registered - test isolation may be broken' + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request->set_param( 'category', 'nonexistent' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $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/phpunit/tests/rest-api/wpRestAbilitiesRunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php index f7ae219539bf2..92693cc63159d 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php @@ -61,6 +61,13 @@ 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' ); $this->register_test_abilities(); @@ -81,22 +88,60 @@ 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; parent::tear_down(); } + /** + * Register test categories for testing. + */ + public function register_test_categories(): void { + wp_register_ability_category( + 'math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations and calculations.', + ) + ); + + wp_register_ability_category( + 'system', + array( + 'label' => 'System', + 'description' => 'System information and operations.', + ) + ); + + wp_register_ability_category( + 'general', + array( + 'label' => 'General', + 'description' => 'General purpose abilities.', + ) + ); + } + /** * Register test abilities for testing. */ private function register_test_abilities(): void { - // Tool ability (POST only) + // Regular ability (POST only). wp_register_ability( 'test/calculator', array( 'label' => 'Calculator', 'description' => 'Performs calculations', + 'category' => 'math', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -122,17 +167,18 @@ private function register_test_abilities(): void { return current_user_can( 'edit_posts' ); }, 'meta' => array( - 'type' => 'tool', + 'show_in_rest' => true, ), ) ); - // Resource ability (GET only) + // Read-only ability (GET method). wp_register_ability( 'test/user-info', array( 'label' => 'User Info', 'description' => 'Gets user information', + 'category' => 'system', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -164,7 +210,10 @@ private function register_test_abilities(): void { return is_user_logged_in(); }, 'meta' => array( - 'type' => 'resource', + 'annotations' => array( + 'readonly' => true, + ), + 'show_in_rest' => true, ), ) ); @@ -175,6 +224,7 @@ private function register_test_abilities(): void { array( 'label' => 'Restricted Action', 'description' => 'Requires specific input for permission', + 'category' => 'general', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -194,23 +244,38 @@ private function register_test_abilities(): void { return isset( $input['secret'] ) && 'valid_secret' === $input['secret']; }, 'meta' => array( - 'type' => 'tool', + 'show_in_rest' => true, ), ) ); + // Ability that does not show in REST. + wp_register_ability( + 'test/not-show-in-rest', + array( + 'label' => 'Hidden from REST', + 'description' => 'It does not show in REST.', + 'category' => 'general', + 'execute_callback' => static function (): int { + return 0; + }, + 'permission_callback' => '__return_true', + ) + ); + // Ability that returns null wp_register_ability( 'test/null-return', array( 'label' => 'Null Return', 'description' => 'Returns null', + 'category' => 'general', 'execute_callback' => static function () { return null; }, 'permission_callback' => '__return_true', 'meta' => array( - 'type' => 'tool', + 'show_in_rest' => true, ), ) ); @@ -221,12 +286,13 @@ private function register_test_abilities(): void { array( 'label' => 'Error Return', 'description' => 'Returns error', + 'category' => 'general', 'execute_callback' => static function () { return new \WP_Error( 'test_error', 'This is a test error' ); }, 'permission_callback' => '__return_true', 'meta' => array( - 'type' => 'tool', + 'show_in_rest' => true, ), ) ); @@ -237,6 +303,7 @@ private function register_test_abilities(): void { array( 'label' => 'Invalid Output', 'description' => 'Returns invalid output', + 'category' => 'general', 'output_schema' => array( 'type' => 'number', ), @@ -245,17 +312,18 @@ private function register_test_abilities(): void { }, 'permission_callback' => '__return_true', 'meta' => array( - 'type' => 'tool', + 'show_in_rest' => true, ), ) ); - // Resource ability for query params testing + // Read-only ability for query params testing. wp_register_ability( 'test/query-params', array( 'label' => 'Query Params Test', 'description' => 'Tests query parameter handling', + 'category' => 'general', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -268,16 +336,19 @@ private function register_test_abilities(): void { }, 'permission_callback' => '__return_true', 'meta' => array( - 'type' => 'resource', + 'annotations' => array( + 'readonly' => true, + ), + 'show_in_rest' => true, ), ) ); } /** - * Test executing a tool ability with POST. + * Test executing a regular ability with POST. */ - public function test_execute_tool_ability_post(): void { + public function test_execute_regular_ability_post(): void { $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/calculator/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( @@ -298,9 +369,9 @@ public function test_execute_tool_ability_post(): void { } /** - * Test executing a resource ability with GET. + * Test executing a read-only ability with GET. */ - public function test_execute_resource_ability_get(): void { + public function test_execute_readonly_ability_get(): void { $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/user-info/run' ); $request->set_query_params( array( @@ -318,20 +389,21 @@ public function test_execute_resource_ability_get(): void { } /** - * Test HTTP method validation for tool abilities. + * Test HTTP method validation for regular abilities. */ - public function test_tool_ability_requires_post(): void { + public function test_regular_ability_requires_post(): void { wp_register_ability( 'test/open-tool', array( 'label' => 'Open Tool', 'description' => 'Tool with no permission requirements', + 'category' => 'general', 'execute_callback' => static function () { return 'success'; }, 'permission_callback' => '__return_true', 'meta' => array( - 'type' => 'tool', + 'show_in_rest' => true, ), ) ); @@ -342,14 +414,14 @@ public function test_tool_ability_requires_post(): void { $this->assertSame( 405, $response->get_status() ); $data = $response->get_data(); $this->assertSame( 'rest_ability_invalid_method', $data['code'] ); - $this->assertSame( 'Tool abilities require POST method.', $data['message'] ); + $this->assertSame( 'Abilities that perform updates require POST method.', $data['message'] ); } /** - * Test HTTP method validation for resource abilities. + * Test HTTP method validation for read-only abilities. */ - public function test_resource_ability_requires_get(): void { - // Try POST on a resource ability (should fail) + 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->set_header( 'Content-Type', 'application/json' ); $request->set_body( wp_json_encode( array( 'user_id' => 1 ) ) ); @@ -359,7 +431,7 @@ public function test_resource_ability_requires_get(): void { $this->assertSame( 405, $response->get_status() ); $data = $response->get_data(); $this->assertSame( 'rest_ability_invalid_method', $data['code'] ); - $this->assertSame( 'Resource abilities require GET method.', $data['message'] ); + $this->assertSame( 'Read-only abilities require GET method.', $data['message'] ); } @@ -446,6 +518,21 @@ public function test_contextual_permission_check(): void { $this->assertEquals( 'Success: test data', $response->get_data() ); } + /** + * Test handling an ability that does not show in REST. + */ + 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->set_header( 'Content-Type', 'application/json' ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 404, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_ability_not_found', $data['code'] ); + $this->assertEquals( 'Ability not found.', $data['message'] ); + } + /** * Test handling of null is a valid return value. */ @@ -594,6 +681,7 @@ public function test_output_validation_failure_returns_error(): void { array( 'label' => 'Strict Output', 'description' => 'Ability with strict output schema', + 'category' => 'general', 'output_schema' => array( 'type' => 'object', 'properties' => array( @@ -609,7 +697,9 @@ public function test_output_validation_failure_returns_error(): void { return array( 'wrong_field' => 'value' ); }, 'permission_callback' => '__return_true', - 'meta' => array( 'type' => 'tool' ), + 'meta' => array( + 'show_in_rest' => true, + ), ) ); @@ -638,6 +728,7 @@ public function test_input_validation_failure_returns_error(): void { array( 'label' => 'Strict Input', 'description' => 'Ability with strict input schema', + 'category' => 'general', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -651,7 +742,9 @@ public function test_input_validation_failure_returns_error(): void { return array( 'status' => 'success' ); }, 'permission_callback' => '__return_true', - 'meta' => array( 'type' => 'tool' ), + 'meta' => array( + 'show_in_rest' => true, + ), ) ); @@ -673,30 +766,33 @@ public function test_input_validation_failure_returns_error(): void { } /** - * Test ability type not set defaults to tool. + * Test ability without annotations defaults to POST method. */ - public function test_ability_without_type_defaults_to_tool(): void { - // Register ability without type in meta. + public function test_ability_without_annotations_defaults_to_post_method(): void { + // Register ability without annotations. wp_register_ability( - 'test/no-type', + 'test/no-annotations', array( - 'label' => 'No Type', - 'description' => 'Ability without type', + 'label' => 'No Annotations', + 'description' => 'Ability without annotations.', + 'category' => 'general', 'execute_callback' => static function () { return array( 'executed' => true ); }, 'permission_callback' => '__return_true', - 'meta' => array(), // No type specified + 'meta' => array( + 'show_in_rest' => true, + ), ) ); - // Should require POST (default tool behavior) - $get_request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/no-type/run' ); + // Should require POST (default behavior). + $get_request = new WP_REST_Request( 'GET', '/wp/v2/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-type/run' ); + // Should work with POST. + $post_request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/no-annotations/run' ); $post_request->set_header( 'Content-Type', 'application/json' ); $post_response = $this->server->dispatch( $post_request ); @@ -704,44 +800,53 @@ public function test_ability_without_type_defaults_to_tool(): void { } /** - * Test edge case with empty input for both GET and POST. + * Test edge case with empty input for both GET and POST methods. */ public function test_empty_input_handling(): void { // Registers abilities for empty input testing. wp_register_ability( - 'test/resource-empty', + 'test/read-only-empty', array( - 'label' => 'Resource Empty', - 'description' => 'Resource with empty input', + 'label' => 'Read-only Empty', + 'description' => 'Read-only with empty input.', + 'category' => 'general', 'execute_callback' => static function () { return array( 'input_was_empty' => 0 === func_num_args() ); }, 'permission_callback' => '__return_true', - 'meta' => array( 'type' => 'resource' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + ), + 'show_in_rest' => true, + ), ) ); wp_register_ability( - 'test/tool-empty', + 'test/regular-empty', array( - 'label' => 'Tool Empty', - 'description' => 'Tool with empty input', + 'label' => 'Regular Empty', + 'description' => 'Regular with empty input.', + 'category' => 'general', 'execute_callback' => static function () { return array( 'input_was_empty' => 0 === func_num_args() ); }, 'permission_callback' => '__return_true', - 'meta' => array( 'type' => 'tool' ), + 'meta' => array( + 'show_in_rest' => true, + ), ) ); // Tests GET with no input parameter. - $get_request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/resource-empty/run' ); + $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/tool-empty/run' ); + $post_request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/regular-empty/run' ); $post_request->set_header( 'Content-Type', 'application/json' ); $post_request->set_body( '{}' ); // Empty JSON object @@ -796,6 +901,7 @@ public function test_php_type_strings_in_input(): void { array( 'label' => 'Echo', 'description' => 'Echoes input', + 'category' => 'general', 'input_schema' => array( 'type' => 'object', ), @@ -803,7 +909,9 @@ public function test_php_type_strings_in_input(): void { return array( 'echo' => $input ); }, 'permission_callback' => '__return_true', - 'meta' => array( 'type' => 'tool' ), + 'meta' => array( + 'show_in_rest' => true, + ), ) ); @@ -840,6 +948,7 @@ public function test_mixed_encoding_in_input(): void { array( 'label' => 'Echo Encoding', 'description' => 'Echoes input with encoding', + 'category' => 'general', 'input_schema' => array( 'type' => 'object', ), @@ -847,7 +956,9 @@ public function test_mixed_encoding_in_input(): void { return array( 'echo' => $input ); }, 'permission_callback' => '__return_true', - 'meta' => array( 'type' => 'tool' ), + 'meta' => array( + 'show_in_rest' => true, + ), ) ); @@ -903,22 +1014,25 @@ public function test_invalid_http_methods( string $method ): void { array( 'label' => 'Method Test', 'description' => 'Test ability for HTTP method validation', + 'category' => 'general', 'execute_callback' => static function () { return array( 'success' => true ); }, 'permission_callback' => '__return_true', // No permission requirements - 'meta' => array( 'type' => 'tool' ), + 'meta' => array( + 'show_in_rest' => true, + ), ) ); $request = new WP_REST_Request( $method, '/wp/v2/abilities/test/method-test/run' ); $response = $this->server->dispatch( $request ); - // Tool abilities should only accept POST, so these should return 405. + // Regular abilities should only accept POST, so these should return 405. $this->assertSame( 405, $response->get_status() ); $data = $response->get_data(); $this->assertSame( 'rest_ability_invalid_method', $data['code'] ); - $this->assertSame( 'Tool abilities require POST method.', $data['message'] ); + $this->assertSame( 'Abilities that perform updates require POST method.', $data['message'] ); } /** diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 3ecd55244cc56..486c68a1ab273 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12471,6 +12471,11 @@ mockedApiResponse.Schema = { "minimum": 1, "maximum": 100, "required": false + }, + "category": { + "description": "Limit results to abilities in specific category.", + "type": "string", + "required": false } } }