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 3892b8cd33f91..3dec061bd8ff3 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -266,6 +266,9 @@ require ABSPATH . WPINC . '/html-api/class-wp-html-stack-event.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor-state.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor.php'; +require ABSPATH . WPINC . '/abilities-api/class-wp-ability.php'; +require ABSPATH . WPINC . '/abilities-api/class-wp-abilities-registry.php'; +require ABSPATH . WPINC . '/abilities-api/abilities-api.php'; require ABSPATH . WPINC . '/class-wp-http.php'; require ABSPATH . WPINC . '/class-wp-http-streams.php'; require ABSPATH . WPINC . '/class-wp-http-curl.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() ); + } +}