From 55d75fc0c1343d28c2148c021a19d8f196a6a766 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 6 Oct 2025 15:05:52 +0300 Subject: [PATCH 01/50] Implement ability categories in the Abilities API * Add functions to register, unregister, and retrieve ability categories. * Introduce WP_Ability_Category and WP_Abilities_Category_Registry classes for managing categories. * Update WP_Ability class to support categories and modify the abilities retrieval process to filter by category. * Enhance REST API to allow filtering abilities by category and include category information in responses. * Bump version to 0.3.0 to reflect new features. --- includes/abilities-api.php | 93 +++++++ .../class-wp-abilities-category-registry.php | 226 ++++++++++++++++++ .../class-wp-abilities-registry.php | 18 ++ .../class-wp-ability-category.php | 159 ++++++++++++ includes/abilities-api/class-wp-ability.php | 49 ++++ includes/bootstrap.php | 8 +- ...lass-wp-rest-abilities-list-controller.php | 29 +++ 7 files changed, 581 insertions(+), 1 deletion(-) create mode 100644 includes/abilities-api/class-wp-abilities-category-registry.php create mode 100644 includes/abilities-api/class-wp-ability-category.php diff --git a/includes/abilities-api.php b/includes/abilities-api.php index f898ac00..b3194689 100644 --- a/includes/abilities-api.php +++ b/includes/abilities-api.php @@ -98,3 +98,96 @@ function wp_get_ability( string $name ): ?WP_Ability { function wp_get_abilities(): array { return WP_Abilities_Registry::get_instance()->get_all_registered(); } + +/** + * Retrieves abilities filtered by category. + * + * @since 0.3.0 + * + * @see WP_Abilities_Registry::get_abilities_by_category() + * + * @param string $category The category slug to filter by. + * @return \WP_Ability[] The array of abilities in the specified category. + */ +function wp_get_abilities_by_category( string $category ): array { + return WP_Abilities_Registry::get_instance()->get_abilities_by_category( $category ); +} + +/** + * 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` and `description`. + * @return ?\WP_Ability_Category The registered category instance on success, null on failure. + * + * @phpstan-param array{ + * label?: string, + * description?: string, + * ... + * } $args + */ +function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_Category { + if ( ! did_action( 'abilities_api_category_registry_init' ) && ! did_action( 'abilities_api_init' ) ) { + _doing_it_wrong( + __FUNCTION__, + sprintf( + /* translators: 1: abilities_api_category_registry_init, 2: abilities_api_init, 3: category slug. */ + esc_html__( 'Categories must be registered on the %1$s or %2$s action. The category %3$s was not registered.' ), + 'abilities_api_category_registry_init', + 'abilities_api_init', + '' . esc_html( $slug ) . '' + ), + '0.3.0' + ); + return null; + } + + 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/includes/abilities-api/class-wp-abilities-category-registry.php b/includes/abilities-api/class-wp-abilities-category-registry.php new file mode 100644 index 00000000..ce1b3f2d --- /dev/null +++ b/includes/abilities-api/class-wp-abilities-category-registry.php @@ -0,0 +1,226 @@ + $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, + * ... + * } $args + */ + public function register( string $slug, array $args ): ?WP_Ability_Category { + if ( ! preg_match( '/^[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; + } + + 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; + } + + /** + * 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: Category slug. */ + sprintf( esc_html__( '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 \\WP_Ability_Category[] 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: Category slug. */ + sprintf( esc_html__( '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_category_registry_init', self::$instance ); + } + + return self::$instance; + } + + /** + * Wakeup magic method. + * + * @since 0.3.0 + * @throws \\UnexpectedValueException If any of the registered categories is not an instance of WP_Ability_Category. + */ + public function __wakeup(): void { + foreach ( $this->registered_categories as $category ) { + if ( ! $category instanceof WP_Ability_Category ) { + throw new \UnexpectedValueException(); + } + } + } +} \ No newline at end of file diff --git a/includes/abilities-api/class-wp-abilities-registry.php b/includes/abilities-api/class-wp-abilities-registry.php index 3acf8a14..013a3df8 100644 --- a/includes/abilities-api/class-wp-abilities-registry.php +++ b/includes/abilities-api/class-wp-abilities-registry.php @@ -205,6 +205,24 @@ public function get_registered( string $name ): ?WP_Ability { return $this->registered_abilities[ $name ]; } + /** + * Retrieves abilities filtered by category. + * + * @since 0.3.0 + * + * @param string $category The category slug to filter by. + * @return \WP_Ability[] The array of abilities in the specified category. + */ + public function get_abilities_by_category( string $category ): array { + $filtered = array(); + foreach ( $this->registered_abilities as $ability ) { + if ( in_array( $category, $ability->get_categories(), true ) ) { + $filtered[] = $ability; + } + } + return $filtered; + } + /** * Utility method to retrieve the main instance of the registry class. * 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 00000000..48756158 --- /dev/null +++ b/includes/abilities-api/class-wp-ability-category.php @@ -0,0 +1,159 @@ + $args An associative array of arguments for the category. + */ + public function __construct( string $slug, array $args ) { + $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, + * ..., + * } + */ + 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.' ) + ); + } + + 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; + } + + /** + * Wakeup magic method. + * + * @since 0.3.0 + */ + public function __wakeup(): void { + throw new \LogicException( self::class . ' should never be unserialized.' ); + } +} \ No newline at end of file diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index 78ac5c3e..c29de22a 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -85,6 +85,14 @@ class WP_Ability { */ protected $meta = array(); + /** + * The ability categories. + * + * @since 0.3.0 + * @var array + */ + protected $categories = array(); + /** * Constructor. * @@ -196,6 +204,36 @@ protected function prepare_properties( array $args ): array { ); } + if ( isset( $args['categories'] ) ) { + // 1. NORMALIZE: Convert single string to array. + if ( is_string( $args['categories'] ) ) { + $args['categories'] = array( $args['categories'] ); + } + + // 2. VALIDATE: Check type. + if ( ! is_array( $args['categories'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The ability properties should provide a valid `categories` array or string.' ) + ); + } + + // 3. VALIDATE: Check each item. + foreach ( $args['categories'] as $category ) { + if ( ! is_string( $category ) ) { + throw new \InvalidArgumentException( + esc_html__( 'All category values must be strings.' ) + ); + } + + // Validate category slug format. + if ( ! preg_match( '/^[a-z0-9-]+$/', $category ) ) { + throw new \InvalidArgumentException( + esc_html__( 'Category slugs must contain only lowercase alphanumeric characters and dashes.' ) + ); + } + } + } + return $args; } @@ -266,6 +304,17 @@ public function get_meta(): array { return $this->meta; } + /** + * Retrieves the categories for the ability. + * + * @since 0.3.0 + * + * @return array The categories for the ability. + */ + public function get_categories(): array { + return $this->categories; + } + /** * Validates input data against the input schema. * diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 52705f0c..ae23de56 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -19,7 +19,7 @@ // Version of the plugin. if ( ! defined( 'WP_ABILITIES_API_VERSION' ) ) { - define( 'WP_ABILITIES_API_VERSION', '0.2.0' ); + define( 'WP_ABILITIES_API_VERSION', '0.3.0' ); } // Load core classes if they are not already defined (for non-Composer installs or direct includes). @@ -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 3727c844..18463e09 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 @@ -96,6 +96,19 @@ public function register_routes(): void { public function get_items( $request ) { $abilities = wp_get_abilities(); + // Filter by category if specified. + $category = $request->get_param( 'category' ); + if ( ! empty( $category ) ) { + $abilities = array_filter( + $abilities, + function ( $ability ) use ( $category ) { + return in_array( $category, $ability->get_categories(), true ); + } + ); + // Reset array keys after filtering. + $abilities = array_values( $abilities ); + } + // Handle pagination with explicit defaults. $params = $request->get_params(); $page = $params['page'] ?? 1; @@ -189,6 +202,7 @@ public function prepare_item_for_response( $ability, $request ) { 'name' => $ability->get_name(), 'label' => $ability->get_label(), 'description' => $ability->get_description(), + 'categories' => $ability->get_categories(), 'input_schema' => $ability->get_input_schema(), 'output_schema' => $ability->get_output_schema(), 'meta' => $ability->get_meta(), @@ -252,6 +266,15 @@ public function get_item_schema(): array { 'context' => array( 'view', 'edit' ), 'readonly' => true, ), + 'categories' => array( + 'description' => __( 'Categories this ability belongs to.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), 'input_schema' => array( 'description' => __( 'JSON Schema for the ability input.' ), 'type' => 'object', @@ -304,6 +327,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', + ), ); } } From df4af2c969077be159a38fe966d1f9e2c0742e32 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 6 Oct 2025 15:38:30 +0300 Subject: [PATCH 02/50] Refactor ability category registration and retrieval logic * Update the `register` method in `WP_Abilities_Category_Registry` to check for existing slugs before validating format. * Modify the `WP_Abilities_Registry` class to return abilities as an associative array keyed by ability name. * Enhance the `WP_Ability_Category` constructor to throw an exception for empty slugs and streamline property assignment. --- .../class-wp-abilities-category-registry.php | 10 +++--- .../class-wp-abilities-registry.php | 6 +++- .../class-wp-ability-category.php | 33 ++++++++----------- includes/abilities-api/class-wp-ability.php | 2 +- 4 files changed, 24 insertions(+), 27 deletions(-) diff --git a/includes/abilities-api/class-wp-abilities-category-registry.php b/includes/abilities-api/class-wp-abilities-category-registry.php index ce1b3f2d..783d6107 100644 --- a/includes/abilities-api/class-wp-abilities-category-registry.php +++ b/includes/abilities-api/class-wp-abilities-category-registry.php @@ -56,20 +56,20 @@ final class WP_Abilities_Category_Registry { * } $args */ public function register( string $slug, array $args ): ?WP_Ability_Category { - if ( ! preg_match( '/^[a-z0-9-]+$/', $slug ) ) { + if ( $this->is_registered( $slug ) ) { _doing_it_wrong( __METHOD__, - esc_html__( 'Category slug must contain only lowercase alphanumeric characters and dashes.' ), + /* translators: %s: Category slug. */ + esc_html( sprintf( __( 'Category "%s" is already registered.' ), $slug ) ), '0.3.0' ); return null; } - if ( $this->is_registered( $slug ) ) { + if ( ! preg_match( '/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug ) ) { _doing_it_wrong( __METHOD__, - /* translators: %s: Category slug. */ - esc_html( sprintf( __( 'Category "%s" is already registered.' ), $slug ) ), + esc_html__( 'Category slug must contain only lowercase alphanumeric characters and dashes.' ), '0.3.0' ); return null; diff --git a/includes/abilities-api/class-wp-abilities-registry.php b/includes/abilities-api/class-wp-abilities-registry.php index 013a3df8..da09487d 100644 --- a/includes/abilities-api/class-wp-abilities-registry.php +++ b/includes/abilities-api/class-wp-abilities-registry.php @@ -208,8 +208,12 @@ public function get_registered( string $name ): ?WP_Ability { /** * Retrieves abilities filtered by category. * + * Do not use this method directly. Instead, use the `wp_get_abilities_by_category()` function. + * * @since 0.3.0 * + * @see wp_get_abilities_by_category() + * * @param string $category The category slug to filter by. * @return \WP_Ability[] The array of abilities in the specified category. */ @@ -217,7 +221,7 @@ public function get_abilities_by_category( string $category ): array { $filtered = array(); foreach ( $this->registered_abilities as $ability ) { if ( in_array( $category, $ability->get_categories(), true ) ) { - $filtered[] = $ability; + $filtered[ $ability->get_name() ] = $ability; } } return $filtered; diff --git a/includes/abilities-api/class-wp-ability-category.php b/includes/abilities-api/class-wp-ability-category.php index 48756158..9aeaef19 100644 --- a/includes/abilities-api/class-wp-ability-category.php +++ b/includes/abilities-api/class-wp-ability-category.php @@ -59,28 +59,18 @@ class WP_Ability_Category { * @param array $args An associative array of arguments for the category. */ public function __construct( string $slug, array $args ) { - $this->slug = $slug; + if ( empty( $slug ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The category slug cannot be empty.' ) + ); + } + + $this->slug = sanitize_title( $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; - } + $this->label = $properties['label']; + $this->description = $properties['description']; } /** @@ -112,7 +102,10 @@ protected function prepare_properties( array $args ): array { ); } - return $args; + return array( + 'label' => $args['label'], + 'description' => $args['description'], + ); } /** diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index c29de22a..73386ffb 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -226,7 +226,7 @@ protected function prepare_properties( array $args ): array { } // Validate category slug format. - if ( ! preg_match( '/^[a-z0-9-]+$/', $category ) ) { + if ( ! preg_match( '/^[a-z0-9]+(-[a-z0-9]+)*$/', $category ) ) { throw new \InvalidArgumentException( esc_html__( 'Category slugs must contain only lowercase alphanumeric characters and dashes.' ) ); From b97a258708d30b2de690ec9847ecdb621f3c8cf3 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 6 Oct 2025 20:05:35 +0300 Subject: [PATCH 03/50] Refactor(Abilities): Change ability category from an array to a single required string --- includes/abilities-api/class-wp-ability.php | 57 ++++++++------------- 1 file changed, 21 insertions(+), 36 deletions(-) diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index 73386ffb..0dc924ba 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -86,12 +86,12 @@ class WP_Ability { protected $meta = array(); /** - * The ability categories. + * The ability category (required). * * @since 0.3.0 - * @var array + * @var string */ - protected $categories = array(); + protected $category; /** * Constructor. @@ -106,8 +106,8 @@ class WP_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`. + * include `label`, `description`, `category`, `input_schema`, + * `output_schema`, `execute_callback`, `permission_callback`, and `meta`. */ public function __construct( string $name, array $args ) { $this->name = $name; @@ -151,6 +151,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, @@ -204,34 +205,18 @@ protected function prepare_properties( array $args ): array { ); } - if ( isset( $args['categories'] ) ) { - // 1. NORMALIZE: Convert single string to array. - if ( is_string( $args['categories'] ) ) { - $args['categories'] = array( $args['categories'] ); - } - - // 2. VALIDATE: Check type. - if ( ! is_array( $args['categories'] ) ) { - throw new \InvalidArgumentException( - esc_html__( 'The ability properties should provide a valid `categories` array or string.' ) - ); - } - - // 3. VALIDATE: Check each item. - foreach ( $args['categories'] as $category ) { - if ( ! is_string( $category ) ) { - throw new \InvalidArgumentException( - esc_html__( 'All category values must be strings.' ) - ); - } + // VALIDATE: Category is required and must be a string. + if ( empty( $args['category'] ) || ! is_string( $args['category'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The ability properties must contain a `category` string.' ) + ); + } - // Validate category slug format. - if ( ! preg_match( '/^[a-z0-9]+(-[a-z0-9]+)*$/', $category ) ) { - throw new \InvalidArgumentException( - esc_html__( 'Category slugs must contain only lowercase alphanumeric characters and dashes.' ) - ); - } - } + // Validate category slug format. + if ( ! preg_match( '/^[a-z0-9]+(-[a-z0-9]+)*$/', $args['category'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'Category slug must contain only lowercase alphanumeric characters and dashes.' ) + ); } return $args; @@ -305,14 +290,14 @@ public function get_meta(): array { } /** - * Retrieves the categories for the ability. + * Retrieves the category for the ability. * * @since 0.3.0 * - * @return array The categories for the ability. + * @return string The category for the ability. */ - public function get_categories(): array { - return $this->categories; + public function get_category(): string { + return $this->category; } /** From 579333ea2dadcfd90ad23f077326f4fdbc2aa85c Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 6 Oct 2025 20:05:35 +0300 Subject: [PATCH 04/50] Refactor(Abilities): Update registry for single category model --- includes/abilities-api.php | 5 +++-- includes/abilities-api/class-wp-abilities-registry.php | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/includes/abilities-api.php b/includes/abilities-api.php index b3194689..8fc7199d 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`, - * `permission_callback`, `meta`, and `ability_class`. + * `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, diff --git a/includes/abilities-api/class-wp-abilities-registry.php b/includes/abilities-api/class-wp-abilities-registry.php index da09487d..66348d85 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, @@ -220,7 +221,7 @@ public function get_registered( string $name ): ?WP_Ability { public function get_abilities_by_category( string $category ): array { $filtered = array(); foreach ( $this->registered_abilities as $ability ) { - if ( in_array( $category, $ability->get_categories(), true ) ) { + if ( $ability->get_category() === $category ) { $filtered[ $ability->get_name() ] = $ability; } } From 8a7e62ed94d8809662affb32cfd1b5d9c185feb6 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 6 Oct 2025 20:05:35 +0300 Subject: [PATCH 05/50] Refactor(Abilities): Update REST API for single category model --- .../class-wp-rest-abilities-list-controller.php | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) 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 18463e09..2760e6bf 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 @@ -102,7 +102,7 @@ public function get_items( $request ) { $abilities = array_filter( $abilities, function ( $ability ) use ( $category ) { - return in_array( $category, $ability->get_categories(), true ); + return $ability->get_category() === $category; } ); // Reset array keys after filtering. @@ -202,7 +202,7 @@ public function prepare_item_for_response( $ability, $request ) { 'name' => $ability->get_name(), 'label' => $ability->get_label(), 'description' => $ability->get_description(), - 'categories' => $ability->get_categories(), + 'category' => $ability->get_category(), 'input_schema' => $ability->get_input_schema(), 'output_schema' => $ability->get_output_schema(), 'meta' => $ability->get_meta(), @@ -266,12 +266,9 @@ public function get_item_schema(): array { 'context' => array( 'view', 'edit' ), 'readonly' => true, ), - 'categories' => array( - 'description' => __( 'Categories this ability belongs to.' ), - 'type' => 'array', - 'items' => array( - 'type' => 'string', - ), + 'category' => array( + 'description' => __( 'Category this ability belongs to.' ), + 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, ), @@ -294,7 +291,7 @@ public function get_item_schema(): array { 'readonly' => true, ), ), - 'required' => array( 'name', 'label', 'description' ), + 'required' => array( 'name', 'label', 'description', 'category' ), ); return $this->add_additional_fields_schema( $schema ); From 38a46aa40cff617a1672f7e2222dff7e0efaa501 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 6 Oct 2025 20:05:35 +0300 Subject: [PATCH 06/50] Feat(Abilities): Validate that an ability's category is registered --- .../class-wp-abilities-registry.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/includes/abilities-api/class-wp-abilities-registry.php b/includes/abilities-api/class-wp-abilities-registry.php index 66348d85..003ef286 100644 --- a/includes/abilities-api/class-wp-abilities-registry.php +++ b/includes/abilities-api/class-wp-abilities-registry.php @@ -95,6 +95,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: category slug, %2$s: ability name */ + esc_html__( '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( @@ -241,6 +259,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. * From d2ac5ccf6b2ce6d99e144f72da4951be8bfa5178 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 6 Oct 2025 21:20:40 +0300 Subject: [PATCH 07/50] Fix: Correct PHPDoc type hints in WP_Abilities_Category_Registry --- .../class-wp-abilities-category-registry.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/includes/abilities-api/class-wp-abilities-category-registry.php b/includes/abilities-api/class-wp-abilities-category-registry.php index 783d6107..5a6f5af8 100644 --- a/includes/abilities-api/class-wp-abilities-category-registry.php +++ b/includes/abilities-api/class-wp-abilities-category-registry.php @@ -47,7 +47,7 @@ final class WP_Abilities_Category_Registry { * alphanumeric characters and dashes. * @param array $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. + * @return ?\WP_Ability_Category The registered category instance on success, null on failure. * * @phpstan-param array{ * label?: string, @@ -111,7 +111,7 @@ public function register( string $slug, array $args ): ?WP_Ability_Category { * @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. + * @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 ) ) { @@ -139,7 +139,7 @@ public function unregister( string $slug ): ?WP_Ability_Category { * * @see wp_get_ability_categories() * - * @return \\WP_Ability_Category[] The array of registered categories. + * @return array The array of registered categories. */ public function get_all_registered(): array { return $this->registered_categories; @@ -167,7 +167,7 @@ public function is_registered( string $slug ): bool { * @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. + * @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 ) ) { @@ -189,7 +189,7 @@ public function get_registered( string $slug ): ?WP_Ability_Category { * * @since 0.3.0 * - * @return \\WP_Abilities_Category_Registry The main registry instance. + * @return \WP_Abilities_Category_Registry The main registry instance. */ public static function get_instance(): self { if ( null === self::$instance ) { @@ -214,7 +214,7 @@ public static function get_instance(): self { * Wakeup magic method. * * @since 0.3.0 - * @throws \\UnexpectedValueException If any of the registered categories is not an instance of WP_Ability_Category. + * @throws \UnexpectedValueException If any of the registered categories is not an instance of WP_Ability_Category. */ public function __wakeup(): void { foreach ( $this->registered_categories as $category ) { From 8490c39bf0bc025952e4d29f1ee7beadebededb8 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 6 Oct 2025 21:20:40 +0300 Subject: [PATCH 08/50] Refactor: Rename abilities_category_registry_init hook for consistency --- .../abilities-api/class-wp-abilities-category-registry.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/abilities-api/class-wp-abilities-category-registry.php b/includes/abilities-api/class-wp-abilities-category-registry.php index 5a6f5af8..61b27eb0 100644 --- a/includes/abilities-api/class-wp-abilities-category-registry.php +++ b/includes/abilities-api/class-wp-abilities-category-registry.php @@ -202,9 +202,9 @@ public static function get_instance(): self { * * @since 0.3.0 * - * @param \\WP_Abilities_Category_Registry $instance Categories registry object. + * @param \WP_Abilities_Category_Registry $instance Categories registry object. */ - do_action( 'abilities_category_registry_init', self::$instance ); + do_action( 'abilities_api_category_registry_init', self::$instance ); } return self::$instance; From 59f6634a45e470cca4b832fc499e9daeeecb1d68 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 6 Oct 2025 21:20:40 +0300 Subject: [PATCH 09/50] Refactor: Improve ability filtering logic --- includes/abilities-api/class-wp-abilities-registry.php | 6 ++++-- .../endpoints/class-wp-rest-abilities-list-controller.php | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/includes/abilities-api/class-wp-abilities-registry.php b/includes/abilities-api/class-wp-abilities-registry.php index 003ef286..c9232589 100644 --- a/includes/abilities-api/class-wp-abilities-registry.php +++ b/includes/abilities-api/class-wp-abilities-registry.php @@ -239,9 +239,11 @@ public function get_registered( string $name ): ?WP_Ability { public function get_abilities_by_category( string $category ): array { $filtered = array(); foreach ( $this->registered_abilities as $ability ) { - if ( $ability->get_category() === $category ) { - $filtered[ $ability->get_name() ] = $ability; + if ( $ability->get_category() !== $category ) { + continue; } + + $filtered[ $ability->get_name() ] = $ability; } return $filtered; } 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 2760e6bf..b15b20f5 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,7 +101,7 @@ public function get_items( $request ) { if ( ! empty( $category ) ) { $abilities = array_filter( $abilities, - function ( $ability ) use ( $category ) { + static function ( $ability ) use ( $category ) { return $ability->get_category() === $category; } ); From 6a6b38198be62f9fe664e21cb6cee89ee5c0ccb9 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 6 Oct 2025 21:20:40 +0300 Subject: [PATCH 10/50] Chore: Add missing newlines at end of files --- includes/abilities-api/class-wp-abilities-category-registry.php | 2 +- includes/abilities-api/class-wp-ability-category.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/abilities-api/class-wp-abilities-category-registry.php b/includes/abilities-api/class-wp-abilities-category-registry.php index 61b27eb0..b9081ff4 100644 --- a/includes/abilities-api/class-wp-abilities-category-registry.php +++ b/includes/abilities-api/class-wp-abilities-category-registry.php @@ -223,4 +223,4 @@ public function __wakeup(): void { } } } -} \ No newline at end of file +} diff --git a/includes/abilities-api/class-wp-ability-category.php b/includes/abilities-api/class-wp-ability-category.php index 9aeaef19..faa29445 100644 --- a/includes/abilities-api/class-wp-ability-category.php +++ b/includes/abilities-api/class-wp-ability-category.php @@ -149,4 +149,4 @@ public function get_description(): string { public function __wakeup(): void { throw new \LogicException( self::class . ' should never be unserialized.' ); } -} \ No newline at end of file +} From 42c7ace8ceb5cbf9ee1fd3bcb683d9dd64b74b56 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 6 Oct 2025 21:22:42 +0300 Subject: [PATCH 11/50] test(abilities-api): Add tests for ability categories --- .../unit/abilities-api/wpAbilityCategory.php | 550 ++++++++++++++++++ .../unit/abilities-api/wpRegisterAbility.php | 23 + 2 files changed, 573 insertions(+) create mode 100644 tests/unit/abilities-api/wpAbilityCategory.php diff --git a/tests/unit/abilities-api/wpAbilityCategory.php b/tests/unit/abilities-api/wpAbilityCategory.php new file mode 100644 index 00000000..a327442a --- /dev/null +++ b/tests/unit/abilities-api/wpAbilityCategory.php @@ -0,0 +1,550 @@ +registry = WP_Abilities_Category_Registry::get_instance(); + } + + /** + * Tear down after each test. + */ + public function tear_down(): void { + // Clean up all test categories. + $categories = $this->registry->get_all_registered(); + foreach ( $categories as $category ) { + if ( str_starts_with( $category->get_slug(), 'test-' ) ) { + $this->registry->unregister( $category->get_slug() ); + } + } + + parent::tear_down(); + } + + /** + * Test registering a valid category. + */ + public function test_register_valid_category(): void { + do_action( 'abilities_api_category_registry_init' ); + + $result = wp_register_ability_category( + '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 { + do_action( 'abilities_api_category_registry_init' ); + + // Uppercase characters not allowed. + $result = wp_register_ability_category( + 'Test-Math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertNull( $result ); + } + + /** + * Test registering category with invalid slug - underscore. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_register_category_invalid_slug_underscore(): void { + do_action( 'abilities_api_category_registry_init' ); + + $result = wp_register_ability_category( + 'test_math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertNull( $result ); + } + + /** + * Test registering category without label. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_register_category_missing_label(): void { + do_action( 'abilities_api_category_registry_init' ); + + $result = wp_register_ability_category( + 'test-math', + array( + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertNull( $result ); + } + + /** + * Test registering category without description. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_register_category_missing_description(): void { + do_action( 'abilities_api_category_registry_init' ); + + $result = wp_register_ability_category( + 'test-math', + array( + 'label' => 'Math', + ) + ); + + $this->assertNull( $result ); + } + + /** + * Test registering category before abilities_api_category_registry_init hook. + * + * @expectedIncorrectUsage wp_register_ability_category + */ + public function test_register_category_before_init_hook(): void { + global $wp_actions; + + // Store original count. + $original_count = isset( $wp_actions['abilities_api_category_registry_init'] ) ? $wp_actions['abilities_api_category_registry_init'] : 0; + + // Reset to simulate hook not fired. + unset( $wp_actions['abilities_api_category_registry_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_category_registry_init'] = $original_count; + } + + $this->assertNull( $result ); + } + + /** + * Test registering duplicate category. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_register_duplicate_category(): void { + do_action( 'abilities_api_category_registry_init' ); + + wp_register_ability_category( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $result = wp_register_ability_category( + 'test-math', + array( + 'label' => 'Math 2', + 'description' => 'Another math category.', + ) + ); + + $this->assertNull( $result ); + } + + /** + * Test unregistering existing category. + */ + public function test_unregister_existing_category(): void { + do_action( 'abilities_api_category_registry_init' ); + + wp_register_ability_category( + '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 { + do_action( 'abilities_api_category_registry_init' ); + + $result = wp_unregister_ability_category( 'test-nonexistent' ); + + $this->assertNull( $result ); + } + + /** + * Test retrieving existing category. + */ + public function test_get_existing_category(): void { + do_action( 'abilities_api_category_registry_init' ); + + wp_register_ability_category( + '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 { + do_action( 'abilities_api_category_registry_init' ); + + $result = wp_get_ability_category( 'test-nonexistent' ); + + $this->assertNull( $result ); + } + + /** + * Test retrieving all registered categories. + */ + public function test_get_all_categories(): void { + do_action( 'abilities_api_category_registry_init' ); + + wp_register_ability_category( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + wp_register_ability_category( + 'test-system', + array( + 'label' => 'System', + 'description' => 'System operations.', + ) + ); + + $categories = wp_get_ability_categories(); + + $this->assertIsArray( $categories ); + $this->assertCount( 2, $categories ); + $this->assertArrayHasKey( 'test-math', $categories ); + $this->assertArrayHasKey( 'test-system', $categories ); + } + + /** + * Test category is_registered method. + */ + public function test_category_is_registered(): void { + do_action( 'abilities_api_category_registry_init' ); + + $this->assertFalse( $this->registry->is_registered( 'test-math' ) ); + + wp_register_ability_category( + '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' ); + + // 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 ); + } + + /** + * Test ability can be registered with valid category. + */ + public function test_ability_with_valid_category(): void { + do_action( 'abilities_api_category_registry_init' ); + do_action( 'abilities_api_init' ); + + wp_register_ability_category( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $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 { + do_action( 'abilities_api_category_registry_init' ); + + $result = wp_register_ability_category( + '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() ); + } + + /** + * Test category slug validation with valid formats. + */ + public function test_category_slug_valid_formats(): void { + do_action( 'abilities_api_category_registry_init' ); + + $valid_slugs = array( + 'test-simple', + 'test-multiple-words', + 'test-with-numbers-123', + 'test-a', + 'test-123', + ); + + foreach ( $valid_slugs as $slug ) { + $result = wp_register_ability_category( + $slug, + array( + 'label' => 'Test', + 'description' => 'Test description.', + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result, "Slug '{$slug}' should be valid" ); + } + } + + /** + * Test category slug validation with invalid formats. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_category_slug_invalid_formats(): void { + do_action( 'abilities_api_category_registry_init' ); + + $invalid_slugs = array( + 'Test-Uppercase', + 'test_underscore', + 'test.dot', + 'test/slash', + 'test space', + '-test-start-dash', + 'test-end-dash-', + 'test--double-dash', + ); + + foreach ( $invalid_slugs as $slug ) { + $result = wp_register_ability_category( + $slug, + array( + 'label' => 'Test', + 'description' => 'Test description.', + ) + ); + + $this->assertNull( $result, "Slug '{$slug}' should be invalid" ); + } + } + + /** + * Test filtering abilities by category. + */ + public function test_filtering_abilities_by_category(): void { + do_action( 'abilities_api_category_registry_init' ); + do_action( 'abilities_api_init' ); + + // Register categories. + wp_register_ability_category( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + wp_register_ability_category( + 'test-system', + array( + 'label' => 'System', + 'description' => 'System operations.', + ) + ); + + // Register abilities. + wp_register_ability( + 'test/add', + array( + 'label' => 'Add', + 'description' => 'Adds numbers.', + 'category' => 'test-math', + 'execute_callback' => static function () { + return 1; + }, + 'permission_callback' => '__return_true', + ) + ); + + wp_register_ability( + 'test/info', + array( + 'label' => 'Info', + 'description' => 'Gets info.', + 'category' => 'test-system', + 'execute_callback' => static function () { + return 2; + }, + 'permission_callback' => '__return_true', + ) + ); + + // Filter by category. + $math_abilities = WP_Abilities_Registry::get_instance()->get_abilities_by_category( 'test-math' ); + $system_abilities = WP_Abilities_Registry::get_instance()->get_abilities_by_category( 'test-system' ); + + $this->assertCount( 1, $math_abilities ); + $this->assertArrayHasKey( 'test/add', $math_abilities ); + + $this->assertCount( 1, $system_abilities ); + $this->assertArrayHasKey( 'test/info', $system_abilities ); + + // Cleanup. + wp_unregister_ability( 'test/add' ); + wp_unregister_ability( 'test/info' ); + } + + /** + * Test category registry initialization order. + */ + public function test_category_registry_initializes_before_abilities(): void { + // Reset registries. + $registry_reflection = new ReflectionClass( WP_Abilities_Registry::class ); + $instance_prop = $registry_reflection->getProperty( 'instance' ); + $instance_prop->setAccessible( true ); + $instance_prop->setValue( null, null ); + + $category_reflection = new ReflectionClass( WP_Abilities_Category_Registry::class ); + $category_prop = $category_reflection->getProperty( 'instance' ); + $category_prop->setAccessible( true ); + $category_prop->setValue( null, null ); + + // Get abilities registry (should initialize categories first). + $abilities_registry = WP_Abilities_Registry::get_instance(); + + // Verify category registry is initialized. + $this->assertInstanceOf( WP_Abilities_Category_Registry::class, WP_Abilities_Category_Registry::get_instance() ); + } +} diff --git a/tests/unit/abilities-api/wpRegisterAbility.php b/tests/unit/abilities-api/wpRegisterAbility.php index a292a118..0f61d894 100644 --- a/tests/unit/abilities-api/wpRegisterAbility.php +++ b/tests/unit/abilities-api/wpRegisterAbility.php @@ -451,4 +451,27 @@ 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. + if ( WP_Abilities_Category_Registry::get_instance()->is_registered( 'nonexistent' ) ) { + wp_unregister_ability_category( 'nonexistent' ); + } + + $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' ); + } } From 92a166eee08a932759e41712d8710482bd3f9f71 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 6 Oct 2025 21:22:43 +0300 Subject: [PATCH 12/50] test(abilities-api): Update unit tests to use ability categories --- .../abilities-api/wpAbilitiesRegistry.php | 23 +++++++++++++- tests/unit/abilities-api/wpAbility.php | 30 ++++++++++++++++++- .../unit/abilities-api/wpRegisterAbility.php | 23 +++++++++++++- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/tests/unit/abilities-api/wpAbilitiesRegistry.php b/tests/unit/abilities-api/wpAbilitiesRegistry.php index 94f46863..08f7e943 100644 --- a/tests/unit/abilities-api/wpAbilitiesRegistry.php +++ b/tests/unit/abilities-api/wpAbilitiesRegistry.php @@ -29,9 +29,24 @@ public function set_up(): void { remove_all_filters( 'register_ability_args' ); + // Fire the hook to allow category registration. + do_action( 'abilities_api_category_registry_init' ); + + // Register the math category for tests. + if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( 'math' ) ) { + wp_register_ability_category( + 'math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations and calculations.', + ) + ); + } + 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 +75,7 @@ public function set_up(): void { return true; }, 'meta' => array( - 'category' => 'math', + 'foo' => 'bar', ), ); } @@ -73,6 +88,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 961a40f8..f3348335 100644 --- a/tests/unit/abilities-api/wpAbility.php +++ b/tests/unit/abilities-api/wpAbility.php @@ -18,9 +18,24 @@ class Tests_Abilities_API_WpAbility extends WP_UnitTestCase { public function set_up(): void { parent::set_up(); + // Fire the hook to allow category registration. + do_action( 'abilities_api_category_registry_init' ); + + // Register the math category for tests. + if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( 'math' ) ) { + wp_register_ability_category( + 'math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations and calculations.', + ) + ); + } + 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.', @@ -30,11 +45,24 @@ public function set_up(): void { return true; }, 'meta' => array( - 'category' => 'math', + 'foo' => 'bar', ), ); } + /** + * 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(); + } + /** * Data provider for testing the execution of the ability. */ diff --git a/tests/unit/abilities-api/wpRegisterAbility.php b/tests/unit/abilities-api/wpRegisterAbility.php index 0f61d894..bde5e220 100644 --- a/tests/unit/abilities-api/wpRegisterAbility.php +++ b/tests/unit/abilities-api/wpRegisterAbility.php @@ -30,9 +30,24 @@ class Test_Abilities_API_WpRegisterAbility extends WP_UnitTestCase { public function set_up(): void { parent::set_up(); + // Fire the hook to allow category registration. + do_action( 'abilities_api_category_registry_init' ); + + // Register the math category for tests. + if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( 'math' ) ) { + wp_register_ability_category( + 'math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations and calculations.', + ) + ); + } + 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 +76,7 @@ public function set_up(): void { return true; }, 'meta' => array( - 'category' => 'math', + 'foo' => 'bar', ), ); } @@ -78,6 +93,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(); } From 3098037f191d883246797e6d6a02c9f828948aaa Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 6 Oct 2025 21:22:43 +0300 Subject: [PATCH 13/50] test(rest-api): Update tests to use ability categories --- .../wpRestAbilitiesListController.php | 50 +++++++++++++++-- .../rest-api/wpRestAbilitiesRunController.php | 56 +++++++++++++++++++ 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/tests/unit/rest-api/wpRestAbilitiesListController.php b/tests/unit/rest-api/wpRestAbilitiesListController.php index 319e26e0..f3f1fef2 100644 --- a/tests/unit/rest-api/wpRestAbilitiesListController.php +++ b/tests/unit/rest-api/wpRestAbilitiesListController.php @@ -49,6 +49,10 @@ public function set_up(): void { $this->server = $wp_rest_server; do_action( 'rest_api_init' ); + do_action( 'abilities_api_category_registry_init' ); + + // Register test categories + $this->register_test_categories(); // Initialize abilities API do_action( 'abilities_api_init' ); @@ -73,6 +77,13 @@ 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 ) ) { + wp_unregister_ability_category( $slug ); + } + } + // Reset REST server global $wp_rest_server; $wp_rest_server = null; @@ -80,6 +91,35 @@ public function tear_down(): void { parent::tear_down(); } + /** + * Register test categories for testing. + */ + private 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 +130,7 @@ private function register_test_abilities(): void { array( 'label' => 'Calculator', 'description' => 'Performs basic calculations', + 'category' => 'math', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -122,8 +163,7 @@ private function register_test_abilities(): void { return current_user_can( 'read' ); }, 'meta' => array( - 'type' => 'tool', - 'category' => 'math', + 'type' => 'tool', ), ) ); @@ -134,6 +174,7 @@ private function register_test_abilities(): void { array( 'label' => 'System Info', 'description' => 'Returns system information', + 'category' => 'system', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -165,8 +206,7 @@ private function register_test_abilities(): void { return current_user_can( 'read' ); }, 'meta' => array( - 'type' => 'resource', - 'category' => 'system', + 'type' => 'resource', ), ) ); @@ -178,6 +218,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}"; }, @@ -437,6 +478,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 ); }, diff --git a/tests/unit/rest-api/wpRestAbilitiesRunController.php b/tests/unit/rest-api/wpRestAbilitiesRunController.php index f7ae2195..9abb9659 100644 --- a/tests/unit/rest-api/wpRestAbilitiesRunController.php +++ b/tests/unit/rest-api/wpRestAbilitiesRunController.php @@ -60,6 +60,9 @@ public function set_up(): void { $this->server = $wp_rest_server; do_action( 'rest_api_init' ); + do_action( 'abilities_api_category_registry_init' ); + + $this->register_test_categories(); do_action( 'abilities_api_init' ); @@ -81,12 +84,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. + */ + private 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 +137,7 @@ private function register_test_abilities(): void { array( 'label' => 'Calculator', 'description' => 'Performs calculations', + 'category' => 'math', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -133,6 +174,7 @@ private function register_test_abilities(): void { array( 'label' => 'User Info', 'description' => 'Gets user information', + 'category' => 'system', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -175,6 +217,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( @@ -205,6 +248,7 @@ private function register_test_abilities(): void { array( 'label' => 'Null Return', 'description' => 'Returns null', + 'category' => 'general', 'execute_callback' => static function () { return null; }, @@ -221,6 +265,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' ); }, @@ -237,6 +282,7 @@ private function register_test_abilities(): void { array( 'label' => 'Invalid Output', 'description' => 'Returns invalid output', + 'category' => 'general', 'output_schema' => array( 'type' => 'number', ), @@ -256,6 +302,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( @@ -326,6 +373,7 @@ public function test_tool_ability_requires_post(): void { array( 'label' => 'Open Tool', 'description' => 'Tool with no permission requirements', + 'category' => 'general', 'execute_callback' => static function () { return 'success'; }, @@ -594,6 +642,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( @@ -638,6 +687,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( @@ -682,6 +732,7 @@ public function test_ability_without_type_defaults_to_tool(): void { array( 'label' => 'No Type', 'description' => 'Ability without type', + 'category' => 'general', 'execute_callback' => static function () { return array( 'executed' => true ); }, @@ -713,6 +764,7 @@ public function test_empty_input_handling(): void { array( 'label' => 'Resource Empty', 'description' => 'Resource with empty input', + 'category' => 'general', 'execute_callback' => static function () { return array( 'input_was_empty' => 0 === func_num_args() ); }, @@ -726,6 +778,7 @@ public function test_empty_input_handling(): void { array( 'label' => 'Tool Empty', 'description' => 'Tool with empty input', + 'category' => 'general', 'execute_callback' => static function () { return array( 'input_was_empty' => 0 === func_num_args() ); }, @@ -796,6 +849,7 @@ public function test_php_type_strings_in_input(): void { array( 'label' => 'Echo', 'description' => 'Echoes input', + 'category' => 'general', 'input_schema' => array( 'type' => 'object', ), @@ -840,6 +894,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', ), @@ -903,6 +958,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 ); }, From 1bac36376e415961faaac5ada38e74f7c6a18db7 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 6 Oct 2025 21:22:43 +0300 Subject: [PATCH 14/50] test(rest-api): Add tests for category filtering and schema --- .../wpRestAbilitiesListController.php | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/unit/rest-api/wpRestAbilitiesListController.php b/tests/unit/rest-api/wpRestAbilitiesListController.php index f3f1fef2..24683e2c 100644 --- a/tests/unit/rest-api/wpRestAbilitiesListController.php +++ b/tests/unit/rest-api/wpRestAbilitiesListController.php @@ -579,4 +579,79 @@ 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 { + $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' ); + } + + /** + * Test that category is in schema and marked as required. + */ + public function test_category_in_schema(): void { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/abilities' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $schema = $data['schema']; + $this->assertArrayHasKey( 'properties', $schema ); + $this->assertArrayHasKey( 'category', $schema['properties'] ); + + $category_property = $schema['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'] ); + } } From cd406da626c04319e606ec1fcf78889c2a70402c Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 6 Oct 2025 21:56:08 +0300 Subject: [PATCH 15/50] docs: Document Ability Categories feature --- docs/1.intro.md | 7 ++-- docs/3.registering-abilities.md | 55 ++++++++++++++++++++++++++ docs/6.hooks.md | 70 +++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 3 deletions(-) diff --git a/docs/1.intro.md b/docs/1.intro.md index d985d57d..a74751ac 100644 --- a/docs/1.intro.md +++ b/docs/1.intro.md @@ -8,11 +8,12 @@ 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. +- **Permission Callback:** An optional function that determines if the current user can execute a specific ability. - **Namespace:** The first part of an ability name (before the slash), typically matching the plugin or component name that registers the ability. ## Goals and Benefits diff --git a/docs/3.registering-abilities.md b/docs/3.registering-abilities.md index 2e62c2ec..cbc9ae4f 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](#registering-categories) below. - `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. @@ -37,6 +38,60 @@ The `$id` parameter must follow the pattern `namespace/ability-name`: - **Convention:** Use your plugin slug as the namespace, like `my-plugin/ability-name`. - **Examples:** `my-plugin/update-settings`, `woocommerce/get-product`, `contact-form/send-message`, `analytics/track-event` +## Registering Categories + +Before registering abilities, you must register at least one category. Categories help organize abilities and make them easier to discover and filter. + +### `wp_register_ability_category()` + +```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. It Should be translatable. + - `description` (`string`, **Required**): Detailed description of the category's purpose. It Should be translatable. + +**Return:** (`?\WP_Ability_Category`) An instance of the registered category if it was successfully registered, `null` on failure (e.g., invalid arguments, duplicate slug). + +**Example:** + +```php +add_action( 'abilities_api_category_registry_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 (`-`). +- **Invalid:** No underscores, uppercase letters, spaces, or special characters. +- **Examples:** `data-retrieval`, `ecommerce`, `site-information`, `user-management` + +### 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. + ## Code Examples ### Registering a Simple Data Retrieval Ability diff --git a/docs/6.hooks.md b/docs/6.hooks.md index fcb35b53..9f9a9cc8 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_category_registry_init`](#abilities_api_category_registry_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_category_registry_init` + +Fires when the category registry is first initialized. This is the proper hook to use when registering categories. + +```php +do_action( 'abilities_api_category_registry_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_category_registry_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 ); +``` From ff86ae20c80f7a6218c2adbd58da7b0bf6d33968 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 6 Oct 2025 21:56:08 +0300 Subject: [PATCH 16/50] docs: Update examples to use Ability Categories --- docs/1.intro.md | 11 +++++++++++ docs/3.registering-abilities.md | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/docs/1.intro.md b/docs/1.intro.md index a74751ac..1e62dab1 100644 --- a/docs/1.intro.md +++ b/docs/1.intro.md @@ -36,11 +36,22 @@ It acts as a central registry, making it easier for different parts of WordPress ## Registration Example ```php +// First, register a category +add_action( 'abilities_api_category_registry_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 cbc9ae4f..531ad524 100644 --- a/docs/3.registering-abilities.md +++ b/docs/3.registering-abilities.md @@ -102,6 +102,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(), @@ -145,6 +146,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( @@ -204,6 +206,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( @@ -257,6 +260,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( From 2a3bf050be7f3994f33dd964331a7efda032f424 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 6 Oct 2025 21:56:08 +0300 Subject: [PATCH 17/50] docs: Document Category support in REST API --- docs/5.rest-api.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/5.rest-api.md b/docs/5.rest-api.md index 6686512c..524f802a 100644 --- a/docs/5.rest-api.md +++ b/docs/5.rest-api.md @@ -19,6 +19,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": { @@ -47,6 +48,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 @@ -62,6 +64,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": { @@ -105,6 +108,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": { From 9b17174f7ea95b9f8807f54001e30d6006a6e82c Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 7 Oct 2025 23:31:37 +0300 Subject: [PATCH 18/50] Refactor ability category registration validation Move the action hook validation for ability category registration from the public API function into the registry class itself. This change centralizes the validation logic, ensuring it's consistently applied. The validation is also made more specific, now only permitting registration during the `abilities_api_category_registry_init` action to enforce a stricter and more predictable initialization order. --- includes/abilities-api.php | 15 --------------- .../class-wp-abilities-category-registry.php | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/includes/abilities-api.php b/includes/abilities-api.php index 8fc7199d..d2bac29d 100644 --- a/includes/abilities-api.php +++ b/includes/abilities-api.php @@ -134,21 +134,6 @@ function wp_get_abilities_by_category( string $category ): array { * } $args */ function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_Category { - if ( ! did_action( 'abilities_api_category_registry_init' ) && ! did_action( 'abilities_api_init' ) ) { - _doing_it_wrong( - __FUNCTION__, - sprintf( - /* translators: 1: abilities_api_category_registry_init, 2: abilities_api_init, 3: category slug. */ - esc_html__( 'Categories must be registered on the %1$s or %2$s action. The category %3$s was not registered.' ), - 'abilities_api_category_registry_init', - 'abilities_api_init', - '' . esc_html( $slug ) . '' - ), - '0.3.0' - ); - return null; - } - return WP_Abilities_Category_Registry::get_instance()->register( $slug, $args ); } diff --git a/includes/abilities-api/class-wp-abilities-category-registry.php b/includes/abilities-api/class-wp-abilities-category-registry.php index b9081ff4..15b42c64 100644 --- a/includes/abilities-api/class-wp-abilities-category-registry.php +++ b/includes/abilities-api/class-wp-abilities-category-registry.php @@ -56,6 +56,20 @@ final class WP_Abilities_Category_Registry { * } $args */ public function register( string $slug, array $args ): ?WP_Ability_Category { + if ( ! doing_action( 'abilities_api_category_registry_init' ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: abilities_api_category_registry_init, 2: category slug. */ + esc_html__( 'Categories must be registered during the %1$s action. The category %2$s was not registered.' ), + 'abilities_api_category_registry_init', + '' . esc_html( $slug ) . '' + ), + '0.3.0' + ); + return null; + } + if ( $this->is_registered( $slug ) ) { _doing_it_wrong( __METHOD__, From 6e42f67e6350daac4e220b695c19502f1c0177fc Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 7 Oct 2025 23:32:37 +0300 Subject: [PATCH 19/50] Refactor tests to register ability categories on the init hook Update unit tests to register ability categories using the `abilities_api_category_registry_init` action hook. Previously, tests registered categories after this hook had already fired, which does not reflect the intended API usage. This change ensures that the test setup accurately simulates how categories should be registered, making the test suite more robust and reliable. A helper method has also been introduced in the `wpAbilityCategory` test class to streamline this process and reduce code duplication. --- .../abilities-api/wpAbilitiesRegistry.php | 27 +- tests/unit/abilities-api/wpAbility.php | 27 +- .../unit/abilities-api/wpAbilityCategory.php | 248 ++++++++++-------- .../unit/abilities-api/wpRegisterAbility.php | 27 +- .../wpRestAbilitiesListController.php | 11 +- .../rest-api/wpRestAbilitiesRunController.php | 10 +- 6 files changed, 195 insertions(+), 155 deletions(-) diff --git a/tests/unit/abilities-api/wpAbilitiesRegistry.php b/tests/unit/abilities-api/wpAbilitiesRegistry.php index 08f7e943..c907602f 100644 --- a/tests/unit/abilities-api/wpAbilitiesRegistry.php +++ b/tests/unit/abilities-api/wpAbilitiesRegistry.php @@ -29,20 +29,25 @@ public function set_up(): void { remove_all_filters( 'register_ability_args' ); + // Register category during the hook. + add_action( + 'abilities_api_category_registry_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_category_registry_init' ); - // Register the math category for tests. - if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( 'math' ) ) { - wp_register_ability_category( - 'math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations and calculations.', - ) - ); - } - self::$test_ability_args = array( 'label' => 'Add numbers', 'description' => 'Calculates the result of adding two numbers.', diff --git a/tests/unit/abilities-api/wpAbility.php b/tests/unit/abilities-api/wpAbility.php index f3348335..d62a5b13 100644 --- a/tests/unit/abilities-api/wpAbility.php +++ b/tests/unit/abilities-api/wpAbility.php @@ -18,20 +18,25 @@ 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_category_registry_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_category_registry_init' ); - // Register the math category for tests. - if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( 'math' ) ) { - wp_register_ability_category( - 'math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations and calculations.', - ) - ); - } - self::$test_ability_properties = array( 'label' => 'Calculator', 'description' => 'Calculates the result of math operations.', diff --git a/tests/unit/abilities-api/wpAbilityCategory.php b/tests/unit/abilities-api/wpAbilityCategory.php index a327442a..b3f07e87 100644 --- a/tests/unit/abilities-api/wpAbilityCategory.php +++ b/tests/unit/abilities-api/wpAbilityCategory.php @@ -46,12 +46,25 @@ public function tear_down(): void { } /** - * Test registering a valid category. + * Helper to register a category during the hook. */ - public function test_register_valid_category(): void { + private function register_category_during_hook( string $slug, array $args ): ?WP_Ability_Category { + $result = null; + add_action( + 'abilities_api_category_registry_init', + function () use ( $slug, $args, &$result ) { + $result = wp_register_ability_category( $slug, $args ); + } + ); do_action( 'abilities_api_category_registry_init' ); + return $result; + } - $result = wp_register_ability_category( + /** + * Test registering a valid category. + */ + public function test_register_valid_category(): void { + $result = $this->register_category_during_hook( 'test-math', array( 'label' => 'Math', @@ -71,10 +84,8 @@ public function test_register_valid_category(): void { * @expectedIncorrectUsage WP_Abilities_Category_Registry::register */ public function test_register_category_invalid_slug_format(): void { - do_action( 'abilities_api_category_registry_init' ); - // Uppercase characters not allowed. - $result = wp_register_ability_category( + $result = $this->register_category_during_hook( 'Test-Math', array( 'label' => 'Math', @@ -91,9 +102,7 @@ public function test_register_category_invalid_slug_format(): void { * @expectedIncorrectUsage WP_Abilities_Category_Registry::register */ public function test_register_category_invalid_slug_underscore(): void { - do_action( 'abilities_api_category_registry_init' ); - - $result = wp_register_ability_category( + $result = $this->register_category_during_hook( 'test_math', array( 'label' => 'Math', @@ -110,9 +119,7 @@ public function test_register_category_invalid_slug_underscore(): void { * @expectedIncorrectUsage WP_Abilities_Category_Registry::register */ public function test_register_category_missing_label(): void { - do_action( 'abilities_api_category_registry_init' ); - - $result = wp_register_ability_category( + $result = $this->register_category_during_hook( 'test-math', array( 'description' => 'Mathematical operations.', @@ -128,9 +135,7 @@ public function test_register_category_missing_label(): void { * @expectedIncorrectUsage WP_Abilities_Category_Registry::register */ public function test_register_category_missing_description(): void { - do_action( 'abilities_api_category_registry_init' ); - - $result = wp_register_ability_category( + $result = $this->register_category_during_hook( 'test-math', array( 'label' => 'Math', @@ -143,7 +148,7 @@ public function test_register_category_missing_description(): void { /** * Test registering category before abilities_api_category_registry_init hook. * - * @expectedIncorrectUsage wp_register_ability_category + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register */ public function test_register_category_before_init_hook(): void { global $wp_actions; @@ -176,23 +181,28 @@ public function test_register_category_before_init_hook(): void { * @expectedIncorrectUsage WP_Abilities_Category_Registry::register */ public function test_register_duplicate_category(): void { - do_action( 'abilities_api_category_registry_init' ); - - 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.', - ) + $result = null; + add_action( + 'abilities_api_category_registry_init', + function () use ( &$result ) { + 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.', + ) + ); + } ); + do_action( 'abilities_api_category_registry_init' ); $this->assertNull( $result ); } @@ -201,9 +211,7 @@ public function test_register_duplicate_category(): void { * Test unregistering existing category. */ public function test_unregister_existing_category(): void { - do_action( 'abilities_api_category_registry_init' ); - - wp_register_ability_category( + $this->register_category_during_hook( 'test-math', array( 'label' => 'Math', @@ -223,8 +231,6 @@ public function test_unregister_existing_category(): void { * @expectedIncorrectUsage WP_Abilities_Category_Registry::unregister */ public function test_unregister_nonexistent_category(): void { - do_action( 'abilities_api_category_registry_init' ); - $result = wp_unregister_ability_category( 'test-nonexistent' ); $this->assertNull( $result ); @@ -234,9 +240,7 @@ public function test_unregister_nonexistent_category(): void { * Test retrieving existing category. */ public function test_get_existing_category(): void { - do_action( 'abilities_api_category_registry_init' ); - - wp_register_ability_category( + $this->register_category_during_hook( 'test-math', array( 'label' => 'Math', @@ -256,8 +260,6 @@ public function test_get_existing_category(): void { * @expectedIncorrectUsage WP_Abilities_Category_Registry::get_registered */ public function test_get_nonexistent_category(): void { - do_action( 'abilities_api_category_registry_init' ); - $result = wp_get_ability_category( 'test-nonexistent' ); $this->assertNull( $result ); @@ -267,23 +269,27 @@ public function test_get_nonexistent_category(): void { * Test retrieving all registered categories. */ public function test_get_all_categories(): void { - do_action( 'abilities_api_category_registry_init' ); - - wp_register_ability_category( - 'test-math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations.', - ) - ); - - wp_register_ability_category( - 'test-system', - array( - 'label' => 'System', - 'description' => 'System operations.', - ) + add_action( + 'abilities_api_category_registry_init', + function () { + wp_register_ability_category( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + wp_register_ability_category( + 'test-system', + array( + 'label' => 'System', + 'description' => 'System operations.', + ) + ); + } ); + do_action( 'abilities_api_category_registry_init' ); $categories = wp_get_ability_categories(); @@ -297,11 +303,9 @@ public function test_get_all_categories(): void { * Test category is_registered method. */ public function test_category_is_registered(): void { - do_action( 'abilities_api_category_registry_init' ); - $this->assertFalse( $this->registry->is_registered( 'test-math' ) ); - wp_register_ability_category( + $this->register_category_during_hook( 'test-math', array( 'label' => 'Math', @@ -341,17 +345,21 @@ public function test_ability_requires_existing_category(): void { * Test ability can be registered with valid category. */ public function test_ability_with_valid_category(): void { + add_action( + 'abilities_api_category_registry_init', + function () { + wp_register_ability_category( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + } + ); do_action( 'abilities_api_category_registry_init' ); do_action( 'abilities_api_init' ); - wp_register_ability_category( - 'test-math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations.', - ) - ); - $result = wp_register_ability( 'test/calculator', array( @@ -386,9 +394,7 @@ public function test_category_registry_singleton(): void { * Test category with special characters in label and description. */ public function test_category_with_special_characters(): void { - do_action( 'abilities_api_category_registry_init' ); - - $result = wp_register_ability_category( + $result = $this->register_category_during_hook( 'test-special', array( 'label' => 'Math & Science ', @@ -405,8 +411,6 @@ public function test_category_with_special_characters(): void { * Test category slug validation with valid formats. */ public function test_category_slug_valid_formats(): void { - do_action( 'abilities_api_category_registry_init' ); - $valid_slugs = array( 'test-simple', 'test-multiple-words', @@ -415,17 +419,23 @@ public function test_category_slug_valid_formats(): void { 'test-123', ); - foreach ( $valid_slugs as $slug ) { - $result = wp_register_ability_category( - $slug, - array( - 'label' => 'Test', - 'description' => 'Test description.', - ) - ); - - $this->assertInstanceOf( WP_Ability_Category::class, $result, "Slug '{$slug}' should be valid" ); - } + add_action( + 'abilities_api_category_registry_init', + function () use ( $valid_slugs ) { + foreach ( $valid_slugs as $slug ) { + $result = wp_register_ability_category( + $slug, + array( + 'label' => 'Test', + 'description' => 'Test description.', + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result, "Slug '{$slug}' should be valid" ); + } + } + ); + do_action( 'abilities_api_category_registry_init' ); } /** @@ -434,8 +444,6 @@ public function test_category_slug_valid_formats(): void { * @expectedIncorrectUsage WP_Abilities_Category_Registry::register */ public function test_category_slug_invalid_formats(): void { - do_action( 'abilities_api_category_registry_init' ); - $invalid_slugs = array( 'Test-Uppercase', 'test_underscore', @@ -447,43 +455,53 @@ public function test_category_slug_invalid_formats(): void { 'test--double-dash', ); - foreach ( $invalid_slugs as $slug ) { - $result = wp_register_ability_category( - $slug, - array( - 'label' => 'Test', - 'description' => 'Test description.', - ) - ); - - $this->assertNull( $result, "Slug '{$slug}' should be invalid" ); - } + add_action( + 'abilities_api_category_registry_init', + function () use ( $invalid_slugs ) { + foreach ( $invalid_slugs as $slug ) { + $result = wp_register_ability_category( + $slug, + array( + 'label' => 'Test', + 'description' => 'Test description.', + ) + ); + + $this->assertNull( $result, "Slug '{$slug}' should be invalid" ); + } + } + ); + do_action( 'abilities_api_category_registry_init' ); } /** * Test filtering abilities by category. */ public function test_filtering_abilities_by_category(): void { + add_action( + 'abilities_api_category_registry_init', + function () { + // Register categories. + wp_register_ability_category( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + wp_register_ability_category( + 'test-system', + array( + 'label' => 'System', + 'description' => 'System operations.', + ) + ); + } + ); do_action( 'abilities_api_category_registry_init' ); do_action( 'abilities_api_init' ); - // Register categories. - wp_register_ability_category( - 'test-math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations.', - ) - ); - - wp_register_ability_category( - 'test-system', - array( - 'label' => 'System', - 'description' => 'System operations.', - ) - ); - // Register abilities. wp_register_ability( 'test/add', diff --git a/tests/unit/abilities-api/wpRegisterAbility.php b/tests/unit/abilities-api/wpRegisterAbility.php index bde5e220..64e4f10c 100644 --- a/tests/unit/abilities-api/wpRegisterAbility.php +++ b/tests/unit/abilities-api/wpRegisterAbility.php @@ -30,20 +30,25 @@ 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_category_registry_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_category_registry_init' ); - // Register the math category for tests. - if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( 'math' ) ) { - wp_register_ability_category( - 'math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations and calculations.', - ) - ); - } - self::$test_ability_args = array( 'label' => 'Add numbers', 'description' => 'Calculates the result of adding two numbers.', diff --git a/tests/unit/rest-api/wpRestAbilitiesListController.php b/tests/unit/rest-api/wpRestAbilitiesListController.php index 24683e2c..c40e284c 100644 --- a/tests/unit/rest-api/wpRestAbilitiesListController.php +++ b/tests/unit/rest-api/wpRestAbilitiesListController.php @@ -49,10 +49,13 @@ public function set_up(): void { $this->server = $wp_rest_server; do_action( 'rest_api_init' ); - do_action( 'abilities_api_category_registry_init' ); - // Register test categories - $this->register_test_categories(); + // Register test categories during the hook + add_action( + 'abilities_api_category_registry_init', + array( $this, 'register_test_categories' ) + ); + do_action( 'abilities_api_category_registry_init' ); // Initialize abilities API do_action( 'abilities_api_init' ); @@ -94,7 +97,7 @@ public function tear_down(): void { /** * Register test categories for testing. */ - private function register_test_categories(): void { + public function register_test_categories(): void { wp_register_ability_category( 'math', array( diff --git a/tests/unit/rest-api/wpRestAbilitiesRunController.php b/tests/unit/rest-api/wpRestAbilitiesRunController.php index 9abb9659..94789de0 100644 --- a/tests/unit/rest-api/wpRestAbilitiesRunController.php +++ b/tests/unit/rest-api/wpRestAbilitiesRunController.php @@ -60,9 +60,13 @@ public function set_up(): void { $this->server = $wp_rest_server; do_action( 'rest_api_init' ); - do_action( 'abilities_api_category_registry_init' ); - $this->register_test_categories(); + // Register test categories during the hook + add_action( + 'abilities_api_category_registry_init', + array( $this, 'register_test_categories' ) + ); + do_action( 'abilities_api_category_registry_init' ); do_action( 'abilities_api_init' ); @@ -101,7 +105,7 @@ public function tear_down(): void { /** * Register test categories for testing. */ - private function register_test_categories(): void { + public function register_test_categories(): void { wp_register_ability_category( 'math', array( From 1f9e75cae9ae3204991a5836e5a2a5253faeba2c Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Tue, 7 Oct 2025 23:48:37 +0300 Subject: [PATCH 20/50] Rename category registration hook The `abilities_api_category_registry_init` action hook is renamed to the more concise and intuitive `abilities_api_categories_init`. This change improves developer experience by making the hook's purpose clearer and aligning it more closely with standard WordPress naming conventions. All related code, documentation, and tests have been updated to use the new hook name. --- docs/1.intro.md | 2 +- docs/3.registering-abilities.md | 2 +- docs/6.hooks.md | 8 ++--- .../class-wp-abilities-category-registry.php | 8 ++--- .../abilities-api/wpAbilitiesRegistry.php | 4 +-- tests/unit/abilities-api/wpAbility.php | 4 +-- .../unit/abilities-api/wpAbilityCategory.php | 36 +++++++++---------- .../unit/abilities-api/wpRegisterAbility.php | 4 +-- .../wpRestAbilitiesListController.php | 4 +-- .../rest-api/wpRestAbilitiesRunController.php | 4 +-- 10 files changed, 38 insertions(+), 38 deletions(-) diff --git a/docs/1.intro.md b/docs/1.intro.md index 1e62dab1..717ca936 100644 --- a/docs/1.intro.md +++ b/docs/1.intro.md @@ -37,7 +37,7 @@ It acts as a central registry, making it easier for different parts of WordPress ```php // First, register a category -add_action( 'abilities_api_category_registry_init', 'my_plugin_register_category'); +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' ), diff --git a/docs/3.registering-abilities.md b/docs/3.registering-abilities.md index 531ad524..a305ec62 100644 --- a/docs/3.registering-abilities.md +++ b/docs/3.registering-abilities.md @@ -59,7 +59,7 @@ wp_register_ability_category( string $slug, array $args ): ?\WP_Ability_Category **Example:** ```php -add_action( 'abilities_api_category_registry_init', 'my_plugin_register_categories' ); +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' ), diff --git a/docs/6.hooks.md b/docs/6.hooks.md index 9f9a9cc8..02339b6c 100644 --- a/docs/6.hooks.md +++ b/docs/6.hooks.md @@ -5,7 +5,7 @@ The Abilities API provides [WordPress Action and Filter Hooks](https://developer ## Quick Links - [Actions](#actions) - - [`abilities_api_category_registry_init`](#abilities_api_category_registry_init) + - [`abilities_api_categories_init`](#abilities_api_categories_init) - [`before_execute_ability`](#before_execute_ability) - [`after_execute_ability`](#after_execute_ability) - [Filters](#filters) @@ -14,12 +14,12 @@ The Abilities API provides [WordPress Action and Filter Hooks](https://developer ## Actions -### `abilities_api_category_registry_init` +### `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_category_registry_init', $registry ); +do_action( 'abilities_api_categories_init', $registry ); ``` #### Parameters @@ -45,7 +45,7 @@ function my_plugin_register_categories( $registry ) { 'description' => __( 'Abilities that provide analytical data and insights.', 'my-plugin' ), )); } -add_action( 'abilities_api_category_registry_init', 'my_plugin_register_categories' ); +add_action( 'abilities_api_categories_init', 'my_plugin_register_categories' ); ``` ### `before_execute_ability` diff --git a/includes/abilities-api/class-wp-abilities-category-registry.php b/includes/abilities-api/class-wp-abilities-category-registry.php index 15b42c64..e657ea9f 100644 --- a/includes/abilities-api/class-wp-abilities-category-registry.php +++ b/includes/abilities-api/class-wp-abilities-category-registry.php @@ -56,13 +56,13 @@ final class WP_Abilities_Category_Registry { * } $args */ public function register( string $slug, array $args ): ?WP_Ability_Category { - if ( ! doing_action( 'abilities_api_category_registry_init' ) ) { + if ( ! doing_action( 'abilities_api_categories_init' ) ) { _doing_it_wrong( __METHOD__, sprintf( - /* translators: 1: abilities_api_category_registry_init, 2: category slug. */ + /* 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_category_registry_init', + 'abilities_api_categories_init', '' . esc_html( $slug ) . '' ), '0.3.0' @@ -218,7 +218,7 @@ public static function get_instance(): self { * * @param \WP_Abilities_Category_Registry $instance Categories registry object. */ - do_action( 'abilities_api_category_registry_init', self::$instance ); + do_action( 'abilities_api_categories_init', self::$instance ); } return self::$instance; diff --git a/tests/unit/abilities-api/wpAbilitiesRegistry.php b/tests/unit/abilities-api/wpAbilitiesRegistry.php index c907602f..cfb28e13 100644 --- a/tests/unit/abilities-api/wpAbilitiesRegistry.php +++ b/tests/unit/abilities-api/wpAbilitiesRegistry.php @@ -31,7 +31,7 @@ public function set_up(): void { // Register category during the hook. add_action( - 'abilities_api_category_registry_init', + 'abilities_api_categories_init', function () { if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( 'math' ) ) { wp_register_ability_category( @@ -46,7 +46,7 @@ function () { ); // Fire the hook to allow category registration. - do_action( 'abilities_api_category_registry_init' ); + do_action( 'abilities_api_categories_init' ); self::$test_ability_args = array( 'label' => 'Add numbers', diff --git a/tests/unit/abilities-api/wpAbility.php b/tests/unit/abilities-api/wpAbility.php index d62a5b13..16dfff50 100644 --- a/tests/unit/abilities-api/wpAbility.php +++ b/tests/unit/abilities-api/wpAbility.php @@ -20,7 +20,7 @@ public function set_up(): void { // Register category during the hook. add_action( - 'abilities_api_category_registry_init', + 'abilities_api_categories_init', function () { if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( 'math' ) ) { wp_register_ability_category( @@ -35,7 +35,7 @@ function () { ); // Fire the hook to allow category registration. - do_action( 'abilities_api_category_registry_init' ); + do_action( 'abilities_api_categories_init' ); self::$test_ability_properties = array( 'label' => 'Calculator', diff --git a/tests/unit/abilities-api/wpAbilityCategory.php b/tests/unit/abilities-api/wpAbilityCategory.php index b3f07e87..0fd7e2ef 100644 --- a/tests/unit/abilities-api/wpAbilityCategory.php +++ b/tests/unit/abilities-api/wpAbilityCategory.php @@ -51,12 +51,12 @@ public function tear_down(): void { private function register_category_during_hook( string $slug, array $args ): ?WP_Ability_Category { $result = null; add_action( - 'abilities_api_category_registry_init', + 'abilities_api_categories_init', function () use ( $slug, $args, &$result ) { $result = wp_register_ability_category( $slug, $args ); } ); - do_action( 'abilities_api_category_registry_init' ); + do_action( 'abilities_api_categories_init' ); return $result; } @@ -146,7 +146,7 @@ public function test_register_category_missing_description(): void { } /** - * Test registering category before abilities_api_category_registry_init hook. + * Test registering category before abilities_api_categories_init hook. * * @expectedIncorrectUsage WP_Abilities_Category_Registry::register */ @@ -154,10 +154,10 @@ public function test_register_category_before_init_hook(): void { global $wp_actions; // Store original count. - $original_count = isset( $wp_actions['abilities_api_category_registry_init'] ) ? $wp_actions['abilities_api_category_registry_init'] : 0; + $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_category_registry_init'] ); + unset( $wp_actions['abilities_api_categories_init'] ); $result = wp_register_ability_category( 'test-math', @@ -169,7 +169,7 @@ public function test_register_category_before_init_hook(): void { // Restore original count. if ( $original_count > 0 ) { - $wp_actions['abilities_api_category_registry_init'] = $original_count; + $wp_actions['abilities_api_categories_init'] = $original_count; } $this->assertNull( $result ); @@ -183,7 +183,7 @@ public function test_register_category_before_init_hook(): void { public function test_register_duplicate_category(): void { $result = null; add_action( - 'abilities_api_category_registry_init', + 'abilities_api_categories_init', function () use ( &$result ) { wp_register_ability_category( 'test-math', @@ -202,7 +202,7 @@ function () use ( &$result ) { ); } ); - do_action( 'abilities_api_category_registry_init' ); + do_action( 'abilities_api_categories_init' ); $this->assertNull( $result ); } @@ -270,7 +270,7 @@ public function test_get_nonexistent_category(): void { */ public function test_get_all_categories(): void { add_action( - 'abilities_api_category_registry_init', + 'abilities_api_categories_init', function () { wp_register_ability_category( 'test-math', @@ -289,7 +289,7 @@ function () { ); } ); - do_action( 'abilities_api_category_registry_init' ); + do_action( 'abilities_api_categories_init' ); $categories = wp_get_ability_categories(); @@ -346,7 +346,7 @@ public function test_ability_requires_existing_category(): void { */ public function test_ability_with_valid_category(): void { add_action( - 'abilities_api_category_registry_init', + 'abilities_api_categories_init', function () { wp_register_ability_category( 'test-math', @@ -357,7 +357,7 @@ function () { ); } ); - do_action( 'abilities_api_category_registry_init' ); + do_action( 'abilities_api_categories_init' ); do_action( 'abilities_api_init' ); $result = wp_register_ability( @@ -420,7 +420,7 @@ public function test_category_slug_valid_formats(): void { ); add_action( - 'abilities_api_category_registry_init', + 'abilities_api_categories_init', function () use ( $valid_slugs ) { foreach ( $valid_slugs as $slug ) { $result = wp_register_ability_category( @@ -435,7 +435,7 @@ function () use ( $valid_slugs ) { } } ); - do_action( 'abilities_api_category_registry_init' ); + do_action( 'abilities_api_categories_init' ); } /** @@ -456,7 +456,7 @@ public function test_category_slug_invalid_formats(): void { ); add_action( - 'abilities_api_category_registry_init', + 'abilities_api_categories_init', function () use ( $invalid_slugs ) { foreach ( $invalid_slugs as $slug ) { $result = wp_register_ability_category( @@ -471,7 +471,7 @@ function () use ( $invalid_slugs ) { } } ); - do_action( 'abilities_api_category_registry_init' ); + do_action( 'abilities_api_categories_init' ); } /** @@ -479,7 +479,7 @@ function () use ( $invalid_slugs ) { */ public function test_filtering_abilities_by_category(): void { add_action( - 'abilities_api_category_registry_init', + 'abilities_api_categories_init', function () { // Register categories. wp_register_ability_category( @@ -499,7 +499,7 @@ function () { ); } ); - do_action( 'abilities_api_category_registry_init' ); + do_action( 'abilities_api_categories_init' ); do_action( 'abilities_api_init' ); // Register abilities. diff --git a/tests/unit/abilities-api/wpRegisterAbility.php b/tests/unit/abilities-api/wpRegisterAbility.php index 64e4f10c..c04f61cb 100644 --- a/tests/unit/abilities-api/wpRegisterAbility.php +++ b/tests/unit/abilities-api/wpRegisterAbility.php @@ -32,7 +32,7 @@ public function set_up(): void { // Register category during the hook. add_action( - 'abilities_api_category_registry_init', + 'abilities_api_categories_init', function () { if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( 'math' ) ) { wp_register_ability_category( @@ -47,7 +47,7 @@ function () { ); // Fire the hook to allow category registration. - do_action( 'abilities_api_category_registry_init' ); + do_action( 'abilities_api_categories_init' ); self::$test_ability_args = array( 'label' => 'Add numbers', diff --git a/tests/unit/rest-api/wpRestAbilitiesListController.php b/tests/unit/rest-api/wpRestAbilitiesListController.php index c40e284c..78ea5bde 100644 --- a/tests/unit/rest-api/wpRestAbilitiesListController.php +++ b/tests/unit/rest-api/wpRestAbilitiesListController.php @@ -52,10 +52,10 @@ public function set_up(): void { // Register test categories during the hook add_action( - 'abilities_api_category_registry_init', + 'abilities_api_categories_init', array( $this, 'register_test_categories' ) ); - do_action( 'abilities_api_category_registry_init' ); + do_action( 'abilities_api_categories_init' ); // Initialize abilities API do_action( 'abilities_api_init' ); diff --git a/tests/unit/rest-api/wpRestAbilitiesRunController.php b/tests/unit/rest-api/wpRestAbilitiesRunController.php index 94789de0..721c1162 100644 --- a/tests/unit/rest-api/wpRestAbilitiesRunController.php +++ b/tests/unit/rest-api/wpRestAbilitiesRunController.php @@ -63,10 +63,10 @@ public function set_up(): void { // Register test categories during the hook add_action( - 'abilities_api_category_registry_init', + 'abilities_api_categories_init', array( $this, 'register_test_categories' ) ); - do_action( 'abilities_api_category_registry_init' ); + do_action( 'abilities_api_categories_init' ); do_action( 'abilities_api_init' ); From b82607d23ec2e25b52aaa0392d5cdd997e4a5291 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Wed, 8 Oct 2025 00:12:16 +0300 Subject: [PATCH 21/50] Remove ability filtering by category Removes the `wp_get_abilities_by_category()` function and its underlying implementation from the abilities registry. --- includes/abilities-api.php | 14 ---- .../class-wp-abilities-registry.php | 24 ------- .../unit/abilities-api/wpAbilityCategory.php | 70 ------------------- 3 files changed, 108 deletions(-) diff --git a/includes/abilities-api.php b/includes/abilities-api.php index d2bac29d..4bd0b2c6 100644 --- a/includes/abilities-api.php +++ b/includes/abilities-api.php @@ -100,20 +100,6 @@ function wp_get_abilities(): array { return WP_Abilities_Registry::get_instance()->get_all_registered(); } -/** - * Retrieves abilities filtered by category. - * - * @since 0.3.0 - * - * @see WP_Abilities_Registry::get_abilities_by_category() - * - * @param string $category The category slug to filter by. - * @return \WP_Ability[] The array of abilities in the specified category. - */ -function wp_get_abilities_by_category( string $category ): array { - return WP_Abilities_Registry::get_instance()->get_abilities_by_category( $category ); -} - /** * Registers a new ability category. * diff --git a/includes/abilities-api/class-wp-abilities-registry.php b/includes/abilities-api/class-wp-abilities-registry.php index c9232589..aa7980a4 100644 --- a/includes/abilities-api/class-wp-abilities-registry.php +++ b/includes/abilities-api/class-wp-abilities-registry.php @@ -224,30 +224,6 @@ public function get_registered( string $name ): ?WP_Ability { return $this->registered_abilities[ $name ]; } - /** - * Retrieves abilities filtered by category. - * - * Do not use this method directly. Instead, use the `wp_get_abilities_by_category()` function. - * - * @since 0.3.0 - * - * @see wp_get_abilities_by_category() - * - * @param string $category The category slug to filter by. - * @return \WP_Ability[] The array of abilities in the specified category. - */ - public function get_abilities_by_category( string $category ): array { - $filtered = array(); - foreach ( $this->registered_abilities as $ability ) { - if ( $ability->get_category() !== $category ) { - continue; - } - - $filtered[ $ability->get_name() ] = $ability; - } - return $filtered; - } - /** * Utility method to retrieve the main instance of the registry class. * diff --git a/tests/unit/abilities-api/wpAbilityCategory.php b/tests/unit/abilities-api/wpAbilityCategory.php index 0fd7e2ef..9a4a406c 100644 --- a/tests/unit/abilities-api/wpAbilityCategory.php +++ b/tests/unit/abilities-api/wpAbilityCategory.php @@ -474,76 +474,6 @@ function () use ( $invalid_slugs ) { do_action( 'abilities_api_categories_init' ); } - /** - * Test filtering abilities by category. - */ - public function test_filtering_abilities_by_category(): void { - add_action( - 'abilities_api_categories_init', - function () { - // Register categories. - wp_register_ability_category( - 'test-math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations.', - ) - ); - - wp_register_ability_category( - 'test-system', - array( - 'label' => 'System', - 'description' => 'System operations.', - ) - ); - } - ); - do_action( 'abilities_api_categories_init' ); - do_action( 'abilities_api_init' ); - - // Register abilities. - wp_register_ability( - 'test/add', - array( - 'label' => 'Add', - 'description' => 'Adds numbers.', - 'category' => 'test-math', - 'execute_callback' => static function () { - return 1; - }, - 'permission_callback' => '__return_true', - ) - ); - - wp_register_ability( - 'test/info', - array( - 'label' => 'Info', - 'description' => 'Gets info.', - 'category' => 'test-system', - 'execute_callback' => static function () { - return 2; - }, - 'permission_callback' => '__return_true', - ) - ); - - // Filter by category. - $math_abilities = WP_Abilities_Registry::get_instance()->get_abilities_by_category( 'test-math' ); - $system_abilities = WP_Abilities_Registry::get_instance()->get_abilities_by_category( 'test-system' ); - - $this->assertCount( 1, $math_abilities ); - $this->assertArrayHasKey( 'test/add', $math_abilities ); - - $this->assertCount( 1, $system_abilities ); - $this->assertArrayHasKey( 'test/info', $system_abilities ); - - // Cleanup. - wp_unregister_ability( 'test/add' ); - wp_unregister_ability( 'test/info' ); - } - /** * Test category registry initialization order. */ From 110d403450ea5ba6ef5768dafeaf9b3336f7b0f6 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Wed, 8 Oct 2025 11:09:00 +0300 Subject: [PATCH 22/50] Improve tests for ability category validation Enhances the unit test suite for the Abilities API by introducing a helper to capture and assert `_doing_it_wrong()` notices. This allows for more explicit and robust testing of error conditions. Existing tests are updated to assert that incorrect usage, such as providing an invalid slug or registering a duplicate category, triggers the expected notice. New test cases are also added to cover: - Invalid arguments for label and description (non-string, empty). - The `register_ability_category_args` filter. - An exception being thrown when attempting to unserialize a category object. --- .../unit/abilities-api/wpAbilityCategory.php | 395 +++++++++++++----- 1 file changed, 281 insertions(+), 114 deletions(-) diff --git a/tests/unit/abilities-api/wpAbilityCategory.php b/tests/unit/abilities-api/wpAbilityCategory.php index 9a4a406c..5e9437eb 100644 --- a/tests/unit/abilities-api/wpAbilityCategory.php +++ b/tests/unit/abilities-api/wpAbilityCategory.php @@ -21,42 +21,102 @@ class Tests_Abilities_API_WpAbilityCategory extends WP_UnitTestCase { */ private $registry; + /** + * Captured `_doing_it_wrong` calls during a test. + * + * @var array + */ + 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->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' ), 10 ); + $this->doing_it_wrong_log = array(); + // Clean up all test categories. $categories = $this->registry->get_all_registered(); foreach ( $categories as $category ) { - if ( str_starts_with( $category->get_slug(), 'test-' ) ) { - $this->registry->unregister( $category->get_slug() ); + 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; - add_action( - 'abilities_api_categories_init', - function () use ( $slug, $args, &$result ) { - $result = wp_register_ability_category( $slug, $args ); - } - ); - do_action( 'abilities_api_categories_init' ); + $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; } @@ -94,6 +154,7 @@ public function test_register_category_invalid_slug_format(): void { ); $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'slug must contain only lowercase' ); } /** @@ -111,6 +172,7 @@ public function test_register_category_invalid_slug_underscore(): void { ); $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'slug must contain only lowercase' ); } /** @@ -127,6 +189,7 @@ public function test_register_category_missing_label(): void { ); $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); } /** @@ -143,6 +206,7 @@ public function test_register_category_missing_description(): void { ); $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); } /** @@ -173,6 +237,7 @@ public function test_register_category_before_init_hook(): void { } $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'abilities_api_categories_init' ); } /** @@ -181,30 +246,31 @@ public function test_register_category_before_init_hook(): void { * @expectedIncorrectUsage WP_Abilities_Category_Registry::register */ public function test_register_duplicate_category(): void { - $result = null; - add_action( - 'abilities_api_categories_init', - function () use ( &$result ) { - 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.', - ) - ); - } - ); - do_action( 'abilities_api_categories_init' ); + $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' ); } /** @@ -234,6 +300,7 @@ public function test_unregister_nonexistent_category(): void { $result = wp_unregister_ability_category( 'test-nonexistent' ); $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::unregister' ); } /** @@ -263,33 +330,34 @@ 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 { - add_action( - 'abilities_api_categories_init', - function () { - wp_register_ability_category( - 'test-math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations.', - ) - ); - - wp_register_ability_category( - 'test-system', - array( - 'label' => 'System', - 'description' => 'System operations.', - ) - ); - } - ); - do_action( 'abilities_api_categories_init' ); + $callback = static function (): void { + wp_register_ability_category( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + wp_register_ability_category( + 'test-system', + array( + 'label' => 'System', + 'description' => 'System operations.', + ) + ); + }; + + 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 ); $categories = wp_get_ability_categories(); @@ -339,25 +407,26 @@ public function test_ability_requires_existing_category(): void { ); $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 { - add_action( - 'abilities_api_categories_init', - function () { - wp_register_ability_category( - 'test-math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations.', - ) - ); - } - ); - do_action( 'abilities_api_categories_init' ); + $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( @@ -419,23 +488,23 @@ public function test_category_slug_valid_formats(): void { 'test-123', ); - add_action( - 'abilities_api_categories_init', - function () use ( $valid_slugs ) { - foreach ( $valid_slugs as $slug ) { - $result = wp_register_ability_category( - $slug, - array( - 'label' => 'Test', - 'description' => 'Test description.', - ) - ); - - $this->assertInstanceOf( WP_Ability_Category::class, $result, "Slug '{$slug}' should be valid" ); - } + $callback = function () use ( $valid_slugs ): void { + foreach ( $valid_slugs as $slug ) { + $result = wp_register_ability_category( + $slug, + array( + 'label' => 'Test', + 'description' => 'Test description.', + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result, "Slug '{$slug}' should be valid" ); } - ); - do_action( 'abilities_api_categories_init' ); + }; + + 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 ); } /** @@ -455,44 +524,142 @@ public function test_category_slug_invalid_formats(): void { 'test--double-dash', ); - add_action( - 'abilities_api_categories_init', - function () use ( $invalid_slugs ) { - foreach ( $invalid_slugs as $slug ) { - $result = wp_register_ability_category( - $slug, - array( - 'label' => 'Test', - 'description' => 'Test description.', - ) - ); - - $this->assertNull( $result, "Slug '{$slug}' should be invalid" ); - } + $callback = function () use ( $invalid_slugs ): void { + foreach ( $invalid_slugs as $slug ) { + $result = wp_register_ability_category( + $slug, + array( + 'label' => 'Test', + 'description' => 'Test description.', + ) + ); + + $this->assertNull( $result, "Slug '{$slug}' should be invalid" ); } + }; + + 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->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' => '', + ) ); - do_action( 'abilities_api_categories_init' ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); } /** - * Test category registry initialization order. + * Test register_ability_category_args filter. */ - public function test_category_registry_initializes_before_abilities(): void { - // Reset registries. - $registry_reflection = new ReflectionClass( WP_Abilities_Registry::class ); - $instance_prop = $registry_reflection->getProperty( 'instance' ); - $instance_prop->setAccessible( true ); - $instance_prop->setValue( null, null ); + 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.', + ) + ); - $category_reflection = new ReflectionClass( WP_Abilities_Category_Registry::class ); - $category_prop = $category_reflection->getProperty( 'instance' ); - $category_prop->setAccessible( true ); - $category_prop->setValue( null, null ); + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( 'Filtered Label', $result->get_label() ); + $this->assertSame( 'Filtered Description', $result->get_description() ); + } - // Get abilities registry (should initialize categories first). - $abilities_registry = WP_Abilities_Registry::get_instance(); + /** + * 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.', + ) + ); - // Verify category registry is initialized. - $this->assertInstanceOf( WP_Abilities_Category_Registry::class, WP_Abilities_Category_Registry::get_instance() ); + $this->expectException( \LogicException::class ); + $serialized = serialize( $category ); + unserialize( $serialized ); } } From 7e4e1951596e9536cd36ff9eee5a284db217f74f Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Wed, 8 Oct 2025 11:56:29 +0300 Subject: [PATCH 23/50] Fix: Correct formatting and assertions in unit tests Corrects a malformed comment block and adjusts a method's closing brace. Updates a test assertion for the REST API to match the expected metadata. --- tests/unit/abilities-api/wpAbility.php | 4 +++- tests/unit/rest-api/wpRestAbilitiesListController.php | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit/abilities-api/wpAbility.php b/tests/unit/abilities-api/wpAbility.php index 468c9de5..aaf22bbb 100644 --- a/tests/unit/abilities-api/wpAbility.php +++ b/tests/unit/abilities-api/wpAbility.php @@ -73,8 +73,10 @@ public function tear_down(): void { } parent::tear_down(); + } - * Tests getting all annotations when selective overrides are applied. + /* + * Tests getting all annotations when selective overrides are applied. */ public function test_get_all_annotations() { $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); diff --git a/tests/unit/rest-api/wpRestAbilitiesListController.php b/tests/unit/rest-api/wpRestAbilitiesListController.php index 1be5b401..70ebce1d 100644 --- a/tests/unit/rest-api/wpRestAbilitiesListController.php +++ b/tests/unit/rest-api/wpRestAbilitiesListController.php @@ -270,7 +270,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->assertEquals( 'bar', $data['meta']['foo'] ); } /** From 9de15dd6c8e56b44665db3c709e4c040c56a048f Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Thu, 9 Oct 2025 11:05:31 +0300 Subject: [PATCH 24/50] Changes the version annotations from `0.3.0` to `n.e.x.t` --- includes/abilities-api.php | 8 +++--- .../class-wp-abilities-category-registry.php | 26 +++++++++---------- .../class-wp-ability-category.php | 22 ++++++++-------- includes/abilities-api/class-wp-ability.php | 4 +-- 4 files changed, 30 insertions(+), 30 deletions(-) diff --git a/includes/abilities-api.php b/includes/abilities-api.php index 2ba3f3b7..be5eaee7 100644 --- a/includes/abilities-api.php +++ b/includes/abilities-api.php @@ -104,7 +104,7 @@ function wp_get_abilities(): array { /** * Registers a new ability category. * - * @since 0.3.0 + * @since n.e.x.t * * @see WP_Abilities_Category_Registry::register() * @@ -127,7 +127,7 @@ function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_ /** * Unregisters an ability category. * - * @since 0.3.0 + * @since n.e.x.t * * @see WP_Abilities_Category_Registry::unregister() * @@ -141,7 +141,7 @@ function wp_unregister_ability_category( string $slug ): ?WP_Ability_Category { /** * Retrieves a registered ability category. * - * @since 0.3.0 + * @since n.e.x.t * * @see WP_Abilities_Category_Registry::get_registered() * @@ -155,7 +155,7 @@ function wp_get_ability_category( string $slug ): ?WP_Ability_Category { /** * Retrieves all registered ability categories. * - * @since 0.3.0 + * @since n.e.x.t * * @see WP_Abilities_Category_Registry::get_all_registered() * diff --git a/includes/abilities-api/class-wp-abilities-category-registry.php b/includes/abilities-api/class-wp-abilities-category-registry.php index e657ea9f..f8022c90 100644 --- a/includes/abilities-api/class-wp-abilities-category-registry.php +++ b/includes/abilities-api/class-wp-abilities-category-registry.php @@ -6,7 +6,7 @@ * * @package WordPress * @subpackage Abilities API - * @since 0.3.0 + * @since n.e.x.t */ declare( strict_types = 1 ); @@ -14,14 +14,14 @@ /** * Manages the registration and lookup of ability categories. * - * @since 0.3.0 + * @since n.e.x.t * @access private */ final class WP_Abilities_Category_Registry { /** * The singleton instance of the registry. * - * @since 0.3.0 + * @since n.e.x.t * @var ?self */ private static $instance = null; @@ -29,7 +29,7 @@ final class WP_Abilities_Category_Registry { /** * Holds the registered categories. * - * @since 0.3.0 + * @since n.e.x.t * @var \WP_Ability_Category[] */ private $registered_categories = array(); @@ -39,7 +39,7 @@ final class WP_Abilities_Category_Registry { * * Do not use this method directly. Instead, use the `wp_register_ability_category()` function. * - * @since 0.3.0 + * @since n.e.x.t * * @see wp_register_ability_category() * @@ -92,7 +92,7 @@ public function register( string $slug, array $args ): ?WP_Ability_Category { /** * Filters the category arguments before they are validated and used to instantiate the category. * - * @since 0.3.0 + * @since n.e.x.t * * @param array $args The arguments used to instantiate the category. * @param string $slug The slug of the category. @@ -120,7 +120,7 @@ public function register( string $slug, array $args ): ?WP_Ability_Category { * * Do not use this method directly. Instead, use the `wp_unregister_ability_category()` function. * - * @since 0.3.0 + * @since n.e.x.t * * @see wp_unregister_ability_category() * @@ -149,7 +149,7 @@ public function unregister( string $slug ): ?WP_Ability_Category { * * Do not use this method directly. Instead, use the `wp_get_ability_categories()` function. * - * @since 0.3.0 + * @since n.e.x.t * * @see wp_get_ability_categories() * @@ -162,7 +162,7 @@ public function get_all_registered(): array { /** * Checks if a category is registered. * - * @since 0.3.0 + * @since n.e.x.t * * @param string $slug The slug of the category. * @return bool True if the category is registered, false otherwise. @@ -176,7 +176,7 @@ public function is_registered( string $slug ): bool { * * Do not use this method directly. Instead, use the `wp_get_ability_category()` function. * - * @since 0.3.0 + * @since n.e.x.t * * @see wp_get_ability_category() * @@ -201,7 +201,7 @@ public function get_registered( string $slug ): ?WP_Ability_Category { * * The instance will be created if it does not exist yet. * - * @since 0.3.0 + * @since n.e.x.t * * @return \WP_Abilities_Category_Registry The main registry instance. */ @@ -214,7 +214,7 @@ public static function get_instance(): self { * * Categories should be registered on this action to ensure they're available when needed. * - * @since 0.3.0 + * @since n.e.x.t * * @param \WP_Abilities_Category_Registry $instance Categories registry object. */ @@ -227,7 +227,7 @@ public static function get_instance(): self { /** * Wakeup magic method. * - * @since 0.3.0 + * @since n.e.x.t * @throws \UnexpectedValueException If any of the registered categories is not an instance of WP_Ability_Category. */ public function __wakeup(): void { diff --git a/includes/abilities-api/class-wp-ability-category.php b/includes/abilities-api/class-wp-ability-category.php index faa29445..5f88728c 100644 --- a/includes/abilities-api/class-wp-ability-category.php +++ b/includes/abilities-api/class-wp-ability-category.php @@ -6,7 +6,7 @@ * * @package WordPress * @subpackage Abilities API - * @since 0.3.0 + * @since n.e.x.t */ declare( strict_types = 1 ); @@ -14,7 +14,7 @@ /** * Encapsulates the properties and methods related to a specific ability category. * - * @since 0.3.0 + * @since n.e.x.t * * @see WP_Abilities_Category_Registry */ @@ -23,7 +23,7 @@ class WP_Ability_Category { /** * The unique slug for the category. * - * @since 0.3.0 + * @since n.e.x.t * @var string */ protected $slug; @@ -31,7 +31,7 @@ class WP_Ability_Category { /** * The human-readable category label. * - * @since 0.3.0 + * @since n.e.x.t * @var string */ protected $label; @@ -39,7 +39,7 @@ class WP_Ability_Category { /** * The detailed category description. * - * @since 0.3.0 + * @since n.e.x.t * @var string */ protected $description; @@ -51,7 +51,7 @@ class WP_Ability_Category { * * @access private * - * @since 0.3.0 + * @since n.e.x.t * * @see wp_register_ability_category() * @@ -76,7 +76,7 @@ public function __construct( string $slug, array $args ) { /** * Prepares and validates the properties used to instantiate the category. * - * @since 0.3.0 + * @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. @@ -111,7 +111,7 @@ protected function prepare_properties( array $args ): array { /** * Retrieves the slug of the category. * - * @since 0.3.0 + * @since n.e.x.t * * @return string The category slug. */ @@ -122,7 +122,7 @@ public function get_slug(): string { /** * Retrieves the human-readable label for the category. * - * @since 0.3.0 + * @since n.e.x.t * * @return string The human-readable category label. */ @@ -133,7 +133,7 @@ public function get_label(): string { /** * Retrieves the detailed description for the category. * - * @since 0.3.0 + * @since n.e.x.t * * @return string The detailed description for the category. */ @@ -144,7 +144,7 @@ public function get_description(): string { /** * Wakeup magic method. * - * @since 0.3.0 + * @since n.e.x.t */ public function __wakeup(): void { throw new \LogicException( self::class . ' should never be unserialized.' ); diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index 7f276fb3..85b1a8d6 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -119,7 +119,7 @@ class WP_Ability { /** * The ability category (required). * - * @since 0.3.0 + * @since n.e.x.t * @var string */ protected $category; @@ -347,7 +347,7 @@ public function get_meta(): array { /** * Retrieves the category for the ability. * - * @since 0.3.0 + * @since n.e.x.t * * @return string The category for the ability. */ From 7a5047bbba7bd1aae585047fdceb757d0309af0a Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Thu, 9 Oct 2025 11:10:52 +0300 Subject: [PATCH 25/50] Update version annotations from `0.3.0` to `n.e.x.t` in abilities category and registry classes --- .../class-wp-abilities-category-registry.php | 12 ++++++------ .../abilities-api/class-wp-abilities-registry.php | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/includes/abilities-api/class-wp-abilities-category-registry.php b/includes/abilities-api/class-wp-abilities-category-registry.php index f8022c90..5066e503 100644 --- a/includes/abilities-api/class-wp-abilities-category-registry.php +++ b/includes/abilities-api/class-wp-abilities-category-registry.php @@ -65,7 +65,7 @@ public function register( string $slug, array $args ): ?WP_Ability_Category { 'abilities_api_categories_init', '' . esc_html( $slug ) . '' ), - '0.3.0' + 'n.e.x.t' ); return null; } @@ -75,7 +75,7 @@ public function register( string $slug, array $args ): ?WP_Ability_Category { __METHOD__, /* translators: %s: Category slug. */ esc_html( sprintf( __( 'Category "%s" is already registered.' ), $slug ) ), - '0.3.0' + 'n.e.x.t' ); return null; } @@ -84,7 +84,7 @@ public function register( string $slug, array $args ): ?WP_Ability_Category { _doing_it_wrong( __METHOD__, esc_html__( 'Category slug must contain only lowercase alphanumeric characters and dashes.' ), - '0.3.0' + 'n.e.x.t' ); return null; } @@ -106,7 +106,7 @@ public function register( string $slug, array $args ): ?WP_Ability_Category { _doing_it_wrong( __METHOD__, esc_html( $e->getMessage() ), - '0.3.0' + 'n.e.x.t' ); return null; } @@ -133,7 +133,7 @@ public function unregister( string $slug ): ?WP_Ability_Category { __METHOD__, /* translators: %s: Category slug. */ sprintf( esc_html__( 'Category "%s" not found.' ), esc_attr( $slug ) ), - '0.3.0' + 'n.e.x.t' ); return null; } @@ -189,7 +189,7 @@ public function get_registered( string $slug ): ?WP_Ability_Category { __METHOD__, /* translators: %s: Category slug. */ sprintf( esc_html__( 'Category "%s" not found.' ), esc_attr( $slug ) ), - '0.3.0' + 'n.e.x.t' ); return null; } diff --git a/includes/abilities-api/class-wp-abilities-registry.php b/includes/abilities-api/class-wp-abilities-registry.php index fc442c7a..4608b1c1 100644 --- a/includes/abilities-api/class-wp-abilities-registry.php +++ b/includes/abilities-api/class-wp-abilities-registry.php @@ -108,7 +108,7 @@ public function register( string $name, array $args ): ?WP_Ability { esc_attr( $args['category'] ), esc_attr( $name ) ), - '0.3.0' + 'n.e.x.t' ); return null; } From c093d32ebfed029e626aa049860f468e7dda35ad Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Thu, 9 Oct 2025 11:14:04 +0300 Subject: [PATCH 26/50] Revert version to 0.2.0 --- includes/bootstrap.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/bootstrap.php b/includes/bootstrap.php index ae23de56..4838495a 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -19,7 +19,7 @@ // Version of the plugin. if ( ! defined( 'WP_ABILITIES_API_VERSION' ) ) { - define( 'WP_ABILITIES_API_VERSION', '0.3.0' ); + define( 'WP_ABILITIES_API_VERSION', '0.2.0' ); } // Load core classes if they are not already defined (for non-Composer installs or direct includes). From 308e62c1e27c775c126e313c3ee3e93ed5e42d02 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Thu, 9 Oct 2025 12:34:12 +0300 Subject: [PATCH 27/50] Removes slug sanitization for ability categories The validation is done on WP_Abilities_Category_Registry::register() --- includes/abilities-api/class-wp-ability-category.php | 2 +- includes/abilities-api/class-wp-ability.php | 1 - tests/unit/abilities-api/wpAbilityCategory.php | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/includes/abilities-api/class-wp-ability-category.php b/includes/abilities-api/class-wp-ability-category.php index 5f88728c..5bdea9e2 100644 --- a/includes/abilities-api/class-wp-ability-category.php +++ b/includes/abilities-api/class-wp-ability-category.php @@ -65,7 +65,7 @@ public function __construct( string $slug, array $args ) { ); } - $this->slug = sanitize_title( $slug ); + $this->slug = $slug; $properties = $this->prepare_properties( $args ); diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index 85b1a8d6..d8474e98 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -262,7 +262,6 @@ protected function prepare_properties( array $args ): array { static::$default_annotations ); - return $args; } diff --git a/tests/unit/abilities-api/wpAbilityCategory.php b/tests/unit/abilities-api/wpAbilityCategory.php index 5e9437eb..2185bca7 100644 --- a/tests/unit/abilities-api/wpAbilityCategory.php +++ b/tests/unit/abilities-api/wpAbilityCategory.php @@ -17,7 +17,7 @@ class Tests_Abilities_API_WpAbilityCategory extends WP_UnitTestCase { /** * Category registry instance. * - * @var WP_Abilities_Category_Registry + * @var \WP_Abilities_Category_Registry */ private $registry; From 6bde79ad4648abb31c22b57a13f41d5afed62de3 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Thu, 9 Oct 2025 13:13:42 +0300 Subject: [PATCH 28/50] test: Replace conditional checks with assertions for non-existent categories Improved test quality by using assertions instead of conditional checks when verifying behavior with non-existent categories. This ensures tests fail loudly if test isolation is broken, rather than silently working around the problem. Changes: - test_register_ability_nonexistent_category: Assert category doesn't exist before testing - test_ability_requires_existing_category: Assert category doesn't exist before testing - test_filter_by_nonexistent_category: Assert category doesn't exist before testing This makes test intent clearer and helps catch test isolation issues early. --- tests/unit/abilities-api/wpAbilityCategory.php | 6 ++++++ tests/unit/abilities-api/wpRegisterAbility.php | 9 +++++---- tests/unit/rest-api/wpRestAbilitiesListController.php | 6 ++++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/unit/abilities-api/wpAbilityCategory.php b/tests/unit/abilities-api/wpAbilityCategory.php index 2185bca7..68e21e27 100644 --- a/tests/unit/abilities-api/wpAbilityCategory.php +++ b/tests/unit/abilities-api/wpAbilityCategory.php @@ -392,6 +392,12 @@ public function test_category_is_registered(): void { 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', diff --git a/tests/unit/abilities-api/wpRegisterAbility.php b/tests/unit/abilities-api/wpRegisterAbility.php index 83c6b600..0a0251c0 100644 --- a/tests/unit/abilities-api/wpRegisterAbility.php +++ b/tests/unit/abilities-api/wpRegisterAbility.php @@ -500,10 +500,11 @@ public function test_get_all_registered_abilities() { public function test_register_ability_nonexistent_category(): void { do_action( 'abilities_api_init' ); - // Ensure category doesn't exist. - if ( WP_Abilities_Category_Registry::get_instance()->is_registered( 'nonexistent' ) ) { - wp_unregister_ability_category( 'nonexistent' ); - } + // 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, diff --git a/tests/unit/rest-api/wpRestAbilitiesListController.php b/tests/unit/rest-api/wpRestAbilitiesListController.php index 70ebce1d..6fcacaa1 100644 --- a/tests/unit/rest-api/wpRestAbilitiesListController.php +++ b/tests/unit/rest-api/wpRestAbilitiesListController.php @@ -614,6 +614,12 @@ public function test_filter_by_category(): void { * 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 ); From 99a6eb8b45bff4f3f55e5ec112aaa98d751acf70 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Thu, 9 Oct 2025 13:35:20 +0300 Subject: [PATCH 29/50] Fix: Add missing category to show_in_rest test abilities Fixes test failures after merging trunk by adding the required `category` field to `test/not-show-in-rest` abilities. These abilities were introduced in PR #103 (show_in_rest feature) on trunk before the category requirement existed. Also fixes code bugs issues in WP_Ability class: - Add missing PHPDoc comment opener for show_in_rest property - Add missing PHPDoc comment opener for show_in_rest() method - Remove extra blank line in validate_args() --- includes/abilities-api/class-wp-ability.php | 3 ++- tests/unit/rest-api/wpRestAbilitiesListController.php | 1 + tests/unit/rest-api/wpRestAbilitiesRunController.php | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index 68444674..5a175258 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -124,6 +124,7 @@ class WP_Ability { */ protected $category; + /** * Whether to show the ability in the REST API. * * @since n.e.x.t @@ -272,7 +273,6 @@ protected function prepare_properties( array $args ): array { ); } - // Set defaults for optional args. $args['annotations'] = wp_parse_args( $args['annotations'] ?? array(), @@ -371,6 +371,7 @@ public function get_category(): string { return $this->category; } + /** * Checks whether the ability should be shown in the REST API. * * @since n.e.x.t diff --git a/tests/unit/rest-api/wpRestAbilitiesListController.php b/tests/unit/rest-api/wpRestAbilitiesListController.php index 7ab5a9d7..b49a2f16 100644 --- a/tests/unit/rest-api/wpRestAbilitiesListController.php +++ b/tests/unit/rest-api/wpRestAbilitiesListController.php @@ -225,6 +225,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; }, diff --git a/tests/unit/rest-api/wpRestAbilitiesRunController.php b/tests/unit/rest-api/wpRestAbilitiesRunController.php index 8e48a356..a54bc40e 100644 --- a/tests/unit/rest-api/wpRestAbilitiesRunController.php +++ b/tests/unit/rest-api/wpRestAbilitiesRunController.php @@ -249,6 +249,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; }, From 5b8adbbd11c367c8c24b984e56704bc5e457ed4c Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Thu, 9 Oct 2025 15:32:17 +0300 Subject: [PATCH 30/50] Added a comment to the __wakeup method to clarify that unserialization is disabled as a security measure against vulnerabilities like object injection and remote code execution. --- includes/abilities-api/class-wp-ability-category.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/includes/abilities-api/class-wp-ability-category.php b/includes/abilities-api/class-wp-ability-category.php index 5bdea9e2..857df636 100644 --- a/includes/abilities-api/class-wp-ability-category.php +++ b/includes/abilities-api/class-wp-ability-category.php @@ -144,6 +144,10 @@ public function get_description(): string { /** * Wakeup magic method. * + * Prevents unserialization of this object as a security hardening measure. + * Unserializing objects can lead to security vulnerabilities such as object + * injection attacks and remote code execution. + * * @since n.e.x.t */ public function __wakeup(): void { From f19f0cb1cea9e951293edfde36079bc01a8b4357 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Thu, 9 Oct 2025 16:54:49 +0300 Subject: [PATCH 31/50] Add meta support to ability categories This change adds metadata support to WP_Ability_Category, following the same pattern used in WP_Ability class for consistency. Changes: - Add optional meta property to WP_Ability_Category class - Update constructor to use foreach loop for property assignment - Add validation for meta array in prepare_properties() method - Add get_meta() getter method to retrieve category metadata - Update PHPStan type annotations to include meta parameter - Add comprehensive test coverage for meta functionality The meta field is optional and defaults to an empty array when not provided. This allows developers to attach arbitrary metadata to categories for custom use cases. --- .../class-wp-ability-category.php | 52 ++++++++-- .../unit/abilities-api/wpAbilityCategory.php | 99 ++++++++++++++++++- 2 files changed, 144 insertions(+), 7 deletions(-) diff --git a/includes/abilities-api/class-wp-ability-category.php b/includes/abilities-api/class-wp-ability-category.php index 857df636..f59d0ab9 100644 --- a/includes/abilities-api/class-wp-ability-category.php +++ b/includes/abilities-api/class-wp-ability-category.php @@ -44,6 +44,14 @@ class WP_Ability_Category { */ protected $description; + /** + * The optional category metadata. + * + * @since n.e.x.t + * @var array + */ + protected $meta = array(); + /** * Constructor. * @@ -69,8 +77,24 @@ public function __construct( string $slug, array $args ) { $properties = $this->prepare_properties( $args ); - $this->label = $properties['label']; - $this->description = $properties['description']; + 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; + } } /** @@ -85,6 +109,7 @@ public function __construct( string $slug, array $args ) { * @phpstan-return array{ * label: string, * description: string, + * meta?: array, * ..., * } */ @@ -102,10 +127,14 @@ protected function prepare_properties( array $args ): array { ); } - return array( - 'label' => $args['label'], - 'description' => $args['description'], - ); + // 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; } /** @@ -141,6 +170,17 @@ 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. * diff --git a/tests/unit/abilities-api/wpAbilityCategory.php b/tests/unit/abilities-api/wpAbilityCategory.php index 68e21e27..29dc8b2b 100644 --- a/tests/unit/abilities-api/wpAbilityCategory.php +++ b/tests/unit/abilities-api/wpAbilityCategory.php @@ -38,7 +38,7 @@ public function set_up(): void { $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. @@ -668,4 +668,101 @@ public function test_category_wakeup_throws_exception(): void { $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' ); + } } From 2bb837dc5a075a93f3d93fb00dbedcd340f4b4a3 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Fri, 10 Oct 2025 14:26:05 +0300 Subject: [PATCH 32/50] Remove validation for ability category slug format - The validation is done already when the category is created - Code style improvements --- includes/abilities-api/class-wp-ability.php | 28 ++++++++------------- 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index 5a175258..fc728ac4 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -132,7 +132,6 @@ class WP_Ability { */ protected $show_in_rest = false; - /** * Constructor. * @@ -145,10 +144,10 @@ 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`, `category`, `input_schema`, `output_schema`, `execute_callback`, - * `permission_callback`, `annotations`, `meta`, and `show_in_rest`. + * @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`, + * `annotations`, `meta`, and `show_in_rest`. */ public function __construct( string $name, array $args ) { $this->name = $name; @@ -217,6 +216,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.' ) @@ -254,19 +259,6 @@ protected function prepare_properties( array $args ): array { ); } - // VALIDATE: Category is required and must be a string. - if ( empty( $args['category'] ) || ! is_string( $args['category'] ) ) { - throw new \InvalidArgumentException( - esc_html__( 'The ability properties must contain a `category` string.' ) - ); - } - - // Validate category slug format. - if ( ! preg_match( '/^[a-z0-9]+(-[a-z0-9]+)*$/', $args['category'] ) ) { - throw new \InvalidArgumentException( - esc_html__( 'Category slug must contain only lowercase alphanumeric characters and dashes.' ) - ); - } if ( isset( $args['show_in_rest'] ) && ! is_bool( $args['show_in_rest'] ) ) { throw new \InvalidArgumentException( esc_html__( 'The ability properties should provide a valid `show_in_rest` boolean.' ) From cfe3c240e8418e202a8a6bc00fd0913a86988087 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Fri, 10 Oct 2025 14:26:19 +0300 Subject: [PATCH 33/50] Change WP_Ability_Category class to final --- includes/abilities-api/class-wp-ability-category.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/abilities-api/class-wp-ability-category.php b/includes/abilities-api/class-wp-ability-category.php index f59d0ab9..148dff85 100644 --- a/includes/abilities-api/class-wp-ability-category.php +++ b/includes/abilities-api/class-wp-ability-category.php @@ -18,7 +18,7 @@ * * @see WP_Abilities_Category_Registry */ -class WP_Ability_Category { +final class WP_Ability_Category { /** * The unique slug for the category. From 150461ec07bca700d8bb80222f4309da0bc0db01 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Fri, 10 Oct 2025 23:39:35 +0300 Subject: [PATCH 34/50] Fix: Correct assertion in REST API ability get_item test --- tests/unit/rest-api/wpRestAbilitiesListController.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/rest-api/wpRestAbilitiesListController.php b/tests/unit/rest-api/wpRestAbilitiesListController.php index 117688b3..aac545e0 100644 --- a/tests/unit/rest-api/wpRestAbilitiesListController.php +++ b/tests/unit/rest-api/wpRestAbilitiesListController.php @@ -166,7 +166,8 @@ private function register_test_abilities(): void { return current_user_can( 'read' ); }, 'meta' => array( - 'show_in_rest' => true, ), + 'show_in_rest' => true, + ), ) ); @@ -288,7 +289,7 @@ public function test_get_item(): void { $this->assertArrayHasKey( 'input_schema', $data ); $this->assertArrayHasKey( 'output_schema', $data ); $this->assertArrayHasKey( 'meta', $data ); - $this->assertEquals( 'bar', $data['meta']['foo'] ); + $this->assertTrue( $data['meta']['show_in_rest'] ); } /** From ebdcfb18f4fa1a82cd05827b880707d7f950390e Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Fri, 10 Oct 2025 23:39:35 +0300 Subject: [PATCH 35/50] Refactor: Improve ability category cleanup in tests --- tests/unit/rest-api/wpRestAbilitiesListController.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit/rest-api/wpRestAbilitiesListController.php b/tests/unit/rest-api/wpRestAbilitiesListController.php index aac545e0..1361a6d6 100644 --- a/tests/unit/rest-api/wpRestAbilitiesListController.php +++ b/tests/unit/rest-api/wpRestAbilitiesListController.php @@ -82,9 +82,11 @@ public function tear_down(): void { // Clean up test categories foreach ( array( 'math', 'system', 'general' ) as $slug ) { - if ( WP_Abilities_Category_Registry::get_instance()->is_registered( $slug ) ) { - wp_unregister_ability_category( $slug ); + if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( $slug ) ) { + continue; } + + wp_unregister_ability_category( $slug ); } // Reset REST server From a573fd765b67a28fe778b3e6e85a9bb1b8797bb5 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Fri, 10 Oct 2025 23:39:35 +0300 Subject: [PATCH 36/50] Chore: Fix indentation in WP_Ability class --- includes/abilities-api/class-wp-ability.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index c61b46e7..cce2e04e 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -115,9 +115,9 @@ class WP_Ability { * @since 0.1.0 * @var array */ - protected $meta; + protected $meta; - /** + /** * The ability category (required). * * @since n.e.x.t From ab23e20e6c6565e32761692e1383c7a83db16bd7 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Fri, 10 Oct 2025 23:50:19 +0300 Subject: [PATCH 37/50] Docs: Move category registration to a dedicated file Extracts the documentation for registering ability categories from the "Registering Abilities" page into a new, dedicated file. The new category documentation is also expanded to include details on the optional `meta` parameter, the `register_ability_category_args` filter, and provides more comprehensive examples for slug conventions. --- docs/3.registering-abilities.md | 56 +--------------------- docs/7.registering-categories.md | 80 ++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 55 deletions(-) create mode 100644 docs/7.registering-categories.md diff --git a/docs/3.registering-abilities.md b/docs/3.registering-abilities.md index c958bfc4..2d648a0b 100644 --- a/docs/3.registering-abilities.md +++ b/docs/3.registering-abilities.md @@ -18,7 +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](#registering-categories) below. +- `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. @@ -46,60 +46,6 @@ The `$id` parameter must follow the pattern `namespace/ability-name`: - **Convention:** Use your plugin slug as the namespace, like `my-plugin/ability-name`. - **Examples:** `my-plugin/update-settings`, `woocommerce/get-product`, `contact-form/send-message`, `analytics/track-event` -## Registering Categories - -Before registering abilities, you must register at least one category. Categories help organize abilities and make them easier to discover and filter. - -### `wp_register_ability_category()` - -```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. It Should be translatable. - - `description` (`string`, **Required**): Detailed description of the category's purpose. It Should be translatable. - -**Return:** (`?\WP_Ability_Category`) An instance of the registered category if it was successfully registered, `null` on failure (e.g., invalid arguments, duplicate slug). - -**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 (`-`). -- **Invalid:** No underscores, uppercase letters, spaces, or special characters. -- **Examples:** `data-retrieval`, `ecommerce`, `site-information`, `user-management` - -### 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. - ## Code Examples ### Registering a Simple Data Retrieval Ability diff --git a/docs/7.registering-categories.md b/docs/7.registering-categories.md new file mode 100644 index 00000000..1cb7f5e3 --- /dev/null +++ b/docs/7.registering-categories.md @@ -0,0 +1,80 @@ +# 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. + +## Hooks + +### Filter: `register_ability_category_args` + +Filters the category arguments before they are validated and used to instantiate the category. + +```php +add_filter( 'register_ability_category_args', 'my_plugin_filter_category_args', 10, 2 ); +function my_plugin_filter_category_args( $args, $slug ) { + // Modify $args before validation + return $args; +} +``` + +**Parameters:** +- `$args` (`array`): The arguments used to instantiate the category. +- `$slug` (`string`): The slug of the category being registered. + From 56ccd799d366872580c28c16921d8a5b1965f43b Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 13 Oct 2025 11:05:04 +0300 Subject: [PATCH 38/50] Harden: Prevent serialization of ability and registry classes Disallow serialization and unserialization for the `WP_Ability_Category` and `WP_Abilities_Category_Registry` classes. This is a security hardening measure to mitigate potential object injection vulnerabilities that can arise from processing untrusted serialized data. Throwing an exception from the `__sleep()` and `__wakeup()` magic methods ensures these objects are not intended for storage or transport. --- .../class-wp-abilities-category-registry.php | 18 ++++++++++++------ .../class-wp-ability-category.php | 17 ++++++++++++----- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/includes/abilities-api/class-wp-abilities-category-registry.php b/includes/abilities-api/class-wp-abilities-category-registry.php index 5066e503..46bb0d7f 100644 --- a/includes/abilities-api/class-wp-abilities-category-registry.php +++ b/includes/abilities-api/class-wp-abilities-category-registry.php @@ -228,13 +228,19 @@ public static function get_instance(): self { * Wakeup magic method. * * @since n.e.x.t - * @throws \UnexpectedValueException If any of the registered categories is not an instance of WP_Ability_Category. + * @throws \LogicException If the registry is unserialized. This is a security hardening measure to prevent unserialization of the registry. */ public function __wakeup(): void { - foreach ( $this->registered_categories as $category ) { - if ( ! $category instanceof WP_Ability_Category ) { - throw new \UnexpectedValueException(); - } - } + 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-ability-category.php b/includes/abilities-api/class-wp-ability-category.php index 148dff85..722a1949 100644 --- a/includes/abilities-api/class-wp-ability-category.php +++ b/includes/abilities-api/class-wp-ability-category.php @@ -184,13 +184,20 @@ public function get_meta(): array { /** * Wakeup magic method. * - * Prevents unserialization of this object as a security hardening measure. - * Unserializing objects can lead to security vulnerabilities such as object - * injection attacks and remote code execution. - * * @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 . ' should never be unserialized.' ); + 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.' ); } } From 7a66dcfcd834c648f2b60cb049b2fdac88ff4574 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 13 Oct 2025 13:30:05 +0300 Subject: [PATCH 39/50] docs: clarify category registration step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Greg Ziółkowski --- docs/1.intro.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/1.intro.md b/docs/1.intro.md index 2f1db4a5..b8171b4f 100644 --- a/docs/1.intro.md +++ b/docs/1.intro.md @@ -36,7 +36,7 @@ It acts as a central registry, making it easier for different parts of WordPress ## Registration Example ```php -// First, register a category +// 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( From 88fe7a6e72e1bc112c8ede3c987e812b6775ed41 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 13 Oct 2025 13:37:57 +0300 Subject: [PATCH 40/50] =?UTF-8?q?Update=20translator=20comment=20and=20str?= =?UTF-8?q?ing=20to=20specify=20=E2=80=9CAbility=20category=E2=80=9D=20for?= =?UTF-8?q?=20clearer=20context=20during=20translation.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Greg Ziółkowski --- .../abilities-api/class-wp-abilities-category-registry.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/abilities-api/class-wp-abilities-category-registry.php b/includes/abilities-api/class-wp-abilities-category-registry.php index 46bb0d7f..8f1b9834 100644 --- a/includes/abilities-api/class-wp-abilities-category-registry.php +++ b/includes/abilities-api/class-wp-abilities-category-registry.php @@ -131,8 +131,8 @@ public function unregister( string $slug ): ?WP_Ability_Category { if ( ! $this->is_registered( $slug ) ) { _doing_it_wrong( __METHOD__, - /* translators: %s: Category slug. */ - sprintf( esc_html__( 'Category "%s" not found.' ), esc_attr( $slug ) ), + /* translators: %s: Ability category slug. */ + sprintf( esc_html__( 'Ability category "%s" not found.' ), esc_attr( $slug ) ), 'n.e.x.t' ); return null; From b96c89d529c1e7da150ad223045521f2e07c8976 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 13 Oct 2025 13:38:57 +0300 Subject: [PATCH 41/50] =?UTF-8?q?Update=20translator=20comment=20and=20str?= =?UTF-8?q?ing=20to=20specify=20=E2=80=9CAbility=20category=E2=80=9D=20for?= =?UTF-8?q?=20clearer=20context=20during=20translation.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Greg Ziółkowski --- .../abilities-api/class-wp-abilities-category-registry.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/abilities-api/class-wp-abilities-category-registry.php b/includes/abilities-api/class-wp-abilities-category-registry.php index 8f1b9834..473f8515 100644 --- a/includes/abilities-api/class-wp-abilities-category-registry.php +++ b/includes/abilities-api/class-wp-abilities-category-registry.php @@ -187,8 +187,8 @@ public function get_registered( string $slug ): ?WP_Ability_Category { if ( ! $this->is_registered( $slug ) ) { _doing_it_wrong( __METHOD__, - /* translators: %s: Category slug. */ - sprintf( esc_html__( 'Category "%s" not found.' ), esc_attr( $slug ) ), + /* translators: %s: Ability category slug. */ + sprintf( esc_html__( 'Ability category "%s" not found.' ), esc_attr( $slug ) ), 'n.e.x.t' ); return null; From 84c77e44d2c94cdf9b6cc7c265eb3c819389b9db Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 13 Oct 2025 13:39:21 +0300 Subject: [PATCH 42/50] =?UTF-8?q?Update=20translator=20comment=20and=20str?= =?UTF-8?q?ing=20to=20specify=20=E2=80=9CAbility=20category=E2=80=9D=20for?= =?UTF-8?q?=20clearer=20context=20during=20translation.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Greg Ziółkowski --- includes/abilities-api/class-wp-abilities-registry.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/abilities-api/class-wp-abilities-registry.php b/includes/abilities-api/class-wp-abilities-registry.php index 37cecde7..8a10a15d 100644 --- a/includes/abilities-api/class-wp-abilities-registry.php +++ b/includes/abilities-api/class-wp-abilities-registry.php @@ -106,8 +106,8 @@ public function register( string $name, array $args ): ?WP_Ability { _doing_it_wrong( __METHOD__, sprintf( - /* translators: %1$s: category slug, %2$s: ability name */ - esc_html__( 'Category "%1$s" is not registered. Please register the category before assigning it to ability "%2$s".' ), + /* 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 ) ), From aee17c83a8ba3aeeb00a47e231dbe77958083b25 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 13 Oct 2025 13:44:18 +0300 Subject: [PATCH 43/50] Docs: Remove documentation for category registration filter This parts is coverded in Hooks documentation --- docs/7.registering-categories.md | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/docs/7.registering-categories.md b/docs/7.registering-categories.md index 1cb7f5e3..8ddc78e9 100644 --- a/docs/7.registering-categories.md +++ b/docs/7.registering-categories.md @@ -59,22 +59,3 @@ The `$slug` parameter must follow these rules: - `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. - -## Hooks - -### Filter: `register_ability_category_args` - -Filters the category arguments before they are validated and used to instantiate the category. - -```php -add_filter( 'register_ability_category_args', 'my_plugin_filter_category_args', 10, 2 ); -function my_plugin_filter_category_args( $args, $slug ) { - // Modify $args before validation - return $args; -} -``` - -**Parameters:** -- `$args` (`array`): The arguments used to instantiate the category. -- `$slug` (`string`): The slug of the category being registered. - From 2e2992c692138e802f795ee9fb58e4c48e907dbf Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 13 Oct 2025 14:05:38 +0300 Subject: [PATCH 44/50] Fix PHPStan annotations for category registration Corrects the PHPStan type annotations for wp_register_ability_category() and WP_Abilities_Category_Registry::register() to accurately reflect the actual implementation: - Mark `label` and `description` as required fields (removed optional `?`) - Add `meta` as an optional property (array) - Update docblock to mention `meta` parameter The label and description fields are validated as required in the WP_Ability_Category::prepare_properties() method, while meta is truly optional. The annotations now match the runtime behavior. --- includes/abilities-api.php | 7 ++++--- .../class-wp-abilities-category-registry.php | 11 ++++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/includes/abilities-api.php b/includes/abilities-api.php index e1a466f1..6e8c283f 100644 --- a/includes/abilities-api.php +++ b/includes/abilities-api.php @@ -114,12 +114,13 @@ function wp_get_abilities(): array { * @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` and `description`. + * 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, + * label: string, + * description: string, + * meta?: array, * ... * } $args */ diff --git a/includes/abilities-api/class-wp-abilities-category-registry.php b/includes/abilities-api/class-wp-abilities-category-registry.php index 473f8515..09fc07b8 100644 --- a/includes/abilities-api/class-wp-abilities-category-registry.php +++ b/includes/abilities-api/class-wp-abilities-category-registry.php @@ -49,11 +49,12 @@ final class WP_Abilities_Category_Registry { * details. * @return ?\WP_Ability_Category The registered category instance on success, null on failure. * - * @phpstan-param array{ - * label?: string, - * description?: string, - * ... - * } $args + * @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' ) ) { From be1b20e6337e6a2aa545e8035d5ae8f74fee863b Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 13 Oct 2025 15:14:54 +0300 Subject: [PATCH 45/50] Update tests/unit/abilities-api/wpAbilityCategory.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Greg Ziółkowski --- tests/unit/abilities-api/wpAbilityCategory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/abilities-api/wpAbilityCategory.php b/tests/unit/abilities-api/wpAbilityCategory.php index 29dc8b2b..369d9532 100644 --- a/tests/unit/abilities-api/wpAbilityCategory.php +++ b/tests/unit/abilities-api/wpAbilityCategory.php @@ -44,7 +44,7 @@ public function set_up(): void { * Tear down after each test. */ public function tear_down(): void { - remove_action( 'doing_it_wrong_run', array( $this, 'record_doing_it_wrong' ), 10 ); + remove_action( 'doing_it_wrong_run', array( $this, 'record_doing_it_wrong' ) ); $this->doing_it_wrong_log = array(); // Clean up all test categories. From 990e77aa17b34c8479d03baa7aba62a4ec676ee5 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 13 Oct 2025 15:27:41 +0300 Subject: [PATCH 46/50] Refactor category slug tests to use @dataProvider Replace loop-based slug validation tests with PHPUnit data providers for better test isolation and clearer failure reporting. - Add valid_slug_provider() for valid slug format tests - Add invalid_slug_provider() for invalid slug format tests --- .../unit/abilities-api/wpAbilityCategory.php | 101 +++++++++--------- 1 file changed, 51 insertions(+), 50 deletions(-) diff --git a/tests/unit/abilities-api/wpAbilityCategory.php b/tests/unit/abilities-api/wpAbilityCategory.php index 369d9532..66055100 100644 --- a/tests/unit/abilities-api/wpAbilityCategory.php +++ b/tests/unit/abilities-api/wpAbilityCategory.php @@ -482,71 +482,72 @@ public function test_category_with_special_characters(): void { $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(): void { - $valid_slugs = array( - 'test-simple', - 'test-multiple-words', - 'test-with-numbers-123', - 'test-a', - 'test-123', + public function test_category_slug_valid_formats( string $slug ): void { + $result = $this->register_category_during_hook( + $slug, + array( + 'label' => 'Test', + 'description' => 'Test description.', + ) ); - $callback = function () use ( $valid_slugs ): void { - foreach ( $valid_slugs as $slug ) { - $result = wp_register_ability_category( - $slug, - array( - 'label' => 'Test', - 'description' => 'Test description.', - ) - ); - - $this->assertInstanceOf( WP_Ability_Category::class, $result, "Slug '{$slug}' should be valid" ); - } - }; + $this->assertInstanceOf( WP_Ability_Category::class, $result, "Slug '{$slug}' should be valid" ); + } - 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 ); + /** + * 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(): void { - $invalid_slugs = array( - 'Test-Uppercase', - 'test_underscore', - 'test.dot', - 'test/slash', - 'test space', - '-test-start-dash', - 'test-end-dash-', - 'test--double-dash', + public function test_category_slug_invalid_formats( string $slug ): void { + $result = $this->register_category_during_hook( + $slug, + array( + 'label' => 'Test', + 'description' => 'Test description.', + ) ); - $callback = function () use ( $invalid_slugs ): void { - foreach ( $invalid_slugs as $slug ) { - $result = wp_register_ability_category( - $slug, - array( - 'label' => 'Test', - 'description' => 'Test description.', - ) - ); - - $this->assertNull( $result, "Slug '{$slug}' should be invalid" ); - } - }; - - 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, "Slug '{$slug}' should be invalid" ); $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); } From 2414c2f28945519cde234d5621e851a68ac0acc7 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 13 Oct 2025 15:39:15 +0300 Subject: [PATCH 47/50] Refactor test_get_all_categories to use register_category_during_hook Simplify the test by replacing the callback function with direct calls to register_category_during_hook --- .../unit/abilities-api/wpAbilityCategory.php | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/tests/unit/abilities-api/wpAbilityCategory.php b/tests/unit/abilities-api/wpAbilityCategory.php index 66055100..c82056c4 100644 --- a/tests/unit/abilities-api/wpAbilityCategory.php +++ b/tests/unit/abilities-api/wpAbilityCategory.php @@ -337,27 +337,21 @@ public function test_get_nonexistent_category(): void { * Test retrieving all registered categories. */ public function test_get_all_categories(): void { - $callback = static function (): void { - wp_register_ability_category( - 'test-math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations.', - ) - ); - - wp_register_ability_category( - 'test-system', - array( - 'label' => 'System', - 'description' => 'System operations.', - ) - ); - }; + $this->register_category_during_hook( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); - 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->register_category_during_hook( + 'test-system', + array( + 'label' => 'System', + 'description' => 'System operations.', + ) + ); $categories = wp_get_ability_categories(); From b724ae7d5b60c248aa82ac7d8c518315d7fbebe6 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 13 Oct 2025 16:06:01 +0300 Subject: [PATCH 48/50] Remove 'annotations' field from abilities schema and update required fields to include 'meta' --- .../endpoints/class-wp-rest-abilities-list-controller.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) 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 02f2e02e..610e1d1e 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 @@ -288,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', @@ -301,7 +295,7 @@ public function get_item_schema(): array { 'readonly' => true, ), ), - 'required' => array( 'name', 'label', 'description', 'category' ), + 'required' => array( 'name', 'label', 'meta', 'description', 'category' ), ); return $this->add_additional_fields_schema( $schema ); From 48c3ba130105674396ef251f2af7c390561cb9b2 Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 13 Oct 2025 16:07:02 +0300 Subject: [PATCH 49/50] Update required fields in abilities schema to include 'input_schema' and 'output_schema' --- .../endpoints/class-wp-rest-abilities-list-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 610e1d1e..54e393f4 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 @@ -295,7 +295,7 @@ public function get_item_schema(): array { 'readonly' => true, ), ), - 'required' => array( 'name', 'label', 'meta', 'description', 'category' ), + 'required' => array( 'name', 'label', 'meta', 'description', 'category', 'input_schema', 'output_schema' ), ); return $this->add_additional_fields_schema( $schema ); From e69d69bc4bd347e9753bb94aaae945430c8f54ef Mon Sep 17 00:00:00 2001 From: Ovidiu Galatan Date: Mon, 13 Oct 2025 16:08:27 +0300 Subject: [PATCH 50/50] Add category property tests to abilities schema validation - Assert the count of properties in the schema to ensure it matches expected values. - Verify the existence and details of the 'category' property, including its type and readonly status. - Confirm that 'category' is included in the required fields of the schema. - Remove redundant test method for category schema validation. --- .../wpRestAbilitiesListController.php | 36 ++++++++----------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/tests/unit/rest-api/wpRestAbilitiesListController.php b/tests/unit/rest-api/wpRestAbilitiesListController.php index 1361a6d6..24093c46 100644 --- a/tests/unit/rest-api/wpRestAbilitiesListController.php +++ b/tests/unit/rest-api/wpRestAbilitiesListController.php @@ -500,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'] ); } /** @@ -682,25 +697,4 @@ public function test_category_field_in_response(): void { $this->assertEquals( 'math', $data['category'] ); $this->assertIsString( $data['category'], 'Category should be a string' ); } - - /** - * Test that category is in schema and marked as required. - */ - public function test_category_in_schema(): void { - $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/abilities' ); - $response = $this->server->dispatch( $request ); - $data = $response->get_data(); - - $schema = $data['schema']; - $this->assertArrayHasKey( 'properties', $schema ); - $this->assertArrayHasKey( 'category', $schema['properties'] ); - - $category_property = $schema['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'] ); - } }