diff --git a/docs/1.intro.md b/docs/1.intro.md index c270a16..b8171b4 100644 --- a/docs/1.intro.md +++ b/docs/1.intro.md @@ -8,8 +8,9 @@ It acts as a central registry, making it easier for different parts of WordPress ## Core Concepts -- **Ability:** A distinct piece of functionality with a unique name following the `namespace/ability-name` pattern. Each ability has a human-readable name and description, input/output definitions (using JSON Schema), optional permissions, and an associated callback function for execution. Each registered Ability is an instance of the `WP_Ability` class. -- **Registry:** A central, singleton object (`WP_Abilities_Registry`) that holds all registered abilities. It provides methods for registering, unregistering, finding, and querying abilities. +- **Ability:** A distinct piece of functionality with a unique name following the `namespace/ability-name` pattern. Each ability has a human-readable name and description, input/output definitions (using JSON Schema), a category assignment, optional permissions, and an associated callback function for execution. Each registered Ability is an instance of the `WP_Ability` class. +- **Category:** A way to organize related abilities. Each ability must belong to exactly one category. Categories have a slug, label, and description. Each registered category is an instance of the `WP_Ability_Category` class. +- **Registry:** A central, singleton object (`WP_Abilities_Registry`) that holds all registered abilities. It provides methods for registering, unregistering, finding, and querying abilities. Similarly, `WP_Abilities_Category_Registry` manages all registered categories. - **Callback:** The PHP function or method executed when an ability is called via `WP_Ability::execute()`. - **Schema:** JSON Schema definitions for an ability's expected input (`input_schema`) and its returned output (`output_schema`). This allows for validation and helps agents understand how to use the ability. - **Permission Callback:** An optional function that determines if the current user can execute a specific ability. @@ -35,11 +36,22 @@ It acts as a central registry, making it easier for different parts of WordPress ## Registration Example ```php +// First, register a category, or use one of the existing categories. +add_action( 'abilities_api_categories_init', 'my_plugin_register_category'); +function my_plugin_register_category(){ + wp_register_ability_category( 'site-information', array( + 'label' => __( 'Site Information', 'my-plugin' ), + 'description' => __( 'Abilities that provide information about the WordPress site.', 'my-plugin' ), + )); +} + +// Then, register an ability in that category add_action( 'abilities_api_init', 'my_plugin_register_ability'); function my_plugin_register_ability(){ wp_register_ability( 'my-plugin/site-info', array( 'label' => __( 'Site Info', 'my-plugin' ), 'description' => __( 'Returns information about this WordPress site', 'my-plugin' ), + 'category' => 'site-information', 'input_schema' => array(), 'output_schema' => array( 'type' => 'object', diff --git a/docs/3.registering-abilities.md b/docs/3.registering-abilities.md index e1250b3..2d648a0 100644 --- a/docs/3.registering-abilities.md +++ b/docs/3.registering-abilities.md @@ -18,6 +18,7 @@ The `$args` array accepts the following keys: - `label` (`string`, **Required**): A human-readable name for the ability. Used for display purposes. Should be translatable. - `description` (`string`, **Required**): A detailed description of what the ability does, its purpose, and its parameters or return values. This is crucial for AI agents to understand how and when to use the ability. Should be translatable. +- `category` (`string`, **Required**): The slug of the category this ability belongs to. The category must be registered before registering the ability using `wp_register_ability_category()`. Categories help organize and filter abilities by their purpose. See [Registering Categories](7.registering-categories.md) for details. - `input_schema` (`array`, **Required**): A JSON Schema definition describing the expected input parameters for the ability's execute callback. Used for validation and documentation. - `output_schema` (`array`, **Required**): A JSON Schema definition describing the expected format of the data returned by the ability. Used for validation and documentation. - `execute_callback` (`callable`, **Required**): The PHP function or method to execute when this ability is called. @@ -55,6 +56,7 @@ function my_plugin_register_site_info_ability() { wp_register_ability( 'my-plugin/get-site-info', array( 'label' => __( 'Get Site Information', 'my-plugin' ), 'description' => __( 'Retrieves basic information about the WordPress site including name, description, and URL.', 'my-plugin' ), + 'category' => 'data-retrieval', 'input_schema' => array( 'type' => 'object', 'properties' => array(), @@ -104,6 +106,7 @@ function my_plugin_register_update_option_ability() { wp_register_ability( 'my-plugin/update-option', array( 'label' => __( 'Update WordPress Option', 'my-plugin' ), 'description' => __( 'Updates the value of a WordPress option in the database. Requires manage_options capability.', 'my-plugin' ), + 'category' => 'data-modification', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -169,6 +172,7 @@ function my_plugin_register_woo_stats_ability() { wp_register_ability( 'my-plugin/get-woo-stats', array( 'label' => __( 'Get WooCommerce Statistics', 'my-plugin' ), 'description' => __( 'Retrieves basic WooCommerce store statistics including total orders and revenue.', 'my-plugin' ), + 'category' => 'ecommerce', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -222,6 +226,7 @@ function my_plugin_register_send_email_ability() { wp_register_ability( 'my-plugin/send-email', array( 'label' => __( 'Send Email', 'my-plugin' ), 'description' => __( 'Sends an email to the specified recipient using WordPress mail functions.', 'my-plugin' ), + 'category' => 'communication', 'input_schema' => array( 'type' => 'object', 'properties' => array( diff --git a/docs/5.rest-api.md b/docs/5.rest-api.md index f9ffd3f..c2cebb4 100644 --- a/docs/5.rest-api.md +++ b/docs/5.rest-api.md @@ -28,6 +28,7 @@ Abilities are represented in JSON with the following structure: "name": "my-plugin/get-site-info", "label": "Get Site Information", "description": "Retrieves basic information about the WordPress site.", + "category": "site-information", "output_schema": { "type": "object", "properties": { @@ -63,6 +64,7 @@ Abilities are represented in JSON with the following structure: - `page` _(integer)_: Current page of the collection. Default: `1`. - `per_page` _(integer)_: Maximum number of items to return per page. Default: `50`, Maximum: `100`. +- `category` _(string)_: Filter abilities by category slug. ### Example Request @@ -78,6 +80,7 @@ curl https://example.com/wp-json/wp/v2/abilities "name": "my-plugin/get-site-info", "label": "Get Site Information", "description": "Retrieves basic information about the WordPress site.", + "category": "site-information", "output_schema": { "type": "object", "properties": { @@ -128,6 +131,7 @@ curl https://example.com/wp-json/wp/v2/abilities/my-plugin/get-site-info "name": "my-plugin/get-site-info", "label": "Get Site Information", "description": "Retrieves basic information about the WordPress site.", + "category": "site-information", "output_schema": { "type": "object", "properties": { diff --git a/docs/6.hooks.md b/docs/6.hooks.md index fcb35b5..02339b6 100644 --- a/docs/6.hooks.md +++ b/docs/6.hooks.md @@ -5,13 +5,49 @@ The Abilities API provides [WordPress Action and Filter Hooks](https://developer ## Quick Links - [Actions](#actions) + - [`abilities_api_categories_init`](#abilities_api_categories_init) - [`before_execute_ability`](#before_execute_ability) - [`after_execute_ability`](#after_execute_ability) - [Filters](#filters) - [`register_ability_args`](#register_ability_args) + - [`register_ability_category_args`](#register_ability_category_args) ## Actions +### `abilities_api_categories_init` + +Fires when the category registry is first initialized. This is the proper hook to use when registering categories. + +```php +do_action( 'abilities_api_categories_init', $registry ); +``` + +#### Parameters + +- `$registry` (`\WP_Abilities_Category_Registry`): The category registry instance. + +#### Usage Example + +```php +/** + * Register custom ability categories. + * + * @param \WP_Abilities_Category_Registry $registry The category registry instance. + */ +function my_plugin_register_categories( $registry ) { + wp_register_ability_category( 'ecommerce', array( + 'label' => __( 'E-commerce', 'my-plugin' ), + 'description' => __( 'Abilities related to e-commerce functionality.', 'my-plugin' ), + )); + + wp_register_ability_category( 'analytics', array( + 'label' => __( 'Analytics', 'my-plugin' ), + 'description' => __( 'Abilities that provide analytical data and insights.', 'my-plugin' ), + )); +} +add_action( 'abilities_api_categories_init', 'my_plugin_register_categories' ); +``` + ### `before_execute_ability` Fires immediately before an ability gets executed, after permission checks have passed but before the execution callback is called. @@ -132,3 +168,37 @@ function my_modify_ability_args( array $args, string $ability_name ): array { } add_filter( 'register_ability_args', 'my_modify_ability_args', 10, 2 ); ``` + +### `register_ability_category_args` + +Allows modification of a category's arguments before validation. + +```php +$args = apply_filters( 'register_ability_category_args', array $args, string $slug ); +``` + +#### Parameters + +- `$args` (`array`): The arguments used to instantiate the category (label, description). +- `$slug` (`string`): The slug of the category being registered. + +#### Usage Example + +```php +/** + * Modify category args before validation. + * + * @param array $args The arguments used to instantiate the category. + * @param string $slug The slug of the category being registered. + * + * @return array The modified category arguments. + */ +function my_modify_category_args( array $args, string $slug ): array { + if ( 'my-category' === $slug ) { + $args['label'] = __( 'My Custom Label', 'my-plugin' ); + $args['description'] = __( 'My custom description for this category.', 'my-plugin' ); + } + return $args; +} +add_filter( 'register_ability_category_args', 'my_modify_category_args', 10, 2 ); +``` diff --git a/docs/7.registering-categories.md b/docs/7.registering-categories.md new file mode 100644 index 0000000..8ddc78e --- /dev/null +++ b/docs/7.registering-categories.md @@ -0,0 +1,61 @@ +# 7. Registering Categories + +Before registering abilities, you must register at least one category. Categories help organize abilities and make them easier to discover and filter. + +## Function Signature + +```php +wp_register_ability_category( string $slug, array $args ): ?\WP_Ability_Category +``` + +**Parameters:** +- `$slug` (`string`): A unique identifier for the category. Must contain only lowercase alphanumeric characters and dashes (no underscores, no uppercase). +- `$args` (`array`): Category configuration with these keys: + - `label` (`string`, **Required**): Human-readable name for the category. Should be translatable. + - `description` (`string`, **Required**): Detailed description of the category's purpose. Should be translatable. + - `meta` (`array`, **Optional**): An associative array for storing arbitrary additional metadata about the category. + +**Return:** (`?\WP_Ability_Category`) An instance of the registered category if it was successfully registered, `null` on failure (e.g., invalid arguments, duplicate slug). + +**Note:** Categories must be registered during the `abilities_api_categories_init` action hook. + +## Code Example + +```php +add_action( 'abilities_api_categories_init', 'my_plugin_register_categories' ); +function my_plugin_register_categories() { + wp_register_ability_category( 'data-retrieval', array( + 'label' => __( 'Data Retrieval', 'my-plugin' ), + 'description' => __( 'Abilities that retrieve and return data from the WordPress site.', 'my-plugin' ), + )); + + wp_register_ability_category( 'data-modification', array( + 'label' => __( 'Data Modification', 'my-plugin' ), + 'description' => __( 'Abilities that modify data on the WordPress site.', 'my-plugin' ), + )); + + wp_register_ability_category( 'communication', array( + 'label' => __( 'Communication', 'my-plugin' ), + 'description' => __( 'Abilities that send messages or notifications.', 'my-plugin' ), + )); +} +``` + +## Category Slug Convention + +The `$slug` parameter must follow these rules: + +- **Format:** Must contain only lowercase alphanumeric characters (`a-z`, `0-9`) and hyphens (`-`). +- **Valid examples:** `data-retrieval`, `ecommerce`, `site-information`, `user-management`, `category-123` +- **Invalid examples:** + - Uppercase: `Data-Retrieval`, `MyCategory` + - Underscores: `data_retrieval` + - Special characters: `data.retrieval`, `data/retrieval`, `data retrieval` + - Leading/trailing dashes: `-data`, `data-` + - Double dashes: `data--retrieval` + +## Other Category Functions + +- `wp_unregister_ability_category( string $slug )` - Remove a registered category. Returns the unregistered category instance or `null` on failure. +- `wp_get_ability_category( string $slug )` - Retrieve a specific category by slug. Returns the category instance or `null` if not found. +- `wp_get_ability_categories()` - Get all registered categories as an associative array keyed by slug. diff --git a/includes/abilities-api.php b/includes/abilities-api.php index 9df0bfc..6e8c283 100644 --- a/includes/abilities-api.php +++ b/includes/abilities-api.php @@ -24,13 +24,14 @@ * 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, @@ -102,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 n.e.x.t + * + * @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 n.e.x.t + * + * @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 n.e.x.t + * + * @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 n.e.x.t + * + * @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/includes/abilities-api/class-wp-abilities-category-registry.php b/includes/abilities-api/class-wp-abilities-category-registry.php new file mode 100644 index 0000000..09fc07b --- /dev/null +++ b/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 ) . '' + ), + 'n.e.x.t' + ); + return null; + } + + if ( $this->is_registered( $slug ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Category slug. */ + esc_html( sprintf( __( 'Category "%s" is already registered.' ), $slug ) ), + 'n.e.x.t' + ); + 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.' ), + 'n.e.x.t' + ); + return null; + } + + /** + * Filters the category arguments before they are validated and used to instantiate the category. + * + * @since n.e.x.t + * + * @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() ), + 'n.e.x.t' + ); + 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 n.e.x.t + * + * @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 ) ), + 'n.e.x.t' + ); + 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 n.e.x.t + * + * @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 n.e.x.t + * + * @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 n.e.x.t + * + * @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 ) ), + 'n.e.x.t' + ); + 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 n.e.x.t + * + * @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 n.e.x.t + * + * @param \WP_Abilities_Category_Registry $instance Categories registry object. + */ + do_action( 'abilities_api_categories_init', self::$instance ); + } + + return self::$instance; + } + + /** + * Wakeup magic method. + * + * @since n.e.x.t + * @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 n.e.x.t + * @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 bd2cf2b..8a10a15 100644 --- a/includes/abilities-api/class-wp-abilities-registry.php +++ b/includes/abilities-api/class-wp-abilities-registry.php @@ -53,6 +53,7 @@ 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, @@ -98,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 ) + ), + 'n.e.x.t' + ); + 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( @@ -222,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/includes/abilities-api/class-wp-ability-category.php b/includes/abilities-api/class-wp-ability-category.php new file mode 100644 index 0000000..722a194 --- /dev/null +++ b/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 n.e.x.t + * + * @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 ) . '' + ), + 'n.e.x.t' + ); + continue; + } + + $this->$property_name = $property_value; + } + } + + /** + * Prepares and validates the properties used to instantiate the category. + * + * @since n.e.x.t + * + * @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 n.e.x.t + * + * @return string The category slug. + */ + public function get_slug(): string { + return $this->slug; + } + + /** + * Retrieves the human-readable label for the category. + * + * @since n.e.x.t + * + * @return string The human-readable category label. + */ + public function get_label(): string { + return $this->label; + } + + /** + * Retrieves the detailed description for the category. + * + * @since n.e.x.t + * + * @return string The detailed description for the category. + */ + public function get_description(): string { + return $this->description; + } + + /** + * Retrieves the metadata for the category. + * + * @since n.e.x.t + * + * @return array The metadata for the category. + */ + public function get_meta(): array { + return $this->meta; + } + + /** + * Wakeup magic method. + * + * @since n.e.x.t + * @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 n.e.x.t + * @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/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index 280b7fc..cce2e04 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -117,6 +117,14 @@ class WP_Ability { */ protected $meta; + /** + * The ability category (required). + * + * @since n.e.x.t + * @var string + */ + protected $category; + /** * Constructor. * @@ -129,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; @@ -175,6 +183,7 @@ 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, @@ -201,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.' ) @@ -327,6 +342,17 @@ public function get_meta(): array { return $this->meta; } + /** + * Retrieves the category for the ability. + * + * @since n.e.x.t + * + * @return string The category for the ability. + */ + public function get_category(): string { + return $this->category; + } + /** * Retrieves a specific metadata item for the ability. * diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 52705f0..4838495 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -29,6 +29,12 @@ 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' ) ) { diff --git a/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php b/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php index 9f8216f..54e393f 100644 --- a/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php +++ b/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php @@ -101,6 +101,19 @@ static function ( $ability ) { } ); + // 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(); $page = $params['page'] ?? 1; @@ -193,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(), @@ -256,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', @@ -268,12 +288,6 @@ public function get_item_schema(): array { 'context' => array( 'view', 'edit' ), 'readonly' => true, ), - 'annotations' => array( - 'description' => __( 'Annotations for the ability.' ), - 'type' => 'object', - 'context' => array( 'view', 'edit' ), - 'readonly' => true, - ), 'meta' => array( 'description' => __( 'Meta information about the ability.' ), 'type' => 'object', @@ -281,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 ); @@ -314,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/tests/unit/abilities-api/wpAbilitiesRegistry.php b/tests/unit/abilities-api/wpAbilitiesRegistry.php index ba8d0dd..5700093 100644 --- a/tests/unit/abilities-api/wpAbilitiesRegistry.php +++ b/tests/unit/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,8 +80,7 @@ public function set_up(): void { return true; }, 'meta' => array( - 'category' => 'math', - 'show_in_rest' => true, + 'foo' => 'bar', ), ); } @@ -74,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(); } diff --git a/tests/unit/abilities-api/wpAbility.php b/tests/unit/abilities-api/wpAbility.php index 7c86152..b60a056 100644 --- a/tests/unit/abilities-api/wpAbility.php +++ b/tests/unit/abilities-api/wpAbility.php @@ -18,9 +18,29 @@ 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.', @@ -42,6 +62,19 @@ public function set_up(): void { } /** + * 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() { @@ -66,7 +99,7 @@ public function test_meta_get_non_existing_item_with_custom_default() { ); } - /** + /** * Tests getting all annotations when selective overrides are applied. */ public function test_get_merged_annotations_from_meta() { diff --git a/tests/unit/abilities-api/wpAbilityCategory.php b/tests/unit/abilities-api/wpAbilityCategory.php new file mode 100644 index 0000000..c82056c --- /dev/null +++ b/tests/unit/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/unit/abilities-api/wpRegisterAbility.php b/tests/unit/abilities-api/wpRegisterAbility.php index 39df7de..1fa0aaa 100644 --- a/tests/unit/abilities-api/wpRegisterAbility.php +++ b/tests/unit/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( @@ -65,7 +85,6 @@ public function set_up(): void { 'readonly' => true, 'destructive' => false, ), - 'category' => 'math', 'show_in_rest' => true, ), ); @@ -83,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(); } @@ -471,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/unit/rest-api/wpRestAbilitiesListController.php b/tests/unit/rest-api/wpRestAbilitiesListController.php index 7d3a8a9..24093c4 100644 --- a/tests/unit/rest-api/wpRestAbilitiesListController.php +++ b/tests/unit/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,6 +96,35 @@ 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. */ @@ -90,6 +135,7 @@ private function register_test_abilities(): void { array( 'label' => 'Calculator', 'description' => 'Performs basic calculations', + 'category' => 'math', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -122,7 +168,6 @@ private function register_test_abilities(): void { return current_user_can( 'read' ); }, 'meta' => array( - 'category' => 'math', 'show_in_rest' => true, ), ) @@ -134,6 +179,7 @@ private function register_test_abilities(): void { array( 'label' => 'System Info', 'description' => 'Returns system information', + 'category' => 'system', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -180,6 +226,7 @@ private function register_test_abilities(): void { array( 'label' => 'Hidden from REST', 'description' => 'It does not show in REST.', + 'category' => 'general', 'execute_callback' => static function (): int { return 0; }, @@ -194,6 +241,7 @@ 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}"; }, @@ -243,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( 'math', $data['meta']['category'] ); + $this->assertTrue( $data['meta']['show_in_rest'] ); } /** @@ -452,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'] ); } /** @@ -470,6 +533,7 @@ 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 ); }, @@ -573,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/unit/rest-api/wpRestAbilitiesRunController.php b/tests/unit/rest-api/wpRestAbilitiesRunController.php index 8c0f319..92693cc 100644 --- a/tests/unit/rest-api/wpRestAbilitiesRunController.php +++ b/tests/unit/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,12 +88,49 @@ 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. */ @@ -97,6 +141,7 @@ private function register_test_abilities(): void { array( 'label' => 'Calculator', 'description' => 'Performs calculations', + 'category' => 'math', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -133,6 +178,7 @@ private function register_test_abilities(): void { array( 'label' => 'User Info', 'description' => 'Gets user information', + 'category' => 'system', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -178,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( @@ -208,6 +255,7 @@ private function register_test_abilities(): void { array( 'label' => 'Hidden from REST', 'description' => 'It does not show in REST.', + 'category' => 'general', 'execute_callback' => static function (): int { return 0; }, @@ -221,6 +269,7 @@ private function register_test_abilities(): void { array( 'label' => 'Null Return', 'description' => 'Returns null', + 'category' => 'general', 'execute_callback' => static function () { return null; }, @@ -237,6 +286,7 @@ 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' ); }, @@ -253,6 +303,7 @@ private function register_test_abilities(): void { array( 'label' => 'Invalid Output', 'description' => 'Returns invalid output', + 'category' => 'general', 'output_schema' => array( 'type' => 'number', ), @@ -272,6 +323,7 @@ private function register_test_abilities(): void { array( 'label' => 'Query Params Test', 'description' => 'Tests query parameter handling', + 'category' => 'general', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -345,6 +397,7 @@ public function test_regular_ability_requires_post(): void { array( 'label' => 'Open Tool', 'description' => 'Tool with no permission requirements', + 'category' => 'general', 'execute_callback' => static function () { return 'success'; }, @@ -628,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( @@ -674,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( @@ -720,6 +775,7 @@ public function test_ability_without_annotations_defaults_to_post_method(): void array( 'label' => 'No Annotations', 'description' => 'Ability without annotations.', + 'category' => 'general', 'execute_callback' => static function () { return array( 'executed' => true ); }, @@ -753,6 +809,7 @@ public function test_empty_input_handling(): void { array( '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() ); }, @@ -771,6 +828,7 @@ public function test_empty_input_handling(): void { array( 'label' => 'Regular Empty', 'description' => 'Regular with empty input.', + 'category' => 'general', 'execute_callback' => static function () { return array( 'input_was_empty' => 0 === func_num_args() ); }, @@ -843,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', ), @@ -889,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', ), @@ -954,6 +1014,7 @@ 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 ); },