diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..c6f3cf4
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,24 @@
+# This file is for unifying the coding style for different editors and IDEs
+# editorconfig.org
+
+# WordPress Coding Standards
+# https://make.wordpress.org/core/handbook/coding-standards/
+
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+indent_style = tab
+
+[*.yml]
+indent_style = space
+indent_size = 2
+
+[*.md]
+trim_trailing_whitespace = false
+
+[{*.txt,wp-config-sample.php}]
+end_of_line = crlf
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..a50c6a6
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,20 @@
+
+
+
+
+ tests/unit
+
+
+
diff --git a/src/abilities-api.php b/src/abilities-api.php
new file mode 100644
index 0000000..dabc211
--- /dev/null
+++ b/src/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/class-wp-abilities-registry.php b/src/class-wp-abilities-registry.php
new file mode 100644
index 0000000..4c52c74
--- /dev/null
+++ b/src/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/class-wp-ability.php b/src/class-wp-ability.php
new file mode 100644
index 0000000..50dea61
--- /dev/null
+++ b/src/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/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..e69de29
diff --git a/tests/unit/AbilitiesAPITest.php b/tests/unit/AbilitiesAPITest.php
new file mode 100644
index 0000000..c70d1dd
--- /dev/null
+++ b/tests/unit/AbilitiesAPITest.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/unit/WPAbilitiesRegistryTest.php b/tests/unit/WPAbilitiesRegistryTest.php
new file mode 100644
index 0000000..3b741e0
--- /dev/null
+++ b/tests/unit/WPAbilitiesRegistryTest.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() );
+ }
+}