diff --git a/docs/5.rest-api.md b/docs/5.rest-api.md index c2cebb4..bbadba3 100644 --- a/docs/5.rest-api.md +++ b/docs/5.rest-api.md @@ -107,6 +107,99 @@ curl https://example.com/wp-json/wp/v2/abilities ] ``` +## List Categories + +### Definition + +`GET /wp/v2/abilities/categories` + +### Arguments + +- `page` _(integer)_: Current page of the collection. Default: `1`. +- `per_page` _(integer)_: Maximum number of items to return per page. Default: `50`, Maximum: `100`. + +### Example Request + +```bash +curl -u 'USERNAME:APPLICATION_PASSWORD' \ + https://example.com/wp-json/wp/v2/abilities/categories +``` + +### Example Response + +```json +[ + { + "slug": "data-retrieval", + "label": "Data Retrieval", + "description": "Abilities that retrieve and return data from the WordPress site.", + "meta": {}, + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wp/v2/abilities/categories/data-retrieval" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wp/v2/abilities/categories" + } + ], + "abilities": [ + { + "href": "https://example.com/wp-json/wp/v2/abilities?category=data-retrieval" + } + ] + } + } +] +``` + +## Retrieve a Category + +### Definition + +`GET /wp/v2/abilities/categories/{slug}` + +### Arguments + +- `slug` _(string)_: The unique slug of the category. + +### Example Request + +```bash +curl -u 'USERNAME:APPLICATION_PASSWORD' \ + https://example.com/wp-json/wp/v2/abilities/categories/data-retrieval +``` + +### Example Response + +```json +{ + "slug": "data-retrieval", + "label": "Data Retrieval", + "description": "Abilities that retrieve and return data from the WordPress site.", + "meta": {}, + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wp/v2/abilities/categories/data-retrieval" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wp/v2/abilities/categories" + } + ], + "abilities": [ + { + "href": "https://example.com/wp-json/wp/v2/abilities?category=data-retrieval" + } + ] + } +} +``` + ## Retrieve an Ability ### Definition @@ -247,5 +340,6 @@ The API returns standard WordPress REST API error responses with these common co - `ability_invalid_output` - output validation failed according to the ability's schema. - `ability_invalid_execute_callback` - the ability's execute callback is not callable. - `rest_ability_not_found` - the requested ability is not registered. +- `rest_category_not_found` - the requested category is not registered. - `rest_ability_invalid_method` - the requested HTTP method is not allowed for executing the selected ability (e.g., using GET on a read-only ability, or POST on a regular ability). - `rest_ability_cannot_execute` - the ability cannot be executed due to insufficient permissions. diff --git a/includes/rest-api/class-wp-rest-abilities-init.php b/includes/rest-api/class-wp-rest-abilities-init.php index cce4935..0b3798b 100644 --- a/includes/rest-api/class-wp-rest-abilities-init.php +++ b/includes/rest-api/class-wp-rest-abilities-init.php @@ -24,6 +24,10 @@ class WP_REST_Abilities_Init { public static function register_routes(): void { require_once __DIR__ . '/endpoints/class-wp-rest-abilities-run-controller.php'; require_once __DIR__ . '/endpoints/class-wp-rest-abilities-list-controller.php'; + require_once __DIR__ . '/endpoints/class-wp-rest-abilities-categories-controller.php'; + + $categories_controller = new WP_REST_Abilities_Categories_Controller(); + $categories_controller->register_routes(); $run_controller = new WP_REST_Abilities_Run_Controller(); $run_controller->register_routes(); diff --git a/includes/rest-api/endpoints/class-wp-rest-abilities-categories-controller.php b/includes/rest-api/endpoints/class-wp-rest-abilities-categories-controller.php new file mode 100644 index 0000000..c793311 --- /dev/null +++ b/includes/rest-api/endpoints/class-wp-rest-abilities-categories-controller.php @@ -0,0 +1,292 @@ +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-z0-9]+(?:-[a-z0-9]+)*)', + array( + 'args' => array( + 'slug' => array( + 'description' => __( 'Unique identifier for the category.' ), + 'type' => 'string', + 'pattern' => '^[a-z0-9]+(?:-[a-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 ability categories. + * + * @since n.e.x.t + * + * @param \WP_REST_Request> $request Full details about the request. + * @return \WP_REST_Response Response object on success. + */ + public function get_items( $request ) { + $categories = wp_get_ability_categories(); + + $params = $request->get_params(); + $page = $params['page'] ?? 1; + $per_page = $params['per_page'] ?? self::DEFAULT_PER_PAGE; + $offset = ( $page - 1 ) * $per_page; + + $total_categories = count( $categories ); + $max_pages = ceil( $total_categories / $per_page ); + + if ( $request->get_method() === 'HEAD' ) { + $response = new \WP_REST_Response( array() ); + } else { + $categories = array_slice( $categories, $offset, $per_page ); + + $data = array(); + foreach ( $categories as $category ) { + $item = $this->prepare_item_for_response( $category, $request ); + $data[] = $this->prepare_response_for_collection( $item ); + } + + $response = rest_ensure_response( $data ); + } + + $response->header( 'X-WP-Total', (string) $total_categories ); + $response->header( 'X-WP-TotalPages', (string) $max_pages ); + + $query_params = $request->get_query_params(); + $base = add_query_arg( urlencode_deep( $query_params ), rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) ); + + if ( $page > 1 ) { + $prev_page = $page - 1; + $prev_link = add_query_arg( 'page', $prev_page, $base ); + $response->link_header( 'prev', $prev_link ); + } + + if ( $page < $max_pages ) { + $next_page = $page + 1; + $next_link = add_query_arg( 'page', $next_page, $base ); + $response->link_header( 'next', $next_link ); + } + + return $response; + } + + /** + * Retrieves a specific ability category. + * + * @since n.e.x.t + * + * @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 ) { + $category = wp_get_ability_category( $request->get_param( 'slug' ) ); + if ( ! $category ) { + return new \WP_Error( + 'rest_category_not_found', + __( 'Ability category not found.' ), + array( 'status' => 404 ) + ); + } + + $data = $this->prepare_item_for_response( $category, $request ); + return rest_ensure_response( $data ); + } + + /** + * Checks if a given request has access to read ability categories. + * + * @since n.e.x.t + * + * @param \WP_REST_Request> $request Full details about the request. + * @return bool True if the request has read access. + */ + public function get_permissions_check( $request ) { + return current_user_can( 'read' ); + } + + /** + * Prepares a category for response. + * + * @since n.e.x.t + * + * @param \WP_Ability_Category $category The category object. + * @param \WP_REST_Request> $request Request object. + * @return \WP_REST_Response Response object. + */ + public function prepare_item_for_response( $category, $request ) { + $data = array( + 'slug' => $category->get_slug(), + 'label' => $category->get_label(), + 'description' => $category->get_description(), + 'meta' => $category->get_meta(), + ); + + $context = $request->get_param( 'context' ) ?? 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + $response = rest_ensure_response( $data ); + + $fields = $this->get_fields_for_response( $request ); + if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { + $links = array( + 'self' => array( + 'href' => rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $category->get_slug() ) ), + ), + 'collection' => array( + 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ), + ), + 'abilities' => array( + 'href' => rest_url( sprintf( '%s/abilities?category=%s', $this->namespace, $category->get_slug() ) ), + ), + ); + + $response->add_links( $links ); + } + + return $response; + } + + /** + * Retrieves the category's schema, conforming to JSON Schema. + * + * @since n.e.x.t + * + * @return array Item schema data. + */ + public function get_item_schema(): array { + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'ability-category', + 'type' => 'object', + 'properties' => array( + 'slug' => array( + 'description' => __( 'Unique identifier for the category.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'label' => array( + 'description' => __( 'Display label for the category.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'description' => array( + 'description' => __( 'Description of the category.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'meta' => array( + 'description' => __( 'Meta information about the category.' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + ), + 'required' => array( 'slug', 'label', 'description', 'meta' ), + ); + + return $this->add_additional_fields_schema( $schema ); + } + + /** + * Retrieves the query params for collections. + * + * @since n.e.x.t + * + * @return array Collection parameters. + */ + public function get_collection_params(): array { + return array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + 'page' => array( + 'description' => __( 'Current page of the collection.' ), + 'type' => 'integer', + 'default' => 1, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'rest_validate_request_arg', + 'minimum' => 1, + ), + 'per_page' => array( + 'description' => __( 'Maximum number of items to be returned in result set.' ), + 'type' => 'integer', + 'default' => self::DEFAULT_PER_PAGE, + 'minimum' => 1, + 'maximum' => 100, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'rest_validate_request_arg', + ), + ); + } +} diff --git a/tests/unit/rest-api/wpRestAbilityCategoriesController.php b/tests/unit/rest-api/wpRestAbilityCategoriesController.php new file mode 100644 index 0000000..7afa92f --- /dev/null +++ b/tests/unit/rest-api/wpRestAbilityCategoriesController.php @@ -0,0 +1,495 @@ +user->create( + array( + 'role' => 'administrator', + ) + ); + + self::$subscriber_user_id = self::factory()->user->create( + array( + 'role' => 'subscriber', + ) + ); + } + + /** + * Set up before each test. + */ + public function set_up(): void { + parent::set_up(); + + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + $this->server = $wp_rest_server; + + do_action( 'rest_api_init' ); + + add_action( + 'abilities_api_categories_init', + array( $this, 'register_test_categories' ) + ); + do_action( 'abilities_api_categories_init' ); + + wp_set_current_user( self::$admin_user_id ); + } + + /** + * Tear down after each test. + */ + public function tear_down(): void { + foreach ( array( 'data-retrieval', 'data-modification', 'communication', 'test-category-1', 'test-category-2', 'test-category-3' ) as $slug ) { + if ( ! WP_Abilities_Category_Registry::get_instance()->is_registered( $slug ) ) { + continue; + } + + wp_unregister_ability_category( $slug ); + } + + for ( $i = 4; $i <= 60; $i++ ) { + $slug = "test-category-{$i}"; + if ( WP_Abilities_Category_Registry::get_instance()->is_registered( $slug ) ) { + wp_unregister_ability_category( $slug ); + } + } + + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Register test categories for testing. + */ + public function register_test_categories(): void { + wp_register_ability_category( + 'data-retrieval', + array( + 'label' => 'Data Retrieval', + 'description' => 'Abilities that retrieve and return data from the WordPress site.', + ) + ); + + wp_register_ability_category( + 'data-modification', + array( + 'label' => 'Data Modification', + 'description' => 'Abilities that modify data on the WordPress site.', + ) + ); + + wp_register_ability_category( + 'communication', + array( + 'label' => 'Communication', + 'description' => 'Abilities that send messages or notifications.', + 'meta' => array( + 'priority' => 'high', + ), + ) + ); + + // Register multiple categories for pagination testing + for ( $i = 1; $i <= 60; $i++ ) { + wp_register_ability_category( + "test-category-{$i}", + array( + 'label' => "Test Category {$i}", + 'description' => "Test category number {$i}", + ) + ); + } + } + + /** + * Test listing all categories. + */ + public function test_get_items(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories' ); + $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)' ); + + $category_slugs = wp_list_pluck( $data, 'slug' ); + $this->assertContains( 'data-retrieval', $category_slugs ); + $this->assertContains( 'data-modification', $category_slugs ); + $this->assertContains( 'communication', $category_slugs ); + } + + /** + * Test getting a specific category. + */ + public function test_get_item(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories/data-retrieval' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( 'data-retrieval', $data['slug'] ); + $this->assertEquals( 'Data Retrieval', $data['label'] ); + $this->assertEquals( 'Abilities that retrieve and return data from the WordPress site.', $data['description'] ); + $this->assertArrayHasKey( 'meta', $data ); + } + + /** + * Test getting a category with meta. + */ + public function test_get_item_with_meta(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories/communication' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( 'communication', $data['slug'] ); + $this->assertArrayHasKey( 'meta', $data ); + $this->assertIsArray( $data['meta'] ); + $this->assertEquals( 'high', $data['meta']['priority'] ); + } + + /** + * Test getting a non-existent category returns 404. + * + * @expectedIncorrectUsage WP_Abilities_Category_Registry::get_registered + */ + public function test_get_item_not_found(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories/non-existent' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 404, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( 'rest_category_not_found', $data['code'] ); + } + + /** + * Test permission check for listing categories. + */ + public function test_get_items_permission_denied(): void { + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 401, $response->get_status() ); + } + + /** + * Test permission check for single category. + */ + public function test_get_item_permission_denied(): void { + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories/data-retrieval' ); + $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/categories' ); + $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_categories = count( wp_get_ability_categories() ); + $this->assertEquals( $total_categories, (int) $headers['X-WP-Total'] ); + $this->assertEquals( ceil( $total_categories / 10 ), (int) $headers['X-WP-TotalPages'] ); + } + + /** + * Test HEAD method returns empty body with proper headers. + */ + public function test_head_request(): void { + $request = new WP_REST_Request( 'HEAD', '/wp/v2/abilities/categories' ); + $response = $this->server->dispatch( $request ); + + $data = $response->get_data(); + $this->assertEmpty( $data ); + + $headers = $response->get_headers(); + $this->assertArrayHasKey( 'X-WP-Total', $headers ); + $this->assertArrayHasKey( 'X-WP-TotalPages', $headers ); + } + + /** + * Test pagination links. + */ + public function test_pagination_links(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories' ); + $request->set_param( 'per_page', 10 ); + $request->set_param( 'page', 1 ); + $response = $this->server->dispatch( $request ); + + $headers = $response->get_headers(); + $link_header = $headers['Link'] ?? ''; + + $this->assertStringContainsString( 'rel="next"', $link_header ); + $this->assertStringNotContainsString( 'rel="prev"', $link_header ); + + $request->set_param( 'page', 3 ); + $response = $this->server->dispatch( $request ); + + $headers = $response->get_headers(); + $link_header = $headers['Link'] ?? ''; + + $this->assertStringContainsString( 'rel="next"', $link_header ); + $this->assertStringContainsString( 'rel="prev"', $link_header ); + + $total_categories = count( wp_get_ability_categories() ); + $last_page = ceil( $total_categories / 10 ); + $request->set_param( 'page', $last_page ); + $response = $this->server->dispatch( $request ); + + $headers = $response->get_headers(); + $link_header = $headers['Link'] ?? ''; + + $this->assertStringNotContainsString( 'rel="next"', $link_header ); + $this->assertStringContainsString( 'rel="prev"', $link_header ); + } + + /** + * Test collection parameters. + */ + public function test_collection_params(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories' ); + $request->set_param( 'per_page', 5 ); + $response = $this->server->dispatch( $request ); + + $data = $response->get_data(); + $this->assertCount( 5, $data ); + $request->set_param( 'page', 2 ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertCount( 5, $data ); + + $page1_request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories' ); + $page1_request->set_param( 'per_page', 5 ); + $page1_request->set_param( 'page', 1 ); + $page1_response = $this->server->dispatch( $page1_request ); + $page1_slugs = wp_list_pluck( $page1_response->get_data(), 'slug' ); + $page2_slugs = wp_list_pluck( $data, 'slug' ); + + $this->assertNotEquals( $page1_slugs, $page2_slugs ); + } + + /** + * Test response links for individual categories. + */ + public function test_category_response_links(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories/data-retrieval' ); + $response = $this->server->dispatch( $request ); + + $links = $response->get_links(); + $this->assertArrayHasKey( 'self', $links ); + $this->assertArrayHasKey( 'collection', $links ); + $this->assertArrayHasKey( 'abilities', $links ); + + $self_link = $links['self'][0]['href']; + $this->assertStringContainsString( '/wp/v2/abilities/categories/data-retrieval', $self_link ); + + $collection_link = $links['collection'][0]['href']; + $this->assertStringContainsString( '/wp/v2/abilities/categories', $collection_link ); + + $abilities_link = $links['abilities'][0]['href']; + $this->assertStringContainsString( '/wp/v2/abilities?category=data-retrieval', $abilities_link ); + } + + /** + * Test context parameter. + */ + public function test_context_parameter(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories/data-retrieval' ); + $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( 'slug', $data ); + $this->assertArrayHasKey( 'label', $data ); + } + + /** + * Test schema retrieval. + */ + public function test_get_schema(): void { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/abilities/categories' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'schema', $data ); + $schema = $data['schema']; + + $this->assertEquals( 'ability-category', $schema['title'] ); + $this->assertEquals( 'object', $schema['type'] ); + $this->assertArrayHasKey( 'properties', $schema ); + + $properties = $schema['properties']; + + $this->assertCount( 4, $properties, 'Schema should have exactly 4 properties.' ); + + $this->assertArrayHasKey( 'slug', $properties ); + $this->assertArrayHasKey( 'label', $properties ); + $this->assertArrayHasKey( 'description', $properties ); + $this->assertArrayHasKey( 'meta', $properties ); + + $slug_property = $properties['slug']; + $this->assertEquals( 'string', $slug_property['type'] ); + $this->assertTrue( $slug_property['readonly'] ); + + $this->assertArrayHasKey( 'required', $schema ); + $this->assertContains( 'slug', $schema['required'] ); + $this->assertContains( 'label', $schema['required'] ); + $this->assertContains( 'description', $schema['required'] ); + $this->assertContains( 'meta', $schema['required'] ); + } + + /** + * Test category slug with valid format. + */ + public function test_category_slug_with_valid_format(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories/data-retrieval' ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * Data provider for invalid category slugs. + * + * @return array + */ + public function invalid_category_slugs_provider(): array { + return array( + 'Uppercase' => array( 'Data-Retrieval' ), + '@ symbol' => array( 'data@retrieval' ), + 'space' => array( 'data retrieval' ), + 'dot' => array( 'data.retrieval' ), + 'underscore' => array( 'data_retrieval' ), + 'URL encoded space' => array( 'data%20retrieval' ), + ); + } + + /** + * Test category slugs with invalid format. + * + * @dataProvider invalid_category_slugs_provider + * @param string $slug Invalid category slug to test. + */ + public function test_category_slug_with_invalid_format( string $slug ): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories/' . $slug ); + $response = $this->server->dispatch( $request ); + + $this->assertContains( $response->get_status(), array( 400, 404 ) ); + } + + /** + * Test category slug with forward slash (matched by abilities route). + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_registered + */ + public function test_category_slug_with_forward_slash(): void { + // Forward slashes cause the URL to be matched by the abilities route instead + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/categories/data/retrieval' ); + $response = $this->server->dispatch( $request ); + + $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/categories' ); + $request->set_query_params( $params ); + + $response = $this->server->dispatch( $request ); + + $this->assertContains( $response->get_status(), array( 200, 400 ) ); + + if ( $response->get_status() !== 200 ) { + return; + } + + $data = $response->get_data(); + $this->assertIsArray( $data ); + } +}