From 42556b4b19cc8e4588f5ad95d3b3656da0451fd2 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 8 Aug 2025 15:22:12 +0200 Subject: [PATCH 01/31] Abilities API: Implement server-side registry --- .../abilities-api/abilities-api.php | 87 ++++ .../class-wp-abilities-registry.php | 294 ++++++++++++ .../abilities-api/class-wp-ability.php | 331 ++++++++++++++ src/wp-settings.php | 3 + .../phpunit/tests/abilities-api/register.php | 349 ++++++++++++++ .../abilities-api/wpAbilitiesRegistry.php | 425 ++++++++++++++++++ 6 files changed, 1489 insertions(+) create mode 100644 src/wp-includes/abilities-api/abilities-api.php create mode 100644 src/wp-includes/abilities-api/class-wp-abilities-registry.php create mode 100644 src/wp-includes/abilities-api/class-wp-ability.php create mode 100644 tests/phpunit/tests/abilities-api/register.php create mode 100644 tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php diff --git a/src/wp-includes/abilities-api/abilities-api.php b/src/wp-includes/abilities-api/abilities-api.php new file mode 100644 index 0000000000000..dabc2111cf750 --- /dev/null +++ b/src/wp-includes/abilities-api/abilities-api.php @@ -0,0 +1,87 @@ +abilities_api_init', + '' . esc_attr( $name ) . '' + ), + '0.1.0' + ); + return null; + } + + return WP_Abilities_Registry::get_instance()->register( $name, $properties ); +} + +/** + * Unregisters an ability using Abilities API. + * + * @see WP_Abilities_Registry::unregister() + * + * @since 0.1.0 + * + * @param string $name The name of the registered ability, with its namespace. + * @return ?WP_Ability The unregistered ability instance on success, null on failure. + */ +function wp_unregister_ability( string $name ): ?WP_Ability { + return WP_Abilities_Registry::get_instance()->unregister( $name ); +} + +/** + * Retrieves a registered ability using Abilities API. + * + * @see WP_Abilities_Registry::get_registered() + * + * @since 0.1.0 + * + * @param string $name The name of the registered ability, with its namespace. + * @return ?WP_Ability The registered ability instance, or null if it is not registered. + */ +function wp_get_ability( string $name ): ?WP_Ability { + return WP_Abilities_Registry::get_instance()->get_registered( $name ); +} + +/** + * Retrieves all registered abilities using Abilities API. + * + * @see WP_Abilities_Registry::get_all_registered() + * + * @since 0.1.0 + * + * @return WP_Ability[] The array of registered abilities. + */ +function wp_get_abilities(): array { + return WP_Abilities_Registry::get_instance()->get_all_registered(); +} diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php new file mode 100644 index 0000000000000..4c52c748fe355 --- /dev/null +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -0,0 +1,294 @@ +get_name(); + } + + if ( ! preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $name ) ) { + _doing_it_wrong( + __METHOD__, + esc_html__( + 'Ability name must be a string containing a namespace prefix, i.e. "my-plugin/my-ability". It can only contain lowercase alphanumeric characters, dashes and the forward slash.' + ), + '0.1.0' + ); + return null; + } + + if ( $this->is_registered( $name ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Ability name. */ + esc_html( sprintf( __( 'Ability "%s" is already registered.' ), $name ) ), + '0.1.0' + ); + return null; + } + + // If the ability is already an instance, we can skip the rest of the validation. + if ( null !== $ability ) { + $this->registered_abilities[ $name ] = $ability; + return $ability; + } + + if ( empty( $properties['label'] ) || ! is_string( $properties['label'] ) ) { + _doing_it_wrong( + __METHOD__, + esc_html__( 'The ability properties must contain a `label` string.' ), + '0.1.0' + ); + return null; + } + + if ( empty( $properties['description'] ) || ! is_string( $properties['description'] ) ) { + _doing_it_wrong( + __METHOD__, + esc_html__( 'The ability properties must contain a `description` string.' ), + '0.1.0' + ); + return null; + } + + if ( isset( $properties['input_schema'] ) && ! is_array( $properties['input_schema'] ) ) { + _doing_it_wrong( + __METHOD__, + esc_html__( 'The ability properties should provide a valid `input_schema` definition.' ), + '0.1.0' + ); + return null; + } + + if ( isset( $properties['output_schema'] ) && ! is_array( $properties['output_schema'] ) ) { + _doing_it_wrong( + __METHOD__, + esc_html__( 'The ability properties should provide a valid `output_schema` definition.' ), + '0.1.0' + ); + return null; + } + + if ( empty( $properties['execute_callback'] ) || ! is_callable( $properties['execute_callback'] ) ) { + _doing_it_wrong( + __METHOD__, + esc_html__( 'The ability properties must contain a valid `execute_callback` function.' ), + '0.1.0' + ); + return null; + } + + if ( isset( $properties['permission_callback'] ) && ! is_callable( $properties['permission_callback'] ) ) { + _doing_it_wrong( + __METHOD__, + esc_html__( 'The ability properties should provide a valid `permission_callback` function.' ), + '0.1.0' + ); + return null; + } + + if ( isset( $properties['meta'] ) && ! is_array( $properties['meta'] ) ) { + _doing_it_wrong( + __METHOD__, + esc_html__( 'The ability properties should provide a valid `meta` array.' ), + '0.1.0' + ); + return null; + } + + $ability = new WP_Ability( + $name, + array( + 'label' => $properties['label'], + 'description' => $properties['description'], + 'input_schema' => $properties['input_schema'] ?? array(), + 'output_schema' => $properties['output_schema'] ?? array(), + 'execute_callback' => $properties['execute_callback'], + 'permission_callback' => $properties['permission_callback'] ?? null, + 'meta' => $properties['meta'] ?? array(), + ) + ); + $this->registered_abilities[ $name ] = $ability; + return $ability; + } + + /** + * Unregisters an ability. + * + * Do not use this method directly. Instead, use the `wp_unregister_ability()` function. + * + * @see wp_unregister_ability() + * + * @since 0.1.0 + * + * @param string $name The name of the registered ability, with its namespace. + * @return ?WP_Ability The unregistered ability instance on success, null on failure. + */ + public function unregister( $name ): ?WP_Ability { + if ( ! $this->is_registered( $name ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Ability name. */ + sprintf( esc_html__( 'Ability "%s" not found.' ), esc_attr( $name ) ), + '0.1.0' + ); + return null; + } + + $unregistered_ability = $this->registered_abilities[ $name ]; + unset( $this->registered_abilities[ $name ] ); + + return $unregistered_ability; + } + + /** + * Retrieves the list of all registered abilities. + * + * Do not use this method directly. Instead, use the `wp_get_abilities()` function. + * + * @see wp_get_abilities() + * + * @since 0.1.0 + * + * @return WP_Ability[] The array of registered abilities. + */ + public function get_all_registered(): array { + return $this->registered_abilities; + } + + /** + * Checks if an ability is registered. + * + * @since 0.1.0 + * + * @param string $name The name of the registered ability, with its namespace. + * @return bool True if the ability is registered, false otherwise. + */ + public function is_registered( $name ): bool { + return isset( $this->registered_abilities[ $name ] ); + } + + /** + * Retrieves a registered ability. + * + * Do not use this method directly. Instead, use the `wp_get_ability()` function. + * + * @see wp_get_ability() + * + * @since 0.1.0 + * + * @param string $name The name of the registered ability, with its namespace. + * @return ?WP_Ability The registered ability instance, or null if it is not registered. + */ + public function get_registered( $name ): ?WP_Ability { + if ( ! $this->is_registered( $name ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Ability name. */ + sprintf( esc_html__( 'Ability "%s" not found.' ), esc_attr( $name ) ), + '0.1.0' + ); + return null; + } + return $this->registered_abilities[ $name ]; + } + + /** + * Utility method to retrieve the main instance of the registry class. + * + * The instance will be created if it does not exist yet. + * + * @since 0.1.0 + * + * @return WP_Abilities_Registry The main registry instance. + */ + public static function get_instance(): WP_Abilities_Registry { + /* @var WP_Abilities_Registry $wp_abilities */ + global $wp_abilities; + + if ( empty( $wp_abilities ) ) { + $wp_abilities = new self(); + /** + * Fires when preparing abilities registry. + * + * Abilities should be created and register their hooks on this action rather + * than another action to ensure they're only loaded when needed. + * + * @since 0.1.0 + * + * @param WP_Abilities_Registry $instance Abilities registry object. + */ + do_action( 'abilities_api_init', $wp_abilities ); + } + + return $wp_abilities; + } + + /** + * Wakeup magic method. + * + * @since 0.1.0 + */ + public function __wakeup(): void { + if ( empty( $this->registered_abilities ) ) { + return; + } + + foreach ( $this->registered_abilities as $ability ) { + if ( ! $ability instanceof WP_Ability ) { + throw new UnexpectedValueException(); + } + } + } +} diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php new file mode 100644 index 0000000000000..50dea61207b9b --- /dev/null +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -0,0 +1,331 @@ +name = $name; + foreach ( $properties as $property_name => $property_value ) { + $this->$property_name = $property_value; + } + } + + /** + * Retrieves the name of the ability, with its namespace. + * Example: `my-plugin/my-ability`. + * + * @since 0.1.0 + * + * @return string The ability name, with its namespace. + */ + public function get_name(): string { + return $this->name; + } + + /** + * Retrieves the human-readable label for the ability. + * + * @since 0.1.0 + * + * @return string The human-readable ability label. + */ + public function get_label(): string { + return $this->label; + } + + /** + * Retrieves the detailed description for the ability. + * + * @since 0.1.0 + * + * @return string The detailed description for the ability. + */ + public function get_description(): string { + return $this->description; + } + + /** + * Retrieves the input schema for the ability. + * + * @since 0.1.0 + * + * @return array The input schema for the ability. + */ + public function get_input_schema(): array { + return $this->input_schema; + } + + /** + * Retrieves the output schema for the ability. + * + * @since 0.1.0 + * + * @return array The output schema for the ability. + */ + public function get_output_schema(): array { + return $this->output_schema; + } + + /** + * Retrieves the metadata for the ability. + * + * @since 0.1.0 + * + * @return array The metadata for the ability. + */ + public function get_meta(): array { + return $this->meta; + } + + /** + * Validates input data against the input schema. + * + * @since 0.1.0 + * + * @param array $input Optional. The input data to validate. + * @return bool Returns true if valid, false if validation fails. + */ + protected function validate_input( array $input = array() ): bool { + $input_schema = $this->get_input_schema(); + if ( empty( $input_schema ) ) { + return true; + } + + $valid_input = rest_validate_value_from_schema( $input, $input_schema ); + if ( is_wp_error( $valid_input ) ) { + _doing_it_wrong( + __METHOD__, + esc_html( + sprintf( + /* translators: %1$s ability name, %2$s error message. */ + __( 'Invalid input provided for ability "%1$s": %2$s.' ), + $this->name, + $valid_input->get_error_message() + ) + ), + '0.1.0' + ); + return false; + } + + return true; + } + + /** + * Checks whether the ability has the necessary permissions. + * If the permission callback is not set, the default behavior is to allow access + * when the input provided passes validation. + * + * @since 0.1.0 + * + * @param array $input Optional. The input data for permission checking. + * @return bool Whether the ability has the necessary permission. + */ + public function has_permission( array $input = array() ): bool { + if ( ! $this->validate_input( $input ) ) { + return false; + } + + if ( ! is_callable( $this->permission_callback ) ) { + return true; + } + + return call_user_func( $this->permission_callback, $input ); + } + + /** + * Executes the ability callback. + * + * @since 0.1.0 + * + * @param array $input The input data for the ability. + * @return mixed|WP_Error The result of the ability execution, or WP_Error on failure. + */ + protected function do_execute( array $input ) { + if ( ! is_callable( $this->execute_callback ) ) { + _doing_it_wrong( + __METHOD__, + esc_html( + /* translators: %s ability name. */ + sprintf( __( 'Ability "%s" does not have a valid execute callback.' ), $this->name ) + ), + '0.1.0' + ); + return null; + } + return call_user_func( $this->execute_callback, $input ); + } + + /** + * Validates output data against the output schema. + * + * @since 0.1.0 + * + * @param mixed $output The output data to validate. + * @return bool Returns true if valid, false if validation fails. + */ + protected function validate_output( $output ): bool { + $output_schema = $this->get_output_schema(); + if ( empty( $output_schema ) ) { + return true; + } + + $valid_output = rest_validate_value_from_schema( $output, $output_schema ); + if ( is_wp_error( $valid_output ) ) { + _doing_it_wrong( + __METHOD__, + esc_html( + sprintf( + /* translators: %1$s ability name, %2$s error message. */ + __( 'Invalid output provided for ability "%1$s": %2$s.' ), + $this->name, + $valid_output->get_error_message() + ) + ), + '0.1.0' + ); + return false; + } + + return true; + } + + /** + * Executes the ability after input validation and running a permission check. + * Before returning the return value, it also validates the output. + * + * @since 0.1.0 + * + * @param array $input Optional. The input data for the ability. + * @return mixed|WP_Error The result of the ability execution, or WP_Error on failure. + */ + public function execute( array $input = array() ) { + if ( ! $this->has_permission( $input ) ) { + _doing_it_wrong( + __METHOD__, + esc_html( + /* translators: %s ability name. */ + sprintf( __( 'Ability "%s" does not have necessary permission.' ), $this->name ) + ), + '0.1.0' + ); + return null; + } + + $result = $this->do_execute( $input ); + if ( is_wp_error( $result ) ) { + return $result; + } + + if ( ! $this->validate_output( $result ) ) { + return null; + } + + return $result; + } + + /** + * Wakeup magic method. + * + * @since 0.1.0 + */ + public function __wakeup(): void { + throw new \LogicException( __CLASS__ . ' should never be unserialized.' ); + } +} diff --git a/src/wp-settings.php b/src/wp-settings.php index b67b385e791a2..e54bc9d398127 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -285,6 +285,9 @@ require ABSPATH . WPINC . '/nav-menu.php'; require ABSPATH . WPINC . '/admin-bar.php'; require ABSPATH . WPINC . '/class-wp-application-passwords.php'; +require ABSPATH . WPINC . '/abilities-api/class-wp-ability.php'; +require ABSPATH . WPINC . '/abilities-api/class-wp-abilities-registry.php'; +require ABSPATH . WPINC . '/abilities-api/abilities-api.php'; require ABSPATH . WPINC . '/rest-api.php'; require ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php'; require ABSPATH . WPINC . '/rest-api/class-wp-rest-response.php'; diff --git a/tests/phpunit/tests/abilities-api/register.php b/tests/phpunit/tests/abilities-api/register.php new file mode 100644 index 0000000000000..9878d5c4d5d48 --- /dev/null +++ b/tests/phpunit/tests/abilities-api/register.php @@ -0,0 +1,349 @@ + 'Add numbers', + 'description' => 'Calculates the result of adding two numbers.', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'a' => array( + 'type' => 'number', + 'description' => 'First number.', + 'required' => true, + ), + 'b' => array( + 'type' => 'number', + 'description' => 'Second number.', + 'required' => true, + ), + ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'number', + 'description' => 'The result of adding the two numbers.', + 'required' => true, + ), + 'execute_callback' => function ( array $input ): int { + return $input['a'] + $input['b']; + }, + 'permission_callback' => function (): bool { + return true; + }, + 'meta' => array( + 'category' => 'math', + ), + ); + } + + /** + * Tear down after each test. + */ + public function tear_down(): void { + foreach ( wp_get_abilities() as $ability ) { + if ( str_starts_with( $ability->get_name(), 'test/' ) ) { + wp_unregister_ability( $ability->get_name() ); + } + } + + parent::tear_down(); + } + + /** + * Tests registering an ability with invalid name. + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_ability_invalid_name(): void { + do_action( 'abilities_api_init' ); + + $result = wp_register_ability( 'invalid_name', array() ); + + $this->assertNull( $result ); + } + + /** + * Tests registering an ability when `abilities_api_init` hook is not fired. + * + * @expectedIncorrectUsage wp_register_ability + */ + public function test_register_ability_no_abilities_api_init_hook(): void { + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertNull( $result ); + } + + /** + * Tests registering a valid ability. + */ + public function test_register_valid_ability(): void { + do_action( 'abilities_api_init' ); + + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertInstanceOf( WP_Ability::class, $result ); + $this->assertSame( self::$test_ability_name, $result->get_name() ); + $this->assertSame( self::$test_ability_properties['label'], $result->get_label() ); + $this->assertSame( self::$test_ability_properties['description'], $result->get_description() ); + $this->assertSame( self::$test_ability_properties['input_schema'], $result->get_input_schema() ); + $this->assertSame( self::$test_ability_properties['output_schema'], $result->get_output_schema() ); + $this->assertSame( self::$test_ability_properties['meta'], $result->get_meta() ); + $this->assertTrue( + $result->has_permission( + array( + 'a' => 2, + 'b' => 3, + ) + ) + ); + $this->assertSame( + 5, + $result->execute( + array( + 'a' => 2, + 'b' => 3, + ) + ) + ); + } + + /** + * Tests executing an ability with no permissions. + * + * @expectedIncorrectUsage WP_Ability::execute + */ + public function test_register_ability_no_permissions(): void { + do_action( 'abilities_api_init' ); + + self::$test_ability_properties['permission_callback'] = function (): bool { + return false; + }; + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertFalse( + $result->has_permission( + array( + 'a' => 2, + 'b' => 3, + ) + ) + ); + $this->assertNull( + $result->execute( + array( + 'a' => 2, + 'b' => 3, + ) + ) + ); + } + + /** + * Tests executing an ability with input not matching schema. + * + * @expectedIncorrectUsage WP_Ability::validate_input + * @expectedIncorrectUsage WP_Ability::execute + */ + public function test_execute_ability_no_input_schema_match(): void { + do_action( 'abilities_api_init' ); + + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertNull( + $result->execute( + array( + 'a' => 2, + 'b' => 3, + 'unknown' => 1, + ) + ) + ); + } + + /** + * Tests executing an ability with output not matching schema. + * + * @expectedIncorrectUsage WP_Ability::validate_output + */ + public function test_execute_ability_no_output_schema_match(): void { + do_action( 'abilities_api_init' ); + + self::$test_ability_properties['execute_callback'] = function (): bool { + return true; + }; + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertNull( + $result->execute( + array( + 'a' => 2, + 'b' => 3, + ) + ) + ); + } + + /** + * Tests permission callback receiving input not matching schema. + * + * @expectedIncorrectUsage WP_Ability::validate_input + */ + public function test_permission_callback_no_input_schema_match(): void { + do_action( 'abilities_api_init' ); + + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertFalse( + $result->has_permission( + array( + 'a' => 2, + 'b' => 3, + 'unknown' => 1, + ) + ) + ); + } + + /** + * Tests permission callback receiving input for contextual permission checks. + */ + public function test_permission_callback_receives_input(): void { + do_action( 'abilities_api_init' ); + + $received_input = null; + self::$test_ability_properties['permission_callback'] = function ( array $input ) use ( &$received_input ): bool { + $received_input = $input; + // Allow only if 'a' is greater than 'b' + return $input['a'] > $input['b']; + }; + + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); + + // Test with a > b (should be allowed) + $this->assertTrue( + $result->has_permission( + array( + 'a' => 5, + 'b' => 3, + ) + ) + ); + $this->assertSame( + array( + 'a' => 5, + 'b' => 3, + ), + $received_input + ); + + // Test with a < b (should be denied) + $this->assertFalse( + $result->has_permission( + array( + 'a' => 2, + 'b' => 8, + ) + ) + ); + $this->assertSame( + array( + 'a' => 2, + 'b' => 8, + ), + $received_input + ); + } + + /** + * Tests unregistering existing ability. + */ + public function test_unregister_existing_ability() { + do_action( 'abilities_api_init' ); + + wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); + + $result = wp_unregister_ability( self::$test_ability_name ); + + $this->assertEquals( + new WP_Ability( self::$test_ability_name, self::$test_ability_properties ), + $result + ); + } + + /** + * Tests retrieving existing ability. + */ + public function test_get_existing_ability() { + global $wp_abilities; + + $name = self::$test_ability_name; + $properties = self::$test_ability_properties; + $callback = function ( $instance ) use ( $name, $properties ) { + wp_register_ability( $name, $properties ); + }; + + add_action( 'abilities_api_init', $callback ); + + // Temporarily set `$wp_abilities` to null to ensure `wp_get_ability()` triggers `abilities_api_init` action. + $old_wp_abilities = $wp_abilities; + $wp_abilities = null; + + $result = wp_get_ability( $name ); + + $wp_abilities = $old_wp_abilities; + + remove_action( 'abilities_api_init', $callback ); + + $this->assertEquals( + new WP_Ability( $name, $properties ), + $result + ); + } + + /** + * Tests retrieving all registered abilities. + */ + public function test_get_all_registered_abilities() { + do_action( 'abilities_api_init' ); + + $ability_one_name = 'test/ability-one'; + $ability_one_properties = self::$test_ability_properties; + wp_register_ability( $ability_one_name, $ability_one_properties ); + + $ability_two_name = 'test/ability-two'; + $ability_two_properties = self::$test_ability_properties; + wp_register_ability( $ability_two_name, $ability_two_properties ); + + $ability_three_name = 'test/ability-three'; + $ability_three_properties = self::$test_ability_properties; + wp_register_ability( $ability_three_name, $ability_three_properties ); + + $expected = array( + $ability_one_name => new WP_Ability( $ability_one_name, $ability_one_properties ), + $ability_two_name => new WP_Ability( $ability_two_name, $ability_two_properties ), + $ability_three_name => new WP_Ability( $ability_three_name, $ability_three_properties ), + ); + + $result = wp_get_abilities(); + $this->assertEquals( $expected, $result ); + } +} diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php new file mode 100644 index 0000000000000..3b741e0c19b93 --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -0,0 +1,425 @@ +registry = new WP_Abilities_Registry(); + + self::$test_ability_properties = array( + 'label' => 'Add numbers', + 'description' => 'Calculates the result of adding two numbers.', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'a' => array( + 'type' => 'number', + 'description' => 'First number.', + 'required' => true, + ), + 'b' => array( + 'type' => 'number', + 'description' => 'Second number.', + 'required' => true, + ), + ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'number', + 'description' => 'The result of adding the two numbers.', + 'required' => true, + ), + 'execute_callback' => function ( array $input ): int { + return $input['a'] + $input['b']; + }, + 'permission_callback' => function (): bool { + return true; + }, + 'meta' => array( + 'category' => 'math', + ), + ); + } + + /** + * Tear down each test method. + */ + public function tear_down(): void { + $this->registry = null; + + parent::tear_down(); + } + + /** + * Should reject ability name without a namespace. + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_name_without_namespace() { + $result = $this->registry->register( 'without-namespace', self::$test_ability_properties ); + $this->assertNull( $result ); + } + + /** + * Should reject ability name with invalid characters. + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_characters_in_name() { + $result = $this->registry->register( 'still/_doing_it_wrong', array() ); + $this->assertNull( $result ); + } + + /** + * Should reject ability name with uppercase characters. + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_uppercase_characters_in_name() { + $result = $this->registry->register( 'Test/AddNumbers', self::$test_ability_properties ); + $this->assertNull( $result ); + } + + /** + * Should reject ability instance with invalid name. + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_name_using_instance() { + $ability = new WP_Ability( 'invalid_name', array() ); + $result = $this->registry->register( $ability ); + $this->assertNull( $result ); + } + + /** + * Should reject ability registration without a label. + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_missing_label() { + // Remove the label from the properties. + unset( self::$test_ability_properties['label'] ); + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $this->assertNull( $result ); + } + + /** + * Should reject ability registration with invalid label type. + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_label_type() { + self::$test_ability_properties['label'] = false; + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $this->assertNull( $result ); + } + + /** + * Should reject ability registration without a description. + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_missing_description() { + // Remove the description from the properties. + unset( self::$test_ability_properties['description'] ); + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $this->assertNull( $result ); + } + + /** + * Should reject ability registration with invalid description type. + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_description_type() { + self::$test_ability_properties['description'] = false; + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $this->assertNull( $result ); + } + + /** + * Should reject ability registration without an execute callback. + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_missing_execute_callback() { + // Remove the execute_callback from the properties. + unset( self::$test_ability_properties['execute_callback'] ); + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $this->assertNull( $result ); + } + + /** + * Should reject ability registration if the execute callback is not a callable. + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_incorrect_execute_callback_type() { + self::$test_ability_properties['execute_callback'] = 'not-a-callback'; + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $this->assertNull( $result ); + } + + /** + * Should reject ability registration if the permission callback is not a callable. + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_incorrect_permission_callback_type() { + self::$test_ability_properties['permission_callback'] = 'not-a-callback'; + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $this->assertNull( $result ); + } + + /** + * Should reject ability registration if the input schema is not an array. + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_incorrect_input_schema_type() { + self::$test_ability_properties['input_schema'] = 'not-an-array'; + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $this->assertNull( $result ); + } + + /** + * Should reject ability registration if the output schema is not an array. + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_incorrect_output_schema_type() { + self::$test_ability_properties['output_schema'] = 'not-an-array'; + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $this->assertNull( $result ); + } + + /** + * Should reject ability registration with invalid meta type. + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_meta_type() { + self::$test_ability_properties['meta'] = false; + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $this->assertNull( $result ); + } + + /** + * Should reject registration for already registered ability. + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_incorrect_already_registered_ability() { + $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertNull( $result ); + } + + /** + * Should reject registration for already registered ability when passing an ability instance. + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_incorrect_already_registered_ability_using_instance() { + $ability = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + + $result = $this->registry->register( $ability ); + + $this->assertNull( $result ); + } + + /** + * Should successfully register a new ability. + * + * @covers WP_Abilities_Registry::register + */ + public function test_register_new_ability() { + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertEquals( + new WP_Ability( self::$test_ability_name, self::$test_ability_properties ), + $result + ); + } + + /** + * Should successfully register a new ability using an instance. + * + * @covers WP_Abilities_Registry::register + * @covers WP_Ability::construct + */ + public function test_register_new_ability_using_instance() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + $result = $this->registry->register( $ability ); + + $this->assertSame( $ability, $result ); + } + + /** + * Should return false for ability that's not registered. + * + * @covers WP_Abilities_Registry::is_registered + */ + public function test_is_registered_for_unknown_ability() { + $result = $this->registry->is_registered( 'test/unknown' ); + $this->assertFalse( $result ); + } + + /** + * Should return true if ability is registered. + * + * @covers WP_Abilities_Registry::register + * @covers WP_Abilities_Registry::is_registered + */ + public function test_is_registered_for_known_ability() { + $this->registry->register( 'test/one', self::$test_ability_properties ); + $this->registry->register( 'test/two', self::$test_ability_properties ); + $this->registry->register( 'test/three', self::$test_ability_properties ); + + $result = $this->registry->is_registered( 'test/one' ); + $this->assertTrue( $result ); + } + + /** + * Should not find ability that's not registered. + * + * @covers WP_Abilities_Registry::get_registered + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_registered + */ + public function test_get_registered_rejects_unknown_ability_name() { + $ability = $this->registry->get_registered( 'test/unknown' ); + $this->assertNull( $ability ); + } + + /** + * Should find registered ability by name. + * + * @covers WP_Abilities_Registry::register + * @covers WP_Abilities_Registry::get_registered + */ + public function test_get_registered_for_known_ability() { + $this->registry->register( 'test/one', self::$test_ability_properties ); + $this->registry->register( 'test/two', self::$test_ability_properties ); + $this->registry->register( 'test/three', self::$test_ability_properties ); + + $result = $this->registry->get_registered( 'test/two' ); + $this->assertEquals( 'test/two', $result->get_name() ); + } + + /** + * Unregistering should fail if a ability is not registered. + * + * @covers WP_Abilities_Registry::unregister + * + * @expectedIncorrectUsage WP_Abilities_Registry::unregister + */ + public function test_unregister_not_registered_ability() { + $result = $this->registry->unregister( 'test/unregistered' ); + $this->assertNull( $result ); + } + + /** + * Should unregister ability by name. + * + * @covers WP_Abilities_Registry::register + * @covers WP_Abilities_Registry::unregister + */ + public function test_unregister_for_known_ability() { + $this->registry->register( 'test/one', self::$test_ability_properties ); + $this->registry->register( 'test/two', self::$test_ability_properties ); + $this->registry->register( 'test/three', self::$test_ability_properties ); + + $result = $this->registry->unregister( 'test/three' ); + $this->assertEquals( 'test/three', $result->get_name() ); + + $this->assertFalse( $this->registry->is_registered( 'test/three' ) ); + } + + /** + * Should retrieve all registered abilities. + * + * @covers WP_Abilities_Registry::register + * @covers WP_Abilities_Registry::get_all_registered + */ + public function test_get_all_registered() { + $ability_one_name = 'test/one'; + $this->registry->register( $ability_one_name, self::$test_ability_properties ); + + $ability_two_name = 'test/two'; + $this->registry->register( $ability_two_name, self::$test_ability_properties ); + + $ability_three_name = 'test/three'; + $this->registry->register( $ability_three_name, self::$test_ability_properties ); + + $result = $this->registry->get_all_registered(); + $this->assertCount( 3, $result ); + $this->assertSame( $ability_one_name, $result[ $ability_one_name ]->get_name() ); + $this->assertSame( $ability_two_name, $result[ $ability_two_name ]->get_name() ); + $this->assertSame( $ability_three_name, $result[ $ability_three_name ]->get_name() ); + } +} From c0efd2c0b2cfb8335340cf574705e8527cf4fa02 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Mon, 18 Aug 2025 11:16:19 +0200 Subject: [PATCH 02/31] Sync latest changes from Abilities API repo --- .../{abilities-api => }/abilities-api.php | 27 +- .../class-wp-abilities-registry.php | 57 +- .../abilities-api/class-wp-ability.php | 39 +- src/wp-includes/rest-api.php | 6 + ...lass-wp-rest-abilities-list-controller.php | 308 ++++++ ...class-wp-rest-abilities-run-controller.php | 331 ++++++ src/wp-settings.php | 2 + .../abilities-api/wpAbilitiesRegistry.php | 2 +- .../{register.php => wpRegisterAbility.php} | 15 +- .../tests/rest-api/rest-schema-setup.php | 3 + .../wpRestAbilitiesListController.php | 510 ++++++++++ .../rest-api/wpRestAbilitiesRunController.php | 955 ++++++++++++++++++ tests/qunit/fixtures/wp-api-generated.js | 104 ++ 13 files changed, 2292 insertions(+), 67 deletions(-) rename src/wp-includes/{abilities-api => }/abilities-api.php (61%) create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php rename tests/phpunit/tests/abilities-api/{register.php => wpRegisterAbility.php} (94%) create mode 100644 tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php create mode 100644 tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php diff --git a/src/wp-includes/abilities-api/abilities-api.php b/src/wp-includes/abilities-api.php similarity index 61% rename from src/wp-includes/abilities-api/abilities-api.php rename to src/wp-includes/abilities-api.php index dabc2111cf750..ae538a2a6d512 100644 --- a/src/wp-includes/abilities-api/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -1,5 +1,4 @@ - $properties Optional. An associative array of properties for the ability. This should + * include `label`, `description`, `input_schema`, `output_schema`, + * `execute_callback`, `permission_callback`, and `meta`. + * @return ?\WP_Ability An instance of registered ability on success, null on failure. */ function wp_register_ability( $name, array $properties = array() ): ?WP_Ability { if ( ! did_action( 'abilities_api_init' ) ) { @@ -35,7 +36,7 @@ function wp_register_ability( $name, array $properties = array() ): ?WP_Ability /* translators: 1: abilities_api_init, 2: string value of the ability name. */ esc_html__( 'Abilities must be registered on the %1$s action. The ability %2$s was not registered.' ), 'abilities_api_init', - '' . esc_attr( $name ) . '' + '' . esc_html( $name instanceof WP_Ability ? $name->get_name() : $name ) . '' ), '0.1.0' ); @@ -53,7 +54,7 @@ function wp_register_ability( $name, array $properties = array() ): ?WP_Ability * @since 0.1.0 * * @param string $name The name of the registered ability, with its namespace. - * @return ?WP_Ability The unregistered ability instance on success, null on failure. + * @return ?\WP_Ability The unregistered ability instance on success, null on failure. */ function wp_unregister_ability( string $name ): ?WP_Ability { return WP_Abilities_Registry::get_instance()->unregister( $name ); @@ -67,7 +68,7 @@ function wp_unregister_ability( string $name ): ?WP_Ability { * @since 0.1.0 * * @param string $name The name of the registered ability, with its namespace. - * @return ?WP_Ability The registered ability instance, or null if it is not registered. + * @return ?\WP_Ability The registered ability instance, or null if it is not registered. */ function wp_get_ability( string $name ): ?WP_Ability { return WP_Abilities_Registry::get_instance()->get_registered( $name ); @@ -80,7 +81,7 @@ function wp_get_ability( string $name ): ?WP_Ability { * * @since 0.1.0 * - * @return WP_Ability[] The array of registered abilities. + * @return \WP_Ability[] The array of registered abilities. */ function wp_get_abilities(): array { return WP_Abilities_Registry::get_instance()->get_all_registered(); diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index 4c52c748fe355..0e72925a049d3 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -1,5 +1,4 @@ - $properties Optional. An associative array of properties for the ability. This should + * include `label`, `description`, `input_schema`, `output_schema`, + * `execute_callback`, `permission_callback`, and `meta`. + * @return ?\WP_Ability The registered ability instance on success, null on failure. */ public function register( $name, array $properties = array() ): ?WP_Ability { $ability = null; @@ -159,6 +152,7 @@ public function register( $name, array $properties = array() ): ?WP_Ability { 'meta' => $properties['meta'] ?? array(), ) ); + $this->registered_abilities[ $name ] = $ability; return $ability; } @@ -173,9 +167,9 @@ public function register( $name, array $properties = array() ): ?WP_Ability { * @since 0.1.0 * * @param string $name The name of the registered ability, with its namespace. - * @return ?WP_Ability The unregistered ability instance on success, null on failure. + * @return ?\WP_Ability The unregistered ability instance on success, null on failure. */ - public function unregister( $name ): ?WP_Ability { + public function unregister( string $name ): ?WP_Ability { if ( ! $this->is_registered( $name ) ) { _doing_it_wrong( __METHOD__, @@ -201,7 +195,7 @@ public function unregister( $name ): ?WP_Ability { * * @since 0.1.0 * - * @return WP_Ability[] The array of registered abilities. + * @return \WP_Ability[] The array of registered abilities. */ public function get_all_registered(): array { return $this->registered_abilities; @@ -215,7 +209,7 @@ public function get_all_registered(): array { * @param string $name The name of the registered ability, with its namespace. * @return bool True if the ability is registered, false otherwise. */ - public function is_registered( $name ): bool { + public function is_registered( string $name ): bool { return isset( $this->registered_abilities[ $name ] ); } @@ -229,9 +223,9 @@ public function is_registered( $name ): bool { * @since 0.1.0 * * @param string $name The name of the registered ability, with its namespace. - * @return ?WP_Ability The registered ability instance, or null if it is not registered. + * @return ?\WP_Ability The registered ability instance, or null if it is not registered. */ - public function get_registered( $name ): ?WP_Ability { + public function get_registered( string $name ): ?WP_Ability { if ( ! $this->is_registered( $name ) ) { _doing_it_wrong( __METHOD__, @@ -251,10 +245,10 @@ public function get_registered( $name ): ?WP_Ability { * * @since 0.1.0 * - * @return WP_Abilities_Registry The main registry instance. + * @return \WP_Abilities_Registry The main registry instance. */ - public static function get_instance(): WP_Abilities_Registry { - /* @var WP_Abilities_Registry $wp_abilities */ + public static function get_instance(): self { + /** @var \WP_Abilities_Registry $wp_abilities */ global $wp_abilities; if ( empty( $wp_abilities ) ) { @@ -267,7 +261,7 @@ public static function get_instance(): WP_Abilities_Registry { * * @since 0.1.0 * - * @param WP_Abilities_Registry $instance Abilities registry object. + * @param \WP_Abilities_Registry $instance Abilities registry object. */ do_action( 'abilities_api_init', $wp_abilities ); } @@ -279,15 +273,12 @@ public static function get_instance(): WP_Abilities_Registry { * Wakeup magic method. * * @since 0.1.0 + * @throws \UnexpectedValueException If any of the registered abilities is not an instance of WP_Ability. */ public function __wakeup(): void { - if ( empty( $this->registered_abilities ) ) { - return; - } - foreach ( $this->registered_abilities as $ability ) { if ( ! $ability instanceof WP_Ability ) { - throw new UnexpectedValueException(); + throw new \UnexpectedValueException(); } } } diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 50dea61207b9b..f5ee8fbb4b000 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -1,5 +1,4 @@ - */ protected $input_schema = array(); @@ -57,7 +58,7 @@ class WP_Ability { * The optional ability output schema. * * @since 0.1.0 - * @var array + * @var array */ protected $output_schema = array(); @@ -81,7 +82,7 @@ class WP_Ability { * The optional ability metadata. * * @since 0.1.0 - * @var array + * @var array */ protected $meta = array(); @@ -94,10 +95,10 @@ class WP_Ability { * * @since 0.1.0 * - * @param string $name The name of the ability, with its namespace. - * @param array $properties An associative array of properties for the ability. This should - * include `label`, `description`, `input_schema`, `output_schema`, - * `execute_callback`, `permission_callback`, and `meta`. + * @param string $name The name of the ability, with its namespace. + * @param array $properties An associative array of properties for the ability. This should + * include `label`, `description`, `input_schema`, `output_schema`, + * `execute_callback`, `permission_callback`, and `meta`. */ public function __construct( string $name, array $properties ) { $this->name = $name; @@ -145,7 +146,7 @@ public function get_description(): string { * * @since 0.1.0 * - * @return array The input schema for the ability. + * @return array The input schema for the ability. */ public function get_input_schema(): array { return $this->input_schema; @@ -156,7 +157,7 @@ public function get_input_schema(): array { * * @since 0.1.0 * - * @return array The output schema for the ability. + * @return array The output schema for the ability. */ public function get_output_schema(): array { return $this->output_schema; @@ -167,7 +168,7 @@ public function get_output_schema(): array { * * @since 0.1.0 * - * @return array The metadata for the ability. + * @return array The metadata for the ability. */ public function get_meta(): array { return $this->meta; @@ -178,7 +179,7 @@ public function get_meta(): array { * * @since 0.1.0 * - * @param array $input Optional. The input data to validate. + * @param array $input Optional. The input data to validate. * @return bool Returns true if valid, false if validation fails. */ protected function validate_input( array $input = array() ): bool { @@ -214,7 +215,7 @@ protected function validate_input( array $input = array() ): bool { * * @since 0.1.0 * - * @param array $input Optional. The input data for permission checking. + * @param array $input Optional. The input data for permission checking. * @return bool Whether the ability has the necessary permission. */ public function has_permission( array $input = array() ): bool { @@ -234,8 +235,8 @@ public function has_permission( array $input = array() ): bool { * * @since 0.1.0 * - * @param array $input The input data for the ability. - * @return mixed|WP_Error The result of the ability execution, or WP_Error on failure. + * @param array $input The input data for the ability. + * @return mixed|\WP_Error The result of the ability execution, or WP_Error on failure. */ protected function do_execute( array $input ) { if ( ! is_callable( $this->execute_callback ) ) { @@ -292,8 +293,8 @@ protected function validate_output( $output ): bool { * * @since 0.1.0 * - * @param array $input Optional. The input data for the ability. - * @return mixed|WP_Error The result of the ability execution, or WP_Error on failure. + * @param array $input Optional. The input data for the ability. + * @return mixed|\WP_Error The result of the ability execution, or WP_Error on failure. */ public function execute( array $input = array() ) { if ( ! $this->has_permission( $input ) ) { @@ -326,6 +327,6 @@ public function execute( array $input = array() ) { * @since 0.1.0 */ public function __wakeup(): void { - throw new \LogicException( __CLASS__ . ' should never be unserialized.' ); + throw new \LogicException( self::class . ' should never be unserialized.' ); } } diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 836e0e5ec8a23..3241d38fd095f 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -416,6 +416,12 @@ function create_initial_rest_routes() { // Font Collections. $font_collections_controller = new WP_REST_Font_Collections_Controller(); $font_collections_controller->register_routes(); + + // Abilities. + $abilities_run_controller = new WP_REST_Abilities_Run_Controller(); + $abilities_run_controller->register_routes(); + $abilities_list_controller = new WP_REST_Abilities_List_Controller(); + $abilities_list_controller->register_routes(); } /** diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php new file mode 100644 index 0000000000000..78009a7f285a5 --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php @@ -0,0 +1,308 @@ +namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[a-zA-Z0-9\-\/]+)', + array( + 'args' => array( + 'name' => array( + 'description' => __( 'Unique identifier for the ability.' ), + 'type' => 'string', + 'pattern' => '^[a-zA-Z0-9\-\/]+$', + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_permissions_check' ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Retrieves all abilities. + * + * @since 0.1.0 + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response Response object on success. + */ + public function get_items( $request ) { + // TODO: Add HEAD method support for performance optimization. + // Should return early with empty body but include X-WP-Total and X-WP-TotalPages headers. + // See: https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php#L316-L318 + + $abilities = wp_get_abilities(); + + // Handle pagination with explicit defaults. + $params = $request->get_params(); + $page = $params['page'] ?? 1; + $per_page = $params['per_page'] ?? self::DEFAULT_PER_PAGE; + $offset = ( $page - 1 ) * $per_page; + + $total_abilities = count( $abilities ); + $max_pages = ceil( $total_abilities / $per_page ); + + $abilities = array_slice( $abilities, $offset, $per_page ); + + $data = array(); + foreach ( $abilities as $ability ) { + $item = $this->prepare_item_for_response( $ability, $request ); + $data[] = $this->prepare_response_for_collection( $item ); + } + + $response = rest_ensure_response( $data ); + + $response->header( 'X-WP-Total', (string) $total_abilities ); + $response->header( 'X-WP-TotalPages', (string) $max_pages ); + + $query_params = $request->get_query_params(); + $base = add_query_arg( urlencode_deep( $query_params ), rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) ); + + if ( $page > 1 ) { + $prev_page = $page - 1; + $prev_link = add_query_arg( 'page', $prev_page, $base ); + $response->add_link( 'prev', $prev_link ); + } + + if ( $page < $max_pages ) { + $next_page = $page + 1; + $next_link = add_query_arg( 'page', $next_page, $base ); + $response->add_link( 'next', $next_link ); + } + + return $response; + } + + /** + * Retrieves a specific ability. + * + * @since 0.1.0 + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_item( $request ) { + $ability = wp_get_ability( $request->get_param( 'name' ) ); + + if ( ! $ability ) { + return new \WP_Error( + 'rest_ability_not_found', + __( 'Ability not found.' ), + array( 'status' => 404 ) + ); + } + + $data = $this->prepare_item_for_response( $ability, $request ); + return rest_ensure_response( $data ); + } + + /** + * Checks if a given request has access to read abilities. + * + * @since 0.1.0 + * + * @param \WP_REST_Request $request Full details about the request. + * @return bool True if the request has read access. + */ + public function get_permissions_check( $request ) { + return current_user_can( 'read' ); + } + + /** + * Prepares an ability for response. + * + * @since 0.1.0 + * + * @param \WP_Ability $ability The ability object. + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response Response object. + */ + public function prepare_item_for_response( $ability, $request ) { + $data = array( + 'name' => $ability->get_name(), + 'label' => $ability->get_label(), + 'description' => $ability->get_description(), + 'input_schema' => $ability->get_input_schema(), + 'output_schema' => $ability->get_output_schema(), + 'meta' => $ability->get_meta(), + ); + + $context = $request->get_param( 'context' ) ?? 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + $response = rest_ensure_response( $data ); + + $fields = $this->get_fields_for_response( $request ); + if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { + $links = array( + 'self' => array( + 'href' => rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $ability->get_name() ) ), + ), + 'collection' => array( + 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ), + ), + ); + + $links['run'] = array( + 'href' => rest_url( sprintf( '%s/%s/%s/run', $this->namespace, $this->rest_base, $ability->get_name() ) ), + ); + + $response->add_links( $links ); + } + + return $response; + } + + /** + * Retrieves the ability's schema, conforming to JSON Schema. + * + * @since 0.1.0 + * + * @return array Item schema data. + */ + public function get_item_schema(): array { + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'ability', + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'description' => __( 'Unique identifier for the ability.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'label' => array( + 'description' => __( 'Display label for the ability.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'description' => array( + 'description' => __( 'Description of the ability.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'input_schema' => array( + 'description' => __( 'JSON Schema for the ability input.' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'output_schema' => array( + 'description' => __( 'JSON Schema for the ability output.' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'meta' => array( + 'description' => __( 'Meta information about the ability.' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + ), + 'required' => array( 'name', 'label', 'description' ), + ); + + return $this->add_additional_fields_schema( $schema ); + } + + /** + * Retrieves the query params for collections. + * + * @since 0.1.0 + * + * @return array Collection parameters. + */ + public function get_collection_params(): array { + return array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + 'page' => array( + 'description' => __( 'Current page of the collection.' ), + 'type' => 'integer', + 'default' => 1, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'rest_validate_request_arg', + 'minimum' => 1, + ), + 'per_page' => array( + 'description' => __( 'Maximum number of items to be returned in result set.' ), + 'type' => 'integer', + 'default' => self::DEFAULT_PER_PAGE, + 'minimum' => 1, + 'maximum' => 100, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'rest_validate_request_arg', + ), + ); + } +} diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php new file mode 100644 index 0000000000000..762ce14e8da6c --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php @@ -0,0 +1,331 @@ +namespace, + '/' . $this->rest_base . '/(?P[a-zA-Z0-9\-\/]+?)/run', + array( + 'args' => array( + 'name' => array( + 'description' => __( 'Unique identifier for the ability.' ), + 'type' => 'string', + 'pattern' => '^[a-zA-Z0-9\-\/]+$', + ), + ), + + // TODO: We register ALLMETHODS because at route registration time, we don't know + // which abilities exist or their types (resource vs tool). This is due to WordPress + // load order - routes are registered early, before plugins have registered their abilities. + // This approach works but could be improved with lazy route registration or a different + // architecture that allows type-specific routes after abilities are registered. + // This was the same issue that we ended up seeing with the Feature API. + array( + 'methods' => WP_REST_Server::ALLMETHODS, + 'callback' => array( $this, 'run_ability_with_method_check' ), + 'permission_callback' => array( $this, 'run_ability_permissions_check' ), + 'args' => $this->get_run_args(), + ), + 'schema' => array( $this, 'get_run_schema' ), + ) + ); + } + + /** + * Executes an ability with HTTP method validation. + * + * @since 0.1.0 + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + */ + public function run_ability_with_method_check( $request ) { + $ability = wp_get_ability( $request->get_param( 'name' ) ); + + if ( ! $ability ) { + return new \WP_Error( + 'rest_ability_not_found', + __( 'Ability not found.' ), + array( 'status' => 404 ) + ); + } + + // Check if the HTTP method matches the ability type. + $meta = $ability->get_meta(); + $type = isset( $meta['type'] ) ? $meta['type'] : 'tool'; + $method = $request->get_method(); + + if ( 'resource' === $type && 'GET' !== $method ) { + return new \WP_Error( + 'rest_invalid_method', + __( 'Resource abilities require GET method.' ), + array( 'status' => 405 ) + ); + } + + if ( 'tool' === $type && 'POST' !== $method ) { + return new \WP_Error( + 'rest_invalid_method', + __( 'Tool abilities require POST method.' ), + array( 'status' => 405 ) + ); + } + + return $this->run_ability( $request ); + } + + /** + * Executes an ability. + * + * @since 0.1.0 + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + */ + public function run_ability( $request ) { + $ability = wp_get_ability( $request->get_param( 'name' ) ); + + if ( ! $ability ) { + return new \WP_Error( + 'rest_ability_not_found', + __( 'Ability not found.' ), + array( 'status' => 404 ) + ); + } + + $input = $this->get_input_from_request( $request ); + + // REST API needs detailed error messages with HTTP status codes. + // While WP_Ability::execute() validates internally, it only returns false + // and logs with _doing_it_wrong, which doesn't provide capturable error messages. + // TODO: Consider updating WP_Ability to return WP_Error for better error handling. + $input_validation = $this->validate_input( $ability, $input ); + if ( is_wp_error( $input_validation ) ) { + return $input_validation; + } + + $result = $ability->execute( $input ); + + if ( is_wp_error( $result ) ) { + return new \WP_Error( + 'rest_ability_execution_failed', + $result->get_error_message(), + array( 'status' => 500 ) + ); + } + + if ( is_null( $result ) ) { + return new \WP_Error( + 'rest_ability_execution_failed', + __( 'Ability execution failed. Please check permissions and input parameters.' ), + array( 'status' => 500 ) + ); + } + + $output_validation = $this->validate_output( $ability, $result ); + if ( is_wp_error( $output_validation ) ) { + return $output_validation; + } + + return rest_ensure_response( $result ); + } + + /** + * Checks if a given request has permission to execute a specific ability. + * + * @since 0.1.0 + * + * @param \WP_REST_Request $request Full details about the request. + * @return true|\WP_Error True if the request has execution permission, WP_Error object otherwise. + */ + public function run_ability_permissions_check( $request ) { + $ability = wp_get_ability( $request->get_param( 'name' ) ); + + if ( ! $ability ) { + return new \WP_Error( + 'rest_ability_not_found', + __( 'Ability not found.' ), + array( 'status' => 404 ) + ); + } + + $input = $this->get_input_from_request( $request ); + + if ( ! $ability->has_permission( $input ) ) { + return new \WP_Error( + 'rest_cannot_execute', + __( 'Sorry, you are not allowed to execute this ability.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Validates input data against the ability's input schema. + * + * @since 0.1.0 + * + * @param \WP_Ability $ability The ability object. + * @param array $input The input data to validate. + * @return true|\WP_Error True if validation passes, WP_Error object on failure. + */ + private function validate_input( $ability, $input ) { + $input_schema = $ability->get_input_schema(); + + if ( empty( $input_schema ) ) { + return true; + } + + $validation_result = rest_validate_value_from_schema( $input, $input_schema ); + if ( is_wp_error( $validation_result ) ) { + return new \WP_Error( + 'rest_invalid_param', + sprintf( + /* translators: %s: error message */ + __( 'Invalid input parameters: %s' ), + $validation_result->get_error_message() + ), + array( 'status' => 400 ) + ); + } + + return true; + } + + /** + * Validates output data against the ability's output schema. + * + * @since 0.1.0 + * + * @param \WP_Ability $ability The ability object. + * @param mixed $output The output data to validate. + * @return true|\WP_Error True if validation passes, WP_Error object on failure. + */ + private function validate_output( $ability, $output ) { + $output_schema = $ability->get_output_schema(); + + if ( empty( $output_schema ) ) { + return true; + } + + $validation_result = rest_validate_value_from_schema( $output, $output_schema ); + if ( is_wp_error( $validation_result ) ) { + return new \WP_Error( + 'rest_invalid_response', + sprintf( + /* translators: %s: error message */ + __( 'Invalid response from ability: %s' ), + $validation_result->get_error_message() + ), + array( 'status' => 500 ) + ); + } + + return true; + } + + /** + * Extracts input parameters from the request. + * + * @since 0.1.0 + * + * @param \WP_REST_Request $request The request object. + * @return array The input parameters. + */ + private function get_input_from_request( $request ) { + if ( 'GET' === $request->get_method() ) { + // For GET requests, look for 'input' query parameter. + $query_params = $request->get_query_params(); + return isset( $query_params['input'] ) && is_array( $query_params['input'] ) + ? $query_params['input'] + : array(); + } + + // For POST requests, look for 'input' in JSON body. + $json_params = $request->get_json_params(); + return isset( $json_params['input'] ) && is_array( $json_params['input'] ) + ? $json_params['input'] + : array(); + } + + /** + * Retrieves the arguments for ability execution endpoint. + * + * @since 0.1.0 + * + * @return array Arguments for the run endpoint. + */ + public function get_run_args(): array { + return array( + 'input' => array( + 'description' => __( 'Input parameters for the ability execution.' ), + 'type' => 'object', + 'default' => array(), + ), + ); + } + + /** + * Retrieves the schema for ability execution endpoint. + * + * @since 0.1.0 + * + * @return array Schema for the run endpoint. + */ + public function get_run_schema(): array { + return array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'ability-execution', + 'type' => 'object', + 'properties' => array( + 'result' => array( + 'description' => __( 'The result of the ability execution.' ), + 'type' => 'mixed', + 'context' => array( 'view' ), + 'readonly' => true, + ), + ), + ); + } +} diff --git a/src/wp-settings.php b/src/wp-settings.php index e54bc9d398127..7330228bdb36b 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -333,6 +333,8 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-families-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-faces-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-collections-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-list-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-run-controller.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-meta-fields.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-comment-meta-fields.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-post-meta-fields.php'; diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php index 3b741e0c19b93..523f5398de58e 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -5,7 +5,7 @@ * * @group abilities-api */ -class Tests_Abilities_API_wpAbilitiesRegistry extends WP_UnitTestCase { +class Tests_Abilities_API_WpAbilitiesRegistry extends WP_UnitTestCase { public static $test_ability_name = 'test/add-numbers'; public static $test_ability_properties = array(); diff --git a/tests/phpunit/tests/abilities-api/register.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php similarity index 94% rename from tests/phpunit/tests/abilities-api/register.php rename to tests/phpunit/tests/abilities-api/wpRegisterAbility.php index 9878d5c4d5d48..e3709a1243f99 100644 --- a/tests/phpunit/tests/abilities-api/register.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -8,7 +8,7 @@ * * @group abilities-api */ -class Tests_Abilities_API_Register extends WP_UnitTestCase { +class Test_Abilities_API_WpRegisterAbility extends WP_UnitTestCase { public static $test_ability_name = 'test/add-numbers'; public static $test_ability_properties = array(); @@ -87,8 +87,21 @@ public function test_register_ability_invalid_name(): void { * @expectedIncorrectUsage wp_register_ability */ public function test_register_ability_no_abilities_api_init_hook(): void { + global $wp_actions; + + // Store the original action count + $original_count = isset( $wp_actions['abilities_api_init'] ) ? $wp_actions['abilities_api_init'] : 0; + + // Reset the action count to simulate it not being fired + unset( $wp_actions['abilities_api_init'] ); + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); + // Restore the original action count + if ( $original_count > 0 ) { + $wp_actions['abilities_api_init'] = $original_count; + } + $this->assertNull( $result ); } diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 7f8de5f0dd83d..9d54df41a8c47 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -195,6 +195,9 @@ public function test_expected_routes_in_schema() { '/wp/v2/font-families/(?P[\d]+)/font-faces', '/wp/v2/font-families/(?P[\d]+)/font-faces/(?P[\d]+)', '/wp/v2/font-families/(?P[\d]+)', + '/wp/v2/abilities/(?P[a-zA-Z0-9\-\/]+?)/run', + '/wp/v2/abilities/(?P[a-zA-Z0-9\-\/]+)', + '/wp/v2/abilities', ); $this->assertSameSets( $expected_routes, $routes ); diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php new file mode 100644 index 0000000000000..76c2071dcfd06 --- /dev/null +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php @@ -0,0 +1,510 @@ +user->create( + array( + 'role' => 'subscriber', + ) + ); + } + + /** + * Set up before each test. + */ + public function set_up(): void { + parent::set_up(); + + // Set up REST server + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + $this->server = $wp_rest_server; + + do_action( 'rest_api_init' ); + + // Initialize abilities API + do_action( 'abilities_api_init' ); + + // Register test abilities + $this->register_test_abilities(); + + // Set default user for tests + wp_set_current_user( self::$user_id ); + } + + /** + * Tear down after each test. + */ + public function tear_down(): void { + // Clean up test abilities + foreach ( wp_get_abilities() as $ability ) { + if ( str_starts_with( $ability->get_name(), 'test/' ) ) { + wp_unregister_ability( $ability->get_name() ); + } + } + + // Reset REST server + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Register test abilities for testing. + */ + private function register_test_abilities(): void { + // Register a tool ability + wp_register_ability( + 'test/calculator', + array( + 'label' => 'Calculator', + 'description' => 'Performs basic calculations', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'operation' => array( + 'type' => 'string', + 'enum' => array( 'add', 'subtract', 'multiply', 'divide' ), + ), + 'a' => array( 'type' => 'number' ), + 'b' => array( 'type' => 'number' ), + ), + ), + 'output_schema' => array( + 'type' => 'number', + ), + 'execute_callback' => function ( array $input ) { + switch ( $input['operation'] ) { + case 'add': + return $input['a'] + $input['b']; + case 'subtract': + return $input['a'] - $input['b']; + case 'multiply': + return $input['a'] * $input['b']; + case 'divide': + return 0 !== $input['b'] ? $input['a'] / $input['b'] : null; + default: + return null; + } + }, + 'permission_callback' => function () { + return current_user_can( 'read' ); + }, + 'meta' => array( + 'type' => 'tool', + 'category' => 'math', + ), + ) + ); + + // Register a resource ability + wp_register_ability( + 'test/system-info', + array( + 'label' => 'System Info', + 'description' => 'Returns system information', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'detail_level' => array( + 'type' => 'string', + 'enum' => array( 'basic', 'full' ), + 'default' => 'basic', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'php_version' => array( 'type' => 'string' ), + 'wp_version' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => function ( array $input ) { + $info = array( + 'php_version' => phpversion(), + 'wp_version' => get_bloginfo( 'version' ), + ); + if ( 'full' === ( $input['detail_level'] ?? 'basic' ) ) { + $info['memory_limit'] = ini_get( 'memory_limit' ); + } + return $info; + }, + 'permission_callback' => function () { + return current_user_can( 'read' ); + }, + 'meta' => array( + 'type' => 'resource', + 'category' => 'system', + ), + ) + ); + + // Register multiple abilities for pagination testing + for ( $i = 1; $i <= 60; $i++ ) { + wp_register_ability( + "test/ability-{$i}", + array( + 'label' => "Test Ability {$i}", + 'description' => "Test ability number {$i}", + 'execute_callback' => function () use ( $i ) { + return "Result from ability {$i}"; + }, + 'permission_callback' => '__return_true', + ) + ); + } + } + + /** + * Test listing all abilities. + */ + public function test_get_items(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertNotEmpty( $data ); + + $this->assertCount( 50, $data, 'First page should return exactly 50 items (default per_page)' ); + + $ability_names = wp_list_pluck( $data, 'name' ); + $this->assertContains( 'test/calculator', $ability_names ); + $this->assertContains( 'test/system-info', $ability_names ); + } + + /** + * Test getting a specific ability. + */ + public function test_get_item(): 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->assertEquals( 'test/calculator', $data['name'] ); + $this->assertEquals( 'Calculator', $data['label'] ); + $this->assertEquals( 'Performs basic calculations', $data['description'] ); + $this->assertArrayHasKey( 'input_schema', $data ); + $this->assertArrayHasKey( 'output_schema', $data ); + $this->assertArrayHasKey( 'meta', $data ); + $this->assertEquals( 'tool', $data['meta']['type'] ); + } + + /** + * Test getting a non-existent ability returns 404. + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_registered + */ + public function test_get_item_not_found(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/non/existent' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 404, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( 'rest_ability_not_found', $data['code'] ); + } + + /** + * Test permission check for listing abilities. + */ + public function test_get_items_permission_denied(): void { + // Test with non-logged-in user + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 401, $response->get_status() ); + } + + /** + * Test pagination headers. + */ + public function test_pagination_headers(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request->set_param( 'per_page', 10 ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $headers = $response->get_headers(); + $this->assertArrayHasKey( 'X-WP-Total', $headers ); + $this->assertArrayHasKey( 'X-WP-TotalPages', $headers ); + + $total_abilities = count( wp_get_abilities() ); + $this->assertEquals( $total_abilities, (int) $headers['X-WP-Total'] ); + $this->assertEquals( ceil( $total_abilities / 10 ), (int) $headers['X-WP-TotalPages'] ); + } + + /** + * Test pagination links. + */ + public function test_pagination_links(): void { + // Test first page (should have 'next' link but no 'prev') + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request->set_param( 'per_page', 10 ); + $request->set_param( 'page', 1 ); + $response = $this->server->dispatch( $request ); + + $links = $response->get_links(); + $this->assertArrayHasKey( 'next', $links ); + $this->assertArrayNotHasKey( 'prev', $links ); + + // Test middle page (should have both 'next' and 'prev' links) + $request->set_param( 'page', 3 ); + $response = $this->server->dispatch( $request ); + + $links = $response->get_links(); + $this->assertArrayHasKey( 'next', $links ); + $this->assertArrayHasKey( 'prev', $links ); + + // Test last page (should have 'prev' link but no 'next') + $total_abilities = count( wp_get_abilities() ); + $last_page = ceil( $total_abilities / 10 ); + $request->set_param( 'page', $last_page ); + $response = $this->server->dispatch( $request ); + + $links = $response->get_links(); + $this->assertArrayNotHasKey( 'next', $links ); + $this->assertArrayHasKey( 'prev', $links ); + } + + /** + * Test collection parameters. + */ + public function test_collection_params(): void { + // Test per_page parameter + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request->set_param( 'per_page', 5 ); + $response = $this->server->dispatch( $request ); + + $data = $response->get_data(); + $this->assertCount( 5, $data ); + + // Test page parameter + $request->set_param( 'page', 2 ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertCount( 5, $data ); + + // Verify we got different abilities on page 2 + $page1_request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $page1_request->set_param( 'per_page', 5 ); + $page1_request->set_param( 'page', 1 ); + $page1_response = $this->server->dispatch( $page1_request ); + $page1_names = wp_list_pluck( $page1_response->get_data(), 'name' ); + $page2_names = wp_list_pluck( $data, 'name' ); + + $this->assertNotEquals( $page1_names, $page2_names ); + } + + /** + * Test response links for individual abilities. + */ + public function test_ability_response_links(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/calculator' ); + $response = $this->server->dispatch( $request ); + + $links = $response->get_links(); + $this->assertArrayHasKey( 'self', $links ); + $this->assertArrayHasKey( 'collection', $links ); + $this->assertArrayHasKey( 'run', $links ); + + // Verify link URLs + $self_link = $links['self'][0]['href']; + $this->assertStringContainsString( '/wp/v2/abilities/test/calculator', $self_link ); + + $collection_link = $links['collection'][0]['href']; + $this->assertStringContainsString( '/wp/v2/abilities', $collection_link ); + + $run_link = $links['run'][0]['href']; + $this->assertStringContainsString( '/wp/v2/abilities/test/calculator/run', $run_link ); + } + + /** + * Test context parameter. + */ + public function test_context_parameter(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/calculator' ); + $request->set_param( 'context', 'view' ); + $response = $this->server->dispatch( $request ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'description', $data ); + + $request->set_param( 'context', 'embed' ); + $response = $this->server->dispatch( $request ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'name', $data ); + $this->assertArrayHasKey( 'label', $data ); + } + + /** + * Test schema retrieval. + */ + public function test_get_schema(): void { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/abilities' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'schema', $data ); + $schema = $data['schema']; + + $this->assertEquals( 'ability', $schema['title'] ); + $this->assertEquals( 'object', $schema['type'] ); + $this->assertArrayHasKey( 'properties', $schema ); + + $properties = $schema['properties']; + $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 ); + } + + /** + * Test ability name with valid special characters. + */ + public function test_ability_name_with_valid_special_characters(): void { + // Register ability with hyphen (valid) + wp_register_ability( + 'test-hyphen/ability', + array( + 'label' => 'Test Hyphen Ability', + 'description' => 'Test ability with hyphen', + 'execute_callback' => function ( $input ) { + return array( 'success' => true ); + }, + 'permission_callback' => '__return_true', + ) + ); + + // Test valid special characters (hyphen, forward slash) + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test-hyphen/ability' ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * Data provider for invalid ability names. + * + * @return array + */ + public function invalid_ability_names_provider(): array { + return array( + '@ symbol' => array( 'test@ability' ), + 'space' => array( 'test ability' ), + 'dot' => array( 'test.ability' ), + 'hash' => array( 'test#ability' ), + 'URL encoded space' => array( 'test%20ability' ), + 'angle brackets' => array( 'test' ), + 'pipe' => array( 'test|ability' ), + 'backslash' => array( 'test\\ability' ), + ); + } + + /** + * Test ability names with invalid special characters. + * + * @dataProvider invalid_ability_names_provider + * @param string $name Invalid ability name to test. + */ + public function test_ability_name_with_invalid_special_characters( string $name ): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/' . $name ); + $response = $this->server->dispatch( $request ); + // Should return 404 as the regex pattern won't match + $this->assertEquals( 404, $response->get_status() ); + } + + + /** + * Test extremely long ability names. + * @expectedIncorrectUsage WP_Abilities_Registry::get_registered + */ + public function test_extremely_long_ability_names(): void { + // Create a very long but valid ability name + $long_name = 'test/' . str_repeat( 'a', 1000 ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/' . $long_name ); + $response = $this->server->dispatch( $request ); + + // Should return 404 as ability doesn't exist + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * Data provider for invalid pagination parameters. + * + * @return array}> + */ + public function invalid_pagination_params_provider(): array { + return array( + 'Zero page' => array( array( 'page' => 0 ) ), + 'Negative page' => array( array( 'page' => -1 ) ), + 'Non-numeric page' => array( array( 'page' => 'abc' ) ), + 'Zero per page' => array( array( 'per_page' => 0 ) ), + 'Negative per page' => array( array( 'per_page' => -10 ) ), + 'Exceeds maximum' => array( array( 'per_page' => 1000 ) ), + 'Non-numeric per page' => array( array( 'per_page' => 'all' ) ), + ); + } + + /** + * Test pagination parameters with invalid values. + * + * @dataProvider invalid_pagination_params_provider + * @param array $params Invalid pagination parameters. + */ + public function test_invalid_pagination_parameters( array $params ): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request->set_query_params( $params ); + + $response = $this->server->dispatch( $request ); + + // Should either use defaults or return error + $this->assertContains( $response->get_status(), array( 200, 400 ) ); + + if ( $response->get_status() === 200 ) { + // Check that reasonable defaults were used + $data = $response->get_data(); + $this->assertIsArray( $data ); + } + } +} diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php new file mode 100644 index 0000000000000..7bb47f0491276 --- /dev/null +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php @@ -0,0 +1,955 @@ +user->create( + array( + 'role' => 'editor', + ) + ); + + self::$no_permission_user_id = self::factory()->user->create( + array( + 'role' => 'subscriber', + ) + ); + } + + /** + * Set up before each test. + */ + public function set_up(): void { + parent::set_up(); + + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + $this->server = $wp_rest_server; + + do_action( 'rest_api_init' ); + + do_action( 'abilities_api_init' ); + + $this->register_test_abilities(); + + // Set default user for tests + wp_set_current_user( self::$user_id ); + } + + /** + * Tear down after each test. + */ + public function tear_down(): void { + foreach ( wp_get_abilities() as $ability ) { + if ( str_starts_with( $ability->get_name(), 'test/' ) ) { + wp_unregister_ability( $ability->get_name() ); + } + } + + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Register test abilities for testing. + */ + private function register_test_abilities(): void { + // Tool ability (POST only) + wp_register_ability( + 'test/calculator', + array( + 'label' => 'Calculator', + 'description' => 'Performs calculations', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'a' => array( + 'type' => 'number', + 'description' => 'First number', + ), + 'b' => array( + 'type' => 'number', + 'description' => 'Second number', + ), + ), + 'required' => array( 'a', 'b' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'number', + ), + 'execute_callback' => function ( array $input ) { + return $input['a'] + $input['b']; + }, + 'permission_callback' => function ( array $input ) { + return current_user_can( 'edit_posts' ); + }, + 'meta' => array( + 'type' => 'tool', + ), + ) + ); + + // Resource ability (GET only) + wp_register_ability( + 'test/user-info', + array( + 'label' => 'User Info', + 'description' => 'Gets user information', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'user_id' => array( + 'type' => 'integer', + 'default' => 0, + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( 'type' => 'integer' ), + 'login' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => function ( array $input ) { + $user_id = $input['user_id'] ?? get_current_user_id(); + $user = get_user_by( 'id', $user_id ); + if ( ! $user ) { + return new WP_Error( 'user_not_found', 'User not found' ); + } + return array( + 'id' => $user->ID, + 'login' => $user->user_login, + ); + }, + 'permission_callback' => function ( array $input ) { + return is_user_logged_in(); + }, + 'meta' => array( + 'type' => 'resource', + ), + ) + ); + + // Ability with contextual permissions + wp_register_ability( + 'test/restricted', + array( + 'label' => 'Restricted Action', + 'description' => 'Requires specific input for permission', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'secret' => array( 'type' => 'string' ), + 'data' => array( 'type' => 'string' ), + ), + 'required' => array( 'secret', 'data' ), + ), + 'output_schema' => array( + 'type' => 'string', + ), + 'execute_callback' => function ( array $input ) { + return 'Success: ' . $input['data']; + }, + 'permission_callback' => function ( array $input ) { + // Only allow if secret matches + return isset( $input['secret'] ) && 'valid_secret' === $input['secret']; + }, + 'meta' => array( + 'type' => 'tool', + ), + ) + ); + + // Ability that returns null + wp_register_ability( + 'test/null-return', + array( + 'label' => 'Null Return', + 'description' => 'Returns null', + 'execute_callback' => function () { + return null; + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'type' => 'tool', + ), + ) + ); + + // Ability that returns WP_Error + wp_register_ability( + 'test/error-return', + array( + 'label' => 'Error Return', + 'description' => 'Returns error', + 'execute_callback' => function () { + return new WP_Error( 'test_error', 'This is a test error' ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'type' => 'tool', + ), + ) + ); + + // Ability with invalid output + wp_register_ability( + 'test/invalid-output', + array( + 'label' => 'Invalid Output', + 'description' => 'Returns invalid output', + 'output_schema' => array( + 'type' => 'number', + ), + 'execute_callback' => function () { + return 'not a number'; // Invalid - schema expects number + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'type' => 'tool', + ), + ) + ); + + // Resource ability for query params testing + wp_register_ability( + 'test/query-params', + array( + 'label' => 'Query Params Test', + 'description' => 'Tests query parameter handling', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'param1' => array( 'type' => 'string' ), + 'param2' => array( 'type' => 'integer' ), + ), + ), + 'execute_callback' => function ( array $input ) { + return $input; + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'type' => 'resource', + ), + ) + ); + } + + /** + * Test executing a tool ability with POST. + */ + public function test_execute_tool_ability_post(): void { + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/calculator/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'a' => 5, + 'b' => 3, + ), + ) + ) + ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 8, $response->get_data() ); + } + + /** + * Test executing a resource ability with GET. + */ + public function test_execute_resource_ability_get(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/user-info/run' ); + $request->set_query_params( + array( + 'input' => array( + 'user_id' => self::$user_id, + ), + ) + ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( self::$user_id, $data['id'] ); + } + + /** + * Test HTTP method validation for tool abilities. + */ + public function test_tool_ability_requires_post(): void { + wp_register_ability( + 'test/open-tool', + array( + 'label' => 'Open Tool', + 'description' => 'Tool with no permission requirements', + 'execute_callback' => function () { + return 'success'; + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'type' => 'tool', + ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/open-tool/run' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 405, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_invalid_method', $data['code'] ); + $this->assertStringContainsString( 'Tool abilities require POST', $data['message'] ); + } + + /** + * Test HTTP method validation for resource abilities. + */ + public function test_resource_ability_requires_get(): void { + // Try POST on a resource ability (should fail) + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/user-info/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'user_id' => 1 ) ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 405, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_invalid_method', $data['code'] ); + $this->assertStringContainsString( 'Resource abilities require GET', $data['message'] ); + } + + + /** + * Test output validation against schema. + * Note: When output validation fails in WP_Ability::execute(), it returns null, + * which causes the REST controller to return 'rest_ability_execution_failed'. + * + * @expectedIncorrectUsage WP_Ability::validate_output + */ + public function test_output_validation(): void { + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/invalid-output/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array() ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 500, $response->get_status() ); + $data = $response->get_data(); + + $this->assertEquals( 'rest_ability_execution_failed', $data['code'] ); + $this->assertStringContainsString( 'Ability execution failed', $data['message'] ); + } + + /** + * Test permission check for execution. + */ + public function test_execution_permission_denied(): void { + wp_set_current_user( self::$no_permission_user_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/calculator/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'a' => 5, + 'b' => 3, + ), + ) + ) + ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 403, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_cannot_execute', $data['code'] ); + } + + /** + * Test contextual permission check. + */ + public function test_contextual_permission_check(): void { + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/restricted/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'secret' => 'wrong_secret', + 'data' => 'test data', + ), + ) + ) + ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 403, $response->get_status() ); + + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'secret' => 'valid_secret', + 'data' => 'test data', + ), + ) + ) + ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 'Success: test data', $response->get_data() ); + } + + /** + * Test handling of null return from ability. + */ + public function test_null_return_handling(): void { + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/null-return/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array() ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 500, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_ability_execution_failed', $data['code'] ); + $this->assertStringContainsString( 'Ability execution failed', $data['message'] ); + } + + /** + * Test handling of WP_Error return from ability. + */ + public function test_wp_error_return_handling(): void { + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/error-return/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array() ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 500, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_ability_execution_failed', $data['code'] ); + $this->assertEquals( 'This is a test error', $data['message'] ); + } + + /** + * Test non-existent ability returns 404. + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_registered + */ + public function test_execute_non_existent_ability(): void { + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/non/existent/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array() ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 404, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_ability_not_found', $data['code'] ); + } + + /** + * Test schema retrieval for run endpoint. + */ + public function test_run_endpoint_schema(): void { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/abilities/test/calculator/run' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'schema', $data ); + $schema = $data['schema']; + + $this->assertEquals( 'ability-execution', $schema['title'] ); + $this->assertEquals( 'object', $schema['type'] ); + $this->assertArrayHasKey( 'properties', $schema ); + $this->assertArrayHasKey( 'result', $schema['properties'] ); + } + + /** + * Test that invalid JSON in POST body is handled correctly. + */ + public function test_invalid_json_in_post_body(): void { + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/calculator/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + // Set raw body with invalid JSON + $request->set_body( '{"input": {invalid json}' ); + + $response = $this->server->dispatch( $request ); + + // When JSON is invalid, WordPress returns 400 Bad Request + $this->assertEquals( 400, $response->get_status() ); + } + + /** + * Test GET request with complex nested input array. + */ + public function test_get_request_with_nested_input_array(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/query-params/run' ); + $request->set_query_params( + array( + 'input' => array( + 'level1' => array( + 'level2' => array( + 'value' => 'nested', + ), + ), + 'array' => array( 1, 2, 3 ), + ), + ) + ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( 'nested', $data['level1']['level2']['value'] ); + $this->assertEquals( array( 1, 2, 3 ), $data['array'] ); + } + + /** + * Test GET request with non-array input parameter. + */ + public function test_get_request_with_non_array_input(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/query-params/run' ); + $request->set_query_params( + array( + 'input' => 'not-an-array', // String instead of array + ) + ); + + $response = $this->server->dispatch( $request ); + // When input is not an array, WordPress returns 400 Bad Request + $this->assertEquals( 400, $response->get_status() ); + } + + /** + * Test POST request with non-array input in JSON body. + */ + public function test_post_request_with_non_array_input(): void { + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/calculator/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => 'string-value', // String instead of array + ) + ) + ); + + $response = $this->server->dispatch( $request ); + // When input is not an array, WordPress returns 400 Bad Request + $this->assertEquals( 400, $response->get_status() ); + } + + /** + * Test ability with invalid output that fails validation. + * @expectedIncorrectUsage WP_Ability::validate_output + */ + public function test_output_validation_failure_returns_error(): void { + // Register ability with strict output schema. + wp_register_ability( + 'test/strict-output', + array( + 'label' => 'Strict Output', + 'description' => 'Ability with strict output schema', + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'status' => array( + 'type' => 'string', + 'enum' => array( 'success', 'failure' ), + ), + ), + 'required' => array( 'status' ), + ), + 'execute_callback' => function ( $input ) { + // Return invalid output that doesn't match schema + return array( 'wrong_field' => 'value' ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'type' => 'tool' ), + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/strict-output/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'input' => array() ) ) ); + + $response = $this->server->dispatch( $request ); + + // Should return error when output validation fails + $this->assertEquals( 500, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_ability_execution_failed', $data['code'] ); + } + + /** + * Test ability with invalid input that fails validation. + * @expectedIncorrectUsage WP_Ability::validate_input + */ + public function test_input_validation_failure_returns_error(): void { + // Register ability with strict input schema. + wp_register_ability( + 'test/strict-input', + array( + 'label' => 'Strict Input', + 'description' => 'Ability with strict input schema', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'required_field' => array( + 'type' => 'string', + ), + ), + 'required' => array( 'required_field' ), + ), + 'execute_callback' => function ( $input ) { + return array( 'status' => 'success' ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'type' => 'tool' ), + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/strict-input/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + // Missing required field + $request->set_body( wp_json_encode( array( 'input' => array( 'other_field' => 'value' ) ) ) ); + + $response = $this->server->dispatch( $request ); + + // Should return error when input validation fails (403 due to permission check) + $this->assertEquals( 403, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_cannot_execute', $data['code'] ); + } + + /** + * Test ability type not set defaults to tool. + */ + public function test_ability_without_type_defaults_to_tool(): void { + // Register ability without type in meta. + wp_register_ability( + 'test/no-type', + array( + 'label' => 'No Type', + 'description' => 'Ability without type', + 'execute_callback' => function ( $input ) { + return array( 'executed' => true ); + }, + 'permission_callback' => '__return_true', + 'meta' => array(), // No type specified + ) + ); + + // Should require POST (default tool behavior) + $get_request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/no-type/run' ); + $get_response = $this->server->dispatch( $get_request ); + $this->assertEquals( 405, $get_response->get_status() ); + + // Should work with POST + $post_request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/no-type/run' ); + $post_request->set_header( 'Content-Type', 'application/json' ); + $post_request->set_body( wp_json_encode( array( 'input' => array() ) ) ); + + $post_response = $this->server->dispatch( $post_request ); + $this->assertEquals( 200, $post_response->get_status() ); + } + + /** + * Test permission check with null permission callback. + */ + public function test_permission_check_passes_when_callback_not_set(): void { + // Register ability without permission callback. + wp_register_ability( + 'test/no-permission-callback', + array( + 'label' => 'No Permission Callback', + 'description' => 'Ability without permission callback', + 'execute_callback' => function ( $input ) { + return array( 'executed' => true ); + }, + 'meta' => array( 'type' => 'tool' ), + // No permission_callback set + ) + ); + + wp_set_current_user( 0 ); // Not logged in + + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/no-permission-callback/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'input' => array() ) ) ); + + $response = $this->server->dispatch( $request ); + + // Should succeed when no permission callback is set + $this->assertEquals( 200, $response->get_status() ); + + // Restore user for other tests + wp_set_current_user( self::$user_id ); + } + + /** + * Test edge case with empty input for both GET and POST. + */ + public function test_empty_input_handling(): void { + // Register abilities for empty input testing + wp_register_ability( + 'test/resource-empty', + array( + 'label' => 'Resource Empty', + 'description' => 'Resource with empty input', + 'execute_callback' => function ( $input ) { + return array( 'input_was_empty' => empty( $input ) ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'type' => 'resource' ), + ) + ); + + wp_register_ability( + 'test/tool-empty', + array( + 'label' => 'Tool Empty', + 'description' => 'Tool with empty input', + 'execute_callback' => function ( $input ) { + return array( 'input_was_empty' => empty( $input ) ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'type' => 'tool' ), + ) + ); + + // Test GET with no input parameter + $get_request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/resource-empty/run' ); + $get_response = $this->server->dispatch( $get_request ); + $this->assertEquals( 200, $get_response->get_status() ); + $this->assertTrue( $get_response->get_data()['input_was_empty'] ); + + // Test POST with no body + $post_request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/tool-empty/run' ); + $post_request->set_header( 'Content-Type', 'application/json' ); + $post_request->set_body( '{}' ); // Empty JSON object + + $post_response = $this->server->dispatch( $post_request ); + $this->assertEquals( 200, $post_response->get_status() ); + $this->assertTrue( $post_response->get_data()['input_was_empty'] ); + } + + /** + * Data provider for malformed JSON tests. + * + * @return array + */ + public function malformed_json_provider(): array { + return array( + 'Missing value' => array( '{"input": }' ), + 'Trailing comma in array' => array( '{"input": [1, 2, }' ), + 'Missing quotes on key' => array( '{input: {}}' ), + 'JavaScript undefined' => array( '{"input": undefined}' ), + 'JavaScript NaN' => array( '{"input": NaN}' ), + 'Missing quotes nested keys' => array( '{"input": {a: 1, b: 2}}' ), + 'Single quotes' => array( '\'{"input": {}}\'' ), + 'Unclosed object' => array( '{"input": {"key": "value"' ), + ); + } + + /** + * Test malformed JSON in POST body. + * + * @dataProvider malformed_json_provider + * @param string $json Malformed JSON to test. + */ + public function test_malformed_json_post_body( string $json ): void { + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/calculator/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( $json ); + + $response = $this->server->dispatch( $request ); + + // Malformed JSON should result in 400 Bad Request + $this->assertEquals( 400, $response->get_status() ); + } + + + /** + * Test input with various PHP types as strings. + */ + public function test_php_type_strings_in_input(): void { + // Register ability that accepts any input + wp_register_ability( + 'test/echo', + array( + 'label' => 'Echo', + 'description' => 'Echoes input', + 'execute_callback' => function ( $input ) { + return array( 'echo' => $input ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'type' => 'tool' ), + ) + ); + + $inputs = array( + 'null' => null, + 'true' => true, + 'false' => false, + 'int' => 123, + 'float' => 123.456, + 'string' => 'test', + 'empty' => '', + 'zero' => 0, + 'negative' => -1, + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/echo/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'input' => $inputs ) ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( $inputs, $data['echo'] ); + } + + /** + * Test input with mixed encoding. + */ + public function test_mixed_encoding_in_input(): void { + // Register ability that accepts any input + wp_register_ability( + 'test/echo-encoding', + array( + 'label' => 'Echo Encoding', + 'description' => 'Echoes input with encoding', + 'execute_callback' => function ( $input ) { + return array( 'echo' => $input ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'type' => 'tool' ), + ) + ); + + $input = array( + 'utf8' => 'Hello 世界', + 'emoji' => '🎉🎊🎈', + 'html' => '', + 'encoded' => '<test>', + 'newlines' => "line1\nline2\rline3\r\nline4", + 'tabs' => "col1\tcol2\tcol3", + 'quotes' => "It's \"quoted\"", + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/echo-encoding/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'input' => $input ) ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + + // Input should be preserved exactly + $this->assertEquals( $input['utf8'], $data['echo']['utf8'] ); + $this->assertEquals( $input['emoji'], $data['echo']['emoji'] ); + $this->assertEquals( $input['html'], $data['echo']['html'] ); + } + + /** + * Data provider for invalid HTTP methods. + * + * @return array + */ + public function invalid_http_methods_provider(): array { + return array( + 'PATCH' => array( 'PATCH' ), + 'PUT' => array( 'PUT' ), + 'DELETE' => array( 'DELETE' ), + 'HEAD' => array( 'HEAD' ), + ); + } + + /** + * Test request with invalid HTTP methods. + * + * @dataProvider invalid_http_methods_provider + * @param string $method HTTP method to test. + */ + public function test_invalid_http_methods( string $method ): void { + // Register an ability with no permission requirements for this test + wp_register_ability( + 'test/method-test', + array( + 'label' => 'Method Test', + 'description' => 'Test ability for HTTP method validation', + 'execute_callback' => function () { + return array( 'success' => true ); + }, + 'permission_callback' => '__return_true', // No permission requirements + 'meta' => array( 'type' => 'tool' ), + ) + ); + + $request = new WP_REST_Request( $method, '/wp/v2/abilities/test/method-test/run' ); + $response = $this->server->dispatch( $request ); + + // Tool abilities should only accept POST, so these should return 405 + $this->assertEquals( 405, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_invalid_method', $data['code'] ); + } + + /** + * Test OPTIONS method handling. + */ + public function test_options_method_handling(): void { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/abilities/test/calculator/run' ); + $response = $this->server->dispatch( $request ); + // OPTIONS requests return 200 with allowed methods + $this->assertEquals( 200, $response->get_status() ); + } +} diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index bc9ea0a2dc424..e67ffebabe65c 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12391,6 +12391,110 @@ mockedApiResponse.Schema = { } } ] + }, + "/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ], + "endpoints": [ + { + "methods": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ], + "args": { + "name": { + "description": "Unique identifier for the ability.", + "type": "string", + "pattern": "^[a-zA-Z0-9\\-\\/]+$", + "required": false + }, + "input": { + "description": "Input parameters for the ability execution.", + "type": "object", + "default": [], + "required": false + } + } + } + ] + }, + "/wp/v2/abilities": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + }, + "page": { + "description": "Current page of the collection.", + "type": "integer", + "default": 1, + "minimum": 1, + "required": false + }, + "per_page": { + "description": "Maximum number of items to be returned in result set.", + "type": "integer", + "default": 50, + "minimum": 1, + "maximum": 100, + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/abilities" + } + ] + } + }, + "/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+)": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "name": { + "description": "Unique identifier for the ability.", + "type": "string", + "pattern": "^[a-zA-Z0-9\\-\\/]+$", + "required": false + } + } + } + ] } }, "site_logo": 0, From a0c16335ee25fb9dc1bd13fa5d79da8e78996760 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 26 Aug 2025 09:47:50 +0200 Subject: [PATCH 03/31] Bring the latest changes from Abilities API repo --- src/wp-includes/abilities-api.php | 28 ++- .../class-wp-abilities-registry.php | 81 +++++---- .../abilities-api/class-wp-ability.php | 141 ++++++++------- ...lass-wp-rest-abilities-list-controller.php | 10 +- ...class-wp-rest-abilities-run-controller.php | 116 ++----------- .../abilities-api/wpAbilitiesRegistry.php | 49 +----- .../tests/abilities-api/wpRegisterAbility.php | 163 +++++++++++++----- .../wpRestAbilitiesListController.php | 30 ++-- .../rest-api/wpRestAbilitiesRunController.php | 119 +++++++------ 9 files changed, 369 insertions(+), 368 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index ae538a2a6d512..fabb439e59182 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -20,15 +20,27 @@ * * @since 0.1.0 * - * @param string|\WP_Ability $name The name of the ability, or WP_Ability instance. - * The name must be a string containing a namespace prefix, i.e. `my-plugin/my-ability`. It can only - * contain lowercase alphanumeric characters, dashes and the forward slash. - * @param array $properties Optional. An associative array of properties for the ability. This should - * include `label`, `description`, `input_schema`, `output_schema`, - * `execute_callback`, `permission_callback`, and `meta`. + * @param string $name The name of the ability. The name must be a string containing a namespace + * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase + * alphanumeric characters, dashes and the forward slash. + * @param array $properties An associative array of properties for the ability. This should include + * `label`, `description`, `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, + * input_schema?: array, + * output_schema?: array, + * execute_callback?: callable( array $input): (mixed|\WP_Error), + * permission_callback?: callable( array $input ): (bool|\WP_Error), + * meta?: array, + * ability_class?: class-string<\WP_Ability>, + * ... + * } $properties */ -function wp_register_ability( $name, array $properties = array() ): ?WP_Ability { +function wp_register_ability( string $name, array $properties = array() ): ?WP_Ability { if ( ! did_action( 'abilities_api_init' ) ) { _doing_it_wrong( __FUNCTION__, @@ -36,7 +48,7 @@ function wp_register_ability( $name, array $properties = array() ): ?WP_Ability /* translators: 1: abilities_api_init, 2: string value of the ability name. */ esc_html__( 'Abilities must be registered on the %1$s action. The ability %2$s was not registered.' ), 'abilities_api_init', - '' . esc_html( $name instanceof WP_Ability ? $name->get_name() : $name ) . '' + '' . esc_html( $name ) . '' ), '0.1.0' ); diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index 0e72925a049d3..107e28fccbc5a 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -18,6 +18,14 @@ * @access private */ final class WP_Abilities_Registry { + /** + * The singleton instance of the registry. + * + * @since 0.1.0 + * @var ?self + */ + private static $instance = null; + /** * Holds the registered abilities. * @@ -35,21 +43,27 @@ final class WP_Abilities_Registry { * * @since 0.1.0 * - * @param string|\WP_Ability $name The name of the ability, or WP_Ability instance. The name must be a string - * containing a namespace prefix, i.e. `my-plugin/my-ability`. It can only - * contain lowercase alphanumeric characters, dashes and the forward slash. - * @param array $properties Optional. An associative array of properties for the ability. This should - * include `label`, `description`, `input_schema`, `output_schema`, - * `execute_callback`, `permission_callback`, and `meta`. + * @param string $name The name of the ability. The name must be a string containing a namespace + * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase + * alphanumeric characters, dashes and the forward slash. + * @param array $properties An associative array of properties for the ability. This should include + * `label`, `description`, `input_schema`, `output_schema`, + * `execute_callback`, `permission_callback`, `meta`, and ability_class. * @return ?\WP_Ability The registered ability instance on success, null on failure. + * + * @phpstan-param array{ + * label?: string, + * description?: string, + * input_schema?: array, + * output_schema?: array, + * execute_callback?: callable( array $input): (mixed|\WP_Error), + * permission_callback?: ?callable( array $input ): (bool|\WP_Error), + * meta?: array, + * ability_class?: class-string<\WP_Ability>, + * ... + * } $properties */ - public function register( $name, array $properties = array() ): ?WP_Ability { - $ability = null; - if ( $name instanceof WP_Ability ) { - $ability = $name; - $name = $ability->get_name(); - } - + public function register( string $name, array $properties = array() ): ?WP_Ability { if ( ! preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $name ) ) { _doing_it_wrong( __METHOD__, @@ -71,12 +85,6 @@ public function register( $name, array $properties = array() ): ?WP_Ability { return null; } - // If the ability is already an instance, we can skip the rest of the validation. - if ( null !== $ability ) { - $this->registered_abilities[ $name ] = $ability; - return $ability; - } - if ( empty( $properties['label'] ) || ! is_string( $properties['label'] ) ) { _doing_it_wrong( __METHOD__, @@ -140,17 +148,22 @@ public function register( $name, array $properties = array() ): ?WP_Ability { return null; } - $ability = new WP_Ability( + if ( isset( $properties['ability_class'] ) && ! is_a( $properties['ability_class'], WP_Ability::class, true ) ) { + _doing_it_wrong( + __METHOD__, + esc_html__( 'The ability properties should provide a valid `ability_class` that extends WP_Ability.' ), + '0.1.0' + ); + return null; + } + + // The class is only used to instantiate the ability, and is not a property of the ability itself. + $ability_class = $properties['ability_class'] ?? WP_Ability::class; + unset( $properties['ability_class'] ); + + $ability = new $ability_class( $name, - array( - 'label' => $properties['label'], - 'description' => $properties['description'], - 'input_schema' => $properties['input_schema'] ?? array(), - 'output_schema' => $properties['output_schema'] ?? array(), - 'execute_callback' => $properties['execute_callback'], - 'permission_callback' => $properties['permission_callback'] ?? null, - 'meta' => $properties['meta'] ?? array(), - ) + $properties ); $this->registered_abilities[ $name ] = $ability; @@ -248,11 +261,9 @@ public function get_registered( string $name ): ?WP_Ability { * @return \WP_Abilities_Registry The main registry instance. */ public static function get_instance(): self { - /** @var \WP_Abilities_Registry $wp_abilities */ - global $wp_abilities; + if ( null === self::$instance ) { + self::$instance = new self(); - if ( empty( $wp_abilities ) ) { - $wp_abilities = new self(); /** * Fires when preparing abilities registry. * @@ -263,10 +274,10 @@ public static function get_instance(): self { * * @param \WP_Abilities_Registry $instance Abilities registry object. */ - do_action( 'abilities_api_init', $wp_abilities ); + do_action( 'abilities_api_init', self::$instance ); } - return $wp_abilities; + return self::$instance; } /** diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index f5ee8fbb4b000..2193b06200cf2 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -15,7 +15,6 @@ * Encapsulates the properties and methods related to a specific ability in the registry. * * @since 0.1.0 - * @access private * * @see WP_Abilities_Registry */ @@ -66,7 +65,7 @@ class WP_Ability { * The ability execute callback. * * @since 0.1.0 - * @var callable + * @var callable( array $input): (mixed|\WP_Error) */ protected $execute_callback; @@ -74,7 +73,7 @@ class WP_Ability { * The optional ability permission callback. * * @since 0.1.0 - * @var ?callable + * @var ?callable( array $input ): (bool|\WP_Error) */ protected $permission_callback = null; @@ -91,6 +90,8 @@ class WP_Ability { * * Do not use this constructor directly. Instead, use the `wp_register_ability()` function. * + * @access private + * * @see wp_register_ability() * * @since 0.1.0 @@ -99,10 +100,37 @@ class WP_Ability { * @param array $properties An associative array of properties for the ability. This should * include `label`, `description`, `input_schema`, `output_schema`, * `execute_callback`, `permission_callback`, and `meta`. + * + * @phpstan-param array{ + * label: string, + * description: string, + * input_schema?: array, + * output_schema?: array, + * execute_callback: callable( array $input): (mixed|\WP_Error), + * permission_callback?: ?callable( array $input ): (bool|\WP_Error), + * meta?: array, + * ..., + * } $properties */ public function __construct( string $name, array $properties ) { $this->name = $name; + 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 ability "%2$s". Please check the %3$s class for allowed properties.' ), + '' . esc_html( $property_name ) . '', + '' . esc_html( $this->name ) . '', + '' . esc_html( self::class ) . '' + ), + '0.1.0' + ); + continue; + } + $this->$property_name = $property_value; } } @@ -180,29 +208,25 @@ public function get_meta(): array { * @since 0.1.0 * * @param array $input Optional. The input data to validate. - * @return bool Returns true if valid, false if validation fails. + * @return true|\WP_Error Returns true if valid or the WP_Error object if validation fails. */ - protected function validate_input( array $input = array() ): bool { + protected function validate_input( array $input = array() ) { $input_schema = $this->get_input_schema(); if ( empty( $input_schema ) ) { return true; } - $valid_input = rest_validate_value_from_schema( $input, $input_schema ); + $valid_input = rest_validate_value_from_schema( $input, $input_schema, 'input' ); if ( is_wp_error( $valid_input ) ) { - _doing_it_wrong( - __METHOD__, - esc_html( - sprintf( - /* translators: %1$s ability name, %2$s error message. */ - __( 'Invalid input provided for ability "%1$s": %2$s.' ), - $this->name, - $valid_input->get_error_message() - ) - ), - '0.1.0' + return new \WP_Error( + 'ability_invalid_input', + sprintf( + /* translators: %1$s ability name, %2$s error message. */ + __( 'Ability "%1$s" has invalid input. Reason: %2$s' ), + $this->name, + $valid_input->get_error_message() + ) ); - return false; } return true; @@ -216,11 +240,12 @@ protected function validate_input( array $input = array() ): bool { * @since 0.1.0 * * @param array $input Optional. The input data for permission checking. - * @return bool Whether the ability has the necessary permission. + * @return bool|\WP_Error Whether the ability has the necessary permission. */ - public function has_permission( array $input = array() ): bool { - if ( ! $this->validate_input( $input ) ) { - return false; + public function has_permission( array $input = array() ) { + $is_valid = $this->validate_input( $input ); + if ( is_wp_error( $is_valid ) ) { + return $is_valid; } if ( ! is_callable( $this->permission_callback ) ) { @@ -240,16 +265,13 @@ public function has_permission( array $input = array() ): bool { */ protected function do_execute( array $input ) { if ( ! is_callable( $this->execute_callback ) ) { - _doing_it_wrong( - __METHOD__, - esc_html( - /* translators: %s ability name. */ - sprintf( __( 'Ability "%s" does not have a valid execute callback.' ), $this->name ) - ), - '0.1.0' + return new \WP_Error( + 'ability_invalid_execute_callback', + /* translators: %s ability name. */ + sprintf( __( 'Ability "%s" does not have a valid execute callback.' ), $this->name ) ); - return null; } + return call_user_func( $this->execute_callback, $input ); } @@ -259,29 +281,25 @@ protected function do_execute( array $input ) { * @since 0.1.0 * * @param mixed $output The output data to validate. - * @return bool Returns true if valid, false if validation fails. + * @return true|\WP_Error Returns true if valid, or a WP_Error object if validation fails. */ - protected function validate_output( $output ): bool { + protected function validate_output( $output ) { $output_schema = $this->get_output_schema(); if ( empty( $output_schema ) ) { return true; } - $valid_output = rest_validate_value_from_schema( $output, $output_schema ); + $valid_output = rest_validate_value_from_schema( $output, $output_schema, 'output' ); if ( is_wp_error( $valid_output ) ) { - _doing_it_wrong( - __METHOD__, - esc_html( - sprintf( - /* translators: %1$s ability name, %2$s error message. */ - __( 'Invalid output provided for ability "%1$s": %2$s.' ), - $this->name, - $valid_output->get_error_message() - ) - ), - '0.1.0' + return new \WP_Error( + 'ability_invalid_output', + sprintf( + /* translators: %1$s ability name, %2$s error message. */ + __( 'Ability "%1$s" has invalid output. Reason: %2$s' ), + $this->name, + $valid_output->get_error_message() + ) ); - return false; } return true; @@ -297,16 +315,25 @@ protected function validate_output( $output ): bool { * @return mixed|\WP_Error The result of the ability execution, or WP_Error on failure. */ public function execute( array $input = array() ) { - if ( ! $this->has_permission( $input ) ) { - _doing_it_wrong( - __METHOD__, - esc_html( - /* translators: %s ability name. */ - sprintf( __( 'Ability "%s" does not have necessary permission.' ), $this->name ) - ), - '0.1.0' + $has_permissions = $this->has_permission( $input ); + if ( true !== $has_permissions ) { + if ( is_wp_error( $has_permissions ) ) { + if ( 'ability_invalid_input' === $has_permissions->get_error_code() ) { + return $has_permissions; + } + // Don't leak the permission check error to someone without the correct perms. + _doing_it_wrong( + __METHOD__, + esc_html( $has_permissions->get_error_message() ), + '0.1.0' + ); + } + + return new \WP_Error( + 'ability_invalid_permissions', + /* translators: %s ability name. */ + sprintf( __( 'Ability "%s" does not have necessary permission.' ), $this->name ) ); - return null; } $result = $this->do_execute( $input ); @@ -314,11 +341,9 @@ public function execute( array $input = array() ) { return $result; } - if ( ! $this->validate_output( $result ) ) { - return null; - } + $is_valid = $this->validate_output( $result ); - return $result; + return is_wp_error( $is_valid ) ? $is_valid : $result; } /** diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php index 78009a7f285a5..652a1f8fda519 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php @@ -89,7 +89,7 @@ public function register_routes(): void { * * @since 0.1.0 * - * @param \WP_REST_Request $request Full details about the request. + * @param \WP_REST_Request> $request Full details about the request. * @return \WP_REST_Response Response object on success. */ public function get_items( $request ) { @@ -144,7 +144,7 @@ public function get_items( $request ) { * * @since 0.1.0 * - * @param \WP_REST_Request $request Full details about the request. + * @param \WP_REST_Request> $request Full details about the request. * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. */ public function get_item( $request ) { @@ -167,7 +167,7 @@ public function get_item( $request ) { * * @since 0.1.0 * - * @param \WP_REST_Request $request Full details about the request. + * @param \WP_REST_Request> $request Full details about the request. * @return bool True if the request has read access. */ public function get_permissions_check( $request ) { @@ -179,8 +179,8 @@ public function get_permissions_check( $request ) { * * @since 0.1.0 * - * @param \WP_Ability $ability The ability object. - * @param \WP_REST_Request $request Request object. + * @param \WP_Ability $ability The ability object. + * @param \WP_REST_Request> $request Request object. * @return \WP_REST_Response Response object. */ public function prepare_item_for_response( $ability, $request ) { diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php index 762ce14e8da6c..147ad7dbacb5b 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php @@ -75,7 +75,7 @@ public function register_routes(): void { * * @since 0.1.0 * - * @param \WP_REST_Request $request Full details about the request. + * @param \WP_REST_Request> $request Full details about the request. * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. */ public function run_ability_with_method_check( $request ) { @@ -96,7 +96,7 @@ public function run_ability_with_method_check( $request ) { if ( 'resource' === $type && 'GET' !== $method ) { return new \WP_Error( - 'rest_invalid_method', + 'rest_ability_invalid_method', __( 'Resource abilities require GET method.' ), array( 'status' => 405 ) ); @@ -104,7 +104,7 @@ public function run_ability_with_method_check( $request ) { if ( 'tool' === $type && 'POST' !== $method ) { return new \WP_Error( - 'rest_invalid_method', + 'rest_ability_invalid_method', __( 'Tool abilities require POST method.' ), array( 'status' => 405 ) ); @@ -118,12 +118,11 @@ public function run_ability_with_method_check( $request ) { * * @since 0.1.0 * - * @param \WP_REST_Request $request Full details about the request. + * @param \WP_REST_Request> $request Full details about the request. * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. */ public function run_ability( $request ) { $ability = wp_get_ability( $request->get_param( 'name' ) ); - if ( ! $ability ) { return new \WP_Error( 'rest_ability_not_found', @@ -132,38 +131,13 @@ public function run_ability( $request ) { ); } - $input = $this->get_input_from_request( $request ); - - // REST API needs detailed error messages with HTTP status codes. - // While WP_Ability::execute() validates internally, it only returns false - // and logs with _doing_it_wrong, which doesn't provide capturable error messages. - // TODO: Consider updating WP_Ability to return WP_Error for better error handling. - $input_validation = $this->validate_input( $ability, $input ); - if ( is_wp_error( $input_validation ) ) { - return $input_validation; - } - + $input = $this->get_input_from_request( $request ); $result = $ability->execute( $input ); - if ( is_wp_error( $result ) ) { - return new \WP_Error( - 'rest_ability_execution_failed', - $result->get_error_message(), - array( 'status' => 500 ) - ); - } - - if ( is_null( $result ) ) { - return new \WP_Error( - 'rest_ability_execution_failed', - __( 'Ability execution failed. Please check permissions and input parameters.' ), - array( 'status' => 500 ) - ); - } - - $output_validation = $this->validate_output( $ability, $result ); - if ( is_wp_error( $output_validation ) ) { - return $output_validation; + if ( 'ability_invalid_input' === $result->get_error_code() ) { + $result->add_data( array( 'status' => 400 ) ); + } + return $result; } return rest_ensure_response( $result ); @@ -174,12 +148,11 @@ public function run_ability( $request ) { * * @since 0.1.0 * - * @param \WP_REST_Request $request Full details about the request. + * @param \WP_REST_Request> $request Full details about the request. * @return true|\WP_Error True if the request has execution permission, WP_Error object otherwise. */ public function run_ability_permissions_check( $request ) { $ability = wp_get_ability( $request->get_param( 'name' ) ); - if ( ! $ability ) { return new \WP_Error( 'rest_ability_not_found', @@ -189,10 +162,9 @@ public function run_ability_permissions_check( $request ) { } $input = $this->get_input_from_request( $request ); - if ( ! $ability->has_permission( $input ) ) { return new \WP_Error( - 'rest_cannot_execute', + 'rest_ability_cannot_execute', __( 'Sorry, you are not allowed to execute this ability.' ), array( 'status' => rest_authorization_required_code() ) ); @@ -201,76 +173,12 @@ public function run_ability_permissions_check( $request ) { return true; } - /** - * Validates input data against the ability's input schema. - * - * @since 0.1.0 - * - * @param \WP_Ability $ability The ability object. - * @param array $input The input data to validate. - * @return true|\WP_Error True if validation passes, WP_Error object on failure. - */ - private function validate_input( $ability, $input ) { - $input_schema = $ability->get_input_schema(); - - if ( empty( $input_schema ) ) { - return true; - } - - $validation_result = rest_validate_value_from_schema( $input, $input_schema ); - if ( is_wp_error( $validation_result ) ) { - return new \WP_Error( - 'rest_invalid_param', - sprintf( - /* translators: %s: error message */ - __( 'Invalid input parameters: %s' ), - $validation_result->get_error_message() - ), - array( 'status' => 400 ) - ); - } - - return true; - } - - /** - * Validates output data against the ability's output schema. - * - * @since 0.1.0 - * - * @param \WP_Ability $ability The ability object. - * @param mixed $output The output data to validate. - * @return true|\WP_Error True if validation passes, WP_Error object on failure. - */ - private function validate_output( $ability, $output ) { - $output_schema = $ability->get_output_schema(); - - if ( empty( $output_schema ) ) { - return true; - } - - $validation_result = rest_validate_value_from_schema( $output, $output_schema ); - if ( is_wp_error( $validation_result ) ) { - return new \WP_Error( - 'rest_invalid_response', - sprintf( - /* translators: %s: error message */ - __( 'Invalid response from ability: %s' ), - $validation_result->get_error_message() - ), - array( 'status' => 500 ) - ); - } - - return true; - } - /** * Extracts input parameters from the request. * * @since 0.1.0 * - * @param \WP_REST_Request $request The request object. + * @param \WP_REST_Request> $request The request object. * @return array The input parameters. */ private function get_input_from_request( $request ) { diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php index 523f5398de58e..dff53e9760c94 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -1,6 +1,8 @@ 'The result of adding the two numbers.', 'required' => true, ), - 'execute_callback' => function ( array $input ): int { + 'execute_callback' => static function ( array $input ): int { return $input['a'] + $input['b']; }, - 'permission_callback' => function (): bool { + 'permission_callback' => static function (): bool { return true; }, 'meta' => array( @@ -106,19 +108,6 @@ public function test_register_invalid_uppercase_characters_in_name() { $this->assertNull( $result ); } - /** - * Should reject ability instance with invalid name. - * - * @covers WP_Abilities_Registry::register - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_invalid_name_using_instance() { - $ability = new WP_Ability( 'invalid_name', array() ); - $result = $this->registry->register( $ability ); - $this->assertNull( $result ); - } - /** * Should reject ability registration without a label. * @@ -277,21 +266,6 @@ public function test_register_incorrect_already_registered_ability() { $this->assertNull( $result ); } - /** - * Should reject registration for already registered ability when passing an ability instance. - * - * @covers WP_Abilities_Registry::register - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_incorrect_already_registered_ability_using_instance() { - $ability = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); - - $result = $this->registry->register( $ability ); - - $this->assertNull( $result ); - } - /** * Should successfully register a new ability. * @@ -306,19 +280,6 @@ public function test_register_new_ability() { ); } - /** - * Should successfully register a new ability using an instance. - * - * @covers WP_Abilities_Registry::register - * @covers WP_Ability::construct - */ - public function test_register_new_ability_using_instance() { - $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); - $result = $this->registry->register( $ability ); - - $this->assertSame( $ability, $result ); - } - /** * Should return false for ability that's not registered. * diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index e3709a1243f99..556e5cd781114 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -1,6 +1,17 @@ 'The result of adding the two numbers.', 'required' => true, ), - 'execute_callback' => function ( array $input ): int { + 'execute_callback' => static function ( array $input ): int { return $input['a'] + $input['b']; }, - 'permission_callback' => function (): bool { + 'permission_callback' => static function (): bool { return true; }, 'meta' => array( @@ -60,9 +71,11 @@ public function set_up(): void { */ public function tear_down(): void { foreach ( wp_get_abilities() as $ability ) { - if ( str_starts_with( $ability->get_name(), 'test/' ) ) { - wp_unregister_ability( $ability->get_name() ); + if ( ! str_starts_with( $ability->get_name(), 'test/' ) ) { + continue; } + + wp_unregister_ability( $ability->get_name() ); } parent::tear_down(); @@ -141,13 +154,11 @@ public function test_register_valid_ability(): void { /** * Tests executing an ability with no permissions. - * - * @expectedIncorrectUsage WP_Ability::execute */ public function test_register_ability_no_permissions(): void { do_action( 'abilities_api_init' ); - self::$test_ability_properties['permission_callback'] = function (): bool { + self::$test_ability_properties['permission_callback'] = static function (): bool { return false; }; $result = wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); @@ -160,7 +171,39 @@ public function test_register_ability_no_permissions(): void { ) ) ); - $this->assertNull( + + $actual = $result->execute( + array( + 'a' => 2, + 'b' => 3, + ) + ); + $this->assertWPError( + $actual, + 'Execution should fail due to no permissions' + ); + $this->assertEquals( 'ability_invalid_permissions', $actual->get_error_code() ); + } + + /** + * Tests registering an ability with a custom ability class. + */ + public function test_register_ability_custom_ability_class(): void { + do_action( 'abilities_api_init' ); + + $result = wp_register_ability( + self::$test_ability_name, + array_merge( + self::$test_ability_properties, + array( + 'ability_class' => Mock_Custom_Ability::class, + ) + ) + ); + + $this->assertInstanceOf( Mock_Custom_Ability::class, $result ); + $this->assertSame( + 9999, $result->execute( array( 'a' => 2, @@ -168,72 +211,101 @@ public function test_register_ability_no_permissions(): void { ) ) ); + + // Try again with an invalid class throws a doing it wrong. + $this->setExpectedIncorrectUsage( WP_Abilities_Registry::class . '::register' ); + wp_register_ability( + self::$test_ability_name, + array_merge( + self::$test_ability_properties, + array( + 'ability_class' => 'Non_Existent_Class', + ) + ) + ); } + /** * Tests executing an ability with input not matching schema. - * - * @expectedIncorrectUsage WP_Ability::validate_input - * @expectedIncorrectUsage WP_Ability::execute */ public function test_execute_ability_no_input_schema_match(): void { do_action( 'abilities_api_init' ); $result = wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); - $this->assertNull( - $result->execute( - array( - 'a' => 2, - 'b' => 3, - 'unknown' => 1, - ) + $actual = $result->execute( + array( + 'a' => 2, + 'b' => 3, + 'unknown' => 1, ) ); + + $this->assertWPError( + $actual, + 'Execution should fail due to input not matching schema.' + ); + $this->assertSame( 'ability_invalid_input', $actual->get_error_code() ); + $this->assertSame( + 'Ability "test/add-numbers" has invalid input. Reason: unknown is not a valid property of Object.', + $actual->get_error_message() + ); } /** * Tests executing an ability with output not matching schema. - * - * @expectedIncorrectUsage WP_Ability::validate_output */ public function test_execute_ability_no_output_schema_match(): void { do_action( 'abilities_api_init' ); - self::$test_ability_properties['execute_callback'] = function (): bool { + self::$test_ability_properties['execute_callback'] = static function (): bool { return true; }; $result = wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); - $this->assertNull( - $result->execute( - array( - 'a' => 2, - 'b' => 3, - ) + $actual = $result->execute( + array( + 'a' => 2, + 'b' => 3, ) ); + $this->assertWPError( + $actual, + 'Execution should fail due to output not matching schema.' + ); + $this->assertSame( 'ability_invalid_output', $actual->get_error_code() ); + $this->assertSame( + 'Ability "test/add-numbers" has invalid output. Reason: output is not of type number.', + $actual->get_error_message() + ); } /** * Tests permission callback receiving input not matching schema. - * - * @expectedIncorrectUsage WP_Ability::validate_input */ public function test_permission_callback_no_input_schema_match(): void { do_action( 'abilities_api_init' ); $result = wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); - $this->assertFalse( - $result->has_permission( - array( - 'a' => 2, - 'b' => 3, - 'unknown' => 1, - ) + $actual = $result->has_permission( + array( + 'a' => 2, + 'b' => 3, + 'unknown' => 1, ) ); + + $this->assertWPError( + $actual, + 'Permission check should fail due to input not matching schema.' + ); + $this->assertSame( 'ability_invalid_input', $actual->get_error_code() ); + $this->assertSame( + 'Ability "test/add-numbers" has invalid input. Reason: unknown is not a valid property of Object.', + $actual->get_error_message() + ); } /** @@ -243,7 +315,7 @@ public function test_permission_callback_receives_input(): void { do_action( 'abilities_api_init' ); $received_input = null; - self::$test_ability_properties['permission_callback'] = function ( array $input ) use ( &$received_input ): bool { + self::$test_ability_properties['permission_callback'] = static function ( array $input ) use ( &$received_input ): bool { $received_input = $input; // Allow only if 'a' is greater than 'b' return $input['a'] > $input['b']; @@ -306,29 +378,28 @@ public function test_unregister_existing_ability() { * Tests retrieving existing ability. */ public function test_get_existing_ability() { - global $wp_abilities; - $name = self::$test_ability_name; $properties = self::$test_ability_properties; - $callback = function ( $instance ) use ( $name, $properties ) { + $callback = static function ( $instance ) use ( $name, $properties ) { wp_register_ability( $name, $properties ); }; add_action( 'abilities_api_init', $callback ); - // Temporarily set `$wp_abilities` to null to ensure `wp_get_ability()` triggers `abilities_api_init` action. - $old_wp_abilities = $wp_abilities; - $wp_abilities = null; + // Reset the Registry, to ensure it's empty before the test. + $registry_reflection = new ReflectionClass( WP_Abilities_Registry::class ); + $instance_prop = $registry_reflection->getProperty( 'instance' ); + $instance_prop->setAccessible( true ); + $instance_prop->setValue( null ); $result = wp_get_ability( $name ); - $wp_abilities = $old_wp_abilities; - remove_action( 'abilities_api_init', $callback ); $this->assertEquals( new WP_Ability( $name, $properties ), - $result + $result, + 'Ability does not share expected properties.' ); } diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php index 76c2071dcfd06..1fac1ac1f3496 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php @@ -10,7 +10,7 @@ class Tests_REST_API_WpRestAbilitiesListController extends WP_UnitTestCase { /** * REST Server instance. * - * @var WP_REST_Server + * @var \WP_REST_Server */ protected $server; @@ -64,9 +64,11 @@ public function set_up(): void { public function tear_down(): void { // Clean up test abilities foreach ( wp_get_abilities() as $ability ) { - if ( str_starts_with( $ability->get_name(), 'test/' ) ) { - wp_unregister_ability( $ability->get_name() ); + if ( ! str_starts_with( $ability->get_name(), 'test/' ) ) { + continue; } + + wp_unregister_ability( $ability->get_name() ); } // Reset REST server @@ -100,7 +102,7 @@ private function register_test_abilities(): void { 'output_schema' => array( 'type' => 'number', ), - 'execute_callback' => function ( array $input ) { + 'execute_callback' => static function ( array $input ) { switch ( $input['operation'] ) { case 'add': return $input['a'] + $input['b']; @@ -114,7 +116,7 @@ private function register_test_abilities(): void { return null; } }, - 'permission_callback' => function () { + 'permission_callback' => static function () { return current_user_can( 'read' ); }, 'meta' => array( @@ -147,7 +149,7 @@ private function register_test_abilities(): void { 'wp_version' => array( 'type' => 'string' ), ), ), - 'execute_callback' => function ( array $input ) { + 'execute_callback' => static function ( array $input ) { $info = array( 'php_version' => phpversion(), 'wp_version' => get_bloginfo( 'version' ), @@ -157,7 +159,7 @@ private function register_test_abilities(): void { } return $info; }, - 'permission_callback' => function () { + 'permission_callback' => static function () { return current_user_can( 'read' ); }, 'meta' => array( @@ -174,7 +176,7 @@ private function register_test_abilities(): void { array( 'label' => "Test Ability {$i}", 'description' => "Test ability number {$i}", - 'execute_callback' => function () use ( $i ) { + 'execute_callback' => static function () use ( $i ) { return "Result from ability {$i}"; }, 'permission_callback' => '__return_true', @@ -409,7 +411,7 @@ public function test_ability_name_with_valid_special_characters(): void { array( 'label' => 'Test Hyphen Ability', 'description' => 'Test ability with hyphen', - 'execute_callback' => function ( $input ) { + 'execute_callback' => static function ( $input ) { return array( 'success' => true ); }, 'permission_callback' => '__return_true', @@ -501,10 +503,12 @@ public function test_invalid_pagination_parameters( array $params ): void { // Should either use defaults or return error $this->assertContains( $response->get_status(), array( 200, 400 ) ); - if ( $response->get_status() === 200 ) { - // Check that reasonable defaults were used - $data = $response->get_data(); - $this->assertIsArray( $data ); + if ( $response->get_status() !== 200 ) { + return; } + + // Check that reasonable defaults were used + $data = $response->get_data(); + $this->assertIsArray( $data ); } } diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php index 7bb47f0491276..71f972074d376 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php @@ -10,7 +10,7 @@ class Tests_REST_API_WpRestAbilitiesRunController extends WP_UnitTestCase { /** * REST Server instance. * - * @var WP_REST_Server + * @var \WP_REST_Server */ protected $server; @@ -72,9 +72,11 @@ public function set_up(): void { */ public function tear_down(): void { foreach ( wp_get_abilities() as $ability ) { - if ( str_starts_with( $ability->get_name(), 'test/' ) ) { - wp_unregister_ability( $ability->get_name() ); + if ( ! str_starts_with( $ability->get_name(), 'test/' ) ) { + continue; } + + wp_unregister_ability( $ability->get_name() ); } global $wp_rest_server; @@ -111,10 +113,10 @@ private function register_test_abilities(): void { 'output_schema' => array( 'type' => 'number', ), - 'execute_callback' => function ( array $input ) { + 'execute_callback' => static function ( array $input ) { return $input['a'] + $input['b']; }, - 'permission_callback' => function ( array $input ) { + 'permission_callback' => static function ( array $input ) { return current_user_can( 'edit_posts' ); }, 'meta' => array( @@ -145,18 +147,18 @@ private function register_test_abilities(): void { 'login' => array( 'type' => 'string' ), ), ), - 'execute_callback' => function ( array $input ) { + 'execute_callback' => static function ( array $input ) { $user_id = $input['user_id'] ?? get_current_user_id(); $user = get_user_by( 'id', $user_id ); if ( ! $user ) { - return new WP_Error( 'user_not_found', 'User not found' ); + return new \WP_Error( 'user_not_found', 'User not found' ); } return array( 'id' => $user->ID, 'login' => $user->user_login, ); }, - 'permission_callback' => function ( array $input ) { + 'permission_callback' => static function ( array $input ) { return is_user_logged_in(); }, 'meta' => array( @@ -182,10 +184,10 @@ private function register_test_abilities(): void { 'output_schema' => array( 'type' => 'string', ), - 'execute_callback' => function ( array $input ) { + 'execute_callback' => static function ( array $input ) { return 'Success: ' . $input['data']; }, - 'permission_callback' => function ( array $input ) { + 'permission_callback' => static function ( array $input ) { // Only allow if secret matches return isset( $input['secret'] ) && 'valid_secret' === $input['secret']; }, @@ -201,7 +203,7 @@ private function register_test_abilities(): void { array( 'label' => 'Null Return', 'description' => 'Returns null', - 'execute_callback' => function () { + 'execute_callback' => static function () { return null; }, 'permission_callback' => '__return_true', @@ -217,8 +219,8 @@ private function register_test_abilities(): void { array( 'label' => 'Error Return', 'description' => 'Returns error', - 'execute_callback' => function () { - return new WP_Error( 'test_error', 'This is a test error' ); + 'execute_callback' => static function () { + return new \WP_Error( 'test_error', 'This is a test error' ); }, 'permission_callback' => '__return_true', 'meta' => array( @@ -236,7 +238,7 @@ private function register_test_abilities(): void { 'output_schema' => array( 'type' => 'number', ), - 'execute_callback' => function () { + 'execute_callback' => static function () { return 'not a number'; // Invalid - schema expects number }, 'permission_callback' => '__return_true', @@ -259,7 +261,7 @@ private function register_test_abilities(): void { 'param2' => array( 'type' => 'integer' ), ), ), - 'execute_callback' => function ( array $input ) { + 'execute_callback' => static function ( array $input ) { return $input; }, 'permission_callback' => '__return_true', @@ -322,7 +324,7 @@ public function test_tool_ability_requires_post(): void { array( 'label' => 'Open Tool', 'description' => 'Tool with no permission requirements', - 'execute_callback' => function () { + 'execute_callback' => static function () { return 'success'; }, 'permission_callback' => '__return_true', @@ -335,10 +337,10 @@ public function test_tool_ability_requires_post(): void { $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/open-tool/run' ); $response = $this->server->dispatch( $request ); - $this->assertEquals( 405, $response->get_status() ); + $this->assertSame( 405, $response->get_status() ); $data = $response->get_data(); - $this->assertEquals( 'rest_invalid_method', $data['code'] ); - $this->assertStringContainsString( 'Tool abilities require POST', $data['message'] ); + $this->assertSame( 'rest_ability_invalid_method', $data['code'] ); + $this->assertSame( 'Tool abilities require POST method.', $data['message'] ); } /** @@ -352,10 +354,10 @@ public function test_resource_ability_requires_get(): void { $response = $this->server->dispatch( $request ); - $this->assertEquals( 405, $response->get_status() ); + $this->assertSame( 405, $response->get_status() ); $data = $response->get_data(); - $this->assertEquals( 'rest_invalid_method', $data['code'] ); - $this->assertStringContainsString( 'Resource abilities require GET', $data['message'] ); + $this->assertSame( 'rest_ability_invalid_method', $data['code'] ); + $this->assertSame( 'Resource abilities require GET method.', $data['message'] ); } @@ -363,8 +365,6 @@ public function test_resource_ability_requires_get(): void { * Test output validation against schema. * Note: When output validation fails in WP_Ability::execute(), it returns null, * which causes the REST controller to return 'rest_ability_execution_failed'. - * - * @expectedIncorrectUsage WP_Ability::validate_output */ public function test_output_validation(): void { $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/invalid-output/run' ); @@ -373,11 +373,13 @@ public function test_output_validation(): void { $response = $this->server->dispatch( $request ); - $this->assertEquals( 500, $response->get_status() ); + $this->assertSame( 500, $response->get_status() ); $data = $response->get_data(); - - $this->assertEquals( 'rest_ability_execution_failed', $data['code'] ); - $this->assertStringContainsString( 'Ability execution failed', $data['message'] ); + $this->assertSame( 'ability_invalid_output', $data['code'] ); + $this->assertSame( + 'Ability "test/invalid-output" has invalid output. Reason: output is not of type number.', + $data['message'] + ); } /** @@ -401,9 +403,10 @@ public function test_execution_permission_denied(): void { $response = $this->server->dispatch( $request ); - $this->assertEquals( 403, $response->get_status() ); + $this->assertSame( 403, $response->get_status() ); $data = $response->get_data(); - $this->assertEquals( 'rest_cannot_execute', $data['code'] ); + $this->assertSame( 'rest_ability_cannot_execute', $data['code'] ); + $this->assertSame( 'Sorry, you are not allowed to execute this ability.', $data['message'] ); } /** @@ -443,7 +446,7 @@ public function test_contextual_permission_check(): void { } /** - * Test handling of null return from ability. + * Test handling of null is a valid return value. */ public function test_null_return_handling(): void { $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/null-return/run' ); @@ -452,10 +455,9 @@ public function test_null_return_handling(): void { $response = $this->server->dispatch( $request ); - $this->assertEquals( 500, $response->get_status() ); + $this->assertEquals( 200, $response->get_status() ); $data = $response->get_data(); - $this->assertEquals( 'rest_ability_execution_failed', $data['code'] ); - $this->assertStringContainsString( 'Ability execution failed', $data['message'] ); + $this->assertNull( $data ); } /** @@ -470,7 +472,7 @@ public function test_wp_error_return_handling(): void { $this->assertEquals( 500, $response->get_status() ); $data = $response->get_data(); - $this->assertEquals( 'rest_ability_execution_failed', $data['code'] ); + $this->assertEquals( 'test_error', $data['code'] ); $this->assertEquals( 'This is a test error', $data['message'] ); } @@ -586,7 +588,6 @@ public function test_post_request_with_non_array_input(): void { /** * Test ability with invalid output that fails validation. - * @expectedIncorrectUsage WP_Ability::validate_output */ public function test_output_validation_failure_returns_error(): void { // Register ability with strict output schema. @@ -605,7 +606,7 @@ public function test_output_validation_failure_returns_error(): void { ), 'required' => array( 'status' ), ), - 'execute_callback' => function ( $input ) { + 'execute_callback' => static function ( $input ) { // Return invalid output that doesn't match schema return array( 'wrong_field' => 'value' ); }, @@ -620,15 +621,18 @@ public function test_output_validation_failure_returns_error(): void { $response = $this->server->dispatch( $request ); - // Should return error when output validation fails - $this->assertEquals( 500, $response->get_status() ); + // Should return error when output validation fails. + $this->assertSame( 500, $response->get_status() ); $data = $response->get_data(); - $this->assertEquals( 'rest_ability_execution_failed', $data['code'] ); + $this->assertSame( 'ability_invalid_output', $data['code'] ); + $this->assertSame( + 'Ability "test/strict-output" has invalid output. Reason: status is a required property of output.', + $data['message'] + ); } /** * Test ability with invalid input that fails validation. - * @expectedIncorrectUsage WP_Ability::validate_input */ public function test_input_validation_failure_returns_error(): void { // Register ability with strict input schema. @@ -646,7 +650,7 @@ public function test_input_validation_failure_returns_error(): void { ), 'required' => array( 'required_field' ), ), - 'execute_callback' => function ( $input ) { + 'execute_callback' => static function ( $input ) { return array( 'status' => 'success' ); }, 'permission_callback' => '__return_true', @@ -661,10 +665,14 @@ public function test_input_validation_failure_returns_error(): void { $response = $this->server->dispatch( $request ); - // Should return error when input validation fails (403 due to permission check) - $this->assertEquals( 403, $response->get_status() ); + // Should return error when input validation fails. + $this->assertSame( 400, $response->get_status() ); $data = $response->get_data(); - $this->assertEquals( 'rest_cannot_execute', $data['code'] ); + $this->assertSame( 'ability_invalid_input', $data['code'] ); + $this->assertSame( + 'Ability "test/strict-input" has invalid input. Reason: required_field is a required property of input.', + $data['message'] + ); } /** @@ -677,7 +685,7 @@ public function test_ability_without_type_defaults_to_tool(): void { array( 'label' => 'No Type', 'description' => 'Ability without type', - 'execute_callback' => function ( $input ) { + 'execute_callback' => static function ( $input ) { return array( 'executed' => true ); }, 'permission_callback' => '__return_true', @@ -709,7 +717,7 @@ public function test_permission_check_passes_when_callback_not_set(): void { array( 'label' => 'No Permission Callback', 'description' => 'Ability without permission callback', - 'execute_callback' => function ( $input ) { + 'execute_callback' => static function ( $input ) { return array( 'executed' => true ); }, 'meta' => array( 'type' => 'tool' ), @@ -742,7 +750,7 @@ public function test_empty_input_handling(): void { array( 'label' => 'Resource Empty', 'description' => 'Resource with empty input', - 'execute_callback' => function ( $input ) { + 'execute_callback' => static function ( $input ) { return array( 'input_was_empty' => empty( $input ) ); }, 'permission_callback' => '__return_true', @@ -755,7 +763,7 @@ public function test_empty_input_handling(): void { array( 'label' => 'Tool Empty', 'description' => 'Tool with empty input', - 'execute_callback' => function ( $input ) { + 'execute_callback' => static function ( $input ) { return array( 'input_was_empty' => empty( $input ) ); }, 'permission_callback' => '__return_true', @@ -825,7 +833,7 @@ public function test_php_type_strings_in_input(): void { array( 'label' => 'Echo', 'description' => 'Echoes input', - 'execute_callback' => function ( $input ) { + 'execute_callback' => static function ( $input ) { return array( 'echo' => $input ); }, 'permission_callback' => '__return_true', @@ -866,7 +874,7 @@ public function test_mixed_encoding_in_input(): void { array( 'label' => 'Echo Encoding', 'description' => 'Echoes input with encoding', - 'execute_callback' => function ( $input ) { + 'execute_callback' => static function ( $input ) { return array( 'echo' => $input ); }, 'permission_callback' => '__return_true', @@ -926,7 +934,7 @@ public function test_invalid_http_methods( string $method ): void { array( 'label' => 'Method Test', 'description' => 'Test ability for HTTP method validation', - 'execute_callback' => function () { + 'execute_callback' => static function () { return array( 'success' => true ); }, 'permission_callback' => '__return_true', // No permission requirements @@ -937,10 +945,11 @@ public function test_invalid_http_methods( string $method ): void { $request = new WP_REST_Request( $method, '/wp/v2/abilities/test/method-test/run' ); $response = $this->server->dispatch( $request ); - // Tool abilities should only accept POST, so these should return 405 - $this->assertEquals( 405, $response->get_status() ); + // Tool abilities should only accept POST, so these should return 405. + $this->assertSame( 405, $response->get_status() ); $data = $response->get_data(); - $this->assertEquals( 'rest_invalid_method', $data['code'] ); + $this->assertSame( 'rest_ability_invalid_method', $data['code'] ); + $this->assertSame( 'Tool abilities require POST method.', $data['message'] ); } /** From 85f3a49d5dc357a922eb8d344a49fef80b3bf8b4 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Mon, 8 Sep 2025 13:46:13 +0200 Subject: [PATCH 04/31] Bring another set of upstream changes planned for v0.2.0 release --- .../class-wp-abilities-registry.php | 109 +++++------------- .../abilities-api/class-wp-ability.php | 96 ++++++++++++--- ...lass-wp-rest-abilities-list-controller.php | 5 +- ...class-wp-rest-abilities-run-controller.php | 5 +- .../abilities-api/wpAbilitiesRegistry.php | 106 ++++++++++------- .../tests/abilities-api/wpRegisterAbility.php | 83 ++++++------- .../wpRestAbilitiesListController.php | 2 + .../rest-api/wpRestAbilitiesRunController.php | 2 + 8 files changed, 219 insertions(+), 189 deletions(-) diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index 107e28fccbc5a..9094398cc7222 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -39,16 +39,16 @@ final class WP_Abilities_Registry { * * Do not use this method directly. Instead, use the `wp_register_ability()` function. * - * @see wp_register_ability() - * * @since 0.1.0 * - * @param string $name The name of the ability. The name must be a string containing a namespace - * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase - * alphanumeric characters, dashes and the forward slash. - * @param array $properties An associative array of properties for the ability. This should include - * `label`, `description`, `input_schema`, `output_schema`, - * `execute_callback`, `permission_callback`, `meta`, and ability_class. + * @see wp_register_ability() + * + * @param string $name The name of the ability. The name must be a string containing a namespace + * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase + * alphanumeric characters, dashes and the forward slash. + * @param array $args An associative array of arguments for the ability. This should include + * `label`, `description`, `input_schema`, `output_schema`, + * `execute_callback`, `permission_callback`, `meta`, and ability_class. * @return ?\WP_Ability The registered ability instance on success, null on failure. * * @phpstan-param array{ @@ -61,9 +61,9 @@ final class WP_Abilities_Registry { * meta?: array, * ability_class?: class-string<\WP_Ability>, * ... - * } $properties + * } $args */ - public function register( string $name, array $properties = array() ): ?WP_Ability { + public function register( string $name, array $args ): ?WP_Ability { if ( ! preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $name ) ) { _doing_it_wrong( __METHOD__, @@ -85,87 +85,30 @@ public function register( string $name, array $properties = array() ): ?WP_Abili return null; } - if ( empty( $properties['label'] ) || ! is_string( $properties['label'] ) ) { - _doing_it_wrong( - __METHOD__, - esc_html__( 'The ability properties must contain a `label` string.' ), - '0.1.0' - ); - return null; - } - - if ( empty( $properties['description'] ) || ! is_string( $properties['description'] ) ) { - _doing_it_wrong( - __METHOD__, - esc_html__( 'The ability properties must contain a `description` string.' ), - '0.1.0' - ); - return null; - } - - if ( isset( $properties['input_schema'] ) && ! is_array( $properties['input_schema'] ) ) { - _doing_it_wrong( - __METHOD__, - esc_html__( 'The ability properties should provide a valid `input_schema` definition.' ), - '0.1.0' - ); - return null; - } - - if ( isset( $properties['output_schema'] ) && ! is_array( $properties['output_schema'] ) ) { - _doing_it_wrong( - __METHOD__, - esc_html__( 'The ability properties should provide a valid `output_schema` definition.' ), - '0.1.0' - ); - return null; - } - - if ( empty( $properties['execute_callback'] ) || ! is_callable( $properties['execute_callback'] ) ) { - _doing_it_wrong( - __METHOD__, - esc_html__( 'The ability properties must contain a valid `execute_callback` function.' ), - '0.1.0' - ); - return null; - } - - if ( isset( $properties['permission_callback'] ) && ! is_callable( $properties['permission_callback'] ) ) { - _doing_it_wrong( - __METHOD__, - esc_html__( 'The ability properties should provide a valid `permission_callback` function.' ), - '0.1.0' - ); - return null; - } - - if ( isset( $properties['meta'] ) && ! is_array( $properties['meta'] ) ) { + // 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( __METHOD__, - esc_html__( 'The ability properties should provide a valid `meta` array.' ), + esc_html__( 'The ability args should provide a valid `ability_class` that extends WP_Ability.' ), '0.1.0' ); return null; } + $ability_class = $args['ability_class'] ?? WP_Ability::class; + unset( $args['ability_class'] ); - if ( isset( $properties['ability_class'] ) && ! is_a( $properties['ability_class'], WP_Ability::class, true ) ) { + try { + // WP_Ability::prepare_properties() will throw an exception if the properties are invalid. + $ability = new $ability_class( $name, $args ); + } catch ( \InvalidArgumentException $e ) { _doing_it_wrong( __METHOD__, - esc_html__( 'The ability properties should provide a valid `ability_class` that extends WP_Ability.' ), + esc_html( $e->getMessage() ), '0.1.0' ); return null; } - // The class is only used to instantiate the ability, and is not a property of the ability itself. - $ability_class = $properties['ability_class'] ?? WP_Ability::class; - unset( $properties['ability_class'] ); - - $ability = new $ability_class( - $name, - $properties - ); - $this->registered_abilities[ $name ] = $ability; return $ability; } @@ -175,10 +118,10 @@ public function register( string $name, array $properties = array() ): ?WP_Abili * * Do not use this method directly. Instead, use the `wp_unregister_ability()` function. * - * @see wp_unregister_ability() - * * @since 0.1.0 * + * @see wp_unregister_ability() + * * @param string $name The name of the registered ability, with its namespace. * @return ?\WP_Ability The unregistered ability instance on success, null on failure. */ @@ -204,10 +147,10 @@ public function unregister( string $name ): ?WP_Ability { * * Do not use this method directly. Instead, use the `wp_get_abilities()` function. * - * @see wp_get_abilities() - * * @since 0.1.0 * + * @see wp_get_abilities() + * * @return \WP_Ability[] The array of registered abilities. */ public function get_all_registered(): array { @@ -231,10 +174,10 @@ public function is_registered( string $name ): bool { * * Do not use this method directly. Instead, use the `wp_get_ability()` function. * - * @see wp_get_ability() - * * @since 0.1.0 * + * @see wp_get_ability() + * * @param string $name The name of the registered ability, with its namespace. * @return ?\WP_Ability The registered ability instance, or null if it is not registered. */ diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 2193b06200cf2..d622e926368dc 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -92,29 +92,20 @@ class WP_Ability { * * @access private * - * @see wp_register_ability() - * * @since 0.1.0 * - * @param string $name The name of the ability, with its namespace. - * @param array $properties An associative array of properties for the ability. This should - * include `label`, `description`, `input_schema`, `output_schema`, - * `execute_callback`, `permission_callback`, and `meta`. + * @see wp_register_ability() * - * @phpstan-param array{ - * label: string, - * description: string, - * input_schema?: array, - * output_schema?: array, - * execute_callback: callable( array $input): (mixed|\WP_Error), - * permission_callback?: ?callable( array $input ): (bool|\WP_Error), - * meta?: array, - * ..., - * } $properties + * @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`. */ - public function __construct( string $name, array $properties ) { + public function __construct( string $name, array $args ) { $this->name = $name; + $properties = $this->prepare_properties( $args ); + foreach ( $properties as $property_name => $property_value ) { if ( ! property_exists( $this, $property_name ) ) { _doing_it_wrong( @@ -135,6 +126,77 @@ public function __construct( string $name, array $properties ) { } } + /** + * Prepares and validates the properties used to instantiate the ability. + * + * Errors are thrown as exceptions instead of \WP_Errors to allow for simpler handling and overloading. They are then + * caught and converted to a WP_Error when by WP_Abilities_Registry::register(). + * + * @since 0.2.0 + * + * @see WP_Abilities_Registry::register() + * + * @param array $args An associative array of arguments used to instantiate the class. + * @return array The validated and prepared properties. + * @throws \InvalidArgumentException if an argument is invalid. + * + * @phpstan-return array{ + * label: string, + * description: string, + * input_schema?: array, + * output_schema?: array, + * execute_callback: callable( array $input): (mixed|\WP_Error), + * permission_callback?: ?callable( array $input ): (bool|\WP_Error), + * meta?: array, + * ..., + * } $args + */ + protected function prepare_properties( array $args ): array { + if ( empty( $args['label'] ) || ! is_string( $args['label'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The ability properties must contain a `label` string.' ) + ); + } + + if ( empty( $args['description'] ) || ! is_string( $args['description'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The ability properties must contain a `description` string.' ) + ); + } + + if ( isset( $args['input_schema'] ) && ! is_array( $args['input_schema'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The ability properties should provide a valid `input_schema` definition.' ) + ); + } + + if ( isset( $args['output_schema'] ) && ! is_array( $args['output_schema'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The ability properties should provide a valid `output_schema` definition.' ) + ); + } + + 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.' ) + ); + } + + if ( isset( $args['permission_callback'] ) && ! is_callable( $args['permission_callback'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The ability properties should provide a valid `permission_callback` function.' ) + ); + } + + if ( isset( $args['meta'] ) && ! is_array( $args['meta'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The ability properties should provide a valid `meta` array.' ) + ); + } + + return $args; + } + /** * Retrieves the name of the ability, with its namespace. * Example: `my-plugin/my-ability`. diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php index 652a1f8fda519..771858cab784f 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php @@ -2,8 +2,9 @@ /** * REST API list controller for Abilities API. * - * @package abilities-api - * @since 0.1.0 + * @package WordPress + * @subpackage Abilities_API + * @since 0.1.0 */ declare( strict_types = 1 ); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php index 147ad7dbacb5b..6ae5af091da39 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php @@ -2,8 +2,9 @@ /** * REST API run controller for Abilities API. * - * @package abilities-api - * @since 0.1.0 + * @package WordPress + * @subpackage Abilities_API + * @since 0.1.0 */ declare( strict_types = 1 ); diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php index dff53e9760c94..1564a84088aa2 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -9,8 +9,8 @@ */ class Tests_Abilities_API_WpAbilitiesRegistry extends WP_UnitTestCase { - public static $test_ability_name = 'test/add-numbers'; - public static $test_ability_properties = array(); + public static $test_ability_name = 'test/add-numbers'; + public static $test_ability_args = array(); /** * Mock abilities registry. @@ -27,7 +27,7 @@ public function set_up(): void { $this->registry = new WP_Abilities_Registry(); - self::$test_ability_properties = array( + self::$test_ability_args = array( 'label' => 'Add numbers', 'description' => 'Calculates the result of adding two numbers.', 'input_schema' => array( @@ -80,7 +80,7 @@ public function tear_down(): void { * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_invalid_name_without_namespace() { - $result = $this->registry->register( 'without-namespace', self::$test_ability_properties ); + $result = $this->registry->register( 'without-namespace', self::$test_ability_args ); $this->assertNull( $result ); } @@ -104,7 +104,7 @@ public function test_register_invalid_characters_in_name() { * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_invalid_uppercase_characters_in_name() { - $result = $this->registry->register( 'Test/AddNumbers', self::$test_ability_properties ); + $result = $this->registry->register( 'Test/AddNumbers', self::$test_ability_args ); $this->assertNull( $result ); } @@ -116,10 +116,10 @@ public function test_register_invalid_uppercase_characters_in_name() { * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_invalid_missing_label() { - // Remove the label from the properties. - unset( self::$test_ability_properties['label'] ); + // Remove the label from the args. + unset( self::$test_ability_args['label'] ); - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); $this->assertNull( $result ); } @@ -131,9 +131,9 @@ public function test_register_invalid_missing_label() { * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_invalid_label_type() { - self::$test_ability_properties['label'] = false; + self::$test_ability_args['label'] = false; - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); $this->assertNull( $result ); } @@ -145,10 +145,10 @@ public function test_register_invalid_label_type() { * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_invalid_missing_description() { - // Remove the description from the properties. - unset( self::$test_ability_properties['description'] ); + // Remove the description from the args. + unset( self::$test_ability_args['description'] ); - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); $this->assertNull( $result ); } @@ -160,9 +160,9 @@ public function test_register_invalid_missing_description() { * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_invalid_description_type() { - self::$test_ability_properties['description'] = false; + self::$test_ability_args['description'] = false; - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); $this->assertNull( $result ); } @@ -174,10 +174,10 @@ public function test_register_invalid_description_type() { * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_invalid_missing_execute_callback() { - // Remove the execute_callback from the properties. - unset( self::$test_ability_properties['execute_callback'] ); + // Remove the execute_callback from the args. + unset( self::$test_ability_args['execute_callback'] ); - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); $this->assertNull( $result ); } @@ -189,9 +189,9 @@ public function test_register_invalid_missing_execute_callback() { * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_incorrect_execute_callback_type() { - self::$test_ability_properties['execute_callback'] = 'not-a-callback'; + self::$test_ability_args['execute_callback'] = 'not-a-callback'; - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); $this->assertNull( $result ); } @@ -203,9 +203,9 @@ public function test_register_incorrect_execute_callback_type() { * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_incorrect_permission_callback_type() { - self::$test_ability_properties['permission_callback'] = 'not-a-callback'; + self::$test_ability_args['permission_callback'] = 'not-a-callback'; - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); $this->assertNull( $result ); } @@ -217,9 +217,9 @@ public function test_register_incorrect_permission_callback_type() { * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_incorrect_input_schema_type() { - self::$test_ability_properties['input_schema'] = 'not-an-array'; + self::$test_ability_args['input_schema'] = 'not-an-array'; - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); $this->assertNull( $result ); } @@ -231,9 +231,9 @@ public function test_register_incorrect_input_schema_type() { * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_incorrect_output_schema_type() { - self::$test_ability_properties['output_schema'] = 'not-an-array'; + self::$test_ability_args['output_schema'] = 'not-an-array'; - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); $this->assertNull( $result ); } @@ -245,9 +245,9 @@ public function test_register_incorrect_output_schema_type() { * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_invalid_meta_type() { - self::$test_ability_properties['meta'] = false; + self::$test_ability_args['meta'] = false; - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); $this->assertNull( $result ); } @@ -259,9 +259,9 @@ public function test_register_invalid_meta_type() { * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_incorrect_already_registered_ability() { - $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); $this->assertNull( $result ); } @@ -272,10 +272,10 @@ public function test_register_incorrect_already_registered_ability() { * @covers WP_Abilities_Registry::register */ public function test_register_new_ability() { - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_properties ); + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); $this->assertEquals( - new WP_Ability( self::$test_ability_name, self::$test_ability_properties ), + new WP_Ability( self::$test_ability_name, self::$test_ability_args ), $result ); } @@ -297,9 +297,9 @@ public function test_is_registered_for_unknown_ability() { * @covers WP_Abilities_Registry::is_registered */ public function test_is_registered_for_known_ability() { - $this->registry->register( 'test/one', self::$test_ability_properties ); - $this->registry->register( 'test/two', self::$test_ability_properties ); - $this->registry->register( 'test/three', self::$test_ability_properties ); + $this->registry->register( 'test/one', self::$test_ability_args ); + $this->registry->register( 'test/two', self::$test_ability_args ); + $this->registry->register( 'test/three', self::$test_ability_args ); $result = $this->registry->is_registered( 'test/one' ); $this->assertTrue( $result ); @@ -324,9 +324,9 @@ public function test_get_registered_rejects_unknown_ability_name() { * @covers WP_Abilities_Registry::get_registered */ public function test_get_registered_for_known_ability() { - $this->registry->register( 'test/one', self::$test_ability_properties ); - $this->registry->register( 'test/two', self::$test_ability_properties ); - $this->registry->register( 'test/three', self::$test_ability_properties ); + $this->registry->register( 'test/one', self::$test_ability_args ); + $this->registry->register( 'test/two', self::$test_ability_args ); + $this->registry->register( 'test/three', self::$test_ability_args ); $result = $this->registry->get_registered( 'test/two' ); $this->assertEquals( 'test/two', $result->get_name() ); @@ -351,9 +351,9 @@ public function test_unregister_not_registered_ability() { * @covers WP_Abilities_Registry::unregister */ public function test_unregister_for_known_ability() { - $this->registry->register( 'test/one', self::$test_ability_properties ); - $this->registry->register( 'test/two', self::$test_ability_properties ); - $this->registry->register( 'test/three', self::$test_ability_properties ); + $this->registry->register( 'test/one', self::$test_ability_args ); + $this->registry->register( 'test/two', self::$test_ability_args ); + $this->registry->register( 'test/three', self::$test_ability_args ); $result = $this->registry->unregister( 'test/three' ); $this->assertEquals( 'test/three', $result->get_name() ); @@ -369,13 +369,13 @@ public function test_unregister_for_known_ability() { */ public function test_get_all_registered() { $ability_one_name = 'test/one'; - $this->registry->register( $ability_one_name, self::$test_ability_properties ); + $this->registry->register( $ability_one_name, self::$test_ability_args ); $ability_two_name = 'test/two'; - $this->registry->register( $ability_two_name, self::$test_ability_properties ); + $this->registry->register( $ability_two_name, self::$test_ability_args ); $ability_three_name = 'test/three'; - $this->registry->register( $ability_three_name, self::$test_ability_properties ); + $this->registry->register( $ability_three_name, self::$test_ability_args ); $result = $this->registry->get_all_registered(); $this->assertCount( 3, $result ); @@ -383,4 +383,22 @@ public function test_get_all_registered() { $this->assertSame( $ability_two_name, $result[ $ability_two_name ]->get_name() ); $this->assertSame( $ability_three_name, $result[ $ability_three_name ]->get_name() ); } + + /** + * Direct instantiation of WP_Ability with invalid properties should throw an exception. + * + * @covers WP_Ability::__construct + * @covers WP_Ability::prepare_properties + */ + public function test_wp_ability_invalid_properties_throws_exception() { + $this->expectException( \InvalidArgumentException::class ); + new WP_Ability( + 'test/invalid', + array( + 'label' => '', + 'description' => '', + 'execute_callback' => null, + ) + ); + } } diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index 556e5cd781114..a1c29c0f67f85 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -21,8 +21,8 @@ protected function do_execute( array $input ) { */ class Test_Abilities_API_WpRegisterAbility extends WP_UnitTestCase { - public static $test_ability_name = 'test/add-numbers'; - public static $test_ability_properties = array(); + public static $test_ability_name = 'test/add-numbers'; + public static $test_ability_args = array(); /** * Set up before each test. @@ -30,7 +30,7 @@ class Test_Abilities_API_WpRegisterAbility extends WP_UnitTestCase { public function set_up(): void { parent::set_up(); - self::$test_ability_properties = array( + self::$test_ability_args = array( 'label' => 'Add numbers', 'description' => 'Calculates the result of adding two numbers.', 'input_schema' => array( @@ -108,7 +108,7 @@ public function test_register_ability_no_abilities_api_init_hook(): void { // Reset the action count to simulate it not being fired unset( $wp_actions['abilities_api_init'] ); - $result = wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); // Restore the original action count if ( $original_count > 0 ) { @@ -124,15 +124,15 @@ public function test_register_ability_no_abilities_api_init_hook(): void { public function test_register_valid_ability(): void { do_action( 'abilities_api_init' ); - $result = wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); $this->assertInstanceOf( WP_Ability::class, $result ); $this->assertSame( self::$test_ability_name, $result->get_name() ); - $this->assertSame( self::$test_ability_properties['label'], $result->get_label() ); - $this->assertSame( self::$test_ability_properties['description'], $result->get_description() ); - $this->assertSame( self::$test_ability_properties['input_schema'], $result->get_input_schema() ); - $this->assertSame( self::$test_ability_properties['output_schema'], $result->get_output_schema() ); - $this->assertSame( self::$test_ability_properties['meta'], $result->get_meta() ); + $this->assertSame( self::$test_ability_args['label'], $result->get_label() ); + $this->assertSame( self::$test_ability_args['description'], $result->get_description() ); + $this->assertSame( self::$test_ability_args['input_schema'], $result->get_input_schema() ); + $this->assertSame( self::$test_ability_args['output_schema'], $result->get_output_schema() ); + $this->assertSame( self::$test_ability_args['meta'], $result->get_meta() ); $this->assertTrue( $result->has_permission( array( @@ -158,10 +158,10 @@ public function test_register_valid_ability(): void { public function test_register_ability_no_permissions(): void { do_action( 'abilities_api_init' ); - self::$test_ability_properties['permission_callback'] = static function (): bool { + self::$test_ability_args['permission_callback'] = static function (): bool { return false; }; - $result = wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); $this->assertFalse( $result->has_permission( @@ -194,7 +194,7 @@ public function test_register_ability_custom_ability_class(): void { $result = wp_register_ability( self::$test_ability_name, array_merge( - self::$test_ability_properties, + self::$test_ability_args, array( 'ability_class' => Mock_Custom_Ability::class, ) @@ -217,7 +217,7 @@ public function test_register_ability_custom_ability_class(): void { wp_register_ability( self::$test_ability_name, array_merge( - self::$test_ability_properties, + self::$test_ability_args, array( 'ability_class' => 'Non_Existent_Class', ) @@ -232,7 +232,7 @@ public function test_register_ability_custom_ability_class(): void { public function test_execute_ability_no_input_schema_match(): void { do_action( 'abilities_api_init' ); - $result = wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); $actual = $result->execute( array( @@ -259,10 +259,11 @@ public function test_execute_ability_no_input_schema_match(): void { public function test_execute_ability_no_output_schema_match(): void { do_action( 'abilities_api_init' ); - self::$test_ability_properties['execute_callback'] = static function (): bool { + self::$test_ability_args['execute_callback'] = static function (): bool { return true; }; - $result = wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); + + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); $actual = $result->execute( array( @@ -287,7 +288,7 @@ public function test_execute_ability_no_output_schema_match(): void { public function test_permission_callback_no_input_schema_match(): void { do_action( 'abilities_api_init' ); - $result = wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); $actual = $result->has_permission( array( @@ -314,14 +315,14 @@ public function test_permission_callback_no_input_schema_match(): void { public function test_permission_callback_receives_input(): void { do_action( 'abilities_api_init' ); - $received_input = null; - self::$test_ability_properties['permission_callback'] = static function ( array $input ) use ( &$received_input ): bool { + $received_input = null; + self::$test_ability_args['permission_callback'] = static function ( array $input ) use ( &$received_input ): bool { $received_input = $input; // Allow only if 'a' is greater than 'b' return $input['a'] > $input['b']; }; - $result = wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); // Test with a > b (should be allowed) $this->assertTrue( @@ -364,12 +365,12 @@ public function test_permission_callback_receives_input(): void { public function test_unregister_existing_ability() { do_action( 'abilities_api_init' ); - wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); + wp_register_ability( self::$test_ability_name, self::$test_ability_args ); $result = wp_unregister_ability( self::$test_ability_name ); $this->assertEquals( - new WP_Ability( self::$test_ability_name, self::$test_ability_properties ), + new WP_Ability( self::$test_ability_name, self::$test_ability_args ), $result ); } @@ -378,10 +379,10 @@ public function test_unregister_existing_ability() { * Tests retrieving existing ability. */ public function test_get_existing_ability() { - $name = self::$test_ability_name; - $properties = self::$test_ability_properties; - $callback = static function ( $instance ) use ( $name, $properties ) { - wp_register_ability( $name, $properties ); + $name = self::$test_ability_name; + $args = self::$test_ability_args; + $callback = static function ( $instance ) use ( $name, $args ) { + wp_register_ability( $name, $args ); }; add_action( 'abilities_api_init', $callback ); @@ -390,14 +391,14 @@ public function test_get_existing_ability() { $registry_reflection = new ReflectionClass( WP_Abilities_Registry::class ); $instance_prop = $registry_reflection->getProperty( 'instance' ); $instance_prop->setAccessible( true ); - $instance_prop->setValue( null ); + $instance_prop->setValue( null, null ); $result = wp_get_ability( $name ); remove_action( 'abilities_api_init', $callback ); $this->assertEquals( - new WP_Ability( $name, $properties ), + new WP_Ability( $name, $args ), $result, 'Ability does not share expected properties.' ); @@ -409,22 +410,22 @@ public function test_get_existing_ability() { public function test_get_all_registered_abilities() { do_action( 'abilities_api_init' ); - $ability_one_name = 'test/ability-one'; - $ability_one_properties = self::$test_ability_properties; - wp_register_ability( $ability_one_name, $ability_one_properties ); + $ability_one_name = 'test/ability-one'; + $ability_one_args = self::$test_ability_args; + wp_register_ability( $ability_one_name, $ability_one_args ); - $ability_two_name = 'test/ability-two'; - $ability_two_properties = self::$test_ability_properties; - wp_register_ability( $ability_two_name, $ability_two_properties ); + $ability_two_name = 'test/ability-two'; + $ability_two_args = self::$test_ability_args; + wp_register_ability( $ability_two_name, $ability_two_args ); - $ability_three_name = 'test/ability-three'; - $ability_three_properties = self::$test_ability_properties; - wp_register_ability( $ability_three_name, $ability_three_properties ); + $ability_three_name = 'test/ability-three'; + $ability_three_args = self::$test_ability_args; + wp_register_ability( $ability_three_name, $ability_three_args ); $expected = array( - $ability_one_name => new WP_Ability( $ability_one_name, $ability_one_properties ), - $ability_two_name => new WP_Ability( $ability_two_name, $ability_two_properties ), - $ability_three_name => new WP_Ability( $ability_three_name, $ability_three_properties ), + $ability_one_name => new WP_Ability( $ability_one_name, $ability_one_args ), + $ability_two_name => new WP_Ability( $ability_two_name, $ability_two_args ), + $ability_three_name => new WP_Ability( $ability_three_name, $ability_three_args ), ); $result = wp_get_abilities(); diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php index 1fac1ac1f3496..71e41af2386a2 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php @@ -1,6 +1,8 @@ Date: Mon, 29 Sep 2025 14:19:28 +0200 Subject: [PATCH 05/31] Sync changes from Abilities API targeted for v0.2.0 --- src/wp-includes/abilities-api.php | 40 +- .../class-wp-abilities-registry.php | 21 +- .../abilities-api/class-wp-ability.php | 117 +++-- ...lass-wp-rest-abilities-list-controller.php | 26 +- ...class-wp-rest-abilities-run-controller.php | 17 +- .../abilities-api/wpAbilitiesRegistry.php | 143 ++++++ .../phpunit/tests/abilities-api/wpAbility.php | 457 ++++++++++++++++++ .../tests/abilities-api/wpRegisterAbility.php | 34 +- .../wpRestAbilitiesListController.php | 48 +- .../rest-api/wpRestAbilitiesRunController.php | 81 +--- tests/qunit/fixtures/wp-api-generated.js | 12 +- 11 files changed, 840 insertions(+), 156 deletions(-) create mode 100644 tests/phpunit/tests/abilities-api/wpAbility.php diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index fabb439e59182..f898ac0084f06 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -5,7 +5,7 @@ * Defines functions for managing abilities in WordPress. * * @package WordPress - * @subpackage Abilities API + * @subpackage Abilities_API * @since 0.1.0 */ @@ -16,31 +16,31 @@ * * Note: Do not use before the {@see 'abilities_api_init'} hook. * - * @see WP_Abilities_Registry::register() - * * @since 0.1.0 * - * @param string $name The name of the ability. The name must be a string containing a namespace - * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase - * alphanumeric characters, dashes and the forward slash. - * @param array $properties An associative array of properties for the ability. This should include - * `label`, `description`, `input_schema`, `output_schema`, `execute_callback`, - * `permission_callback`, `meta`, and `ability_class`. + * @see WP_Abilities_Registry::register() + * + * @param string $name The name of the ability. The name must be a string containing a namespace + * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase + * alphanumeric characters, dashes and the forward slash. + * @param array $args An associative array of arguments for the ability. This should include + * `label`, `description`, `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, + * execute_callback?: callable( mixed $input= ): (mixed|\WP_Error), + * permission_callback?: callable( mixed $input= ): (bool|\WP_Error), * input_schema?: array, * output_schema?: array, - * execute_callback?: callable( array $input): (mixed|\WP_Error), - * permission_callback?: callable( array $input ): (bool|\WP_Error), * meta?: array, * ability_class?: class-string<\WP_Ability>, * ... - * } $properties + * } $args */ -function wp_register_ability( string $name, array $properties = array() ): ?WP_Ability { +function wp_register_ability( string $name, array $args ): ?WP_Ability { if ( ! did_action( 'abilities_api_init' ) ) { _doing_it_wrong( __FUNCTION__, @@ -55,16 +55,16 @@ function wp_register_ability( string $name, array $properties = array() ): ?WP_A return null; } - return WP_Abilities_Registry::get_instance()->register( $name, $properties ); + return WP_Abilities_Registry::get_instance()->register( $name, $args ); } /** * Unregisters an ability using Abilities API. * - * @see WP_Abilities_Registry::unregister() - * * @since 0.1.0 * + * @see WP_Abilities_Registry::unregister() + * * @param string $name The name of the registered ability, with its namespace. * @return ?\WP_Ability The unregistered ability instance on success, null on failure. */ @@ -75,10 +75,10 @@ function wp_unregister_ability( string $name ): ?WP_Ability { /** * Retrieves a registered ability using Abilities API. * - * @see WP_Abilities_Registry::get_registered() - * * @since 0.1.0 * + * @see WP_Abilities_Registry::get_registered() + * * @param string $name The name of the registered ability, with its namespace. * @return ?\WP_Ability The registered ability instance, or null if it is not registered. */ @@ -89,10 +89,10 @@ function wp_get_ability( string $name ): ?WP_Ability { /** * Retrieves all registered abilities using Abilities API. * - * @see WP_Abilities_Registry::get_all_registered() - * * @since 0.1.0 * + * @see WP_Abilities_Registry::get_all_registered() + * * @return \WP_Ability[] The array of registered abilities. */ function wp_get_abilities(): array { diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index 9094398cc7222..3acf8a14ad6dd 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -46,18 +46,17 @@ final class WP_Abilities_Registry { * @param string $name The name of the ability. The name must be a string containing a namespace * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase * alphanumeric characters, dashes and the forward slash. - * @param array $args An associative array of arguments for the ability. This should include - * `label`, `description`, `input_schema`, `output_schema`, - * `execute_callback`, `permission_callback`, `meta`, and ability_class. + * @param array $args An associative array of arguments for the ability. See wp_register_ability() for + * details. * @return ?\WP_Ability The registered ability instance on success, null on failure. * * @phpstan-param array{ * label?: string, * description?: string, + * execute_callback?: callable( mixed $input= ): (mixed|\WP_Error), + * permission_callback?: callable( mixed $input= ): (bool|\WP_Error), * input_schema?: array, * output_schema?: array, - * execute_callback?: callable( array $input): (mixed|\WP_Error), - * permission_callback?: ?callable( array $input ): (bool|\WP_Error), * meta?: array, * ability_class?: class-string<\WP_Ability>, * ... @@ -85,6 +84,16 @@ public function register( string $name, array $args ): ?WP_Ability { return null; } + /** + * Filters the ability arguments before they are validated and used to instantiate the ability. + * + * @since 0.2.0 + * + * @param array $args The arguments used to instantiate the ability. + * @param string $name The name of the ability, with its namespace. + */ + $args = apply_filters( 'register_ability_args', $args, $name ); + // 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( @@ -94,6 +103,8 @@ public function register( string $name, array $args ): ?WP_Ability { ); return null; } + + /** @var class-string<\WP_Ability> */ $ability_class = $args['ability_class'] ?? WP_Ability::class; unset( $args['ability_class'] ); diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index d622e926368dc..78ac5c3ea5e01 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -65,7 +65,7 @@ class WP_Ability { * The ability execute callback. * * @since 0.1.0 - * @var callable( array $input): (mixed|\WP_Error) + * @var callable( mixed $input= ): (mixed|\WP_Error) */ protected $execute_callback; @@ -73,9 +73,9 @@ class WP_Ability { * The optional ability permission callback. * * @since 0.1.0 - * @var ?callable( array $input ): (bool|\WP_Error) + * @var callable( mixed $input= ): (bool|\WP_Error) */ - protected $permission_callback = null; + protected $permission_callback; /** * The optional ability metadata. @@ -143,15 +143,16 @@ public function __construct( string $name, array $args ) { * @phpstan-return array{ * label: string, * description: string, + * execute_callback: callable( mixed $input= ): (mixed|\WP_Error), + * permission_callback: callable( mixed $input= ): (bool|\WP_Error), * input_schema?: array, * output_schema?: array, - * execute_callback: callable( array $input): (mixed|\WP_Error), - * permission_callback?: ?callable( array $input ): (bool|\WP_Error), * meta?: array, * ..., * } $args */ protected function prepare_properties( array $args ): array { + // Required args must be present and of the correct type. if ( empty( $args['label'] ) || ! is_string( $args['label'] ) ) { throw new \InvalidArgumentException( esc_html__( 'The ability properties must contain a `label` string.' ) @@ -164,27 +165,28 @@ protected function prepare_properties( array $args ): array { ); } - if ( isset( $args['input_schema'] ) && ! is_array( $args['input_schema'] ) ) { + if ( empty( $args['execute_callback'] ) || ! is_callable( $args['execute_callback'] ) ) { throw new \InvalidArgumentException( - esc_html__( 'The ability properties should provide a valid `input_schema` definition.' ) + esc_html__( 'The ability properties must contain a valid `execute_callback` function.' ) ); } - if ( isset( $args['output_schema'] ) && ! is_array( $args['output_schema'] ) ) { + if ( empty( $args['permission_callback'] ) || ! is_callable( $args['permission_callback'] ) ) { throw new \InvalidArgumentException( - esc_html__( 'The ability properties should provide a valid `output_schema` definition.' ) + esc_html__( 'The ability properties must provide a valid `permission_callback` function.' ) ); } - if ( empty( $args['execute_callback'] ) || ! is_callable( $args['execute_callback'] ) ) { + // Optional args only need to be of the correct type if they are present. + if ( isset( $args['input_schema'] ) && ! is_array( $args['input_schema'] ) ) { throw new \InvalidArgumentException( - esc_html__( 'The ability properties must contain a valid `execute_callback` function.' ) + esc_html__( 'The ability properties should provide a valid `input_schema` definition.' ) ); } - if ( isset( $args['permission_callback'] ) && ! is_callable( $args['permission_callback'] ) ) { + if ( isset( $args['output_schema'] ) && ! is_array( $args['output_schema'] ) ) { throw new \InvalidArgumentException( - esc_html__( 'The ability properties should provide a valid `permission_callback` function.' ) + esc_html__( 'The ability properties should provide a valid `output_schema` definition.' ) ); } @@ -269,13 +271,24 @@ public function get_meta(): array { * * @since 0.1.0 * - * @param array $input Optional. The input data to validate. + * @param mixed $input Optional. The input data to validate. Default `null`. * @return true|\WP_Error Returns true if valid or the WP_Error object if validation fails. */ - protected function validate_input( array $input = array() ) { + protected function validate_input( $input = null ) { $input_schema = $this->get_input_schema(); if ( empty( $input_schema ) ) { - return true; + if ( null === $input ) { + return true; + } + + return new \WP_Error( + 'ability_missing_input_schema', + sprintf( + /* translators: %s ability name. */ + __( 'Ability "%s" does not define an input schema required to validate the provided input.' ), + $this->name + ) + ); } $valid_input = rest_validate_value_from_schema( $input, $input_schema, 'input' ); @@ -296,36 +309,54 @@ protected function validate_input( array $input = array() ) { /** * Checks whether the ability has the necessary permissions. - * If the permission callback is not set, the default behavior is to allow access - * when the input provided passes validation. * - * @since 0.1.0 + * The input is validated against the input schema before it is passed to to permission callback. * - * @param array $input Optional. The input data for permission checking. + * @since 0.2.0 + * + * @param mixed $input Optional. The input data for permission checking. Default `null`. * @return bool|\WP_Error Whether the ability has the necessary permission. */ - public function has_permission( array $input = array() ) { + public function check_permissions( $input = null ) { $is_valid = $this->validate_input( $input ); if ( is_wp_error( $is_valid ) ) { return $is_valid; } - if ( ! is_callable( $this->permission_callback ) ) { - return true; + if ( empty( $this->get_input_schema() ) ) { + return call_user_func( $this->permission_callback ); } return call_user_func( $this->permission_callback, $input ); } + /** + * Checks whether the ability has the necessary permissions (deprecated). + * + * The input is validated against the input schema before it is passed to to permission callback. + * + * @deprecated 0.2.0 Use check_permissions() instead. + * @see WP_Ability::check_permissions() + * + * @since 0.1.0 + * + * @param mixed $input Optional. The input data for permission checking. Default `null`. + * @return bool|\WP_Error Whether the ability has the necessary permission. + */ + public function has_permission( $input = null ) { + _deprecated_function( __METHOD__, '0.2.0', 'WP_Ability::check_permissions()' ); + return $this->check_permissions( $input ); + } + /** * Executes the ability callback. * * @since 0.1.0 * - * @param array $input The input data for the ability. + * @param mixed $input Optional. The input data for the ability. Default `null`. * @return mixed|\WP_Error The result of the ability execution, or WP_Error on failure. */ - protected function do_execute( array $input ) { + protected function do_execute( $input = null ) { if ( ! is_callable( $this->execute_callback ) ) { return new \WP_Error( 'ability_invalid_execute_callback', @@ -334,6 +365,10 @@ protected function do_execute( array $input ) { ); } + if ( empty( $this->get_input_schema() ) ) { + return call_user_func( $this->execute_callback ); + } + return call_user_func( $this->execute_callback, $input ); } @@ -373,11 +408,11 @@ protected function validate_output( $output ) { * * @since 0.1.0 * - * @param array $input Optional. The input data for the ability. + * @param mixed $input Optional. The input data for the ability. Default `null`. * @return mixed|\WP_Error The result of the ability execution, or WP_Error on failure. */ - public function execute( array $input = array() ) { - $has_permissions = $this->has_permission( $input ); + public function execute( $input = null ) { + $has_permissions = $this->check_permissions( $input ); if ( true !== $has_permissions ) { if ( is_wp_error( $has_permissions ) ) { if ( 'ability_invalid_input' === $has_permissions->get_error_code() ) { @@ -398,14 +433,38 @@ public function execute( array $input = array() ) { ); } + /** + * Fires before an ability gets executed. + * + * @since 0.2.0 + * + * @param string $ability_name The name of the ability. + * @param mixed $input The input data for the ability. + */ + do_action( 'before_execute_ability', $this->name, $input ); + $result = $this->do_execute( $input ); if ( is_wp_error( $result ) ) { return $result; } $is_valid = $this->validate_output( $result ); + if ( is_wp_error( $is_valid ) ) { + return $is_valid; + } - return is_wp_error( $is_valid ) ? $is_valid : $result; + /** + * Fires immediately after an ability finished executing. + * + * @since 0.2.0 + * + * @param string $ability_name The name of the ability. + * @param mixed $input The input data for the ability. + * @param mixed $result The result of the ability execution. + */ + do_action( 'after_execute_ability', $this->name, $input, $result ); + + return $result; } /** diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php index 771858cab784f..1cd0f1cac0ce4 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php @@ -94,10 +94,6 @@ public function register_routes(): void { * @return \WP_REST_Response Response object on success. */ public function get_items( $request ) { - // TODO: Add HEAD method support for performance optimization. - // Should return early with empty body but include X-WP-Total and X-WP-TotalPages headers. - // See: https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php#L316-L318 - $abilities = wp_get_abilities(); // Handle pagination with explicit defaults. @@ -109,15 +105,19 @@ public function get_items( $request ) { $total_abilities = count( $abilities ); $max_pages = ceil( $total_abilities / $per_page ); - $abilities = array_slice( $abilities, $offset, $per_page ); + if ( $request->is_method( 'HEAD' ) ) { + $response = new \WP_REST_Response( array() ); + } else { + $abilities = array_slice( $abilities, $offset, $per_page ); - $data = array(); - foreach ( $abilities as $ability ) { - $item = $this->prepare_item_for_response( $ability, $request ); - $data[] = $this->prepare_response_for_collection( $item ); - } + $data = array(); + foreach ( $abilities as $ability ) { + $item = $this->prepare_item_for_response( $ability, $request ); + $data[] = $this->prepare_response_for_collection( $item ); + } - $response = rest_ensure_response( $data ); + $response = rest_ensure_response( $data ); + } $response->header( 'X-WP-Total', (string) $total_abilities ); $response->header( 'X-WP-TotalPages', (string) $max_pages ); @@ -128,13 +128,13 @@ public function get_items( $request ) { if ( $page > 1 ) { $prev_page = $page - 1; $prev_link = add_query_arg( 'page', $prev_page, $base ); - $response->add_link( 'prev', $prev_link ); + $response->link_header( 'prev', $prev_link ); } if ( $page < $max_pages ) { $next_page = $page + 1; $next_link = add_query_arg( 'page', $next_page, $base ); - $response->add_link( 'next', $next_link ); + $response->link_header( 'next', $next_link ); } return $response; diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php index 6ae5af091da39..a54003a5b8743 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php @@ -163,7 +163,7 @@ public function run_ability_permissions_check( $request ) { } $input = $this->get_input_from_request( $request ); - if ( ! $ability->has_permission( $input ) ) { + if ( ! $ability->check_permissions( $input ) ) { return new \WP_Error( 'rest_ability_cannot_execute', __( 'Sorry, you are not allowed to execute this ability.' ), @@ -180,22 +180,18 @@ public function run_ability_permissions_check( $request ) { * @since 0.1.0 * * @param \WP_REST_Request> $request The request object. - * @return array The input parameters. + * @return mixed|null The input parameters. */ private function get_input_from_request( $request ) { if ( 'GET' === $request->get_method() ) { // For GET requests, look for 'input' query parameter. $query_params = $request->get_query_params(); - return isset( $query_params['input'] ) && is_array( $query_params['input'] ) - ? $query_params['input'] - : array(); + return $query_params['input'] ?? null; } // For POST requests, look for 'input' in JSON body. $json_params = $request->get_json_params(); - return isset( $json_params['input'] ) && is_array( $json_params['input'] ) - ? $json_params['input'] - : array(); + return $json_params['input'] ?? null; } /** @@ -209,8 +205,8 @@ public function get_run_args(): array { return array( 'input' => array( 'description' => __( 'Input parameters for the ability execution.' ), - 'type' => 'object', - 'default' => array(), + 'type' => array( 'integer', 'number', 'boolean', 'string', 'array', 'object', 'null' ), + 'default' => null, ), ); } @@ -230,7 +226,6 @@ public function get_run_schema(): array { 'properties' => array( 'result' => array( 'description' => __( 'The result of the ability execution.' ), - 'type' => 'mixed', 'context' => array( 'view' ), 'readonly' => true, ), diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php index 1564a84088aa2..94f46863e3394 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -27,6 +27,8 @@ public function set_up(): void { $this->registry = new WP_Abilities_Registry(); + remove_all_filters( 'register_ability_args' ); + self::$test_ability_args = array( 'label' => 'Add numbers', 'description' => 'Calculates the result of adding two numbers.', @@ -69,6 +71,8 @@ public function set_up(): void { public function tear_down(): void { $this->registry = null; + remove_all_filters( 'register_ability_args' ); + parent::tear_down(); } @@ -112,6 +116,7 @@ public function test_register_invalid_uppercase_characters_in_name() { * Should reject ability registration without a label. * * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties * * @expectedIncorrectUsage WP_Abilities_Registry::register */ @@ -127,6 +132,7 @@ public function test_register_invalid_missing_label() { * Should reject ability registration with invalid label type. * * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties * * @expectedIncorrectUsage WP_Abilities_Registry::register */ @@ -141,6 +147,7 @@ public function test_register_invalid_label_type() { * Should reject ability registration without a description. * * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties * * @expectedIncorrectUsage WP_Abilities_Registry::register */ @@ -156,6 +163,7 @@ public function test_register_invalid_missing_description() { * Should reject ability registration with invalid description type. * * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties * * @expectedIncorrectUsage WP_Abilities_Registry::register */ @@ -170,6 +178,7 @@ public function test_register_invalid_description_type() { * Should reject ability registration without an execute callback. * * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties * * @expectedIncorrectUsage WP_Abilities_Registry::register */ @@ -185,6 +194,7 @@ public function test_register_invalid_missing_execute_callback() { * Should reject ability registration if the execute callback is not a callable. * * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties * * @expectedIncorrectUsage WP_Abilities_Registry::register */ @@ -195,10 +205,27 @@ public function test_register_incorrect_execute_callback_type() { $this->assertNull( $result ); } + /** + * Should reject ability registration without an execute callback. + * + * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_missing_permission_callback() { + // Remove the permission_callback from the args. + unset( self::$test_ability_args['permission_callback'] ); + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + $this->assertNull( $result ); + } + /** * Should reject ability registration if the permission callback is not a callable. * * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties * * @expectedIncorrectUsage WP_Abilities_Registry::register */ @@ -213,6 +240,7 @@ public function test_register_incorrect_permission_callback_type() { * Should reject ability registration if the input schema is not an array. * * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties * * @expectedIncorrectUsage WP_Abilities_Registry::register */ @@ -227,6 +255,7 @@ public function test_register_incorrect_input_schema_type() { * Should reject ability registration if the output schema is not an array. * * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties * * @expectedIncorrectUsage WP_Abilities_Registry::register */ @@ -241,6 +270,7 @@ public function test_register_incorrect_output_schema_type() { * Should reject ability registration with invalid meta type. * * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties * * @expectedIncorrectUsage WP_Abilities_Registry::register */ @@ -401,4 +431,117 @@ public function test_wp_ability_invalid_properties_throws_exception() { ) ); } + + /** + * Test register_ability_args filter modifies the args before ability instantiation. + */ + public function test_register_ability_args_filter_modifies_args() { + $was_filter_callback_fired = false; + + // Define the filter. + add_filter( + 'register_ability_args', + static function ( $args ) use ( &$was_filter_callback_fired ) { + $args['label'] = 'Modified label'; + $original_execute_callback = $args['execute_callback']; + $args['execute_callback'] = static function ( array $input ) use ( &$was_filter_callback_fired, $original_execute_callback ) { + $was_filter_callback_fired = true; + return $original_execute_callback( $input ); + }; + + return $args; + }, + 10 + ); + + // Register the ability. + $ability = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + + // Check the label was modified by the filter. + $this->assertSame( 'Modified label', $ability->get_label() ); + + // Call the execute callback. + $result = $ability->execute( + array( + 'a' => 1, + 'b' => 2, + ) + ); + + $this->assertTrue( $was_filter_callback_fired, 'The execute callback defined in the filter was not fired.' ); + $this->assertSame( 3, $result, 'The original execute callback did not return the expected result.' ); + } + + /** + * Test register_ability_args filter can block ability registration by returning invalid args. + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_ability_args_filter_blocks_registration() { + // Define the filter. + add_filter( + 'register_ability_args', + static function ( $args ) { + // Remove the label to make the args invalid. + unset( $args['label'] ); + return $args; + }, + 10 + ); + + // Register the ability. + $ability = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + + // Check the ability was not registered. + $this->assertNull( $ability, 'The ability was registered even though the args were made invalid by the filter.' ); + } + + /** + * Test register_ability_args filter can block an invalid ability class from being used. + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_ability_args_filter_blocks_invalid_ability_class() { + // Define the filter. + add_filter( + 'register_ability_args', + static function ( $args ) { + // Set an invalid ability class. + $args['ability_class'] = 'NonExistentClass'; + return $args; + }, + 10 + ); + // Register the ability. + $ability = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + + // Check the ability was not registered. + $this->assertNull( $ability, 'The ability was registered even though the ability class was made invalid by the filter.' ); + } + + /** + * Tests register_ability_args filter is only applied to the specific ability being registered. + */ + public function test_register_ability_args_filter_only_applies_to_specific_ability() { + add_filter( + 'register_ability_args', + static function ( $args, $name ) { + if ( self::$test_ability_name !== $name ) { + // Do not modify args for other abilities. + return $args; + } + + $args['label'] = 'Modified label for specific ability'; + return $args; + }, + 10, + 2 + ); + + // Register the first ability, which the filter should modify. + $filtered_ability = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + $this->assertSame( 'Modified label for specific ability', $filtered_ability->get_label() ); + + $unfiltered_ability = $this->registry->register( 'test/another-ability', self::$test_ability_args ); + $this->assertNotSame( $filtered_ability->get_label(), $unfiltered_ability->get_label(), 'The filter incorrectly modified the args for an ability it should not have.' ); + } } diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php new file mode 100644 index 0000000000000..961a40f8fcd90 --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -0,0 +1,457 @@ + 'Calculator', + 'description' => 'Calculates the result of math operations.', + 'output_schema' => array( + 'type' => 'number', + 'description' => 'The result of performing a math operation.', + 'required' => true, + ), + 'permission_callback' => static function (): bool { + return true; + }, + 'meta' => array( + 'category' => 'math', + ), + ); + } + + /** + * Data provider for testing the execution of the ability. + */ + public function data_execute_input() { + return array( + 'null input' => array( + array( + 'type' => array( 'null', 'integer' ), + 'description' => 'The null or integer to convert to integer.', + 'required' => true, + ), + static function ( $input ): int { + return null === $input ? 0 : (int) $input; + }, + null, + 0, + ), + 'boolean input' => array( + array( + 'type' => 'boolean', + 'description' => 'The boolean to convert to integer.', + 'required' => true, + ), + static function ( bool $input ): int { + return $input ? 1 : 0; + }, + true, + 1, + ), + 'integer input' => array( + array( + 'type' => 'integer', + 'description' => 'The integer to add 5 to.', + 'required' => true, + ), + static function ( int $input ): int { + return 5 + $input; + }, + 2, + 7, + ), + 'number input' => array( + array( + 'type' => 'number', + 'description' => 'The floating number to round.', + 'required' => true, + ), + static function ( float $input ): int { + return (int) round( $input ); + }, + 2.7, + 3, + ), + 'string input' => array( + array( + 'type' => 'string', + 'description' => 'The string to measure the length of.', + 'required' => true, + ), + static function ( string $input ): int { + return strlen( $input ); + }, + 'Hello world!', + 12, + ), + 'object input' => array( + array( + 'type' => 'object', + 'description' => 'An object containing two numbers to add.', + 'properties' => array( + 'a' => array( + 'type' => 'integer', + 'description' => 'First number.', + 'required' => true, + ), + 'b' => array( + 'type' => 'integer', + 'description' => 'Second number.', + 'required' => true, + ), + ), + 'additionalProperties' => false, + ), + static function ( array $input ): int { + return $input['a'] + $input['b']; + }, + array( + 'a' => 2, + 'b' => 3, + ), + 5, + ), + 'array input' => array( + array( + 'type' => 'array', + 'description' => 'An array containing two numbers to add.', + 'required' => true, + 'minItems' => 2, + 'maxItems' => 2, + 'items' => array( + 'type' => 'integer', + ), + ), + static function ( array $input ): int { + return $input[0] + $input[1]; + }, + array( 2, 3 ), + 5, + ), + ); + } + + /** + * Tests the execution of the ability. + * + * @dataProvider data_execute_input + */ + public function test_execute_input( $input_schema, $execute_callback, $input, $result ) { + $args = array_merge( + self::$test_ability_properties, + array( + 'input_schema' => $input_schema, + 'execute_callback' => $execute_callback, + ) + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertSame( $result, $ability->execute( $input ) ); + } + + /** + * Tests the execution of the ability with no input. + */ + public function test_execute_no_input() { + $args = array_merge( + self::$test_ability_properties, + array( + 'execute_callback' => static function (): int { + return 42; + }, + ) + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertSame( 42, $ability->execute() ); + } + + /** + * Tests that before_execute_ability action is fired with correct parameters. + */ + public function test_before_execute_ability_action() { + $action_ability_name = null; + $action_input = null; + + $args = array_merge( + self::$test_ability_properties, + array( + 'input_schema' => array( + 'type' => 'integer', + 'description' => 'Test input parameter.', + 'required' => true, + ), + 'execute_callback' => static function ( int $input ): int { + return $input * 2; + }, + ) + ); + + $callback = static function ( $ability_name, $input ) use ( &$action_ability_name, &$action_input ) { + $action_ability_name = $ability_name; + $action_input = $input; + }; + + add_action( 'before_execute_ability', $callback, 10, 2 ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute( 5 ); + + remove_action( 'before_execute_ability', $callback ); + + $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' ); + $this->assertSame( 5, $action_input, 'Action should receive correct input' ); + $this->assertSame( 10, $result, 'Ability should execute correctly' ); + } + + /** + * Tests that before_execute_ability action is fired with null input when no input schema is defined. + */ + public function test_before_execute_ability_action_no_input() { + $action_ability_name = null; + $action_input = null; + + $args = array_merge( + self::$test_ability_properties, + array( + 'execute_callback' => static function (): int { + return 42; + }, + ) + ); + + $callback = static function ( $ability_name, $input ) use ( &$action_ability_name, &$action_input ) { + $action_ability_name = $ability_name; + $action_input = $input; + }; + + add_action( 'before_execute_ability', $callback, 10, 2 ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute(); + + remove_action( 'before_execute_ability', $callback ); + + $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' ); + $this->assertNull( $action_input, 'Action should receive null input when no input provided' ); + $this->assertSame( 42, $result, 'Ability should execute correctly' ); + } + + /** + * Tests that after_execute_ability action is fired with correct parameters. + */ + public function test_after_execute_ability_action() { + $action_ability_name = null; + $action_input = null; + $action_result = null; + + $args = array_merge( + self::$test_ability_properties, + array( + 'input_schema' => array( + 'type' => 'integer', + 'description' => 'Test input parameter.', + 'required' => true, + ), + 'execute_callback' => static function ( int $input ): int { + return $input * 3; + }, + ) + ); + + $callback = static function ( $ability_name, $input, $result ) use ( &$action_ability_name, &$action_input, &$action_result ) { + $action_ability_name = $ability_name; + $action_input = $input; + $action_result = $result; + }; + + add_action( 'after_execute_ability', $callback, 10, 3 ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute( 7 ); + + remove_action( 'after_execute_ability', $callback ); + + $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' ); + $this->assertSame( 7, $action_input, 'Action should receive correct input' ); + $this->assertSame( 21, $action_result, 'Action should receive correct result' ); + $this->assertSame( 21, $result, 'Ability should execute correctly' ); + } + + /** + * Tests that after_execute_ability action is fired with null input when no input schema is defined. + */ + public function test_after_execute_ability_action_no_input() { + $action_ability_name = null; + $action_input = null; + $action_result = null; + + $args = array_merge( + self::$test_ability_properties, + array( + 'output_schema' => array(), + 'execute_callback' => static function (): string { + return 'test-result'; + }, + ) + ); + + $callback = static function ( $ability_name, $input, $result ) use ( &$action_ability_name, &$action_input, &$action_result ) { + $action_ability_name = $ability_name; + $action_input = $input; + $action_result = $result; + }; + + add_action( 'after_execute_ability', $callback, 10, 3 ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute(); + + remove_action( 'after_execute_ability', $callback ); + + $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' ); + $this->assertNull( $action_input, 'Action should receive null input when no input provided' ); + $this->assertSame( 'test-result', $action_result, 'Action should receive correct result' ); + $this->assertSame( 'test-result', $result, 'Ability should execute correctly' ); + } + + /** + * Tests that neither action is fired when execution fails due to permission issues. + */ + public function test_actions_not_fired_on_permission_failure() { + $before_action_fired = false; + $after_action_fired = false; + + $args = array_merge( + self::$test_ability_properties, + array( + 'permission_callback' => static function (): bool { + return false; + }, + 'execute_callback' => static function (): int { + return 42; + }, + ) + ); + + $before_callback = static function () use ( &$before_action_fired ) { + $before_action_fired = true; + }; + + $after_callback = static function () use ( &$after_action_fired ) { + $after_action_fired = true; + }; + + add_action( 'before_execute_ability', $before_callback ); + add_action( 'after_execute_ability', $after_callback ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute(); + + remove_action( 'before_execute_ability', $before_callback ); + remove_action( 'after_execute_ability', $after_callback ); + + $this->assertFalse( $before_action_fired, 'before_execute_ability action should not be fired on permission failure' ); + $this->assertFalse( $after_action_fired, 'after_execute_ability action should not be fired on permission failure' ); + $this->assertInstanceOf( WP_Error::class, $result, 'Should return WP_Error on permission failure' ); + } + + /** + * Tests that after_execute_ability action is not fired when execution callback returns WP_Error. + */ + public function test_after_action_not_fired_on_execution_error() { + $before_action_fired = false; + $after_action_fired = false; + + $args = array_merge( + self::$test_ability_properties, + array( + 'execute_callback' => static function () { + return new WP_Error( 'test_error', 'Test execution error' ); + }, + ) + ); + + $before_callback = static function () use ( &$before_action_fired ) { + $before_action_fired = true; + }; + + $after_callback = static function () use ( &$after_action_fired ) { + $after_action_fired = true; + }; + + add_action( 'before_execute_ability', $before_callback ); + add_action( 'after_execute_ability', $after_callback ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute(); + + remove_action( 'before_execute_ability', $before_callback ); + remove_action( 'after_execute_ability', $after_callback ); + + $this->assertTrue( $before_action_fired, 'before_execute_ability action should be fired even if execution fails' ); + $this->assertFalse( $after_action_fired, 'after_execute_ability action should not be fired when execution returns WP_Error' ); + $this->assertInstanceOf( WP_Error::class, $result, 'Should return WP_Error from execution callback' ); + } + + /** + * Tests that after_execute_ability action is not fired when output validation fails. + */ + public function test_after_action_not_fired_on_output_validation_error() { + $before_action_fired = false; + $after_action_fired = false; + + $args = array_merge( + self::$test_ability_properties, + array( + 'output_schema' => array( + 'type' => 'string', + 'description' => 'Expected string output.', + 'required' => true, + ), + 'execute_callback' => static function (): int { + return 42; + }, + ) + ); + + $before_callback = static function () use ( &$before_action_fired ) { + $before_action_fired = true; + }; + + $after_callback = static function () use ( &$after_action_fired ) { + $after_action_fired = true; + }; + + add_action( 'before_execute_ability', $before_callback ); + add_action( 'after_execute_ability', $after_callback ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute(); + + remove_action( 'before_execute_ability', $before_callback ); + remove_action( 'after_execute_ability', $after_callback ); + + $this->assertTrue( $before_action_fired, 'before_execute_ability action should be fired even if output validation fails' ); + $this->assertFalse( $after_action_fired, 'after_execute_ability action should not be fired when output validation fails' ); + $this->assertInstanceOf( WP_Error::class, $result, 'Should return WP_Error for output validation failure' ); + } +} diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index a1c29c0f67f85..a292a11896c8a 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -4,7 +4,7 @@ * Mock used to test a custom ability class. */ class Mock_Custom_Ability extends WP_Ability { - protected function do_execute( array $input ) { + protected function do_execute( $input = null ) { return 9999; } } @@ -134,7 +134,7 @@ public function test_register_valid_ability(): void { $this->assertSame( self::$test_ability_args['output_schema'], $result->get_output_schema() ); $this->assertSame( self::$test_ability_args['meta'], $result->get_meta() ); $this->assertTrue( - $result->has_permission( + $result->check_permissions( array( 'a' => 2, 'b' => 3, @@ -164,7 +164,7 @@ public function test_register_ability_no_permissions(): void { $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); $this->assertFalse( - $result->has_permission( + $result->check_permissions( array( 'a' => 2, 'b' => 3, @@ -225,7 +225,6 @@ public function test_register_ability_custom_ability_class(): void { ); } - /** * Tests executing an ability with input not matching schema. */ @@ -290,7 +289,7 @@ public function test_permission_callback_no_input_schema_match(): void { $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); - $actual = $result->has_permission( + $actual = $result->check_permissions( array( 'a' => 2, 'b' => 3, @@ -309,6 +308,27 @@ public function test_permission_callback_no_input_schema_match(): void { ); } + /** + * Tests that deprecated has_permission() method still works. + * + * @expectedDeprecated WP_Ability::has_permission + */ + public function test_has_permission_deprecated_coverage(): void { + do_action( 'abilities_api_init' ); + + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); + + // Test that deprecated method still works + $this->assertTrue( + $result->has_permission( + array( + 'a' => 2, + 'b' => 3, + ) + ) + ); + } + /** * Tests permission callback receiving input for contextual permission checks. */ @@ -326,7 +346,7 @@ public function test_permission_callback_receives_input(): void { // Test with a > b (should be allowed) $this->assertTrue( - $result->has_permission( + $result->check_permissions( array( 'a' => 5, 'b' => 3, @@ -343,7 +363,7 @@ public function test_permission_callback_receives_input(): void { // Test with a < b (should be denied) $this->assertFalse( - $result->has_permission( + $result->check_permissions( array( 'a' => 2, 'b' => 8, diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php index 71e41af2386a2..319e26e0e8499 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php @@ -273,37 +273,61 @@ public function test_pagination_headers(): void { $this->assertEquals( ceil( $total_abilities / 10 ), (int) $headers['X-WP-TotalPages'] ); } + /** + * Test HEAD method returns empty body with proper headers. + */ + public function test_head_request(): void { + $request = new WP_REST_Request( 'HEAD', '/wp/v2/abilities' ); + $response = $this->server->dispatch( $request ); + + // Verify empty response body + $data = $response->get_data(); + $this->assertEmpty( $data ); + + // Verify pagination headers are present + $headers = $response->get_headers(); + $this->assertArrayHasKey( 'X-WP-Total', $headers ); + $this->assertArrayHasKey( 'X-WP-TotalPages', $headers ); + } + /** * Test pagination links. */ public function test_pagination_links(): void { - // Test first page (should have 'next' link but no 'prev') + // Test first page (should have 'next' link header but no 'prev') $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); $request->set_param( 'per_page', 10 ); $request->set_param( 'page', 1 ); $response = $this->server->dispatch( $request ); - $links = $response->get_links(); - $this->assertArrayHasKey( 'next', $links ); - $this->assertArrayNotHasKey( 'prev', $links ); + $headers = $response->get_headers(); + $link_header = $headers['Link'] ?? ''; + + // Parse Link header for rel="next" and rel="prev" + $this->assertStringContainsString( 'rel="next"', $link_header ); + $this->assertStringNotContainsString( 'rel="prev"', $link_header ); - // Test middle page (should have both 'next' and 'prev' links) + // Test middle page (should have both 'next' and 'prev' link headers) $request->set_param( 'page', 3 ); $response = $this->server->dispatch( $request ); - $links = $response->get_links(); - $this->assertArrayHasKey( 'next', $links ); - $this->assertArrayHasKey( 'prev', $links ); + $headers = $response->get_headers(); + $link_header = $headers['Link'] ?? ''; + + $this->assertStringContainsString( 'rel="next"', $link_header ); + $this->assertStringContainsString( 'rel="prev"', $link_header ); - // Test last page (should have 'prev' link but no 'next') + // Test last page (should have 'prev' link header but no 'next') $total_abilities = count( wp_get_abilities() ); $last_page = ceil( $total_abilities / 10 ); $request->set_param( 'page', $last_page ); $response = $this->server->dispatch( $request ); - $links = $response->get_links(); - $this->assertArrayNotHasKey( 'next', $links ); - $this->assertArrayHasKey( 'prev', $links ); + $headers = $response->get_headers(); + $link_header = $headers['Link'] ?? ''; + + $this->assertStringNotContainsString( 'rel="next"', $link_header ); + $this->assertStringContainsString( 'rel="prev"', $link_header ); } /** diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php index 7263b79d94f26..f7ae219539bf2 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php @@ -118,7 +118,7 @@ private function register_test_abilities(): void { 'execute_callback' => static function ( array $input ) { return $input['a'] + $input['b']; }, - 'permission_callback' => static function ( array $input ) { + 'permission_callback' => static function () { return current_user_can( 'edit_posts' ); }, 'meta' => array( @@ -160,7 +160,7 @@ private function register_test_abilities(): void { 'login' => $user->user_login, ); }, - 'permission_callback' => static function ( array $input ) { + 'permission_callback' => static function () { return is_user_logged_in(); }, 'meta' => array( @@ -263,7 +263,7 @@ private function register_test_abilities(): void { 'param2' => array( 'type' => 'integer' ), ), ), - 'execute_callback' => static function ( array $input ) { + 'execute_callback' => static function ( $input ) { return $input; }, 'permission_callback' => '__return_true', @@ -366,12 +366,11 @@ public function test_resource_ability_requires_get(): void { /** * Test output validation against schema. * Note: When output validation fails in WP_Ability::execute(), it returns null, - * which causes the REST controller to return 'rest_ability_execution_failed'. + * which causes the REST controller to return 'ability_invalid_output'. */ public function test_output_validation(): void { $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/invalid-output/run' ); $request->set_header( 'Content-Type', 'application/json' ); - $request->set_body( wp_json_encode( array() ) ); $response = $this->server->dispatch( $request ); @@ -453,7 +452,6 @@ public function test_contextual_permission_check(): void { public function test_null_return_handling(): void { $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/null-return/run' ); $request->set_header( 'Content-Type', 'application/json' ); - $request->set_body( wp_json_encode( array() ) ); $response = $this->server->dispatch( $request ); @@ -468,7 +466,6 @@ public function test_null_return_handling(): void { public function test_wp_error_return_handling(): void { $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/error-return/run' ); $request->set_header( 'Content-Type', 'application/json' ); - $request->set_body( wp_json_encode( array() ) ); $response = $this->server->dispatch( $request ); @@ -486,7 +483,6 @@ public function test_wp_error_return_handling(): void { public function test_execute_non_existent_ability(): void { $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/non/existent/run' ); $request->set_header( 'Content-Type', 'application/json' ); - $request->set_body( wp_json_encode( array() ) ); $response = $this->server->dispatch( $request ); @@ -608,7 +604,7 @@ public function test_output_validation_failure_returns_error(): void { ), 'required' => array( 'status' ), ), - 'execute_callback' => static function ( $input ) { + 'execute_callback' => static function () { // Return invalid output that doesn't match schema return array( 'wrong_field' => 'value' ); }, @@ -619,7 +615,6 @@ public function test_output_validation_failure_returns_error(): void { $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/strict-output/run' ); $request->set_header( 'Content-Type', 'application/json' ); - $request->set_body( wp_json_encode( array( 'input' => array() ) ) ); $response = $this->server->dispatch( $request ); @@ -652,7 +647,7 @@ public function test_input_validation_failure_returns_error(): void { ), 'required' => array( 'required_field' ), ), - 'execute_callback' => static function ( $input ) { + 'execute_callback' => static function () { return array( 'status' => 'success' ); }, 'permission_callback' => '__return_true', @@ -687,7 +682,7 @@ public function test_ability_without_type_defaults_to_tool(): void { array( 'label' => 'No Type', 'description' => 'Ability without type', - 'execute_callback' => static function ( $input ) { + 'execute_callback' => static function () { return array( 'executed' => true ); }, 'permission_callback' => '__return_true', @@ -703,57 +698,23 @@ public function test_ability_without_type_defaults_to_tool(): void { // Should work with POST $post_request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/no-type/run' ); $post_request->set_header( 'Content-Type', 'application/json' ); - $post_request->set_body( wp_json_encode( array( 'input' => array() ) ) ); $post_response = $this->server->dispatch( $post_request ); $this->assertEquals( 200, $post_response->get_status() ); } - /** - * Test permission check with null permission callback. - */ - public function test_permission_check_passes_when_callback_not_set(): void { - // Register ability without permission callback. - wp_register_ability( - 'test/no-permission-callback', - array( - 'label' => 'No Permission Callback', - 'description' => 'Ability without permission callback', - 'execute_callback' => static function ( $input ) { - return array( 'executed' => true ); - }, - 'meta' => array( 'type' => 'tool' ), - // No permission_callback set - ) - ); - - wp_set_current_user( 0 ); // Not logged in - - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/no-permission-callback/run' ); - $request->set_header( 'Content-Type', 'application/json' ); - $request->set_body( wp_json_encode( array( 'input' => array() ) ) ); - - $response = $this->server->dispatch( $request ); - - // Should succeed when no permission callback is set - $this->assertEquals( 200, $response->get_status() ); - - // Restore user for other tests - wp_set_current_user( self::$user_id ); - } - /** * Test edge case with empty input for both GET and POST. */ public function test_empty_input_handling(): void { - // Register abilities for empty input testing + // Registers abilities for empty input testing. wp_register_ability( 'test/resource-empty', array( 'label' => 'Resource Empty', 'description' => 'Resource with empty input', - 'execute_callback' => static function ( $input ) { - return array( 'input_was_empty' => empty( $input ) ); + 'execute_callback' => static function () { + return array( 'input_was_empty' => 0 === func_num_args() ); }, 'permission_callback' => '__return_true', 'meta' => array( 'type' => 'resource' ), @@ -765,21 +726,21 @@ public function test_empty_input_handling(): void { array( 'label' => 'Tool Empty', 'description' => 'Tool with empty input', - 'execute_callback' => static function ( $input ) { - return array( 'input_was_empty' => empty( $input ) ); + 'execute_callback' => static function () { + return array( 'input_was_empty' => 0 === func_num_args() ); }, 'permission_callback' => '__return_true', 'meta' => array( 'type' => 'tool' ), ) ); - // Test GET with no input parameter + // Tests GET with no input parameter. $get_request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/resource-empty/run' ); $get_response = $this->server->dispatch( $get_request ); $this->assertEquals( 200, $get_response->get_status() ); $this->assertTrue( $get_response->get_data()['input_was_empty'] ); - // Test POST with no body + // Tests POST with no body. $post_request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/tool-empty/run' ); $post_request->set_header( 'Content-Type', 'application/json' ); $post_request->set_body( '{}' ); // Empty JSON object @@ -794,7 +755,7 @@ public function test_empty_input_handling(): void { * * @return array */ - public function malformed_json_provider(): array { + public function data_malformed_json_provider(): array { return array( 'Missing value' => array( '{"input": }' ), 'Trailing comma in array' => array( '{"input": [1, 2, }' ), @@ -810,7 +771,7 @@ public function malformed_json_provider(): array { /** * Test malformed JSON in POST body. * - * @dataProvider malformed_json_provider + * @dataProvider data_malformed_json_provider * @param string $json Malformed JSON to test. */ public function test_malformed_json_post_body( string $json ): void { @@ -835,6 +796,9 @@ public function test_php_type_strings_in_input(): void { array( 'label' => 'Echo', 'description' => 'Echoes input', + 'input_schema' => array( + 'type' => 'object', + ), 'execute_callback' => static function ( $input ) { return array( 'echo' => $input ); }, @@ -876,6 +840,9 @@ public function test_mixed_encoding_in_input(): void { array( 'label' => 'Echo Encoding', 'description' => 'Echoes input with encoding', + 'input_schema' => array( + 'type' => 'object', + ), 'execute_callback' => static function ( $input ) { return array( 'echo' => $input ); }, @@ -914,7 +881,7 @@ public function test_mixed_encoding_in_input(): void { * * @return array */ - public function invalid_http_methods_provider(): array { + public function data_invalid_http_methods_provider(): array { return array( 'PATCH' => array( 'PATCH' ), 'PUT' => array( 'PUT' ), @@ -926,7 +893,7 @@ public function invalid_http_methods_provider(): array { /** * Test request with invalid HTTP methods. * - * @dataProvider invalid_http_methods_provider + * @dataProvider data_invalid_http_methods_provider * @param string $method HTTP method to test. */ public function test_invalid_http_methods( string $method ): void { diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index e67ffebabe65c..3ecd55244cc56 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12419,8 +12419,16 @@ mockedApiResponse.Schema = { }, "input": { "description": "Input parameters for the ability execution.", - "type": "object", - "default": [], + "type": [ + "integer", + "number", + "boolean", + "string", + "array", + "object", + "null" + ], + "default": null, "required": false } } From a2b852fecb2da6b94b0b6c6c2456fce0caeb5329 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 14 Oct 2025 15:43:16 +0200 Subject: [PATCH 06/31] Sync changes from the `v0.3.0` release --- src/wp-includes/abilities-api.php | 74 +- .../class-wp-abilities-category-registry.php | 247 ++++++ .../class-wp-abilities-registry.php | 29 +- .../class-wp-ability-category.php | 203 +++++ .../abilities-api/class-wp-ability.php | 140 +++- ...lass-wp-rest-abilities-list-controller.php | 40 +- ...class-wp-rest-abilities-run-controller.php | 22 +- src/wp-settings.php | 4 +- .../abilities-api/wpAbilitiesRegistry.php | 59 +- .../phpunit/tests/abilities-api/wpAbility.php | 289 ++++++- .../tests/abilities-api/wpAbilityCategory.php | 763 ++++++++++++++++++ .../tests/abilities-api/wpRegisterAbility.php | 73 +- .../wpRestAbilitiesListController.php | 176 +++- .../rest-api/wpRestAbilitiesRunController.php | 216 +++-- tests/qunit/fixtures/wp-api-generated.js | 5 + 15 files changed, 2239 insertions(+), 101 deletions(-) create mode 100644 src/wp-includes/abilities-api/class-wp-abilities-category-registry.php create mode 100644 src/wp-includes/abilities-api/class-wp-ability-category.php create mode 100644 tests/phpunit/tests/abilities-api/wpAbilityCategory.php diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index f898ac0084f06..6672b0c6dbaa6 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -24,18 +24,23 @@ * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase * alphanumeric characters, dashes and the forward slash. * @param array $args An associative array of arguments for the ability. This should include - * `label`, `description`, `input_schema`, `output_schema`, `execute_callback`, + * `label`, `description`, `category`, `input_schema`, `output_schema`, `execute_callback`, * `permission_callback`, `meta`, and `ability_class`. * @return ?\WP_Ability An instance of registered ability on success, null on failure. * * @phpstan-param array{ * label?: string, * description?: string, + * category?: string, * execute_callback?: callable( mixed $input= ): (mixed|\WP_Error), * permission_callback?: callable( mixed $input= ): (bool|\WP_Error), * input_schema?: array, * output_schema?: array, - * meta?: array, + * meta?: array{ + * annotations?: array, + * show_in_rest?: bool, + * ..., + * }, * ability_class?: class-string<\WP_Ability>, * ... * } $args @@ -98,3 +103,68 @@ function wp_get_ability( string $name ): ?WP_Ability { function wp_get_abilities(): array { return WP_Abilities_Registry::get_instance()->get_all_registered(); } + +/** + * Registers a new ability category. + * + * @since 0.3.0 + * + * @see WP_Abilities_Category_Registry::register() + * + * @param string $slug The unique slug for the category. Must contain only lowercase + * alphanumeric characters and dashes. + * @param array $args An associative array of arguments for the category. This should + * include `label`, `description`, and optionally `meta`. + * @return ?\WP_Ability_Category The registered category instance on success, null on failure. + * + * @phpstan-param array{ + * label: string, + * description: string, + * meta?: array, + * ... + * } $args + */ +function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_Category { + return WP_Abilities_Category_Registry::get_instance()->register( $slug, $args ); +} + +/** + * Unregisters an ability category. + * + * @since 0.3.0 + * + * @see WP_Abilities_Category_Registry::unregister() + * + * @param string $slug The slug of the registered category. + * @return ?\WP_Ability_Category The unregistered category instance on success, null on failure. + */ +function wp_unregister_ability_category( string $slug ): ?WP_Ability_Category { + return WP_Abilities_Category_Registry::get_instance()->unregister( $slug ); +} + +/** + * Retrieves a registered ability category. + * + * @since 0.3.0 + * + * @see WP_Abilities_Category_Registry::get_registered() + * + * @param string $slug The slug of the registered category. + * @return ?\WP_Ability_Category The registered category instance, or null if it is not registered. + */ +function wp_get_ability_category( string $slug ): ?WP_Ability_Category { + return WP_Abilities_Category_Registry::get_instance()->get_registered( $slug ); +} + +/** + * Retrieves all registered ability categories. + * + * @since 0.3.0 + * + * @see WP_Abilities_Category_Registry::get_all_registered() + * + * @return \WP_Ability_Category[] The array of registered categories. + */ +function wp_get_ability_categories(): array { + return WP_Abilities_Category_Registry::get_instance()->get_all_registered(); +} diff --git a/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php new file mode 100644 index 0000000000000..045a1df64407e --- /dev/null +++ b/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php @@ -0,0 +1,247 @@ + $args An associative array of arguments for the category. See wp_register_ability_category() for + * details. + * @return ?\WP_Ability_Category The registered category instance on success, null on failure. + * + * @phpstan-param array{ + * label: string, + * description: string, + * meta?: array, + * ... + * } $args + */ + public function register( string $slug, array $args ): ?WP_Ability_Category { + if ( ! doing_action( 'abilities_api_categories_init' ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: abilities_api_categories_init, 2: category slug. */ + esc_html__( 'Categories must be registered during the %1$s action. The category %2$s was not registered.' ), + 'abilities_api_categories_init', + '' . esc_html( $slug ) . '' + ), + '0.3.0' + ); + return null; + } + + if ( $this->is_registered( $slug ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Category slug. */ + esc_html( sprintf( __( 'Category "%s" is already registered.' ), $slug ) ), + '0.3.0' + ); + return null; + } + + if ( ! preg_match( '/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug ) ) { + _doing_it_wrong( + __METHOD__, + esc_html__( 'Category slug must contain only lowercase alphanumeric characters and dashes.' ), + '0.3.0' + ); + return null; + } + + /** + * Filters the category arguments before they are validated and used to instantiate the category. + * + * @since 0.3.0 + * + * @param array $args The arguments used to instantiate the category. + * @param string $slug The slug of the category. + */ + $args = apply_filters( 'register_ability_category_args', $args, $slug ); + + try { + // WP_Ability_Category::prepare_properties() will throw an exception if the properties are invalid. + $category = new WP_Ability_Category( $slug, $args ); + } catch ( \InvalidArgumentException $e ) { + _doing_it_wrong( + __METHOD__, + esc_html( $e->getMessage() ), + '0.3.0' + ); + return null; + } + + $this->registered_categories[ $slug ] = $category; + return $category; + } + + /** + * Unregisters a category. + * + * Do not use this method directly. Instead, use the `wp_unregister_ability_category()` function. + * + * @since 0.3.0 + * + * @see wp_unregister_ability_category() + * + * @param string $slug The slug of the registered category. + * @return ?\WP_Ability_Category The unregistered category instance on success, null on failure. + */ + public function unregister( string $slug ): ?WP_Ability_Category { + if ( ! $this->is_registered( $slug ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Ability category slug. */ + sprintf( esc_html__( 'Ability category "%s" not found.' ), esc_attr( $slug ) ), + '0.3.0' + ); + return null; + } + + $unregistered_category = $this->registered_categories[ $slug ]; + unset( $this->registered_categories[ $slug ] ); + + return $unregistered_category; + } + + /** + * Retrieves the list of all registered categories. + * + * Do not use this method directly. Instead, use the `wp_get_ability_categories()` function. + * + * @since 0.3.0 + * + * @see wp_get_ability_categories() + * + * @return array The array of registered categories. + */ + public function get_all_registered(): array { + return $this->registered_categories; + } + + /** + * Checks if a category is registered. + * + * @since 0.3.0 + * + * @param string $slug The slug of the category. + * @return bool True if the category is registered, false otherwise. + */ + public function is_registered( string $slug ): bool { + return isset( $this->registered_categories[ $slug ] ); + } + + /** + * Retrieves a registered category. + * + * Do not use this method directly. Instead, use the `wp_get_ability_category()` function. + * + * @since 0.3.0 + * + * @see wp_get_ability_category() + * + * @param string $slug The slug of the registered category. + * @return ?\WP_Ability_Category The registered category instance, or null if it is not registered. + */ + public function get_registered( string $slug ): ?WP_Ability_Category { + if ( ! $this->is_registered( $slug ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Ability category slug. */ + sprintf( esc_html__( 'Ability category "%s" not found.' ), esc_attr( $slug ) ), + '0.3.0' + ); + return null; + } + return $this->registered_categories[ $slug ]; + } + + /** + * Utility method to retrieve the main instance of the registry class. + * + * The instance will be created if it does not exist yet. + * + * @since 0.3.0 + * + * @return \WP_Abilities_Category_Registry The main registry instance. + */ + public static function get_instance(): self { + if ( null === self::$instance ) { + self::$instance = new self(); + + /** + * Fires when preparing ability categories registry. + * + * Categories should be registered on this action to ensure they're available when needed. + * + * @since 0.3.0 + * + * @param \WP_Abilities_Category_Registry $instance Categories registry object. + */ + do_action( 'abilities_api_categories_init', self::$instance ); + } + + return self::$instance; + } + + /** + * Wakeup magic method. + * + * @since 0.3.0 + * @throws \LogicException If the registry is unserialized. This is a security hardening measure to prevent unserialization of the registry. + */ + public function __wakeup(): void { + throw new \LogicException( self::class . ' must not be unserialized.' ); + } + + /** + * Serialization magic method. + * + * @since 0.3.0 + * @throws \LogicException If the registry is serialized. This is a security hardening measure to prevent serialization of the registry. + */ + public function __sleep(): array { + throw new \LogicException( self::class . ' must not be serialized.' ); + } +} diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index 3acf8a14ad6dd..d5f36999b384b 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -53,11 +53,16 @@ final class WP_Abilities_Registry { * @phpstan-param array{ * label?: string, * description?: string, + * category?: string, * execute_callback?: callable( mixed $input= ): (mixed|\WP_Error), * permission_callback?: callable( mixed $input= ): (bool|\WP_Error), * input_schema?: array, * output_schema?: array, - * meta?: array, + * meta?: array{ + * annotations?: array, + * show_in_rest?: bool, + * ... + * }, * ability_class?: class-string<\WP_Ability>, * ... * } $args @@ -94,6 +99,24 @@ public function register( string $name, array $args ): ?WP_Ability { */ $args = apply_filters( 'register_ability_args', $args, $name ); + // Validate category exists if provided (will be validated as required in WP_Ability). + if ( isset( $args['category'] ) ) { + $category_registry = WP_Abilities_Category_Registry::get_instance(); + if ( ! $category_registry->is_registered( $args['category'] ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %1$s: ability category slug, %2$s: ability name */ + esc_html__( 'Ability category "%1$s" is not registered. Please register the category before assigning it to ability "%2$s".' ), + esc_attr( $args['category'] ), + esc_attr( $name ) + ), + '0.3.0' + ); + return null; + } + } + // The class is only used to instantiate the ability, and is not a property of the ability itself. if ( isset( $args['ability_class'] ) && ! is_a( $args['ability_class'], WP_Ability::class, true ) ) { _doing_it_wrong( @@ -218,6 +241,10 @@ public static function get_instance(): self { if ( null === self::$instance ) { self::$instance = new self(); + // Ensure category registry is initialized first to allow categories to be registered + // before abilities that depend on them. + WP_Abilities_Category_Registry::get_instance(); + /** * Fires when preparing abilities registry. * diff --git a/src/wp-includes/abilities-api/class-wp-ability-category.php b/src/wp-includes/abilities-api/class-wp-ability-category.php new file mode 100644 index 0000000000000..734b436fab66c --- /dev/null +++ b/src/wp-includes/abilities-api/class-wp-ability-category.php @@ -0,0 +1,203 @@ + + */ + protected $meta = array(); + + /** + * Constructor. + * + * Do not use this constructor directly. Instead, use the `wp_register_ability_category()` function. + * + * @access private + * + * @since 0.3.0 + * + * @see wp_register_ability_category() + * + * @param string $slug The unique slug for the category. + * @param array $args An associative array of arguments for the category. + */ + public function __construct( string $slug, array $args ) { + if ( empty( $slug ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The category slug cannot be empty.' ) + ); + } + + $this->slug = $slug; + + $properties = $this->prepare_properties( $args ); + + foreach ( $properties as $property_name => $property_value ) { + if ( ! property_exists( $this, $property_name ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: Property name. */ + esc_html__( 'Property "%1$s" is not a valid property for category "%2$s". Please check the %3$s class for allowed properties.' ), + '' . esc_html( $property_name ) . '', + '' . esc_html( $this->slug ) . '', + '' . esc_html( self::class ) . '' + ), + '0.3.0' + ); + continue; + } + + $this->$property_name = $property_value; + } + } + + /** + * Prepares and validates the properties used to instantiate the category. + * + * @since 0.3.0 + * + * @param array $args An associative array of arguments used to instantiate the class. + * @return array The validated and prepared properties. + * @throws \InvalidArgumentException if an argument is invalid. + * + * @phpstan-return array{ + * label: string, + * description: string, + * meta?: array, + * ..., + * } + */ + protected function prepare_properties( array $args ): array { + // Required args must be present and of the correct type. + if ( empty( $args['label'] ) || ! is_string( $args['label'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The category properties must contain a `label` string.' ) + ); + } + + if ( empty( $args['description'] ) || ! is_string( $args['description'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The category properties must contain a `description` string.' ) + ); + } + + // Optional args only need to be of the correct type if they are present. + if ( isset( $args['meta'] ) && ! is_array( $args['meta'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The category properties should provide a valid `meta` array.' ) + ); + } + + return $args; + } + + /** + * Retrieves the slug of the category. + * + * @since 0.3.0 + * + * @return string The category slug. + */ + public function get_slug(): string { + return $this->slug; + } + + /** + * Retrieves the human-readable label for the category. + * + * @since 0.3.0 + * + * @return string The human-readable category label. + */ + public function get_label(): string { + return $this->label; + } + + /** + * Retrieves the detailed description for the category. + * + * @since 0.3.0 + * + * @return string The detailed description for the category. + */ + public function get_description(): string { + return $this->description; + } + + /** + * Retrieves the metadata for the category. + * + * @since 0.3.0 + * + * @return array The metadata for the category. + */ + public function get_meta(): array { + return $this->meta; + } + + /** + * Wakeup magic method. + * + * @since 0.3.0 + * @throws \LogicException If the category is unserialized. This is a security hardening measure to prevent unserialization of the category. + */ + public function __wakeup(): void { + throw new \LogicException( self::class . ' must not be unserialized.' ); + } + + /** + * Serialization magic method. + * + * @since 0.3.0 + * @throws \LogicException If the category is serialized. This is a security hardening measure to prevent serialization of the category. + */ + public function __sleep(): array { + throw new \LogicException( self::class . ' must not be serialized.' ); + } +} diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 78ac5c3ea5e01..e31ce5ba0b315 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -20,6 +20,38 @@ */ class WP_Ability { + /** + * The default value for the `show_in_rest` meta. + * + * @since 0.3.0 + * @var bool + */ + protected const DEFAULT_SHOW_IN_REST = false; + + /** + * The default ability annotations. + * They are not guaranteed to provide a faithful description of ability behavior. + * + * @since 0.3.0 + * @var array + */ + protected static $default_annotations = array( + // Instructions on how to use the ability. + 'instructions' => '', + // If true, the ability does not modify its environment. + 'readonly' => false, + /* + * If true, the ability may perform destructive updates to its environment. + * If false, the ability performs only additive updates. + */ + 'destructive' => true, + /* + * If true, calling the ability repeatedly with the same arguments will have no additional effect + * on its environment. + */ + 'idempotent' => false, + ); + /** * The name of the ability, with its namespace. * Example: `my-plugin/my-ability`. @@ -83,7 +115,15 @@ class WP_Ability { * @since 0.1.0 * @var array */ - protected $meta = array(); + protected $meta; + + /** + * The ability category (required). + * + * @since 0.3.0 + * @var string + */ + protected $category; /** * Constructor. @@ -97,9 +137,9 @@ class WP_Ability { * @see wp_register_ability() * * @param string $name The name of the ability, with its namespace. - * @param array $args An associative array of arguments for the ability. This should - * include `label`, `description`, `input_schema`, `output_schema`, - * `execute_callback`, `permission_callback`, and `meta`. + * @param array $args An associative array of arguments for the ability. This should include: + * `label`, `description`, `category`, `input_schema`, `output_schema`, + * `execute_callback`, `permission_callback` and `meta` */ public function __construct( string $name, array $args ) { $this->name = $name; @@ -143,11 +183,16 @@ public function __construct( string $name, array $args ) { * @phpstan-return array{ * label: string, * description: string, + * category: string, * execute_callback: callable( mixed $input= ): (mixed|\WP_Error), * permission_callback: callable( mixed $input= ): (bool|\WP_Error), * input_schema?: array, * output_schema?: array, - * meta?: array, + * meta?: array{ + * annotations?: array, + * show_in_rest?: bool, + * ... + * }, * ..., * } $args */ @@ -165,6 +210,12 @@ protected function prepare_properties( array $args ): array { ); } + if ( empty( $args['category'] ) || ! is_string( $args['category'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The ability properties must contain a `category` string.' ) + ); + } + if ( empty( $args['execute_callback'] ) || ! is_callable( $args['execute_callback'] ) ) { throw new \InvalidArgumentException( esc_html__( 'The ability properties must contain a valid `execute_callback` function.' ) @@ -196,6 +247,31 @@ protected function prepare_properties( array $args ): array { ); } + if ( isset( $args['meta']['annotations'] ) && ! is_array( $args['meta']['annotations'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The ability meta should provide a valid `annotations` array.' ) + ); + } + + if ( isset( $args['meta']['show_in_rest'] ) && ! is_bool( $args['meta']['show_in_rest'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The ability meta should provide a valid `show_in_rest` boolean.' ) + ); + } + + // Set defaults for optional meta. + $args['meta'] = wp_parse_args( + $args['meta'] ?? array(), + array( + 'annotations' => static::$default_annotations, + 'show_in_rest' => self::DEFAULT_SHOW_IN_REST, + ) + ); + $args['meta']['annotations'] = wp_parse_args( + $args['meta']['annotations'], + static::$default_annotations + ); + return $args; } @@ -266,6 +342,30 @@ public function get_meta(): array { return $this->meta; } + /** + * Retrieves the category for the ability. + * + * @since 0.3.0 + * + * @return string The category for the ability. + */ + public function get_category(): string { + return $this->category; + } + + /** + * Retrieves a specific metadata item for the ability. + * + * @since 0.3.0 + * + * @param string $key The metadata key to retrieve. + * @param mixed $default_value Optional. The default value to return if the metadata item is not found. Default `null`. + * @return mixed The value of the metadata item, or the default value if not found. + */ + public function get_meta_item( string $key, $default_value = null ) { + return array_key_exists( $key, $this->meta ) ? $this->meta[ $key ] : $default_value; + } + /** * Validates input data against the input schema. * @@ -307,6 +407,24 @@ protected function validate_input( $input = null ) { return true; } + /** + * Invokes a callable, ensuring the input is passed through only if the input schema is defined. + * + * @since 0.3.0 + * + * @param callable $callback The callable to invoke. + * @param mixed $input Optional. The input data for the ability. Default `null`. + * @return mixed The result of the callable execution. + */ + protected function invoke_callback( callable $callback, $input = null ) { + $args = array(); + if ( ! empty( $this->get_input_schema() ) ) { + $args[] = $input; + } + + return $callback( ...$args ); + } + /** * Checks whether the ability has the necessary permissions. * @@ -323,11 +441,7 @@ public function check_permissions( $input = null ) { return $is_valid; } - if ( empty( $this->get_input_schema() ) ) { - return call_user_func( $this->permission_callback ); - } - - return call_user_func( $this->permission_callback, $input ); + return $this->invoke_callback( $this->permission_callback, $input ); } /** @@ -365,11 +479,7 @@ protected function do_execute( $input = null ) { ); } - if ( empty( $this->get_input_schema() ) ) { - return call_user_func( $this->execute_callback ); - } - - return call_user_func( $this->execute_callback, $input ); + return $this->invoke_callback( $this->execute_callback, $input ); } /** diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php index 1cd0f1cac0ce4..54e393f458e64 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php @@ -94,7 +94,25 @@ public function register_routes(): void { * @return \WP_REST_Response Response object on success. */ public function get_items( $request ) { - $abilities = wp_get_abilities(); + $abilities = array_filter( + wp_get_abilities(), + static function ( $ability ) { + return $ability->get_meta_item( 'show_in_rest' ); + } + ); + + // Filter by category if specified. + $category = $request->get_param( 'category' ); + if ( ! empty( $category ) ) { + $abilities = array_filter( + $abilities, + static function ( $ability ) use ( $category ) { + return $ability->get_category() === $category; + } + ); + // Reset array keys after filtering. + $abilities = array_values( $abilities ); + } // Handle pagination with explicit defaults. $params = $request->get_params(); @@ -105,7 +123,7 @@ public function get_items( $request ) { $total_abilities = count( $abilities ); $max_pages = ceil( $total_abilities / $per_page ); - if ( $request->is_method( 'HEAD' ) ) { + if ( $request->get_method() === 'HEAD' ) { $response = new \WP_REST_Response( array() ); } else { $abilities = array_slice( $abilities, $offset, $per_page ); @@ -150,8 +168,7 @@ public function get_items( $request ) { */ public function get_item( $request ) { $ability = wp_get_ability( $request->get_param( 'name' ) ); - - if ( ! $ability ) { + if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) { return new \WP_Error( 'rest_ability_not_found', __( 'Ability not found.' ), @@ -189,6 +206,7 @@ public function prepare_item_for_response( $ability, $request ) { 'name' => $ability->get_name(), 'label' => $ability->get_label(), 'description' => $ability->get_description(), + 'category' => $ability->get_category(), 'input_schema' => $ability->get_input_schema(), 'output_schema' => $ability->get_output_schema(), 'meta' => $ability->get_meta(), @@ -252,6 +270,12 @@ public function get_item_schema(): array { 'context' => array( 'view', 'edit' ), 'readonly' => true, ), + 'category' => array( + 'description' => __( 'Category this ability belongs to.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), 'input_schema' => array( 'description' => __( 'JSON Schema for the ability input.' ), 'type' => 'object', @@ -271,7 +295,7 @@ public function get_item_schema(): array { 'readonly' => true, ), ), - 'required' => array( 'name', 'label', 'description' ), + 'required' => array( 'name', 'label', 'meta', 'description', 'category', 'input_schema', 'output_schema' ), ); return $this->add_additional_fields_schema( $schema ); @@ -304,6 +328,12 @@ public function get_collection_params(): array { 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), + 'category' => array( + 'description' => __( 'Limit results to abilities in specific category.' ), + 'type' => 'string', + 'sanitize_callback' => 'sanitize_key', + 'validate_callback' => 'rest_validate_request_arg', + ), ); } } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php index a54003a5b8743..5fdbfea4a65e4 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php @@ -54,8 +54,8 @@ public function register_routes(): void { ), ), - // TODO: We register ALLMETHODS because at route registration time, we don't know - // which abilities exist or their types (resource vs tool). This is due to WordPress + // TODO: We register ALLMETHODS because at route registration time, we don't know which abilities + // exist or their annotations (`destructive`, `idempotent`, `readonly`). This is due to WordPress // load order - routes are registered early, before plugins have registered their abilities. // This approach works but could be improved with lazy route registration or a different // architecture that allows type-specific routes after abilities are registered. @@ -90,23 +90,23 @@ public function run_ability_with_method_check( $request ) { ); } - // Check if the HTTP method matches the ability type. - $meta = $ability->get_meta(); - $type = isset( $meta['type'] ) ? $meta['type'] : 'tool'; - $method = $request->get_method(); + // Check if the HTTP method matches the ability annotations. + $annotations = $ability->get_meta_item( 'annotations' ); + $is_readonly = ! empty( $annotations['readonly'] ); + $method = $request->get_method(); - if ( 'resource' === $type && 'GET' !== $method ) { + if ( $is_readonly && 'GET' !== $method ) { return new \WP_Error( 'rest_ability_invalid_method', - __( 'Resource abilities require GET method.' ), + __( 'Read-only abilities require GET method.' ), array( 'status' => 405 ) ); } - if ( 'tool' === $type && 'POST' !== $method ) { + if ( ! $is_readonly && 'POST' !== $method ) { return new \WP_Error( 'rest_ability_invalid_method', - __( 'Tool abilities require POST method.' ), + __( 'Abilities that perform updates require POST method.' ), array( 'status' => 405 ) ); } @@ -154,7 +154,7 @@ public function run_ability( $request ) { */ public function run_ability_permissions_check( $request ) { $ability = wp_get_ability( $request->get_param( 'name' ) ); - if ( ! $ability ) { + if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) { return new \WP_Error( 'rest_ability_not_found', __( 'Ability not found.' ), diff --git a/src/wp-settings.php b/src/wp-settings.php index 7330228bdb36b..bbc0745b84ee2 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -285,9 +285,11 @@ require ABSPATH . WPINC . '/nav-menu.php'; require ABSPATH . WPINC . '/admin-bar.php'; require ABSPATH . WPINC . '/class-wp-application-passwords.php'; +require ABSPATH . WPINC . '/abilities-api/class-wp-ability-category.php'; +require ABSPATH . WPINC . '/abilities-api/class-wp-abilities-category-registry.php'; require ABSPATH . WPINC . '/abilities-api/class-wp-ability.php'; require ABSPATH . WPINC . '/abilities-api/class-wp-abilities-registry.php'; -require ABSPATH . WPINC . '/abilities-api/abilities-api.php'; +require ABSPATH . WPINC . '/abilities-api.php'; require ABSPATH . WPINC . '/rest-api.php'; require ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php'; require ABSPATH . WPINC . '/rest-api/class-wp-rest-response.php'; diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php index 94f46863e3394..5700093368723 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -29,9 +29,29 @@ public function set_up(): void { remove_all_filters( 'register_ability_args' ); + // Register category during the hook. + add_action( + 'abilities_api_categories_init', + function () { + if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( 'math' ) ) { + wp_register_ability_category( + 'math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations and calculations.', + ) + ); + } + } + ); + + // Fire the hook to allow category registration. + do_action( 'abilities_api_categories_init' ); + self::$test_ability_args = array( 'label' => 'Add numbers', 'description' => 'Calculates the result of adding two numbers.', + 'category' => 'math', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -60,7 +80,7 @@ public function set_up(): void { return true; }, 'meta' => array( - 'category' => 'math', + 'foo' => 'bar', ), ); } @@ -73,6 +93,12 @@ public function tear_down(): void { remove_all_filters( 'register_ability_args' ); + // Clean up registered categories. + $category_registry = WP_Abilities_Category_Registry::get_instance(); + if ( $category_registry->is_registered( 'math' ) ) { + wp_unregister_ability_category( 'math' ); + } + parent::tear_down(); } @@ -266,6 +292,22 @@ public function test_register_incorrect_output_schema_type() { $this->assertNull( $result ); } + + /** + * Should reject ability registration with invalid `annotations` type. + * + * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_annotations_type() { + self::$test_ability_args['meta']['annotations'] = false; + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + $this->assertNull( $result ); + } + /** * Should reject ability registration with invalid meta type. * @@ -281,6 +323,21 @@ public function test_register_invalid_meta_type() { $this->assertNull( $result ); } + /** + * Should reject ability registration with invalid show in REST type. + * + * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_show_in_rest_type() { + self::$test_ability_args['meta']['show_in_rest'] = 5; + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + $this->assertNull( $result ); + } + /** * Should reject registration for already registered ability. * diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php index 961a40f8fcd90..17c16e7b60f1b 100644 --- a/tests/phpunit/tests/abilities-api/wpAbility.php +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -18,23 +18,239 @@ class Tests_Abilities_API_WpAbility extends WP_UnitTestCase { public function set_up(): void { parent::set_up(); + // Register category during the hook. + add_action( + 'abilities_api_categories_init', + function () { + if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( 'math' ) ) { + wp_register_ability_category( + 'math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations and calculations.', + ) + ); + } + } + ); + + // Fire the hook to allow category registration. + do_action( 'abilities_api_categories_init' ); + self::$test_ability_properties = array( 'label' => 'Calculator', 'description' => 'Calculates the result of math operations.', + 'category' => 'math', 'output_schema' => array( 'type' => 'number', 'description' => 'The result of performing a math operation.', 'required' => true, ), + 'execute_callback' => static function (): int { + return 0; + }, 'permission_callback' => static function (): bool { return true; }, 'meta' => array( - 'category' => 'math', + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + ), + ), + ); + } + + /** + * Tear down after each test. + */ + public function tear_down(): void { + // Clean up registered categories. + $category_registry = WP_Abilities_Category_Registry::get_instance(); + if ( $category_registry->is_registered( 'math' ) ) { + wp_unregister_ability_category( 'math' ); + } + + parent::tear_down(); + } + + /* + * Tests that getting non-existing metadata item returns default value. + */ + public function test_meta_get_non_existing_item_returns_default() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertNull( + $ability->get_meta_item( 'non_existing' ), + 'Non-existing metadata item should return null.' + ); + } + + /** + * Tests that getting non-existing metadata item with custom default returns that default. + */ + public function test_meta_get_non_existing_item_with_custom_default() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertSame( + 'default_value', + $ability->get_meta_item( 'non_existing', 'default_value' ), + 'Non-existing metadata item should return custom default value.' + ); + } + + /** + * Tests getting all annotations when selective overrides are applied. + */ + public function test_get_merged_annotations_from_meta() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertEquals( + array_merge( + self::$test_ability_properties['meta']['annotations'], + array( + 'instructions' => '', + 'idempotent' => false, + ) + ), + $ability->get_meta_item( 'annotations' ) + ); + } + + /** + * Tests getting default annotations when not provided. + */ + public function test_get_default_annotations_from_meta() { + $args = self::$test_ability_properties; + unset( $args['meta']['annotations'] ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertSame( + array( + 'instructions' => '', + 'readonly' => false, + 'destructive' => true, + 'idempotent' => false, ), + $ability->get_meta_item( 'annotations' ) ); } + /** + * Tests getting all annotations when values overridden. + */ + public function test_get_overridden_annotations_from_meta() { + $annotations = array( + 'instructions' => 'Enjoy responsibly.', + 'readonly' => true, + 'destructive' => false, + 'idempotent' => false, + ); + $args = array_merge( + self::$test_ability_properties, + array( + 'meta' => array( + 'annotations' => $annotations, + ), + ) + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertSame( $annotations, $ability->get_meta_item( 'annotations' ) ); + } + + /** + * Tests that invalid `annotations` value throws an exception. + */ + public function test_annotations_from_meta_throws_exception() { + $args = array_merge( + self::$test_ability_properties, + array( + 'meta' => array( + 'annotations' => 5, + ), + ) + ); + + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'The ability meta should provide a valid `annotations` array.' ); + + new WP_Ability( self::$test_ability_name, $args ); + } + + /** + * Tests that `show_in_rest` metadata defaults to false when not provided. + */ + public function test_meta_show_in_rest_defaults_to_false() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertFalse( + $ability->get_meta_item( 'show_in_rest' ), + '`show_in_rest` metadata should default to false.' + ); + } + + /** + * Tests that `show_in_rest` metadata can be set to true. + */ + public function test_meta_show_in_rest_can_be_set_to_true() { + $args = array_merge( + self::$test_ability_properties, + array( + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertTrue( + $ability->get_meta_item( 'show_in_rest' ), + '`show_in_rest` metadata should be true.' + ); + } + + /** + * Tests that `show_in_rest` can be set to false. + */ + public function test_show_in_rest_can_be_set_to_false() { + $args = array_merge( + self::$test_ability_properties, + array( + 'meta' => array( + 'show_in_rest' => false, + ), + ) + ); + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertFalse( + $ability->get_meta_item( 'show_in_rest' ), + '`show_in_rest` metadata should be false.' + ); + } + + /** + * Tests that invalid `show_in_rest` value throws an exception. + */ + public function test_show_in_rest_throws_exception() { + $args = array_merge( + self::$test_ability_properties, + array( + 'meta' => array( + 'show_in_rest' => 5, + ), + ) + ); + + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'The ability meta should provide a valid `show_in_rest` boolean.' ); + + new WP_Ability( self::$test_ability_name, $args ); + } + /** * Data provider for testing the execution of the ability. */ @@ -166,6 +382,74 @@ public function test_execute_input( $input_schema, $execute_callback, $input, $r $this->assertSame( $result, $ability->execute( $input ) ); } + /** + * A static method to be used as a callback in tests. + * + * @param string $input An input string. + * @return int The length of the input string. + */ + public static function my_static_execute_callback( string $input ): int { + return strlen( $input ); + } + + /** + * An instance method to be used as a callback in tests. + * + * @param string $input An input string. + * @return int The length of the input string. + */ + public function my_instance_execute_callback( string $input ): int { + return strlen( $input ); + } + + /** + * Data provider for testing different types of execute callbacks. + */ + public function data_execute_callback() { + return array( + 'function name string' => array( + 'strlen', + ), + 'closure' => array( + static function ( string $input ): int { + return strlen( $input ); + }, + ), + 'static class method string' => array( + 'Tests_Abilities_API_WpAbility::my_static_execute_callback', + ), + 'static class method array' => array( + array( 'Tests_Abilities_API_WpAbility', 'my_static_execute_callback' ), + ), + 'object method' => array( + array( $this, 'my_instance_execute_callback' ), + ), + ); + } + + /** + * Tests the execution of the ability with different types of callbacks. + * + * @dataProvider data_execute_callback + */ + public function test_execute_with_different_callbacks( $execute_callback ) { + $args = array_merge( + self::$test_ability_properties, + array( + 'input_schema' => array( + 'type' => 'string', + 'description' => 'Test input string.', + 'required' => true, + ), + 'execute_callback' => $execute_callback, + ) + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertSame( 6, $ability->execute( 'hello!' ) ); + } + /** * Tests the execution of the ability with no input. */ @@ -346,9 +630,6 @@ public function test_actions_not_fired_on_permission_failure() { 'permission_callback' => static function (): bool { return false; }, - 'execute_callback' => static function (): int { - return 42; - }, ) ); diff --git a/tests/phpunit/tests/abilities-api/wpAbilityCategory.php b/tests/phpunit/tests/abilities-api/wpAbilityCategory.php new file mode 100644 index 0000000000000..c82056c430526 --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpAbilityCategory.php @@ -0,0 +1,763 @@ + + */ + private $doing_it_wrong_log = array(); + + /** + * Set up before each test. + */ + public function set_up(): void { + parent::set_up(); + + $this->registry = WP_Abilities_Category_Registry::get_instance(); + $this->doing_it_wrong_log = array(); + + add_action( 'doing_it_wrong_run', array( $this, 'record_doing_it_wrong' ), 10, 3 ); + } + + /** + * Tear down after each test. + */ + public function tear_down(): void { + remove_action( 'doing_it_wrong_run', array( $this, 'record_doing_it_wrong' ) ); + $this->doing_it_wrong_log = array(); + + // Clean up all test categories. + $categories = $this->registry->get_all_registered(); + foreach ( $categories as $category ) { + if ( 0 !== strpos( $category->get_slug(), 'test-' ) ) { + continue; + } + $this->registry->unregister( $category->get_slug() ); + } + + parent::tear_down(); + } + + /** + * Records `_doing_it_wrong` calls for later assertions. + * + * @param string $the_method Function name flagged by `_doing_it_wrong`. + * @param string $message Message supplied to `_doing_it_wrong`. + * @param string $version Version string supplied to `_doing_it_wrong`. + */ + public function record_doing_it_wrong( string $the_method, string $message, string $version ): void { + $this->doing_it_wrong_log[] = array( + 'function' => $the_method, + 'message' => $message, + 'version' => $version, + ); + } + + /** + * Asserts that `_doing_it_wrong` was triggered for the expected function. + * + * @param string $the_method Function name expected to trigger `_doing_it_wrong`. + * @param string|null $message_contains Optional. String that should be contained in the error message. + */ + private function assertDoingItWrongTriggered( string $the_method, ?string $message_contains = null ): void { + foreach ( $this->doing_it_wrong_log as $entry ) { + if ( $the_method === $entry['function'] ) { + // If message check is specified, verify it contains the expected text. + if ( null !== $message_contains && false === strpos( $entry['message'], $message_contains ) ) { + continue; + } + return; + } + } + + if ( null !== $message_contains ) { + $this->fail( + sprintf( + 'Failed asserting that _doing_it_wrong() was triggered for %s with message containing "%s".', + $the_method, + $message_contains + ) + ); + } else { + $this->fail( sprintf( 'Failed asserting that _doing_it_wrong() was triggered for %s.', $the_method ) ); + } + } + + /** + * Helper to register a category during the hook. + */ + private function register_category_during_hook( string $slug, array $args ): ?WP_Ability_Category { + $result = null; + $callback = static function () use ( $slug, $args, &$result ): void { + $result = wp_register_ability_category( $slug, $args ); + }; + + add_action( 'abilities_api_categories_init', $callback ); + do_action( 'abilities_api_categories_init', WP_Abilities_Category_Registry::get_instance() ); + remove_action( 'abilities_api_categories_init', $callback ); + + return $result; + } + + /** + * Test registering a valid category. + */ + public function test_register_valid_category(): void { + $result = $this->register_category_during_hook( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( 'test-math', $result->get_slug() ); + $this->assertSame( 'Math', $result->get_label() ); + $this->assertSame( 'Mathematical operations.', $result->get_description() ); + } + + /** + * Test registering category with invalid slug format. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_register_category_invalid_slug_format(): void { + // Uppercase characters not allowed. + $result = $this->register_category_during_hook( + 'Test-Math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'slug must contain only lowercase' ); + } + + /** + * Test registering category with invalid slug - underscore. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_register_category_invalid_slug_underscore(): void { + $result = $this->register_category_during_hook( + 'test_math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'slug must contain only lowercase' ); + } + + /** + * Test registering category without label. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_register_category_missing_label(): void { + $result = $this->register_category_during_hook( + 'test-math', + array( + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + } + + /** + * Test registering category without description. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_register_category_missing_description(): void { + $result = $this->register_category_during_hook( + 'test-math', + array( + 'label' => 'Math', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + } + + /** + * Test registering category before abilities_api_categories_init hook. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_register_category_before_init_hook(): void { + global $wp_actions; + + // Store original count. + $original_count = isset( $wp_actions['abilities_api_categories_init'] ) ? $wp_actions['abilities_api_categories_init'] : 0; + + // Reset to simulate hook not fired. + unset( $wp_actions['abilities_api_categories_init'] ); + + $result = wp_register_ability_category( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + // Restore original count. + if ( $original_count > 0 ) { + $wp_actions['abilities_api_categories_init'] = $original_count; + } + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'abilities_api_categories_init' ); + } + + /** + * Test registering duplicate category. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_register_duplicate_category(): void { + $result = null; + $callback = static function () use ( &$result ): void { + wp_register_ability_category( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $result = wp_register_ability_category( + 'test-math', + array( + 'label' => 'Math 2', + 'description' => 'Another math category.', + ) + ); + }; + + add_action( 'abilities_api_categories_init', $callback ); + do_action( 'abilities_api_categories_init', WP_Abilities_Category_Registry::get_instance() ); + remove_action( 'abilities_api_categories_init', $callback ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'already registered' ); + } + + /** + * Test unregistering existing category. + */ + public function test_unregister_existing_category(): void { + $this->register_category_during_hook( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $result = wp_unregister_ability_category( 'test-math' ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertFalse( $this->registry->is_registered( 'test-math' ) ); + } + + /** + * Test unregistering non-existent category. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::unregister + */ + public function test_unregister_nonexistent_category(): void { + $result = wp_unregister_ability_category( 'test-nonexistent' ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::unregister' ); + } + + /** + * Test retrieving existing category. + */ + public function test_get_existing_category(): void { + $this->register_category_during_hook( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $result = wp_get_ability_category( 'test-math' ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( 'test-math', $result->get_slug() ); + } + + /** + * Test retrieving non-existent category. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::get_registered + */ + public function test_get_nonexistent_category(): void { + $result = wp_get_ability_category( 'test-nonexistent' ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::get_registered' ); + } + + /** + * Test retrieving all registered categories. + */ + public function test_get_all_categories(): void { + $this->register_category_during_hook( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $this->register_category_during_hook( + 'test-system', + array( + 'label' => 'System', + 'description' => 'System operations.', + ) + ); + + $categories = wp_get_ability_categories(); + + $this->assertIsArray( $categories ); + $this->assertCount( 2, $categories ); + $this->assertArrayHasKey( 'test-math', $categories ); + $this->assertArrayHasKey( 'test-system', $categories ); + } + + /** + * Test category is_registered method. + */ + public function test_category_is_registered(): void { + $this->assertFalse( $this->registry->is_registered( 'test-math' ) ); + + $this->register_category_during_hook( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertTrue( $this->registry->is_registered( 'test-math' ) ); + } + + /** + * Test ability can only be registered with existing category. + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_ability_requires_existing_category(): void { + do_action( 'abilities_api_init' ); + + // Ensure category doesn't exist - test should fail if it does. + $this->assertFalse( + WP_Abilities_Category_Registry::get_instance()->is_registered( 'test-nonexistent' ), + 'The test-nonexistent category should not be registered - test isolation may be broken' + ); + + // Try to register ability with non-existent category. + $result = wp_register_ability( + 'test/calculator', + array( + 'label' => 'Calculator', + 'description' => 'Performs calculations.', + 'category' => 'test-nonexistent', + 'execute_callback' => static function () { + return 42; + }, + 'permission_callback' => '__return_true', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Registry::register', 'not registered' ); + } + + /** + * Test ability can be registered with valid category. + */ + public function test_ability_with_valid_category(): void { + $category_callback = static function (): void { + wp_register_ability_category( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + }; + + add_action( 'abilities_api_categories_init', $category_callback ); + do_action( 'abilities_api_categories_init', WP_Abilities_Category_Registry::get_instance() ); + remove_action( 'abilities_api_categories_init', $category_callback ); + do_action( 'abilities_api_init' ); + + $result = wp_register_ability( + 'test/calculator', + array( + 'label' => 'Calculator', + 'description' => 'Performs calculations.', + 'category' => 'test-math', + 'execute_callback' => static function () { + return 42; + }, + 'permission_callback' => '__return_true', + ) + ); + + $this->assertInstanceOf( WP_Ability::class, $result ); + $this->assertSame( 'test-math', $result->get_category() ); + + // Cleanup. + wp_unregister_ability( 'test/calculator' ); + } + + /** + * Test category registry singleton. + */ + public function test_category_registry_singleton(): void { + $instance1 = WP_Abilities_Category_Registry::get_instance(); + $instance2 = WP_Abilities_Category_Registry::get_instance(); + + $this->assertSame( $instance1, $instance2 ); + } + + /** + * Test category with special characters in label and description. + */ + public function test_category_with_special_characters(): void { + $result = $this->register_category_during_hook( + 'test-special', + array( + 'label' => 'Math & Science ', + 'description' => 'Operations with "quotes" and \'apostrophes\'.', + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( 'Math & Science ', $result->get_label() ); + $this->assertSame( 'Operations with "quotes" and \'apostrophes\'.', $result->get_description() ); + } + + /** + * Data provider for valid category slugs. + * + * @return array> + */ + public function valid_slug_provider(): array { + return array( + array( 'test-simple' ), + array( 'test-multiple-words' ), + array( 'test-with-numbers-123' ), + array( 'test-a' ), + array( 'test-123' ), + ); + } + + /** + * Test category slug validation with valid formats. + * + * @dataProvider valid_slug_provider + */ + public function test_category_slug_valid_formats( string $slug ): void { + $result = $this->register_category_during_hook( + $slug, + array( + 'label' => 'Test', + 'description' => 'Test description.', + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result, "Slug '{$slug}' should be valid" ); + } + + /** + * Data provider for invalid category slugs. + * + * @return array> + */ + public function invalid_slug_provider(): array { + return array( + array( 'Test-Uppercase' ), + array( 'test_underscore' ), + array( 'test.dot' ), + array( 'test/slash' ), + array( 'test space' ), + array( '-test-start-dash' ), + array( 'test-end-dash-' ), + array( 'test--double-dash' ), + ); + } + + /** + * Test category slug validation with invalid formats. + * + * @dataProvider invalid_slug_provider + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_category_slug_invalid_formats( string $slug ): void { + $result = $this->register_category_during_hook( + $slug, + array( + 'label' => 'Test', + 'description' => 'Test description.', + ) + ); + + $this->assertNull( $result, "Slug '{$slug}' should be invalid" ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + } + + /** + * Test registering category with non-string label. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_category_constructor_non_string_label(): void { + $result = $this->register_category_during_hook( + 'test-invalid', + array( + 'label' => 123, // Integer instead of string + 'description' => 'Valid description.', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + } + + /** + * Test registering category with empty label. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_category_constructor_empty_label(): void { + $result = $this->register_category_during_hook( + 'test-invalid', + array( + 'label' => '', + 'description' => 'Valid description.', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + } + + /** + * Test registering category with non-string description. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_category_constructor_non_string_description(): void { + $result = $this->register_category_during_hook( + 'test-invalid', + array( + 'label' => 'Valid Label', + 'description' => array( 'invalid' ), // Array instead of string + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + } + + /** + * Test registering category with empty description. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_category_constructor_empty_description(): void { + $result = $this->register_category_during_hook( + 'test-invalid', + array( + 'label' => 'Valid Label', + 'description' => '', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + } + + /** + * Test register_ability_category_args filter. + */ + public function test_register_category_args_filter(): void { + add_filter( + 'register_ability_category_args', + static function ( $args, $slug ) { + if ( 'test-filtered' === $slug ) { + $args['label'] = 'Filtered Label'; + $args['description'] = 'Filtered Description'; + } + return $args; + }, + 10, + 2 + ); + + $result = $this->register_category_during_hook( + 'test-filtered', + array( + 'label' => 'Original Label', + 'description' => 'Original Description.', + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( 'Filtered Label', $result->get_label() ); + $this->assertSame( 'Filtered Description', $result->get_description() ); + } + + /** + * Test that WP_Ability_Category cannot be unserialized. + */ + public function test_category_wakeup_throws_exception(): void { + $category = $this->register_category_during_hook( + 'test-serialize', + array( + 'label' => 'Test', + 'description' => 'Test description.', + ) + ); + + $this->expectException( \LogicException::class ); + $serialized = serialize( $category ); + unserialize( $serialized ); + } + + /** + * Test registering a category with valid meta. + */ + public function test_register_category_with_valid_meta(): void { + $meta = array( + 'icon' => 'dashicons-calculator', + 'priority' => 10, + 'custom' => array( 'key' => 'value' ), + ); + + $result = $this->register_category_during_hook( + 'test-meta', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + 'meta' => $meta, + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( 'test-meta', $result->get_slug() ); + $this->assertSame( $meta, $result->get_meta() ); + } + + /** + * Test registering a category with empty meta array. + */ + public function test_register_category_with_empty_meta(): void { + $result = $this->register_category_during_hook( + 'test-empty-meta', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + 'meta' => array(), + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( array(), $result->get_meta() ); + } + + /** + * Test registering a category without meta returns empty array. + */ + public function test_register_category_without_meta_returns_empty_array(): void { + $result = $this->register_category_during_hook( + 'test-no-meta', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( array(), $result->get_meta() ); + } + + /** + * Test registering a category with invalid meta (non-array). + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + */ + public function test_register_category_with_invalid_meta(): void { + $result = $this->register_category_during_hook( + 'test-invalid-meta', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + 'meta' => 'invalid-string', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'valid `meta` array' ); + } + + /** + * Test registering a category with unknown property triggers _doing_it_wrong. + * + * @expectedIncorrectUsage WP_Ability_Category::__construct + */ + public function test_register_category_with_unknown_property(): void { + $result = $this->register_category_during_hook( + 'test-unknown-property', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + 'unknown_property' => 'some value', + ) + ); + + // Category should still be created. + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + // But _doing_it_wrong should be triggered. + $this->assertDoingItWrongTriggered( 'WP_Ability_Category::__construct', 'not a valid property' ); + } +} diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index a292a11896c8a..04a39a210ef5f 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -30,9 +30,29 @@ class Test_Abilities_API_WpRegisterAbility extends WP_UnitTestCase { public function set_up(): void { parent::set_up(); + // Register category during the hook. + add_action( + 'abilities_api_categories_init', + function () { + if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( 'math' ) ) { + wp_register_ability_category( + 'math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations and calculations.', + ) + ); + } + } + ); + + // Fire the hook to allow category registration. + do_action( 'abilities_api_categories_init' ); + self::$test_ability_args = array( 'label' => 'Add numbers', 'description' => 'Calculates the result of adding two numbers.', + 'category' => 'math', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -61,7 +81,11 @@ public function set_up(): void { return true; }, 'meta' => array( - 'category' => 'math', + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + ), + 'show_in_rest' => true, ), ); } @@ -78,6 +102,12 @@ public function tear_down(): void { wp_unregister_ability( $ability->get_name() ); } + // Clean up registered categories. + $category_registry = WP_Abilities_Category_Registry::get_instance(); + if ( $category_registry->is_registered( 'math' ) ) { + wp_unregister_ability_category( 'math' ); + } + parent::tear_down(); } @@ -126,13 +156,28 @@ public function test_register_valid_ability(): void { $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); + $expected_annotations = array_merge( + self::$test_ability_args['meta']['annotations'], + array( + 'instructions' => '', + 'idempotent' => false, + ) + ); + $expected_meta = array_merge( + self::$test_ability_args['meta'], + array( + 'annotations' => $expected_annotations, + 'show_in_rest' => true, + ) + ); + $this->assertInstanceOf( WP_Ability::class, $result ); $this->assertSame( self::$test_ability_name, $result->get_name() ); $this->assertSame( self::$test_ability_args['label'], $result->get_label() ); $this->assertSame( self::$test_ability_args['description'], $result->get_description() ); $this->assertSame( self::$test_ability_args['input_schema'], $result->get_input_schema() ); $this->assertSame( self::$test_ability_args['output_schema'], $result->get_output_schema() ); - $this->assertSame( self::$test_ability_args['meta'], $result->get_meta() ); + $this->assertEquals( $expected_meta, $result->get_meta() ); $this->assertTrue( $result->check_permissions( array( @@ -451,4 +496,28 @@ public function test_get_all_registered_abilities() { $result = wp_get_abilities(); $this->assertEquals( $expected, $result ); } + + /** + * Tests registering an ability with non-existent category. + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_ability_nonexistent_category(): void { + do_action( 'abilities_api_init' ); + + // Ensure category doesn't exist - test should fail if it does. + $this->assertFalse( + WP_Abilities_Category_Registry::get_instance()->is_registered( 'nonexistent' ), + 'The nonexistent category should not be registered - test isolation may be broken' + ); + + $args = array_merge( + self::$test_ability_args, + array( 'category' => 'nonexistent' ) + ); + + $result = wp_register_ability( self::$test_ability_name, $args ); + + $this->assertNull( $result, 'Should return null when category does not exist' ); + } } diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php index 319e26e0e8499..24093c4655847 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php @@ -50,6 +50,13 @@ public function set_up(): void { do_action( 'rest_api_init' ); + // Register test categories during the hook + add_action( + 'abilities_api_categories_init', + array( $this, 'register_test_categories' ) + ); + do_action( 'abilities_api_categories_init' ); + // Initialize abilities API do_action( 'abilities_api_init' ); @@ -73,6 +80,15 @@ public function tear_down(): void { wp_unregister_ability( $ability->get_name() ); } + // Clean up test categories + foreach ( array( 'math', 'system', 'general' ) as $slug ) { + if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( $slug ) ) { + continue; + } + + wp_unregister_ability_category( $slug ); + } + // Reset REST server global $wp_rest_server; $wp_rest_server = null; @@ -80,16 +96,46 @@ public function tear_down(): void { parent::tear_down(); } + /** + * Register test categories for testing. + */ + public function register_test_categories(): void { + wp_register_ability_category( + 'math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations and calculations.', + ) + ); + + wp_register_ability_category( + 'system', + array( + 'label' => 'System', + 'description' => 'System information and operations.', + ) + ); + + wp_register_ability_category( + 'general', + array( + 'label' => 'General', + 'description' => 'General purpose abilities.', + ) + ); + } + /** * Register test abilities for testing. */ private function register_test_abilities(): void { - // Register a tool ability + // Register a regular ability. wp_register_ability( 'test/calculator', array( 'label' => 'Calculator', 'description' => 'Performs basic calculations', + 'category' => 'math', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -122,18 +168,18 @@ private function register_test_abilities(): void { return current_user_can( 'read' ); }, 'meta' => array( - 'type' => 'tool', - 'category' => 'math', + 'show_in_rest' => true, ), ) ); - // Register a resource ability + // Register a read-only ability. wp_register_ability( 'test/system-info', array( 'label' => 'System Info', 'description' => 'Returns system information', + 'category' => 'system', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -165,12 +211,29 @@ private function register_test_abilities(): void { return current_user_can( 'read' ); }, 'meta' => array( - 'type' => 'resource', - 'category' => 'system', + 'annotations' => array( + 'readonly' => true, + ), + 'category' => 'system', + 'show_in_rest' => true, ), ) ); + // Ability that does not show in REST. + wp_register_ability( + 'test/not-show-in-rest', + array( + 'label' => 'Hidden from REST', + 'description' => 'It does not show in REST.', + 'category' => 'general', + 'execute_callback' => static function (): int { + return 0; + }, + 'permission_callback' => '__return_true', + ) + ); + // Register multiple abilities for pagination testing for ( $i = 1; $i <= 60; $i++ ) { wp_register_ability( @@ -178,10 +241,14 @@ private function register_test_abilities(): void { array( 'label' => "Test Ability {$i}", 'description' => "Test ability number {$i}", + 'category' => 'general', 'execute_callback' => static function () use ( $i ) { return "Result from ability {$i}"; }, 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), ) ); } @@ -205,6 +272,7 @@ public function test_get_items(): void { $ability_names = wp_list_pluck( $data, 'name' ); $this->assertContains( 'test/calculator', $ability_names ); $this->assertContains( 'test/system-info', $ability_names ); + $this->assertNotContains( 'test/not-show-in-rest', $ability_names ); } /** @@ -223,7 +291,7 @@ public function test_get_item(): void { $this->assertArrayHasKey( 'input_schema', $data ); $this->assertArrayHasKey( 'output_schema', $data ); $this->assertArrayHasKey( 'meta', $data ); - $this->assertEquals( 'tool', $data['meta']['type'] ); + $this->assertTrue( $data['meta']['show_in_rest'] ); } /** @@ -241,6 +309,19 @@ public function test_get_item_not_found(): void { $this->assertEquals( 'rest_ability_not_found', $data['code'] ); } + /** + * Test getting an ability that does not show in REST returns 404. + */ + public function test_get_item_not_show_in_rest(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/not-show-in-rest' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 404, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( 'rest_ability_not_found', $data['code'] ); + } + /** * Test permission check for listing abilities. */ @@ -268,7 +349,7 @@ public function test_pagination_headers(): void { $this->assertArrayHasKey( 'X-WP-Total', $headers ); $this->assertArrayHasKey( 'X-WP-TotalPages', $headers ); - $total_abilities = count( wp_get_abilities() ); + $total_abilities = count( wp_get_abilities() ) - 1; // Exclude the one that doesn't show in REST. $this->assertEquals( $total_abilities, (int) $headers['X-WP-Total'] ); $this->assertEquals( ceil( $total_abilities / 10 ), (int) $headers['X-WP-TotalPages'] ); } @@ -419,12 +500,27 @@ public function test_get_schema(): void { $this->assertArrayHasKey( 'properties', $schema ); $properties = $schema['properties']; + + // Assert the count of properties to catch when new keys are added + $this->assertCount( 7, $properties, 'Schema should have exactly 7 properties. If this fails, update this test to include the new property.' ); + + // Check all expected properties exist $this->assertArrayHasKey( 'name', $properties ); $this->assertArrayHasKey( 'label', $properties ); $this->assertArrayHasKey( 'description', $properties ); $this->assertArrayHasKey( 'input_schema', $properties ); $this->assertArrayHasKey( 'output_schema', $properties ); $this->assertArrayHasKey( 'meta', $properties ); + $this->assertArrayHasKey( 'category', $properties ); + + // Test category property details + $category_property = $properties['category']; + $this->assertEquals( 'string', $category_property['type'] ); + $this->assertTrue( $category_property['readonly'] ); + + // Check that category is in required fields + $this->assertArrayHasKey( 'required', $schema ); + $this->assertContains( 'category', $schema['required'] ); } /** @@ -437,10 +533,14 @@ public function test_ability_name_with_valid_special_characters(): void { array( 'label' => 'Test Hyphen Ability', 'description' => 'Test ability with hyphen', + 'category' => 'general', 'execute_callback' => static function ( $input ) { return array( 'success' => true ); }, 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), ) ); @@ -537,4 +637,64 @@ public function test_invalid_pagination_parameters( array $params ): void { $data = $response->get_data(); $this->assertIsArray( $data ); } + + /** + * Test filtering abilities by category. + */ + public function test_filter_by_category(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request->set_param( 'category', 'math' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertIsArray( $data ); + + // Should only have math category abilities + foreach ( $data as $ability ) { + $this->assertEquals( 'math', $ability['category'], 'All abilities should be in math category' ); + } + + // Should at least contain the calculator + $ability_names = wp_list_pluck( $data, 'name' ); + $this->assertContains( 'test/calculator', $ability_names ); + $this->assertNotContains( 'test/system-info', $ability_names, 'System info should not be in math category' ); + } + + /** + * Test filtering by non-existent category returns empty results. + */ + public function test_filter_by_nonexistent_category(): void { + // Ensure category doesn't exist - test should fail if it does. + $this->assertFalse( + WP_Abilities_Category_Registry::get_instance()->is_registered( 'nonexistent' ), + 'The nonexistent category should not be registered - test isolation may be broken' + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request->set_param( 'category', 'nonexistent' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertEmpty( $data, 'Should return empty array for non-existent category' ); + } + + /** + * Test that category field is present in response. + */ + public function test_category_field_in_response(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/calculator' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'category', $data ); + $this->assertEquals( 'math', $data['category'] ); + $this->assertIsString( $data['category'], 'Category should be a string' ); + } } diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php index f7ae219539bf2..92693cc63159d 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php @@ -61,6 +61,13 @@ public function set_up(): void { do_action( 'rest_api_init' ); + // Register test categories during the hook + add_action( + 'abilities_api_categories_init', + array( $this, 'register_test_categories' ) + ); + do_action( 'abilities_api_categories_init' ); + do_action( 'abilities_api_init' ); $this->register_test_abilities(); @@ -81,22 +88,60 @@ public function tear_down(): void { wp_unregister_ability( $ability->get_name() ); } + // Clean up registered categories. + $category_registry = WP_Abilities_Category_Registry::get_instance(); + foreach ( array( 'math', 'system', 'general' ) as $category ) { + if ( $category_registry->is_registered( $category ) ) { + wp_unregister_ability_category( $category ); + } + } + global $wp_rest_server; $wp_rest_server = null; parent::tear_down(); } + /** + * Register test categories for testing. + */ + public function register_test_categories(): void { + wp_register_ability_category( + 'math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations and calculations.', + ) + ); + + wp_register_ability_category( + 'system', + array( + 'label' => 'System', + 'description' => 'System information and operations.', + ) + ); + + wp_register_ability_category( + 'general', + array( + 'label' => 'General', + 'description' => 'General purpose abilities.', + ) + ); + } + /** * Register test abilities for testing. */ private function register_test_abilities(): void { - // Tool ability (POST only) + // Regular ability (POST only). wp_register_ability( 'test/calculator', array( 'label' => 'Calculator', 'description' => 'Performs calculations', + 'category' => 'math', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -122,17 +167,18 @@ private function register_test_abilities(): void { return current_user_can( 'edit_posts' ); }, 'meta' => array( - 'type' => 'tool', + 'show_in_rest' => true, ), ) ); - // Resource ability (GET only) + // Read-only ability (GET method). wp_register_ability( 'test/user-info', array( 'label' => 'User Info', 'description' => 'Gets user information', + 'category' => 'system', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -164,7 +210,10 @@ private function register_test_abilities(): void { return is_user_logged_in(); }, 'meta' => array( - 'type' => 'resource', + 'annotations' => array( + 'readonly' => true, + ), + 'show_in_rest' => true, ), ) ); @@ -175,6 +224,7 @@ private function register_test_abilities(): void { array( 'label' => 'Restricted Action', 'description' => 'Requires specific input for permission', + 'category' => 'general', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -194,23 +244,38 @@ private function register_test_abilities(): void { return isset( $input['secret'] ) && 'valid_secret' === $input['secret']; }, 'meta' => array( - 'type' => 'tool', + 'show_in_rest' => true, ), ) ); + // Ability that does not show in REST. + wp_register_ability( + 'test/not-show-in-rest', + array( + 'label' => 'Hidden from REST', + 'description' => 'It does not show in REST.', + 'category' => 'general', + 'execute_callback' => static function (): int { + return 0; + }, + 'permission_callback' => '__return_true', + ) + ); + // Ability that returns null wp_register_ability( 'test/null-return', array( 'label' => 'Null Return', 'description' => 'Returns null', + 'category' => 'general', 'execute_callback' => static function () { return null; }, 'permission_callback' => '__return_true', 'meta' => array( - 'type' => 'tool', + 'show_in_rest' => true, ), ) ); @@ -221,12 +286,13 @@ private function register_test_abilities(): void { array( 'label' => 'Error Return', 'description' => 'Returns error', + 'category' => 'general', 'execute_callback' => static function () { return new \WP_Error( 'test_error', 'This is a test error' ); }, 'permission_callback' => '__return_true', 'meta' => array( - 'type' => 'tool', + 'show_in_rest' => true, ), ) ); @@ -237,6 +303,7 @@ private function register_test_abilities(): void { array( 'label' => 'Invalid Output', 'description' => 'Returns invalid output', + 'category' => 'general', 'output_schema' => array( 'type' => 'number', ), @@ -245,17 +312,18 @@ private function register_test_abilities(): void { }, 'permission_callback' => '__return_true', 'meta' => array( - 'type' => 'tool', + 'show_in_rest' => true, ), ) ); - // Resource ability for query params testing + // Read-only ability for query params testing. wp_register_ability( 'test/query-params', array( 'label' => 'Query Params Test', 'description' => 'Tests query parameter handling', + 'category' => 'general', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -268,16 +336,19 @@ private function register_test_abilities(): void { }, 'permission_callback' => '__return_true', 'meta' => array( - 'type' => 'resource', + 'annotations' => array( + 'readonly' => true, + ), + 'show_in_rest' => true, ), ) ); } /** - * Test executing a tool ability with POST. + * Test executing a regular ability with POST. */ - public function test_execute_tool_ability_post(): void { + public function test_execute_regular_ability_post(): void { $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/calculator/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( @@ -298,9 +369,9 @@ public function test_execute_tool_ability_post(): void { } /** - * Test executing a resource ability with GET. + * Test executing a read-only ability with GET. */ - public function test_execute_resource_ability_get(): void { + public function test_execute_readonly_ability_get(): void { $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/user-info/run' ); $request->set_query_params( array( @@ -318,20 +389,21 @@ public function test_execute_resource_ability_get(): void { } /** - * Test HTTP method validation for tool abilities. + * Test HTTP method validation for regular abilities. */ - public function test_tool_ability_requires_post(): void { + public function test_regular_ability_requires_post(): void { wp_register_ability( 'test/open-tool', array( 'label' => 'Open Tool', 'description' => 'Tool with no permission requirements', + 'category' => 'general', 'execute_callback' => static function () { return 'success'; }, 'permission_callback' => '__return_true', 'meta' => array( - 'type' => 'tool', + 'show_in_rest' => true, ), ) ); @@ -342,14 +414,14 @@ public function test_tool_ability_requires_post(): void { $this->assertSame( 405, $response->get_status() ); $data = $response->get_data(); $this->assertSame( 'rest_ability_invalid_method', $data['code'] ); - $this->assertSame( 'Tool abilities require POST method.', $data['message'] ); + $this->assertSame( 'Abilities that perform updates require POST method.', $data['message'] ); } /** - * Test HTTP method validation for resource abilities. + * Test HTTP method validation for read-only abilities. */ - public function test_resource_ability_requires_get(): void { - // Try POST on a resource ability (should fail) + public function test_readonly_ability_requires_get(): void { + // Try POST on a read-only ability (should fail). $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/user-info/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( wp_json_encode( array( 'user_id' => 1 ) ) ); @@ -359,7 +431,7 @@ public function test_resource_ability_requires_get(): void { $this->assertSame( 405, $response->get_status() ); $data = $response->get_data(); $this->assertSame( 'rest_ability_invalid_method', $data['code'] ); - $this->assertSame( 'Resource abilities require GET method.', $data['message'] ); + $this->assertSame( 'Read-only abilities require GET method.', $data['message'] ); } @@ -446,6 +518,21 @@ public function test_contextual_permission_check(): void { $this->assertEquals( 'Success: test data', $response->get_data() ); } + /** + * Test handling an ability that does not show in REST. + */ + public function test_do_not_show_in_rest(): void { + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/not-show-in-rest/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 404, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_ability_not_found', $data['code'] ); + $this->assertEquals( 'Ability not found.', $data['message'] ); + } + /** * Test handling of null is a valid return value. */ @@ -594,6 +681,7 @@ public function test_output_validation_failure_returns_error(): void { array( 'label' => 'Strict Output', 'description' => 'Ability with strict output schema', + 'category' => 'general', 'output_schema' => array( 'type' => 'object', 'properties' => array( @@ -609,7 +697,9 @@ public function test_output_validation_failure_returns_error(): void { return array( 'wrong_field' => 'value' ); }, 'permission_callback' => '__return_true', - 'meta' => array( 'type' => 'tool' ), + 'meta' => array( + 'show_in_rest' => true, + ), ) ); @@ -638,6 +728,7 @@ public function test_input_validation_failure_returns_error(): void { array( 'label' => 'Strict Input', 'description' => 'Ability with strict input schema', + 'category' => 'general', 'input_schema' => array( 'type' => 'object', 'properties' => array( @@ -651,7 +742,9 @@ public function test_input_validation_failure_returns_error(): void { return array( 'status' => 'success' ); }, 'permission_callback' => '__return_true', - 'meta' => array( 'type' => 'tool' ), + 'meta' => array( + 'show_in_rest' => true, + ), ) ); @@ -673,30 +766,33 @@ public function test_input_validation_failure_returns_error(): void { } /** - * Test ability type not set defaults to tool. + * Test ability without annotations defaults to POST method. */ - public function test_ability_without_type_defaults_to_tool(): void { - // Register ability without type in meta. + public function test_ability_without_annotations_defaults_to_post_method(): void { + // Register ability without annotations. wp_register_ability( - 'test/no-type', + 'test/no-annotations', array( - 'label' => 'No Type', - 'description' => 'Ability without type', + 'label' => 'No Annotations', + 'description' => 'Ability without annotations.', + 'category' => 'general', 'execute_callback' => static function () { return array( 'executed' => true ); }, 'permission_callback' => '__return_true', - 'meta' => array(), // No type specified + 'meta' => array( + 'show_in_rest' => true, + ), ) ); - // Should require POST (default tool behavior) - $get_request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/no-type/run' ); + // Should require POST (default behavior). + $get_request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/no-annotations/run' ); $get_response = $this->server->dispatch( $get_request ); $this->assertEquals( 405, $get_response->get_status() ); - // Should work with POST - $post_request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/no-type/run' ); + // Should work with POST. + $post_request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/no-annotations/run' ); $post_request->set_header( 'Content-Type', 'application/json' ); $post_response = $this->server->dispatch( $post_request ); @@ -704,44 +800,53 @@ public function test_ability_without_type_defaults_to_tool(): void { } /** - * Test edge case with empty input for both GET and POST. + * Test edge case with empty input for both GET and POST methods. */ public function test_empty_input_handling(): void { // Registers abilities for empty input testing. wp_register_ability( - 'test/resource-empty', + 'test/read-only-empty', array( - 'label' => 'Resource Empty', - 'description' => 'Resource with empty input', + 'label' => 'Read-only Empty', + 'description' => 'Read-only with empty input.', + 'category' => 'general', 'execute_callback' => static function () { return array( 'input_was_empty' => 0 === func_num_args() ); }, 'permission_callback' => '__return_true', - 'meta' => array( 'type' => 'resource' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + ), + 'show_in_rest' => true, + ), ) ); wp_register_ability( - 'test/tool-empty', + 'test/regular-empty', array( - 'label' => 'Tool Empty', - 'description' => 'Tool with empty input', + 'label' => 'Regular Empty', + 'description' => 'Regular with empty input.', + 'category' => 'general', 'execute_callback' => static function () { return array( 'input_was_empty' => 0 === func_num_args() ); }, 'permission_callback' => '__return_true', - 'meta' => array( 'type' => 'tool' ), + 'meta' => array( + 'show_in_rest' => true, + ), ) ); // Tests GET with no input parameter. - $get_request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/resource-empty/run' ); + $get_request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/read-only-empty/run' ); $get_response = $this->server->dispatch( $get_request ); $this->assertEquals( 200, $get_response->get_status() ); $this->assertTrue( $get_response->get_data()['input_was_empty'] ); // Tests POST with no body. - $post_request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/tool-empty/run' ); + $post_request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/regular-empty/run' ); $post_request->set_header( 'Content-Type', 'application/json' ); $post_request->set_body( '{}' ); // Empty JSON object @@ -796,6 +901,7 @@ public function test_php_type_strings_in_input(): void { array( 'label' => 'Echo', 'description' => 'Echoes input', + 'category' => 'general', 'input_schema' => array( 'type' => 'object', ), @@ -803,7 +909,9 @@ public function test_php_type_strings_in_input(): void { return array( 'echo' => $input ); }, 'permission_callback' => '__return_true', - 'meta' => array( 'type' => 'tool' ), + 'meta' => array( + 'show_in_rest' => true, + ), ) ); @@ -840,6 +948,7 @@ public function test_mixed_encoding_in_input(): void { array( 'label' => 'Echo Encoding', 'description' => 'Echoes input with encoding', + 'category' => 'general', 'input_schema' => array( 'type' => 'object', ), @@ -847,7 +956,9 @@ public function test_mixed_encoding_in_input(): void { return array( 'echo' => $input ); }, 'permission_callback' => '__return_true', - 'meta' => array( 'type' => 'tool' ), + 'meta' => array( + 'show_in_rest' => true, + ), ) ); @@ -903,22 +1014,25 @@ public function test_invalid_http_methods( string $method ): void { array( 'label' => 'Method Test', 'description' => 'Test ability for HTTP method validation', + 'category' => 'general', 'execute_callback' => static function () { return array( 'success' => true ); }, 'permission_callback' => '__return_true', // No permission requirements - 'meta' => array( 'type' => 'tool' ), + 'meta' => array( + 'show_in_rest' => true, + ), ) ); $request = new WP_REST_Request( $method, '/wp/v2/abilities/test/method-test/run' ); $response = $this->server->dispatch( $request ); - // Tool abilities should only accept POST, so these should return 405. + // Regular abilities should only accept POST, so these should return 405. $this->assertSame( 405, $response->get_status() ); $data = $response->get_data(); $this->assertSame( 'rest_ability_invalid_method', $data['code'] ); - $this->assertSame( 'Tool abilities require POST method.', $data['message'] ); + $this->assertSame( 'Abilities that perform updates require POST method.', $data['message'] ); } /** diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 3ecd55244cc56..486c68a1ab273 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12471,6 +12471,11 @@ mockedApiResponse.Schema = { "minimum": 1, "maximum": 100, "required": false + }, + "category": { + "description": "Limit results to abilities in specific category.", + "type": "string", + "required": false } } } From fb3ac2a8febd47a34a701b322a488518da5cd1f5 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Wed, 15 Oct 2025 21:43:58 +0200 Subject: [PATCH 07/31] Update `@since` to the next WP version --- src/wp-includes/abilities-api.php | 20 +++--- .../class-wp-abilities-category-registry.php | 40 +++++------ .../class-wp-abilities-registry.php | 40 +++++------ .../class-wp-ability-category.php | 30 ++++---- .../abilities-api/class-wp-ability.php | 72 +++++++++---------- ...lass-wp-rest-abilities-list-controller.php | 24 +++---- ...class-wp-rest-abilities-run-controller.php | 22 +++--- .../abilities-api/wpAbilitiesRegistry.php | 61 ++++++++++++++++ .../phpunit/tests/abilities-api/wpAbility.php | 51 +++++++++++++ .../tests/abilities-api/wpAbilityCategory.php | 72 +++++++++++++++++-- .../tests/abilities-api/wpRegisterAbility.php | 28 ++++++++ .../wpRestAbilitiesListController.php | 51 +++++++++++-- .../rest-api/wpRestAbilitiesRunController.php | 54 +++++++++++++- 13 files changed, 431 insertions(+), 134 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index 6672b0c6dbaa6..1e22a8488a804 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -6,7 +6,7 @@ * * @package WordPress * @subpackage Abilities_API - * @since 0.1.0 + * @since 6.9.0 */ declare( strict_types = 1 ); @@ -16,7 +16,7 @@ * * Note: Do not use before the {@see 'abilities_api_init'} hook. * - * @since 0.1.0 + * @since 6.9.0 * * @see WP_Abilities_Registry::register() * @@ -55,7 +55,7 @@ function wp_register_ability( string $name, array $args ): ?WP_Ability { 'abilities_api_init', '' . esc_html( $name ) . '' ), - '0.1.0' + '6.9.0' ); return null; } @@ -66,7 +66,7 @@ function wp_register_ability( string $name, array $args ): ?WP_Ability { /** * Unregisters an ability using Abilities API. * - * @since 0.1.0 + * @since 6.9.0 * * @see WP_Abilities_Registry::unregister() * @@ -80,7 +80,7 @@ function wp_unregister_ability( string $name ): ?WP_Ability { /** * Retrieves a registered ability using Abilities API. * - * @since 0.1.0 + * @since 6.9.0 * * @see WP_Abilities_Registry::get_registered() * @@ -94,7 +94,7 @@ function wp_get_ability( string $name ): ?WP_Ability { /** * Retrieves all registered abilities using Abilities API. * - * @since 0.1.0 + * @since 6.9.0 * * @see WP_Abilities_Registry::get_all_registered() * @@ -107,7 +107,7 @@ function wp_get_abilities(): array { /** * Registers a new ability category. * - * @since 0.3.0 + * @since 6.9.0 * * @see WP_Abilities_Category_Registry::register() * @@ -131,7 +131,7 @@ function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_ /** * Unregisters an ability category. * - * @since 0.3.0 + * @since 6.9.0 * * @see WP_Abilities_Category_Registry::unregister() * @@ -145,7 +145,7 @@ function wp_unregister_ability_category( string $slug ): ?WP_Ability_Category { /** * Retrieves a registered ability category. * - * @since 0.3.0 + * @since 6.9.0 * * @see WP_Abilities_Category_Registry::get_registered() * @@ -159,7 +159,7 @@ function wp_get_ability_category( string $slug ): ?WP_Ability_Category { /** * Retrieves all registered ability categories. * - * @since 0.3.0 + * @since 6.9.0 * * @see WP_Abilities_Category_Registry::get_all_registered() * diff --git a/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php index 045a1df64407e..227f765da2a40 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php @@ -6,7 +6,7 @@ * * @package WordPress * @subpackage Abilities API - * @since 0.3.0 + * @since 6.9.0 */ declare( strict_types = 1 ); @@ -14,14 +14,14 @@ /** * Manages the registration and lookup of ability categories. * - * @since 0.3.0 + * @since 6.9.0 * @access private */ final class WP_Abilities_Category_Registry { /** * The singleton instance of the registry. * - * @since 0.3.0 + * @since 6.9.0 * @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 6.9.0 * @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 6.9.0 * * @see wp_register_ability_category() * @@ -66,7 +66,7 @@ public function register( string $slug, array $args ): ?WP_Ability_Category { 'abilities_api_categories_init', '' . esc_html( $slug ) . '' ), - '0.3.0' + '6.9.0' ); return null; } @@ -76,7 +76,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' + '6.9.0' ); return null; } @@ -85,7 +85,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' + '6.9.0' ); return null; } @@ -93,7 +93,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 6.9.0 * * @param array $args The arguments used to instantiate the category. * @param string $slug The slug of the category. @@ -107,7 +107,7 @@ public function register( string $slug, array $args ): ?WP_Ability_Category { _doing_it_wrong( __METHOD__, esc_html( $e->getMessage() ), - '0.3.0' + '6.9.0' ); return null; } @@ -121,7 +121,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 6.9.0 * * @see wp_unregister_ability_category() * @@ -134,7 +134,7 @@ public function unregister( string $slug ): ?WP_Ability_Category { __METHOD__, /* translators: %s: Ability category slug. */ sprintf( esc_html__( 'Ability category "%s" not found.' ), esc_attr( $slug ) ), - '0.3.0' + '6.9.0' ); return null; } @@ -150,7 +150,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 6.9.0 * * @see wp_get_ability_categories() * @@ -163,7 +163,7 @@ public function get_all_registered(): array { /** * Checks if a category is registered. * - * @since 0.3.0 + * @since 6.9.0 * * @param string $slug The slug of the category. * @return bool True if the category is registered, false otherwise. @@ -177,7 +177,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 6.9.0 * * @see wp_get_ability_category() * @@ -190,7 +190,7 @@ public function get_registered( string $slug ): ?WP_Ability_Category { __METHOD__, /* translators: %s: Ability category slug. */ sprintf( esc_html__( 'Ability category "%s" not found.' ), esc_attr( $slug ) ), - '0.3.0' + '6.9.0' ); return null; } @@ -202,7 +202,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 6.9.0 * * @return \WP_Abilities_Category_Registry The main registry instance. */ @@ -215,7 +215,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 6.9.0 * * @param \WP_Abilities_Category_Registry $instance Categories registry object. */ @@ -228,7 +228,7 @@ public static function get_instance(): self { /** * Wakeup magic method. * - * @since 0.3.0 + * @since 6.9.0 * @throws \LogicException If the registry is unserialized. This is a security hardening measure to prevent unserialization of the registry. */ public function __wakeup(): void { @@ -238,7 +238,7 @@ public function __wakeup(): void { /** * Serialization magic method. * - * @since 0.3.0 + * @since 6.9.0 * @throws \LogicException If the registry is serialized. This is a security hardening measure to prevent serialization of the registry. */ public function __sleep(): array { diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index d5f36999b384b..7cf19dd325e4a 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -6,7 +6,7 @@ * * @package WordPress * @subpackage Abilities API - * @since 0.1.0 + * @since 6.9.0 */ declare( strict_types = 1 ); @@ -14,14 +14,14 @@ /** * Manages the registration and lookup of abilities. * - * @since 0.1.0 + * @since 6.9.0 * @access private */ final class WP_Abilities_Registry { /** * The singleton instance of the registry. * - * @since 0.1.0 + * @since 6.9.0 * @var ?self */ private static $instance = null; @@ -29,7 +29,7 @@ final class WP_Abilities_Registry { /** * Holds the registered abilities. * - * @since 0.1.0 + * @since 6.9.0 * @var \WP_Ability[] */ private $registered_abilities = array(); @@ -39,7 +39,7 @@ final class WP_Abilities_Registry { * * Do not use this method directly. Instead, use the `wp_register_ability()` function. * - * @since 0.1.0 + * @since 6.9.0 * * @see wp_register_ability() * @@ -74,7 +74,7 @@ public function register( string $name, array $args ): ?WP_Ability { esc_html__( 'Ability name must be a string containing a namespace prefix, i.e. "my-plugin/my-ability". It can only contain lowercase alphanumeric characters, dashes and the forward slash.' ), - '0.1.0' + '6.9.0' ); return null; } @@ -84,7 +84,7 @@ public function register( string $name, array $args ): ?WP_Ability { __METHOD__, /* translators: %s: Ability name. */ esc_html( sprintf( __( 'Ability "%s" is already registered.' ), $name ) ), - '0.1.0' + '6.9.0' ); return null; } @@ -92,7 +92,7 @@ public function register( string $name, array $args ): ?WP_Ability { /** * Filters the ability arguments before they are validated and used to instantiate the ability. * - * @since 0.2.0 + * @since 6.9.0 * * @param array $args The arguments used to instantiate the ability. * @param string $name The name of the ability, with its namespace. @@ -111,7 +111,7 @@ public function register( string $name, array $args ): ?WP_Ability { esc_attr( $args['category'] ), esc_attr( $name ) ), - '0.3.0' + '6.9.0' ); return null; } @@ -122,7 +122,7 @@ public function register( string $name, array $args ): ?WP_Ability { _doing_it_wrong( __METHOD__, esc_html__( 'The ability args should provide a valid `ability_class` that extends WP_Ability.' ), - '0.1.0' + '6.9.0' ); return null; } @@ -138,7 +138,7 @@ public function register( string $name, array $args ): ?WP_Ability { _doing_it_wrong( __METHOD__, esc_html( $e->getMessage() ), - '0.1.0' + '6.9.0' ); return null; } @@ -152,7 +152,7 @@ public function register( string $name, array $args ): ?WP_Ability { * * Do not use this method directly. Instead, use the `wp_unregister_ability()` function. * - * @since 0.1.0 + * @since 6.9.0 * * @see wp_unregister_ability() * @@ -165,7 +165,7 @@ public function unregister( string $name ): ?WP_Ability { __METHOD__, /* translators: %s: Ability name. */ sprintf( esc_html__( 'Ability "%s" not found.' ), esc_attr( $name ) ), - '0.1.0' + '6.9.0' ); return null; } @@ -181,7 +181,7 @@ public function unregister( string $name ): ?WP_Ability { * * Do not use this method directly. Instead, use the `wp_get_abilities()` function. * - * @since 0.1.0 + * @since 6.9.0 * * @see wp_get_abilities() * @@ -194,7 +194,7 @@ public function get_all_registered(): array { /** * Checks if an ability is registered. * - * @since 0.1.0 + * @since 6.9.0 * * @param string $name The name of the registered ability, with its namespace. * @return bool True if the ability is registered, false otherwise. @@ -208,7 +208,7 @@ public function is_registered( string $name ): bool { * * Do not use this method directly. Instead, use the `wp_get_ability()` function. * - * @since 0.1.0 + * @since 6.9.0 * * @see wp_get_ability() * @@ -221,7 +221,7 @@ public function get_registered( string $name ): ?WP_Ability { __METHOD__, /* translators: %s: Ability name. */ sprintf( esc_html__( 'Ability "%s" not found.' ), esc_attr( $name ) ), - '0.1.0' + '6.9.0' ); return null; } @@ -233,7 +233,7 @@ public function get_registered( string $name ): ?WP_Ability { * * The instance will be created if it does not exist yet. * - * @since 0.1.0 + * @since 6.9.0 * * @return \WP_Abilities_Registry The main registry instance. */ @@ -251,7 +251,7 @@ public static function get_instance(): self { * Abilities should be created and register their hooks on this action rather * than another action to ensure they're only loaded when needed. * - * @since 0.1.0 + * @since 6.9.0 * * @param \WP_Abilities_Registry $instance Abilities registry object. */ @@ -264,7 +264,7 @@ public static function get_instance(): self { /** * Wakeup magic method. * - * @since 0.1.0 + * @since 6.9.0 * @throws \UnexpectedValueException If any of the registered abilities is not an instance of WP_Ability. */ public function __wakeup(): void { diff --git a/src/wp-includes/abilities-api/class-wp-ability-category.php b/src/wp-includes/abilities-api/class-wp-ability-category.php index 734b436fab66c..622e0f024c65b 100644 --- a/src/wp-includes/abilities-api/class-wp-ability-category.php +++ b/src/wp-includes/abilities-api/class-wp-ability-category.php @@ -6,7 +6,7 @@ * * @package WordPress * @subpackage Abilities API - * @since 0.3.0 + * @since 6.9.0 */ declare( strict_types = 1 ); @@ -14,7 +14,7 @@ /** * Encapsulates the properties and methods related to a specific ability category. * - * @since 0.3.0 + * @since 6.9.0 * * @see WP_Abilities_Category_Registry */ @@ -23,7 +23,7 @@ final class WP_Ability_Category { /** * The unique slug for the category. * - * @since 0.3.0 + * @since 6.9.0 * @var string */ protected $slug; @@ -31,7 +31,7 @@ final class WP_Ability_Category { /** * The human-readable category label. * - * @since 0.3.0 + * @since 6.9.0 * @var string */ protected $label; @@ -39,7 +39,7 @@ final class WP_Ability_Category { /** * The detailed category description. * - * @since 0.3.0 + * @since 6.9.0 * @var string */ protected $description; @@ -47,7 +47,7 @@ final class WP_Ability_Category { /** * The optional category metadata. * - * @since 0.3.0 + * @since 6.9.0 * @var array */ protected $meta = array(); @@ -59,7 +59,7 @@ final class WP_Ability_Category { * * @access private * - * @since 0.3.0 + * @since 6.9.0 * * @see wp_register_ability_category() * @@ -88,7 +88,7 @@ public function __construct( string $slug, array $args ) { '' . esc_html( $this->slug ) . '', '' . esc_html( self::class ) . '' ), - '0.3.0' + '6.9.0' ); continue; } @@ -100,7 +100,7 @@ public function __construct( string $slug, array $args ) { /** * Prepares and validates the properties used to instantiate the category. * - * @since 0.3.0 + * @since 6.9.0 * * @param array $args An associative array of arguments used to instantiate the class. * @return array The validated and prepared properties. @@ -140,7 +140,7 @@ protected function prepare_properties( array $args ): array { /** * Retrieves the slug of the category. * - * @since 0.3.0 + * @since 6.9.0 * * @return string The category slug. */ @@ -151,7 +151,7 @@ public function get_slug(): string { /** * Retrieves the human-readable label for the category. * - * @since 0.3.0 + * @since 6.9.0 * * @return string The human-readable category label. */ @@ -162,7 +162,7 @@ public function get_label(): string { /** * Retrieves the detailed description for the category. * - * @since 0.3.0 + * @since 6.9.0 * * @return string The detailed description for the category. */ @@ -173,7 +173,7 @@ public function get_description(): string { /** * Retrieves the metadata for the category. * - * @since 0.3.0 + * @since 6.9.0 * * @return array The metadata for the category. */ @@ -184,7 +184,7 @@ public function get_meta(): array { /** * Wakeup magic method. * - * @since 0.3.0 + * @since 6.9.0 * @throws \LogicException If the category is unserialized. This is a security hardening measure to prevent unserialization of the category. */ public function __wakeup(): void { @@ -194,7 +194,7 @@ public function __wakeup(): void { /** * Serialization magic method. * - * @since 0.3.0 + * @since 6.9.0 * @throws \LogicException If the category is serialized. This is a security hardening measure to prevent serialization of the category. */ public function __sleep(): array { diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index e31ce5ba0b315..b359592155ab9 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -6,7 +6,7 @@ * * @package WordPress * @subpackage Abilities API - * @since 0.1.0 + * @since 6.9.0 */ declare( strict_types = 1 ); @@ -14,7 +14,7 @@ /** * Encapsulates the properties and methods related to a specific ability in the registry. * - * @since 0.1.0 + * @since 6.9.0 * * @see WP_Abilities_Registry */ @@ -23,7 +23,7 @@ class WP_Ability { /** * The default value for the `show_in_rest` meta. * - * @since 0.3.0 + * @since 6.9.0 * @var bool */ protected const DEFAULT_SHOW_IN_REST = false; @@ -32,7 +32,7 @@ class WP_Ability { * The default ability annotations. * They are not guaranteed to provide a faithful description of ability behavior. * - * @since 0.3.0 + * @since 6.9.0 * @var array */ protected static $default_annotations = array( @@ -56,7 +56,7 @@ class WP_Ability { * The name of the ability, with its namespace. * Example: `my-plugin/my-ability`. * - * @since 0.1.0 + * @since 6.9.0 * @var string */ protected $name; @@ -64,7 +64,7 @@ class WP_Ability { /** * The human-readable ability label. * - * @since 0.1.0 + * @since 6.9.0 * @var string */ protected $label; @@ -72,7 +72,7 @@ class WP_Ability { /** * The detailed ability description. * - * @since 0.1.0 + * @since 6.9.0 * @var string */ protected $description; @@ -80,7 +80,7 @@ class WP_Ability { /** * The optional ability input schema. * - * @since 0.1.0 + * @since 6.9.0 * @var array */ protected $input_schema = array(); @@ -88,7 +88,7 @@ class WP_Ability { /** * The optional ability output schema. * - * @since 0.1.0 + * @since 6.9.0 * @var array */ protected $output_schema = array(); @@ -96,7 +96,7 @@ class WP_Ability { /** * The ability execute callback. * - * @since 0.1.0 + * @since 6.9.0 * @var callable( mixed $input= ): (mixed|\WP_Error) */ protected $execute_callback; @@ -104,7 +104,7 @@ class WP_Ability { /** * The optional ability permission callback. * - * @since 0.1.0 + * @since 6.9.0 * @var callable( mixed $input= ): (bool|\WP_Error) */ protected $permission_callback; @@ -112,7 +112,7 @@ class WP_Ability { /** * The optional ability metadata. * - * @since 0.1.0 + * @since 6.9.0 * @var array */ protected $meta; @@ -120,7 +120,7 @@ class WP_Ability { /** * The ability category (required). * - * @since 0.3.0 + * @since 6.9.0 * @var string */ protected $category; @@ -132,7 +132,7 @@ class WP_Ability { * * @access private * - * @since 0.1.0 + * @since 6.9.0 * * @see wp_register_ability() * @@ -157,7 +157,7 @@ public function __construct( string $name, array $args ) { '' . esc_html( $this->name ) . '', '' . esc_html( self::class ) . '' ), - '0.1.0' + '6.9.0' ); continue; } @@ -172,7 +172,7 @@ public function __construct( string $name, array $args ) { * Errors are thrown as exceptions instead of \WP_Errors to allow for simpler handling and overloading. They are then * caught and converted to a WP_Error when by WP_Abilities_Registry::register(). * - * @since 0.2.0 + * @since 6.9.0 * * @see WP_Abilities_Registry::register() * @@ -279,7 +279,7 @@ protected function prepare_properties( array $args ): array { * Retrieves the name of the ability, with its namespace. * Example: `my-plugin/my-ability`. * - * @since 0.1.0 + * @since 6.9.0 * * @return string The ability name, with its namespace. */ @@ -290,7 +290,7 @@ public function get_name(): string { /** * Retrieves the human-readable label for the ability. * - * @since 0.1.0 + * @since 6.9.0 * * @return string The human-readable ability label. */ @@ -301,7 +301,7 @@ public function get_label(): string { /** * Retrieves the detailed description for the ability. * - * @since 0.1.0 + * @since 6.9.0 * * @return string The detailed description for the ability. */ @@ -312,7 +312,7 @@ public function get_description(): string { /** * Retrieves the input schema for the ability. * - * @since 0.1.0 + * @since 6.9.0 * * @return array The input schema for the ability. */ @@ -323,7 +323,7 @@ public function get_input_schema(): array { /** * Retrieves the output schema for the ability. * - * @since 0.1.0 + * @since 6.9.0 * * @return array The output schema for the ability. */ @@ -334,7 +334,7 @@ public function get_output_schema(): array { /** * Retrieves the metadata for the ability. * - * @since 0.1.0 + * @since 6.9.0 * * @return array The metadata for the ability. */ @@ -345,7 +345,7 @@ public function get_meta(): array { /** * Retrieves the category for the ability. * - * @since 0.3.0 + * @since 6.9.0 * * @return string The category for the ability. */ @@ -356,7 +356,7 @@ public function get_category(): string { /** * Retrieves a specific metadata item for the ability. * - * @since 0.3.0 + * @since 6.9.0 * * @param string $key The metadata key to retrieve. * @param mixed $default_value Optional. The default value to return if the metadata item is not found. Default `null`. @@ -369,7 +369,7 @@ public function get_meta_item( string $key, $default_value = null ) { /** * Validates input data against the input schema. * - * @since 0.1.0 + * @since 6.9.0 * * @param mixed $input Optional. The input data to validate. Default `null`. * @return true|\WP_Error Returns true if valid or the WP_Error object if validation fails. @@ -410,7 +410,7 @@ protected function validate_input( $input = null ) { /** * Invokes a callable, ensuring the input is passed through only if the input schema is defined. * - * @since 0.3.0 + * @since 6.9.0 * * @param callable $callback The callable to invoke. * @param mixed $input Optional. The input data for the ability. Default `null`. @@ -430,7 +430,7 @@ protected function invoke_callback( callable $callback, $input = null ) { * * The input is validated against the input schema before it is passed to to permission callback. * - * @since 0.2.0 + * @since 6.9.0 * * @param mixed $input Optional. The input data for permission checking. Default `null`. * @return bool|\WP_Error Whether the ability has the necessary permission. @@ -452,20 +452,20 @@ public function check_permissions( $input = null ) { * @deprecated 0.2.0 Use check_permissions() instead. * @see WP_Ability::check_permissions() * - * @since 0.1.0 + * @since 6.9.0 * * @param mixed $input Optional. The input data for permission checking. Default `null`. * @return bool|\WP_Error Whether the ability has the necessary permission. */ public function has_permission( $input = null ) { - _deprecated_function( __METHOD__, '0.2.0', 'WP_Ability::check_permissions()' ); + _deprecated_function( __METHOD__, '6.9.0' ); return $this->check_permissions( $input ); } /** * Executes the ability callback. * - * @since 0.1.0 + * @since 6.9.0 * * @param mixed $input Optional. The input data for the ability. Default `null`. * @return mixed|\WP_Error The result of the ability execution, or WP_Error on failure. @@ -485,7 +485,7 @@ protected function do_execute( $input = null ) { /** * Validates output data against the output schema. * - * @since 0.1.0 + * @since 6.9.0 * * @param mixed $output The output data to validate. * @return true|\WP_Error Returns true if valid, or a WP_Error object if validation fails. @@ -516,7 +516,7 @@ protected function validate_output( $output ) { * Executes the ability after input validation and running a permission check. * Before returning the return value, it also validates the output. * - * @since 0.1.0 + * @since 6.9.0 * * @param mixed $input Optional. The input data for the ability. Default `null`. * @return mixed|\WP_Error The result of the ability execution, or WP_Error on failure. @@ -532,7 +532,7 @@ public function execute( $input = null ) { _doing_it_wrong( __METHOD__, esc_html( $has_permissions->get_error_message() ), - '0.1.0' + '6.9.0' ); } @@ -546,7 +546,7 @@ public function execute( $input = null ) { /** * Fires before an ability gets executed. * - * @since 0.2.0 + * @since 6.9.0 * * @param string $ability_name The name of the ability. * @param mixed $input The input data for the ability. @@ -566,7 +566,7 @@ public function execute( $input = null ) { /** * Fires immediately after an ability finished executing. * - * @since 0.2.0 + * @since 6.9.0 * * @param string $ability_name The name of the ability. * @param mixed $input The input data for the ability. @@ -580,7 +580,7 @@ public function execute( $input = null ) { /** * Wakeup magic method. * - * @since 0.1.0 + * @since 6.9.0 */ public function __wakeup(): void { throw new \LogicException( self::class . ' should never be unserialized.' ); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php index 54e393f458e64..8933f904d6d55 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage Abilities_API - * @since 0.1.0 + * @since 6.9.0 */ declare( strict_types = 1 ); @@ -12,7 +12,7 @@ /** * Core controller used to access abilities via the REST API. * - * @since 0.1.0 + * @since 6.9.0 * * @see WP_REST_Controller */ @@ -21,7 +21,7 @@ class WP_REST_Abilities_List_Controller extends WP_REST_Controller { /** * Default number of items per page for pagination. * - * @since 0.1.0 + * @since 6.9.0 * @var int */ public const DEFAULT_PER_PAGE = 50; @@ -29,7 +29,7 @@ class WP_REST_Abilities_List_Controller extends WP_REST_Controller { /** * REST API namespace. * - * @since 0.1.0 + * @since 6.9.0 * @var string */ protected $namespace = 'wp/v2'; @@ -37,7 +37,7 @@ class WP_REST_Abilities_List_Controller extends WP_REST_Controller { /** * REST API base route. * - * @since 0.1.0 + * @since 6.9.0 * @var string */ protected $rest_base = 'abilities'; @@ -45,7 +45,7 @@ class WP_REST_Abilities_List_Controller extends WP_REST_Controller { /** * Registers the routes for abilities. * - * @since 0.1.0 + * @since 6.9.0 * * @see register_rest_route() */ @@ -88,7 +88,7 @@ public function register_routes(): void { /** * Retrieves all abilities. * - * @since 0.1.0 + * @since 6.9.0 * * @param \WP_REST_Request> $request Full details about the request. * @return \WP_REST_Response Response object on success. @@ -161,7 +161,7 @@ static function ( $ability ) use ( $category ) { /** * Retrieves a specific ability. * - * @since 0.1.0 + * @since 6.9.0 * * @param \WP_REST_Request> $request Full details about the request. * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. @@ -183,7 +183,7 @@ public function get_item( $request ) { /** * Checks if a given request has access to read abilities. * - * @since 0.1.0 + * @since 6.9.0 * * @param \WP_REST_Request> $request Full details about the request. * @return bool True if the request has read access. @@ -195,7 +195,7 @@ public function get_permissions_check( $request ) { /** * Prepares an ability for response. * - * @since 0.1.0 + * @since 6.9.0 * * @param \WP_Ability $ability The ability object. * @param \WP_REST_Request> $request Request object. @@ -242,7 +242,7 @@ public function prepare_item_for_response( $ability, $request ) { /** * Retrieves the ability's schema, conforming to JSON Schema. * - * @since 0.1.0 + * @since 6.9.0 * * @return array Item schema data. */ @@ -304,7 +304,7 @@ public function get_item_schema(): array { /** * Retrieves the query params for collections. * - * @since 0.1.0 + * @since 6.9.0 * * @return array Collection parameters. */ diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php index 5fdbfea4a65e4..5d35827457d45 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage Abilities_API - * @since 0.1.0 + * @since 6.9.0 */ declare( strict_types = 1 ); @@ -12,7 +12,7 @@ /** * Core controller used to execute abilities via the REST API. * - * @since 0.1.0 + * @since 6.9.0 * * @see WP_REST_Controller */ @@ -21,7 +21,7 @@ class WP_REST_Abilities_Run_Controller extends WP_REST_Controller { /** * REST API namespace. * - * @since 0.1.0 + * @since 6.9.0 * @var string */ protected $namespace = 'wp/v2'; @@ -29,7 +29,7 @@ class WP_REST_Abilities_Run_Controller extends WP_REST_Controller { /** * REST API base route. * - * @since 0.1.0 + * @since 6.9.0 * @var string */ protected $rest_base = 'abilities'; @@ -37,7 +37,7 @@ class WP_REST_Abilities_Run_Controller extends WP_REST_Controller { /** * Registers the routes for ability execution. * - * @since 0.1.0 + * @since 6.9.0 * * @see register_rest_route() */ @@ -74,7 +74,7 @@ public function register_routes(): void { /** * Executes an ability with HTTP method validation. * - * @since 0.1.0 + * @since 6.9.0 * * @param \WP_REST_Request> $request Full details about the request. * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. @@ -117,7 +117,7 @@ public function run_ability_with_method_check( $request ) { /** * Executes an ability. * - * @since 0.1.0 + * @since 6.9.0 * * @param \WP_REST_Request> $request Full details about the request. * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. @@ -147,7 +147,7 @@ public function run_ability( $request ) { /** * Checks if a given request has permission to execute a specific ability. * - * @since 0.1.0 + * @since 6.9.0 * * @param \WP_REST_Request> $request Full details about the request. * @return true|\WP_Error True if the request has execution permission, WP_Error object otherwise. @@ -177,7 +177,7 @@ public function run_ability_permissions_check( $request ) { /** * Extracts input parameters from the request. * - * @since 0.1.0 + * @since 6.9.0 * * @param \WP_REST_Request> $request The request object. * @return mixed|null The input parameters. @@ -197,7 +197,7 @@ private function get_input_from_request( $request ) { /** * Retrieves the arguments for ability execution endpoint. * - * @since 0.1.0 + * @since 6.9.0 * * @return array Arguments for the run endpoint. */ @@ -214,7 +214,7 @@ public function get_run_args(): array { /** * Retrieves the schema for ability execution endpoint. * - * @since 0.1.0 + * @since 6.9.0 * * @return array Schema for the run endpoint. */ diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php index 5700093368723..b9d8c004b5e73 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -105,6 +105,8 @@ public function tear_down(): void { /** * Should reject ability name without a namespace. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * * @expectedIncorrectUsage WP_Abilities_Registry::register @@ -117,6 +119,8 @@ public function test_register_invalid_name_without_namespace() { /** * Should reject ability name with invalid characters. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * * @expectedIncorrectUsage WP_Abilities_Registry::register @@ -129,6 +133,8 @@ public function test_register_invalid_characters_in_name() { /** * Should reject ability name with uppercase characters. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * * @expectedIncorrectUsage WP_Abilities_Registry::register @@ -141,6 +147,8 @@ public function test_register_invalid_uppercase_characters_in_name() { /** * Should reject ability registration without a label. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -157,6 +165,8 @@ public function test_register_invalid_missing_label() { /** * Should reject ability registration with invalid label type. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -172,6 +182,8 @@ public function test_register_invalid_label_type() { /** * Should reject ability registration without a description. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -188,6 +200,8 @@ public function test_register_invalid_missing_description() { /** * Should reject ability registration with invalid description type. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -203,6 +217,8 @@ public function test_register_invalid_description_type() { /** * Should reject ability registration without an execute callback. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -219,6 +235,8 @@ public function test_register_invalid_missing_execute_callback() { /** * Should reject ability registration if the execute callback is not a callable. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -234,6 +252,8 @@ public function test_register_incorrect_execute_callback_type() { /** * Should reject ability registration without an execute callback. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -250,6 +270,8 @@ public function test_register_invalid_missing_permission_callback() { /** * Should reject ability registration if the permission callback is not a callable. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -265,6 +287,8 @@ public function test_register_incorrect_permission_callback_type() { /** * Should reject ability registration if the input schema is not an array. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -280,6 +304,8 @@ public function test_register_incorrect_input_schema_type() { /** * Should reject ability registration if the output schema is not an array. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -296,6 +322,8 @@ public function test_register_incorrect_output_schema_type() { /** * Should reject ability registration with invalid `annotations` type. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -311,6 +339,8 @@ public function test_register_invalid_annotations_type() { /** * Should reject ability registration with invalid meta type. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -326,6 +356,8 @@ public function test_register_invalid_meta_type() { /** * Should reject ability registration with invalid show in REST type. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Ability::prepare_properties * @@ -341,6 +373,8 @@ public function test_register_invalid_show_in_rest_type() { /** * Should reject registration for already registered ability. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * * @expectedIncorrectUsage WP_Abilities_Registry::register @@ -356,6 +390,8 @@ public function test_register_incorrect_already_registered_ability() { /** * Should successfully register a new ability. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register */ public function test_register_new_ability() { @@ -370,6 +406,8 @@ public function test_register_new_ability() { /** * Should return false for ability that's not registered. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::is_registered */ public function test_is_registered_for_unknown_ability() { @@ -380,6 +418,8 @@ public function test_is_registered_for_unknown_ability() { /** * Should return true if ability is registered. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Abilities_Registry::is_registered */ @@ -395,6 +435,8 @@ public function test_is_registered_for_known_ability() { /** * Should not find ability that's not registered. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::get_registered * * @expectedIncorrectUsage WP_Abilities_Registry::get_registered @@ -407,6 +449,8 @@ public function test_get_registered_rejects_unknown_ability_name() { /** * Should find registered ability by name. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Abilities_Registry::get_registered */ @@ -422,6 +466,8 @@ public function test_get_registered_for_known_ability() { /** * Unregistering should fail if a ability is not registered. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::unregister * * @expectedIncorrectUsage WP_Abilities_Registry::unregister @@ -434,6 +480,8 @@ public function test_unregister_not_registered_ability() { /** * Should unregister ability by name. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Abilities_Registry::unregister */ @@ -451,6 +499,8 @@ public function test_unregister_for_known_ability() { /** * Should retrieve all registered abilities. * + * @ticket 64098 + * * @covers WP_Abilities_Registry::register * @covers WP_Abilities_Registry::get_all_registered */ @@ -474,6 +524,8 @@ public function test_get_all_registered() { /** * Direct instantiation of WP_Ability with invalid properties should throw an exception. * + * @ticket 64098 + * * @covers WP_Ability::__construct * @covers WP_Ability::prepare_properties */ @@ -491,6 +543,8 @@ public function test_wp_ability_invalid_properties_throws_exception() { /** * Test register_ability_args filter modifies the args before ability instantiation. + * + * @ticket 64098 */ public function test_register_ability_args_filter_modifies_args() { $was_filter_callback_fired = false; @@ -532,6 +586,8 @@ static function ( $args ) use ( &$was_filter_callback_fired ) { /** * Test register_ability_args filter can block ability registration by returning invalid args. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_ability_args_filter_blocks_registration() { @@ -555,6 +611,9 @@ static function ( $args ) { /** * Test register_ability_args filter can block an invalid ability class from being used. + * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_ability_args_filter_blocks_invalid_ability_class() { @@ -577,6 +636,8 @@ static function ( $args ) { /** * Tests register_ability_args filter is only applied to the specific ability being registered. + * + * @ticket 64098 */ public function test_register_ability_args_filter_only_applies_to_specific_ability() { add_filter( diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php index 17c16e7b60f1b..62694559d4752 100644 --- a/tests/phpunit/tests/abilities-api/wpAbility.php +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -76,6 +76,8 @@ public function tear_down(): void { /* * Tests that getting non-existing metadata item returns default value. + * + * @ticket 64098 */ public function test_meta_get_non_existing_item_returns_default() { $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); @@ -88,6 +90,8 @@ public function test_meta_get_non_existing_item_returns_default() { /** * Tests that getting non-existing metadata item with custom default returns that default. + * + * @ticket 64098 */ public function test_meta_get_non_existing_item_with_custom_default() { $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); @@ -101,6 +105,8 @@ public function test_meta_get_non_existing_item_with_custom_default() { /** * Tests getting all annotations when selective overrides are applied. + * + * @ticket 64098 */ public function test_get_merged_annotations_from_meta() { $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); @@ -119,6 +125,8 @@ public function test_get_merged_annotations_from_meta() { /** * Tests getting default annotations when not provided. + * + * @ticket 64098 */ public function test_get_default_annotations_from_meta() { $args = self::$test_ability_properties; @@ -139,6 +147,8 @@ public function test_get_default_annotations_from_meta() { /** * Tests getting all annotations when values overridden. + * + * @ticket 64098 */ public function test_get_overridden_annotations_from_meta() { $annotations = array( @@ -163,6 +173,8 @@ public function test_get_overridden_annotations_from_meta() { /** * Tests that invalid `annotations` value throws an exception. + * + * @ticket 64098 */ public function test_annotations_from_meta_throws_exception() { $args = array_merge( @@ -182,6 +194,8 @@ public function test_annotations_from_meta_throws_exception() { /** * Tests that `show_in_rest` metadata defaults to false when not provided. + * + * @ticket 64098 */ public function test_meta_show_in_rest_defaults_to_false() { $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); @@ -194,6 +208,8 @@ public function test_meta_show_in_rest_defaults_to_false() { /** * Tests that `show_in_rest` metadata can be set to true. + * + * @ticket 64098 */ public function test_meta_show_in_rest_can_be_set_to_true() { $args = array_merge( @@ -214,6 +230,8 @@ public function test_meta_show_in_rest_can_be_set_to_true() { /** * Tests that `show_in_rest` can be set to false. + * + * @ticket 64098 */ public function test_show_in_rest_can_be_set_to_false() { $args = array_merge( @@ -234,6 +252,8 @@ public function test_show_in_rest_can_be_set_to_false() { /** * Tests that invalid `show_in_rest` value throws an exception. + * + * @ticket 64098 */ public function test_show_in_rest_throws_exception() { $args = array_merge( @@ -253,6 +273,8 @@ public function test_show_in_rest_throws_exception() { /** * Data provider for testing the execution of the ability. + * + * @return array Data sets with different configurations. */ public function data_execute_input() { return array( @@ -366,7 +388,14 @@ static function ( array $input ): int { /** * Tests the execution of the ability. * + * @ticket 64098 + * * @dataProvider data_execute_input + * + * @param array $input_schema The input schema for the ability. + * @param callable $execute_callback The execute callback for the ability. + * @param mixed $input The input to pass to the execute method. + * @param mixed $result The expected result from the execute method. */ public function test_execute_input( $input_schema, $execute_callback, $input, $result ) { $args = array_merge( @@ -404,6 +433,8 @@ public function my_instance_execute_callback( string $input ): int { /** * Data provider for testing different types of execute callbacks. + * + * @return array Data sets with different execute callbacks. */ public function data_execute_callback() { return array( @@ -430,7 +461,11 @@ static function ( string $input ): int { /** * Tests the execution of the ability with different types of callbacks. * + * @ticket 64098 + * * @dataProvider data_execute_callback + * + * @param callable $execute_callback The execute callback to test. */ public function test_execute_with_different_callbacks( $execute_callback ) { $args = array_merge( @@ -452,6 +487,8 @@ public function test_execute_with_different_callbacks( $execute_callback ) { /** * Tests the execution of the ability with no input. + * + * @ticket 64098 */ public function test_execute_no_input() { $args = array_merge( @@ -470,6 +507,8 @@ public function test_execute_no_input() { /** * Tests that before_execute_ability action is fired with correct parameters. + * + * @ticket 64098 */ public function test_before_execute_ability_action() { $action_ability_name = null; @@ -508,6 +547,8 @@ public function test_before_execute_ability_action() { /** * Tests that before_execute_ability action is fired with null input when no input schema is defined. + * + * @ticket 64098 */ public function test_before_execute_ability_action_no_input() { $action_ability_name = null; @@ -541,6 +582,8 @@ public function test_before_execute_ability_action_no_input() { /** * Tests that after_execute_ability action is fired with correct parameters. + * + * @ticket 64098 */ public function test_after_execute_ability_action() { $action_ability_name = null; @@ -582,6 +625,8 @@ public function test_after_execute_ability_action() { /** * Tests that after_execute_ability action is fired with null input when no input schema is defined. + * + * @ticket 64098 */ public function test_after_execute_ability_action_no_input() { $action_ability_name = null; @@ -619,6 +664,8 @@ public function test_after_execute_ability_action_no_input() { /** * Tests that neither action is fired when execution fails due to permission issues. + * + * @ticket 64098 */ public function test_actions_not_fired_on_permission_failure() { $before_action_fired = false; @@ -657,6 +704,8 @@ public function test_actions_not_fired_on_permission_failure() { /** * Tests that after_execute_ability action is not fired when execution callback returns WP_Error. + * + * @ticket 64098 */ public function test_after_action_not_fired_on_execution_error() { $before_action_fired = false; @@ -695,6 +744,8 @@ public function test_after_action_not_fired_on_execution_error() { /** * Tests that after_execute_ability action is not fired when output validation fails. + * + * @ticket 64098 */ public function test_after_action_not_fired_on_output_validation_error() { $before_action_fired = false; diff --git a/tests/phpunit/tests/abilities-api/wpAbilityCategory.php b/tests/phpunit/tests/abilities-api/wpAbilityCategory.php index c82056c430526..f65496bd39b98 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilityCategory.php +++ b/tests/phpunit/tests/abilities-api/wpAbilityCategory.php @@ -122,6 +122,8 @@ private function register_category_during_hook( string $slug, array $args ): ?WP /** * Test registering a valid category. + * + * @ticket 64098 */ public function test_register_valid_category(): void { $result = $this->register_category_during_hook( @@ -141,6 +143,8 @@ public function test_register_valid_category(): void { /** * Test registering category with invalid slug format. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Category_Registry::register */ public function test_register_category_invalid_slug_format(): void { @@ -160,6 +164,8 @@ public function test_register_category_invalid_slug_format(): void { /** * Test registering category with invalid slug - underscore. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Category_Registry::register */ public function test_register_category_invalid_slug_underscore(): void { @@ -178,6 +184,8 @@ public function test_register_category_invalid_slug_underscore(): void { /** * Test registering category without label. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Category_Registry::register */ public function test_register_category_missing_label(): void { @@ -195,6 +203,8 @@ public function test_register_category_missing_label(): void { /** * Test registering category without description. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Category_Registry::register */ public function test_register_category_missing_description(): void { @@ -212,6 +222,8 @@ public function test_register_category_missing_description(): void { /** * Test registering category before abilities_api_categories_init hook. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Category_Registry::register */ public function test_register_category_before_init_hook(): void { @@ -243,6 +255,8 @@ public function test_register_category_before_init_hook(): void { /** * Test registering duplicate category. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Category_Registry::register */ public function test_register_duplicate_category(): void { @@ -275,6 +289,8 @@ public function test_register_duplicate_category(): void { /** * Test unregistering existing category. + * + * @ticket 64098 */ public function test_unregister_existing_category(): void { $this->register_category_during_hook( @@ -294,6 +310,8 @@ public function test_unregister_existing_category(): void { /** * Test unregistering non-existent category. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Category_Registry::unregister */ public function test_unregister_nonexistent_category(): void { @@ -305,6 +323,8 @@ public function test_unregister_nonexistent_category(): void { /** * Test retrieving existing category. + * + * @ticket 64098 */ public function test_get_existing_category(): void { $this->register_category_during_hook( @@ -324,6 +344,8 @@ public function test_get_existing_category(): void { /** * Test retrieving non-existent category. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Category_Registry::get_registered */ public function test_get_nonexistent_category(): void { @@ -335,6 +357,8 @@ public function test_get_nonexistent_category(): void { /** * Test retrieving all registered categories. + * + * @ticket 64098 */ public function test_get_all_categories(): void { $this->register_category_during_hook( @@ -363,6 +387,8 @@ public function test_get_all_categories(): void { /** * Test category is_registered method. + * + * @ticket 64098 */ public function test_category_is_registered(): void { $this->assertFalse( $this->registry->is_registered( 'test-math' ) ); @@ -381,6 +407,8 @@ public function test_category_is_registered(): void { /** * Test ability can only be registered with existing category. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_ability_requires_existing_category(): void { @@ -412,6 +440,8 @@ public function test_ability_requires_existing_category(): void { /** * Test ability can be registered with valid category. + * + * @ticket 64098 */ public function test_ability_with_valid_category(): void { $category_callback = static function (): void { @@ -451,6 +481,8 @@ public function test_ability_with_valid_category(): void { /** * Test category registry singleton. + * + * @ticket 64098 */ public function test_category_registry_singleton(): void { $instance1 = WP_Abilities_Category_Registry::get_instance(); @@ -461,6 +493,8 @@ public function test_category_registry_singleton(): void { /** * Test category with special characters in label and description. + * + * @ticket 64098 */ public function test_category_with_special_characters(): void { $result = $this->register_category_during_hook( @@ -481,7 +515,7 @@ public function test_category_with_special_characters(): void { * * @return array> */ - public function valid_slug_provider(): array { + public function data_valid_slug_provider(): array { return array( array( 'test-simple' ), array( 'test-multiple-words' ), @@ -494,7 +528,11 @@ public function valid_slug_provider(): array { /** * Test category slug validation with valid formats. * - * @dataProvider valid_slug_provider + * @ticket 64098 + * + * @dataProvider data_valid_slug_provider + * + * @param string $slug The category slug to test. */ public function test_category_slug_valid_formats( string $slug ): void { $result = $this->register_category_during_hook( @@ -513,7 +551,7 @@ public function test_category_slug_valid_formats( string $slug ): void { * * @return array> */ - public function invalid_slug_provider(): array { + public function data_invalid_slug_provider(): array { return array( array( 'Test-Uppercase' ), array( 'test_underscore' ), @@ -529,8 +567,12 @@ public function invalid_slug_provider(): array { /** * Test category slug validation with invalid formats. * - * @dataProvider invalid_slug_provider + * @ticket 64098 + * + * @dataProvider data_invalid_slug_provider * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * + * @param string $slug The category slug to test. */ public function test_category_slug_invalid_formats( string $slug ): void { $result = $this->register_category_during_hook( @@ -548,6 +590,8 @@ public function test_category_slug_invalid_formats( string $slug ): void { /** * Test registering category with non-string label. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Category_Registry::register */ public function test_category_constructor_non_string_label(): void { @@ -566,6 +610,8 @@ public function test_category_constructor_non_string_label(): void { /** * Test registering category with empty label. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Category_Registry::register */ public function test_category_constructor_empty_label(): void { @@ -584,6 +630,8 @@ public function test_category_constructor_empty_label(): void { /** * Test registering category with non-string description. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Category_Registry::register */ public function test_category_constructor_non_string_description(): void { @@ -602,6 +650,8 @@ public function test_category_constructor_non_string_description(): void { /** * Test registering category with empty description. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Category_Registry::register */ public function test_category_constructor_empty_description(): void { @@ -619,6 +669,8 @@ public function test_category_constructor_empty_description(): void { /** * Test register_ability_category_args filter. + * + * @ticket 64098 */ public function test_register_category_args_filter(): void { add_filter( @@ -649,6 +701,8 @@ static function ( $args, $slug ) { /** * Test that WP_Ability_Category cannot be unserialized. + * + * @ticket 64098 */ public function test_category_wakeup_throws_exception(): void { $category = $this->register_category_during_hook( @@ -666,6 +720,8 @@ public function test_category_wakeup_throws_exception(): void { /** * Test registering a category with valid meta. + * + * @ticket 64098 */ public function test_register_category_with_valid_meta(): void { $meta = array( @@ -690,6 +746,8 @@ public function test_register_category_with_valid_meta(): void { /** * Test registering a category with empty meta array. + * + * @ticket 64098 */ public function test_register_category_with_empty_meta(): void { $result = $this->register_category_during_hook( @@ -707,6 +765,8 @@ public function test_register_category_with_empty_meta(): void { /** * Test registering a category without meta returns empty array. + * + * @ticket 64098 */ public function test_register_category_without_meta_returns_empty_array(): void { $result = $this->register_category_during_hook( @@ -724,6 +784,8 @@ public function test_register_category_without_meta_returns_empty_array(): void /** * Test registering a category with invalid meta (non-array). * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Category_Registry::register */ public function test_register_category_with_invalid_meta(): void { @@ -743,6 +805,8 @@ public function test_register_category_with_invalid_meta(): void { /** * Test registering a category with unknown property triggers _doing_it_wrong. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Ability_Category::__construct */ public function test_register_category_with_unknown_property(): void { diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index 04a39a210ef5f..785ac3edc742d 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -114,6 +114,8 @@ public function tear_down(): void { /** * Tests registering an ability with invalid name. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_ability_invalid_name(): void { @@ -127,6 +129,8 @@ public function test_register_ability_invalid_name(): void { /** * Tests registering an ability when `abilities_api_init` hook is not fired. * + * @ticket 64098 + * * @expectedIncorrectUsage wp_register_ability */ public function test_register_ability_no_abilities_api_init_hook(): void { @@ -150,6 +154,8 @@ public function test_register_ability_no_abilities_api_init_hook(): void { /** * Tests registering a valid ability. + * + * @ticket 64098 */ public function test_register_valid_ability(): void { do_action( 'abilities_api_init' ); @@ -199,6 +205,8 @@ public function test_register_valid_ability(): void { /** * Tests executing an ability with no permissions. + * + * @ticket 64098 */ public function test_register_ability_no_permissions(): void { do_action( 'abilities_api_init' ); @@ -232,6 +240,8 @@ public function test_register_ability_no_permissions(): void { /** * Tests registering an ability with a custom ability class. + * + * @ticket 64098 */ public function test_register_ability_custom_ability_class(): void { do_action( 'abilities_api_init' ); @@ -272,6 +282,8 @@ public function test_register_ability_custom_ability_class(): void { /** * Tests executing an ability with input not matching schema. + * + * @ticket 64098 */ public function test_execute_ability_no_input_schema_match(): void { do_action( 'abilities_api_init' ); @@ -299,6 +311,8 @@ public function test_execute_ability_no_input_schema_match(): void { /** * Tests executing an ability with output not matching schema. + * + * @ticket 64098 */ public function test_execute_ability_no_output_schema_match(): void { do_action( 'abilities_api_init' ); @@ -328,6 +342,8 @@ public function test_execute_ability_no_output_schema_match(): void { /** * Tests permission callback receiving input not matching schema. + * + * @ticket 64098 */ public function test_permission_callback_no_input_schema_match(): void { do_action( 'abilities_api_init' ); @@ -356,6 +372,8 @@ public function test_permission_callback_no_input_schema_match(): void { /** * Tests that deprecated has_permission() method still works. * + * @ticket 64098 + * * @expectedDeprecated WP_Ability::has_permission */ public function test_has_permission_deprecated_coverage(): void { @@ -376,6 +394,8 @@ public function test_has_permission_deprecated_coverage(): void { /** * Tests permission callback receiving input for contextual permission checks. + * + * @ticket 64098 */ public function test_permission_callback_receives_input(): void { do_action( 'abilities_api_init' ); @@ -426,6 +446,8 @@ public function test_permission_callback_receives_input(): void { /** * Tests unregistering existing ability. + * + * @ticket 64098 */ public function test_unregister_existing_ability() { do_action( 'abilities_api_init' ); @@ -442,6 +464,8 @@ public function test_unregister_existing_ability() { /** * Tests retrieving existing ability. + * + * @ticket 64098 */ public function test_get_existing_ability() { $name = self::$test_ability_name; @@ -471,6 +495,8 @@ public function test_get_existing_ability() { /** * Tests retrieving all registered abilities. + * + * @ticket 64098 */ public function test_get_all_registered_abilities() { do_action( 'abilities_api_init' ); @@ -500,6 +526,8 @@ public function test_get_all_registered_abilities() { /** * Tests registering an ability with non-existent category. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_ability_nonexistent_category(): void { diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php index 24093c4655847..c761ad08b75b1 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php @@ -4,6 +4,7 @@ * Tests for the REST list controller for abilities endpoint. * * @covers WP_REST_Abilities_List_Controller + * * @group abilities-api * @group rest-api */ @@ -256,6 +257,8 @@ private function register_test_abilities(): void { /** * Test listing all abilities. + * + * @ticket 64098 */ public function test_get_items(): void { $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); @@ -277,6 +280,8 @@ public function test_get_items(): void { /** * Test getting a specific ability. + * + * @ticket 64098 */ public function test_get_item(): void { $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/calculator' ); @@ -297,6 +302,8 @@ public function test_get_item(): void { /** * Test getting a non-existent ability returns 404. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Registry::get_registered */ public function test_get_item_not_found(): void { @@ -311,6 +318,8 @@ public function test_get_item_not_found(): void { /** * Test getting an ability that does not show in REST returns 404. + * + * @ticket 64098 */ public function test_get_item_not_show_in_rest(): void { $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/not-show-in-rest' ); @@ -324,6 +333,8 @@ public function test_get_item_not_show_in_rest(): void { /** * Test permission check for listing abilities. + * + * @ticket 64098 */ public function test_get_items_permission_denied(): void { // Test with non-logged-in user @@ -337,6 +348,8 @@ public function test_get_items_permission_denied(): void { /** * Test pagination headers. + * + * @ticket 64098 */ public function test_pagination_headers(): void { $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); @@ -356,6 +369,8 @@ public function test_pagination_headers(): void { /** * Test HEAD method returns empty body with proper headers. + * + * @ticket 64098 */ public function test_head_request(): void { $request = new WP_REST_Request( 'HEAD', '/wp/v2/abilities' ); @@ -373,6 +388,8 @@ public function test_head_request(): void { /** * Test pagination links. + * + * @ticket 64098 */ public function test_pagination_links(): void { // Test first page (should have 'next' link header but no 'prev') @@ -413,6 +430,8 @@ public function test_pagination_links(): void { /** * Test collection parameters. + * + * @ticket 64098 */ public function test_collection_params(): void { // Test per_page parameter @@ -444,6 +463,8 @@ public function test_collection_params(): void { /** * Test response links for individual abilities. + * + * @ticket 64098 */ public function test_ability_response_links(): void { $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/calculator' ); @@ -467,6 +488,8 @@ public function test_ability_response_links(): void { /** * Test context parameter. + * + * @ticket 64098 */ public function test_context_parameter(): void { $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/calculator' ); @@ -486,6 +509,8 @@ public function test_context_parameter(): void { /** * Test schema retrieval. + * + * @ticket 64098 */ public function test_get_schema(): void { $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/abilities' ); @@ -525,6 +550,8 @@ public function test_get_schema(): void { /** * Test ability name with valid special characters. + * + * @ticket 64098 */ public function test_ability_name_with_valid_special_characters(): void { // Register ability with hyphen (valid) @@ -555,7 +582,7 @@ public function test_ability_name_with_valid_special_characters(): void { * * @return array */ - public function invalid_ability_names_provider(): array { + public function data_invalid_ability_names_provider(): array { return array( '@ symbol' => array( 'test@ability' ), 'space' => array( 'test ability' ), @@ -571,7 +598,10 @@ public function invalid_ability_names_provider(): array { /** * Test ability names with invalid special characters. * - * @dataProvider invalid_ability_names_provider + * @ticket 64098 + * + * @dataProvider data_invalid_ability_names_provider + * * @param string $name Invalid ability name to test. */ public function test_ability_name_with_invalid_special_characters( string $name ): void { @@ -581,9 +611,11 @@ public function test_ability_name_with_invalid_special_characters( string $name $this->assertEquals( 404, $response->get_status() ); } - /** * Test extremely long ability names. + * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Registry::get_registered */ public function test_extremely_long_ability_names(): void { @@ -602,7 +634,7 @@ public function test_extremely_long_ability_names(): void { * * @return array}> */ - public function invalid_pagination_params_provider(): array { + public function data_invalid_pagination_params_provider(): array { return array( 'Zero page' => array( array( 'page' => 0 ) ), 'Negative page' => array( array( 'page' => -1 ) ), @@ -617,7 +649,10 @@ public function invalid_pagination_params_provider(): array { /** * Test pagination parameters with invalid values. * - * @dataProvider invalid_pagination_params_provider + * @ticket 64098 + * + * @dataProvider data_invalid_pagination_params_provider + * * @param array $params Invalid pagination parameters. */ public function test_invalid_pagination_parameters( array $params ): void { @@ -640,6 +675,8 @@ public function test_invalid_pagination_parameters( array $params ): void { /** * Test filtering abilities by category. + * + * @ticket 64098 */ public function test_filter_by_category(): void { $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); @@ -664,6 +701,8 @@ public function test_filter_by_category(): void { /** * Test filtering by non-existent category returns empty results. + * + * @ticket 64098 */ public function test_filter_by_nonexistent_category(): void { // Ensure category doesn't exist - test should fail if it does. @@ -685,6 +724,8 @@ public function test_filter_by_nonexistent_category(): void { /** * Test that category field is present in response. + * + * @ticket 64098 */ public function test_category_field_in_response(): void { $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/calculator' ); diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php index 92693cc63159d..2efeed7a0e480 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php @@ -4,6 +4,7 @@ * Tests for the REST run controller for abilities endpoint. * * @covers WP_REST_Abilities_Run_Controller + * * @group abilities-api * @group rest-api */ @@ -347,6 +348,8 @@ private function register_test_abilities(): void { /** * Test executing a regular ability with POST. + * + * @ticket 64098 */ public function test_execute_regular_ability_post(): void { $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/calculator/run' ); @@ -370,6 +373,8 @@ public function test_execute_regular_ability_post(): void { /** * Test executing a read-only ability with GET. + * + * @ticket 64098 */ public function test_execute_readonly_ability_get(): void { $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/user-info/run' ); @@ -390,6 +395,8 @@ public function test_execute_readonly_ability_get(): void { /** * Test HTTP method validation for regular abilities. + * + * @ticket 64098 */ public function test_regular_ability_requires_post(): void { wp_register_ability( @@ -419,6 +426,8 @@ public function test_regular_ability_requires_post(): void { /** * Test HTTP method validation for read-only abilities. + * + * @ticket 64098 */ public function test_readonly_ability_requires_get(): void { // Try POST on a read-only ability (should fail). @@ -439,6 +448,8 @@ public function test_readonly_ability_requires_get(): void { * Test output validation against schema. * Note: When output validation fails in WP_Ability::execute(), it returns null, * which causes the REST controller to return 'ability_invalid_output'. + * + * @ticket 64098 */ public function test_output_validation(): void { $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/invalid-output/run' ); @@ -457,6 +468,8 @@ public function test_output_validation(): void { /** * Test permission check for execution. + * + * @ticket 64098 */ public function test_execution_permission_denied(): void { wp_set_current_user( self::$no_permission_user_id ); @@ -484,6 +497,8 @@ public function test_execution_permission_denied(): void { /** * Test contextual permission check. + * + * @ticket 64098 */ public function test_contextual_permission_check(): void { $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/restricted/run' ); @@ -520,6 +535,8 @@ public function test_contextual_permission_check(): void { /** * Test handling an ability that does not show in REST. + * + * @ticket 64098 */ public function test_do_not_show_in_rest(): void { $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/not-show-in-rest/run' ); @@ -535,6 +552,8 @@ public function test_do_not_show_in_rest(): void { /** * Test handling of null is a valid return value. + * + * @ticket 64098 */ public function test_null_return_handling(): void { $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/null-return/run' ); @@ -549,6 +568,8 @@ public function test_null_return_handling(): void { /** * Test handling of WP_Error return from ability. + * + * @ticket 64098 */ public function test_wp_error_return_handling(): void { $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/error-return/run' ); @@ -565,6 +586,8 @@ public function test_wp_error_return_handling(): void { /** * Test non-existent ability returns 404. * + * @ticket 64098 + * * @expectedIncorrectUsage WP_Abilities_Registry::get_registered */ public function test_execute_non_existent_ability(): void { @@ -580,6 +603,8 @@ public function test_execute_non_existent_ability(): void { /** * Test schema retrieval for run endpoint. + * + * @ticket 64098 */ public function test_run_endpoint_schema(): void { $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/abilities/test/calculator/run' ); @@ -597,6 +622,8 @@ public function test_run_endpoint_schema(): void { /** * Test that invalid JSON in POST body is handled correctly. + * + * @ticket 64098 */ public function test_invalid_json_in_post_body(): void { $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/calculator/run' ); @@ -612,6 +639,8 @@ public function test_invalid_json_in_post_body(): void { /** * Test GET request with complex nested input array. + * + * @ticket 64098 */ public function test_get_request_with_nested_input_array(): void { $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/query-params/run' ); @@ -638,6 +667,8 @@ public function test_get_request_with_nested_input_array(): void { /** * Test GET request with non-array input parameter. + * + * @ticket 64098 */ public function test_get_request_with_non_array_input(): void { $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/query-params/run' ); @@ -654,6 +685,8 @@ public function test_get_request_with_non_array_input(): void { /** * Test POST request with non-array input in JSON body. + * + * @ticket 64098 */ public function test_post_request_with_non_array_input(): void { $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/calculator/run' ); @@ -673,6 +706,8 @@ public function test_post_request_with_non_array_input(): void { /** * Test ability with invalid output that fails validation. + * + * @ticket 64098 */ public function test_output_validation_failure_returns_error(): void { // Register ability with strict output schema. @@ -720,6 +755,8 @@ public function test_output_validation_failure_returns_error(): void { /** * Test ability with invalid input that fails validation. + * + * @ticket 64098 */ public function test_input_validation_failure_returns_error(): void { // Register ability with strict input schema. @@ -767,6 +804,8 @@ public function test_input_validation_failure_returns_error(): void { /** * Test ability without annotations defaults to POST method. + * + * @ticket 64098 */ public function test_ability_without_annotations_defaults_to_post_method(): void { // Register ability without annotations. @@ -801,6 +840,8 @@ public function test_ability_without_annotations_defaults_to_post_method(): void /** * Test edge case with empty input for both GET and POST methods. + * + * @ticket 64098 */ public function test_empty_input_handling(): void { // Registers abilities for empty input testing. @@ -876,7 +917,10 @@ public function data_malformed_json_provider(): array { /** * Test malformed JSON in POST body. * + * @ticket 64098 + * * @dataProvider data_malformed_json_provider + * * @param string $json Malformed JSON to test. */ public function test_malformed_json_post_body( string $json ): void { @@ -890,9 +934,10 @@ public function test_malformed_json_post_body( string $json ): void { $this->assertEquals( 400, $response->get_status() ); } - /** * Test input with various PHP types as strings. + * + * @ticket 64098 */ public function test_php_type_strings_in_input(): void { // Register ability that accepts any input @@ -940,6 +985,8 @@ public function test_php_type_strings_in_input(): void { /** * Test input with mixed encoding. + * + * @ticket 64098 */ public function test_mixed_encoding_in_input(): void { // Register ability that accepts any input @@ -1004,7 +1051,10 @@ public function data_invalid_http_methods_provider(): array { /** * Test request with invalid HTTP methods. * + * @ticket 64098 + * * @dataProvider data_invalid_http_methods_provider + * * @param string $method HTTP method to test. */ public function test_invalid_http_methods( string $method ): void { @@ -1037,6 +1087,8 @@ public function test_invalid_http_methods( string $method ): void { /** * Test OPTIONS method handling. + * + * @ticket 64098 */ public function test_options_method_handling(): void { $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/abilities/test/calculator/run' ); From 4ee5446ef0c37115530f3ee539dddb7094ef914a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Zi=C3=B3=C5=82kowski?= Date: Thu, 16 Oct 2025 20:05:19 +0200 Subject: [PATCH 08/31] Apply suggestions from code review Co-authored-by: Aaron Jorbin --- src/wp-includes/abilities-api.php | 16 +++--- .../class-wp-abilities-category-registry.php | 24 ++++---- .../class-wp-abilities-registry.php | 22 ++++---- .../class-wp-ability-category.php | 14 ++--- .../abilities-api/class-wp-ability.php | 45 +++++---------- .../tests/abilities-api/wpRegisterAbility.php | 23 -------- .../wpRestAbilitiesListController.php | 56 +++++++++++-------- .../rest-api/wpRestAbilitiesRunController.php | 46 +++++++++------ 8 files changed, 113 insertions(+), 133 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index 1e22a8488a804..d77c66020a410 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -14,7 +14,7 @@ /** * Registers a new ability using Abilities API. * - * Note: Do not use before the {@see 'abilities_api_init'} hook. + * Note: Should only be used on the {@see 'abilities_api_init'} hook. * * @since 6.9.0 * @@ -26,7 +26,7 @@ * @param array $args An associative array of arguments for the ability. This should include * `label`, `description`, `category`, `input_schema`, `output_schema`, `execute_callback`, * `permission_callback`, `meta`, and `ability_class`. - * @return ?\WP_Ability An instance of registered ability on success, null on failure. + * @return WP_Ability|null An instance of registered ability on success, null on failure. * * @phpstan-param array{ * label?: string, @@ -64,14 +64,14 @@ function wp_register_ability( string $name, array $args ): ?WP_Ability { } /** - * Unregisters an ability using Abilities API. + * Unregisters an ability from the Abilities API. * * @since 6.9.0 * * @see WP_Abilities_Registry::unregister() * * @param string $name The name of the registered ability, with its namespace. - * @return ?\WP_Ability The unregistered ability instance on success, null on failure. + * @return WP_Ability|null The unregistered ability instance on success, null on failure. */ function wp_unregister_ability( string $name ): ?WP_Ability { return WP_Abilities_Registry::get_instance()->unregister( $name ); @@ -85,7 +85,7 @@ function wp_unregister_ability( string $name ): ?WP_Ability { * @see WP_Abilities_Registry::get_registered() * * @param string $name The name of the registered ability, with its namespace. - * @return ?\WP_Ability The registered ability instance, or null if it is not registered. + * @return WP_Ability|null The registered ability instance, or null if it is not registered. */ function wp_get_ability( string $name ): ?WP_Ability { return WP_Abilities_Registry::get_instance()->get_registered( $name ); @@ -115,7 +115,7 @@ function wp_get_abilities(): array { * alphanumeric characters and dashes. * @param array $args An associative array of arguments for the category. This should * include `label`, `description`, and optionally `meta`. - * @return ?\WP_Ability_Category The registered category instance on success, null on failure. + * @return WP_Ability_Category|null The registered category instance on success, null on failure. * * @phpstan-param array{ * label: string, @@ -136,7 +136,7 @@ function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_ * @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. + * @return WP_Ability_Category|null 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 ); @@ -150,7 +150,7 @@ function wp_unregister_ability_category( string $slug ): ?WP_Ability_Category { * @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. + * @return WP_Ability_Category|null 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 ); diff --git a/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php index 227f765da2a40..57348e2bc0f27 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php @@ -22,7 +22,7 @@ final class WP_Abilities_Category_Registry { * The singleton instance of the registry. * * @since 6.9.0 - * @var ?self + * @var self|null */ private static $instance = null; @@ -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|null The registered category instance on success, null on failure. * * @phpstan-param array{ * label: string, @@ -62,7 +62,7 @@ public function register( string $slug, array $args ): ?WP_Ability_Category { __METHOD__, sprintf( /* translators: 1: abilities_api_categories_init, 2: category slug. */ - esc_html__( 'Categories must be registered during the %1$s action. The category %2$s was not registered.' ), + __( 'Categories must be registered during the %1$s action. The category %2$s was not registered.' ), 'abilities_api_categories_init', '' . esc_html( $slug ) . '' ), @@ -75,7 +75,7 @@ public function register( string $slug, array $args ): ?WP_Ability_Category { _doing_it_wrong( __METHOD__, /* translators: %s: Category slug. */ - esc_html( sprintf( __( 'Category "%s" is already registered.' ), $slug ) ), + sprintf( __( 'Category "%s" is already registered.' ), esc_html( $slug ) ), '6.9.0' ); return null; @@ -84,7 +84,7 @@ public function register( string $slug, array $args ): ?WP_Ability_Category { if ( ! preg_match( '/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug ) ) { _doing_it_wrong( __METHOD__, - esc_html__( 'Category slug must contain only lowercase alphanumeric characters and dashes.' ), + __( 'Category slug must contain only lowercase alphanumeric characters and dashes.' ), '6.9.0' ); return null; @@ -106,7 +106,7 @@ public function register( string $slug, array $args ): ?WP_Ability_Category { } catch ( \InvalidArgumentException $e ) { _doing_it_wrong( __METHOD__, - esc_html( $e->getMessage() ), + $e->getMessage(), '6.9.0' ); return null; @@ -126,14 +126,14 @@ 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|null The unregistered category instance on success, null on failure. */ public function unregister( string $slug ): ?WP_Ability_Category { if ( ! $this->is_registered( $slug ) ) { _doing_it_wrong( __METHOD__, /* translators: %s: Ability category slug. */ - sprintf( esc_html__( 'Ability category "%s" not found.' ), esc_attr( $slug ) ), + sprintf( __( 'Ability category "%s" not found.' ), esc_html( $slug ) ), '6.9.0' ); return null; @@ -182,14 +182,14 @@ 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|null The registered category instance, or null if it is not registered. */ public function get_registered( string $slug ): ?WP_Ability_Category { if ( ! $this->is_registered( $slug ) ) { _doing_it_wrong( __METHOD__, /* translators: %s: Ability category slug. */ - sprintf( esc_html__( 'Ability category "%s" not found.' ), esc_attr( $slug ) ), + sprintf( __( 'Ability category "%s" not found.' ), esc_html( $slug ) ), '6.9.0' ); return null; @@ -232,7 +232,7 @@ public static function get_instance(): self { * @throws \LogicException If the registry is unserialized. This is a security hardening measure to prevent unserialization of the registry. */ public function __wakeup(): void { - throw new \LogicException( self::class . ' must not be unserialized.' ); + throw new \LogicException( __CLASS__ . ' should never be unserialized.' ); } /** @@ -242,6 +242,6 @@ public function __wakeup(): void { * @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.' ); + throw new \LogicException( __CLASS__ . ' should never be serialized' ); } } diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index 7cf19dd325e4a..c5733ffc14a9b 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -22,7 +22,7 @@ final class WP_Abilities_Registry { * The singleton instance of the registry. * * @since 6.9.0 - * @var ?self + * @var self|null */ private static $instance = null; @@ -48,7 +48,7 @@ final class WP_Abilities_Registry { * alphanumeric characters, dashes and the forward slash. * @param array $args An associative array of arguments for the ability. See wp_register_ability() for * details. - * @return ?\WP_Ability The registered ability instance on success, null on failure. + * @return WP_Ability|null The registered ability instance on success, null on failure. * * @phpstan-param array{ * label?: string, @@ -71,7 +71,7 @@ public function register( string $name, array $args ): ?WP_Ability { if ( ! preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $name ) ) { _doing_it_wrong( __METHOD__, - esc_html__( + __( 'Ability name must be a string containing a namespace prefix, i.e. "my-plugin/my-ability". It can only contain lowercase alphanumeric characters, dashes and the forward slash.' ), '6.9.0' @@ -83,7 +83,7 @@ public function register( string $name, array $args ): ?WP_Ability { _doing_it_wrong( __METHOD__, /* translators: %s: Ability name. */ - esc_html( sprintf( __( 'Ability "%s" is already registered.' ), $name ) ), + sprintf( __( 'Ability "%s" is already registered.' ), esc_html( $name ) ), '6.9.0' ); return null; @@ -107,9 +107,9 @@ public function register( string $name, array $args ): ?WP_Ability { __METHOD__, sprintf( /* translators: %1$s: ability category slug, %2$s: ability name */ - esc_html__( 'Ability category "%1$s" is not registered. Please register the category before assigning it to ability "%2$s".' ), - esc_attr( $args['category'] ), - esc_attr( $name ) + __( 'Ability category "%1$s" is not registered. Please register the category before assigning it to ability "%2$s".' ), + esc_html( $args['category'] ), + esc_html( $name ) ), '6.9.0' ); @@ -121,7 +121,7 @@ public function register( string $name, array $args ): ?WP_Ability { if ( isset( $args['ability_class'] ) && ! is_a( $args['ability_class'], WP_Ability::class, true ) ) { _doing_it_wrong( __METHOD__, - esc_html__( 'The ability args should provide a valid `ability_class` that extends WP_Ability.' ), + __( 'The ability args should provide a valid `ability_class` that extends WP_Ability.' ), '6.9.0' ); return null; @@ -137,7 +137,7 @@ public function register( string $name, array $args ): ?WP_Ability { } catch ( \InvalidArgumentException $e ) { _doing_it_wrong( __METHOD__, - esc_html( $e->getMessage() ), + $e->getMessage(), '6.9.0' ); return null; @@ -157,14 +157,14 @@ public function register( string $name, array $args ): ?WP_Ability { * @see wp_unregister_ability() * * @param string $name The name of the registered ability, with its namespace. - * @return ?\WP_Ability The unregistered ability instance on success, null on failure. + * @return WP_Ability|null The unregistered ability instance on success, null on failure. */ public function unregister( string $name ): ?WP_Ability { if ( ! $this->is_registered( $name ) ) { _doing_it_wrong( __METHOD__, /* translators: %s: Ability name. */ - sprintf( esc_html__( 'Ability "%s" not found.' ), esc_attr( $name ) ), + sprintf( __( 'Ability "%s" not found.' ), esc_html( $name ) ), '6.9.0' ); return null; diff --git a/src/wp-includes/abilities-api/class-wp-ability-category.php b/src/wp-includes/abilities-api/class-wp-ability-category.php index 622e0f024c65b..da2f00e0478f4 100644 --- a/src/wp-includes/abilities-api/class-wp-ability-category.php +++ b/src/wp-includes/abilities-api/class-wp-ability-category.php @@ -83,10 +83,10 @@ public function __construct( string $slug, array $args ) { __METHOD__, sprintf( /* translators: %s: Property name. */ - esc_html__( 'Property "%1$s" is not a valid property for category "%2$s". Please check the %3$s class for allowed properties.' ), + __( 'Property "%1$s" is not a valid property for category "%2$s". Please check the %3$s class for allowed properties.' ), '' . esc_html( $property_name ) . '', '' . esc_html( $this->slug ) . '', - '' . esc_html( self::class ) . '' + '' . __CLASS__ . '' ), '6.9.0' ); @@ -117,20 +117,20 @@ 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.' ) + __( '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.' ) + __( 'The category properties must contain a `description` string.' ) ); } // Optional args only need to be of the correct type if they are present. if ( isset( $args['meta'] ) && ! is_array( $args['meta'] ) ) { throw new \InvalidArgumentException( - esc_html__( 'The category properties should provide a valid `meta` array.' ) + __( 'The category properties should provide a valid `meta` array.' ) ); } @@ -188,7 +188,7 @@ public function get_meta(): array { * @throws \LogicException If the category is unserialized. This is a security hardening measure to prevent unserialization of the category. */ public function __wakeup(): void { - throw new \LogicException( self::class . ' must not be unserialized.' ); + throw new \LogicException( __CLASS__ . ' should never be unserialized.' ); } /** @@ -198,6 +198,6 @@ public function __wakeup(): void { * @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.' ); + throw new \LogicException( __CLASS__ . ' should never be serialized' ); } } diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index b359592155ab9..53a83df24713b 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -152,10 +152,10 @@ public function __construct( string $name, array $args ) { __METHOD__, sprintf( /* translators: %s: Property name. */ - esc_html__( 'Property "%1$s" is not a valid property for ability "%2$s". Please check the %3$s class for allowed properties.' ), + __( 'Property "%1$s" is not a valid property for ability "%2$s". Please check the %3$s class for allowed properties.' ), '' . esc_html( $property_name ) . '', '' . esc_html( $this->name ) . '', - '' . esc_html( self::class ) . '' + '' . self::class . '' ), '6.9.0' ); @@ -200,62 +200,62 @@ protected function prepare_properties( array $args ): array { // Required args must be present and of the correct type. if ( empty( $args['label'] ) || ! is_string( $args['label'] ) ) { throw new \InvalidArgumentException( - esc_html__( 'The ability properties must contain a `label` string.' ) + __( 'The ability properties must contain a `label` string.' ) ); } if ( empty( $args['description'] ) || ! is_string( $args['description'] ) ) { throw new \InvalidArgumentException( - esc_html__( 'The ability properties must contain a `description` string.' ) + __( 'The ability properties must contain a `description` string.' ) ); } if ( empty( $args['category'] ) || ! is_string( $args['category'] ) ) { throw new \InvalidArgumentException( - esc_html__( 'The ability properties must contain a `category` string.' ) + __( '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.' ) + __( 'The ability properties must contain a valid `execute_callback` function.' ) ); } if ( empty( $args['permission_callback'] ) || ! is_callable( $args['permission_callback'] ) ) { throw new \InvalidArgumentException( - esc_html__( 'The ability properties must provide a valid `permission_callback` function.' ) + __( 'The ability properties must provide a valid `permission_callback` function.' ) ); } // Optional args only need to be of the correct type if they are present. if ( isset( $args['input_schema'] ) && ! is_array( $args['input_schema'] ) ) { throw new \InvalidArgumentException( - esc_html__( 'The ability properties should provide a valid `input_schema` definition.' ) + __( 'The ability properties should provide a valid `input_schema` definition.' ) ); } if ( isset( $args['output_schema'] ) && ! is_array( $args['output_schema'] ) ) { throw new \InvalidArgumentException( - esc_html__( 'The ability properties should provide a valid `output_schema` definition.' ) + __( 'The ability properties should provide a valid `output_schema` definition.' ) ); } if ( isset( $args['meta'] ) && ! is_array( $args['meta'] ) ) { throw new \InvalidArgumentException( - esc_html__( 'The ability properties should provide a valid `meta` array.' ) + __( 'The ability properties should provide a valid `meta` array.' ) ); } if ( isset( $args['meta']['annotations'] ) && ! is_array( $args['meta']['annotations'] ) ) { throw new \InvalidArgumentException( - esc_html__( 'The ability meta should provide a valid `annotations` array.' ) + __( 'The ability meta should provide a valid `annotations` array.' ) ); } if ( isset( $args['meta']['show_in_rest'] ) && ! is_bool( $args['meta']['show_in_rest'] ) ) { throw new \InvalidArgumentException( - esc_html__( 'The ability meta should provide a valid `show_in_rest` boolean.' ) + __( 'The ability meta should provide a valid `show_in_rest` boolean.' ) ); } @@ -444,23 +444,6 @@ public function check_permissions( $input = null ) { return $this->invoke_callback( $this->permission_callback, $input ); } - /** - * Checks whether the ability has the necessary permissions (deprecated). - * - * The input is validated against the input schema before it is passed to to permission callback. - * - * @deprecated 0.2.0 Use check_permissions() instead. - * @see WP_Ability::check_permissions() - * - * @since 6.9.0 - * - * @param mixed $input Optional. The input data for permission checking. Default `null`. - * @return bool|\WP_Error Whether the ability has the necessary permission. - */ - public function has_permission( $input = null ) { - _deprecated_function( __METHOD__, '6.9.0' ); - return $this->check_permissions( $input ); - } /** * Executes the ability callback. @@ -544,7 +527,7 @@ public function execute( $input = null ) { } /** - * Fires before an ability gets executed. + * Fires before an ability gets executed and after permission check. * * @since 6.9.0 * @@ -583,6 +566,6 @@ public function execute( $input = null ) { * @since 6.9.0 */ public function __wakeup(): void { - throw new \LogicException( self::class . ' should never be unserialized.' ); + throw new \LogicException( __CLASS__ . ' should never be unserialized.' ); } } diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index 785ac3edc742d..0d246630ba9b1 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -369,29 +369,6 @@ public function test_permission_callback_no_input_schema_match(): void { ); } - /** - * Tests that deprecated has_permission() method still works. - * - * @ticket 64098 - * - * @expectedDeprecated WP_Ability::has_permission - */ - public function test_has_permission_deprecated_coverage(): void { - do_action( 'abilities_api_init' ); - - $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); - - // Test that deprecated method still works - $this->assertTrue( - $result->has_permission( - array( - 'a' => 2, - 'b' => 3, - ) - ) - ); - } - /** * Tests permission callback receiving input for contextual permission checks. * diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php index c761ad08b75b1..5c73be38fe3dd 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php @@ -36,6 +36,33 @@ public static function set_up_before_class(): void { 'role' => 'subscriber', ) ); + + // Register test categories during the hook. + add_action( + 'abilities_api_categories_init', + array( __CLASS__, 'register_test_categories' ) + ); + do_action( 'abilities_api_categories_init' ); + remove_action( + 'abilities_api_categories_init', + array( __CLASS__, 'register_test_categories' ) + ); + + // Initialize Abilities API. + do_action( 'abilities_api_init' ); + + } + + /** + * Tear down after class. + */ + public static function tear_down_after_class(): void { + // Clean up test categories. + foreach ( array( 'math', 'system', 'general' ) as $slug ) { + wp_unregister_ability_category( $slug ); + } + + parent::tear_down_after_class(); } /** @@ -51,17 +78,6 @@ public function set_up(): void { do_action( 'rest_api_init' ); - // Register test categories during the hook - add_action( - 'abilities_api_categories_init', - array( $this, 'register_test_categories' ) - ); - do_action( 'abilities_api_categories_init' ); - - // Initialize abilities API - do_action( 'abilities_api_init' ); - - // Register test abilities $this->register_test_abilities(); // Set default user for tests @@ -72,7 +88,7 @@ public function set_up(): void { * Tear down after each test. */ public function tear_down(): void { - // Clean up test abilities + // Clean up test abilities. foreach ( wp_get_abilities() as $ability ) { if ( ! str_starts_with( $ability->get_name(), 'test/' ) ) { continue; @@ -81,15 +97,6 @@ public function tear_down(): void { wp_unregister_ability( $ability->get_name() ); } - // Clean up test categories - foreach ( array( 'math', 'system', 'general' ) as $slug ) { - if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( $slug ) ) { - continue; - } - - wp_unregister_ability_category( $slug ); - } - // Reset REST server global $wp_rest_server; $wp_rest_server = null; @@ -100,7 +107,7 @@ public function tear_down(): void { /** * Register test categories for testing. */ - public function register_test_categories(): void { + public static function register_test_categories(): void { wp_register_ability_category( 'math', array( @@ -554,7 +561,7 @@ public function test_get_schema(): void { * @ticket 64098 */ public function test_ability_name_with_valid_special_characters(): void { - // Register ability with hyphen (valid) + // Register ability with hyphen (valid). wp_register_ability( 'test-hyphen/ability', array( @@ -574,6 +581,9 @@ public function test_ability_name_with_valid_special_characters(): void { // Test valid special characters (hyphen, forward slash) $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test-hyphen/ability' ); $response = $this->server->dispatch( $request ); + + wp_unregister_ability( 'test-hyphen/ability' ); + $this->assertEquals( 200, $response->get_status() ); } diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php index 2efeed7a0e480..9aa74b377a169 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php @@ -48,6 +48,32 @@ public static function set_up_before_class(): void { 'role' => 'subscriber', ) ); + + // Register test categories during the hook. + add_action( + 'abilities_api_categories_init', + array( __CLASS__, 'register_test_categories' ) + ); + do_action( 'abilities_api_categories_init' ); + remove_action( + 'abilities_api_categories_init', + array( __CLASS__, 'register_test_categories' ) + ); + + // Initialize Abilities API. + do_action( 'abilities_api_init' ); + } + + /** + * Tear down after class. + */ + public static function tear_down_after_class(): void { + // Clean up test categories. + foreach ( array( 'math', 'system', 'general' ) as $slug ) { + wp_unregister_ability_category( $slug ); + } + + parent::tear_down_after_class(); } /** @@ -62,15 +88,6 @@ public function set_up(): void { do_action( 'rest_api_init' ); - // Register test categories during the hook - add_action( - 'abilities_api_categories_init', - array( $this, 'register_test_categories' ) - ); - do_action( 'abilities_api_categories_init' ); - - do_action( 'abilities_api_init' ); - $this->register_test_abilities(); // Set default user for tests @@ -81,6 +98,7 @@ public function set_up(): void { * Tear down after each test. */ public function tear_down(): void { + // Clean up test abilities. foreach ( wp_get_abilities() as $ability ) { if ( ! str_starts_with( $ability->get_name(), 'test/' ) ) { continue; @@ -89,14 +107,6 @@ public function tear_down(): void { wp_unregister_ability( $ability->get_name() ); } - // Clean up registered categories. - $category_registry = WP_Abilities_Category_Registry::get_instance(); - foreach ( array( 'math', 'system', 'general' ) as $category ) { - if ( $category_registry->is_registered( $category ) ) { - wp_unregister_ability_category( $category ); - } - } - global $wp_rest_server; $wp_rest_server = null; @@ -106,7 +116,7 @@ public function tear_down(): void { /** * Register test categories for testing. */ - public function register_test_categories(): void { + public static function register_test_categories(): void { wp_register_ability_category( 'math', array( From f1c1dc820994db0d8b5dada4d7b50a0619979cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Zi=C3=B3=C5=82kowski?= Date: Thu, 16 Oct 2025 23:36:30 +0200 Subject: [PATCH 09/31] Update src/wp-includes/abilities-api/class-wp-abilities-category-registry.php Co-authored-by: Dovid Levine --- .../class-wp-abilities-category-registry.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php index 57348e2bc0f27..af0c0869c4572 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php @@ -49,12 +49,12 @@ final class WP_Abilities_Category_Registry { * details. * @return WP_Ability_Category|null The registered category instance on success, null on failure. * - * @phpstan-param array{ - * label: string, - * description: string, - * meta?: array, - * ... - * } $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 fd37d48ebed062a030097a64d2d5502eaae3545d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Zi=C3=B3=C5=82kowski?= Date: Thu, 16 Oct 2025 23:51:22 +0200 Subject: [PATCH 10/31] Update src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php Co-authored-by: Weston Ruter --- .../class-wp-abilities-category-registry.php | 2 +- .../abilities-api/class-wp-abilities-registry.php | 2 +- .../endpoints/class-wp-rest-abilities-list-controller.php | 4 ---- tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php | 2 +- tests/phpunit/tests/abilities-api/wpAbilityCategory.php | 2 +- .../tests/rest-api/wpRestAbilitiesListController.php | 8 +++----- .../tests/rest-api/wpRestAbilitiesRunController.php | 7 +++---- 7 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php index af0c0869c4572..dc647db13800c 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php @@ -30,7 +30,7 @@ final class WP_Abilities_Category_Registry { * Holds the registered categories. * * @since 6.9.0 - * @var \WP_Ability_Category[] + * @var WP_Ability_Category[] */ private $registered_categories = array(); diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index c5733ffc14a9b..a27c84da0033d 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -30,7 +30,7 @@ final class WP_Abilities_Registry { * Holds the registered abilities. * * @since 6.9.0 - * @var \WP_Ability[] + * @var WP_Ability[] */ private $registered_abilities = array(); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php index 8933f904d6d55..251da92a5391c 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php @@ -315,8 +315,6 @@ public function get_collection_params(): array { 'description' => __( 'Current page of the collection.' ), 'type' => 'integer', 'default' => 1, - 'sanitize_callback' => 'absint', - 'validate_callback' => 'rest_validate_request_arg', 'minimum' => 1, ), 'per_page' => array( @@ -325,8 +323,6 @@ public function get_collection_params(): array { 'default' => self::DEFAULT_PER_PAGE, 'minimum' => 1, 'maximum' => 100, - 'sanitize_callback' => 'absint', - 'validate_callback' => 'rest_validate_request_arg', ), 'category' => array( 'description' => __( 'Limit results to abilities in specific category.' ), diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php index b9d8c004b5e73..d506f460d894b 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -15,7 +15,7 @@ class Tests_Abilities_API_WpAbilitiesRegistry extends WP_UnitTestCase { /** * Mock abilities registry. * - * @var \WP_Abilities_Registry + * @var WP_Abilities_Registry */ private $registry = null; diff --git a/tests/phpunit/tests/abilities-api/wpAbilityCategory.php b/tests/phpunit/tests/abilities-api/wpAbilityCategory.php index f65496bd39b98..14fefba2d52fc 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilityCategory.php +++ b/tests/phpunit/tests/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; diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php index 5c73be38fe3dd..f2a104151cdba 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php @@ -13,7 +13,7 @@ class Tests_REST_API_WpRestAbilitiesListController extends WP_UnitTestCase { /** * REST Server instance. * - * @var \WP_REST_Server + * @var WP_REST_Server */ protected $server; @@ -47,10 +47,6 @@ public static function set_up_before_class(): void { 'abilities_api_categories_init', array( __CLASS__, 'register_test_categories' ) ); - - // Initialize Abilities API. - do_action( 'abilities_api_init' ); - } /** @@ -78,6 +74,8 @@ public function set_up(): void { do_action( 'rest_api_init' ); + // Initialize Abilities API. + do_action( 'abilities_api_init' ); $this->register_test_abilities(); // Set default user for tests diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php index 9aa74b377a169..48a7a64aea3dc 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php @@ -13,7 +13,7 @@ class Tests_REST_API_WpRestAbilitiesRunController extends WP_UnitTestCase { /** * REST Server instance. * - * @var \WP_REST_Server + * @var WP_REST_Server */ protected $server; @@ -59,9 +59,6 @@ public static function set_up_before_class(): void { 'abilities_api_categories_init', array( __CLASS__, 'register_test_categories' ) ); - - // Initialize Abilities API. - do_action( 'abilities_api_init' ); } /** @@ -88,6 +85,8 @@ public function set_up(): void { do_action( 'rest_api_init' ); + // Initialize Abilities API. + do_action( 'abilities_api_init' ); $this->register_test_abilities(); // Set default user for tests From 38de7a734c2eeea20acf4322b005d36c5c2a7e1e Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 17 Oct 2025 11:52:19 +0200 Subject: [PATCH 11/31] Expand PHPDoc for array params --- src/wp-includes/abilities-api.php | 32 +++++++++++--- .../class-wp-abilities-category-registry.php | 17 ++++++-- .../class-wp-abilities-registry.php | 42 +++++++++++++++++-- .../class-wp-ability-category.php | 8 +++- .../abilities-api/class-wp-ability.php | 22 ++++++++-- 5 files changed, 106 insertions(+), 15 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index d77c66020a410..91cc664e28e25 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -23,9 +23,26 @@ * @param string $name The name of the ability. The name must be a string containing a namespace * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase * alphanumeric characters, dashes and the forward slash. - * @param array $args An associative array of arguments for the ability. This should include - * `label`, `description`, `category`, `input_schema`, `output_schema`, `execute_callback`, - * `permission_callback`, `meta`, and `ability_class`. + * @param array $args { + * An associative array of arguments for the ability. + * + * @type string $label The human-readable label for the ability. + * @type string $description A detailed description of what the ability does. + * @type string $category The category slug this ability belongs to. + * @type callable $execute_callback A callback function to execute when the ability is invoked. + * Receives optional mixed input and returns mixed result or WP_Error. + * @type callable $permission_callback A callback function to check permissions before execution. + * Receives optional mixed input and returns bool or WP_Error. + * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { + * Optional. Additional metadata for the ability. + * + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * } + * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability. + * } * @return WP_Ability|null An instance of registered ability on success, null on failure. * * @phpstan-param array{ @@ -113,8 +130,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`, `description`, and optionally `meta`. + * @param array $args { + * An associative array of arguments for the category. + * + * @type string $label The human-readable label for the category. + * @type string $description A description of the category. + * @type array $meta Optional. Additional metadata for the category. + * } * @return WP_Ability_Category|null The registered category instance on success, null on failure. * * @phpstan-param array{ diff --git a/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php index dc647db13800c..5dbd21f6b2e03 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php @@ -45,8 +45,13 @@ final class WP_Abilities_Category_Registry { * * @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. See wp_register_ability_category() for - * details. + * @param array $args { + * An associative array of arguments for the category. + * + * @type string $label The human-readable label for the category. + * @type string $description A description of the category. + * @type array $meta Optional. Additional metadata for the category. + * } * @return WP_Ability_Category|null The registered category instance on success, null on failure. * * @phpstan-param array{ @@ -95,7 +100,13 @@ public function register( string $slug, array $args ): ?WP_Ability_Category { * * @since 6.9.0 * - * @param array $args The arguments used to instantiate the category. + * @param array $args { + * The arguments used to instantiate the category. + * + * @type string $label The human-readable label for the category. + * @type string $description A description of the category. + * @type array $meta Optional. Additional metadata for the category. + * } * @param string $slug The slug of the category. */ $args = apply_filters( 'register_ability_category_args', $args, $slug ); diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index a27c84da0033d..957b8e6d1c011 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -46,8 +46,26 @@ final class WP_Abilities_Registry { * @param string $name The name of the ability. The name must be a string containing a namespace * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase * alphanumeric characters, dashes and the forward slash. - * @param array $args An associative array of arguments for the ability. See wp_register_ability() for - * details. + * @param array $args { + * An associative array of arguments for the ability. + * + * @type string $label The human-readable label for the ability. + * @type string $description A detailed description of what the ability does. + * @type string $category The category slug this ability belongs to. + * @type callable $execute_callback A callback function to execute when the ability is invoked. + * Receives optional mixed input and returns mixed result or WP_Error. + * @type callable $permission_callback A callback function to check permissions before execution. + * Receives optional mixed input and returns bool or WP_Error. + * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { + * Optional. Additional metadata for the ability. + * + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * } + * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability. + * } * @return WP_Ability|null The registered ability instance on success, null on failure. * * @phpstan-param array{ @@ -94,7 +112,25 @@ public function register( string $name, array $args ): ?WP_Ability { * * @since 6.9.0 * - * @param array $args The arguments used to instantiate the ability. + * @param array $args { + * An associative array of arguments for the ability. + * + * @type string $label The human-readable label for the ability. + * @type string $description A detailed description of what the ability does. + * @type string $category The category slug this ability belongs to. + * @type callable $execute_callback A callback function to execute when the ability is invoked. + * Receives optional mixed input and returns mixed result or WP_Error. + * @type callable $permission_callback A callback function to check permissions before execution. + * Receives optional mixed input and returns bool or WP_Error. + * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { + * Optional. Additional metadata for the ability. + * + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * } + * } * @param string $name The name of the ability, with its namespace. */ $args = apply_filters( 'register_ability_args', $args, $name ); diff --git a/src/wp-includes/abilities-api/class-wp-ability-category.php b/src/wp-includes/abilities-api/class-wp-ability-category.php index da2f00e0478f4..444ea97634218 100644 --- a/src/wp-includes/abilities-api/class-wp-ability-category.php +++ b/src/wp-includes/abilities-api/class-wp-ability-category.php @@ -64,7 +64,13 @@ final class WP_Ability_Category { * @see wp_register_ability_category() * * @param string $slug The unique slug for the category. - * @param array $args An associative array of arguments for the category. + * @param array $args { + * An associative array of arguments for the category. + * + * @type string $label The human-readable label for the category. + * @type string $description A description of the category. + * @type array $meta Optional. Additional metadata for the category. + * } */ public function __construct( string $slug, array $args ) { if ( empty( $slug ) ) { diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 53a83df24713b..218b621290691 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -137,9 +137,25 @@ 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` and `meta` + * @param array $args { + * An associative array of arguments for the ability. + * + * @type string $label The human-readable label for the ability. + * @type string $description A detailed description of what the ability does. + * @type string $category The category slug this ability belongs to. + * @type callable $execute_callback A callback function to execute when the ability is invoked. + * Receives optional mixed input and returns mixed result or WP_Error. + * @type callable $permission_callback A callback function to check permissions before execution. + * Receives optional mixed input and returns bool or WP_Error. + * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { + * Optional. Additional metadata for the ability. + * + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * } + * } */ public function __construct( string $name, array $args ) { $this->name = $name; From abc62fe5f9308e9d9fe41e6bf768312fe6ad0e9e Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 17 Oct 2025 19:44:35 +0200 Subject: [PATCH 12/31] Address feedback from review regarding coding style --- src/wp-includes/abilities-api.php | 62 ++++---- .../class-wp-abilities-category-registry.php | 44 +++--- .../class-wp-abilities-registry.php | 76 +++++----- .../class-wp-ability-category.php | 40 ++--- .../abilities-api/class-wp-ability.php | 143 ++++++++++-------- ...lass-wp-rest-abilities-list-controller.php | 20 +-- ...class-wp-rest-abilities-run-controller.php | 26 ++-- .../abilities-api/wpAbilitiesRegistry.php | 2 +- .../tests/abilities-api/wpAbilityCategory.php | 10 +- .../rest-api/wpRestAbilitiesRunController.php | 4 +- 10 files changed, 226 insertions(+), 201 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index 91cc664e28e25..b8a5d21c70b1b 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -20,28 +20,28 @@ * * @see WP_Abilities_Registry::register() * - * @param string $name The name of the ability. The name must be a string containing a namespace - * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase - * alphanumeric characters, dashes and the forward slash. - * @param array $args { + * @param string $name The name of the ability. The name must be a string containing a namespace + * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase + * alphanumeric characters, dashes and the forward slash. + * @param array $args { * An associative array of arguments for the ability. * - * @type string $label The human-readable label for the ability. - * @type string $description A detailed description of what the ability does. - * @type string $category The category slug this ability belongs to. - * @type callable $execute_callback A callback function to execute when the ability is invoked. - * Receives optional mixed input and returns mixed result or WP_Error. - * @type callable $permission_callback A callback function to check permissions before execution. - * Receives optional mixed input and returns bool or WP_Error. - * @type array $input_schema Optional. JSON Schema definition for the ability's input. - * @type array $output_schema Optional. JSON Schema definition for the ability's output. - * @type array $meta { + * @type string $label The human-readable label for the ability. + * @type string $description A detailed description of what the ability does. + * @type string $category The category slug this ability belongs to. + * @type callable $execute_callback A callback function to execute when the ability is invoked. + * Receives optional mixed input and returns mixed result or WP_Error. + * @type callable $permission_callback A callback function to check permissions before execution. + * Receives optional mixed input and returns bool or WP_Error. + * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { * Optional. Additional metadata for the ability. * - * @type array $annotations Optional. Annotation metadata for the ability. - * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. * } - * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability. + * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability. * } * @return WP_Ability|null An instance of registered ability on success, null on failure. * @@ -49,16 +49,16 @@ * label?: string, * description?: string, * category?: string, - * execute_callback?: callable( mixed $input= ): (mixed|\WP_Error), - * permission_callback?: callable( mixed $input= ): (bool|\WP_Error), - * input_schema?: array, - * output_schema?: array, + * execute_callback?: callable( mixed $input= ): (mixed|WP_Error), + * permission_callback?: callable( mixed $input= ): (bool|WP_Error), + * input_schema?: array, + * output_schema?: array, * meta?: array{ - * annotations?: array, + * annotations?: array, * show_in_rest?: bool, - * ..., + * ..., * }, - * ability_class?: class-string<\WP_Ability>, + * ability_class?: class-string, * ... * } $args */ @@ -115,7 +115,7 @@ function wp_get_ability( string $name ): ?WP_Ability { * * @see WP_Abilities_Registry::get_all_registered() * - * @return \WP_Ability[] The array of registered abilities. + * @return WP_Ability[] The array of registered abilities. */ function wp_get_abilities(): array { return WP_Abilities_Registry::get_instance()->get_all_registered(); @@ -128,21 +128,21 @@ function wp_get_abilities(): array { * * @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 { + * @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. * * @type string $label The human-readable label for the category. * @type string $description A description of the category. - * @type array $meta Optional. Additional metadata for the category. + * @type array $meta Optional. Additional metadata for the category. * } * @return WP_Ability_Category|null The registered category instance on success, null on failure. * * @phpstan-param array{ * label: string, * description: string, - * meta?: array, + * meta?: array, * ... * } $args */ @@ -185,7 +185,7 @@ function wp_get_ability_category( string $slug ): ?WP_Ability_Category { * * @see WP_Abilities_Category_Registry::get_all_registered() * - * @return \WP_Ability_Category[] The array of registered categories. + * @return WP_Ability_Category[] The array of registered categories. */ function wp_get_ability_categories(): array { return WP_Abilities_Category_Registry::get_instance()->get_all_registered(); diff --git a/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php index 5dbd21f6b2e03..67b78646284b1 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php @@ -43,21 +43,21 @@ final class WP_Abilities_Category_Registry { * * @see wp_register_ability_category() * - * @param string $slug The unique slug for the category. Must contain only lowercase - * alphanumeric characters and dashes. - * @param array $args { + * @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. * - * @type string $label The human-readable label for the category. - * @type string $description A description of the category. - * @type array $meta Optional. Additional metadata for the category. + * @type string $label The human-readable label for the category. + * @type string $description A description of the category. + * @type array $meta Optional. Additional metadata for the category. * } * @return WP_Ability_Category|null The registered category instance on success, null on failure. * * @phpstan-param array{ * label: string, * description: string, - * meta?: array, + * meta?: array, * ... * } $args */ @@ -100,21 +100,21 @@ public function register( string $slug, array $args ): ?WP_Ability_Category { * * @since 6.9.0 * - * @param array $args { + * @param array $args { * The arguments used to instantiate the category. * - * @type string $label The human-readable label for the category. - * @type string $description A description of the category. - * @type array $meta Optional. Additional metadata for the category. + * @type string $label The human-readable label for the category. + * @type string $description A description of the category. + * @type array $meta Optional. Additional metadata for the category. * } - * @param string $slug The slug of 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 ) { + } catch ( InvalidArgumentException $e ) { _doing_it_wrong( __METHOD__, $e->getMessage(), @@ -165,7 +165,7 @@ public function unregister( string $slug ): ?WP_Ability_Category { * * @see wp_get_ability_categories() * - * @return array The array of registered categories. + * @return array The array of registered categories. */ public function get_all_registered(): array { return $this->registered_categories; @@ -215,7 +215,7 @@ public function get_registered( string $slug ): ?WP_Ability_Category { * * @since 6.9.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 ) { @@ -228,7 +228,7 @@ public static function get_instance(): self { * * @since 6.9.0 * - * @param \WP_Abilities_Category_Registry $instance Categories registry object. + * @param WP_Abilities_Category_Registry $instance Categories registry object. */ do_action( 'abilities_api_categories_init', self::$instance ); } @@ -240,19 +240,21 @@ public static function get_instance(): self { * Wakeup magic method. * * @since 6.9.0 - * @throws \LogicException If the registry is unserialized. This is a security hardening measure to prevent unserialization of the registry. + * @throws LogicException If the registry object is unserialized. + * This is a security hardening measure to prevent unserialization of the registry. */ public function __wakeup(): void { - throw new \LogicException( __CLASS__ . ' should never be unserialized.' ); + throw new LogicException( __CLASS__ . ' should never be unserialized.' ); } /** - * Serialization magic method. + * Sleep magic method. * * @since 6.9.0 - * @throws \LogicException If the registry is serialized. This is a security hardening measure to prevent serialization of the registry. + * @throws LogicException If the registry object is serialized. + * This is a security hardening measure to prevent serialization of the registry. */ public function __sleep(): array { - throw new \LogicException( __CLASS__ . ' should never be serialized' ); + throw new LogicException( __CLASS__ . ' should never be serialized' ); } } diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index 957b8e6d1c011..65efb88a1088a 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -43,10 +43,10 @@ final class WP_Abilities_Registry { * * @see wp_register_ability() * - * @param string $name The name of the ability. The name must be a string containing a namespace - * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase - * alphanumeric characters, dashes and the forward slash. - * @param array $args { + * @param string $name The name of the ability. The name must be a string containing a namespace + * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase + * alphanumeric characters, dashes and the forward slash. + * @param array $args { * An associative array of arguments for the ability. * * @type string $label The human-readable label for the ability. @@ -56,13 +56,13 @@ final class WP_Abilities_Registry { * Receives optional mixed input and returns mixed result or WP_Error. * @type callable $permission_callback A callback function to check permissions before execution. * Receives optional mixed input and returns bool or WP_Error. - * @type array $input_schema Optional. JSON Schema definition for the ability's input. - * @type array $output_schema Optional. JSON Schema definition for the ability's output. - * @type array $meta { + * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { * Optional. Additional metadata for the ability. * - * @type array $annotations Optional. Annotation metadata for the ability. - * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. * } * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability. * } @@ -72,16 +72,16 @@ final class WP_Abilities_Registry { * label?: string, * description?: string, * category?: string, - * execute_callback?: callable( mixed $input= ): (mixed|\WP_Error), - * permission_callback?: callable( mixed $input= ): (bool|\WP_Error), - * input_schema?: array, - * output_schema?: array, + * execute_callback?: callable( mixed $input= ): (mixed|WP_Error), + * permission_callback?: callable( mixed $input= ): (bool|WP_Error), + * input_schema?: array, + * output_schema?: array, * meta?: array{ - * annotations?: array, + * annotations?: array, * show_in_rest?: bool, * ... * }, - * ability_class?: class-string<\WP_Ability>, + * ability_class?: class-string, * ... * } $args */ @@ -112,7 +112,7 @@ public function register( string $name, array $args ): ?WP_Ability { * * @since 6.9.0 * - * @param array $args { + * @param array $args { * An associative array of arguments for the ability. * * @type string $label The human-readable label for the ability. @@ -122,16 +122,16 @@ public function register( string $name, array $args ): ?WP_Ability { * Receives optional mixed input and returns mixed result or WP_Error. * @type callable $permission_callback A callback function to check permissions before execution. * Receives optional mixed input and returns bool or WP_Error. - * @type array $input_schema Optional. JSON Schema definition for the ability's input. - * @type array $output_schema Optional. JSON Schema definition for the ability's output. - * @type array $meta { + * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { * Optional. Additional metadata for the ability. * - * @type array $annotations Optional. Annotation metadata for the ability. - * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. * } * } - * @param string $name The name of the ability, with its namespace. + * @param string $name The name of the ability, with its namespace. */ $args = apply_filters( 'register_ability_args', $args, $name ); @@ -163,14 +163,14 @@ public function register( string $name, array $args ): ?WP_Ability { return null; } - /** @var class-string<\WP_Ability> */ + /** @var class-string */ $ability_class = $args['ability_class'] ?? WP_Ability::class; unset( $args['ability_class'] ); try { // WP_Ability::prepare_properties() will throw an exception if the properties are invalid. $ability = new $ability_class( $name, $args ); - } catch ( \InvalidArgumentException $e ) { + } catch ( InvalidArgumentException $e ) { _doing_it_wrong( __METHOD__, $e->getMessage(), @@ -221,7 +221,7 @@ public function unregister( string $name ): ?WP_Ability { * * @see wp_get_abilities() * - * @return \WP_Ability[] The array of registered abilities. + * @return WP_Ability[] The array of registered abilities. */ public function get_all_registered(): array { return $this->registered_abilities; @@ -249,7 +249,7 @@ public function is_registered( string $name ): bool { * @see wp_get_ability() * * @param string $name The name of the registered ability, with its namespace. - * @return ?\WP_Ability The registered ability instance, or null if it is not registered. + * @return ?WP_Ability The registered ability instance, or null if it is not registered. */ public function get_registered( string $name ): ?WP_Ability { if ( ! $this->is_registered( $name ) ) { @@ -271,7 +271,7 @@ public function get_registered( string $name ): ?WP_Ability { * * @since 6.9.0 * - * @return \WP_Abilities_Registry The main registry instance. + * @return WP_Abilities_Registry The main registry instance. */ public static function get_instance(): self { if ( null === self::$instance ) { @@ -289,7 +289,7 @@ public static function get_instance(): self { * * @since 6.9.0 * - * @param \WP_Abilities_Registry $instance Abilities registry object. + * @param WP_Abilities_Registry $instance Abilities registry object. */ do_action( 'abilities_api_init', self::$instance ); } @@ -301,13 +301,21 @@ public static function get_instance(): self { * Wakeup magic method. * * @since 6.9.0 - * @throws \UnexpectedValueException If any of the registered abilities is not an instance of WP_Ability. + * @throws LogicException If the registry object is unserialized. + * This is a security hardening measure to prevent unserialization of the registry. */ public function __wakeup(): void { - foreach ( $this->registered_abilities as $ability ) { - if ( ! $ability instanceof WP_Ability ) { - throw new \UnexpectedValueException(); - } - } + throw new LogicException( __CLASS__ . ' should never be unserialized.' ); + } + + /** + * Sleep magic method. + * + * @since 6.9.0 + * @throws LogicException If the registry object is serialized. + * This is a security hardening measure to prevent serialization of the registry. + */ + public function __sleep(): array { + throw new LogicException( __CLASS__ . ' should never be serialized' ); } } diff --git a/src/wp-includes/abilities-api/class-wp-ability-category.php b/src/wp-includes/abilities-api/class-wp-ability-category.php index 444ea97634218..57cfc38ebe771 100644 --- a/src/wp-includes/abilities-api/class-wp-ability-category.php +++ b/src/wp-includes/abilities-api/class-wp-ability-category.php @@ -48,7 +48,7 @@ final class WP_Ability_Category { * The optional category metadata. * * @since 6.9.0 - * @var array + * @var array */ protected $meta = array(); @@ -63,18 +63,18 @@ final class WP_Ability_Category { * * @see wp_register_ability_category() * - * @param string $slug The unique slug for the category. - * @param array $args { + * @param string $slug The unique slug for the category. + * @param array $args { * An associative array of arguments for the category. * - * @type string $label The human-readable label for the category. - * @type string $description A description of the category. - * @type array $meta Optional. Additional metadata for the category. + * @type string $label The human-readable label for the category. + * @type string $description A description of the category. + * @type array $meta Optional. Additional metadata for the category. * } */ public function __construct( string $slug, array $args ) { if ( empty( $slug ) ) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( esc_html__( 'The category slug cannot be empty.' ) ); } @@ -108,34 +108,34 @@ public function __construct( string $slug, array $args ) { * * @since 6.9.0 * - * @param array $args An associative array of arguments used to instantiate the class. - * @return array The validated and prepared properties. - * @throws \InvalidArgumentException if an argument is invalid. + * @param array $args An associative array of arguments used to instantiate the class. + * @return array The validated and prepared properties. + * @throws InvalidArgumentException if an argument is invalid. * * @phpstan-return array{ * label: string, * description: string, - * meta?: array, + * meta?: array, * ..., * } */ protected function prepare_properties( array $args ): array { // Required args must be present and of the correct type. if ( empty( $args['label'] ) || ! is_string( $args['label'] ) ) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( __( 'The category properties must contain a `label` string.' ) ); } if ( empty( $args['description'] ) || ! is_string( $args['description'] ) ) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( __( 'The category properties must contain a `description` string.' ) ); } // Optional args only need to be of the correct type if they are present. if ( isset( $args['meta'] ) && ! is_array( $args['meta'] ) ) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( __( 'The category properties should provide a valid `meta` array.' ) ); } @@ -191,19 +191,21 @@ public function get_meta(): array { * Wakeup magic method. * * @since 6.9.0 - * @throws \LogicException If the category is unserialized. This is a security hardening measure to prevent unserialization of the category. + * @throws LogicException If the ability category object is unserialized. + * This is a security hardening measure to prevent unserialization of the ability category. */ public function __wakeup(): void { - throw new \LogicException( __CLASS__ . ' should never be unserialized.' ); + throw new LogicException( __CLASS__ . ' should never be unserialized.' ); } /** - * Serialization magic method. + * Sleep magic method. * * @since 6.9.0 - * @throws \LogicException If the category is serialized. This is a security hardening measure to prevent serialization of the category. + * @throws LogicException If the ability category object is serialized. + * This is a security hardening measure to prevent serialization of the ability category. */ public function __sleep(): array { - throw new \LogicException( __CLASS__ . ' should never be serialized' ); + throw new LogicException( __CLASS__ . ' should never be serialized' ); } } diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 218b621290691..afa2f76cc116b 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -33,7 +33,7 @@ class WP_Ability { * They are not guaranteed to provide a faithful description of ability behavior. * * @since 6.9.0 - * @var array + * @var array */ protected static $default_annotations = array( // Instructions on how to use the ability. @@ -77,11 +77,19 @@ class WP_Ability { */ protected $description; + /** + * The ability category. + * + * @since 6.9.0 + * @var string + */ + protected $category; + /** * The optional ability input schema. * * @since 6.9.0 - * @var array + * @var array */ protected $input_schema = array(); @@ -89,7 +97,7 @@ class WP_Ability { * The optional ability output schema. * * @since 6.9.0 - * @var array + * @var array */ protected $output_schema = array(); @@ -97,7 +105,7 @@ class WP_Ability { * The ability execute callback. * * @since 6.9.0 - * @var callable( mixed $input= ): (mixed|\WP_Error) + * @var callable( mixed $input= ): (mixed|WP_Error) */ protected $execute_callback; @@ -105,7 +113,7 @@ class WP_Ability { * The optional ability permission callback. * * @since 6.9.0 - * @var callable( mixed $input= ): (bool|\WP_Error) + * @var callable( mixed $input= ): (bool|WP_Error) */ protected $permission_callback; @@ -113,18 +121,10 @@ class WP_Ability { * The optional ability metadata. * * @since 6.9.0 - * @var array + * @var array */ protected $meta; - /** - * The ability category (required). - * - * @since 6.9.0 - * @var string - */ - protected $category; - /** * Constructor. * @@ -136,8 +136,8 @@ class WP_Ability { * * @see wp_register_ability() * - * @param string $name The name of the ability, with its namespace. - * @param array $args { + * @param string $name The name of the ability, with its namespace. + * @param array $args { * An associative array of arguments for the ability. * * @type string $label The human-readable label for the ability. @@ -147,13 +147,13 @@ class WP_Ability { * Receives optional mixed input and returns mixed result or WP_Error. * @type callable $permission_callback A callback function to check permissions before execution. * Receives optional mixed input and returns bool or WP_Error. - * @type array $input_schema Optional. JSON Schema definition for the ability's input. - * @type array $output_schema Optional. JSON Schema definition for the ability's output. - * @type array $meta { + * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { * Optional. Additional metadata for the ability. * - * @type array $annotations Optional. Annotation metadata for the ability. - * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. * } * } */ @@ -185,27 +185,27 @@ public function __construct( string $name, array $args ) { /** * Prepares and validates the properties used to instantiate the ability. * - * Errors are thrown as exceptions instead of \WP_Errors to allow for simpler handling and overloading. They are then + * Errors are thrown as exceptions instead of WP_Errors to allow for simpler handling and overloading. They are then * caught and converted to a WP_Error when by WP_Abilities_Registry::register(). * * @since 6.9.0 * * @see WP_Abilities_Registry::register() * - * @param array $args An associative array of arguments used to instantiate the class. - * @return array The validated and prepared properties. - * @throws \InvalidArgumentException if an argument is invalid. + * @param array $args An associative array of arguments used to instantiate the class. + * @return array The validated and prepared properties. + * @throws InvalidArgumentException if an argument is invalid. * * @phpstan-return array{ * label: string, * description: string, * category: string, - * execute_callback: callable( mixed $input= ): (mixed|\WP_Error), - * permission_callback: callable( mixed $input= ): (bool|\WP_Error), - * input_schema?: array, - * output_schema?: array, + * execute_callback: callable( mixed $input= ): (mixed|WP_Error), + * permission_callback: callable( mixed $input= ): (bool|WP_Error), + * input_schema?: array, + * output_schema?: array, * meta?: array{ - * annotations?: array, + * annotations?: array, * show_in_rest?: bool, * ... * }, @@ -215,62 +215,62 @@ public function __construct( string $name, array $args ) { 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( + throw new InvalidArgumentException( __( 'The ability properties must contain a `label` string.' ) ); } if ( empty( $args['description'] ) || ! is_string( $args['description'] ) ) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( __( 'The ability properties must contain a `description` string.' ) ); } if ( empty( $args['category'] ) || ! is_string( $args['category'] ) ) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( __( 'The ability properties must contain a `category` string.' ) ); } if ( empty( $args['execute_callback'] ) || ! is_callable( $args['execute_callback'] ) ) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( __( 'The ability properties must contain a valid `execute_callback` function.' ) ); } if ( empty( $args['permission_callback'] ) || ! is_callable( $args['permission_callback'] ) ) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( __( 'The ability properties must provide a valid `permission_callback` function.' ) ); } // Optional args only need to be of the correct type if they are present. if ( isset( $args['input_schema'] ) && ! is_array( $args['input_schema'] ) ) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( __( 'The ability properties should provide a valid `input_schema` definition.' ) ); } if ( isset( $args['output_schema'] ) && ! is_array( $args['output_schema'] ) ) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( __( 'The ability properties should provide a valid `output_schema` definition.' ) ); } if ( isset( $args['meta'] ) && ! is_array( $args['meta'] ) ) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( __( 'The ability properties should provide a valid `meta` array.' ) ); } if ( isset( $args['meta']['annotations'] ) && ! is_array( $args['meta']['annotations'] ) ) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( __( 'The ability meta should provide a valid `annotations` array.' ) ); } if ( isset( $args['meta']['show_in_rest'] ) && ! is_bool( $args['meta']['show_in_rest'] ) ) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( __( 'The ability meta should provide a valid `show_in_rest` boolean.' ) ); } @@ -325,12 +325,23 @@ public function get_description(): string { return $this->description; } + /** + * Retrieves the category for the ability. + * + * @since 6.9.0 + * + * @return string The category for the ability. + */ + public function get_category(): string { + return $this->category; + } + /** * Retrieves the input schema for the ability. * * @since 6.9.0 * - * @return array The input schema for the ability. + * @return array The input schema for the ability. */ public function get_input_schema(): array { return $this->input_schema; @@ -341,7 +352,7 @@ public function get_input_schema(): array { * * @since 6.9.0 * - * @return array The output schema for the ability. + * @return array The output schema for the ability. */ public function get_output_schema(): array { return $this->output_schema; @@ -352,23 +363,12 @@ public function get_output_schema(): array { * * @since 6.9.0 * - * @return array The metadata for the ability. + * @return array The metadata for the ability. */ public function get_meta(): array { return $this->meta; } - /** - * Retrieves the category for the ability. - * - * @since 6.9.0 - * - * @return string The category for the ability. - */ - public function get_category(): string { - return $this->category; - } - /** * Retrieves a specific metadata item for the ability. * @@ -388,7 +388,7 @@ public function get_meta_item( string $key, $default_value = null ) { * @since 6.9.0 * * @param mixed $input Optional. The input data to validate. Default `null`. - * @return true|\WP_Error Returns true if valid or the WP_Error object if validation fails. + * @return true|WP_Error Returns true if valid or the WP_Error object if validation fails. */ protected function validate_input( $input = null ) { $input_schema = $this->get_input_schema(); @@ -397,7 +397,7 @@ protected function validate_input( $input = null ) { return true; } - return new \WP_Error( + return new WP_Error( 'ability_missing_input_schema', sprintf( /* translators: %s ability name. */ @@ -409,7 +409,7 @@ protected function validate_input( $input = null ) { $valid_input = rest_validate_value_from_schema( $input, $input_schema, 'input' ); if ( is_wp_error( $valid_input ) ) { - return new \WP_Error( + return new WP_Error( 'ability_invalid_input', sprintf( /* translators: %1$s ability name, %2$s error message. */ @@ -449,7 +449,7 @@ protected function invoke_callback( callable $callback, $input = null ) { * @since 6.9.0 * * @param mixed $input Optional. The input data for permission checking. Default `null`. - * @return bool|\WP_Error Whether the ability has the necessary permission. + * @return bool|WP_Error Whether the ability has the necessary permission. */ public function check_permissions( $input = null ) { $is_valid = $this->validate_input( $input ); @@ -467,11 +467,11 @@ public function check_permissions( $input = null ) { * @since 6.9.0 * * @param mixed $input Optional. The input data for the ability. Default `null`. - * @return mixed|\WP_Error The result of the ability execution, or WP_Error on failure. + * @return mixed|WP_Error The result of the ability execution, or WP_Error on failure. */ protected function do_execute( $input = null ) { if ( ! is_callable( $this->execute_callback ) ) { - return new \WP_Error( + return new WP_Error( 'ability_invalid_execute_callback', /* translators: %s ability name. */ sprintf( __( 'Ability "%s" does not have a valid execute callback.' ), $this->name ) @@ -487,7 +487,7 @@ protected function do_execute( $input = null ) { * @since 6.9.0 * * @param mixed $output The output data to validate. - * @return true|\WP_Error Returns true if valid, or a WP_Error object if validation fails. + * @return true|WP_Error Returns true if valid, or a WP_Error object if validation fails. */ protected function validate_output( $output ) { $output_schema = $this->get_output_schema(); @@ -497,7 +497,7 @@ protected function validate_output( $output ) { $valid_output = rest_validate_value_from_schema( $output, $output_schema, 'output' ); if ( is_wp_error( $valid_output ) ) { - return new \WP_Error( + return new WP_Error( 'ability_invalid_output', sprintf( /* translators: %1$s ability name, %2$s error message. */ @@ -518,7 +518,7 @@ protected function validate_output( $output ) { * @since 6.9.0 * * @param mixed $input Optional. The input data for the ability. Default `null`. - * @return mixed|\WP_Error The result of the ability execution, or WP_Error on failure. + * @return mixed|WP_Error The result of the ability execution, or WP_Error on failure. */ public function execute( $input = null ) { $has_permissions = $this->check_permissions( $input ); @@ -535,7 +535,7 @@ public function execute( $input = null ) { ); } - return new \WP_Error( + return new WP_Error( 'ability_invalid_permissions', /* translators: %s ability name. */ sprintf( __( 'Ability "%s" does not have necessary permission.' ), $this->name ) @@ -580,8 +580,21 @@ public function execute( $input = null ) { * Wakeup magic method. * * @since 6.9.0 + * @throws LogicException If the ability object is unserialized. + * This is a security hardening measure to prevent unserialization of the ability. */ public function __wakeup(): void { - throw new \LogicException( __CLASS__ . ' should never be unserialized.' ); + throw new LogicException( __CLASS__ . ' should never be unserialized.' ); + } + + /** + * Sleep magic method. + * + * @since 6.9.0 + * @throws LogicException If the ability object is serialized. + * This is a security hardening measure to prevent serialization of the ability. + */ + public function __sleep(): array { + throw new LogicException( __CLASS__ . ' should never be serialized' ); } } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php index 251da92a5391c..1451abbf58485 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php @@ -90,8 +90,8 @@ public function register_routes(): void { * * @since 6.9.0 * - * @param \WP_REST_Request> $request Full details about the request. - * @return \WP_REST_Response Response object on success. + * @param WP_REST_Request> $request Full details about the request. + * @return WP_REST_Response Response object on success. */ public function get_items( $request ) { $abilities = array_filter( @@ -124,7 +124,7 @@ static function ( $ability ) use ( $category ) { $max_pages = ceil( $total_abilities / $per_page ); if ( $request->get_method() === 'HEAD' ) { - $response = new \WP_REST_Response( array() ); + $response = new WP_REST_Response( array() ); } else { $abilities = array_slice( $abilities, $offset, $per_page ); @@ -163,13 +163,13 @@ static function ( $ability ) use ( $category ) { * * @since 6.9.0 * - * @param \WP_REST_Request> $request Full details about the request. - * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + * @param WP_REST_Request> $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function get_item( $request ) { $ability = wp_get_ability( $request->get_param( 'name' ) ); if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) { - return new \WP_Error( + return new WP_Error( 'rest_ability_not_found', __( 'Ability not found.' ), array( 'status' => 404 ) @@ -185,7 +185,7 @@ public function get_item( $request ) { * * @since 6.9.0 * - * @param \WP_REST_Request> $request Full details about the request. + * @param WP_REST_Request> $request Full details about the request. * @return bool True if the request has read access. */ public function get_permissions_check( $request ) { @@ -197,9 +197,9 @@ public function get_permissions_check( $request ) { * * @since 6.9.0 * - * @param \WP_Ability $ability The ability object. - * @param \WP_REST_Request> $request Request object. - * @return \WP_REST_Response Response object. + * @param WP_Ability $ability The ability object. + * @param WP_REST_Request> $request Request object. + * @return WP_REST_Response Response object. */ public function prepare_item_for_response( $ability, $request ) { $data = array( diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php index 5d35827457d45..d48c030d9f609 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php @@ -76,14 +76,14 @@ public function register_routes(): void { * * @since 6.9.0 * - * @param \WP_REST_Request> $request Full details about the request. - * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + * @param WP_REST_Request> $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function run_ability_with_method_check( $request ) { $ability = wp_get_ability( $request->get_param( 'name' ) ); if ( ! $ability ) { - return new \WP_Error( + return new WP_Error( 'rest_ability_not_found', __( 'Ability not found.' ), array( 'status' => 404 ) @@ -96,7 +96,7 @@ public function run_ability_with_method_check( $request ) { $method = $request->get_method(); if ( $is_readonly && 'GET' !== $method ) { - return new \WP_Error( + return new WP_Error( 'rest_ability_invalid_method', __( 'Read-only abilities require GET method.' ), array( 'status' => 405 ) @@ -104,7 +104,7 @@ public function run_ability_with_method_check( $request ) { } if ( ! $is_readonly && 'POST' !== $method ) { - return new \WP_Error( + return new WP_Error( 'rest_ability_invalid_method', __( 'Abilities that perform updates require POST method.' ), array( 'status' => 405 ) @@ -119,13 +119,13 @@ public function run_ability_with_method_check( $request ) { * * @since 6.9.0 * - * @param \WP_REST_Request> $request Full details about the request. - * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + * @param WP_REST_Request> $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function run_ability( $request ) { $ability = wp_get_ability( $request->get_param( 'name' ) ); if ( ! $ability ) { - return new \WP_Error( + return new WP_Error( 'rest_ability_not_found', __( 'Ability not found.' ), array( 'status' => 404 ) @@ -149,13 +149,13 @@ public function run_ability( $request ) { * * @since 6.9.0 * - * @param \WP_REST_Request> $request Full details about the request. - * @return true|\WP_Error True if the request has execution permission, WP_Error object otherwise. + * @param WP_REST_Request> $request Full details about the request. + * @return true|WP_Error True if the request has execution permission, WP_Error object otherwise. */ public function run_ability_permissions_check( $request ) { $ability = wp_get_ability( $request->get_param( 'name' ) ); if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) { - return new \WP_Error( + return new WP_Error( 'rest_ability_not_found', __( 'Ability not found.' ), array( 'status' => 404 ) @@ -164,7 +164,7 @@ public function run_ability_permissions_check( $request ) { $input = $this->get_input_from_request( $request ); if ( ! $ability->check_permissions( $input ) ) { - return new \WP_Error( + return new WP_Error( 'rest_ability_cannot_execute', __( 'Sorry, you are not allowed to execute this ability.' ), array( 'status' => rest_authorization_required_code() ) @@ -179,7 +179,7 @@ public function run_ability_permissions_check( $request ) { * * @since 6.9.0 * - * @param \WP_REST_Request> $request The request object. + * @param WP_REST_Request> $request The request object. * @return mixed|null The input parameters. */ private function get_input_from_request( $request ) { diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php index d506f460d894b..ef8d87ba8f5b7 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -530,7 +530,7 @@ public function test_get_all_registered() { * @covers WP_Ability::prepare_properties */ public function test_wp_ability_invalid_properties_throws_exception() { - $this->expectException( \InvalidArgumentException::class ); + $this->expectException( InvalidArgumentException::class ); new WP_Ability( 'test/invalid', array( diff --git a/tests/phpunit/tests/abilities-api/wpAbilityCategory.php b/tests/phpunit/tests/abilities-api/wpAbilityCategory.php index 14fefba2d52fc..013d9829b61d6 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilityCategory.php +++ b/tests/phpunit/tests/abilities-api/wpAbilityCategory.php @@ -511,9 +511,9 @@ public function test_category_with_special_characters(): void { } /** - * Data provider for valid category slugs. + * Data provider for valid ability category slugs. * - * @return array> + * @return array> Valid ability category slugs. */ public function data_valid_slug_provider(): array { return array( @@ -547,9 +547,9 @@ public function test_category_slug_valid_formats( string $slug ): void { } /** - * Data provider for invalid category slugs. + * Data provider for invalid ability category slugs. * - * @return array> + * @return array> Invalid ability category slugs. */ public function data_invalid_slug_provider(): array { return array( @@ -713,7 +713,7 @@ public function test_category_wakeup_throws_exception(): void { ) ); - $this->expectException( \LogicException::class ); + $this->expectException( LogicException::class ); $serialized = serialize( $category ); unserialize( $serialized ); } diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php index 48a7a64aea3dc..95c7fd6131a95 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php @@ -209,7 +209,7 @@ private function register_test_abilities(): void { $user_id = $input['user_id'] ?? get_current_user_id(); $user = get_user_by( 'id', $user_id ); if ( ! $user ) { - return new \WP_Error( 'user_not_found', 'User not found' ); + return new WP_Error( 'user_not_found', 'User not found' ); } return array( 'id' => $user->ID, @@ -298,7 +298,7 @@ private function register_test_abilities(): void { 'description' => 'Returns error', 'category' => 'general', 'execute_callback' => static function () { - return new \WP_Error( 'test_error', 'This is a test error' ); + return new WP_Error( 'test_error', 'This is a test error' ); }, 'permission_callback' => '__return_true', 'meta' => array( From f5ad2b779c19fcff6f38d8c09530aff1363c6d17 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 17 Oct 2025 20:30:37 +0200 Subject: [PATCH 13/31] Fix PHPDoc for arrays --- .../class-wp-abilities-registry.php | 1 + .../class-wp-ability-category.php | 16 +++++++- .../abilities-api/class-wp-ability.php | 40 ++++++++++++++++++- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index 65efb88a1088a..ee4433d010960 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -130,6 +130,7 @@ public function register( string $name, array $args ): ?WP_Ability { * @type array $annotations Optional. Annotation metadata for the ability. * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. * } + * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability. * } * @param string $name The name of the ability, with its namespace. */ diff --git a/src/wp-includes/abilities-api/class-wp-ability-category.php b/src/wp-includes/abilities-api/class-wp-ability-category.php index 57cfc38ebe771..4319f1fc96c4d 100644 --- a/src/wp-includes/abilities-api/class-wp-ability-category.php +++ b/src/wp-includes/abilities-api/class-wp-ability-category.php @@ -108,8 +108,20 @@ public function __construct( string $slug, array $args ) { * * @since 6.9.0 * - * @param array $args An associative array of arguments used to instantiate the class. - * @return array The validated and prepared properties. + * @param array $args $args { + * An associative array of arguments used to instantiate the ability category class. + * + * @type string $label The human-readable label for the category. + * @type string $description A description of the category. + * @type array $meta Optional. Additional metadata for the category. + * } + * @return array $args { + * An associative array with validated and prepared ability category properties. + * + * @type string $label The human-readable label for the category. + * @type string $description A description of the category. + * @type array $meta Optional. Additional metadata for the category. + * } * @throws InvalidArgumentException if an argument is invalid. * * @phpstan-return array{ diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index afa2f76cc116b..3e03575d5d3e8 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -192,8 +192,44 @@ public function __construct( string $name, array $args ) { * * @see WP_Abilities_Registry::register() * - * @param array $args An associative array of arguments used to instantiate the class. - * @return array The validated and prepared properties. + * @param array $args { + * An associative array of arguments used to instantiate the ability class. + * + * @type string $label The human-readable label for the ability. + * @type string $description A detailed description of what the ability does. + * @type string $category The category slug this ability belongs to. + * @type callable $execute_callback A callback function to execute when the ability is invoked. + * Receives optional mixed input and returns mixed result or WP_Error. + * @type callable $permission_callback A callback function to check permissions before execution. + * Receives optional mixed input and returns bool or WP_Error. + * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { + * Optional. Additional metadata for the ability. + * + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * } + * } + * @return array { + * An associative array of arguments with validated and prepared properties for the ability class. + * + * @type string $label The human-readable label for the ability. + * @type string $description A detailed description of what the ability does. + * @type string $category The category slug this ability belongs to. + * @type callable $execute_callback A callback function to execute when the ability is invoked. + * Receives optional mixed input and returns mixed result or WP_Error. + * @type callable $permission_callback A callback function to check permissions before execution. + * Receives optional mixed input and returns bool or WP_Error. + * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { + * Additional metadata for the ability. + * + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Whether to expose this ability in the REST API. Default false. + * } + * } * @throws InvalidArgumentException if an argument is invalid. * * @phpstan-return array{ From 988f5e50470d8fc72a554ecd72a5411fa6868a07 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 17 Oct 2025 20:45:50 +0200 Subject: [PATCH 14/31] Fix Method ReflectionProperty::setAccessible() is deprecated since 8.5, as it has no effect --- tests/phpunit/tests/abilities-api/wpRegisterAbility.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index 0d246630ba9b1..0f187a02f75a0 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -456,7 +456,9 @@ public function test_get_existing_ability() { // Reset the Registry, to ensure it's empty before the test. $registry_reflection = new ReflectionClass( WP_Abilities_Registry::class ); $instance_prop = $registry_reflection->getProperty( 'instance' ); - $instance_prop->setAccessible( true ); + if ( PHP_VERSION_ID < 80100 ) { + $instance_prop->setAccessible( true ); + } $instance_prop->setValue( null, null ); $result = wp_get_ability( $name ); From 61bf9fe0c083ace21f9c460f53e1c70da4a59580 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 17 Oct 2025 22:29:52 +0200 Subject: [PATCH 15/31] Prefix all filters with `wp_` --- src/wp-includes/abilities-api.php | 4 +- .../class-wp-abilities-category-registry.php | 8 ++-- .../class-wp-abilities-registry.php | 4 +- .../abilities-api/class-wp-ability.php | 4 +- .../abilities-api/wpAbilitiesRegistry.php | 16 +++---- .../phpunit/tests/abilities-api/wpAbility.php | 44 +++++++++---------- .../tests/abilities-api/wpAbilityCategory.php | 32 +++++++------- .../tests/abilities-api/wpRegisterAbility.php | 36 +++++++-------- .../wpRestAbilitiesListController.php | 8 ++-- .../rest-api/wpRestAbilitiesRunController.php | 8 ++-- 10 files changed, 82 insertions(+), 82 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index b8a5d21c70b1b..d927cd92a556e 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -14,7 +14,7 @@ /** * Registers a new ability using Abilities API. * - * Note: Should only be used on the {@see 'abilities_api_init'} hook. + * Note: Should only be used on the {@see 'wp_abilities_api_init'} hook. * * @since 6.9.0 * @@ -63,7 +63,7 @@ * } $args */ function wp_register_ability( string $name, array $args ): ?WP_Ability { - if ( ! did_action( 'abilities_api_init' ) ) { + if ( ! did_action( 'wp_abilities_api_init' ) ) { _doing_it_wrong( __FUNCTION__, sprintf( diff --git a/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php index 67b78646284b1..31e28e7cc25af 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php @@ -62,13 +62,13 @@ final class WP_Abilities_Category_Registry { * } $args */ public function register( string $slug, array $args ): ?WP_Ability_Category { - if ( ! doing_action( 'abilities_api_categories_init' ) ) { + if ( ! doing_action( 'wp_abilities_api_categories_init' ) ) { _doing_it_wrong( __METHOD__, sprintf( /* translators: 1: abilities_api_categories_init, 2: category slug. */ __( 'Categories must be registered during the %1$s action. The category %2$s was not registered.' ), - 'abilities_api_categories_init', + 'wp_abilities_api_categories_init', '' . esc_html( $slug ) . '' ), '6.9.0' @@ -109,7 +109,7 @@ public function register( string $slug, array $args ): ?WP_Ability_Category { * } * @param string $slug The slug of the category. */ - $args = apply_filters( 'register_ability_category_args', $args, $slug ); + $args = apply_filters( 'wp_register_ability_category_args', $args, $slug ); try { // WP_Ability_Category::prepare_properties() will throw an exception if the properties are invalid. @@ -230,7 +230,7 @@ public static function get_instance(): self { * * @param WP_Abilities_Category_Registry $instance Categories registry object. */ - do_action( 'abilities_api_categories_init', self::$instance ); + do_action( 'wp_abilities_api_categories_init', self::$instance ); } return self::$instance; diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index ee4433d010960..c545b554299ca 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -134,7 +134,7 @@ public function register( string $name, array $args ): ?WP_Ability { * } * @param string $name The name of the ability, with its namespace. */ - $args = apply_filters( 'register_ability_args', $args, $name ); + $args = apply_filters( 'wp_register_ability_args', $args, $name ); // Validate category exists if provided (will be validated as required in WP_Ability). if ( isset( $args['category'] ) ) { @@ -292,7 +292,7 @@ public static function get_instance(): self { * * @param WP_Abilities_Registry $instance Abilities registry object. */ - do_action( 'abilities_api_init', self::$instance ); + do_action( 'wp_abilities_api_init', self::$instance ); } return self::$instance; diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 3e03575d5d3e8..9167ece946101 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -586,7 +586,7 @@ public function execute( $input = null ) { * @param string $ability_name The name of the ability. * @param mixed $input The input data for the ability. */ - do_action( 'before_execute_ability', $this->name, $input ); + do_action( 'wp_before_execute_ability', $this->name, $input ); $result = $this->do_execute( $input ); if ( is_wp_error( $result ) ) { @@ -607,7 +607,7 @@ public function execute( $input = null ) { * @param mixed $input The input data for the ability. * @param mixed $result The result of the ability execution. */ - do_action( 'after_execute_ability', $this->name, $input, $result ); + do_action( 'wp_after_execute_ability', $this->name, $input, $result ); return $result; } diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php index ef8d87ba8f5b7..ed7f490b1fd1a 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -27,11 +27,11 @@ public function set_up(): void { $this->registry = new WP_Abilities_Registry(); - remove_all_filters( 'register_ability_args' ); + remove_all_filters( 'wp_register_ability_args' ); // Register category during the hook. add_action( - 'abilities_api_categories_init', + 'wp_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_categories_init' ); + do_action( 'wp_abilities_api_categories_init' ); self::$test_ability_args = array( 'label' => 'Add numbers', @@ -91,7 +91,7 @@ function () { public function tear_down(): void { $this->registry = null; - remove_all_filters( 'register_ability_args' ); + remove_all_filters( 'wp_register_ability_args' ); // Clean up registered categories. $category_registry = WP_Abilities_Category_Registry::get_instance(); @@ -551,7 +551,7 @@ public function test_register_ability_args_filter_modifies_args() { // Define the filter. add_filter( - 'register_ability_args', + 'wp_register_ability_args', static function ( $args ) use ( &$was_filter_callback_fired ) { $args['label'] = 'Modified label'; $original_execute_callback = $args['execute_callback']; @@ -593,7 +593,7 @@ static function ( $args ) use ( &$was_filter_callback_fired ) { public function test_register_ability_args_filter_blocks_registration() { // Define the filter. add_filter( - 'register_ability_args', + 'wp_register_ability_args', static function ( $args ) { // Remove the label to make the args invalid. unset( $args['label'] ); @@ -619,7 +619,7 @@ static function ( $args ) { public function test_register_ability_args_filter_blocks_invalid_ability_class() { // Define the filter. add_filter( - 'register_ability_args', + 'wp_register_ability_args', static function ( $args ) { // Set an invalid ability class. $args['ability_class'] = 'NonExistentClass'; @@ -641,7 +641,7 @@ static function ( $args ) { */ public function test_register_ability_args_filter_only_applies_to_specific_ability() { add_filter( - 'register_ability_args', + 'wp_register_ability_args', static function ( $args, $name ) { if ( self::$test_ability_name !== $name ) { // Do not modify args for other abilities. diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php index 62694559d4752..7ce1ed2ed1b95 100644 --- a/tests/phpunit/tests/abilities-api/wpAbility.php +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -20,7 +20,7 @@ public function set_up(): void { // Register category during the hook. add_action( - 'abilities_api_categories_init', + 'wp_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_categories_init' ); + do_action( 'wp_abilities_api_categories_init' ); self::$test_ability_properties = array( 'label' => 'Calculator', @@ -533,12 +533,12 @@ public function test_before_execute_ability_action() { $action_input = $input; }; - add_action( 'before_execute_ability', $callback, 10, 2 ); + add_action( 'wp_before_execute_ability', $callback, 10, 2 ); $ability = new WP_Ability( self::$test_ability_name, $args ); $result = $ability->execute( 5 ); - remove_action( 'before_execute_ability', $callback ); + remove_action( 'wp_before_execute_ability', $callback ); $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' ); $this->assertSame( 5, $action_input, 'Action should receive correct input' ); @@ -568,12 +568,12 @@ public function test_before_execute_ability_action_no_input() { $action_input = $input; }; - add_action( 'before_execute_ability', $callback, 10, 2 ); + add_action( 'wp_before_execute_ability', $callback, 10, 2 ); $ability = new WP_Ability( self::$test_ability_name, $args ); $result = $ability->execute(); - remove_action( 'before_execute_ability', $callback ); + remove_action( 'wp_before_execute_ability', $callback ); $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' ); $this->assertNull( $action_input, 'Action should receive null input when no input provided' ); @@ -610,12 +610,12 @@ public function test_after_execute_ability_action() { $action_result = $result; }; - add_action( 'after_execute_ability', $callback, 10, 3 ); + add_action( 'wp_after_execute_ability', $callback, 10, 3 ); $ability = new WP_Ability( self::$test_ability_name, $args ); $result = $ability->execute( 7 ); - remove_action( 'after_execute_ability', $callback ); + remove_action( 'wp_after_execute_ability', $callback ); $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' ); $this->assertSame( 7, $action_input, 'Action should receive correct input' ); @@ -649,12 +649,12 @@ public function test_after_execute_ability_action_no_input() { $action_result = $result; }; - add_action( 'after_execute_ability', $callback, 10, 3 ); + add_action( 'wp_after_execute_ability', $callback, 10, 3 ); $ability = new WP_Ability( self::$test_ability_name, $args ); $result = $ability->execute(); - remove_action( 'after_execute_ability', $callback ); + remove_action( 'wp_after_execute_ability', $callback ); $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' ); $this->assertNull( $action_input, 'Action should receive null input when no input provided' ); @@ -688,14 +688,14 @@ public function test_actions_not_fired_on_permission_failure() { $after_action_fired = true; }; - add_action( 'before_execute_ability', $before_callback ); - add_action( 'after_execute_ability', $after_callback ); + add_action( 'wp_before_execute_ability', $before_callback ); + add_action( 'wp_after_execute_ability', $after_callback ); $ability = new WP_Ability( self::$test_ability_name, $args ); $result = $ability->execute(); - remove_action( 'before_execute_ability', $before_callback ); - remove_action( 'after_execute_ability', $after_callback ); + remove_action( 'wp_before_execute_ability', $before_callback ); + remove_action( 'wp_after_execute_ability', $after_callback ); $this->assertFalse( $before_action_fired, 'before_execute_ability action should not be fired on permission failure' ); $this->assertFalse( $after_action_fired, 'after_execute_ability action should not be fired on permission failure' ); @@ -728,14 +728,14 @@ public function test_after_action_not_fired_on_execution_error() { $after_action_fired = true; }; - add_action( 'before_execute_ability', $before_callback ); - add_action( 'after_execute_ability', $after_callback ); + add_action( 'wp_before_execute_ability', $before_callback ); + add_action( 'wp_after_execute_ability', $after_callback ); $ability = new WP_Ability( self::$test_ability_name, $args ); $result = $ability->execute(); - remove_action( 'before_execute_ability', $before_callback ); - remove_action( 'after_execute_ability', $after_callback ); + remove_action( 'wp_before_execute_ability', $before_callback ); + remove_action( 'wp_after_execute_ability', $after_callback ); $this->assertTrue( $before_action_fired, 'before_execute_ability action should be fired even if execution fails' ); $this->assertFalse( $after_action_fired, 'after_execute_ability action should not be fired when execution returns WP_Error' ); @@ -773,14 +773,14 @@ public function test_after_action_not_fired_on_output_validation_error() { $after_action_fired = true; }; - add_action( 'before_execute_ability', $before_callback ); - add_action( 'after_execute_ability', $after_callback ); + add_action( 'wp_before_execute_ability', $before_callback ); + add_action( 'wp_after_execute_ability', $after_callback ); $ability = new WP_Ability( self::$test_ability_name, $args ); $result = $ability->execute(); - remove_action( 'before_execute_ability', $before_callback ); - remove_action( 'after_execute_ability', $after_callback ); + remove_action( 'wp_before_execute_ability', $before_callback ); + remove_action( 'wp_after_execute_ability', $after_callback ); $this->assertTrue( $before_action_fired, 'before_execute_ability action should be fired even if output validation fails' ); $this->assertFalse( $after_action_fired, 'after_execute_ability action should not be fired when output validation fails' ); diff --git a/tests/phpunit/tests/abilities-api/wpAbilityCategory.php b/tests/phpunit/tests/abilities-api/wpAbilityCategory.php index 013d9829b61d6..cef370788aa16 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilityCategory.php +++ b/tests/phpunit/tests/abilities-api/wpAbilityCategory.php @@ -113,9 +113,9 @@ private function register_category_during_hook( string $slug, array $args ): ?WP $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 ); + add_action( 'wp_abilities_api_categories_init', $callback ); + do_action( 'wp_abilities_api_categories_init', WP_Abilities_Category_Registry::get_instance() ); + remove_action( 'wp_abilities_api_categories_init', $callback ); return $result; } @@ -230,10 +230,10 @@ public function test_register_category_before_init_hook(): void { global $wp_actions; // Store original count. - $original_count = isset( $wp_actions['abilities_api_categories_init'] ) ? $wp_actions['abilities_api_categories_init'] : 0; + $original_count = isset( $wp_actions['wp_abilities_api_categories_init'] ) ? $wp_actions['wp_abilities_api_categories_init'] : 0; // Reset to simulate hook not fired. - unset( $wp_actions['abilities_api_categories_init'] ); + unset( $wp_actions['wp_abilities_api_categories_init'] ); $result = wp_register_ability_category( 'test-math', @@ -245,11 +245,11 @@ public function test_register_category_before_init_hook(): void { // Restore original count. if ( $original_count > 0 ) { - $wp_actions['abilities_api_categories_init'] = $original_count; + $wp_actions['wp_abilities_api_categories_init'] = $original_count; } $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'abilities_api_categories_init' ); + $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'wp_abilities_api_categories_init' ); } /** @@ -279,9 +279,9 @@ public function test_register_duplicate_category(): void { ); }; - add_action( 'abilities_api_categories_init', $callback ); - do_action( 'abilities_api_categories_init', WP_Abilities_Category_Registry::get_instance() ); - remove_action( 'abilities_api_categories_init', $callback ); + add_action( 'wp_abilities_api_categories_init', $callback ); + do_action( 'wp_abilities_api_categories_init', WP_Abilities_Category_Registry::get_instance() ); + remove_action( 'wp_abilities_api_categories_init', $callback ); $this->assertNull( $result ); $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'already registered' ); @@ -412,7 +412,7 @@ public function test_category_is_registered(): void { * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_ability_requires_existing_category(): void { - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); // Ensure category doesn't exist - test should fail if it does. $this->assertFalse( @@ -454,10 +454,10 @@ public function test_ability_with_valid_category(): void { ); }; - 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' ); + add_action( 'wp_abilities_api_categories_init', $category_callback ); + do_action( 'wp_abilities_api_categories_init', WP_Abilities_Category_Registry::get_instance() ); + remove_action( 'wp_abilities_api_categories_init', $category_callback ); + do_action( 'wp_abilities_api_init' ); $result = wp_register_ability( 'test/calculator', @@ -674,7 +674,7 @@ public function test_category_constructor_empty_description(): void { */ public function test_register_category_args_filter(): void { add_filter( - 'register_ability_category_args', + 'wp_register_ability_category_args', static function ( $args, $slug ) { if ( 'test-filtered' === $slug ) { $args['label'] = 'Filtered Label'; diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index 0f187a02f75a0..bd5e9831ec1ad 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -32,7 +32,7 @@ public function set_up(): void { // Register category during the hook. add_action( - 'abilities_api_categories_init', + 'wp_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_categories_init' ); + do_action( 'wp_abilities_api_categories_init' ); self::$test_ability_args = array( 'label' => 'Add numbers', @@ -119,7 +119,7 @@ public function tear_down(): void { * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_ability_invalid_name(): void { - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); $result = wp_register_ability( 'invalid_name', array() ); @@ -137,16 +137,16 @@ public function test_register_ability_no_abilities_api_init_hook(): void { global $wp_actions; // Store the original action count - $original_count = isset( $wp_actions['abilities_api_init'] ) ? $wp_actions['abilities_api_init'] : 0; + $original_count = isset( $wp_actions['wp_abilities_api_init'] ) ? $wp_actions['wp_abilities_api_init'] : 0; // Reset the action count to simulate it not being fired - unset( $wp_actions['abilities_api_init'] ); + unset( $wp_actions['wp_abilities_api_init'] ); $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); // Restore the original action count if ( $original_count > 0 ) { - $wp_actions['abilities_api_init'] = $original_count; + $wp_actions['wp_abilities_api_init'] = $original_count; } $this->assertNull( $result ); @@ -158,7 +158,7 @@ public function test_register_ability_no_abilities_api_init_hook(): void { * @ticket 64098 */ public function test_register_valid_ability(): void { - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); @@ -209,7 +209,7 @@ public function test_register_valid_ability(): void { * @ticket 64098 */ public function test_register_ability_no_permissions(): void { - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); self::$test_ability_args['permission_callback'] = static function (): bool { return false; @@ -244,7 +244,7 @@ public function test_register_ability_no_permissions(): void { * @ticket 64098 */ public function test_register_ability_custom_ability_class(): void { - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); $result = wp_register_ability( self::$test_ability_name, @@ -286,7 +286,7 @@ public function test_register_ability_custom_ability_class(): void { * @ticket 64098 */ public function test_execute_ability_no_input_schema_match(): void { - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); @@ -315,7 +315,7 @@ public function test_execute_ability_no_input_schema_match(): void { * @ticket 64098 */ public function test_execute_ability_no_output_schema_match(): void { - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); self::$test_ability_args['execute_callback'] = static function (): bool { return true; @@ -346,7 +346,7 @@ public function test_execute_ability_no_output_schema_match(): void { * @ticket 64098 */ public function test_permission_callback_no_input_schema_match(): void { - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); @@ -375,7 +375,7 @@ public function test_permission_callback_no_input_schema_match(): void { * @ticket 64098 */ public function test_permission_callback_receives_input(): void { - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); $received_input = null; self::$test_ability_args['permission_callback'] = static function ( array $input ) use ( &$received_input ): bool { @@ -427,7 +427,7 @@ public function test_permission_callback_receives_input(): void { * @ticket 64098 */ public function test_unregister_existing_ability() { - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); wp_register_ability( self::$test_ability_name, self::$test_ability_args ); @@ -451,7 +451,7 @@ public function test_get_existing_ability() { wp_register_ability( $name, $args ); }; - add_action( 'abilities_api_init', $callback ); + add_action( 'wp_abilities_api_init', $callback ); // Reset the Registry, to ensure it's empty before the test. $registry_reflection = new ReflectionClass( WP_Abilities_Registry::class ); @@ -463,7 +463,7 @@ public function test_get_existing_ability() { $result = wp_get_ability( $name ); - remove_action( 'abilities_api_init', $callback ); + remove_action( 'wp_abilities_api_init', $callback ); $this->assertEquals( new WP_Ability( $name, $args ), @@ -478,7 +478,7 @@ public function test_get_existing_ability() { * @ticket 64098 */ public function test_get_all_registered_abilities() { - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); $ability_one_name = 'test/ability-one'; $ability_one_args = self::$test_ability_args; @@ -510,7 +510,7 @@ public function test_get_all_registered_abilities() { * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_ability_nonexistent_category(): void { - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); // Ensure category doesn't exist - test should fail if it does. $this->assertFalse( diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php index f2a104151cdba..9a9b2ef51cafb 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php @@ -39,12 +39,12 @@ public static function set_up_before_class(): void { // Register test categories during the hook. add_action( - 'abilities_api_categories_init', + 'wp_abilities_api_categories_init', array( __CLASS__, 'register_test_categories' ) ); - do_action( 'abilities_api_categories_init' ); + do_action( 'wp_abilities_api_categories_init' ); remove_action( - 'abilities_api_categories_init', + 'wp_abilities_api_categories_init', array( __CLASS__, 'register_test_categories' ) ); } @@ -75,7 +75,7 @@ public function set_up(): void { do_action( 'rest_api_init' ); // Initialize Abilities API. - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); $this->register_test_abilities(); // Set default user for tests diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php index 95c7fd6131a95..9902fa34d4f32 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php @@ -51,12 +51,12 @@ public static function set_up_before_class(): void { // Register test categories during the hook. add_action( - 'abilities_api_categories_init', + 'wp_abilities_api_categories_init', array( __CLASS__, 'register_test_categories' ) ); - do_action( 'abilities_api_categories_init' ); + do_action( 'wp_abilities_api_categories_init' ); remove_action( - 'abilities_api_categories_init', + 'wp_abilities_api_categories_init', array( __CLASS__, 'register_test_categories' ) ); } @@ -86,7 +86,7 @@ public function set_up(): void { do_action( 'rest_api_init' ); // Initialize Abilities API. - do_action( 'abilities_api_init' ); + do_action( 'wp_abilities_api_init' ); $this->register_test_abilities(); // Set default user for tests From 8e75dcb9e0d0bdea58d83d96e824275aae142731 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 17 Oct 2025 23:05:52 +0200 Subject: [PATCH 16/31] Remove PHPStan comments in PHPDoc --- src/wp-includes/abilities-api.php | 24 ------------------- .../class-wp-abilities-category-registry.php | 7 ------ .../class-wp-abilities-registry.php | 17 ------------- .../class-wp-ability-category.php | 7 ------ .../abilities-api/class-wp-ability.php | 16 ------------- 5 files changed, 71 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index d927cd92a556e..7edf219f2b96a 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -44,23 +44,6 @@ * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability. * } * @return WP_Ability|null An instance of registered ability on success, null on failure. - * - * @phpstan-param array{ - * label?: string, - * description?: string, - * category?: string, - * execute_callback?: callable( mixed $input= ): (mixed|WP_Error), - * permission_callback?: callable( mixed $input= ): (bool|WP_Error), - * input_schema?: array, - * output_schema?: array, - * meta?: array{ - * annotations?: array, - * show_in_rest?: bool, - * ..., - * }, - * ability_class?: class-string, - * ... - * } $args */ function wp_register_ability( string $name, array $args ): ?WP_Ability { if ( ! did_action( 'wp_abilities_api_init' ) ) { @@ -138,13 +121,6 @@ function wp_get_abilities(): array { * @type array $meta Optional. Additional metadata for the category. * } * @return WP_Ability_Category|null The registered category instance on success, null on failure. - * - * @phpstan-param array{ - * label: string, - * description: string, - * meta?: array, - * ... - * } $args */ function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_Category { return WP_Abilities_Category_Registry::get_instance()->register( $slug, $args ); diff --git a/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php index 31e28e7cc25af..19adc47827a11 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php @@ -53,13 +53,6 @@ final class WP_Abilities_Category_Registry { * @type array $meta Optional. Additional metadata for the category. * } * @return WP_Ability_Category|null The registered category instance on success, null on failure. - * - * @phpstan-param array{ - * label: string, - * description: string, - * meta?: array, - * ... - * } $args */ public function register( string $slug, array $args ): ?WP_Ability_Category { if ( ! doing_action( 'wp_abilities_api_categories_init' ) ) { diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index c545b554299ca..fb339d5296c84 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -67,23 +67,6 @@ final class WP_Abilities_Registry { * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability. * } * @return WP_Ability|null The registered ability instance on success, null on failure. - * - * @phpstan-param array{ - * label?: string, - * description?: string, - * category?: string, - * execute_callback?: callable( mixed $input= ): (mixed|WP_Error), - * permission_callback?: callable( mixed $input= ): (bool|WP_Error), - * input_schema?: array, - * output_schema?: array, - * meta?: array{ - * annotations?: array, - * show_in_rest?: bool, - * ... - * }, - * ability_class?: class-string, - * ... - * } $args */ public function register( string $name, array $args ): ?WP_Ability { if ( ! preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $name ) ) { diff --git a/src/wp-includes/abilities-api/class-wp-ability-category.php b/src/wp-includes/abilities-api/class-wp-ability-category.php index 4319f1fc96c4d..b2bff19023877 100644 --- a/src/wp-includes/abilities-api/class-wp-ability-category.php +++ b/src/wp-includes/abilities-api/class-wp-ability-category.php @@ -123,13 +123,6 @@ public function __construct( string $slug, array $args ) { * @type array $meta Optional. Additional metadata for the category. * } * @throws InvalidArgumentException if an argument is invalid. - * - * @phpstan-return array{ - * label: string, - * description: string, - * meta?: array, - * ..., - * } */ protected function prepare_properties( array $args ): array { // Required args must be present and of the correct type. diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 9167ece946101..e6a5d0a394cf0 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -231,22 +231,6 @@ public function __construct( string $name, array $args ) { * } * } * @throws InvalidArgumentException if an argument is invalid. - * - * @phpstan-return array{ - * label: string, - * description: string, - * category: string, - * execute_callback: callable( mixed $input= ): (mixed|WP_Error), - * permission_callback: callable( mixed $input= ): (bool|WP_Error), - * input_schema?: array, - * output_schema?: array, - * meta?: array{ - * annotations?: array, - * show_in_rest?: bool, - * ... - * }, - * ..., - * } $args */ protected function prepare_properties( array $args ): array { // Required args must be present and of the correct type. From 4242735041fc33a817da7135aa3cef316c595736 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 17 Oct 2025 23:14:20 +0200 Subject: [PATCH 17/31] Remove `instructions` annotation and change default for `destructive` to `false --- src/wp-includes/abilities-api/class-wp-ability.php | 4 +--- tests/phpunit/tests/abilities-api/wpAbility.php | 5 +---- tests/phpunit/tests/abilities-api/wpRegisterAbility.php | 1 - 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index e6a5d0a394cf0..5595ab9eac3d0 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -36,15 +36,13 @@ class WP_Ability { * @var array */ protected static $default_annotations = array( - // Instructions on how to use the ability. - 'instructions' => '', // If true, the ability does not modify its environment. 'readonly' => false, /* * If true, the ability may perform destructive updates to its environment. * If false, the ability performs only additive updates. */ - 'destructive' => true, + 'destructive' => false, /* * If true, calling the ability repeatedly with the same arguments will have no additional effect * on its environment. diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php index 7ce1ed2ed1b95..737a3091c2f89 100644 --- a/tests/phpunit/tests/abilities-api/wpAbility.php +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -115,7 +115,6 @@ public function test_get_merged_annotations_from_meta() { array_merge( self::$test_ability_properties['meta']['annotations'], array( - 'instructions' => '', 'idempotent' => false, ) ), @@ -136,9 +135,8 @@ public function test_get_default_annotations_from_meta() { $this->assertSame( array( - 'instructions' => '', 'readonly' => false, - 'destructive' => true, + 'destructive' => false, 'idempotent' => false, ), $ability->get_meta_item( 'annotations' ) @@ -152,7 +150,6 @@ public function test_get_default_annotations_from_meta() { */ public function test_get_overridden_annotations_from_meta() { $annotations = array( - 'instructions' => 'Enjoy responsibly.', 'readonly' => true, 'destructive' => false, 'idempotent' => false, diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index bd5e9831ec1ad..bd3e8ca046381 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -165,7 +165,6 @@ public function test_register_valid_ability(): void { $expected_annotations = array_merge( self::$test_ability_args['meta']['annotations'], array( - 'instructions' => '', 'idempotent' => false, ) ); From bff3e7f44753260f6d86227ba903d5ba6e43bf88 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 17 Oct 2025 23:32:35 +0200 Subject: [PATCH 18/31] Add tests cases covering context and filtering by fields in REST API --- .../wpRestAbilitiesListController.php | 62 ++++++++++++++----- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php index 9a9b2ef51cafb..4f4839045b822 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php @@ -295,15 +295,60 @@ public function test_get_item(): void { $this->assertEquals( 200, $response->get_status() ); $data = $response->get_data(); + $this->assertCount( 7, $data, 'Response should contain all fields.' ); $this->assertEquals( 'test/calculator', $data['name'] ); $this->assertEquals( 'Calculator', $data['label'] ); $this->assertEquals( 'Performs basic calculations', $data['description'] ); + $this->assertEquals( 'math', $data['category'] ); $this->assertArrayHasKey( 'input_schema', $data ); $this->assertArrayHasKey( 'output_schema', $data ); $this->assertArrayHasKey( 'meta', $data ); $this->assertTrue( $data['meta']['show_in_rest'] ); } + /** + * Test getting a specific ability with only selected fields. + * + * @ticket 64098 + */ + public function test_get_item_with_selected_fields(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/calculator' ); + $request->set_param( '_fields', 'name,label' ); + $response = $this->server->dispatch( $request ); + add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); + $response = apply_filters( 'rest_post_dispatch', $response, $this->server, $request ); + remove_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10 ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertCount( 2, $data, 'Response should only contain the requested fields.' ); + $this->assertEquals( 'test/calculator', $data['name'] ); + $this->assertEquals( 'Calculator', $data['label'] ); + } + + /** + * Test getting a specific ability with embed context. + * + * @ticket 64098 + */ + public function test_get_item_with_embed_context(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/calculator' ); + $request->set_param( 'context', 'embed' ); + $response = $this->server->dispatch( $request ); + add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); + $response = apply_filters( 'rest_post_dispatch', $response, $this->server, $request ); + remove_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10 ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertCount( 3, $data, 'Response should only contain the fields for embed context.' ); + $this->assertEquals( 'test/calculator', $data['name'] ); + $this->assertEquals( 'Calculator', $data['label'] ); + $this->assertEquals( 'math', $data['category'] ); + } + /** * Test getting a non-existent ability returns 404. * @@ -729,21 +774,4 @@ public function test_filter_by_nonexistent_category(): void { $this->assertIsArray( $data ); $this->assertEmpty( $data, 'Should return empty array for non-existent category' ); } - - /** - * Test that category field is present in response. - * - * @ticket 64098 - */ - 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' ); - } } From 533eb68edb077e2aed6145c3fe2b1084aee8fae7 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 17 Oct 2025 23:57:48 +0200 Subject: [PATCH 19/31] Use consistently ability category/categories --- src/wp-includes/abilities-api.php | 40 +++++----- .../class-wp-abilities-registry.php | 12 +-- ... class-wp-ability-categories-registry.php} | 70 +++++++++--------- .../class-wp-ability-category.php | 60 +++++++-------- .../abilities-api/class-wp-ability.php | 16 ++-- ...lass-wp-rest-abilities-list-controller.php | 6 +- src/wp-settings.php | 2 +- .../abilities-api/wpAbilitiesRegistry.php | 4 +- .../phpunit/tests/abilities-api/wpAbility.php | 18 ++--- .../tests/abilities-api/wpAbilityCategory.php | 74 +++++++++---------- .../tests/abilities-api/wpRegisterAbility.php | 8 +- .../wpRestAbilitiesListController.php | 2 +- tests/qunit/fixtures/wp-api-generated.js | 2 +- 13 files changed, 157 insertions(+), 157 deletions(-) rename src/wp-includes/abilities-api/{class-wp-abilities-category-registry.php => class-wp-ability-categories-registry.php} (69%) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index 7edf219f2b96a..c278bbe1dfab4 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -28,7 +28,7 @@ * * @type string $label The human-readable label for the ability. * @type string $description A detailed description of what the ability does. - * @type string $category The category slug this ability belongs to. + * @type string $category The ability category slug this ability belongs to. * @type callable $execute_callback A callback function to execute when the ability is invoked. * Receives optional mixed input and returns mixed result or WP_Error. * @type callable $permission_callback A callback function to check permissions before execution. @@ -109,21 +109,21 @@ function wp_get_abilities(): array { * * @since 6.9.0 * - * @see WP_Abilities_Category_Registry::register() + * @see WP_Ability_Categories_Registry::register() * - * @param string $slug The unique slug for the category. Must contain only lowercase + * @param string $slug The unique slug for the ability category. Must contain only lowercase * alphanumeric characters and dashes. * @param array $args { - * An associative array of arguments for the category. + * An associative array of arguments for the ability category. * - * @type string $label The human-readable label for the category. - * @type string $description A description of the category. - * @type array $meta Optional. Additional metadata for the category. + * @type string $label The human-readable label for the ability category. + * @type string $description A description of the ability category. + * @type array $meta Optional. Additional metadata for the ability category. * } - * @return WP_Ability_Category|null The registered category instance on success, null on failure. + * @return WP_Ability_Category|null The registered ability category instance on success, null on failure. */ function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_Category { - return WP_Abilities_Category_Registry::get_instance()->register( $slug, $args ); + return WP_Ability_Categories_Registry::get_instance()->register( $slug, $args ); } /** @@ -131,13 +131,13 @@ function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_ * * @since 6.9.0 * - * @see WP_Abilities_Category_Registry::unregister() + * @see WP_Ability_Categories_Registry::unregister() * - * @param string $slug The slug of the registered category. - * @return WP_Ability_Category|null The unregistered category instance on success, null on failure. + * @param string $slug The slug of the registered ability category. + * @return WP_Ability_Category|null The unregistered ability category instance on success, null on failure. */ function wp_unregister_ability_category( string $slug ): ?WP_Ability_Category { - return WP_Abilities_Category_Registry::get_instance()->unregister( $slug ); + return WP_Ability_Categories_Registry::get_instance()->unregister( $slug ); } /** @@ -145,13 +145,13 @@ function wp_unregister_ability_category( string $slug ): ?WP_Ability_Category { * * @since 6.9.0 * - * @see WP_Abilities_Category_Registry::get_registered() + * @see WP_Ability_Categories_Registry::get_registered() * - * @param string $slug The slug of the registered category. - * @return WP_Ability_Category|null The registered category instance, or null if it is not registered. + * @param string $slug The slug of the registered ability category. + * @return WP_Ability_Category|null The registered ability category instance, or null if it is not registered. */ function wp_get_ability_category( string $slug ): ?WP_Ability_Category { - return WP_Abilities_Category_Registry::get_instance()->get_registered( $slug ); + return WP_Ability_Categories_Registry::get_instance()->get_registered( $slug ); } /** @@ -159,10 +159,10 @@ function wp_get_ability_category( string $slug ): ?WP_Ability_Category { * * @since 6.9.0 * - * @see WP_Abilities_Category_Registry::get_all_registered() + * @see WP_Ability_Categories_Registry::get_all_registered() * - * @return WP_Ability_Category[] The array of registered categories. + * @return WP_Ability_Category[] The array of registered ability categories. */ function wp_get_ability_categories(): array { - return WP_Abilities_Category_Registry::get_instance()->get_all_registered(); + return WP_Ability_Categories_Registry::get_instance()->get_all_registered(); } diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index fb339d5296c84..5c7917644935c 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -51,7 +51,7 @@ final class WP_Abilities_Registry { * * @type string $label The human-readable label for the ability. * @type string $description A detailed description of what the ability does. - * @type string $category The category slug this ability belongs to. + * @type string $category The ability category slug this ability belongs to. * @type callable $execute_callback A callback function to execute when the ability is invoked. * Receives optional mixed input and returns mixed result or WP_Error. * @type callable $permission_callback A callback function to check permissions before execution. @@ -100,7 +100,7 @@ public function register( string $name, array $args ): ?WP_Ability { * * @type string $label The human-readable label for the ability. * @type string $description A detailed description of what the ability does. - * @type string $category The category slug this ability belongs to. + * @type string $category The ability category slug this ability belongs to. * @type callable $execute_callback A callback function to execute when the ability is invoked. * Receives optional mixed input and returns mixed result or WP_Error. * @type callable $permission_callback A callback function to check permissions before execution. @@ -119,9 +119,9 @@ public function register( string $name, array $args ): ?WP_Ability { */ $args = apply_filters( 'wp_register_ability_args', $args, $name ); - // Validate category exists if provided (will be validated as required in WP_Ability). + // Validate ability category exists if provided (will be validated as required in WP_Ability). if ( isset( $args['category'] ) ) { - $category_registry = WP_Abilities_Category_Registry::get_instance(); + $category_registry = WP_Ability_Categories_Registry::get_instance(); if ( ! $category_registry->is_registered( $args['category'] ) ) { _doing_it_wrong( __METHOD__, @@ -261,9 +261,9 @@ 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 + // Ensure ability category registry is initialized first to allow categories to be registered // before abilities that depend on them. - WP_Abilities_Category_Registry::get_instance(); + WP_Ability_Categories_Registry::get_instance(); /** * Fires when preparing abilities registry. diff --git a/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php b/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php similarity index 69% rename from src/wp-includes/abilities-api/class-wp-abilities-category-registry.php rename to src/wp-includes/abilities-api/class-wp-ability-categories-registry.php index 19adc47827a11..04e1480f0e245 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-category-registry.php +++ b/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php @@ -2,7 +2,7 @@ /** * Abilities API * - * Defines WP_Abilities_Category_Registry class. + * Defines WP_Ability_Categories_Registry class. * * @package WordPress * @subpackage Abilities API @@ -17,7 +17,7 @@ * @since 6.9.0 * @access private */ -final class WP_Abilities_Category_Registry { +final class WP_Ability_Categories_Registry { /** * The singleton instance of the registry. * @@ -27,7 +27,7 @@ final class WP_Abilities_Category_Registry { private static $instance = null; /** - * Holds the registered categories. + * Holds the registered ability categories. * * @since 6.9.0 * @var WP_Ability_Category[] @@ -35,7 +35,7 @@ final class WP_Abilities_Category_Registry { private $registered_categories = array(); /** - * Registers a new category. + * Registers a new ability category. * * Do not use this method directly. Instead, use the `wp_register_ability_category()` function. * @@ -43,24 +43,24 @@ final class WP_Abilities_Category_Registry { * * @see wp_register_ability_category() * - * @param string $slug The unique slug for the category. Must contain only lowercase + * @param string $slug The unique slug for the ability category. Must contain only lowercase * alphanumeric characters and dashes. * @param array $args { - * An associative array of arguments for the category. + * An associative array of arguments for the ability category. * - * @type string $label The human-readable label for the category. - * @type string $description A description of the category. - * @type array $meta Optional. Additional metadata for the category. + * @type string $label The human-readable label for the ability category. + * @type string $description A description of the ability category. + * @type array $meta Optional. Additional metadata for the ability category. * } - * @return WP_Ability_Category|null The registered category instance on success, null on failure. + * @return WP_Ability_Category|null The registered ability category instance on success, null on failure. */ public function register( string $slug, array $args ): ?WP_Ability_Category { if ( ! doing_action( 'wp_abilities_api_categories_init' ) ) { _doing_it_wrong( __METHOD__, sprintf( - /* translators: 1: abilities_api_categories_init, 2: category slug. */ - __( 'Categories must be registered during the %1$s action. The category %2$s was not registered.' ), + /* translators: 1: abilities_api_categories_init, 2: ability category slug. */ + __( 'Ability categories must be registered during the %1$s action. The category %2$s was not registered.' ), 'wp_abilities_api_categories_init', '' . esc_html( $slug ) . '' ), @@ -72,8 +72,8 @@ public function register( string $slug, array $args ): ?WP_Ability_Category { if ( $this->is_registered( $slug ) ) { _doing_it_wrong( __METHOD__, - /* translators: %s: Category slug. */ - sprintf( __( 'Category "%s" is already registered.' ), esc_html( $slug ) ), + /* translators: %s: Ability category slug. */ + sprintf( __( 'Ability category "%s" is already registered.' ), esc_html( $slug ) ), '6.9.0' ); return null; @@ -82,25 +82,25 @@ public function register( string $slug, array $args ): ?WP_Ability_Category { if ( ! preg_match( '/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug ) ) { _doing_it_wrong( __METHOD__, - __( 'Category slug must contain only lowercase alphanumeric characters and dashes.' ), + __( 'Ability category slug must contain only lowercase alphanumeric characters and dashes.' ), '6.9.0' ); return null; } /** - * Filters the category arguments before they are validated and used to instantiate the category. + * Filters the ability category arguments before they are validated and used to instantiate the ability category. * * @since 6.9.0 * * @param array $args { - * The arguments used to instantiate the category. + * The arguments used to instantiate the ability category. * - * @type string $label The human-readable label for the category. - * @type string $description A description of the category. - * @type array $meta Optional. Additional metadata for the category. + * @type string $label The human-readable label for the ability category. + * @type string $description A description of the ability category. + * @type array $meta Optional. Additional metadata for the ability category. * } - * @param string $slug The slug of the category. + * @param string $slug The slug of the ability category. */ $args = apply_filters( 'wp_register_ability_category_args', $args, $slug ); @@ -121,7 +121,7 @@ public function register( string $slug, array $args ): ?WP_Ability_Category { } /** - * Unregisters a category. + * Unregisters a ability category. * * Do not use this method directly. Instead, use the `wp_unregister_ability_category()` function. * @@ -129,8 +129,8 @@ 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|null The unregistered category instance on success, null on failure. + * @param string $slug The slug of the registered ability category. + * @return WP_Ability_Category|null The unregistered ability category instance on success, null on failure. */ public function unregister( string $slug ): ?WP_Ability_Category { if ( ! $this->is_registered( $slug ) ) { @@ -150,7 +150,7 @@ public function unregister( string $slug ): ?WP_Ability_Category { } /** - * Retrieves the list of all registered categories. + * Retrieves the list of all registered ability categories. * * Do not use this method directly. Instead, use the `wp_get_ability_categories()` function. * @@ -158,26 +158,26 @@ public function unregister( string $slug ): ?WP_Ability_Category { * * @see wp_get_ability_categories() * - * @return array The array of registered categories. + * @return array The array of registered ability categories. */ public function get_all_registered(): array { return $this->registered_categories; } /** - * Checks if a category is registered. + * Checks if a ability category is registered. * * @since 6.9.0 * - * @param string $slug The slug of the category. - * @return bool True if the category is registered, false otherwise. + * @param string $slug The slug of the ability category. + * @return bool True if the ability category is registered, false otherwise. */ public function is_registered( string $slug ): bool { return isset( $this->registered_categories[ $slug ] ); } /** - * Retrieves a registered category. + * Retrieves a registered ability category. * * Do not use this method directly. Instead, use the `wp_get_ability_category()` function. * @@ -185,8 +185,8 @@ 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|null The registered category instance, or null if it is not registered. + * @param string $slug The slug of the registered ability category. + * @return WP_Ability_Category|null The registered ability category instance, or null if it is not registered. */ public function get_registered( string $slug ): ?WP_Ability_Category { if ( ! $this->is_registered( $slug ) ) { @@ -208,7 +208,7 @@ public function get_registered( string $slug ): ?WP_Ability_Category { * * @since 6.9.0 * - * @return WP_Abilities_Category_Registry The main registry instance. + * @return WP_Ability_Categories_Registry The main registry instance. */ public static function get_instance(): self { if ( null === self::$instance ) { @@ -217,11 +217,11 @@ public static function get_instance(): self { /** * Fires when preparing ability categories registry. * - * Categories should be registered on this action to ensure they're available when needed. + * Ability categories should be registered on this action to ensure they're available when needed. * * @since 6.9.0 * - * @param WP_Abilities_Category_Registry $instance Categories registry object. + * @param WP_Ability_Categories_Registry $instance Ability categories registry object. */ do_action( 'wp_abilities_api_categories_init', self::$instance ); } diff --git a/src/wp-includes/abilities-api/class-wp-ability-category.php b/src/wp-includes/abilities-api/class-wp-ability-category.php index b2bff19023877..df6c89701885a 100644 --- a/src/wp-includes/abilities-api/class-wp-ability-category.php +++ b/src/wp-includes/abilities-api/class-wp-ability-category.php @@ -16,12 +16,12 @@ * * @since 6.9.0 * - * @see WP_Abilities_Category_Registry + * @see WP_Ability_Categories_Registry */ final class WP_Ability_Category { /** - * The unique slug for the category. + * The unique slug for the ability category. * * @since 6.9.0 * @var string @@ -29,7 +29,7 @@ final class WP_Ability_Category { protected $slug; /** - * The human-readable category label. + * The human-readable ability category label. * * @since 6.9.0 * @var string @@ -37,7 +37,7 @@ final class WP_Ability_Category { protected $label; /** - * The detailed category description. + * The detailed ability category description. * * @since 6.9.0 * @var string @@ -45,7 +45,7 @@ final class WP_Ability_Category { protected $description; /** - * The optional category metadata. + * The optional ability category metadata. * * @since 6.9.0 * @var array @@ -63,19 +63,19 @@ final class WP_Ability_Category { * * @see wp_register_ability_category() * - * @param string $slug The unique slug for the category. + * @param string $slug The unique slug for the ability category. * @param array $args { - * An associative array of arguments for the category. + * An associative array of arguments for the ability category. * - * @type string $label The human-readable label for the category. - * @type string $description A description of the category. - * @type array $meta Optional. Additional metadata for the category. + * @type string $label The human-readable label for the ability category. + * @type string $description A description of the ability category. + * @type array $meta Optional. Additional metadata for the ability category. * } */ public function __construct( string $slug, array $args ) { if ( empty( $slug ) ) { throw new InvalidArgumentException( - esc_html__( 'The category slug cannot be empty.' ) + esc_html__( 'The ability category slug cannot be empty.' ) ); } @@ -89,7 +89,7 @@ public function __construct( string $slug, array $args ) { __METHOD__, sprintf( /* translators: %s: Property name. */ - __( 'Property "%1$s" is not a valid property for category "%2$s". Please check the %3$s class for allowed properties.' ), + __( 'Property "%1$s" is not a valid property for ability category "%2$s". Please check the %3$s class for allowed properties.' ), '' . esc_html( $property_name ) . '', '' . esc_html( $this->slug ) . '', '' . __CLASS__ . '' @@ -104,23 +104,23 @@ public function __construct( string $slug, array $args ) { } /** - * Prepares and validates the properties used to instantiate the category. + * Prepares and validates the properties used to instantiate the ability category. * * @since 6.9.0 * * @param array $args $args { * An associative array of arguments used to instantiate the ability category class. * - * @type string $label The human-readable label for the category. - * @type string $description A description of the category. - * @type array $meta Optional. Additional metadata for the category. + * @type string $label The human-readable label for the ability category. + * @type string $description A description of the ability category. + * @type array $meta Optional. Additional metadata for the ability category. * } * @return array $args { * An associative array with validated and prepared ability category properties. * - * @type string $label The human-readable label for the category. - * @type string $description A description of the category. - * @type array $meta Optional. Additional metadata for the category. + * @type string $label The human-readable label for the ability category. + * @type string $description A description of the ability category. + * @type array $meta Optional. Additional metadata for the ability category. * } * @throws InvalidArgumentException if an argument is invalid. */ @@ -128,20 +128,20 @@ 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( - __( 'The category properties must contain a `label` string.' ) + __( 'The ability category properties must contain a `label` string.' ) ); } if ( empty( $args['description'] ) || ! is_string( $args['description'] ) ) { throw new InvalidArgumentException( - __( 'The category properties must contain a `description` string.' ) + __( 'The ability category properties must contain a `description` string.' ) ); } // Optional args only need to be of the correct type if they are present. if ( isset( $args['meta'] ) && ! is_array( $args['meta'] ) ) { throw new InvalidArgumentException( - __( 'The category properties should provide a valid `meta` array.' ) + __( 'The ability category properties should provide a valid `meta` array.' ) ); } @@ -149,44 +149,44 @@ protected function prepare_properties( array $args ): array { } /** - * Retrieves the slug of the category. + * Retrieves the slug of the ability category. * * @since 6.9.0 * - * @return string The category slug. + * @return string The ability category slug. */ public function get_slug(): string { return $this->slug; } /** - * Retrieves the human-readable label for the category. + * Retrieves the human-readable label for the ability category. * * @since 6.9.0 * - * @return string The human-readable category label. + * @return string The human-readable ability category label. */ public function get_label(): string { return $this->label; } /** - * Retrieves the detailed description for the category. + * Retrieves the detailed description for the ability category. * * @since 6.9.0 * - * @return string The detailed description for the category. + * @return string The detailed description for the ability category. */ public function get_description(): string { return $this->description; } /** - * Retrieves the metadata for the category. + * Retrieves the metadata for the ability category. * * @since 6.9.0 * - * @return array The metadata for the category. + * @return array The metadata for the ability category. */ public function get_meta(): array { return $this->meta; diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 5595ab9eac3d0..6c74ab3871084 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -37,17 +37,17 @@ class WP_Ability { */ protected static $default_annotations = array( // If true, the ability does not modify its environment. - 'readonly' => false, + 'readonly' => false, /* * If true, the ability may perform destructive updates to its environment. * If false, the ability performs only additive updates. */ - 'destructive' => false, + 'destructive' => false, /* * If true, calling the ability repeatedly with the same arguments will have no additional effect * on its environment. */ - 'idempotent' => false, + 'idempotent' => false, ); /** @@ -140,7 +140,7 @@ class WP_Ability { * * @type string $label The human-readable label for the ability. * @type string $description A detailed description of what the ability does. - * @type string $category The category slug this ability belongs to. + * @type string $category The ability category slug this ability belongs to. * @type callable $execute_callback A callback function to execute when the ability is invoked. * Receives optional mixed input and returns mixed result or WP_Error. * @type callable $permission_callback A callback function to check permissions before execution. @@ -195,7 +195,7 @@ public function __construct( string $name, array $args ) { * * @type string $label The human-readable label for the ability. * @type string $description A detailed description of what the ability does. - * @type string $category The category slug this ability belongs to. + * @type string $category The ability category slug this ability belongs to. * @type callable $execute_callback A callback function to execute when the ability is invoked. * Receives optional mixed input and returns mixed result or WP_Error. * @type callable $permission_callback A callback function to check permissions before execution. @@ -214,7 +214,7 @@ public function __construct( string $name, array $args ) { * * @type string $label The human-readable label for the ability. * @type string $description A detailed description of what the ability does. - * @type string $category The category slug this ability belongs to. + * @type string $category The ability category slug this ability belongs to. * @type callable $execute_callback A callback function to execute when the ability is invoked. * Receives optional mixed input and returns mixed result or WP_Error. * @type callable $permission_callback A callback function to check permissions before execution. @@ -344,11 +344,11 @@ public function get_description(): string { } /** - * Retrieves the category for the ability. + * Retrieves the ability category for the ability. * * @since 6.9.0 * - * @return string The category for the ability. + * @return string The ability category for the ability. */ public function get_category(): string { return $this->category; diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php index 1451abbf58485..11eb7a1577c91 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php @@ -101,7 +101,7 @@ static function ( $ability ) { } ); - // Filter by category if specified. + // Filter by ability category if specified. $category = $request->get_param( 'category' ); if ( ! empty( $category ) ) { $abilities = array_filter( @@ -271,7 +271,7 @@ public function get_item_schema(): array { 'readonly' => true, ), 'category' => array( - 'description' => __( 'Category this ability belongs to.' ), + 'description' => __( 'Ability category this ability belongs to.' ), 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, @@ -325,7 +325,7 @@ public function get_collection_params(): array { 'maximum' => 100, ), 'category' => array( - 'description' => __( 'Limit results to abilities in specific category.' ), + 'description' => __( 'Limit results to abilities in specific ability category.' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', diff --git a/src/wp-settings.php b/src/wp-settings.php index bbc0745b84ee2..a0e01db14f643 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -286,7 +286,7 @@ require ABSPATH . WPINC . '/admin-bar.php'; require ABSPATH . WPINC . '/class-wp-application-passwords.php'; require ABSPATH . WPINC . '/abilities-api/class-wp-ability-category.php'; -require ABSPATH . WPINC . '/abilities-api/class-wp-abilities-category-registry.php'; +require ABSPATH . WPINC . '/abilities-api/class-wp-ability-categories-registry.php'; require ABSPATH . WPINC . '/abilities-api/class-wp-ability.php'; require ABSPATH . WPINC . '/abilities-api/class-wp-abilities-registry.php'; require ABSPATH . WPINC . '/abilities-api.php'; diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php index ed7f490b1fd1a..d337f95759556 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -33,7 +33,7 @@ public function set_up(): void { add_action( 'wp_abilities_api_categories_init', function () { - if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( 'math' ) ) { + if ( ! WP_Ability_Categories_Registry::get_instance()->is_registered( 'math' ) ) { wp_register_ability_category( 'math', array( @@ -94,7 +94,7 @@ public function tear_down(): void { remove_all_filters( 'wp_register_ability_args' ); // Clean up registered categories. - $category_registry = WP_Abilities_Category_Registry::get_instance(); + $category_registry = WP_Ability_Categories_Registry::get_instance(); if ( $category_registry->is_registered( 'math' ) ) { wp_unregister_ability_category( 'math' ); } diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php index 737a3091c2f89..8a26eda1b2218 100644 --- a/tests/phpunit/tests/abilities-api/wpAbility.php +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -22,7 +22,7 @@ public function set_up(): void { add_action( 'wp_abilities_api_categories_init', function () { - if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( 'math' ) ) { + if ( ! WP_Ability_Categories_Registry::get_instance()->is_registered( 'math' ) ) { wp_register_ability_category( 'math', array( @@ -66,7 +66,7 @@ function () { */ public function tear_down(): void { // Clean up registered categories. - $category_registry = WP_Abilities_Category_Registry::get_instance(); + $category_registry = WP_Ability_Categories_Registry::get_instance(); if ( $category_registry->is_registered( 'math' ) ) { wp_unregister_ability_category( 'math' ); } @@ -115,7 +115,7 @@ public function test_get_merged_annotations_from_meta() { array_merge( self::$test_ability_properties['meta']['annotations'], array( - 'idempotent' => false, + 'idempotent' => false, ) ), $ability->get_meta_item( 'annotations' ) @@ -135,9 +135,9 @@ public function test_get_default_annotations_from_meta() { $this->assertSame( array( - 'readonly' => false, - 'destructive' => false, - 'idempotent' => false, + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, ), $ability->get_meta_item( 'annotations' ) ); @@ -150,9 +150,9 @@ public function test_get_default_annotations_from_meta() { */ public function test_get_overridden_annotations_from_meta() { $annotations = array( - 'readonly' => true, - 'destructive' => false, - 'idempotent' => false, + 'readonly' => true, + 'destructive' => false, + 'idempotent' => false, ); $args = array_merge( self::$test_ability_properties, diff --git a/tests/phpunit/tests/abilities-api/wpAbilityCategory.php b/tests/phpunit/tests/abilities-api/wpAbilityCategory.php index cef370788aa16..b21c3ea58a202 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilityCategory.php +++ b/tests/phpunit/tests/abilities-api/wpAbilityCategory.php @@ -4,7 +4,7 @@ * Tests for the ability category functionality. * * @covers WP_Ability_Category - * @covers WP_Abilities_Category_Registry + * @covers WP_Ability_Categories_Registry * @covers wp_register_ability_category * @covers wp_unregister_ability_category * @covers wp_get_ability_category @@ -17,7 +17,7 @@ class Tests_Abilities_API_WpAbilityCategory extends WP_UnitTestCase { /** * Category registry instance. * - * @var WP_Abilities_Category_Registry + * @var WP_Ability_Categories_Registry */ private $registry; @@ -34,7 +34,7 @@ class Tests_Abilities_API_WpAbilityCategory extends WP_UnitTestCase { public function set_up(): void { parent::set_up(); - $this->registry = WP_Abilities_Category_Registry::get_instance(); + $this->registry = WP_Ability_Categories_Registry::get_instance(); $this->doing_it_wrong_log = array(); add_action( 'doing_it_wrong_run', array( $this, 'record_doing_it_wrong' ), 10, 3 ); @@ -114,7 +114,7 @@ private function register_category_during_hook( string $slug, array $args ): ?WP }; add_action( 'wp_abilities_api_categories_init', $callback ); - do_action( 'wp_abilities_api_categories_init', WP_Abilities_Category_Registry::get_instance() ); + do_action( 'wp_abilities_api_categories_init', WP_Ability_Categories_Registry::get_instance() ); remove_action( 'wp_abilities_api_categories_init', $callback ); return $result; @@ -145,7 +145,7 @@ public function test_register_valid_category(): void { * * @ticket 64098 * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_category_invalid_slug_format(): void { // Uppercase characters not allowed. @@ -158,7 +158,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' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'slug must contain only lowercase' ); } /** @@ -166,7 +166,7 @@ public function test_register_category_invalid_slug_format(): void { * * @ticket 64098 * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_category_invalid_slug_underscore(): void { $result = $this->register_category_during_hook( @@ -178,7 +178,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' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'slug must contain only lowercase' ); } /** @@ -186,7 +186,7 @@ public function test_register_category_invalid_slug_underscore(): void { * * @ticket 64098 * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_category_missing_label(): void { $result = $this->register_category_during_hook( @@ -197,7 +197,7 @@ public function test_register_category_missing_label(): void { ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); } /** @@ -205,7 +205,7 @@ public function test_register_category_missing_label(): void { * * @ticket 64098 * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_category_missing_description(): void { $result = $this->register_category_during_hook( @@ -216,7 +216,7 @@ public function test_register_category_missing_description(): void { ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); } /** @@ -224,7 +224,7 @@ public function test_register_category_missing_description(): void { * * @ticket 64098 * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_category_before_init_hook(): void { global $wp_actions; @@ -249,7 +249,7 @@ public function test_register_category_before_init_hook(): void { } $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'wp_abilities_api_categories_init' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'wp_abilities_api_categories_init' ); } /** @@ -257,7 +257,7 @@ public function test_register_category_before_init_hook(): void { * * @ticket 64098 * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_duplicate_category(): void { $result = null; @@ -280,11 +280,11 @@ public function test_register_duplicate_category(): void { }; add_action( 'wp_abilities_api_categories_init', $callback ); - do_action( 'wp_abilities_api_categories_init', WP_Abilities_Category_Registry::get_instance() ); + do_action( 'wp_abilities_api_categories_init', WP_Ability_Categories_Registry::get_instance() ); remove_action( 'wp_abilities_api_categories_init', $callback ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'already registered' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'already registered' ); } /** @@ -312,13 +312,13 @@ public function test_unregister_existing_category(): void { * * @ticket 64098 * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::unregister + * @expectedIncorrectUsage WP_Ability_Categories_Registry::unregister */ public function test_unregister_nonexistent_category(): void { $result = wp_unregister_ability_category( 'test-nonexistent' ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::unregister' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::unregister' ); } /** @@ -346,13 +346,13 @@ public function test_get_existing_category(): void { * * @ticket 64098 * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::get_registered + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_registered */ public function test_get_nonexistent_category(): void { $result = wp_get_ability_category( 'test-nonexistent' ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::get_registered' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::get_registered' ); } /** @@ -416,7 +416,7 @@ public function test_ability_requires_existing_category(): void { // Ensure category doesn't exist - test should fail if it does. $this->assertFalse( - WP_Abilities_Category_Registry::get_instance()->is_registered( 'test-nonexistent' ), + WP_Ability_Categories_Registry::get_instance()->is_registered( 'test-nonexistent' ), 'The test-nonexistent category should not be registered - test isolation may be broken' ); @@ -455,7 +455,7 @@ public function test_ability_with_valid_category(): void { }; add_action( 'wp_abilities_api_categories_init', $category_callback ); - do_action( 'wp_abilities_api_categories_init', WP_Abilities_Category_Registry::get_instance() ); + do_action( 'wp_abilities_api_categories_init', WP_Ability_Categories_Registry::get_instance() ); remove_action( 'wp_abilities_api_categories_init', $category_callback ); do_action( 'wp_abilities_api_init' ); @@ -485,8 +485,8 @@ public function test_ability_with_valid_category(): void { * @ticket 64098 */ public function test_category_registry_singleton(): void { - $instance1 = WP_Abilities_Category_Registry::get_instance(); - $instance2 = WP_Abilities_Category_Registry::get_instance(); + $instance1 = WP_Ability_Categories_Registry::get_instance(); + $instance2 = WP_Ability_Categories_Registry::get_instance(); $this->assertSame( $instance1, $instance2 ); } @@ -570,7 +570,7 @@ public function data_invalid_slug_provider(): array { * @ticket 64098 * * @dataProvider data_invalid_slug_provider - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register * * @param string $slug The category slug to test. */ @@ -584,7 +584,7 @@ public function test_category_slug_invalid_formats( string $slug ): void { ); $this->assertNull( $result, "Slug '{$slug}' should be invalid" ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); } /** @@ -592,7 +592,7 @@ public function test_category_slug_invalid_formats( string $slug ): void { * * @ticket 64098 * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_category_constructor_non_string_label(): void { $result = $this->register_category_during_hook( @@ -604,7 +604,7 @@ public function test_category_constructor_non_string_label(): void { ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); } /** @@ -612,7 +612,7 @@ public function test_category_constructor_non_string_label(): void { * * @ticket 64098 * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_category_constructor_empty_label(): void { $result = $this->register_category_during_hook( @@ -624,7 +624,7 @@ public function test_category_constructor_empty_label(): void { ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); } /** @@ -632,7 +632,7 @@ public function test_category_constructor_empty_label(): void { * * @ticket 64098 * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_category_constructor_non_string_description(): void { $result = $this->register_category_during_hook( @@ -644,7 +644,7 @@ public function test_category_constructor_non_string_description(): void { ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); } /** @@ -652,7 +652,7 @@ public function test_category_constructor_non_string_description(): void { * * @ticket 64098 * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_category_constructor_empty_description(): void { $result = $this->register_category_during_hook( @@ -664,7 +664,7 @@ public function test_category_constructor_empty_description(): void { ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); } /** @@ -786,7 +786,7 @@ public function test_register_category_without_meta_returns_empty_array(): void * * @ticket 64098 * - * @expectedIncorrectUsage WP_Abilities_Category_Registry::register + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_category_with_invalid_meta(): void { $result = $this->register_category_during_hook( @@ -799,7 +799,7 @@ public function test_register_category_with_invalid_meta(): void { ); $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Category_Registry::register', 'valid `meta` array' ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'valid `meta` array' ); } /** diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index bd3e8ca046381..fe37b3539a2a3 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -34,7 +34,7 @@ public function set_up(): void { add_action( 'wp_abilities_api_categories_init', function () { - if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( 'math' ) ) { + if ( ! WP_Ability_Categories_Registry::get_instance()->is_registered( 'math' ) ) { wp_register_ability_category( 'math', array( @@ -103,7 +103,7 @@ public function tear_down(): void { } // Clean up registered categories. - $category_registry = WP_Abilities_Category_Registry::get_instance(); + $category_registry = WP_Ability_Categories_Registry::get_instance(); if ( $category_registry->is_registered( 'math' ) ) { wp_unregister_ability_category( 'math' ); } @@ -165,7 +165,7 @@ public function test_register_valid_ability(): void { $expected_annotations = array_merge( self::$test_ability_args['meta']['annotations'], array( - 'idempotent' => false, + 'idempotent' => false, ) ); $expected_meta = array_merge( @@ -513,7 +513,7 @@ public function test_register_ability_nonexistent_category(): void { // Ensure category doesn't exist - test should fail if it does. $this->assertFalse( - WP_Abilities_Category_Registry::get_instance()->is_registered( 'nonexistent' ), + WP_Ability_Categories_Registry::get_instance()->is_registered( 'nonexistent' ), 'The nonexistent category should not be registered - test isolation may be broken' ); diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php index 4f4839045b822..ddf41dfece63e 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php @@ -760,7 +760,7 @@ public function test_filter_by_category(): void { public function test_filter_by_nonexistent_category(): void { // Ensure category doesn't exist - test should fail if it does. $this->assertFalse( - WP_Abilities_Category_Registry::get_instance()->is_registered( 'nonexistent' ), + WP_Ability_Categories_Registry::get_instance()->is_registered( 'nonexistent' ), 'The nonexistent category should not be registered - test isolation may be broken' ); diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 486c68a1ab273..5f24ee391b695 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12473,7 +12473,7 @@ mockedApiResponse.Schema = { "required": false }, "category": { - "description": "Limit results to abilities in specific category.", + "description": "Limit results to abilities in specific ability category.", "type": "string", "required": false } From 19f1a611d86ffaf2c8914fbfec122cef30bbfefe Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Sat, 18 Oct 2025 14:07:26 +0200 Subject: [PATCH 20/31] Introduce `wp_has_ability()` and `wp_has_ability_category()` public methods --- src/wp-includes/abilities-api.php | 28 ++++++++++++++++ .../class-wp-abilities-registry.php | 4 +++ .../class-wp-ability-categories-registry.php | 8 +++-- ...lass-wp-rest-abilities-list-controller.php | 18 +++++----- .../abilities-api/wpAbilitiesRegistry.php | 4 +-- .../phpunit/tests/abilities-api/wpAbility.php | 2 +- .../tests/abilities-api/wpAbilityCategory.php | 33 ++++++++++++++++++- .../tests/abilities-api/wpRegisterAbility.php | 32 ++++++++++++++++-- .../wpRestAbilitiesListController.php | 2 +- 9 files changed, 113 insertions(+), 18 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index c278bbe1dfab4..7f6b6bdb18691 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -77,6 +77,20 @@ function wp_unregister_ability( string $name ): ?WP_Ability { return WP_Abilities_Registry::get_instance()->unregister( $name ); } +/** + * Checks if an ability is registered. + * + * @since 6.9.0 + * + * @see WP_Abilities_Registry::is_registered() + * + * @param string $name The name of the registered ability, with its namespace. + * @return bool True if the ability is registered, false otherwise. + */ +function wp_has_ability( string $name ): bool { + return WP_Abilities_Registry::get_instance()->is_registered( $name ); +} + /** * Retrieves a registered ability using Abilities API. * @@ -140,6 +154,20 @@ function wp_unregister_ability_category( string $slug ): ?WP_Ability_Category { return WP_Ability_Categories_Registry::get_instance()->unregister( $slug ); } +/** + * Checks if an ability category is registered. + * + * @since 6.9.0 + * + * @see WP_Ability_Categories_Registry::is_registered() + * + * @param string $slug The slug of the ability category. + * @return bool True if the ability category is registered, false otherwise. + */ +function wp_has_ability_category( string $slug ): bool { + return WP_Ability_Categories_Registry::get_instance()->is_registered( $slug ); +} + /** * Retrieves a registered ability category. * diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index 5c7917644935c..1ee7d3689ed42 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -214,8 +214,12 @@ public function get_all_registered(): array { /** * Checks if an ability is registered. * + * Do not use this method directly. Instead, use the `wp_has_ability()` function. + * * @since 6.9.0 * + * @see wp_has_ability() + * * @param string $name The name of the registered ability, with its namespace. * @return bool True if the ability is registered, false otherwise. */ diff --git a/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php b/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php index 04e1480f0e245..243420792c964 100644 --- a/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php +++ b/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php @@ -121,7 +121,7 @@ public function register( string $slug, array $args ): ?WP_Ability_Category { } /** - * Unregisters a ability category. + * Unregisters an ability category. * * Do not use this method directly. Instead, use the `wp_unregister_ability_category()` function. * @@ -165,10 +165,14 @@ public function get_all_registered(): array { } /** - * Checks if a ability category is registered. + * Checks if an ability category is registered. + * + * Do not use this method directly. Instead, use the `wp_has_ability_category()` function. * * @since 6.9.0 * + * @see wp_has_ability_category() + * * @param string $slug The slug of the ability category. * @return bool True if the ability category is registered, false otherwise. */ diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php index 11eb7a1577c91..c136c3080221a 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php @@ -312,17 +312,17 @@ public function get_collection_params(): array { return array( 'context' => $this->get_context_param( array( 'default' => 'view' ) ), 'page' => array( - 'description' => __( 'Current page of the collection.' ), - 'type' => 'integer', - 'default' => 1, - 'minimum' => 1, + 'description' => __( 'Current page of the collection.' ), + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, ), 'per_page' => array( - 'description' => __( 'Maximum number of items to be returned in result set.' ), - 'type' => 'integer', - 'default' => self::DEFAULT_PER_PAGE, - 'minimum' => 1, - 'maximum' => 100, + 'description' => __( 'Maximum number of items to be returned in result set.' ), + 'type' => 'integer', + 'default' => self::DEFAULT_PER_PAGE, + 'minimum' => 1, + 'maximum' => 100, ), 'category' => array( 'description' => __( 'Limit results to abilities in specific ability category.' ), diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php index d337f95759556..682ab3aeb383e 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -33,7 +33,7 @@ public function set_up(): void { add_action( 'wp_abilities_api_categories_init', function () { - if ( ! WP_Ability_Categories_Registry::get_instance()->is_registered( 'math' ) ) { + if ( ! wp_has_ability_category( 'math' ) ) { wp_register_ability_category( 'math', array( @@ -464,7 +464,7 @@ public function test_get_registered_for_known_ability() { } /** - * Unregistering should fail if a ability is not registered. + * Unregistering should fail if an ability is not registered. * * @ticket 64098 * diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php index 8a26eda1b2218..d9a429194691f 100644 --- a/tests/phpunit/tests/abilities-api/wpAbility.php +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -22,7 +22,7 @@ public function set_up(): void { add_action( 'wp_abilities_api_categories_init', function () { - if ( ! WP_Ability_Categories_Registry::get_instance()->is_registered( 'math' ) ) { + if ( ! wp_has_ability_category( 'math' ) ) { wp_register_ability_category( 'math', array( diff --git a/tests/phpunit/tests/abilities-api/wpAbilityCategory.php b/tests/phpunit/tests/abilities-api/wpAbilityCategory.php index b21c3ea58a202..c5c75162649c3 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilityCategory.php +++ b/tests/phpunit/tests/abilities-api/wpAbilityCategory.php @@ -355,6 +355,37 @@ public function test_get_nonexistent_category(): void { $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::get_registered' ); } + /** + * Tests checking if an ability category is registered. + * + * @ticket 64098 + */ + public function test_has_registered_ability_category(): void { + $category_slug = 'test-math'; + $this->register_category_during_hook( + $category_slug, + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $result = wp_has_ability_category( $category_slug ); + + $this->assertTrue( $result ); + } + + /** + * Tests checking if a non-existent ability category is registered. + * + * @ticket 64098 + */ + public function test_has_registered_nonexistent_ability_category(): void { + $result = wp_has_ability_category( 'test/non-existent' ); + + $this->assertFalse( $result ); + } + /** * Test retrieving all registered categories. * @@ -416,7 +447,7 @@ public function test_ability_requires_existing_category(): void { // Ensure category doesn't exist - test should fail if it does. $this->assertFalse( - WP_Ability_Categories_Registry::get_instance()->is_registered( 'test-nonexistent' ), + wp_has_ability_category( 'test-nonexistent' ), 'The test-nonexistent category should not be registered - test isolation may be broken' ); diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index fe37b3539a2a3..eb72a6a332f8a 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -34,7 +34,7 @@ public function set_up(): void { add_action( 'wp_abilities_api_categories_init', function () { - if ( ! WP_Ability_Categories_Registry::get_instance()->is_registered( 'math' ) ) { + if ( ! wp_has_ability_category( 'math' ) ) { wp_register_ability_category( 'math', array( @@ -471,6 +471,34 @@ public function test_get_existing_ability() { ); } + /** + * Tests checking if an ability is registered. + * + * @ticket 64098 + */ + public function test_has_registered_ability() { + do_action( 'wp_abilities_api_init' ); + + wp_register_ability( self::$test_ability_name, self::$test_ability_args ); + + $result = wp_has_ability( self::$test_ability_name ); + + $this->assertTrue( $result ); + } + + /** + * Tests checking if a non-existent ability is registered. + * + * @ticket 64098 + */ + public function test_has_registered_nonexistent_abi() { + do_action( 'wp_abilities_api_init' ); + + $result = wp_has_ability( 'test/non-existent' ); + + $this->assertFalse( $result ); + } + /** * Tests retrieving all registered abilities. * @@ -513,7 +541,7 @@ public function test_register_ability_nonexistent_category(): void { // Ensure category doesn't exist - test should fail if it does. $this->assertFalse( - WP_Ability_Categories_Registry::get_instance()->is_registered( 'nonexistent' ), + wp_has_ability_category( 'nonexistent' ), 'The nonexistent category should not be registered - test isolation may be broken' ); diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php index ddf41dfece63e..96c7ea675056b 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php @@ -760,7 +760,7 @@ public function test_filter_by_category(): void { public function test_filter_by_nonexistent_category(): void { // Ensure category doesn't exist - test should fail if it does. $this->assertFalse( - WP_Ability_Categories_Registry::get_instance()->is_registered( 'nonexistent' ), + wp_has_ability_category( 'nonexistent' ), 'The nonexistent category should not be registered - test isolation may be broken' ); From de07c8c983ae208d35d17b292b915e662b63d170 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Sat, 18 Oct 2025 15:56:45 +0200 Subject: [PATCH 21/31] Improve support for annotations, including DELETE handling in REST API --- src/wp-includes/abilities-api.php | 4 +- .../class-wp-abilities-registry.php | 4 +- .../abilities-api/class-wp-ability.php | 20 ++--- ...class-wp-rest-abilities-run-controller.php | 32 ++++---- .../phpunit/tests/abilities-api/wpAbility.php | 10 +-- .../rest-api/wpRestAbilitiesRunController.php | 80 +++++++++++++++++++ 6 files changed, 116 insertions(+), 34 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index 7f6b6bdb18691..5ae0edf751e2b 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -38,8 +38,8 @@ * @type array $meta { * Optional. Additional metadata for the ability. * - * @type array $annotations Optional. Annotation metadata for the ability. - * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. * } * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability. * } diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index 1ee7d3689ed42..cb3e58db9519c 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -61,8 +61,8 @@ final class WP_Abilities_Registry { * @type array $meta { * Optional. Additional metadata for the ability. * - * @type array $annotations Optional. Annotation metadata for the ability. - * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. * } * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability. * } diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 6c74ab3871084..dfaf20bd7db83 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -33,21 +33,21 @@ class WP_Ability { * They are not guaranteed to provide a faithful description of ability behavior. * * @since 6.9.0 - * @var array + * @var array */ protected static $default_annotations = array( // If true, the ability does not modify its environment. - 'readonly' => false, + 'readonly' => null, /* * If true, the ability may perform destructive updates to its environment. * If false, the ability performs only additive updates. */ - 'destructive' => false, + 'destructive' => null, /* * If true, calling the ability repeatedly with the same arguments will have no additional effect * on its environment. */ - 'idempotent' => false, + 'idempotent' => null, ); /** @@ -150,8 +150,8 @@ class WP_Ability { * @type array $meta { * Optional. Additional metadata for the ability. * - * @type array $annotations Optional. Annotation metadata for the ability. - * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. * } * } */ @@ -205,8 +205,8 @@ public function __construct( string $name, array $args ) { * @type array $meta { * Optional. Additional metadata for the ability. * - * @type array $annotations Optional. Annotation metadata for the ability. - * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. * } * } * @return array { @@ -224,8 +224,8 @@ public function __construct( string $name, array $args ) { * @type array $meta { * Additional metadata for the ability. * - * @type array $annotations Optional. Annotation metadata for the ability. - * @type bool $show_in_rest Whether to expose this ability in the REST API. Default false. + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Whether to expose this ability in the REST API. Default false. * } * } * @throws InvalidArgumentException if an argument is invalid. diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php index d48c030d9f609..bebe09ffed518 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php @@ -91,22 +91,24 @@ public function run_ability_with_method_check( $request ) { } // Check if the HTTP method matches the ability annotations. - $annotations = $ability->get_meta_item( 'annotations' ); - $is_readonly = ! empty( $annotations['readonly'] ); - $method = $request->get_method(); - - if ( $is_readonly && 'GET' !== $method ) { - return new WP_Error( - 'rest_ability_invalid_method', - __( 'Read-only abilities require GET method.' ), - array( 'status' => 405 ) - ); + $annotations = $ability->get_meta_item( 'annotations' ); + $expected_method = 'POST'; + if ( ! empty( $annotations['readonly'] ) ) { + $expected_method = 'GET'; + } elseif ( ! empty( $annotations['destructive'] ) && ! empty( $annotations['idempotent'] ) ) { + $expected_method = 'DELETE'; } - if ( ! $is_readonly && 'POST' !== $method ) { + if ( $expected_method !== $request->get_method() ) { + $error_message = __( 'Abilities that perform updates require POST method.' ); + if ( 'GET' === $expected_method ) { + $error_message = __( 'Read-only abilities require GET method.' ); + } elseif ( 'DELETE' === $expected_method ) { + $error_message = __( 'Abilities that perform destructive actions require DELETE method.' ); + } return new WP_Error( 'rest_ability_invalid_method', - __( 'Abilities that perform updates require POST method.' ), + $error_message, array( 'status' => 405 ) ); } @@ -183,8 +185,8 @@ public function run_ability_permissions_check( $request ) { * @return mixed|null The input parameters. */ private function get_input_from_request( $request ) { - if ( 'GET' === $request->get_method() ) { - // For GET requests, look for 'input' query parameter. + if ( in_array( $request->get_method(), array( 'GET', 'DELETE' ) ) ) { + // For GET and DELETE requests, look for 'input' query parameter. $query_params = $request->get_query_params(); return $query_params['input'] ?? null; } @@ -226,7 +228,7 @@ public function get_run_schema(): array { 'properties' => array( 'result' => array( 'description' => __( 'The result of the ability execution.' ), - 'context' => array( 'view' ), + 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php index d9a429194691f..77712ae60e719 100644 --- a/tests/phpunit/tests/abilities-api/wpAbility.php +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -111,11 +111,11 @@ public function test_meta_get_non_existing_item_with_custom_default() { public function test_get_merged_annotations_from_meta() { $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); - $this->assertEquals( + $this->assertSame( array_merge( self::$test_ability_properties['meta']['annotations'], array( - 'idempotent' => false, + 'idempotent' => null, ) ), $ability->get_meta_item( 'annotations' ) @@ -135,9 +135,9 @@ public function test_get_default_annotations_from_meta() { $this->assertSame( array( - 'readonly' => false, - 'destructive' => false, - 'idempotent' => false, + 'readonly' => null, + 'destructive' => null, + 'idempotent' => null, ), $ability->get_meta_item( 'annotations' ) ); diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php index 9902fa34d4f32..aa09a357fe57d 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php @@ -228,6 +228,47 @@ private function register_test_abilities(): void { ) ); + // Destructive ability (DELETE method). + wp_register_ability( + 'test/delete-user', + array( + 'label' => 'Delete User', + 'description' => 'Deletes a user', + 'category' => 'system', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'user_id' => array( + 'type' => 'integer', + 'default' => 0, + ), + ), + ), + 'output_schema' => array( + 'type' => 'string', + 'required' => true, + ), + 'execute_callback' => static function ( array $input ) { + $user_id = $input['user_id'] ?? get_current_user_id(); + $user = get_user_by( 'id', $user_id ); + if ( ! $user ) { + return new WP_Error( 'user_not_found', 'User not found' ); + } + return 'User successfully deleted!'; + }, + 'permission_callback' => static function () { + return is_user_logged_in(); + }, + 'meta' => array( + 'annotations' => array( + 'destructive' => true, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + // Ability with contextual permissions wp_register_ability( 'test/restricted', @@ -402,6 +443,27 @@ public function test_execute_readonly_ability_get(): void { $this->assertEquals( self::$user_id, $data['id'] ); } + /** + * Test executing a destructive ability with GET. + * + * @ticket 64098 + */ + public function test_execute_destructive_ability_delete(): void { + $request = new WP_REST_Request( 'DELETE', '/wp/v2/abilities/test/delete-user/run' ); + $request->set_query_params( + array( + 'input' => array( + 'user_id' => self::$user_id, + ), + ) + ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 'User successfully deleted!', $response->get_data() ); + } + /** * Test HTTP method validation for regular abilities. * @@ -452,6 +514,24 @@ public function test_readonly_ability_requires_get(): void { $this->assertSame( 'Read-only abilities require GET method.', $data['message'] ); } + /** + * Test HTTP method validation for destructive abilities. + * + * @ticket 64098 + */ + public function test_destructive_ability_requires_delete(): void { + // Try POST on a destructive ability (should fail). + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/delete-user/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'user_id' => 1 ) ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 405, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( 'rest_ability_invalid_method', $data['code'] ); + $this->assertSame( 'Abilities that perform destructive actions require DELETE method.', $data['message'] ); + } /** * Test output validation against schema. From 1e54797715cc2ee7b7a2d1523f4db9e8e30b46bb Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Sat, 18 Oct 2025 21:55:13 +0200 Subject: [PATCH 22/31] Fix bug in the permission check for ability in REST API run controller --- ...class-wp-rest-abilities-run-controller.php | 102 +++++++++--------- 1 file changed, 53 insertions(+), 49 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php index bebe09ffed518..93aa6c2fd535b 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php @@ -62,8 +62,8 @@ public function register_routes(): void { // This was the same issue that we ended up seeing with the Feature API. array( 'methods' => WP_REST_Server::ALLMETHODS, - 'callback' => array( $this, 'run_ability_with_method_check' ), - 'permission_callback' => array( $this, 'run_ability_permissions_check' ), + 'callback' => array( $this, 'execute_ability' ), + 'permission_callback' => array( $this, 'check_ability_permissions' ), 'args' => $this->get_run_args(), ), 'schema' => array( $this, 'get_run_schema' ), @@ -72,16 +72,15 @@ public function register_routes(): void { } /** - * Executes an ability with HTTP method validation. + * Executes an ability. * * @since 6.9.0 * * @param WP_REST_Request> $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ - public function run_ability_with_method_check( $request ) { + public function execute_ability( $request ) { $ability = wp_get_ability( $request->get_param( 'name' ) ); - if ( ! $ability ) { return new WP_Error( 'rest_ability_not_found', @@ -90,60 +89,50 @@ public function run_ability_with_method_check( $request ) { ); } - // Check if the HTTP method matches the ability annotations. - $annotations = $ability->get_meta_item( 'annotations' ); - $expected_method = 'POST'; - if ( ! empty( $annotations['readonly'] ) ) { - $expected_method = 'GET'; - } elseif ( ! empty( $annotations['destructive'] ) && ! empty( $annotations['idempotent'] ) ) { - $expected_method = 'DELETE'; - } - - if ( $expected_method !== $request->get_method() ) { - $error_message = __( 'Abilities that perform updates require POST method.' ); - if ( 'GET' === $expected_method ) { - $error_message = __( 'Read-only abilities require GET method.' ); - } elseif ( 'DELETE' === $expected_method ) { - $error_message = __( 'Abilities that perform destructive actions require DELETE method.' ); + $input = $this->get_input_from_request( $request ); + $result = $ability->execute( $input ); + if ( is_wp_error( $result ) ) { + if ( 'ability_invalid_input' === $result->get_error_code() ) { + $result->add_data( array( 'status' => 400 ) ); } - return new WP_Error( - 'rest_ability_invalid_method', - $error_message, - array( 'status' => 405 ) - ); + return $result; } - return $this->run_ability( $request ); + return rest_ensure_response( $result ); } /** - * Executes an ability. + * Validates if the HTTP method matches the expected method for the ability based on its annotations. * * @since 6.9.0 * - * @param WP_REST_Request> $request Full details about the request. - * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + * @param string $request_method The HTTP method of the request. + * @param array $annotations The ability annotations. + * @return true|WP_Error True on success, or WP_Error object on failure. */ - public function run_ability( $request ) { - $ability = wp_get_ability( $request->get_param( 'name' ) ); - if ( ! $ability ) { - return new WP_Error( - 'rest_ability_not_found', - __( 'Ability not found.' ), - array( 'status' => 404 ) - ); + public function validate_request_method( string $request_method, array $annotations ) { + $expected_method = 'POST'; + if ( ! empty( $annotations['readonly'] ) ) { + $expected_method = 'GET'; + } elseif ( ! empty( $annotations['destructive'] ) && ! empty( $annotations['idempotent'] ) ) { + $expected_method = 'DELETE'; } - $input = $this->get_input_from_request( $request ); - $result = $ability->execute( $input ); - if ( is_wp_error( $result ) ) { - if ( 'ability_invalid_input' === $result->get_error_code() ) { - $result->add_data( array( 'status' => 400 ) ); - } - return $result; + if ( $expected_method === $request_method ) { + return true; } - return rest_ensure_response( $result ); + $error_message = __( 'Abilities that perform updates require POST method.' ); + if ( 'GET' === $expected_method ) { + $error_message = __( 'Read-only abilities require GET method.' ); + } elseif ( 'DELETE' === $expected_method ) { + $error_message = __( 'Abilities that perform destructive actions require DELETE method.' ); + } + return new WP_Error( + 'rest_ability_invalid_method', + $error_message, + array( 'status' => 405 ) + ); } /** @@ -154,7 +143,7 @@ public function run_ability( $request ) { * @param WP_REST_Request> $request Full details about the request. * @return true|WP_Error True if the request has execution permission, WP_Error object otherwise. */ - public function run_ability_permissions_check( $request ) { + public function check_ability_permissions( $request ) { $ability = wp_get_ability( $request->get_param( 'name' ) ); if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) { return new WP_Error( @@ -164,8 +153,23 @@ public function run_ability_permissions_check( $request ) { ); } - $input = $this->get_input_from_request( $request ); - if ( ! $ability->check_permissions( $input ) ) { + $is_valid = $this->validate_request_method( + $request->get_method(), + $ability->get_meta_item( 'annotations' ) + ); + if ( is_wp_error( $is_valid ) ) { + return $is_valid; + } + + $input = $this->get_input_from_request( $request ); + $result = $ability->check_permissions( $input ); + if ( is_wp_error( $result ) ) { + if ( 'ability_invalid_input' === $result->get_error_code() ) { + $result->add_data( array( 'status' => 400 ) ); + } + return $result; + } + if ( ! $result ) { return new WP_Error( 'rest_ability_cannot_execute', __( 'Sorry, you are not allowed to execute this ability.' ), @@ -185,7 +189,7 @@ public function run_ability_permissions_check( $request ) { * @return mixed|null The input parameters. */ private function get_input_from_request( $request ) { - if ( in_array( $request->get_method(), array( 'GET', 'DELETE' ) ) ) { + if ( in_array( $request->get_method(), array( 'GET', 'DELETE' ), true ) ) { // For GET and DELETE requests, look for 'input' query parameter. $query_params = $request->get_query_params(); return $query_params['input'] ?? null; From 57d8eda951452130bbb7d405e5e8518d9b77b512 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Sun, 19 Oct 2025 19:58:24 +0200 Subject: [PATCH 23/31] Improve handling for init hooks related to abilities and their categories --- src/wp-includes/abilities-api.php | 14 ++ .../class-wp-ability-categories-registry.php | 14 -- .../abilities-api/wpAbilitiesRegistry.php | 50 ++--- .../phpunit/tests/abilities-api/wpAbility.php | 32 +--- .../tests/abilities-api/wpAbilityCategory.php | 172 +++++------------- .../tests/abilities-api/wpRegisterAbility.php | 60 ++---- .../wpRestAbilitiesListController.php | 13 +- .../rest-api/wpRestAbilitiesRunController.php | 13 +- 8 files changed, 117 insertions(+), 251 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index 5ae0edf751e2b..ce4f9bfe3c54b 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -137,6 +137,20 @@ function wp_get_abilities(): array { * @return WP_Ability_Category|null The registered ability category instance on success, null on failure. */ function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_Category { + if ( ! did_action( 'wp_abilities_api_categories_init' ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: abilities_api_categories_init, 2: ability category slug. */ + __( 'Ability categories must be registered on the %1$s action. The category %2$s was not registered.' ), + 'wp_abilities_api_categories_init', + '' . esc_html( $slug ) . '' + ), + '6.9.0' + ); + return null; + } + return WP_Ability_Categories_Registry::get_instance()->register( $slug, $args ); } diff --git a/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php b/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php index 243420792c964..353d93d3b9353 100644 --- a/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php +++ b/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php @@ -55,20 +55,6 @@ final class WP_Ability_Categories_Registry { * @return WP_Ability_Category|null The registered ability category instance on success, null on failure. */ public function register( string $slug, array $args ): ?WP_Ability_Category { - if ( ! doing_action( 'wp_abilities_api_categories_init' ) ) { - _doing_it_wrong( - __METHOD__, - sprintf( - /* translators: 1: abilities_api_categories_init, 2: ability category slug. */ - __( 'Ability categories must be registered during the %1$s action. The category %2$s was not registered.' ), - 'wp_abilities_api_categories_init', - '' . esc_html( $slug ) . '' - ), - '6.9.0' - ); - return null; - } - if ( $this->is_registered( $slug ) ) { _doing_it_wrong( __METHOD__, diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php index 682ab3aeb383e..fdbf64a870f15 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -29,24 +29,15 @@ public function set_up(): void { remove_all_filters( 'wp_register_ability_args' ); - // Register category during the hook. - add_action( - 'wp_abilities_api_categories_init', - function () { - if ( ! wp_has_ability_category( 'math' ) ) { - wp_register_ability_category( - 'math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations and calculations.', - ) - ); - } - } - ); - - // Fire the hook to allow category registration. + // Fire the init hook to allow test ability category registration. do_action( 'wp_abilities_api_categories_init' ); + wp_register_ability_category( + 'math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations and calculations.', + ) + ); self::$test_ability_args = array( 'label' => 'Add numbers', @@ -93,11 +84,8 @@ public function tear_down(): void { remove_all_filters( 'wp_register_ability_args' ); - // Clean up registered categories. - $category_registry = WP_Ability_Categories_Registry::get_instance(); - if ( $category_registry->is_registered( 'math' ) ) { - wp_unregister_ability_category( 'math' ); - } + // Clean up registered test ability category. + wp_unregister_ability_category( 'math' ); parent::tear_down(); } @@ -214,6 +202,24 @@ public function test_register_invalid_description_type() { $this->assertNull( $result ); } + /** + * Tests registering an ability with non-existent category. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_ability_nonexistent_category(): void { + $args = array_merge( + self::$test_ability_args, + array( 'category' => 'nonexistent' ) + ); + + $result = $this->registry->register( self::$test_ability_name, $args ); + + $this->assertNull( $result, 'Should return null when category does not exist.' ); + } + /** * Should reject ability registration without an execute callback. * diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php index 77712ae60e719..efd4fe9ec23c8 100644 --- a/tests/phpunit/tests/abilities-api/wpAbility.php +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -18,24 +18,15 @@ class Tests_Abilities_API_WpAbility extends WP_UnitTestCase { public function set_up(): void { parent::set_up(); - // Register category during the hook. - add_action( - 'wp_abilities_api_categories_init', - function () { - if ( ! wp_has_ability_category( 'math' ) ) { - wp_register_ability_category( - 'math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations and calculations.', - ) - ); - } - } - ); - - // Fire the hook to allow category registration. + // Fire the init hook to allow test ability category registration. do_action( 'wp_abilities_api_categories_init' ); + wp_register_ability_category( + 'math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations and calculations.', + ) + ); self::$test_ability_properties = array( 'label' => 'Calculator', @@ -65,11 +56,8 @@ function () { * Tear down after each test. */ public function tear_down(): void { - // Clean up registered categories. - $category_registry = WP_Ability_Categories_Registry::get_instance(); - if ( $category_registry->is_registered( 'math' ) ) { - wp_unregister_ability_category( 'math' ); - } + // Clean up registered test ability category. + wp_unregister_ability_category( 'math' ); parent::tear_down(); } diff --git a/tests/phpunit/tests/abilities-api/wpAbilityCategory.php b/tests/phpunit/tests/abilities-api/wpAbilityCategory.php index c5c75162649c3..e520ec593e33b 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilityCategory.php +++ b/tests/phpunit/tests/abilities-api/wpAbilityCategory.php @@ -105,9 +105,13 @@ private function assertDoingItWrongTriggered( string $the_method, ?string $messa } /** - * Helper to register a category during the hook. + * Helper to register a category with the action hook. + * + * @param string $slug The ability category slug. + * @param array $args The ability category arguments. + * @return WP_Ability_Category|null The registered category or null on failure. */ - private function register_category_during_hook( string $slug, array $args ): ?WP_Ability_Category { + private function register_category_with_action_hook( string $slug, array $args ): ?WP_Ability_Category { $result = null; $callback = static function () use ( $slug, $args, &$result ): void { $result = wp_register_ability_category( $slug, $args ); @@ -126,7 +130,7 @@ private function register_category_during_hook( string $slug, array $args ): ?WP * @ticket 64098 */ public function test_register_valid_category(): void { - $result = $this->register_category_during_hook( + $result = $this->register_category_with_action_hook( 'test-math', array( 'label' => 'Math', @@ -149,7 +153,7 @@ public function test_register_valid_category(): void { */ public function test_register_category_invalid_slug_format(): void { // Uppercase characters not allowed. - $result = $this->register_category_during_hook( + $result = $this->register_category_with_action_hook( 'Test-Math', array( 'label' => 'Math', @@ -169,7 +173,7 @@ public function test_register_category_invalid_slug_format(): void { * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_category_invalid_slug_underscore(): void { - $result = $this->register_category_during_hook( + $result = $this->register_category_with_action_hook( 'test_math', array( 'label' => 'Math', @@ -189,7 +193,7 @@ public function test_register_category_invalid_slug_underscore(): void { * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_category_missing_label(): void { - $result = $this->register_category_during_hook( + $result = $this->register_category_with_action_hook( 'test-math', array( 'description' => 'Mathematical operations.', @@ -208,7 +212,7 @@ public function test_register_category_missing_label(): void { * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_category_missing_description(): void { - $result = $this->register_category_during_hook( + $result = $this->register_category_with_action_hook( 'test-math', array( 'label' => 'Math', @@ -224,7 +228,7 @@ public function test_register_category_missing_description(): void { * * @ticket 64098 * - * @expectedIncorrectUsage WP_Ability_Categories_Registry::register + * @expectedIncorrectUsage wp_register_ability_category */ public function test_register_category_before_init_hook(): void { global $wp_actions; @@ -249,7 +253,7 @@ public function test_register_category_before_init_hook(): void { } $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'wp_abilities_api_categories_init' ); + $this->assertDoingItWrongTriggered( 'wp_register_ability_category', 'wp_abilities_api_categories_init' ); } /** @@ -260,28 +264,23 @@ public function test_register_category_before_init_hook(): void { * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_duplicate_category(): void { - $result = null; - $callback = static function () use ( &$result ): void { - wp_register_ability_category( - 'test-math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations.', - ) - ); + $result = $this->register_category_with_action_hook( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); - $result = wp_register_ability_category( - 'test-math', - array( - 'label' => 'Math 2', - 'description' => 'Another math category.', - ) - ); - }; + $this->assertInstanceOf( WP_Ability_Category::class, $result ); - add_action( 'wp_abilities_api_categories_init', $callback ); - do_action( 'wp_abilities_api_categories_init', WP_Ability_Categories_Registry::get_instance() ); - remove_action( 'wp_abilities_api_categories_init', $callback ); + $result = $this->register_category_with_action_hook( + 'test-math', + array( + 'label' => 'Math 2', + 'description' => 'Another math category.', + ) + ); $this->assertNull( $result ); $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'already registered' ); @@ -293,7 +292,7 @@ public function test_register_duplicate_category(): void { * @ticket 64098 */ public function test_unregister_existing_category(): void { - $this->register_category_during_hook( + $this->register_category_with_action_hook( 'test-math', array( 'label' => 'Math', @@ -327,7 +326,7 @@ public function test_unregister_nonexistent_category(): void { * @ticket 64098 */ public function test_get_existing_category(): void { - $this->register_category_during_hook( + $this->register_category_with_action_hook( 'test-math', array( 'label' => 'Math', @@ -362,7 +361,7 @@ public function test_get_nonexistent_category(): void { */ public function test_has_registered_ability_category(): void { $category_slug = 'test-math'; - $this->register_category_during_hook( + $this->register_category_with_action_hook( $category_slug, array( 'label' => 'Math', @@ -392,7 +391,7 @@ public function test_has_registered_nonexistent_ability_category(): void { * @ticket 64098 */ public function test_get_all_categories(): void { - $this->register_category_during_hook( + $this->register_category_with_action_hook( 'test-math', array( 'label' => 'Math', @@ -400,7 +399,7 @@ public function test_get_all_categories(): void { ) ); - $this->register_category_during_hook( + $this->register_category_with_action_hook( 'test-system', array( 'label' => 'System', @@ -424,7 +423,7 @@ public function test_get_all_categories(): void { public function test_category_is_registered(): void { $this->assertFalse( $this->registry->is_registered( 'test-math' ) ); - $this->register_category_during_hook( + $this->register_category_with_action_hook( 'test-math', array( 'label' => 'Math', @@ -435,81 +434,6 @@ public function test_category_is_registered(): void { $this->assertTrue( $this->registry->is_registered( 'test-math' ) ); } - /** - * Test ability can only be registered with existing category. - * - * @ticket 64098 - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_ability_requires_existing_category(): void { - do_action( 'wp_abilities_api_init' ); - - // Ensure category doesn't exist - test should fail if it does. - $this->assertFalse( - wp_has_ability_category( 'test-nonexistent' ), - 'The test-nonexistent category should not be registered - test isolation may be broken' - ); - - // Try to register ability with non-existent category. - $result = wp_register_ability( - 'test/calculator', - array( - 'label' => 'Calculator', - 'description' => 'Performs calculations.', - 'category' => 'test-nonexistent', - 'execute_callback' => static function () { - return 42; - }, - 'permission_callback' => '__return_true', - ) - ); - - $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'WP_Abilities_Registry::register', 'not registered' ); - } - - /** - * Test ability can be registered with valid category. - * - * @ticket 64098 - */ - public function test_ability_with_valid_category(): void { - $category_callback = static function (): void { - wp_register_ability_category( - 'test-math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations.', - ) - ); - }; - - add_action( 'wp_abilities_api_categories_init', $category_callback ); - do_action( 'wp_abilities_api_categories_init', WP_Ability_Categories_Registry::get_instance() ); - remove_action( 'wp_abilities_api_categories_init', $category_callback ); - do_action( 'wp_abilities_api_init' ); - - $result = wp_register_ability( - 'test/calculator', - array( - 'label' => 'Calculator', - 'description' => 'Performs calculations.', - 'category' => 'test-math', - 'execute_callback' => static function () { - return 42; - }, - 'permission_callback' => '__return_true', - ) - ); - - $this->assertInstanceOf( WP_Ability::class, $result ); - $this->assertSame( 'test-math', $result->get_category() ); - - // Cleanup. - wp_unregister_ability( 'test/calculator' ); - } - /** * Test category registry singleton. * @@ -528,7 +452,7 @@ public function test_category_registry_singleton(): void { * @ticket 64098 */ public function test_category_with_special_characters(): void { - $result = $this->register_category_during_hook( + $result = $this->register_category_with_action_hook( 'test-special', array( 'label' => 'Math & Science ', @@ -566,7 +490,7 @@ public function data_valid_slug_provider(): array { * @param string $slug The category slug to test. */ public function test_category_slug_valid_formats( string $slug ): void { - $result = $this->register_category_during_hook( + $result = $this->register_category_with_action_hook( $slug, array( 'label' => 'Test', @@ -606,7 +530,7 @@ public function data_invalid_slug_provider(): array { * @param string $slug The category slug to test. */ public function test_category_slug_invalid_formats( string $slug ): void { - $result = $this->register_category_during_hook( + $result = $this->register_category_with_action_hook( $slug, array( 'label' => 'Test', @@ -626,7 +550,7 @@ public function test_category_slug_invalid_formats( string $slug ): void { * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_category_constructor_non_string_label(): void { - $result = $this->register_category_during_hook( + $result = $this->register_category_with_action_hook( 'test-invalid', array( 'label' => 123, // Integer instead of string @@ -646,7 +570,7 @@ public function test_category_constructor_non_string_label(): void { * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_category_constructor_empty_label(): void { - $result = $this->register_category_during_hook( + $result = $this->register_category_with_action_hook( 'test-invalid', array( 'label' => '', @@ -666,7 +590,7 @@ public function test_category_constructor_empty_label(): void { * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_category_constructor_non_string_description(): void { - $result = $this->register_category_during_hook( + $result = $this->register_category_with_action_hook( 'test-invalid', array( 'label' => 'Valid Label', @@ -686,7 +610,7 @@ public function test_category_constructor_non_string_description(): void { * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_category_constructor_empty_description(): void { - $result = $this->register_category_during_hook( + $result = $this->register_category_with_action_hook( 'test-invalid', array( 'label' => 'Valid Label', @@ -717,7 +641,7 @@ static function ( $args, $slug ) { 2 ); - $result = $this->register_category_during_hook( + $result = $this->register_category_with_action_hook( 'test-filtered', array( 'label' => 'Original Label', @@ -736,7 +660,7 @@ static function ( $args, $slug ) { * @ticket 64098 */ public function test_category_wakeup_throws_exception(): void { - $category = $this->register_category_during_hook( + $category = $this->register_category_with_action_hook( 'test-serialize', array( 'label' => 'Test', @@ -761,7 +685,7 @@ public function test_register_category_with_valid_meta(): void { 'custom' => array( 'key' => 'value' ), ); - $result = $this->register_category_during_hook( + $result = $this->register_category_with_action_hook( 'test-meta', array( 'label' => 'Math', @@ -781,7 +705,7 @@ public function test_register_category_with_valid_meta(): void { * @ticket 64098 */ public function test_register_category_with_empty_meta(): void { - $result = $this->register_category_during_hook( + $result = $this->register_category_with_action_hook( 'test-empty-meta', array( 'label' => 'Math', @@ -800,7 +724,7 @@ public function test_register_category_with_empty_meta(): void { * @ticket 64098 */ public function test_register_category_without_meta_returns_empty_array(): void { - $result = $this->register_category_during_hook( + $result = $this->register_category_with_action_hook( 'test-no-meta', array( 'label' => 'Math', @@ -820,7 +744,7 @@ public function test_register_category_without_meta_returns_empty_array(): void * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_category_with_invalid_meta(): void { - $result = $this->register_category_during_hook( + $result = $this->register_category_with_action_hook( 'test-invalid-meta', array( 'label' => 'Math', @@ -841,7 +765,7 @@ public function test_register_category_with_invalid_meta(): void { * @expectedIncorrectUsage WP_Ability_Category::__construct */ public function test_register_category_with_unknown_property(): void { - $result = $this->register_category_during_hook( + $result = $this->register_category_with_action_hook( 'test-unknown-property', array( 'label' => 'Math', diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index eb72a6a332f8a..1d4709d9440f0 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -30,24 +30,15 @@ class Test_Abilities_API_WpRegisterAbility extends WP_UnitTestCase { public function set_up(): void { parent::set_up(); - // Register category during the hook. - add_action( - 'wp_abilities_api_categories_init', - function () { - if ( ! wp_has_ability_category( 'math' ) ) { - wp_register_ability_category( - 'math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations and calculations.', - ) - ); - } - } - ); - - // Fire the hook to allow category registration. + // Fire the init hook to allow test ability category registration. do_action( 'wp_abilities_api_categories_init' ); + wp_register_ability_category( + 'math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations and calculations.', + ) + ); self::$test_ability_args = array( 'label' => 'Add numbers', @@ -102,11 +93,8 @@ public function tear_down(): void { wp_unregister_ability( $ability->get_name() ); } - // Clean up registered categories. - $category_registry = WP_Ability_Categories_Registry::get_instance(); - if ( $category_registry->is_registered( 'math' ) ) { - wp_unregister_ability_category( 'math' ); - } + // Clean up registered test ability category. + wp_unregister_ability_category( 'math' ); parent::tear_down(); } @@ -491,7 +479,7 @@ public function test_has_registered_ability() { * * @ticket 64098 */ - public function test_has_registered_nonexistent_abi() { + public function test_has_registered_nonexistent_ability() { do_action( 'wp_abilities_api_init' ); $result = wp_has_ability( 'test/non-existent' ); @@ -528,30 +516,4 @@ public function test_get_all_registered_abilities() { $result = wp_get_abilities(); $this->assertEquals( $expected, $result ); } - - /** - * Tests registering an ability with non-existent category. - * - * @ticket 64098 - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_ability_nonexistent_category(): void { - do_action( 'wp_abilities_api_init' ); - - // Ensure category doesn't exist - test should fail if it does. - $this->assertFalse( - wp_has_ability_category( 'nonexistent' ), - 'The nonexistent category should not be registered - test isolation may be broken' - ); - - $args = array_merge( - self::$test_ability_args, - array( 'category' => 'nonexistent' ) - ); - - $result = wp_register_ability( self::$test_ability_name, $args ); - - $this->assertNull( $result, 'Should return null when category does not exist' ); - } } diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php index 96c7ea675056b..f745205cc5d1e 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php @@ -37,23 +37,16 @@ public static function set_up_before_class(): void { ) ); - // Register test categories during the hook. - add_action( - 'wp_abilities_api_categories_init', - array( __CLASS__, 'register_test_categories' ) - ); + // Fire the init hook to allow test ability categories registration. do_action( 'wp_abilities_api_categories_init' ); - remove_action( - 'wp_abilities_api_categories_init', - array( __CLASS__, 'register_test_categories' ) - ); + self::register_test_categories(); } /** * Tear down after class. */ public static function tear_down_after_class(): void { - // Clean up test categories. + // Clean up registered test ability categories. foreach ( array( 'math', 'system', 'general' ) as $slug ) { wp_unregister_ability_category( $slug ); } diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php index aa09a357fe57d..f38ab269b4468 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php @@ -49,23 +49,16 @@ public static function set_up_before_class(): void { ) ); - // Register test categories during the hook. - add_action( - 'wp_abilities_api_categories_init', - array( __CLASS__, 'register_test_categories' ) - ); + // Fire the init hook to allow test ability categories registration. do_action( 'wp_abilities_api_categories_init' ); - remove_action( - 'wp_abilities_api_categories_init', - array( __CLASS__, 'register_test_categories' ) - ); + self::register_test_categories(); } /** * Tear down after class. */ public static function tear_down_after_class(): void { - // Clean up test categories. + // Clean up registered test ability categories. foreach ( array( 'math', 'system', 'general' ) as $slug ) { wp_unregister_ability_category( $slug ); } From 6cb5d7c1e5c35dfb14543e6191fb51980b25ab5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Zi=C3=B3=C5=82kowski?= Date: Mon, 20 Oct 2025 22:36:14 +0200 Subject: [PATCH 24/31] Apply suggestions from code review Co-authored-by: Aaron Jorbin --- src/wp-includes/abilities-api.php | 2 +- src/wp-includes/abilities-api/class-wp-abilities-registry.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index ce4f9bfe3c54b..3dbf954ae2d08 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -142,7 +142,7 @@ function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_ __METHOD__, sprintf( /* translators: 1: abilities_api_categories_init, 2: ability category slug. */ - __( 'Ability categories must be registered on the %1$s action. The category %2$s was not registered.' ), + __( 'Ability categories must be registered on the %1$s action. The ability category %2$s was not registered.' ), 'wp_abilities_api_categories_init', '' . esc_html( $slug ) . '' ), diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index cb3e58db9519c..5810884ff5e41 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -127,7 +127,7 @@ public function register( string $name, array $args ): ?WP_Ability { __METHOD__, sprintf( /* translators: %1$s: ability category slug, %2$s: ability name */ - __( 'Ability category "%1$s" is not registered. Please register the category before assigning it to ability "%2$s".' ), + __( 'Ability category "%1$s" is not registered. Please register the ability category before assigning it to ability "%2$s".' ), esc_html( $args['category'] ), esc_html( $name ) ), From 7f68dd95c79cdd9b97dbbe4da42153eb1a99925c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Zi=C3=B3=C5=82kowski?= Date: Mon, 20 Oct 2025 22:37:27 +0200 Subject: [PATCH 25/31] Update src/wp-includes/abilities-api/class-wp-ability.php Co-authored-by: Aaron Jorbin --- src/wp-includes/abilities-api/class-wp-ability.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index dfaf20bd7db83..5e61c23858db9 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -200,7 +200,7 @@ public function __construct( string $name, array $args ) { * Receives optional mixed input and returns mixed result or WP_Error. * @type callable $permission_callback A callback function to check permissions before execution. * Receives optional mixed input and returns bool or WP_Error. - * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * @type array $input_schema Optional. JSON Schema definition for the ability's input. Required if ability accepts an input. * @type array $output_schema Optional. JSON Schema definition for the ability's output. * @type array $meta { * Optional. Additional metadata for the ability. From b36ea85d6c082d812feac93ca3e9410d7fc2f58e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Zi=C3=B3=C5=82kowski?= Date: Tue, 21 Oct 2025 08:53:07 +0200 Subject: [PATCH 26/31] Apply suggestions from code review Co-authored-by: Timothy Jacobs --- ...lass-wp-rest-abilities-list-controller.php | 62 +++++++++++-------- ...class-wp-rest-abilities-run-controller.php | 11 ++-- .../wpRestAbilitiesListController.php | 13 +--- 3 files changed, 43 insertions(+), 43 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php index c136c3080221a..dac75f557befe 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php @@ -18,14 +18,6 @@ */ class WP_REST_Abilities_List_Controller extends WP_REST_Controller { - /** - * Default number of items per page for pagination. - * - * @since 6.9.0 - * @var int - */ - public const DEFAULT_PER_PAGE = 50; - /** * REST API namespace. * @@ -57,7 +49,7 @@ public function register_routes(): void { array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), - 'permission_callback' => array( $this, 'get_permissions_check' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), @@ -78,7 +70,7 @@ public function register_routes(): void { array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item' ), - 'permission_callback' => array( $this, 'get_permissions_check' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) @@ -90,7 +82,7 @@ public function register_routes(): void { * * @since 6.9.0 * - * @param WP_REST_Request> $request Full details about the request. + * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response Response object on success. */ public function get_items( $request ) { @@ -102,7 +94,7 @@ static function ( $ability ) { ); // Filter by ability category if specified. - $category = $request->get_param( 'category' ); + $category = $request['category']; if ( ! empty( $category ) ) { $abilities = array_filter( $abilities, @@ -114,10 +106,8 @@ static function ( $ability ) use ( $category ) { $abilities = array_values( $abilities ); } - // Handle pagination with explicit defaults. - $params = $request->get_params(); - $page = $params['page'] ?? 1; - $per_page = $params['per_page'] ?? self::DEFAULT_PER_PAGE; + $page = $request['page']; + $per_page = $request['per_page']; $offset = ( $page - 1 ) * $per_page; $total_abilities = count( $abilities ); @@ -163,11 +153,11 @@ static function ( $ability ) use ( $category ) { * * @since 6.9.0 * - * @param WP_REST_Request> $request Full details about the request. + * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function get_item( $request ) { - $ability = wp_get_ability( $request->get_param( 'name' ) ); + $ability = wp_get_ability( $request['name'] ); if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) { return new WP_Error( 'rest_ability_not_found', @@ -181,14 +171,26 @@ public function get_item( $request ) { } /** - * Checks if a given request has access to read abilities. + * Checks if a given request has access to read ability items. * * @since 6.9.0 * - * @param WP_REST_Request> $request Full details about the request. + * @param WP_REST_Request $request Full details about the request. * @return bool True if the request has read access. */ - public function get_permissions_check( $request ) { + public function get_items_permissions_check( $request ) { + return current_user_can( 'read' ); + } + + /** + * Checks if a given request has access to read an ability item. + * + * @since 6.9.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return bool True if the request has read access. + */ + public function get_item_permissions_check( $request ) { return current_user_can( 'read' ); } @@ -197,8 +199,8 @@ public function get_permissions_check( $request ) { * * @since 6.9.0 * - * @param WP_Ability $ability The ability object. - * @param WP_REST_Request> $request Request object. + * @param WP_Ability $ability The ability object. + * @param WP_REST_Request $request Request object. * @return WP_REST_Response Response object. */ public function prepare_item_for_response( $ability, $request ) { @@ -212,7 +214,7 @@ public function prepare_item_for_response( $ability, $request ) { 'meta' => $ability->get_meta(), ); - $context = $request->get_param( 'context' ) ?? 'view'; + $context = $request['context'] ?? 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); @@ -229,7 +231,7 @@ public function prepare_item_for_response( $ability, $request ) { ), ); - $links['run'] = array( + $links['wp:action-run'] = array( 'href' => rest_url( sprintf( '%s/%s/%s/run', $this->namespace, $this->rest_base, $ability->get_name() ) ), ); @@ -291,11 +293,17 @@ public function get_item_schema(): array { 'meta' => array( 'description' => __( 'Meta information about the ability.' ), 'type' => 'object', + 'properties' => array( + 'annotations' => array( + 'description' => __( 'Annotations for the ability.' ), + 'type' => array( 'boolean', 'null' ), + 'default' => null, + ), + ), 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), - 'required' => array( 'name', 'label', 'meta', 'description', 'category', 'input_schema', 'output_schema' ), ); return $this->add_additional_fields_schema( $schema ); @@ -320,7 +328,7 @@ public function get_collection_params(): array { 'per_page' => array( 'description' => __( 'Maximum number of items to be returned in result set.' ), 'type' => 'integer', - 'default' => self::DEFAULT_PER_PAGE, + 'default' => 50, 'minimum' => 1, 'maximum' => 100, ), diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php index 93aa6c2fd535b..01088fa7dcd45 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php @@ -76,11 +76,11 @@ public function register_routes(): void { * * @since 6.9.0 * - * @param WP_REST_Request> $request Full details about the request. + * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function execute_ability( $request ) { - $ability = wp_get_ability( $request->get_param( 'name' ) ); + $ability = wp_get_ability( $request['name'] ); if ( ! $ability ) { return new WP_Error( 'rest_ability_not_found', @@ -140,11 +140,11 @@ public function validate_request_method( string $request_method, array $annotati * * @since 6.9.0 * - * @param WP_REST_Request> $request Full details about the request. + * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has execution permission, WP_Error object otherwise. */ public function check_ability_permissions( $request ) { - $ability = wp_get_ability( $request->get_param( 'name' ) ); + $ability = wp_get_ability( $request['name'] ); if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) { return new WP_Error( 'rest_ability_not_found', @@ -185,7 +185,7 @@ public function check_ability_permissions( $request ) { * * @since 6.9.0 * - * @param WP_REST_Request> $request The request object. + * @param WP_REST_Request $request The request object. * @return mixed|null The input parameters. */ private function get_input_from_request( $request ) { @@ -232,6 +232,7 @@ public function get_run_schema(): array { 'properties' => array( 'result' => array( 'description' => __( 'The result of the ability execution.' ), + 'type' => array( 'integer', 'number', 'boolean', 'string', 'array', 'object', 'null' ), 'context' => array( 'view', 'edit' ), 'readonly' => true, ), diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php index f745205cc5d1e..94c5b6731d3ce 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php @@ -516,7 +516,7 @@ public function test_ability_response_links(): void { $links = $response->get_links(); $this->assertArrayHasKey( 'self', $links ); $this->assertArrayHasKey( 'collection', $links ); - $this->assertArrayHasKey( 'run', $links ); + $this->assertArrayHasKey( 'wp:action-run', $links ); // Verify link URLs $self_link = $links['self'][0]['href']; @@ -525,7 +525,7 @@ public function test_ability_response_links(): void { $collection_link = $links['collection'][0]['href']; $this->assertStringContainsString( '/wp/v2/abilities', $collection_link ); - $run_link = $links['run'][0]['href']; + $run_link = $links['wp:action-run'][0]['href']; $this->assertStringContainsString( '/wp/v2/abilities/test/calculator/run', $run_link ); } @@ -580,15 +580,6 @@ public function test_get_schema(): void { $this->assertArrayHasKey( 'output_schema', $properties ); $this->assertArrayHasKey( 'meta', $properties ); $this->assertArrayHasKey( 'category', $properties ); - - // Test category property details - $category_property = $properties['category']; - $this->assertEquals( 'string', $category_property['type'] ); - $this->assertTrue( $category_property['readonly'] ); - - // Check that category is in required fields - $this->assertArrayHasKey( 'required', $schema ); - $this->assertContains( 'category', $schema['required'] ); } /** From e12409446f5729fd962163b26e71b1c26529e694 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 21 Oct 2025 09:53:37 +0200 Subject: [PATCH 27/31] Rename REST routes and controllers --- src/wp-includes/rest-api.php | 4 +- ...-wp-rest-abilities-v1-list-controller.php} | 4 +- ...s-wp-rest-abilities-v1-run-controller.php} | 4 +- src/wp-settings.php | 4 +- .../tests/rest-api/rest-schema-setup.php | 6 +- ...hp => wpRestAbilitiesV1ListController.php} | 52 ++++++++-------- ...php => wpRestAbilitiesV1RunController.php} | 62 +++++++++---------- tests/qunit/fixtures/wp-api-generated.js | 8 +-- 8 files changed, 72 insertions(+), 72 deletions(-) rename src/wp-includes/rest-api/endpoints/{class-wp-rest-abilities-list-controller.php => class-wp-rest-abilities-v1-list-controller.php} (98%) rename src/wp-includes/rest-api/endpoints/{class-wp-rest-abilities-run-controller.php => class-wp-rest-abilities-v1-run-controller.php} (98%) rename tests/phpunit/tests/rest-api/{wpRestAbilitiesListController.php => wpRestAbilitiesV1ListController.php} (90%) rename tests/phpunit/tests/rest-api/{wpRestAbilitiesRunController.php => wpRestAbilitiesV1RunController.php} (91%) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 3241d38fd095f..5fbdffea3d050 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -418,9 +418,9 @@ function create_initial_rest_routes() { $font_collections_controller->register_routes(); // Abilities. - $abilities_run_controller = new WP_REST_Abilities_Run_Controller(); + $abilities_run_controller = new WP_REST_Abilities_V1_Run_Controller(); $abilities_run_controller->register_routes(); - $abilities_list_controller = new WP_REST_Abilities_List_Controller(); + $abilities_list_controller = new WP_REST_Abilities_V1_List_Controller(); $abilities_list_controller->register_routes(); } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php similarity index 98% rename from src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php rename to src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index dac75f557befe..25084eaf680b5 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -16,7 +16,7 @@ * * @see WP_REST_Controller */ -class WP_REST_Abilities_List_Controller extends WP_REST_Controller { +class WP_REST_Abilities_V1_List_Controller extends WP_REST_Controller { /** * REST API namespace. @@ -24,7 +24,7 @@ class WP_REST_Abilities_List_Controller extends WP_REST_Controller { * @since 6.9.0 * @var string */ - protected $namespace = 'wp/v2'; + protected $namespace = 'wp-abilities/v1'; /** * REST API base route. diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php similarity index 98% rename from src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php rename to src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php index 01088fa7dcd45..70729164b456f 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php @@ -16,7 +16,7 @@ * * @see WP_REST_Controller */ -class WP_REST_Abilities_Run_Controller extends WP_REST_Controller { +class WP_REST_Abilities_V1_Run_Controller extends WP_REST_Controller { /** * REST API namespace. @@ -24,7 +24,7 @@ class WP_REST_Abilities_Run_Controller extends WP_REST_Controller { * @since 6.9.0 * @var string */ - protected $namespace = 'wp/v2'; + protected $namespace = 'wp-abilities/v1'; /** * REST API base route. diff --git a/src/wp-settings.php b/src/wp-settings.php index a0e01db14f643..2968677589b24 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -335,8 +335,8 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-families-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-faces-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-collections-controller.php'; -require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-list-controller.php'; -require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-run-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-meta-fields.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-comment-meta-fields.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-post-meta-fields.php'; diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 9d54df41a8c47..2640d420b1361 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -195,9 +195,9 @@ public function test_expected_routes_in_schema() { '/wp/v2/font-families/(?P[\d]+)/font-faces', '/wp/v2/font-families/(?P[\d]+)/font-faces/(?P[\d]+)', '/wp/v2/font-families/(?P[\d]+)', - '/wp/v2/abilities/(?P[a-zA-Z0-9\-\/]+?)/run', - '/wp/v2/abilities/(?P[a-zA-Z0-9\-\/]+)', - '/wp/v2/abilities', + '/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\-\/]+?)/run', + '/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\-\/]+)', + '/wp-abilities/v1/abilities', ); $this->assertSameSets( $expected_routes, $routes ); diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php similarity index 90% rename from tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php rename to tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index 94c5b6731d3ce..a6e5d92f270b7 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -3,12 +3,12 @@ /** * Tests for the REST list controller for abilities endpoint. * - * @covers WP_REST_Abilities_List_Controller + * @covers WP_REST_Abilities_V1_List_Controller * * @group abilities-api * @group rest-api */ -class Tests_REST_API_WpRestAbilitiesListController extends WP_UnitTestCase { +class Tests_REST_API_WpRestAbilitiesV1ListController extends WP_UnitTestCase { /** * REST Server instance. @@ -259,7 +259,7 @@ private function register_test_abilities(): void { * @ticket 64098 */ public function test_get_items(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); $response = $this->server->dispatch( $request ); $this->assertEquals( 200, $response->get_status() ); @@ -282,7 +282,7 @@ public function test_get_items(): void { * @ticket 64098 */ public function test_get_item(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/calculator' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' ); $response = $this->server->dispatch( $request ); $this->assertEquals( 200, $response->get_status() ); @@ -305,7 +305,7 @@ public function test_get_item(): void { * @ticket 64098 */ public function test_get_item_with_selected_fields(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/calculator' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' ); $request->set_param( '_fields', 'name,label' ); $response = $this->server->dispatch( $request ); add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); @@ -326,7 +326,7 @@ public function test_get_item_with_selected_fields(): void { * @ticket 64098 */ public function test_get_item_with_embed_context(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/calculator' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' ); $request->set_param( 'context', 'embed' ); $response = $this->server->dispatch( $request ); add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); @@ -350,7 +350,7 @@ public function test_get_item_with_embed_context(): void { * @expectedIncorrectUsage WP_Abilities_Registry::get_registered */ public function test_get_item_not_found(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/non/existent' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/non/existent' ); $response = $this->server->dispatch( $request ); $this->assertEquals( 404, $response->get_status() ); @@ -365,7 +365,7 @@ public function test_get_item_not_found(): void { * @ticket 64098 */ public function test_get_item_not_show_in_rest(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/not-show-in-rest' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/not-show-in-rest' ); $response = $this->server->dispatch( $request ); $this->assertEquals( 404, $response->get_status() ); @@ -383,7 +383,7 @@ public function test_get_items_permission_denied(): void { // Test with non-logged-in user wp_set_current_user( 0 ); - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); $response = $this->server->dispatch( $request ); $this->assertEquals( 401, $response->get_status() ); @@ -395,7 +395,7 @@ public function test_get_items_permission_denied(): void { * @ticket 64098 */ public function test_pagination_headers(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); $request->set_param( 'per_page', 10 ); $response = $this->server->dispatch( $request ); @@ -416,7 +416,7 @@ public function test_pagination_headers(): void { * @ticket 64098 */ public function test_head_request(): void { - $request = new WP_REST_Request( 'HEAD', '/wp/v2/abilities' ); + $request = new WP_REST_Request( 'HEAD', '/wp-abilities/v1/abilities' ); $response = $this->server->dispatch( $request ); // Verify empty response body @@ -436,7 +436,7 @@ public function test_head_request(): void { */ public function test_pagination_links(): void { // Test first page (should have 'next' link header but no 'prev') - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); $request->set_param( 'per_page', 10 ); $request->set_param( 'page', 1 ); $response = $this->server->dispatch( $request ); @@ -478,7 +478,7 @@ public function test_pagination_links(): void { */ public function test_collection_params(): void { // Test per_page parameter - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); $request->set_param( 'per_page', 5 ); $response = $this->server->dispatch( $request ); @@ -494,7 +494,7 @@ public function test_collection_params(): void { $this->assertCount( 5, $data ); // Verify we got different abilities on page 2 - $page1_request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $page1_request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); $page1_request->set_param( 'per_page', 5 ); $page1_request->set_param( 'page', 1 ); $page1_response = $this->server->dispatch( $page1_request ); @@ -510,7 +510,7 @@ public function test_collection_params(): void { * @ticket 64098 */ public function test_ability_response_links(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/calculator' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' ); $response = $this->server->dispatch( $request ); $links = $response->get_links(); @@ -520,13 +520,13 @@ public function test_ability_response_links(): void { // Verify link URLs $self_link = $links['self'][0]['href']; - $this->assertStringContainsString( '/wp/v2/abilities/test/calculator', $self_link ); + $this->assertStringContainsString( '/wp-abilities/v1/abilities/test/calculator', $self_link ); $collection_link = $links['collection'][0]['href']; - $this->assertStringContainsString( '/wp/v2/abilities', $collection_link ); + $this->assertStringContainsString( '/wp-abilities/v1/abilities', $collection_link ); $run_link = $links['wp:action-run'][0]['href']; - $this->assertStringContainsString( '/wp/v2/abilities/test/calculator/run', $run_link ); + $this->assertStringContainsString( '/wp-abilities/v1/abilities/test/calculator/run', $run_link ); } /** @@ -535,7 +535,7 @@ public function test_ability_response_links(): void { * @ticket 64098 */ public function test_context_parameter(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/calculator' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' ); $request->set_param( 'context', 'view' ); $response = $this->server->dispatch( $request ); @@ -556,7 +556,7 @@ public function test_context_parameter(): void { * @ticket 64098 */ public function test_get_schema(): void { - $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/abilities' ); + $request = new WP_REST_Request( 'OPTIONS', '/wp-abilities/v1/abilities' ); $response = $this->server->dispatch( $request ); $data = $response->get_data(); @@ -606,7 +606,7 @@ public function test_ability_name_with_valid_special_characters(): void { ); // Test valid special characters (hyphen, forward slash) - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test-hyphen/ability' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test-hyphen/ability' ); $response = $this->server->dispatch( $request ); wp_unregister_ability( 'test-hyphen/ability' ); @@ -642,7 +642,7 @@ public function data_invalid_ability_names_provider(): array { * @param string $name Invalid ability name to test. */ public function test_ability_name_with_invalid_special_characters( string $name ): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/' . $name ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/' . $name ); $response = $this->server->dispatch( $request ); // Should return 404 as the regex pattern won't match $this->assertEquals( 404, $response->get_status() ); @@ -659,7 +659,7 @@ public function test_extremely_long_ability_names(): void { // Create a very long but valid ability name $long_name = 'test/' . str_repeat( 'a', 1000 ); - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/' . $long_name ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/' . $long_name ); $response = $this->server->dispatch( $request ); // Should return 404 as ability doesn't exist @@ -693,7 +693,7 @@ public function data_invalid_pagination_params_provider(): array { * @param array $params Invalid pagination parameters. */ public function test_invalid_pagination_parameters( array $params ): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); $request->set_query_params( $params ); $response = $this->server->dispatch( $request ); @@ -716,7 +716,7 @@ public function test_invalid_pagination_parameters( array $params ): void { * @ticket 64098 */ public function test_filter_by_category(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); $request->set_param( 'category', 'math' ); $response = $this->server->dispatch( $request ); @@ -748,7 +748,7 @@ public function test_filter_by_nonexistent_category(): void { 'The nonexistent category should not be registered - test isolation may be broken' ); - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); $request->set_param( 'category', 'nonexistent' ); $response = $this->server->dispatch( $request ); diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php similarity index 91% rename from tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php rename to tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php index f38ab269b4468..f610bf6a026e3 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php @@ -3,12 +3,12 @@ /** * Tests for the REST run controller for abilities endpoint. * - * @covers WP_REST_Abilities_Run_Controller + * @covers WP_REST_Abilities_V1_Run_Controller * * @group abilities-api * @group rest-api */ -class Tests_REST_API_WpRestAbilitiesRunController extends WP_UnitTestCase { +class Tests_REST_API_WpRestAbilitiesV1RunController extends WP_UnitTestCase { /** * REST Server instance. @@ -395,7 +395,7 @@ private function register_test_abilities(): void { * @ticket 64098 */ public function test_execute_regular_ability_post(): void { - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/calculator/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( wp_json_encode( @@ -420,7 +420,7 @@ public function test_execute_regular_ability_post(): void { * @ticket 64098 */ public function test_execute_readonly_ability_get(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/user-info/run' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/user-info/run' ); $request->set_query_params( array( 'input' => array( @@ -442,7 +442,7 @@ public function test_execute_readonly_ability_get(): void { * @ticket 64098 */ public function test_execute_destructive_ability_delete(): void { - $request = new WP_REST_Request( 'DELETE', '/wp/v2/abilities/test/delete-user/run' ); + $request = new WP_REST_Request( 'DELETE', '/wp-abilities/v1/abilities/test/delete-user/run' ); $request->set_query_params( array( 'input' => array( @@ -479,7 +479,7 @@ public function test_regular_ability_requires_post(): void { ) ); - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/open-tool/run' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/open-tool/run' ); $response = $this->server->dispatch( $request ); $this->assertSame( 405, $response->get_status() ); @@ -495,7 +495,7 @@ public function test_regular_ability_requires_post(): void { */ public function test_readonly_ability_requires_get(): void { // Try POST on a read-only ability (should fail). - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/user-info/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/user-info/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( wp_json_encode( array( 'user_id' => 1 ) ) ); @@ -514,7 +514,7 @@ public function test_readonly_ability_requires_get(): void { */ public function test_destructive_ability_requires_delete(): void { // Try POST on a destructive ability (should fail). - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/delete-user/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/delete-user/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( wp_json_encode( array( 'user_id' => 1 ) ) ); @@ -534,7 +534,7 @@ public function test_destructive_ability_requires_delete(): void { * @ticket 64098 */ public function test_output_validation(): void { - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/invalid-output/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/invalid-output/run' ); $request->set_header( 'Content-Type', 'application/json' ); $response = $this->server->dispatch( $request ); @@ -556,7 +556,7 @@ public function test_output_validation(): void { public function test_execution_permission_denied(): void { wp_set_current_user( self::$no_permission_user_id ); - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/calculator/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( wp_json_encode( @@ -583,7 +583,7 @@ public function test_execution_permission_denied(): void { * @ticket 64098 */ public function test_contextual_permission_check(): void { - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/restricted/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/restricted/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( wp_json_encode( @@ -621,7 +621,7 @@ public function test_contextual_permission_check(): void { * @ticket 64098 */ public function test_do_not_show_in_rest(): void { - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/not-show-in-rest/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/not-show-in-rest/run' ); $request->set_header( 'Content-Type', 'application/json' ); $response = $this->server->dispatch( $request ); @@ -638,7 +638,7 @@ public function test_do_not_show_in_rest(): void { * @ticket 64098 */ public function test_null_return_handling(): void { - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/null-return/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/null-return/run' ); $request->set_header( 'Content-Type', 'application/json' ); $response = $this->server->dispatch( $request ); @@ -654,7 +654,7 @@ public function test_null_return_handling(): void { * @ticket 64098 */ public function test_wp_error_return_handling(): void { - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/error-return/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/error-return/run' ); $request->set_header( 'Content-Type', 'application/json' ); $response = $this->server->dispatch( $request ); @@ -673,7 +673,7 @@ public function test_wp_error_return_handling(): void { * @expectedIncorrectUsage WP_Abilities_Registry::get_registered */ public function test_execute_non_existent_ability(): void { - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/non/existent/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/non/existent/run' ); $request->set_header( 'Content-Type', 'application/json' ); $response = $this->server->dispatch( $request ); @@ -689,7 +689,7 @@ public function test_execute_non_existent_ability(): void { * @ticket 64098 */ public function test_run_endpoint_schema(): void { - $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/abilities/test/calculator/run' ); + $request = new WP_REST_Request( 'OPTIONS', '/wp-abilities/v1/abilities/test/calculator/run' ); $response = $this->server->dispatch( $request ); $data = $response->get_data(); @@ -708,7 +708,7 @@ public function test_run_endpoint_schema(): void { * @ticket 64098 */ public function test_invalid_json_in_post_body(): void { - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/calculator/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' ); $request->set_header( 'Content-Type', 'application/json' ); // Set raw body with invalid JSON $request->set_body( '{"input": {invalid json}' ); @@ -725,7 +725,7 @@ public function test_invalid_json_in_post_body(): void { * @ticket 64098 */ public function test_get_request_with_nested_input_array(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/query-params/run' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/query-params/run' ); $request->set_query_params( array( 'input' => array( @@ -753,7 +753,7 @@ public function test_get_request_with_nested_input_array(): void { * @ticket 64098 */ public function test_get_request_with_non_array_input(): void { - $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/query-params/run' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/query-params/run' ); $request->set_query_params( array( 'input' => 'not-an-array', // String instead of array @@ -771,7 +771,7 @@ public function test_get_request_with_non_array_input(): void { * @ticket 64098 */ public function test_post_request_with_non_array_input(): void { - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/calculator/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( wp_json_encode( @@ -820,7 +820,7 @@ public function test_output_validation_failure_returns_error(): void { ) ); - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/strict-output/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/strict-output/run' ); $request->set_header( 'Content-Type', 'application/json' ); $response = $this->server->dispatch( $request ); @@ -867,7 +867,7 @@ public function test_input_validation_failure_returns_error(): void { ) ); - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/strict-input/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/strict-input/run' ); $request->set_header( 'Content-Type', 'application/json' ); // Missing required field $request->set_body( wp_json_encode( array( 'input' => array( 'other_field' => 'value' ) ) ) ); @@ -908,12 +908,12 @@ public function test_ability_without_annotations_defaults_to_post_method(): void ); // Should require POST (default behavior). - $get_request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/no-annotations/run' ); + $get_request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/no-annotations/run' ); $get_response = $this->server->dispatch( $get_request ); $this->assertEquals( 405, $get_response->get_status() ); // Should work with POST. - $post_request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/no-annotations/run' ); + $post_request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/no-annotations/run' ); $post_request->set_header( 'Content-Type', 'application/json' ); $post_response = $this->server->dispatch( $post_request ); @@ -963,13 +963,13 @@ public function test_empty_input_handling(): void { ); // Tests GET with no input parameter. - $get_request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/read-only-empty/run' ); + $get_request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/read-only-empty/run' ); $get_response = $this->server->dispatch( $get_request ); $this->assertEquals( 200, $get_response->get_status() ); $this->assertTrue( $get_response->get_data()['input_was_empty'] ); // Tests POST with no body. - $post_request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/regular-empty/run' ); + $post_request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/regular-empty/run' ); $post_request->set_header( 'Content-Type', 'application/json' ); $post_request->set_body( '{}' ); // Empty JSON object @@ -1006,7 +1006,7 @@ public function data_malformed_json_provider(): array { * @param string $json Malformed JSON to test. */ public function test_malformed_json_post_body( string $json ): void { - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/calculator/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( $json ); @@ -1054,7 +1054,7 @@ public function test_php_type_strings_in_input(): void { 'negative' => -1, ); - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/echo/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/echo/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( wp_json_encode( array( 'input' => $inputs ) ) ); @@ -1101,7 +1101,7 @@ public function test_mixed_encoding_in_input(): void { 'quotes' => "It's \"quoted\"", ); - $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/echo-encoding/run' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/echo-encoding/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( wp_json_encode( array( 'input' => $input ) ) ); @@ -1157,7 +1157,7 @@ public function test_invalid_http_methods( string $method ): void { ) ); - $request = new WP_REST_Request( $method, '/wp/v2/abilities/test/method-test/run' ); + $request = new WP_REST_Request( $method, '/wp-abilities/v1/abilities/test/method-test/run' ); $response = $this->server->dispatch( $request ); // Regular abilities should only accept POST, so these should return 405. @@ -1173,7 +1173,7 @@ public function test_invalid_http_methods( string $method ): void { * @ticket 64098 */ public function test_options_method_handling(): void { - $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/abilities/test/calculator/run' ); + $request = new WP_REST_Request( 'OPTIONS', '/wp-abilities/v1/abilities/test/calculator/run' ); $response = $this->server->dispatch( $request ); // OPTIONS requests return 200 with allowed methods $this->assertEquals( 200, $response->get_status() ); diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 5f24ee391b695..7e07df07a0dcd 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12392,7 +12392,7 @@ mockedApiResponse.Schema = { } ] }, - "/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run": { + "/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run": { "namespace": "wp/v2", "methods": [ "GET", @@ -12435,7 +12435,7 @@ mockedApiResponse.Schema = { } ] }, - "/wp/v2/abilities": { + "/wp-abilities/v1/abilities": { "namespace": "wp/v2", "methods": [ "GET" @@ -12483,12 +12483,12 @@ mockedApiResponse.Schema = { "_links": { "self": [ { - "href": "http://example.org/index.php?rest_route=/wp/v2/abilities" + "href": "http://example.org/index.php?rest_route=/wp-abilities/v1/abilities" } ] } }, - "/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+)": { + "/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\\-\\/]+)": { "namespace": "wp/v2", "methods": [ "GET" From 1dec6a7c6b1f3138d96358576f79b52aee5ddc78 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 21 Oct 2025 10:51:02 +0200 Subject: [PATCH 28/31] Enforce `init` hook before using ability related registries --- src/wp-includes/abilities-api.php | 70 +++++++-- .../class-wp-abilities-registry.php | 15 +- .../class-wp-ability-categories-registry.php | 15 +- .../abilities-api/wpAbilitiesRegistry.php | 20 --- .../phpunit/tests/abilities-api/wpAbility.php | 20 +++ .../tests/abilities-api/wpRegisterAbility.php | 142 +++++++++++++++++- 6 files changed, 243 insertions(+), 39 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index 3dbf954ae2d08..9fb10c205abbf 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -60,7 +60,12 @@ function wp_register_ability( string $name, array $args ): ?WP_Ability { return null; } - return WP_Abilities_Registry::get_instance()->register( $name, $args ); + $registry = WP_Abilities_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->register( $name, $args ); } /** @@ -74,7 +79,12 @@ function wp_register_ability( string $name, array $args ): ?WP_Ability { * @return WP_Ability|null The unregistered ability instance on success, null on failure. */ function wp_unregister_ability( string $name ): ?WP_Ability { - return WP_Abilities_Registry::get_instance()->unregister( $name ); + $registry = WP_Abilities_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->unregister( $name ); } /** @@ -88,7 +98,12 @@ function wp_unregister_ability( string $name ): ?WP_Ability { * @return bool True if the ability is registered, false otherwise. */ function wp_has_ability( string $name ): bool { - return WP_Abilities_Registry::get_instance()->is_registered( $name ); + $registry = WP_Abilities_Registry::get_instance(); + if ( null === $registry ) { + return false; + } + + return $registry->is_registered( $name ); } /** @@ -102,7 +117,12 @@ function wp_has_ability( string $name ): bool { * @return WP_Ability|null The registered ability instance, or null if it is not registered. */ function wp_get_ability( string $name ): ?WP_Ability { - return WP_Abilities_Registry::get_instance()->get_registered( $name ); + $registry = WP_Abilities_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->get_registered( $name ); } /** @@ -115,7 +135,12 @@ function wp_get_ability( string $name ): ?WP_Ability { * @return WP_Ability[] The array of registered abilities. */ function wp_get_abilities(): array { - return WP_Abilities_Registry::get_instance()->get_all_registered(); + $registry = WP_Abilities_Registry::get_instance(); + if ( null === $registry ) { + return array(); + } + + return $registry->get_all_registered(); } /** @@ -151,7 +176,12 @@ function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_ return null; } - return WP_Ability_Categories_Registry::get_instance()->register( $slug, $args ); + $registry = WP_Ability_Categories_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->register( $slug, $args ); } /** @@ -165,7 +195,12 @@ function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_ * @return WP_Ability_Category|null The unregistered ability category instance on success, null on failure. */ function wp_unregister_ability_category( string $slug ): ?WP_Ability_Category { - return WP_Ability_Categories_Registry::get_instance()->unregister( $slug ); + $registry = WP_Ability_Categories_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->unregister( $slug ); } /** @@ -179,7 +214,12 @@ function wp_unregister_ability_category( string $slug ): ?WP_Ability_Category { * @return bool True if the ability category is registered, false otherwise. */ function wp_has_ability_category( string $slug ): bool { - return WP_Ability_Categories_Registry::get_instance()->is_registered( $slug ); + $registry = WP_Ability_Categories_Registry::get_instance(); + if ( null === $registry ) { + return false; + } + + return $registry->is_registered( $slug ); } /** @@ -193,7 +233,12 @@ function wp_has_ability_category( string $slug ): bool { * @return WP_Ability_Category|null The registered ability category instance, or null if it is not registered. */ function wp_get_ability_category( string $slug ): ?WP_Ability_Category { - return WP_Ability_Categories_Registry::get_instance()->get_registered( $slug ); + $registry = WP_Ability_Categories_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->get_registered( $slug ); } /** @@ -206,5 +251,10 @@ function wp_get_ability_category( string $slug ): ?WP_Ability_Category { * @return WP_Ability_Category[] The array of registered ability categories. */ function wp_get_ability_categories(): array { - return WP_Ability_Categories_Registry::get_instance()->get_all_registered(); + $registry = WP_Ability_Categories_Registry::get_instance(); + if ( null === $registry ) { + return array(); + } + + return $registry->get_all_registered(); } diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index 5810884ff5e41..e3086d71ba174 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -259,9 +259,20 @@ public function get_registered( string $name ): ?WP_Ability { * * @since 6.9.0 * - * @return WP_Abilities_Registry The main registry instance. + * @return WP_Abilities_Registry|null The main registry instance, or null when `init` action has not fired. */ - public static function get_instance(): self { + public static function get_instance(): ?self { + if ( ! did_action( 'init' ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + __( 'Ability API should not be initialized before the init action has fired' ) + ), + '6.9.0' + ); + return null; + } + if ( null === self::$instance ) { self::$instance = new self(); diff --git a/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php b/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php index 353d93d3b9353..6a372c6d8cc82 100644 --- a/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php +++ b/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php @@ -198,9 +198,20 @@ public function get_registered( string $slug ): ?WP_Ability_Category { * * @since 6.9.0 * - * @return WP_Ability_Categories_Registry The main registry instance. + * @return WP_Ability_Categories_Registry|null The main registry instance, or null when `init` action has not fired. */ - public static function get_instance(): self { + public static function get_instance(): ?self { + if ( ! did_action( 'init' ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + __( 'Ability API should not be initialized before the init action has fired' ) + ), + '6.9.0' + ); + return null; + } + if ( null === self::$instance ) { self::$instance = new self(); diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php index fdbf64a870f15..95fa29a20d9f3 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -527,26 +527,6 @@ public function test_get_all_registered() { $this->assertSame( $ability_three_name, $result[ $ability_three_name ]->get_name() ); } - /** - * Direct instantiation of WP_Ability with invalid properties should throw an exception. - * - * @ticket 64098 - * - * @covers WP_Ability::__construct - * @covers WP_Ability::prepare_properties - */ - public function test_wp_ability_invalid_properties_throws_exception() { - $this->expectException( InvalidArgumentException::class ); - new WP_Ability( - 'test/invalid', - array( - 'label' => '', - 'description' => '', - 'execute_callback' => null, - ) - ); - } - /** * Test register_ability_args filter modifies the args before ability instantiation. * diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php index efd4fe9ec23c8..910ae7dad06fe 100644 --- a/tests/phpunit/tests/abilities-api/wpAbility.php +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -62,6 +62,26 @@ public function tear_down(): void { parent::tear_down(); } + /** + * Direct instantiation of WP_Ability with invalid properties should throw an exception. + * + * @ticket 64098 + * + * @covers WP_Ability::__construct + * @covers WP_Ability::prepare_properties + */ + public function test_wp_ability_invalid_properties_throws_exception() { + $this->expectException( InvalidArgumentException::class ); + new WP_Ability( + 'test/invalid', + array( + 'label' => '', + 'description' => '', + 'execute_callback' => null, + ) + ); + } + /* * Tests that getting non-existing metadata item returns default value. * diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index 1d4709d9440f0..71400b973f099 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -115,24 +115,24 @@ public function test_register_ability_invalid_name(): void { } /** - * Tests registering an ability when `abilities_api_init` hook is not fired. + * Tests registering an ability when `abilities_api_init` action has not fired. * * @ticket 64098 * * @expectedIncorrectUsage wp_register_ability */ - public function test_register_ability_no_abilities_api_init_hook(): void { + public function test_register_ability_no_abilities_api_init_action(): void { global $wp_actions; - // Store the original action count + // Store the original action count. $original_count = isset( $wp_actions['wp_abilities_api_init'] ) ? $wp_actions['wp_abilities_api_init'] : 0; - // Reset the action count to simulate it not being fired + // Reset the action count to simulate it not being fired. unset( $wp_actions['wp_abilities_api_init'] ); $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); - // Restore the original action count + // Restore the original action count. if ( $original_count > 0 ) { $wp_actions['wp_abilities_api_init'] = $original_count; } @@ -140,6 +140,34 @@ public function test_register_ability_no_abilities_api_init_hook(): void { $this->assertNull( $result ); } + /** + * Tests registering an ability when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_instance + */ + public function test_register_ability_no_init_action(): void { + global $wp_actions; + + do_action( 'wp_abilities_api_init' ); + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertNull( $result ); + } + /** * Tests registering a valid ability. * @@ -408,6 +436,32 @@ public function test_permission_callback_receives_input(): void { ); } + /** + * Tests unregistering an ability when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_instance + */ + public function test_unregister_ability_no_init_action(): void { + global $wp_actions; + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_unregister_ability( self::$test_ability_name ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertNull( $result ); + } + /** * Tests unregistering existing ability. * @@ -426,6 +480,32 @@ public function test_unregister_existing_ability() { ); } + /** + * Tests retrieving an ability when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_instance + */ + public function test_get_ability_no_init_action(): void { + global $wp_actions; + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_get_ability( self::$test_ability_name ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertNull( $result ); + } + /** * Tests retrieving existing ability. * @@ -459,6 +539,32 @@ public function test_get_existing_ability() { ); } + /** + * Tests checking if an ability is registered when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_instance + */ + public function test_has_ability_no_init_action(): void { + global $wp_actions; + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_has_ability( self::$test_ability_name ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertFalse( $result ); + } + /** * Tests checking if an ability is registered. * @@ -487,6 +593,32 @@ public function test_has_registered_nonexistent_ability() { $this->assertFalse( $result ); } + /** + * Tests retrieving all registered abilities when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_instance + */ + public function test_get_abilities_no_init_action(): void { + global $wp_actions; + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_get_abilities(); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertSame( array(), $result ); + } + /** * Tests retrieving all registered abilities. * From 2dbde17cba5bf5dea4210d9dca8646a834a10f77 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 21 Oct 2025 12:28:48 +0200 Subject: [PATCH 29/31] Increase testing code coverage for required `init` action check in ability categories registry --- ...s-wp-rest-abilities-v1-list-controller.php | 2 +- ...gory.php => wpAbilityCategoryRegistry.php} | 162 +++----- .../tests/abilities-api/wpRegisterAbility.php | 5 +- .../wpRegisterAbilityCategory.php | 365 ++++++++++++++++++ 4 files changed, 418 insertions(+), 116 deletions(-) rename tests/phpunit/tests/abilities-api/{wpAbilityCategory.php => wpAbilityCategoryRegistry.php} (80%) create mode 100644 tests/phpunit/tests/abilities-api/wpRegisterAbilityCategory.php diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index 25084eaf680b5..34566bd141ca8 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -294,7 +294,7 @@ public function get_item_schema(): array { 'description' => __( 'Meta information about the ability.' ), 'type' => 'object', 'properties' => array( - 'annotations' => array( + 'annotations' => array( 'description' => __( 'Annotations for the ability.' ), 'type' => array( 'boolean', 'null' ), 'default' => null, diff --git a/tests/phpunit/tests/abilities-api/wpAbilityCategory.php b/tests/phpunit/tests/abilities-api/wpAbilityCategoryRegistry.php similarity index 80% rename from tests/phpunit/tests/abilities-api/wpAbilityCategory.php rename to tests/phpunit/tests/abilities-api/wpAbilityCategoryRegistry.php index e520ec593e33b..c517ad66a60fd 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilityCategory.php +++ b/tests/phpunit/tests/abilities-api/wpAbilityCategoryRegistry.php @@ -5,14 +5,10 @@ * * @covers WP_Ability_Category * @covers WP_Ability_Categories_Registry - * @covers wp_register_ability_category - * @covers wp_unregister_ability_category - * @covers wp_get_ability_category - * @covers wp_get_ability_categories * * @group abilities-api */ -class Tests_Abilities_API_WpAbilityCategory extends WP_UnitTestCase { +class Tests_Abilities_API_WpAbilityCategoryRegistry extends WP_UnitTestCase { /** * Category registry instance. @@ -34,7 +30,7 @@ class Tests_Abilities_API_WpAbilityCategory extends WP_UnitTestCase { public function set_up(): void { parent::set_up(); - $this->registry = WP_Ability_Categories_Registry::get_instance(); + $this->registry = new WP_Ability_Categories_Registry(); $this->doing_it_wrong_log = array(); add_action( 'doing_it_wrong_run', array( $this, 'record_doing_it_wrong' ), 10, 3 ); @@ -47,14 +43,7 @@ public function tear_down(): void { remove_action( 'doing_it_wrong_run', array( $this, 'record_doing_it_wrong' ) ); $this->doing_it_wrong_log = array(); - // Clean up all test categories. - $categories = $this->registry->get_all_registered(); - foreach ( $categories as $category ) { - if ( 0 !== strpos( $category->get_slug(), 'test-' ) ) { - continue; - } - $this->registry->unregister( $category->get_slug() ); - } + $this->registry = null; parent::tear_down(); } @@ -104,33 +93,13 @@ private function assertDoingItWrongTriggered( string $the_method, ?string $messa } } - /** - * Helper to register a category with the action hook. - * - * @param string $slug The ability category slug. - * @param array $args The ability category arguments. - * @return WP_Ability_Category|null The registered category or null on failure. - */ - private function register_category_with_action_hook( string $slug, array $args ): ?WP_Ability_Category { - $result = null; - $callback = static function () use ( $slug, $args, &$result ): void { - $result = wp_register_ability_category( $slug, $args ); - }; - - add_action( 'wp_abilities_api_categories_init', $callback ); - do_action( 'wp_abilities_api_categories_init', WP_Ability_Categories_Registry::get_instance() ); - remove_action( 'wp_abilities_api_categories_init', $callback ); - - return $result; - } - /** * Test registering a valid category. * * @ticket 64098 */ public function test_register_valid_category(): void { - $result = $this->register_category_with_action_hook( + $result = $this->registry->register( 'test-math', array( 'label' => 'Math', @@ -153,7 +122,7 @@ public function test_register_valid_category(): void { */ public function test_register_category_invalid_slug_format(): void { // Uppercase characters not allowed. - $result = $this->register_category_with_action_hook( + $result = $this->registry->register( 'Test-Math', array( 'label' => 'Math', @@ -173,7 +142,7 @@ public function test_register_category_invalid_slug_format(): void { * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_category_invalid_slug_underscore(): void { - $result = $this->register_category_with_action_hook( + $result = $this->registry->register( 'test_math', array( 'label' => 'Math', @@ -193,7 +162,7 @@ public function test_register_category_invalid_slug_underscore(): void { * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_category_missing_label(): void { - $result = $this->register_category_with_action_hook( + $result = $this->registry->register( 'test-math', array( 'description' => 'Mathematical operations.', @@ -212,7 +181,7 @@ public function test_register_category_missing_label(): void { * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_category_missing_description(): void { - $result = $this->register_category_with_action_hook( + $result = $this->registry->register( 'test-math', array( 'label' => 'Math', @@ -223,39 +192,6 @@ public function test_register_category_missing_description(): void { $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); } - /** - * Test registering category before abilities_api_categories_init hook. - * - * @ticket 64098 - * - * @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['wp_abilities_api_categories_init'] ) ? $wp_actions['wp_abilities_api_categories_init'] : 0; - - // Reset to simulate hook not fired. - unset( $wp_actions['wp_abilities_api_categories_init'] ); - - $result = wp_register_ability_category( - 'test-math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations.', - ) - ); - - // Restore original count. - if ( $original_count > 0 ) { - $wp_actions['wp_abilities_api_categories_init'] = $original_count; - } - - $this->assertNull( $result ); - $this->assertDoingItWrongTriggered( 'wp_register_ability_category', 'wp_abilities_api_categories_init' ); - } - /** * Test registering duplicate category. * @@ -264,7 +200,7 @@ public function test_register_category_before_init_hook(): void { * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_duplicate_category(): void { - $result = $this->register_category_with_action_hook( + $result = $this->registry->register( 'test-math', array( 'label' => 'Math', @@ -274,7 +210,7 @@ public function test_register_duplicate_category(): void { $this->assertInstanceOf( WP_Ability_Category::class, $result ); - $result = $this->register_category_with_action_hook( + $result = $this->registry->register( 'test-math', array( 'label' => 'Math 2', @@ -292,7 +228,7 @@ public function test_register_duplicate_category(): void { * @ticket 64098 */ public function test_unregister_existing_category(): void { - $this->register_category_with_action_hook( + $this->registry->register( 'test-math', array( 'label' => 'Math', @@ -300,7 +236,7 @@ public function test_unregister_existing_category(): void { ) ); - $result = wp_unregister_ability_category( 'test-math' ); + $result = $this->registry->unregister( 'test-math' ); $this->assertInstanceOf( WP_Ability_Category::class, $result ); $this->assertFalse( $this->registry->is_registered( 'test-math' ) ); @@ -314,7 +250,7 @@ public function test_unregister_existing_category(): void { * @expectedIncorrectUsage WP_Ability_Categories_Registry::unregister */ public function test_unregister_nonexistent_category(): void { - $result = wp_unregister_ability_category( 'test-nonexistent' ); + $result = $this->registry->unregister( 'test-nonexistent' ); $this->assertNull( $result ); $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::unregister' ); @@ -326,7 +262,7 @@ public function test_unregister_nonexistent_category(): void { * @ticket 64098 */ public function test_get_existing_category(): void { - $this->register_category_with_action_hook( + $this->registry->register( 'test-math', array( 'label' => 'Math', @@ -334,7 +270,7 @@ public function test_get_existing_category(): void { ) ); - $result = wp_get_ability_category( 'test-math' ); + $result = $this->registry->get_registered( 'test-math' ); $this->assertInstanceOf( WP_Ability_Category::class, $result ); $this->assertSame( 'test-math', $result->get_slug() ); @@ -348,7 +284,7 @@ public function test_get_existing_category(): void { * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_registered */ public function test_get_nonexistent_category(): void { - $result = wp_get_ability_category( 'test-nonexistent' ); + $result = $this->registry->get_registered( 'test-nonexistent' ); $this->assertNull( $result ); $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::get_registered' ); @@ -361,7 +297,7 @@ public function test_get_nonexistent_category(): void { */ public function test_has_registered_ability_category(): void { $category_slug = 'test-math'; - $this->register_category_with_action_hook( + $this->registry->register( $category_slug, array( 'label' => 'Math', @@ -369,7 +305,7 @@ public function test_has_registered_ability_category(): void { ) ); - $result = wp_has_ability_category( $category_slug ); + $result = $this->registry->is_registered( $category_slug ); $this->assertTrue( $result ); } @@ -380,7 +316,7 @@ public function test_has_registered_ability_category(): void { * @ticket 64098 */ public function test_has_registered_nonexistent_ability_category(): void { - $result = wp_has_ability_category( 'test/non-existent' ); + $result = $this->registry->is_registered( 'test/non-existent' ); $this->assertFalse( $result ); } @@ -391,7 +327,7 @@ public function test_has_registered_nonexistent_ability_category(): void { * @ticket 64098 */ public function test_get_all_categories(): void { - $this->register_category_with_action_hook( + $this->registry->register( 'test-math', array( 'label' => 'Math', @@ -399,7 +335,7 @@ public function test_get_all_categories(): void { ) ); - $this->register_category_with_action_hook( + $this->registry->register( 'test-system', array( 'label' => 'System', @@ -407,7 +343,7 @@ public function test_get_all_categories(): void { ) ); - $categories = wp_get_ability_categories(); + $categories = $this->registry->get_all_registered(); $this->assertIsArray( $categories ); $this->assertCount( 2, $categories ); @@ -423,7 +359,7 @@ public function test_get_all_categories(): void { public function test_category_is_registered(): void { $this->assertFalse( $this->registry->is_registered( 'test-math' ) ); - $this->register_category_with_action_hook( + $this->registry->register( 'test-math', array( 'label' => 'Math', @@ -434,25 +370,13 @@ public function test_category_is_registered(): void { $this->assertTrue( $this->registry->is_registered( 'test-math' ) ); } - /** - * Test category registry singleton. - * - * @ticket 64098 - */ - public function test_category_registry_singleton(): void { - $instance1 = WP_Ability_Categories_Registry::get_instance(); - $instance2 = WP_Ability_Categories_Registry::get_instance(); - - $this->assertSame( $instance1, $instance2 ); - } - /** * Test category with special characters in label and description. * * @ticket 64098 */ public function test_category_with_special_characters(): void { - $result = $this->register_category_with_action_hook( + $result = $this->registry->register( 'test-special', array( 'label' => 'Math & Science ', @@ -490,7 +414,7 @@ public function data_valid_slug_provider(): array { * @param string $slug The category slug to test. */ public function test_category_slug_valid_formats( string $slug ): void { - $result = $this->register_category_with_action_hook( + $result = $this->registry->register( $slug, array( 'label' => 'Test', @@ -530,7 +454,7 @@ public function data_invalid_slug_provider(): array { * @param string $slug The category slug to test. */ public function test_category_slug_invalid_formats( string $slug ): void { - $result = $this->register_category_with_action_hook( + $result = $this->registry->register( $slug, array( 'label' => 'Test', @@ -550,7 +474,7 @@ public function test_category_slug_invalid_formats( string $slug ): void { * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_category_constructor_non_string_label(): void { - $result = $this->register_category_with_action_hook( + $result = $this->registry->register( 'test-invalid', array( 'label' => 123, // Integer instead of string @@ -570,7 +494,7 @@ public function test_category_constructor_non_string_label(): void { * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_category_constructor_empty_label(): void { - $result = $this->register_category_with_action_hook( + $result = $this->registry->register( 'test-invalid', array( 'label' => '', @@ -590,7 +514,7 @@ public function test_category_constructor_empty_label(): void { * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_category_constructor_non_string_description(): void { - $result = $this->register_category_with_action_hook( + $result = $this->registry->register( 'test-invalid', array( 'label' => 'Valid Label', @@ -610,7 +534,7 @@ public function test_category_constructor_non_string_description(): void { * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_category_constructor_empty_description(): void { - $result = $this->register_category_with_action_hook( + $result = $this->registry->register( 'test-invalid', array( 'label' => 'Valid Label', @@ -641,7 +565,7 @@ static function ( $args, $slug ) { 2 ); - $result = $this->register_category_with_action_hook( + $result = $this->registry->register( 'test-filtered', array( 'label' => 'Original Label', @@ -660,7 +584,7 @@ static function ( $args, $slug ) { * @ticket 64098 */ public function test_category_wakeup_throws_exception(): void { - $category = $this->register_category_with_action_hook( + $category = $this->registry->register( 'test-serialize', array( 'label' => 'Test', @@ -685,7 +609,7 @@ public function test_register_category_with_valid_meta(): void { 'custom' => array( 'key' => 'value' ), ); - $result = $this->register_category_with_action_hook( + $result = $this->registry->register( 'test-meta', array( 'label' => 'Math', @@ -705,7 +629,7 @@ public function test_register_category_with_valid_meta(): void { * @ticket 64098 */ public function test_register_category_with_empty_meta(): void { - $result = $this->register_category_with_action_hook( + $result = $this->registry->register( 'test-empty-meta', array( 'label' => 'Math', @@ -724,7 +648,7 @@ public function test_register_category_with_empty_meta(): void { * @ticket 64098 */ public function test_register_category_without_meta_returns_empty_array(): void { - $result = $this->register_category_with_action_hook( + $result = $this->registry->register( 'test-no-meta', array( 'label' => 'Math', @@ -744,7 +668,7 @@ public function test_register_category_without_meta_returns_empty_array(): void * @expectedIncorrectUsage WP_Ability_Categories_Registry::register */ public function test_register_category_with_invalid_meta(): void { - $result = $this->register_category_with_action_hook( + $result = $this->registry->register( 'test-invalid-meta', array( 'label' => 'Math', @@ -765,7 +689,7 @@ public function test_register_category_with_invalid_meta(): void { * @expectedIncorrectUsage WP_Ability_Category::__construct */ public function test_register_category_with_unknown_property(): void { - $result = $this->register_category_with_action_hook( + $result = $this->registry->register( 'test-unknown-property', array( 'label' => 'Math', @@ -779,4 +703,16 @@ public function test_register_category_with_unknown_property(): void { // But _doing_it_wrong should be triggered. $this->assertDoingItWrongTriggered( 'WP_Ability_Category::__construct', 'not a valid property' ); } + + /** + * Test category registry singleton. + * + * @ticket 64098 + */ + public function test_category_registry_singleton(): void { + $instance1 = WP_Ability_Categories_Registry::get_instance(); + $instance2 = WP_Ability_Categories_Registry::get_instance(); + + $this->assertSame( $instance1, $instance2 ); + } } diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index 71400b973f099..64fb8405c2e00 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -15,6 +15,7 @@ protected function do_execute( $input = null ) { * @covers wp_register_ability * @covers wp_unregister_ability * @covers wp_get_ability + * @covers wp_has_ability * @covers wp_get_all_abilities * * @group abilities-api @@ -507,11 +508,11 @@ public function test_get_ability_no_init_action(): void { } /** - * Tests retrieving existing ability. + * Tests retrieving existing ability registered with the `wp_abilities_api_init` callback. * * @ticket 64098 */ - public function test_get_existing_ability() { + public function test_get_existing_ability_using_callback() { $name = self::$test_ability_name; $args = self::$test_ability_args; $callback = static function ( $instance ) use ( $name, $args ) { diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbilityCategory.php b/tests/phpunit/tests/abilities-api/wpRegisterAbilityCategory.php new file mode 100644 index 0000000000000..ede55f98eaeaf --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbilityCategory.php @@ -0,0 +1,365 @@ + 'Math', + 'description' => 'Mathematical operations.', + ); + } + + /** + * Tear down after each test. + */ + public function tear_down(): void { + // Clean up any test ability categories registered during tests. + foreach ( wp_get_ability_categories() as $ability_category ) { + if ( ! str_starts_with( $ability_category->get_slug(), 'test-' ) ) { + continue; + } + + wp_unregister_ability_category( $ability_category->get_slug() ); + } + + parent::tear_down(); + } + + /** + * Test registering ability category before `abilities_api_categories_init` hook. + * + * @ticket 64098 + * + * @expectedIncorrectUsage wp_register_ability_category + */ + public function test_register_category_before_init_hook(): void { + $result = wp_register_ability_category( + self::$test_ability_category_name, + self::$test_ability_category_args + ); + + $this->assertNull( $result ); + } + + /** + * Tests registering an ability category when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance + */ + public function test_register_ability_category_no_init_action(): void { + global $wp_actions; + + do_action( 'wp_abilities_api_categories_init' ); + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_register_ability_category( + self::$test_ability_category_name, + self::$test_ability_category_args + ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertNull( $result ); + } + + /** + * Test registering a valid ability category. + * + * @ticket 64098 + */ + public function test_register_valid_category(): void { + do_action( 'wp_abilities_api_categories_init' ); + + $result = wp_register_ability_category( + self::$test_ability_category_name, + self::$test_ability_category_args + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( self::$test_ability_category_name, $result->get_slug() ); + $this->assertSame( 'Math', $result->get_label() ); + $this->assertSame( 'Mathematical operations.', $result->get_description() ); + } + + /** + * Tests unregistering an ability category when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance + */ + public function test_unregister_ability_category_no_init_action(): void { + global $wp_actions; + + do_action( 'wp_abilities_api_categories_init' ); + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_unregister_ability_category( self::$test_ability_category_name ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertNull( $result ); + } + + /** + * Test unregistering non-existent ability category. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::unregister + */ + public function test_unregister_nonexistent_category(): void { + do_action( 'wp_abilities_api_categories_init' ); + + $result = wp_unregister_ability_category( 'test-nonexistent' ); + + $this->assertNull( $result ); + } + + /** + * Test unregistering existing ability category. + * + * @ticket 64098 + */ + public function test_unregister_existing_category(): void { + do_action( 'wp_abilities_api_categories_init' ); + + wp_register_ability_category( + self::$test_ability_category_name, + self::$test_ability_category_args + ); + + $result = wp_unregister_ability_category( self::$test_ability_category_name ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertFalse( wp_has_ability_category( self::$test_ability_category_name ) ); + } + + /** + * Tests checking if an ability category is registered when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance + */ + public function test_has_ability_category_no_init_action(): void { + global $wp_actions; + + do_action( 'wp_abilities_api_categories_init' ); + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_has_ability_category( self::$test_ability_category_name ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertFalse( $result ); + } + + /** + * Tests checking if a non-existent ability category is registered. + * + * @ticket 64098 + */ + public function test_has_registered_nonexistent_ability_category(): void { + do_action( 'wp_abilities_api_categories_init' ); + + $result = wp_has_ability_category( 'test/non-existent' ); + + $this->assertFalse( $result ); + } + + /** + * Tests checking if an ability category is registered. + * + * @ticket 64098 + */ + public function test_has_registered_ability_category(): void { + do_action( 'wp_abilities_api_categories_init' ); + + $category_slug = self::$test_ability_category_name; + + wp_register_ability_category( + $category_slug, + self::$test_ability_category_args + ); + + $result = wp_has_ability_category( $category_slug ); + + $this->assertTrue( $result ); + } + + /** + * Tests retrieving an ability category when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance + */ + public function test_get_ability_category_no_init_action(): void { + global $wp_actions; + + do_action( 'wp_abilities_api_categories_init' ); + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_get_ability_category( self::$test_ability_category_name ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertNull( $result ); + } + + /** + * Test retrieving non-existent ability category. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_registered + */ + public function test_get_nonexistent_category(): void { + do_action( 'wp_abilities_api_categories_init' ); + + $result = wp_get_ability_category( 'test-nonexistent' ); + + $this->assertNull( $result ); + } + + /** + * Test retrieving existing ability category registered with the `wp_abilities_api_categories_init` callback. + * + * @ticket 64098 + */ + public function test_get_existing_category_using_callback(): void { + $name = self::$test_ability_category_name; + $args = self::$test_ability_category_args; + $callback = static function ( $instance ) use ( $name, $args ) { + wp_register_ability_category( $name, $args ); + }; + + add_action( 'wp_abilities_api_categories_init', $callback ); + + // Reset the Registry, to ensure it's empty before the test. + $registry_reflection = new ReflectionClass( WP_Ability_Categories_Registry::class ); + $instance_prop = $registry_reflection->getProperty( 'instance' ); + if ( PHP_VERSION_ID < 80100 ) { + $instance_prop->setAccessible( true ); + } + $instance_prop->setValue( null, null ); + + $result = wp_get_ability_category( $name ); + + remove_action( 'wp_abilities_api_categories_init', $callback ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( self::$test_ability_category_name, $result->get_slug() ); + } + + /** + * Test retrieving all registered ability categories when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance + */ + public function test_get_ability_categories_no_init_action(): void { + global $wp_actions; + + do_action( 'wp_abilities_api_categories_init' ); + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_get_ability_categories( self::$test_ability_category_name ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertSame( array(), $result ); + } + + /** + * Test retrieving all registered ability categories. + * + * @ticket 64098 + */ + public function test_get_all_categories(): void { + do_action( 'wp_abilities_api_categories_init' ); + + wp_register_ability_category( + self::$test_ability_category_name, + self::$test_ability_category_args + ); + + wp_register_ability_category( + 'test-system', + array( + 'label' => 'System', + 'description' => 'System operations.', + ) + ); + + $categories = wp_get_ability_categories(); + + $this->assertIsArray( $categories ); + $this->assertCount( 2, $categories ); + $this->assertArrayHasKey( self::$test_ability_category_name, $categories ); + $this->assertArrayHasKey( 'test-system', $categories ); + } +} From bcc6ff963c1fd0d4b9d6b3217d9a92cb6ee7fa21 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 21 Oct 2025 12:58:50 +0200 Subject: [PATCH 30/31] Promote `validate_input` to public method and refactor usage --- .../abilities-api/class-wp-ability.php | 25 +++++++++---------- ...ss-wp-rest-abilities-v1-run-controller.php | 15 +++++------ .../tests/abilities-api/wpRegisterAbility.php | 8 +++--- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 5e61c23858db9..0c7310f0343bb 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -408,7 +408,7 @@ public function get_meta_item( string $key, $default_value = null ) { * @param mixed $input Optional. The input data to validate. Default `null`. * @return true|WP_Error Returns true if valid or the WP_Error object if validation fails. */ - protected function validate_input( $input = null ) { + public function validate_input( $input = null ) { $input_schema = $this->get_input_schema(); if ( empty( $input_schema ) ) { if ( null === $input ) { @@ -462,23 +462,20 @@ protected function invoke_callback( callable $callback, $input = null ) { /** * Checks whether the ability has the necessary permissions. * - * The input is validated against the input schema before it is passed to to permission callback. + * Please note that input is not automatically validated against the input schema. + * Use `validate_input()` method to validate input before calling this method if needed. * * @since 6.9.0 * - * @param mixed $input Optional. The input data for permission checking. Default `null`. + * @see validate_input() + * + * @param mixed $input Optional. The valid input data for permission checking. Default `null`. * @return bool|WP_Error Whether the ability has the necessary permission. */ public function check_permissions( $input = null ) { - $is_valid = $this->validate_input( $input ); - if ( is_wp_error( $is_valid ) ) { - return $is_valid; - } - return $this->invoke_callback( $this->permission_callback, $input ); } - /** * Executes the ability callback. * @@ -539,12 +536,14 @@ protected function validate_output( $output ) { * @return mixed|WP_Error The result of the ability execution, or WP_Error on failure. */ public function execute( $input = null ) { + $is_valid = $this->validate_input( $input ); + if ( is_wp_error( $is_valid ) ) { + return $is_valid; + } + $has_permissions = $this->check_permissions( $input ); if ( true !== $has_permissions ) { if ( is_wp_error( $has_permissions ) ) { - if ( 'ability_invalid_input' === $has_permissions->get_error_code() ) { - return $has_permissions; - } // Don't leak the permission check error to someone without the correct perms. _doing_it_wrong( __METHOD__, @@ -561,7 +560,7 @@ public function execute( $input = null ) { } /** - * Fires before an ability gets executed and after permission check. + * Fires before an ability gets executed, after input validation and permissions check. * * @since 6.9.0 * diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php index 70729164b456f..c8243526be723 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php @@ -92,9 +92,6 @@ public function execute_ability( $request ) { $input = $this->get_input_from_request( $request ); $result = $ability->execute( $input ); if ( is_wp_error( $result ) ) { - if ( 'ability_invalid_input' === $result->get_error_code() ) { - $result->add_data( array( 'status' => 400 ) ); - } return $result; } @@ -161,12 +158,16 @@ public function check_ability_permissions( $request ) { return $is_valid; } - $input = $this->get_input_from_request( $request ); + $input = $this->get_input_from_request( $request ); + $is_valid = $ability->validate_input( $input ); + if ( is_wp_error( $is_valid ) ) { + $is_valid->add_data( array( 'status' => 400 ) ); + return $is_valid; + } + $result = $ability->check_permissions( $input ); if ( is_wp_error( $result ) ) { - if ( 'ability_invalid_input' === $result->get_error_code() ) { - $result->add_data( array( 'status' => 400 ) ); - } + $result->add_data( array( 'status' => rest_authorization_required_code() ) ); return $result; } if ( ! $result ) { diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index 64fb8405c2e00..21b108f5ac8f9 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -357,16 +357,16 @@ public function test_execute_ability_no_output_schema_match(): void { } /** - * Tests permission callback receiving input not matching schema. + * Tests input validation failing due to schema mismatch. * * @ticket 64098 */ - public function test_permission_callback_no_input_schema_match(): void { + public function test_validate_input_no_input_schema_match(): void { do_action( 'wp_abilities_api_init' ); $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); - $actual = $result->check_permissions( + $actual = $result->validate_input( array( 'a' => 2, 'b' => 3, @@ -376,7 +376,7 @@ public function test_permission_callback_no_input_schema_match(): void { $this->assertWPError( $actual, - 'Permission check should fail due to input not matching schema.' + 'Input validation should fail due to input not matching schema.' ); $this->assertSame( 'ability_invalid_input', $actual->get_error_code() ); $this->assertSame( From 88b4a6fca6e14ac1172e74992c6fdd6318903bc2 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 21 Oct 2025 14:14:37 +0200 Subject: [PATCH 31/31] Fix test for expected routes in REST API schema --- .../tests/rest-api/rest-schema-setup.php | 4 +- tests/qunit/fixtures/wp-api-generated.js | 39 +++++++++++++++++-- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 2640d420b1361..beaf294d09e3d 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -195,6 +195,7 @@ public function test_expected_routes_in_schema() { '/wp/v2/font-families/(?P[\d]+)/font-faces', '/wp/v2/font-families/(?P[\d]+)/font-faces/(?P[\d]+)', '/wp/v2/font-families/(?P[\d]+)', + '/wp-abilities/v1', '/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\-\/]+?)/run', '/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\-\/]+)', '/wp-abilities/v1/abilities', @@ -208,7 +209,8 @@ private function is_builtin_route( $route ) { '/' === $route || preg_match( '#^/oembed/1\.0(/.+)?$#', $route ) || preg_match( '#^/wp/v2(/.+)?$#', $route ) || - preg_match( '#^/wp-site-health/v1(/.+)?$#', $route ) + preg_match( '#^/wp-site-health/v1(/.+)?$#', $route ) || + preg_match( '#^/wp-abilities/v1(/.+)?$#', $route ) ); } diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 7e07df07a0dcd..a47686d6872e3 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -19,7 +19,8 @@ mockedApiResponse.Schema = { "oembed/1.0", "wp/v2", "wp-site-health/v1", - "wp-block-editor/v1" + "wp-block-editor/v1", + "wp-abilities/v1" ], "authentication": { "application-passwords": { @@ -12392,8 +12393,38 @@ mockedApiResponse.Schema = { } ] }, + "/wp-abilities/v1": { + "namespace": "wp-abilities/v1", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "namespace": { + "default": "wp-abilities/v1", + "required": false + }, + "context": { + "default": "view", + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp-abilities/v1" + } + ] + } + }, "/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run": { - "namespace": "wp/v2", + "namespace": "wp-abilities/v1", "methods": [ "GET", "POST", @@ -12436,7 +12467,7 @@ mockedApiResponse.Schema = { ] }, "/wp-abilities/v1/abilities": { - "namespace": "wp/v2", + "namespace": "wp-abilities/v1", "methods": [ "GET" ], @@ -12489,7 +12520,7 @@ mockedApiResponse.Schema = { } }, "/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\\-\\/]+)": { - "namespace": "wp/v2", + "namespace": "wp-abilities/v1", "methods": [ "GET" ],