diff --git a/abilities-api.php b/abilities-api.php
index 2bb6fa55..39dafd64 100644
--- a/abilities-api.php
+++ b/abilities-api.php
@@ -45,3 +45,8 @@
* Then the public access functions that users can use to interact with the abilities.
*/
require_once WP_ABILITIES_API_DIR . 'src/abilities-api.php';
+
+/**
+ * Initialize REST API controllers.
+ */
+require_once WP_ABILITIES_API_DIR . 'src/rest/class-wp-rest-abilities-init.php';
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index 1af8dc9a..2f74b5f8 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -193,6 +193,22 @@
+
+
+
+ src/rest/class-wp-rest-abilities-*-controller.php
+
+
+ src/rest/class-wp-rest-abilities-*-controller.php
+
+
+ src/rest/class-wp-rest-abilities-*-controller.php
+
@@ -254,6 +270,7 @@
+
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index bf768e8d..9f8e19b7 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -29,3 +29,14 @@ parameters:
analyseAndScan:
- node_modules (?)
+ # Ignore specific errors
+ ignoreErrors:
+ # WP_REST_Request is not actually a generic class in WordPress core.
+ # PHPStan's WordPress stubs appear to define it as generic for type checking,
+ # but WordPress itself doesn't use generics. This seems to be an incompatibility
+ # between static analysis tools and WordPress's actual implementation.
+ -
+ message: '#has parameter \$request with generic class WP_REST_Request but does not specify its types#'
+ paths:
+ - src/rest/*.php
+
diff --git a/src/rest/class-wp-rest-abilities-init.php b/src/rest/class-wp-rest-abilities-init.php
new file mode 100644
index 00000000..ed71c2a9
--- /dev/null
+++ b/src/rest/class-wp-rest-abilities-init.php
@@ -0,0 +1,36 @@
+register_routes();
+
+ $list_controller = new WP_REST_Abilities_List_Controller();
+ $list_controller->register_routes();
+ }
+}
+
+add_action( 'rest_api_init', array( 'WP_REST_Abilities_Init', 'register_routes' ) );
diff --git a/src/rest/class-wp-rest-abilities-list-controller.php b/src/rest/class-wp-rest-abilities-list-controller.php
new file mode 100644
index 00000000..aeeb527c
--- /dev/null
+++ b/src/rest/class-wp-rest-abilities-list-controller.php
@@ -0,0 +1,307 @@
+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.', 'abilities-api' ),
+ '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.
+ $page = isset( $request['page'] ) ? $request['page'] : 1;
+ $per_page = isset( $request['per_page'] ) ? $request['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 );
+
+ $request_params = $request->get_query_params();
+ $base = add_query_arg( urlencode_deep( $request_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['name'] );
+
+ if ( ! $ability ) {
+ return new \WP_Error(
+ 'rest_ability_not_found',
+ __( 'Ability not found.', 'abilities-api' ),
+ 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 boolean 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 = ! empty( $request['context'] ) ? $request['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.', 'abilities-api' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'label' => array(
+ 'description' => __( 'Display label for the ability.', 'abilities-api' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'description' => array(
+ 'description' => __( 'Description of the ability.', 'abilities-api' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'input_schema' => array(
+ 'description' => __( 'JSON Schema for the ability input.', 'abilities-api' ),
+ 'type' => 'object',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'output_schema' => array(
+ 'description' => __( 'JSON Schema for the ability output.', 'abilities-api' ),
+ 'type' => 'object',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'meta' => array(
+ 'description' => __( 'Meta information about the ability.', 'abilities-api' ),
+ '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.', 'abilities-api' ),
+ '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.', 'abilities-api' ),
+ 'type' => 'integer',
+ 'default' => self::DEFAULT_PER_PAGE,
+ 'minimum' => 1,
+ 'maximum' => 100,
+ 'sanitize_callback' => 'absint',
+ 'validate_callback' => 'rest_validate_request_arg',
+ ),
+ );
+ }
+}
diff --git a/src/rest/class-wp-rest-abilities-run-controller.php b/src/rest/class-wp-rest-abilities-run-controller.php
new file mode 100644
index 00000000..02c67345
--- /dev/null
+++ b/src/rest/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.', 'abilities-api' ),
+ '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['name'] );
+
+ if ( ! $ability ) {
+ return new \WP_Error(
+ 'rest_ability_not_found',
+ __( 'Ability not found.', 'abilities-api' ),
+ 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.', 'abilities-api' ),
+ array( 'status' => 405 )
+ );
+ }
+
+ if ( 'tool' === $type && 'POST' !== $method ) {
+ return new \WP_Error(
+ 'rest_invalid_method',
+ __( 'Tool abilities require POST method.', 'abilities-api' ),
+ 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['name'] );
+
+ if ( ! $ability ) {
+ return new \WP_Error(
+ 'rest_ability_not_found',
+ __( 'Ability not found.', 'abilities-api' ),
+ 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.', 'abilities-api' ),
+ 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['name'] );
+
+ if ( ! $ability ) {
+ return new \WP_Error(
+ 'rest_ability_not_found',
+ __( 'Ability not found.', 'abilities-api' ),
+ 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.', 'abilities-api' ),
+ 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', 'abilities-api' ),
+ $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', 'abilities-api' ),
+ $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.', 'abilities-api' ),
+ '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.', 'abilities-api' ),
+ 'type' => 'mixed',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+ }
+}
diff --git a/tests/unit/AbilitiesAPITest.php b/tests/unit/AbilitiesAPITest.php
index c70d1dde..a4c4c82d 100644
--- a/tests/unit/AbilitiesAPITest.php
+++ b/tests/unit/AbilitiesAPITest.php
@@ -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/unit/REST/WPRESTAbilitiesInitTest.php b/tests/unit/REST/WPRESTAbilitiesInitTest.php
new file mode 100644
index 00000000..84c92fa5
--- /dev/null
+++ b/tests/unit/REST/WPRESTAbilitiesInitTest.php
@@ -0,0 +1,168 @@
+server = $wp_rest_server = new WP_REST_Server();
+ }
+
+ /**
+ * Clean up after each test.
+ */
+ public function tear_down(): void {
+ parent::tear_down();
+
+ global $wp_rest_server;
+ $wp_rest_server = null;
+ }
+
+ /**
+ * Test that routes are registered when rest_api_init fires.
+ */
+ public function test_routes_registered_on_rest_api_init(): void {
+ // Routes should not exist before init
+ $routes = $this->server->get_routes();
+ $this->assertArrayNotHasKey( '/wp/v2/abilities', $routes );
+ $this->assertArrayNotHasKey( '/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+)', $routes );
+ $this->assertArrayNotHasKey( '/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run', $routes );
+
+ // Trigger rest_api_init
+ do_action( 'rest_api_init' );
+
+ // Routes should now be registered
+ $routes = $this->server->get_routes();
+ $this->assertArrayHasKey( '/wp/v2/abilities', $routes );
+ $this->assertArrayHasKey( '/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+)', $routes );
+ $this->assertArrayHasKey( '/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run', $routes );
+ }
+
+ /**
+ * Test that the correct controller classes are instantiated.
+ */
+ public function test_correct_controllers_instantiated(): void {
+ // Trigger rest_api_init
+ do_action( 'rest_api_init' );
+
+ $routes = $this->server->get_routes();
+
+ // Check list controller
+ $this->assertArrayHasKey( '/wp/v2/abilities', $routes );
+ $list_route = $routes['/wp/v2/abilities'][0];
+ $this->assertIsArray( $list_route['callback'] );
+ $this->assertInstanceOf( 'WP_REST_Abilities_List_Controller', $list_route['callback'][0] );
+
+ // Check run controller
+ $this->assertArrayHasKey( '/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run', $routes );
+ $run_route = $routes['/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run'][0];
+ $this->assertIsArray( $run_route['callback'] );
+ $this->assertInstanceOf( 'WP_REST_Abilities_Run_Controller', $run_route['callback'][0] );
+ }
+
+ /**
+ * Test that the init class loads required files.
+ */
+ public function test_required_files_loaded(): void {
+ // Classes should be available after requiring the main plugin file
+ $this->assertTrue( class_exists( 'WP_REST_Abilities_Init' ) );
+ $this->assertTrue( class_exists( 'WP_REST_Abilities_List_Controller' ) );
+ $this->assertTrue( class_exists( 'WP_REST_Abilities_Run_Controller' ) );
+ }
+
+ /**
+ * Test that routes support expected HTTP methods.
+ */
+ public function test_routes_support_expected_methods(): void {
+ do_action( 'rest_api_init' );
+
+ $routes = $this->server->get_routes();
+
+ // List endpoint should support GET
+ $list_methods = $routes['/wp/v2/abilities'][0]['methods'];
+ // Methods can be a string like 'GET' or an array of method constants
+ if ( is_string( $list_methods ) ) {
+ $this->assertEquals( WP_REST_Server::READABLE, $list_methods );
+ } else {
+ // Just check it's set, don't check specific values
+ $this->assertNotEmpty( $list_methods );
+ }
+
+ // Single ability endpoint should support GET
+ $single_methods = $routes['/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+)'][0]['methods'];
+ // Methods can be a string like 'GET' or an array of method constants
+ if ( is_string( $single_methods ) ) {
+ $this->assertEquals( WP_REST_Server::READABLE, $single_methods );
+ } else {
+ // Just check it's set, don't check specific values
+ $this->assertNotEmpty( $single_methods );
+ }
+
+ // Run endpoint should support all methods (for type-based routing)
+ $run_route = $routes['/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run'][0];
+ // ALLMETHODS can be a string or array
+ if ( is_string( $run_route['methods'] ) ) {
+ $this->assertEquals( WP_REST_Server::ALLMETHODS, $run_route['methods'] );
+ } else {
+ // Just verify it has methods
+ $this->assertNotEmpty( $run_route['methods'] );
+ }
+ }
+
+ /**
+ * Test namespace and base configuration.
+ */
+ public function test_namespace_and_base_configuration(): void {
+ do_action( 'rest_api_init' );
+
+ $namespaces = $this->server->get_namespaces();
+ $this->assertContains( 'wp/v2', $namespaces );
+
+ // Verify abilities endpoints are under wp/v2 namespace
+ $routes = $this->server->get_routes();
+ foreach ( array_keys( $routes ) as $route ) {
+ if ( strpos( $route, 'abilities' ) !== false && $route !== '/' ) {
+ $this->assertStringStartsWith( '/wp/v2/abilities', $route );
+ }
+ }
+ }
+
+ /**
+ * Test that multiple calls to register_routes don't duplicate routes.
+ */
+ public function test_no_duplicate_routes_on_multiple_init(): void {
+ // First init
+ do_action( 'rest_api_init' );
+
+ $routes_first = $this->server->get_routes();
+ $abilities_route_count_first = count( $routes_first['/wp/v2/abilities'] ?? array() );
+
+ // Second init (simulating multiple calls)
+ // Note: WordPress doesn't prevent duplicate registration, so we expect 2x routes
+ WP_REST_Abilities_Init::register_routes();
+
+ $routes_second = $this->server->get_routes();
+ $abilities_route_count_second = count( $routes_second['/wp/v2/abilities'] ?? array() );
+
+ // WordPress allows duplicate route registration
+ $this->assertEquals( $abilities_route_count_first * 2, $abilities_route_count_second );
+ }
+}
\ No newline at end of file
diff --git a/tests/unit/REST/WPRESTAbilitiesListControllerTest.php b/tests/unit/REST/WPRESTAbilitiesListControllerTest.php
new file mode 100644
index 00000000..bb3c27a5
--- /dev/null
+++ b/tests/unit/REST/WPRESTAbilitiesListControllerTest.php
@@ -0,0 +1,509 @@
+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;
+ $this->server = $wp_rest_server = new 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 $input['b'] !== 0 ? $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/unit/REST/WPRESTAbilitiesRunControllerTest.php b/tests/unit/REST/WPRESTAbilitiesRunControllerTest.php
new file mode 100644
index 00000000..3e154365
--- /dev/null
+++ b/tests/unit/REST/WPRESTAbilitiesRunControllerTest.php
@@ -0,0 +1,946 @@
+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;
+ $this->server = $wp_rest_server = new 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() );
+ }
+
+}